@marcomigozzi/trailhead 0.16.0 → 0.17.0

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 (88) hide show
  1. package/README.it.md +67 -18
  2. package/README.md +67 -18
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/config-dir-templates-docs.test.js +52 -0
  6. package/bin/decision-builds-docs.test.js +75 -0
  7. package/bin/engine-route-docs.test.js +148 -0
  8. package/bin/fog-vs-ticket-docs.test.js +56 -0
  9. package/bin/grill-model-docs.test.js +155 -0
  10. package/bin/grilling-holistic-review-docs.test.js +7 -3
  11. package/bin/lib/codex-projection.js +30 -14
  12. package/bin/lib/codex-projection.test.js +81 -17
  13. package/bin/lib/host-descriptor.js +15 -2
  14. package/bin/lib/host-descriptor.test.js +24 -0
  15. package/bin/lib/runtime-command-literals.test.js +184 -0
  16. package/bin/report-status-docs.test.js +11 -3
  17. package/bin/session-trust-docs.test.js +37 -0
  18. package/bin/surfaced-scope-docs.test.js +57 -0
  19. package/bin/trailhead.js +7 -5
  20. package/bin/trailhead.test.js +53 -11
  21. package/bin/unblock-docs.test.js +56 -0
  22. package/package.json +1 -1
  23. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  24. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  25. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  26. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  27. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  28. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  29. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  30. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  31. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  32. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  33. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  34. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  35. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  36. package/plugins/trailhead/hooks/lib/model-defaults-review.js +72 -22
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
  38. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  39. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  40. package/plugins/trailhead/hooks/lib/report-status.test.js +124 -2
  41. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  42. package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
  43. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  44. package/plugins/trailhead/hooks/mod/README.md +24 -10
  45. package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
  46. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  47. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  48. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  49. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  50. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +40 -1
  51. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -0
  52. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  53. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  54. package/plugins/trailhead/skills/_shared/configuration-reference.md +20 -18
  55. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  56. package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
  57. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  58. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  59. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  60. package/plugins/trailhead/skills/_shared/teamwork.md +9 -5
  61. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  62. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  63. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  64. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  65. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  66. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  67. package/plugins/trailhead/skills/_shared/techniques/verify.md +4 -2
  68. package/plugins/trailhead/skills/_shared/techniques.md +7 -5
  69. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  70. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  71. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  73. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  74. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
  75. package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
  76. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
  78. package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
  79. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  80. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  81. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  82. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  83. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  84. package/plugins/trailhead/templates/trailhead-report-status.js +123 -11
  85. package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -37
  86. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  87. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  88. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -4,35 +4,61 @@
4
4
  // install channel). The panel cannot move the session's directory, so it hands the
5
5
  // pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
6
6
  // verb moves there first. This helper decodes the token, validates it and says
7
- // whether the session is already there. It fails closed: it never searches for
8
- // another checkout. #312.
7
+ // whether the session is already there. It fails closed: never a blind search for
8
+ // another checkout, only a lookup by the repo's origin remote when the path is gone
9
+ // and the token names the repo (trailhead-checkout-find.js). #312, #329.
9
10
  //
10
11
  // The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
11
- // with spaces, quotes or a leading `-` travels as one shell-safe word.
12
+ // with spaces, quotes or a leading `-` travels as one shell-safe word. It may carry
13
+ // the repo identity as a second part: base64url(path) + '.' + base64url(host/owner/name)
14
+ // (`.` is outside the alphabet, so an old helper rejects it and stays fail-closed).
12
15
  //
13
16
  // CLI:
14
- // node trailhead-checkout-in.js --in <b64url> --cwd <dir>
17
+ // node trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]
18
+ // `--roots` replaces the default search roots of the remote lookup (tests).
15
19
  //
16
20
  // Prints exactly one JSON line on stdout:
17
- // {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
21
+ // {"status":"here"|"move"|"choose"|"invalid","path":"...","reason":"...","cd":"..."}
18
22
  // `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
19
- // Exit 0 for the three statuses, 2 on a usage error (stderr).
23
+ // `relocatedFrom` (the old path) is added to `here`/`move` when the checkout was
24
+ // found by its remote. `choose` (path gone, several checkouts match) carries
25
+ // `candidates` ([{path, kind}]) and `reason`.
26
+ // Exit 0 for the four statuses, 2 on a usage error (stderr).
20
27
 
21
28
  const fs = require('fs');
22
29
  const path = require('path');
23
30
  const { spawnSync } = require('child_process');
24
31
 
32
+ const { findCheckouts, parseRepoShape } = require('./trailhead-checkout-find.js');
33
+
25
34
  const PREFIX = '--in=';
26
35
 
27
- function encodeIn(p) {
28
- return PREFIX + Buffer.from(String(p), 'utf8').toString('base64url');
36
+ const b64 = (s) => Buffer.from(String(s), 'utf8').toString('base64url');
37
+
38
+ function encodeIn(p, repo) {
39
+ return PREFIX + b64(p) + (repo ? '.' + b64(repo) : '');
40
+ }
41
+
42
+ // One base64url part back to its string, or null when it is not canonical.
43
+ function decodePart(part) {
44
+ if (!/^[A-Za-z0-9_-]+$/.test(part)) return null;
45
+ const s = Buffer.from(part, 'base64url').toString('utf8');
46
+ // Round-trip guard: a part that does not re-encode to itself is not ours.
47
+ return s !== '' && b64(s) === part ? s : null;
29
48
  }
30
49
 
50
+ // {path, repo} (repo null for a one-part token), or null for a malformed token.
31
51
  function decodeIn(token) {
32
- if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token)) return null;
33
- const p = Buffer.from(token, 'base64url').toString('utf8');
34
- // Round-trip guard: a token that does not re-encode to itself is not ours.
35
- return p !== '' && Buffer.from(p, 'utf8').toString('base64url') === token ? p : null;
52
+ if (typeof token !== 'string') return null;
53
+ const parts = token.split('.');
54
+ if (parts.length > 2) return null;
55
+ const p = decodePart(parts[0]);
56
+ if (p === null) return null;
57
+ if (parts.length === 1) return { path: p, repo: null };
58
+ const repo = decodePart(parts[1]);
59
+ // The repo part is `host/owner/name` by shape (the host may be dotless, e.g. `gitea`).
60
+ if (repo === null || parseRepoShape(repo) === null) return null;
61
+ return { path: p, repo };
36
62
  }
37
63
 
38
64
  function real(p) {
@@ -55,12 +81,8 @@ function cdCommand(p) {
55
81
  return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
56
82
  }
57
83
 
58
- function check(token, cwd) {
59
- const p = decodeIn(token);
60
- if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
61
- if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
62
- const canon = real(p);
63
- if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
84
+ // here/move for a path that must be a checkout root.
85
+ function resolveRoot(p, canon, cwd) {
64
86
  // The path must be a checkout root itself, never a folder inside one.
65
87
  if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
66
88
  const here = real(cwd) !== null ? toplevelOf(cwd) : null;
@@ -68,6 +90,38 @@ function check(token, cwd) {
68
90
  return { status: 'move', path: p, cd: cdCommand(p) };
69
91
  }
70
92
 
93
+ // The path is gone: look the repo up by its remote (only when the token names it).
94
+ function relocate(old, repo, cwd, opts) {
95
+ const found = findCheckouts(parseRepoShape(repo), opts.roots ? { roots: opts.roots } : { cwd });
96
+ if (found.status === 'found') {
97
+ const root = resolveRoot(found.path, found.path, cwd);
98
+ return { ...root, relocatedFrom: old };
99
+ }
100
+ if (found.status === 'ambiguous') {
101
+ const cut = found.truncated ? ' (the search was cut short)' : '';
102
+ return {
103
+ status: 'choose',
104
+ path: old,
105
+ candidates: found.candidates,
106
+ reason: `the path does not exist and ${found.candidates.length === 1 ? 'a checkout' : 'several checkouts'} of ${repo} may match${cut}`,
107
+ };
108
+ }
109
+ return { status: 'invalid', path: old, reason: `the path does not exist and no checkout of ${repo} was found` };
110
+ }
111
+
112
+ function check(token, cwd, opts = {}) {
113
+ const d = decodeIn(token);
114
+ if (d === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
115
+ const p = d.path;
116
+ if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
117
+ const canon = real(p);
118
+ if (canon === null) {
119
+ if (d.repo === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
120
+ return relocate(p, d.repo, cwd, opts);
121
+ }
122
+ return resolveRoot(p, canon, cwd);
123
+ }
124
+
71
125
  function main(argv) {
72
126
  const get = (flag) => {
73
127
  const i = argv.indexOf(flag);
@@ -75,11 +129,13 @@ function main(argv) {
75
129
  };
76
130
  const token = get('--in');
77
131
  const cwd = get('--cwd');
132
+ const roots = get('--roots');
78
133
  if (token === null || cwd === null) {
79
- process.stderr.write('usage: trailhead-checkout-in.js --in <b64url> --cwd <dir>\n');
134
+ process.stderr.write('usage: trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]\n');
80
135
  return 2;
81
136
  }
82
- process.stdout.write(JSON.stringify(check(token, cwd)) + '\n');
137
+ const opts = roots === null ? {} : { roots: roots.split(':').filter((r) => r !== '') };
138
+ process.stdout.write(JSON.stringify(check(token, cwd, opts)) + '\n');
83
139
  return 0;
84
140
  }
85
141
 
@@ -7,11 +7,18 @@
7
7
  // updatedAt check, post-write conflict check, secret scan). #253.
8
8
  //
9
9
  // CLI:
10
- // node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
10
+ // node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run] [--update-only]
11
11
  // --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
12
12
  // audit's only writer path); without it the marker is copied verbatim from the
13
13
  // existing body. --dry-run gathers and reads through gh, computes and scans, but
14
14
  // writes, creates, pins and labels nothing; it prints the old and new byte sizes.
15
+ // --update-only refreshes an existing dashboard and never opens one: with no open
16
+ // dashboard issue it creates, pins and labels nothing (the label ensure is skipped
17
+ // too) and prints `skipped: no dashboard issue (update-only)`, exit 0. A capture
18
+ // uses it so it cannot open a second issue; the check lives here, in the helper,
19
+ // so there is no check-then-call race. That is all it forbids: it never creates the
20
+ // dashboard. With a dashboard present it is a normal refresh (the label ensure and
21
+ // the re-pin run as usual, both idempotent).
15
22
  //
16
23
  // Singleton labels (dashboard, codebase, conventions): more than one open issue
17
24
  // aborts before any write, naming every duplicate by title and number.
@@ -23,6 +30,7 @@
23
30
  //
24
31
  // Prints exactly one line on stdout:
25
32
  // dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
33
+ // skipped: no dashboard issue (update-only)
26
34
  // aborted: <reason>
27
35
  // Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
28
36
  // A pin check or pin failure AFTER the body was written is not an abort: the line
@@ -266,10 +274,11 @@ function createDashboard(gh, repo, body, tmpdir) {
266
274
  // --- CLI ---------------------------------------------------------------------
267
275
 
268
276
  function parseArgs(argv) {
269
- const o = { repo: null, setLastAudit: null, dryRun: false };
277
+ const o = { repo: null, setLastAudit: null, dryRun: false, updateOnly: false };
270
278
  for (let i = 0; i < argv.length; i++) {
271
279
  const flag = argv[i];
272
280
  if (flag === '--dry-run') { o.dryRun = true; continue; }
281
+ if (flag === '--update-only') { o.updateOnly = true; continue; }
273
282
  if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
274
283
  i++;
275
284
  if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
@@ -304,6 +313,10 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
304
313
  const repo = o.repo || defaultRepo(run);
305
314
  if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
306
315
  const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
316
+ if (o.updateOnly && singles.dashboard === null) {
317
+ out.write('skipped: no dashboard issue (update-only)\n');
318
+ return 0;
319
+ }
307
320
  if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
308
321
  const snap = gatherSnapshot(run, repo, singles);
309
322
  const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
@@ -116,20 +116,80 @@ function bodyLines(text) {
116
116
  return t === '' ? [] : t.split('\n');
117
117
  }
118
118
 
119
- // A list entry: a top-level `- ` line plus the indented lines that follow it.
119
+ // A list entry: a top-level bullet (`- `, `* `, `+ `) or ordered item (`1. `, `1) `)
120
+ // plus the indented lines that follow it (a continuation paragraph may follow blank
121
+ // lines; the entry's end excludes trailing blanks). Ordered entries carry their number and
122
+ // delimiter (null for a bullet); an indented `1.` is a continuation, not an entry.
120
123
  function listEntries(lines, sec) {
121
124
  const entries = [];
122
125
  const end = contentEnd(lines, sec);
123
126
  for (let i = sec.from; i < end; i++) {
124
- if (!/^[-*+][ \t]/.test(lines[i])) continue;
127
+ const m = /^(?:[-*+]|(\d+)([.)]))[ \t]/.exec(lines[i]);
128
+ if (!m) continue;
125
129
  let j = i + 1;
126
- while (j < end && /^[ \t]+\S/.test(lines[j])) j++;
127
- entries.push({ start: i, end: j, text: lines.slice(i, j).join('\n') });
130
+ for (;;) {
131
+ // Blank lines belong to the entry only when an indented line follows them.
132
+ let k = j;
133
+ while (k < end && isBlank(lines[k])) k++;
134
+ if (k >= end || !/^[ \t]+\S/.test(lines[k])) break;
135
+ j = k + 1;
136
+ }
137
+ entries.push({
138
+ start: i,
139
+ end: j,
140
+ text: lines.slice(i, j).join('\n'),
141
+ num: m[1] === undefined ? null : Number(m[1]),
142
+ numText: m[1] === undefined ? '' : m[1],
143
+ delim: m[2] || null,
144
+ });
128
145
  i = j - 1;
129
146
  }
130
147
  return entries;
131
148
  }
132
149
 
150
+ // After removing entry `h`, the following ordered entries with the same delimiter are
151
+ // renumbered from h's own number. Entries separated only by blank lines (a loose list)
152
+ // stay in the run; a paragraph, a bullet or another delimiter ends it. A list that
153
+ // repeats one number (`1. 1. 1.`) is left alone, and zero padding is kept.
154
+ // Returns a new lines array.
155
+ function renumberAfter(lines, entries, h) {
156
+ if (h.num === null) return lines;
157
+ const run = [];
158
+ let prev = h;
159
+ for (const e of entries.filter((x) => x.start > h.start)) {
160
+ const gap = lines.slice(prev.end, e.start);
161
+ if (e.num === null || e.delim !== h.delim || !gap.every(isBlank)) break;
162
+ run.push(e);
163
+ prev = e;
164
+ }
165
+ if ([h, ...run].every((e) => e.num === h.num)) return lines;
166
+ const padded = (t) => /^0\d/.test(t);
167
+ const fixed = new Map(run.map((e, k) => {
168
+ const width = Math.max(...[h, e].filter((x) => padded(x.numText)).map((x) => x.numText.length), 0);
169
+ const num = String(h.num + k).padStart(width, '0');
170
+ return [e.start, `${num}${h.delim}${lines[e.start].replace(/^\d+[.)]/, '')}`];
171
+ }));
172
+ return lines.map((l, i) => (fixed.has(i) ? fixed.get(i) : l));
173
+ }
174
+
175
+ // The line range [from, to) to drop for entry `h`: the entry plus, in a loose list, one
176
+ // adjoining run of blank lines so no double blank or stray leading blank is left.
177
+ function removalRange(lines, entries, h, sec) {
178
+ const k = entries.indexOf(h);
179
+ const prev = entries[k - 1];
180
+ const next = entries[k + 1];
181
+ const blankGap = (from, to) => to > from && lines.slice(from, to).every(isBlank);
182
+ const blankAfter = next && blankGap(h.end, next.start);
183
+ const blankBefore = prev && blankGap(prev.end, h.start);
184
+ const tightBefore = prev && prev.end === h.start;
185
+ if (blankAfter && !tightBefore) return [h.start, next.start];
186
+ if (blankBefore && !next) return [prev.end, h.end];
187
+ // Sole entry of a spaced section before a heading: drop one trailing blank too.
188
+ const soleSpaced = !prev && !next && h.start > sec.from && isBlank(lines[h.start - 1]) && h.end < sec.to && isBlank(lines[h.end]);
189
+ if (soleSpaced && sec.to < lines.length) return [h.start, h.end + 1];
190
+ return [h.start, h.end];
191
+ }
192
+
133
193
  // The non-blank content lines of a section, or null when the section is missing.
134
194
  function readSection(body, name) {
135
195
  const { lines } = splitBody(String(body));
@@ -179,13 +239,16 @@ function applyOp(body, op) {
179
239
  }
180
240
 
181
241
  if (op.kind === 'remove') {
182
- const hits = listEntries(lines, sec).filter((e) => e.text.includes(op.match));
242
+ const entries = listEntries(lines, sec);
243
+ const hits = entries.filter((e) => e.text.includes(op.match));
183
244
  if (hits.length === 0) {
184
245
  return { body: String(body), changed: false, summary: `no entry matching "${op.match}" in ${label}, nothing written` };
185
246
  }
186
247
  if (hits.length > 1) return { error: `ambiguous: ${hits.length} entries in ${label} contain "${op.match}"` };
187
248
  const h = hits[0];
188
- const next = [...lines.slice(0, h.start), ...lines.slice(h.end)];
249
+ const renumbered = renumberAfter(lines, entries, h);
250
+ const [from, to] = removalRange(lines, entries, h, sec);
251
+ const next = [...renumbered.slice(0, from), ...renumbered.slice(to)];
189
252
  return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `removed 1 entry from ${label}` };
190
253
  }
191
254
 
@@ -3,20 +3,21 @@
3
3
  // trailhead report status check (self-contained, ships in templates/ so it reaches
4
4
  // every install channel). Checks the leading `status:` line of an engine-agent
5
5
  // report (Code review, Goal-backward verification #250; executor, fix, plan, debug,
6
- // research, codebase-map #249) before the engine branches on it, and returns the
6
+ // research, codebase-map #249; grill #323) before the engine branches on it, and returns the
7
7
  // report's fields.
8
8
  //
9
9
  // CLI:
10
- // node trailhead-report-status.js --technique <technique> [--file <path>]
10
+ // node trailhead-report-status.js --technique <technique> [--file <path> | --task-output <path>]
11
11
  // <technique> is verify | code-review | executor | fix | plan | debug | research |
12
- // codebase-map. Reads the report from --file, or from stdin when there is none.
12
+ // codebase-map | grill. Reads the report from --file, from the last assistant message
13
+ // of a background agent's task .output with --task-output, or from stdin when neither.
13
14
  // Prints exactly one JSON line {"action","verdict","problem","report","fields","body"}:
14
15
  // action accept | normalise | reask
15
16
  // verdict the canonical token, or null on reask
16
17
  // problem null | missing | unknown-token | conflicting | counts | fields (null iff not reask)
17
18
  // report the original text on accept and reask; the normalised text on normalise
18
19
  // fields label -> trimmed value for the report's fields (executor, fix, plan, debug,
19
- // research, codebase-map); {} for verify and code-review; null on reask
20
+ // research, codebase-map, grill); {} for verify and code-review; null on reask
20
21
  // body plan with `ready` only: the normalised report without its status line, the
21
22
  // PLAN to post verbatim; null otherwise
22
23
  // Exit 0 for all three actions; exit 2 on a usage error (missing or unknown
@@ -36,6 +37,12 @@
36
37
  // family, matched case-insensitively; an empty value or `none` means none. A required
37
38
  // field missing, a label given twice or a broken invariant is the `fields` problem.
38
39
  // Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
40
+ // The verdict must also agree with the report's content, and only ever moves to the
41
+ // stricter one (fail closed, action normalise): a code-review `clean` whose counts line has
42
+ // critical N > 0 stands only if the ledger closes (`fixed`/`deferred`/`accepted`, first
43
+ // disposition word after the `C<n>` id) at least N distinct Critical ids and none is `open`,
44
+ // else it becomes `issues_found`; a verify tag below the highest `class: <c>` claim line
45
+ // (unmet/partial > inconclusive > met) is raised to it. Warnings and Info never gate.
39
46
 
40
47
  const fs = require('fs');
41
48
 
@@ -48,6 +55,10 @@ const REVIEW_TOKENS = Object.freeze({
48
55
  'code-review': frozen(['clean', 'issues_found']),
49
56
  });
50
57
 
58
+ // The class a verify claim line carries (`class: <c>`); the contract is pinned against
59
+ // scripts/agent-blocks.md and verify.md by report-status.test.js.
60
+ const VERIFY_CLASSES = frozen(['met', 'unmet', 'partial', 'inconclusive']);
61
+
51
62
  // The return contract of each field family. Hard-coded here (the checker ships
52
63
  // self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
53
64
  // states. An invariant says: for these statuses the field (or `body`, the report without
@@ -79,6 +90,19 @@ const CONTRACTS = Object.freeze({
79
90
  Object.freeze({ when: frozen(['needs_input']), field: 'questions', rule: 'non-empty' }),
80
91
  ]),
81
92
  }),
93
+ grilling: Object.freeze({
94
+ tokens: frozen(['question', 'review']),
95
+ required: frozen(['settled', 'frontier', 'amendments', 'conflicts']),
96
+ optional: frozen(['question', 'recommended', 'areas', 'facts']),
97
+ invariants: Object.freeze([
98
+ Object.freeze({ when: frozen(['question']), field: 'question', rule: 'non-empty' }),
99
+ Object.freeze({ when: frozen(['question']), field: 'recommended', rule: 'non-empty' }),
100
+ Object.freeze({ when: frozen(['question']), field: 'amendments', rule: 'none' }),
101
+ Object.freeze({ when: frozen(['question']), field: 'conflicts', rule: 'none' }),
102
+ Object.freeze({ when: frozen(['review']), field: 'frontier', rule: 'none' }),
103
+ Object.freeze({ when: frozen(['review']), field: 'question', rule: 'none' }),
104
+ ]),
105
+ }),
82
106
  });
83
107
 
84
108
  // Technique -> field family.
@@ -89,6 +113,7 @@ const FAMILY_OF = Object.freeze({
89
113
  research: 'investigating',
90
114
  'codebase-map': 'investigating',
91
115
  plan: 'plan',
116
+ grill: 'grilling',
92
117
  });
93
118
 
94
119
  // Technique -> its closed set of status tokens.
@@ -144,6 +169,58 @@ function countsOf(line) {
144
169
  return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
145
170
  }
146
171
 
172
+ // A ledger line: `C<n>`, an optional separator (colon, hyphen, en dash, em dash, `)`), then
173
+ // the disposition word right away (a bare `open` must end the line or be followed by
174
+ // punctuation: `C1 open redirect` is prose).
175
+ const LEDGER_RE = /^C(\d+)\s*[:\-\u2013\u2014)]?\s*(fixed|deferred|accepted|reclassified|open(?=\s*(?:$|[:.,;(\u2192])))(?![-\w])/i;
176
+ const CRITICAL_ID_RE = /^C(\d+)\b/i;
177
+ const CLOSED = frozen(['fixed', 'deferred', 'accepted']);
178
+ // Verify verdicts by strictness; unmet and partial claims both mean gaps_found.
179
+ const VERIFY_RANK = Object.freeze({ met: 0, inconclusive: 1, gaps_found: 2 });
180
+ const CLASS_VERDICT = Object.freeze({ met: 'met', unmet: 'gaps_found', partial: 'gaps_found', inconclusive: 'inconclusive' });
181
+
182
+ // Critical ids at the start of any candidate line, as { seen, ledger }: `seen` is every id
183
+ // (parsed as a ledger line or not), `ledger` the parsed lines [{ id, disposition }].
184
+ function criticalLines(lines, cands) {
185
+ const stripped = cands.map((i) => strip(lines[i]));
186
+ const seen = stripped.flatMap((s) => { const m = CRITICAL_ID_RE.exec(s); return m ? [`C${m[1]}`] : []; });
187
+ const ledger = stripped.flatMap((s) => {
188
+ const m = LEDGER_RE.exec(s);
189
+ return m ? [{ id: `C${m[1]}`, disposition: m[2].toLowerCase() }] : [];
190
+ });
191
+ return { seen: new Set(seen), ledger };
192
+ }
193
+
194
+ // Does a `clean` verdict with `critical` Criticals stand against its ledger? Every id seen
195
+ // must be closed (or reclassified away), none parsed as open, and the closed count reach N.
196
+ function cleanStands(critical, { seen, ledger }) {
197
+ if (critical === 0) return true;
198
+ if (ledger.some((e) => e.disposition === 'open')) return false;
199
+ const closed = new Set(ledger.filter((e) => CLOSED.includes(e.disposition)).map((e) => e.id));
200
+ const moved = new Set(ledger.filter((e) => e.disposition === 'reclassified').map((e) => e.id));
201
+ if ([...seen].some((id) => !closed.has(id) && !moved.has(id))) return false;
202
+ return closed.size >= critical;
203
+ }
204
+
205
+ // The verify verdict raised to the highest class among the claim lines; never lowered.
206
+ function raisedVerify(verdict, lines, cands, statusIdx) {
207
+ const classes = cands.filter((i) => !statusIdx.has(i)).flatMap((i) => {
208
+ const m = new RegExp(`(?:^|[\\s,;(])class\\s*:\\s*(${VERIFY_CLASSES.join('|')})\\b(?![-\\w])`, 'i').exec(strip(lines[i]));
209
+ return m ? [CLASS_VERDICT[m[1].toLowerCase()]] : [];
210
+ });
211
+ return classes.reduce((v, c) => (VERIFY_RANK[c] > VERIFY_RANK[v] ? c : v), verdict);
212
+ }
213
+
214
+ // The verdict after the content check: the declared one, or the stricter one it contradicts.
215
+ function checkedVerdict(technique, verdict, lines, cands, statusIdx, counts) {
216
+ if (technique === 'verify') return raisedVerify(verdict, lines, cands, statusIdx);
217
+ if (technique === 'code-review' && verdict === 'clean') {
218
+ const critical = Number(/^critical: (\d+)/.exec(counts[0].text)[1]);
219
+ return cleanStands(critical, criticalLines(lines, cands)) ? verdict : 'issues_found';
220
+ }
221
+ return verdict;
222
+ }
223
+
147
224
  const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
148
225
 
149
226
  const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
@@ -198,7 +275,7 @@ function checkReport(text, technique) {
198
275
  const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
199
276
  if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
200
277
  if (valid.size === 0) return reask(original, 'unknown-token');
201
- const verdict = [...valid][0];
278
+ const declared = [...valid][0];
202
279
 
203
280
  let counts = [];
204
281
  if (technique === 'code-review') {
@@ -206,6 +283,8 @@ function checkReport(text, technique) {
206
283
  if (counts.length !== 1) return reask(original, 'counts');
207
284
  }
208
285
 
286
+ const verdict = checkedVerdict(technique, declared, lines, cands, new Set(statusLines.map((c) => c.i)), counts);
287
+
209
288
  // Blank lines that led the original are not content: drop them with the status lines.
210
289
  const lead = lines.findIndex((l) => l.trim() !== '');
211
290
  const drop = new Set([...statusLines, ...counts].map((c) => c.i));
@@ -224,7 +303,7 @@ function checkReport(text, technique) {
224
303
  if (family === 'plan' && verdict === 'ready') body = text;
225
304
  }
226
305
 
227
- const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
306
+ const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}` && verdict === declared
228
307
  && (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
229
308
  if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
230
309
 
@@ -232,14 +311,38 @@ function checkReport(text, technique) {
232
311
  return { action: 'normalise', verdict, problem: null, report: [...head, ...rest].join('\n'), fields, body };
233
312
  }
234
313
 
314
+ // The report of a background agent, read from the host's task .output (JSONL, often a
315
+ // symlink to the subagent transcript): the text of the last assistant message that has
316
+ // any, its blocks joined in order. One message can span several lines sharing
317
+ // message.id; lines that are not JSON are skipped. null when there is no assistant text.
318
+ function reportFromTaskOutput(raw) {
319
+ const entries = raw.split('\n').flatMap((l) => {
320
+ if (!l.trim()) return [];
321
+ try { return [JSON.parse(l)]; } catch (_) { return []; }
322
+ });
323
+ const texts = (e) => {
324
+ const c = e && e.message && e.message.content;
325
+ if (typeof c === 'string') return [c];
326
+ return Array.isArray(c) ? c.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text) : [];
327
+ };
328
+ const assistant = entries.filter((e) => e && e.type === 'assistant' && e.message);
329
+ const last = [...assistant].reverse().find((e) => texts(e).length > 0);
330
+ if (!last) return null;
331
+ const id = last.message.id;
332
+ const parts = id === undefined ? texts(last) : assistant.filter((e) => e.message.id === id).flatMap(texts);
333
+ return parts.join('');
334
+ }
335
+
336
+ const VALUE_FLAGS = { '--technique': 'technique', '--file': 'file', '--task-output': 'taskOutput' };
337
+
235
338
  function parseArgs(argv) {
236
- const opts = { technique: null, file: null };
339
+ const opts = { technique: null, file: null, taskOutput: null };
237
340
  for (let i = 0; i < argv.length; i++) {
238
341
  const a = argv[i];
239
- if (a === '--technique' || a === '--file') {
342
+ if (Object.prototype.hasOwnProperty.call(VALUE_FLAGS, a)) {
240
343
  const v = argv[i + 1];
241
344
  if (v === undefined || v === '' || v.startsWith('--')) throw new Error(`${a} needs a value`);
242
- opts[a === '--technique' ? 'technique' : 'file'] = v;
345
+ opts[VALUE_FLAGS[a]] = v;
243
346
  i++;
244
347
  } else {
245
348
  throw new Error(`unknown argument: ${a}`);
@@ -247,9 +350,18 @@ function parseArgs(argv) {
247
350
  }
248
351
  if (!opts.technique) throw new Error(`--technique <${Object.keys(TOKENS).join('|')}> is required`);
249
352
  if (!Object.prototype.hasOwnProperty.call(TOKENS, opts.technique)) throw new Error(`unknown technique: ${opts.technique}`);
353
+ if (opts.file !== null && opts.taskOutput !== null) throw new Error('--file and --task-output are mutually exclusive');
250
354
  return opts;
251
355
  }
252
356
 
357
+ function readReport(opts, readFile, readStdin) {
358
+ if (opts.file !== null) return readFile(opts.file);
359
+ if (opts.taskOutput === null) return readStdin();
360
+ const report = reportFromTaskOutput(readFile(opts.taskOutput));
361
+ if (report === null) throw new Error(`no assistant report in ${opts.taskOutput}`);
362
+ return report;
363
+ }
364
+
253
365
  function main(argv, io = {}) {
254
366
  const stdout = io.stdout || process.stdout;
255
367
  const stderr = io.stderr || process.stderr;
@@ -258,7 +370,7 @@ function main(argv, io = {}) {
258
370
  let result;
259
371
  try {
260
372
  const opts = parseArgs(argv);
261
- const text = opts.file !== null ? readFile(opts.file) : readStdin();
373
+ const text = readReport(opts, readFile, readStdin);
262
374
  result = checkReport(text, opts.technique);
263
375
  } catch (e) {
264
376
  stderr.write(`${JSON.stringify({ error: e.message })}\n`);
@@ -271,4 +383,4 @@ function main(argv, io = {}) {
271
383
  // exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
272
384
  if (require.main === module) process.exitCode = main(process.argv.slice(2));
273
385
 
274
- module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
386
+ module.exports = { checkReport, reportFromTaskOutput, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };