Skip to content

Commit 4b0103f

Browse files
authored
Merge pull request #204 from jerodw/story/story-127
story-127: A story carries its brief's key, and three moments move the item's status
2 parents b91ef81 + 0fdcdb7 commit 4b0103f

18 files changed

Lines changed: 2458 additions & 165 deletions

.harness/docs/ARCHITECTURE.md

Lines changed: 38 additions & 5 deletions
Large diffs are not rendered by default.

.harness/history/execution-log.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,3 +56,4 @@
5656
{"story_id": "story-126", "status": "completed", "retry_count": 0, "timestamp": "2026-09-09 01:42:55"}
5757
{"story_id": "story-128", "status": "completed", "retry_count": 0, "timestamp": "2026-09-09 08:32:13"}
5858
{"story_id": "story-129", "status": "completed", "retry_count": 0, "timestamp": "2026-09-09 09:48:56"}
59+
{"story_id": "story-127", "status": "completed", "retry_count": 0, "timestamp": "2026-09-09 13:54:29"}

.harness/history/inspection-log.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@
2020
{"story_id": "story-126", "mode": "narrow", "cost_usd": 1.9372045000000002, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-09 01:47:09", "findings": 3, "filed": 3, "dropped": 0}
2121
{"story_id": "story-128", "mode": "narrow", "cost_usd": 1.633331, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-09 08:37:45", "findings": 2, "filed": 2, "dropped": 0}
2222
{"story_id": "story-129", "mode": "narrow", "cost_usd": 1.3884794999999999, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-09 09:52:45", "findings": 1, "filed": 1, "dropped": 0}
23+
{"story_id": "story-127", "mode": "narrow", "cost_usd": 2.252045, "scope_files": 60, "invocations": 1, "timestamp": "2026-09-09 13:59:42", "findings": 2, "filed": 2, "dropped": 0}

.harness/history/mandate-log.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
{"story_id": "story-119", "timestamp": "2026-09-08 15:53:25", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
3333
{"story_id": "story-123", "timestamp": "2026-09-08 20:30:12", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
3434
{"story_id": "story-126", "timestamp": "2026-09-09 00:38:53", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
35+
{"story_id": "story-127", "timestamp": "2026-09-09 07:58:21", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
3536
{"story_id": "story-128", "timestamp": "2026-09-09 08:02:40", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}
3637
{"story_id": "story-129", "timestamp": "2026-09-09 09:14:32", "conferred_by": "jerod.wilkerson <30474318+jerodw@users.noreply.github.com>", "source_kind": "human", "recorded_by": "l5-plan", "discarded_session_block": false}

.harness/history/retry-log.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@
4747
{"story_id": "story-126", "timestamp": "2026-09-09 01:13:04", "stage": "tester", "retry_stage": "tester"}
4848
{"story_id": "story-126", "timestamp": "2026-09-09 01:20:52", "stage": "tester", "retry_stage": "tester"}
4949
{"story_id": "story-129", "timestamp": "2026-09-09 09:28:14", "stage": "tester", "retry_stage": "tester"}
50+
{"story_id": "story-127", "timestamp": "2026-09-09 13:34:31", "stage": "tester", "retry_stage": "tester"}

.harness/item/github.sh

Lines changed: 181 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
#!/usr/bin/env bash
22
#
33
# A reference item-update command: publishes a planned story onto the GitHub
4-
# issue the brief was filed as.
4+
# issue the brief was filed as, and moves that issue's board Status as the
5+
# story is planned, run and finished.
56
#
67
# This is a template. It ships with the harness, l5-init installs a copy into a
78
# target's .harness/item/, and the target is expected to edit it — where a
8-
# projection is written, and what it looks like once it is there, are this
9-
# file's business rather than the harness's. What is not negotiable is the
10-
# contract below, which the harness relies on and cannot enforce.
9+
# projection is written, what it looks like once it is there, and which board
10+
# and column a status names, are this file's business rather than the
11+
# harness's. What is not negotiable is the contract below, which the harness
12+
# relies on and cannot enforce.
1113
#
1214
# THE CONTRACT
1315
#
14-
# stdin One JSON document, carrying "key" (the item's, opaque),
15-
# "story_id", and "document" — the projection to publish. It carries
16-
# "status" only where the caller supplied one, and nothing in the
17-
# harness supplies one yet; see THE STATUS HALF below.
16+
# stdin One JSON document, carrying "key" (the item's, opaque) and
17+
# "story_id". It carries "document" — the projection to publish —
18+
# only where the caller had one, and "status" only where the caller
19+
# had one, and at least one of the two is always there; see THE
20+
# THREE MOMENTS below.
1821
# L5_ITEM_KEY
1922
# The item's key, the same value the document on stdin carries
2023
# under "key", taken from the same field so the two cannot disagree.
@@ -56,20 +59,61 @@
5659
# dedupe and brief fetch all stop working if they go missing. This script
5760
# therefore only ever replaces the block between its own markers and appends
5861
# after everything else, and it must keep doing both. It is the third member of
59-
# that family and the only one that rewrites a body somebody else wrote.
62+
# that family and the only one that rewrites a body somebody else wrote. A
63+
# question carrying a status and no document rewrites no body at all, so every
64+
# one of those markers is left exactly as it was found.
6065
#
61-
# THE STATUS HALF IS DECLARED AND UNSENT. The question document may carry a
62-
# "status", because the sibling work that moves an item's status as its story
63-
# runs is about the same item, and two scripts that must agree about one item
64-
# are two scripts that can disagree. Nothing in the harness sends one today, so
65-
# nothing here acts on one; that work fills this half in rather than adding a
66-
# second script beside this one. The harness parses no tracker status and
67-
# infers none either.
66+
# THE THREE MOMENTS. The harness sends a status at three points in a story's
67+
# life: planned, when the planning session's artifact has been committed and
68+
# pushed; in_progress, when the run for that story begins; and ready_to_merge,
69+
# when that run completes. The first arrives beside a document, because the
70+
# invocation that publishes the projection carries it; the other two arrive
71+
# alone, because there is nothing new to publish then.
72+
#
73+
# THE HARNESS DOES NOT KNOW WHAT A STATUS IS. It knows a key and which of those
74+
# three moments the work has reached; what a token means in a tracker is
75+
# decided here and nowhere else. This copy reads it as a column on a project
76+
# board, and a target whose tracker says it differently — a label, a row in a
77+
# table — changes this file and changes nothing in the harness. No status is
78+
# ever read back: stdout is unread, and the harness parses none and infers
79+
# none.
80+
#
81+
# THE BOARD MECHANICS ARE GENERIC; THE VALUES ARE THE TARGET'S. This file
82+
# carries how a Status is written and no statement about which board: the
83+
# project, its owner, the field's name and the option each of the three tokens
84+
# names are the constants below, so a board that spells its columns differently
85+
# is a configuration rather than an edit to this logic. An empty project means
86+
# no board, and then a status is something this copy cannot honour and says so.
87+
#
88+
# A STATUS MOVES THE ITEM WHEREVER IT ALREADY IS, which is where this differs
89+
# from the sync script beside it. That script sets a Status only where it finds
90+
# one empty, because it puts a newly filed item into a column once and never
91+
# moves it between columns; the whole point of these three moments is the
92+
# movement, so this one writes over what it finds.
6893
#
6994
# It requires gh, authenticated, and jq. Both are the target's business.
7095

7196
set -uo pipefail
7297

98+
# --- what this target moves items on. Edit these. ------------------------
99+
PROJECT="${L5_ITEM_PROJECT:-}" # a project number or URL; empty means no board
100+
PROJECT_OWNER="${L5_ITEM_PROJECT_OWNER:-@me}" # who owns that project
101+
STATUS_FIELD="${L5_ITEM_STATUS_FIELD:-Status}"
102+
103+
# The option each of the three tokens names, by the name the board spells it
104+
# with. They are read from the environment for the reason the field name is: a
105+
# board saying "In Progress", "Doing" or "En cours" is a configuration rather
106+
# than an edit to the logic above it.
107+
PLANNED_OPTION="${L5_ITEM_PLANNED_OPTION:-Planned}"
108+
IN_PROGRESS_OPTION="${L5_ITEM_IN_PROGRESS_OPTION:-In progress}"
109+
READY_TO_MERGE_OPTION="${L5_ITEM_READY_TO_MERGE_OPTION:-Ready to merge}"
110+
111+
# How much of the project's item listing is read when looking for this issue's
112+
# item. Not an L5_ITEM_ constant, for the reason the sync script's own bound is
113+
# not: it is a mechanic rather than something this target files against, and a
114+
# bound that was too small costs a reported failure rather than a wrong write.
115+
ITEM_LIST_LIMIT=5000
116+
73117
fail() { echo "$*" >&2; exit 1; }
74118

75119
command -v gh >/dev/null 2>&1 || fail "gh is not on PATH"
@@ -93,40 +137,126 @@ story_id="$(printf '%s' "$question" | jq -r '.story_id // ""')" \
93137

94138
document="$(printf '%s' "$question" | jq -r '.document // ""')" \
95139
|| fail "the question carries no document this command can use"
96-
[ -n "$document" ] || fail "the question carried an empty document; nothing was published"
97-
98-
# The markers this story's projection lives between. They name the story, so an
99-
# item carrying several stories' projections keeps each of them separate, and a
100-
# second invocation for one story replaces that story's block alone.
101-
begin="<!-- l5-story-begin: ${story_id} -->"
102-
end="<!-- l5-story-end: ${story_id} -->"
103-
104-
body="$(gh issue view "$key" --json body --jq '.body // ""' 2>/dev/null)" \
105-
|| fail "the item ${key} could not be read, so nothing was published onto it"
106-
107-
# Everything the item already says, with this story's own block removed if one
108-
# is there. Only the region between this story's markers is dropped: the key
109-
# marker, the path markers and the payload marker the sync script wrote are
110-
# elsewhere in the body and are carried through untouched, which is what keeps
111-
# filing, dedupe and brief fetch working over an item that has been published
112-
# onto.
113-
kept="$(printf '%s' "$body" | awk -v begin="$begin" -v end="$end" '
114-
index($0, begin) { skipping = 1; next }
115-
index($0, end) { skipping = 0; next }
116-
!skipping { print }
117-
')" || fail "the item ${key} body could not be read, so nothing was published onto it"
118-
119-
updated="$(mktemp)" || fail "no temporary file could be made to write the body in"
120-
trap 'rm -f "$updated"' EXIT
121-
122-
{
123-
printf '%s\n' "$kept"
124-
printf '\n%s\n\n' "$begin"
125-
printf '%s\n' "$document"
126-
printf '\n%s\n' "$end"
127-
} >"$updated" || fail "the new body could not be written"
128-
129-
gh issue edit "$key" --body-file "$updated" >/dev/null 2>&1 \
130-
|| fail "the item ${key} could not be updated, so ${story_id} was not published onto it"
140+
141+
status="$(printf '%s' "$question" | jq -r '.status // ""')" \
142+
|| fail "the question carries no status this command can use"
143+
144+
# One of the two is always there on the harness's side, so a question carrying
145+
# neither is a question with nothing to say and is refused rather than treated
146+
# as a no-op that succeeded.
147+
if [ -z "$document" ] && [ -z "$status" ]; then
148+
fail "the question carried neither a document nor a status; there was nothing to do to ${key}"
149+
fi
150+
151+
# --- the status: which column each token names --------------------------
152+
# The whole of what this script knows about a token is which option it names.
153+
# An unrecognised one is refused rather than guessed at: writing a column
154+
# nobody asked for would be worse than saying the word was not understood.
155+
option=""
156+
if [ -n "$status" ]; then
157+
case "$status" in
158+
planned) option="$PLANNED_OPTION" ;;
159+
in_progress) option="$IN_PROGRESS_OPTION" ;;
160+
ready_to_merge) option="$READY_TO_MERGE_OPTION" ;;
161+
*) fail "the status ${status} is not one this command knows an option for, so ${key} was not moved" ;;
162+
esac
163+
[ -n "$option" ] \
164+
|| fail "this copy names no board option for ${status}, so ${key} was not moved"
165+
[ -n "$PROJECT" ] \
166+
|| fail "no project is configured here, so there is no board to move ${key} to ${option} on"
167+
fi
168+
169+
# --- the document: publish the projection -------------------------------
170+
# Only where one was given. A status arriving alone leaves the item's body
171+
# exactly as it is, which is what the two run-time moments want: the projection
172+
# the planning session published is already there and has not changed.
173+
if [ -n "$document" ]; then
174+
# The markers this story's projection lives between. They name the story, so
175+
# an item carrying several stories' projections keeps each of them separate,
176+
# and a second invocation for one story replaces that story's block alone.
177+
begin="<!-- l5-story-begin: ${story_id} -->"
178+
end="<!-- l5-story-end: ${story_id} -->"
179+
180+
body="$(gh issue view "$key" --json body --jq '.body // ""' 2>/dev/null)" \
181+
|| fail "the item ${key} could not be read, so nothing was published onto it"
182+
183+
# Everything the item already says, with this story's own block removed if
184+
# one is there. Only the region between this story's markers is dropped: the
185+
# key marker, the path markers and the payload marker the sync script wrote
186+
# are elsewhere in the body and are carried through untouched, which is what
187+
# keeps filing, dedupe and brief fetch working over an item that has been
188+
# published onto.
189+
kept="$(printf '%s' "$body" | awk -v begin="$begin" -v end="$end" '
190+
index($0, begin) { skipping = 1; next }
191+
index($0, end) { skipping = 0; next }
192+
!skipping { print }
193+
')" || fail "the item ${key} body could not be read, so nothing was published onto it"
194+
195+
updated="$(mktemp)" || fail "no temporary file could be made to write the body in"
196+
trap 'rm -f "$updated"' EXIT
197+
198+
{
199+
printf '%s\n' "$kept"
200+
printf '\n%s\n\n' "$begin"
201+
printf '%s\n' "$document"
202+
printf '\n%s\n' "$end"
203+
} >"$updated" || fail "the new body could not be written"
204+
205+
gh issue edit "$key" --body-file "$updated" >/dev/null 2>&1 \
206+
|| fail "the item ${key} could not be updated, so ${story_id} was not published onto it"
207+
fi
208+
209+
# --- the status: move the item's column ---------------------------------
210+
# Reached only where a status was given, and the project was established above,
211+
# so everything from here is the board work itself. Every way of not reaching
212+
# the board — the item, the project, the field or the option — is said on
213+
# stderr and exits non-zero: an item that was not moved must not be reported as
214+
# one that was.
215+
if [ -n "$status" ]; then
216+
url="$(gh issue view "$key" --json url --jq '.url // ""' 2>/dev/null)" \
217+
|| fail "the item ${key} could not be read, so it was not moved to ${option}"
218+
[ -n "$url" ] || fail "the item ${key} named no URL, so it was not moved to ${option}"
219+
220+
# item-add for an issue already on the board reports the existing item rather
221+
# than adding a second one, so an item this project already carries is found
222+
# here and one it does not is put on it. The item's id is what item-edit
223+
# takes; it deals in ids and not in names.
224+
added="$(gh project item-add "$PROJECT" --owner "$PROJECT_OWNER" --url "$url" \
225+
--format json 2>/dev/null)" \
226+
|| fail "${key} could not be added to project ${PROJECT}, so it was not moved to ${option}"
227+
item_id="$(printf '%s' "$added" | jq -r '.id // ""')" \
228+
|| fail "project ${PROJECT} named no item for ${key}, so it was not moved to ${option}"
229+
[ -n "$item_id" ] \
230+
|| fail "project ${PROJECT} named no item for ${key}, so it was not moved to ${option}"
231+
232+
project_id="$(gh project view "$PROJECT" --owner "$PROJECT_OWNER" --format json 2>/dev/null \
233+
| jq -r '.id // ""')" \
234+
|| fail "project ${PROJECT} could not be read, so ${key} was not moved to ${option}"
235+
[ -n "$project_id" ] \
236+
|| fail "project ${PROJECT} named no id, so ${key} was not moved to ${option}"
237+
238+
fields="$(gh project field-list "$PROJECT" --owner "$PROJECT_OWNER" --format json 2>/dev/null)" \
239+
|| fail "the fields of project ${PROJECT} could not be read, so ${key} was not moved to ${option}"
240+
241+
field_id="$(printf '%s' "$fields" | jq -r --arg name "$STATUS_FIELD" \
242+
'[.fields[]? | select(.name == $name) | .id] | .[0] // ""')" \
243+
|| fail "the fields of project ${PROJECT} could not be read, so ${key} was not moved to ${option}"
244+
[ -n "$field_id" ] \
245+
|| fail "project ${PROJECT} has no field named ${STATUS_FIELD}, so ${key} was not moved to ${option}"
246+
247+
option_id="$(printf '%s' "$fields" | jq -r --arg name "$STATUS_FIELD" --arg option "$option" \
248+
'[.fields[]? | select(.name == $name) | .options[]? | select(.name == $option) | .id] | .[0] // ""')" \
249+
|| fail "the options of ${STATUS_FIELD} could not be read, so ${key} was not moved to ${option}"
250+
[ -n "$option_id" ] \
251+
|| fail "${STATUS_FIELD} in project ${PROJECT} has no option named ${option}, so ${key} was not moved"
252+
253+
# Written over whatever the field already says. This is the movement the
254+
# three moments exist for, so unlike the sync script beside it there is no
255+
# only-where-empty rule: an item that is already in a column is exactly the
256+
# item a later moment has to move out of it.
257+
gh project item-edit --id "$item_id" --project-id "$project_id" \
258+
--field-id "$field_id" --single-select-option-id "$option_id" >/dev/null 2>&1 \
259+
|| fail "the item is on project ${PROJECT} but its ${STATUS_FIELD} could not be set to ${option}"
260+
fi
131261

132262
exit 0

0 commit comments

Comments
 (0)