@marcomigozzi/trailhead 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.it.md +10 -8
  2. package/README.md +10 -8
  3. package/bin/config-dir-templates-docs.test.js +52 -0
  4. package/bin/decision-builds-docs.test.js +75 -0
  5. package/bin/fog-vs-ticket-docs.test.js +56 -0
  6. package/bin/lib/codex-projection.js +8 -1
  7. package/bin/lib/codex-projection.test.js +23 -0
  8. package/bin/lib/host-descriptor.js +14 -1
  9. package/bin/lib/host-descriptor.test.js +18 -0
  10. package/bin/lib/runtime-command-literals.test.js +184 -0
  11. package/bin/report-status-docs.test.js +1 -1
  12. package/package.json +1 -1
  13. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  14. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  15. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  16. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  17. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  18. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  19. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  20. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  21. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  22. package/plugins/trailhead/hooks/mod/README.md +8 -3
  23. package/plugins/trailhead/hooks/mod/map-pane.tsx +52 -28
  24. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  25. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  26. package/plugins/trailhead/skills/_shared/configuration-reference.md +2 -2
  27. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  28. package/plugins/trailhead/skills/_shared/teamwork.md +3 -3
  29. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  30. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  31. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  32. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  33. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  34. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  35. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -3
  36. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +1 -0
  37. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  38. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  39. package/plugins/trailhead/tests/map-pane-ui.test.ts +179 -2
@@ -12,7 +12,7 @@ argument-hint: "[work|quick|pause|resume|split|auto] [ticket | \"text\" | note]"
12
12
  The panel starts `work` and `resume` from wherever the session stands and cannot move it, so it appends the pinned checkout to the args as a last word `--in=<token>` (base64url of the checkout root). When the args of `work` or `resume` end with such a word, this section runs first, before the load, the Preflight and anything else; with no such word it does not apply. `quick` never gets one.
13
13
 
14
14
  1. Drop the `--in=<token>` word from the args: the verb, and its ticket number if any, are what is left.
15
- 2. Run `node <templates>/trailhead-checkout-in.js --in <token> --cwd "$PWD"`. `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `~/.claude/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains the script (the same lookup as `trailhead-work-state.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty). It prints one JSON line with a `status`. If the script is missing (none of the three has it: an outdated install), stop and report that the checkout could not be reached and the install needs an update: nothing is claimed, and never move by hand.
15
+ 2. Run `node <templates>/trailhead-checkout-in.js --in <token> --cwd "$PWD"`. `<templates>` is the first of `${CLAUDE_PLUGIN_ROOT}/templates/`, `${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/templates/`, `~/.codex/skills/trailhead/templates/` that contains the script (the same lookup as `trailhead-work-state.js`: try all three in order, skipping the first when `CLAUDE_PLUGIN_ROOT` is unset or empty). It prints one JSON line with a `status`. If the script is missing (none of the three has it: an outdated install), stop and report that the checkout could not be reached and the install needs an update: nothing is claimed, and never move by hand.
16
16
  3. `invalid` -> stop and report its `reason` in one line; never move, never look for another checkout.
17
17
  4. `here` -> the session is already in the checkout: no move, carry on with the args from step 1.
18
18
  5. `move` -> run the helper's `cd` field verbatim as its own Bash call (a lone `cd` is what moves the session; never join it to a read or another command, and never edit the quoting). A failed `cd` stops the verb with a report. Then re-run the helper with the new `$PWD` and require `here`; anything else means the session could not move: stop and report that, with nothing claimed.
@@ -60,8 +60,8 @@ The user invokes with a map (URL or number). A ticket is optional: without one,
60
60
  3. **Brief the ticket and confirm scope, before the engine runs.** With the ticket claimed, read it and give the user a **brief, result-oriented summary of what resolving it will do** (its goal and the shape of the work, not a plan yet), then **ask whether to proceed or change something as an `AskUserQuestion` menu, never as free text**: **Proceed** (Recommended) first, **Change something** second (the user types the change through the menu's built-in "Other" or in a follow-up message), plus the **delegate** option (this gate is a process choice, see `../_shared/choices.md`). On a host with no structured question tool (Codex), the same options are a plain-text numbered list, per its adapter. This is a HITL confirm gate: on **Proceed**, proceed to the engine. **If the user asks for a change, hold it against the ticket.** A change **consistent** with the ticket is folded in and the engine proceeds. A change that **diverges** from the ticket is surfaced as a mismatch, and the user is **asked, never forced**, whether to (a) work the ticket **as-is**, or (b) open a **separate new ticket** for the divergent request, as an `AskUserQuestion` menu **without the delegate option** (routing a scope change is the human's, per the one-line test in `../_shared/choices.md`). Offer both and decide neither: open the separate ticket **only if the user chooses it** (via **Scope that surfaces while working a ticket**, served by the capture cluster, the same capture-or-split path used for scope that surfaces mid-work), never automatically. **If the user defers the choice rather than settling it here, mark the divergent follow-up pending for the Pending follow-up guard (`../_shared/session-handoff.md`), so it is re-posed at the handoff rather than lost.** **Under an autonomous run (`auto`) this is a process confirm gate**, so it follows the advisory boundary in `references/auto.md`: `auto` proceeds (takes the delegate option) without stopping to brief.
61
61
  - **Skip the confirm on a ticket this session already shaped.** Skip the brief menu when, in this same session (before any `/clear`), (a) the agent created this ticket from the user's own request for it, one ticket at a time (a capture `todo`/`bug`/`idea`/`seed`, `ticket`, `quick "<text>"`, or a `split` child the user approved), or (b) a Grilling round on this ticket concluded (`grill <ticket>`): the user just stated or settled its scope, so asking again is redundant. Not a trigger: tickets created in bulk as a side effect (`new`/`adopt` charting, `inbox` triage, follow-ons spawned by a grill on another topic), where no one confirmed this ticket's own scope. Skipping replaces the menu with a **one-line**, result-oriented note (e.g. "working <name> (#n), scope as just agreed"), so the user can still interrupt with a change; a change typed then follows the same consistent/divergent handling as the menu's **Change something**. Still asked: a ticket created or grilled in an earlier session, by someone else, or before a `/clear` (a fresh session has no memory of it), a `resume`d ticket, a ticket only read or mentioned this session. `auto` is unchanged (it already takes the confirm as delegate). The evidence is the session's own context, never inferred from tracker metadata (an issue's author or creation time does not prove this session made it).
62
62
  4. Resolve it with its type's engine in **`references/ticket-engines.md`**, **zoom as needed**: fetch the full body of related/closed tickets on demand. If in doubt on a `decision` ticket, run **Grilling** + **Domain vocabulary**. If in doubt on a `build`, **stop and ask** (see the Discuss step): never auto-grill.
63
- 5. Record the resolution: a comment with the answer, `gh issue close`, add the pointer to *Decisions so far* (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, which does the fail-safe read-modify-write, the concurrent-edit checks and the secret scan in one call; when the helper is missing, do it by hand per the Fail-safe body rewrites rule in `../_shared/substrate.md`). Then **unblock dependents**: for every ticket this one was blocking, if it was the last open blocker, remove its `trailhead:blocked` label so it graduates onto the frontier.
64
- 6. Add newly-surfaced tickets (create-then-wire, labelling blocked ones `trailhead:blocked`); graduate the fog that became specifiable, clearing the patch from *Not yet specified* (default path: `node <templates>/trailhead-issue-body.js remove --issue <map> --section "Not yet specified" --match "<unique text of the entry>"`, which drops exactly one list entry (no match is a reported no-op, several matches abort); when the helper is missing, do the fail-safe read-modify-write by hand). If the answer reveals a ticket sits beyond the destination, apply the **out-of-scope vs deferred** test (`../_shared/out-of-scope.md`): if it's really deferred on something outside the map, ask the user to route it to a `seed`/`idea`/`todo`; only if it's truly beyond the destination rule it **out of scope** (label, then **drop its native sub-issue edge** to reclaim a slot, then close: the eager drop in `../_shared/substrate-commands.md`, 404-tolerant, references intact) instead of resolving it. If the decision invalidates other parts of the map, update or delete them.
63
+ 5. Record the resolution: a comment with the answer (for a `decision`, carrying the `Builds:` line of its engine's **Carry it into build** step, with the build tickets created before the close), `gh issue close`, add the pointer to *Decisions so far* (default path: `node <templates>/trailhead-issue-body.js append --issue <map> --section "Decisions so far" --line "<pointer>"`, which does the fail-safe read-modify-write, the concurrent-edit checks and the secret scan in one call; when the helper is missing, do it by hand per the Fail-safe body rewrites rule in `../_shared/substrate.md`). Then **unblock dependents**: for every ticket this one was blocking, if it was the last open blocker, remove its `trailhead:blocked` label so it graduates onto the frontier.
64
+ 6. Add newly-surfaced tickets (create-then-wire, labelling blocked ones `trailhead:blocked`); graduate the fog that became specifiable: re-read **every entry** of *Not yet specified* (not just the ones this ticket names) against the answer, apply the **Ticket or fog** test of charting (`../trailhead-chart/references/charting.md`, Mode 1 step 4) to each, and propose all the entries that pass as **one batch** (title, type, blockers per ticket), created on a yes, so the user never has to dissolve ripe fog one entry per session. For each graduated entry, clear the patch from *Not yet specified* (default path: `node <templates>/trailhead-issue-body.js remove --issue <map> --section "Not yet specified" --match "<unique text of the entry>"`, which drops exactly one list entry (no match is a reported no-op, several matches abort); when the helper is missing, do the fail-safe read-modify-write by hand). If the answer reveals a ticket sits beyond the destination, apply the **out-of-scope vs deferred** test (`../_shared/out-of-scope.md`): if it's really deferred on something outside the map, ask the user to route it to a `seed`/`idea`/`todo`; only if it's truly beyond the destination rule it **out of scope** (label, then **drop its native sub-issue edge** to reclaim a slot, then close: the eager drop in `../_shared/substrate-commands.md`, 404-tolerant, references intact) instead of resolving it. If the decision invalidates other parts of the map, update or delete them.
65
65
  7. **Hand off.** Close the session with the **Session handoff** ritual (`../_shared/session-handoff.md`): confirm the ticket is resolved by name, then the scannable **next-step block with `/clear` first** (never the next command without it), naming the next frontier ticket and giving its number (or, when several mutually independent tickets sit on the frontier, presenting them as a **set to choose from** rather than crowning the first-listed), or `/trailhead:map` if the frontier is empty/ambiguous. This holds in any later follow-up too, not only at the moment of resolution. **If this resolution leaves the map exhausted** (no open tickets and no fog left: the destination is reached), say so; but **before closing it out, if *Out of scope* holds any deferred line** (apply the out-of-scope-vs-deferred tells in `../_shared/out-of-scope.md`), **stop and ask the user what to do with each** (a `seed` gated on its trigger, an `idea`, or a `todo`, in this or another map), so nothing wanted-later is lost when the map goes quiet. Only once the deferred items are routed (or the user says leave them), **ask the user whether to close the map issue** now that the destination is reached: `gh issue close` it on a yes (it stays the record), leave it open on a no; never close a map unprompted. Either way **refresh the dashboard** so an exhausted/closed map drops off (a structural event): `node <templates>/trailhead-dashboard.js` regenerates the whole body from the live tracker in one call (`--dry-run` previews it); when the helper is missing, regenerate it by hand per `../_shared/substrate-commands.md`. The `clear` of the work state (`node <templates>/trailhead-work-state.js clear --repo <working-root>`) also removes the session-ticket marker, so the handoff needs just that one call. Maps aren't pinned, so there's no pin to free; the dashboard, codebase, and conventions stay pinned regardless.
66
66
 
67
67
  ## Work one ticket whole, without splitting: `quick`
@@ -16,6 +16,7 @@ Close a choice, but widen before you narrow:
16
16
 
17
17
  1. **Diverge (only if the option space isn't already clear).** Mapping the frontier usually framed the options already; when it did, check that the framed set is still current and complete before grilling: re-read it against the ticket and against what has landed since (the map's `Decisions so far`, tickets closed after it was framed), add any missing candidate (the status quo, "leave it as it is", included when it is a real option), post the additions as an `OPTIONS` comment, then grill. When it didn't (the ticket names a question but the candidate answers aren't laid out) open the space *first*: brainstorm the plausible options, name each concretely, and post them as an `OPTIONS` comment. Divergence has no judgement yet: surface possibilities, don't rank them. If a missing **fact** is what's blocking clarity, spin a `research` ticket and block on it; if the question is "how could it look/behave", spin a `prototype`. Don't grill a space you haven't opened.
18
18
  2. **Converge.** Run the **Grilling** + **Domain vocabulary** techniques over the options to pick one. Before the final confirmation, run the **Holistic review before the final confirmation** (Grilling technique). Record the decision and the why. The delegate option (agent picks) is never offered here, the choice stays the human's, but **defer is offered** (in prose, per the recipe in `../../_shared/choices.md`) when the choice is genuinely blocked on a prerequisite.
19
+ 3. **Carry it into build.** A decision that only gets recorded builds nothing: when a fog area graduated into this ticket, its entry left *Not yet specified*, so once the ticket closes the work it implies lives nowhere unless a ticket carries it. So, before the close, list the work the answer makes buildable (the code, the content, the setup it commits the project to) and give every piece a home: an **existing** open ticket that already covers it (a `build`, `bug` or `task` on the map, its body extended with a `Decided in: <name+link>` line when it lacks one), or a **new `build` ticket** on the same map (on the whiteboard for a whiteboard decision), create-then-wire per `../../_shared/substrate.md`, its `## Question` naming the deliverable and its body a `Decided in: <name+link>` line back to this ticket, labelled `trailhead:blocked` and wired to the open tickets it really waits on (another unresolved decision, the build it extends). Size each one to a session, as at charting; a sprawling answer gets several builds, not one. Put the proposed split (titles, one line of scope each, blockers) in front of the human inside the **final confirmation** of step 2, so the scope of what gets built is theirs too, then create the tickets. The resolution comment carries one `Builds: <name+link>, <name+link>` line listing every ticket (new or existing) that carries the work. Only a decision whose answer builds nothing (a pure policy, a naming, a ruling that a feature is dropped) writes `Builds: none (<why>)` instead, with the reason. **Never `gh issue close` a decision whose resolution comment lacks the `Builds:` line**: the line is what lets the map, and `/trailhead:audit`, tell a carried decision from a stranded one.
19
20
 
20
21
  The two phases are distinct on purpose: divergence generates, grilling chooses. Never let the agent invent the options *and* pick for the human: the choice stays theirs.
21
22
 
@@ -20,12 +20,12 @@
20
20
  #
21
21
  # Usage: % REALE del piano via l'API OAuth di Anthropic (come ccstatusline):
22
22
  # GET https://api.anthropic.com/api/oauth/usage con il Bearer preso da
23
- # ~/.claude/.credentials.json (claudeAiOauth.accessToken). Mostra la finestra
23
+ # ${CLAUDE_CONFIG_DIR:-~/.claude}/.credentials.json (claudeAiOauth.accessToken). Mostra la finestra
24
24
  # 5h e la settimana (7 giorni) piu' il reset. Cache ~60s, chiamata in background
25
25
  # (il render non aspetta la rete). Disabilita con TRAILHEAD_STATUSLINE_USAGE=0.
26
26
  #
27
27
  # Uso:
28
- # - standalone: settings.json -> statusLine.command = "bash ~/.claude/statusline-trailhead.sh"
28
+ # - standalone: settings.json -> statusLine.command = "bash <config dir>/statusline-trailhead.sh" (config dir = $CLAUDE_CONFIG_DIR, default ~/.claude)
29
29
  # - widget singolo (ccstatusline custom command): "--ticket-only" | "--context-only" | "--usage-only"
30
30
 
31
31
  set -uo pipefail
@@ -277,7 +277,7 @@ print(f"[{bar}] {round(tok/1000)}k/{round(limit/1000)}k ({pct}%)")
277
277
  # Retry-After (default 300s); altri errori 30s. Timeout 5s (USAGE_API_TIMEOUT lo cambia).
278
278
  # Chiamata in background: il render non attende mai. Off con TRAILHEAD_STATUSLINE_USAGE=0.
279
279
  USAGE_RAW=""
280
- CREDS="$HOME/.claude/.credentials.json"
280
+ CREDS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/.credentials.json"
281
281
  if [ "${TRAILHEAD_STATUSLINE_USAGE:-1}" != "0" ] && [ -f "$CREDS" ]; then
282
282
  CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/trailhead-statusline"
283
283
  mkdir -p "$CACHE" 2>/dev/null
@@ -36,6 +36,12 @@
36
36
  // family, matched case-insensitively; an empty value or `none` means none. A required
37
37
  // field missing, a label given twice or a broken invariant is the `fields` problem.
38
38
  // Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
39
+ // The verdict must also agree with the report's content, and only ever moves to the
40
+ // stricter one (fail closed, action normalise): a code-review `clean` whose counts line has
41
+ // critical N > 0 stands only if the ledger closes (`fixed`/`deferred`/`accepted`, first
42
+ // disposition word after the `C<n>` id) at least N distinct Critical ids and none is `open`,
43
+ // else it becomes `issues_found`; a verify tag below the highest `class: <c>` claim line
44
+ // (unmet/partial > inconclusive > met) is raised to it. Warnings and Info never gate.
39
45
 
40
46
  const fs = require('fs');
41
47
 
@@ -48,6 +54,10 @@ const REVIEW_TOKENS = Object.freeze({
48
54
  'code-review': frozen(['clean', 'issues_found']),
49
55
  });
50
56
 
57
+ // The class a verify claim line carries (`class: <c>`); the contract is pinned against
58
+ // scripts/agent-blocks.md and verify.md by report-status.test.js.
59
+ const VERIFY_CLASSES = frozen(['met', 'unmet', 'partial', 'inconclusive']);
60
+
51
61
  // The return contract of each field family. Hard-coded here (the checker ships
52
62
  // self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
53
63
  // states. An invariant says: for these statuses the field (or `body`, the report without
@@ -144,6 +154,58 @@ function countsOf(line) {
144
154
  return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
145
155
  }
146
156
 
157
+ // A ledger line: `C<n>`, an optional separator (colon, hyphen, en dash, em dash, `)`), then
158
+ // the disposition word right away (a bare `open` must end the line or be followed by
159
+ // punctuation: `C1 open redirect` is prose).
160
+ const LEDGER_RE = /^C(\d+)\s*[:\-\u2013\u2014)]?\s*(fixed|deferred|accepted|reclassified|open(?=\s*(?:$|[:.,;(\u2192])))(?![-\w])/i;
161
+ const CRITICAL_ID_RE = /^C(\d+)\b/i;
162
+ const CLOSED = frozen(['fixed', 'deferred', 'accepted']);
163
+ // Verify verdicts by strictness; unmet and partial claims both mean gaps_found.
164
+ const VERIFY_RANK = Object.freeze({ met: 0, inconclusive: 1, gaps_found: 2 });
165
+ const CLASS_VERDICT = Object.freeze({ met: 'met', unmet: 'gaps_found', partial: 'gaps_found', inconclusive: 'inconclusive' });
166
+
167
+ // Critical ids at the start of any candidate line, as { seen, ledger }: `seen` is every id
168
+ // (parsed as a ledger line or not), `ledger` the parsed lines [{ id, disposition }].
169
+ function criticalLines(lines, cands) {
170
+ const stripped = cands.map((i) => strip(lines[i]));
171
+ const seen = stripped.flatMap((s) => { const m = CRITICAL_ID_RE.exec(s); return m ? [`C${m[1]}`] : []; });
172
+ const ledger = stripped.flatMap((s) => {
173
+ const m = LEDGER_RE.exec(s);
174
+ return m ? [{ id: `C${m[1]}`, disposition: m[2].toLowerCase() }] : [];
175
+ });
176
+ return { seen: new Set(seen), ledger };
177
+ }
178
+
179
+ // Does a `clean` verdict with `critical` Criticals stand against its ledger? Every id seen
180
+ // must be closed (or reclassified away), none parsed as open, and the closed count reach N.
181
+ function cleanStands(critical, { seen, ledger }) {
182
+ if (critical === 0) return true;
183
+ if (ledger.some((e) => e.disposition === 'open')) return false;
184
+ const closed = new Set(ledger.filter((e) => CLOSED.includes(e.disposition)).map((e) => e.id));
185
+ const moved = new Set(ledger.filter((e) => e.disposition === 'reclassified').map((e) => e.id));
186
+ if ([...seen].some((id) => !closed.has(id) && !moved.has(id))) return false;
187
+ return closed.size >= critical;
188
+ }
189
+
190
+ // The verify verdict raised to the highest class among the claim lines; never lowered.
191
+ function raisedVerify(verdict, lines, cands, statusIdx) {
192
+ const classes = cands.filter((i) => !statusIdx.has(i)).flatMap((i) => {
193
+ const m = new RegExp(`(?:^|[\\s,;(])class\\s*:\\s*(${VERIFY_CLASSES.join('|')})\\b(?![-\\w])`, 'i').exec(strip(lines[i]));
194
+ return m ? [CLASS_VERDICT[m[1].toLowerCase()]] : [];
195
+ });
196
+ return classes.reduce((v, c) => (VERIFY_RANK[c] > VERIFY_RANK[v] ? c : v), verdict);
197
+ }
198
+
199
+ // The verdict after the content check: the declared one, or the stricter one it contradicts.
200
+ function checkedVerdict(technique, verdict, lines, cands, statusIdx, counts) {
201
+ if (technique === 'verify') return raisedVerify(verdict, lines, cands, statusIdx);
202
+ if (technique === 'code-review' && verdict === 'clean') {
203
+ const critical = Number(/^critical: (\d+)/.exec(counts[0].text)[1]);
204
+ return cleanStands(critical, criticalLines(lines, cands)) ? verdict : 'issues_found';
205
+ }
206
+ return verdict;
207
+ }
208
+
147
209
  const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
148
210
 
149
211
  const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
@@ -198,7 +260,7 @@ function checkReport(text, technique) {
198
260
  const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
199
261
  if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
200
262
  if (valid.size === 0) return reask(original, 'unknown-token');
201
- const verdict = [...valid][0];
263
+ const declared = [...valid][0];
202
264
 
203
265
  let counts = [];
204
266
  if (technique === 'code-review') {
@@ -206,6 +268,8 @@ function checkReport(text, technique) {
206
268
  if (counts.length !== 1) return reask(original, 'counts');
207
269
  }
208
270
 
271
+ const verdict = checkedVerdict(technique, declared, lines, cands, new Set(statusLines.map((c) => c.i)), counts);
272
+
209
273
  // Blank lines that led the original are not content: drop them with the status lines.
210
274
  const lead = lines.findIndex((l) => l.trim() !== '');
211
275
  const drop = new Set([...statusLines, ...counts].map((c) => c.i));
@@ -224,7 +288,7 @@ function checkReport(text, technique) {
224
288
  if (family === 'plan' && verdict === 'ready') body = text;
225
289
  }
226
290
 
227
- const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
291
+ const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}` && verdict === declared
228
292
  && (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
229
293
  if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
230
294
 
@@ -271,4 +335,4 @@ function main(argv, io = {}) {
271
335
  // exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
272
336
  if (require.main === module) process.exitCode = main(process.argv.slice(2));
273
337
 
274
- module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
338
+ module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };
@@ -192,6 +192,8 @@ const rig = (on: On, engine?: any) => {
192
192
  repo: { root: '/repo', remote: 'git@github.com:o/r.git', internal: false } as { root: string; remote: string | null; internal: boolean } | null,
193
193
  // The active-map marker of the second checkout (ROOT_OTHER); null: it has none.
194
194
  otherActive: null as string | null,
195
+ // Runs once before the next read of the active-map marker, to land an event inside a check.
196
+ beforeMarkerRead: null as (() => Promise<void>) | null,
195
197
  // The worktree checkout's marker, what `git rev-parse --show-toplevel` answers there (null: it fails), and the
196
198
  // session's project root; every path read and every `git` run is recorded.
197
199
  treeActive: null as string | null,
@@ -288,13 +290,18 @@ const rig = (on: On, engine?: any) => {
288
290
  if (argv[2] === 'view') return ok(JSON.stringify(isOther ? r.otherMap : r.map))
289
291
  return ok(JSON.stringify(argv.includes('all') ? (isOther ? r.otherHistory : r.history) : isOther ? r.otherTickets : r.tickets))
290
292
  }
291
- on('fs.read', (_$, e) => {
293
+ on('fs.read', async (_$, e) => {
292
294
  // A relative path reaches the hook already resolved against the host's directory, not the repo's: put
293
295
  // it under the session's working directory, which is what the engine resolves it against. A path under
294
296
  // one of the repos is absolute (a pinned read) and stays.
295
297
  const isAbsolute = [ROOT, ROOT_OTHER, ROOT_TREE].some(root => e.path.startsWith(`${root}/`))
296
298
  const path = isAbsolute ? e.path : `${r.cwd}${e.path.slice(e.path.indexOf('/.trailhead/'))}`
297
299
  r.reads.push(path)
300
+ if (path.endsWith('/.trailhead/active-map') && r.beforeMarkerRead !== null) {
301
+ const hook = r.beforeMarkerRead
302
+ r.beforeMarkerRead = null
303
+ await hook()
304
+ }
298
305
  const root = path.startsWith(`${ROOT_TREE}/`) ? ROOT_TREE : path.startsWith(`${ROOT_OTHER}/`) ? ROOT_OTHER : path.startsWith(`${ROOT}/`) ? ROOT : null
299
306
  const marker = root === ROOT_TREE ? r.treeActive : root === ROOT_OTHER ? r.otherActive : r.active
300
307
  const file = path.endsWith('/.trailhead/active-map') ? (root === null ? null : marker) : path.endsWith('/.trailhead/work-state.json') ? (root === null ? null : r.work) : undefined
@@ -1119,9 +1126,9 @@ for (const surface of SURFACES) {
1119
1126
  await openAndMount($, r)
1120
1127
  expect(fetches(r)).toBe(1)
1121
1128
 
1129
+ // two turn ends with no write between: no flush, the trailing refresh is the only extra read
1122
1130
  await r.clock.advance(2_000)
1123
1131
  await completeTurn($)
1124
- await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1125
1132
  await completeTurn($)
1126
1133
  await settle(r)
1127
1134
  expect(fetches(r)).toBe(1)
@@ -1138,6 +1145,37 @@ for (const surface of SURFACES) {
1138
1145
  expect(fetches(r)).toBe(2)
1139
1146
  })
1140
1147
 
1148
+ // A write followed by a turn end flushes: the turn end reads at once, inside the floor. The armed trailing
1149
+ // refresh is kept, so the read after the search index caught up still follows, one floor after the flush.
1150
+ test(`a write then a turn end inside the floor reads at once and keeps the trailing [${surface}]`, async ($, on) => {
1151
+ const r = rig(on, $)
1152
+ await $.session.start(START)
1153
+ await openAndMount($, r)
1154
+ expect(fetches(r)).toBe(1)
1155
+
1156
+ await r.clock.advance(2_000)
1157
+ await completeTurn($)
1158
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 5' } as never)
1159
+ await completeTurn($)
1160
+ await settle(r)
1161
+ expect(fetches(r)).toBe(2)
1162
+ // a second turn end with no write since the flush adds nothing
1163
+ await completeTurn($)
1164
+ await settle(r)
1165
+ expect(fetches(r)).toBe(2)
1166
+
1167
+ // the trailing one fires at the first floor's end, lands inside the flush's floor and waits for its end
1168
+ await r.clock.advance(FLOOR - 2_000)
1169
+ await settle(r)
1170
+ expect(fetches(r)).toBe(2)
1171
+ await r.clock.advance(2_000)
1172
+ await settle(r)
1173
+ expect(fetches(r)).toBe(3)
1174
+ await r.clock.advance(FLOOR * 2)
1175
+ await settle(r)
1176
+ expect(fetches(r)).toBe(3)
1177
+ })
1178
+
1141
1179
  // The host drops a refused timer silently (a closed ticket stayed on the pane for good): the pane must not
1142
1180
  // wait on a dead handle.
1143
1181
  test(`a slow timer the host dropped is started again by the next trigger [${surface}]`, async ($, on) => {
@@ -1166,6 +1204,51 @@ for (const surface of SURFACES) {
1166
1204
  expect(fetches(r)).toBe(3)
1167
1205
  })
1168
1206
 
1207
+ // The trailing refresh is dropped like the slow timer (a closed ticket stayed on the pane as held): the turn
1208
+ // end after a write reads at once, floor or not, so the final tracker state shows with no timer involved.
1209
+ test(`a turn end after a write shows the close although the trailing refresh was dropped [${surface}]`, async ($, on) => {
1210
+ on('clock.after', { ms: FLOOR }, async () => ({ deny: 'dropped' }))
1211
+ const r = rig(on, $)
1212
+ await $.session.start(START)
1213
+ const ui = await openAndMount($, r)
1214
+ await r.clock.advance(FLOOR)
1215
+ // the resolution comment reads at once, before the close
1216
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1217
+ await settle(r)
1218
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1219
+ await r.clock.advance(2_000)
1220
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1221
+ await completeTurn($)
1222
+ await settle(r)
1223
+ expect(await hasKey(ui, 'row-209')).toBe(false)
1224
+ })
1225
+
1226
+ // Without a turn end, a pane drawn again past the dead trailing's due time and grace reads at once.
1227
+ test(`a pane drawn again after a dropped trailing refresh reads at once [${surface}]`, async ($, on) => {
1228
+ let isDropping = false
1229
+ on('clock.after', { ms: FLOOR }, async ($$, e, next) => (isDropping ? { deny: 'dropped' } : next(e)))
1230
+ const r = rig(on, $)
1231
+ await $.session.start(START)
1232
+ const ui = await openAndMount($, r)
1233
+ await r.clock.advance(FLOOR)
1234
+ isDropping = true
1235
+ await $.tool.call({ tool: 'Bash', command: 'gh issue comment 209 -b done' } as never)
1236
+ await settle(r)
1237
+ r.tickets = TICKETS.filter(t => t.number !== 209)
1238
+ await r.clock.advance(2_000)
1239
+ await $.tool.call({ tool: 'Bash', command: 'gh issue close 209' } as never)
1240
+ await settle(r)
1241
+ isDropping = false
1242
+ // nothing fired for the close: the pane still shows the closed ticket
1243
+ await r.clock.advance(FLOOR * 4)
1244
+ await settle(r)
1245
+ expect(await hasKey(ui, 'row-209')).toBe(true)
1246
+ await ui.unmount()
1247
+ const again = await mountPane($)
1248
+ await settle(r)
1249
+ expect(await hasKey(again, 'row-209')).toBe(false)
1250
+ })
1251
+
1169
1252
  // The open list is a GraphQL read, never the search-backed `gh issue list --label`, which lags a write.
1170
1253
  const listReads = (r: Rig) => r.calls.filter(argv => argv[2] === 'list' && argv.includes('--label') && argv.includes('trailhead:map-198') && argv.includes('open') && !argv.includes('trailhead:uat'))
1171
1254
 
@@ -3289,6 +3372,100 @@ for (const surface of SURFACES) {
3289
3372
  expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3290
3373
  })
3291
3374
 
3375
+ test(`a refresh in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3376
+ const r = rig(on, $)
3377
+ await $.session.start(START)
3378
+ let release: () => void = () => {}
3379
+ const gate = new Promise<void>(resolve => {
3380
+ release = resolve
3381
+ })
3382
+ // The answer is fixed when the call starts, so the reads begun in /repo keep its tracker state.
3383
+ r.gh = async argv => {
3384
+ const answer = r.defaultGh(argv)
3385
+ await gate
3386
+ return answer
3387
+ }
3388
+ r.open()
3389
+ const ui = await mountPane($)
3390
+ await settle(r)
3391
+ // Another checkout of the SAME repo (same remote), with map 198 active there; the tracker changed after the
3392
+ // read started. A turn start refreshes the band, which moves the pin while the read is still in flight.
3393
+ r.cwd = ROOT_OTHER
3394
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3395
+ r.otherActive = '198\n'
3396
+ r.tickets = OTHER_TICKETS
3397
+ r.history = OTHER_HISTORY
3398
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo' })
3399
+ release()
3400
+ await settle(r)
3401
+ await r.clock.advance(FLOOR)
3402
+ await settle(r)
3403
+ // The dropped read is repeated: the pane shows the new tracker state, not the old list.
3404
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3405
+ })
3406
+
3407
+ test(`a same-repo pin move landing inside the stale check is read again [${surface}]`, async ($, on) => {
3408
+ const r = rig(on, $)
3409
+ await $.session.start(START)
3410
+ let release: () => void = () => {}
3411
+ const gate = new Promise<void>(resolve => {
3412
+ release = resolve
3413
+ })
3414
+ r.gh = async argv => {
3415
+ const answer = r.defaultGh(argv)
3416
+ await gate
3417
+ return answer
3418
+ }
3419
+ r.open()
3420
+ const ui = await mountPane($)
3421
+ await settle(r)
3422
+ // The first marker read once the gate opens is the stale check's last await: the move lands there,
3423
+ // after the check read the old pin and before it decides.
3424
+ r.beforeMarkerRead = async () => {
3425
+ r.cwd = ROOT_OTHER
3426
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3427
+ r.otherActive = '198\n'
3428
+ r.tickets = OTHER_TICKETS
3429
+ r.history = OTHER_HISTORY
3430
+ await $.turn.start({ text: 'go', turnId: 'race-in-check' })
3431
+ }
3432
+ release()
3433
+ await settle(r)
3434
+ await r.clock.advance(FLOOR)
3435
+ await settle(r)
3436
+ expect(await keysOf(ui, 'row-')).toEqual(['row-140', 'row-141'])
3437
+ })
3438
+
3439
+ test(`a dashboard read in flight when the pin moves to another checkout of the same repo is read again [${surface}]`, async ($, on) => {
3440
+ const r = rig(on, $)
3441
+ r.maps = [MAPS[0]]
3442
+ await $.session.start(START)
3443
+ const ui = await openAndMount($, r)
3444
+ let release: () => void = () => {}
3445
+ const gate = new Promise<void>(resolve => {
3446
+ release = resolve
3447
+ })
3448
+ r.gh = async argv => {
3449
+ const answer = r.defaultGh(argv)
3450
+ await gate
3451
+ return answer
3452
+ }
3453
+ // The dashboard read starts under /repo and waits on the gate.
3454
+ await ui.press({ key: 'open-whiteboard' })
3455
+ await settle(r)
3456
+ r.cwd = ROOT_OTHER
3457
+ r.repo = { root: ROOT_OTHER, remote: 'git@github.com:o/r.git', internal: false }
3458
+ r.otherActive = '198\n'
3459
+ r.whiteboard = [issue(320, 'New loose one', [...WB, 'trailhead:bug'])]
3460
+ await $.turn.start({ text: 'go', turnId: 'race-same-repo-dash' })
3461
+ release()
3462
+ await settle(r)
3463
+ await r.clock.advance(FLOOR)
3464
+ await settle(r)
3465
+ expect(await textOf(ui, 'dashboard-title')).toBe('Dashboard')
3466
+ expect(await keysOf(ui, 'row-')).toEqual(['row-320'])
3467
+ })
3468
+
3292
3469
  test(`the pin survives /clear after the session cwd moved out of the repo [${surface}]`, async ($, on) => {
3293
3470
  const r = rig(on, $)
3294
3471
  r.work = stateText()