@marcomigozzi/trailhead 0.16.1 → 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 (76) hide show
  1. package/README.it.md +57 -10
  2. package/README.md +57 -10
  3. package/bin/capture-action.test.js +167 -0
  4. package/bin/capture-docs.test.js +32 -0
  5. package/bin/engine-route-docs.test.js +148 -0
  6. package/bin/grill-model-docs.test.js +155 -0
  7. package/bin/grilling-holistic-review-docs.test.js +7 -3
  8. package/bin/lib/codex-projection.js +22 -13
  9. package/bin/lib/codex-projection.test.js +58 -17
  10. package/bin/lib/host-descriptor.js +1 -1
  11. package/bin/lib/host-descriptor.test.js +6 -0
  12. package/bin/report-status-docs.test.js +10 -2
  13. package/bin/session-trust-docs.test.js +37 -0
  14. package/bin/surfaced-scope-docs.test.js +57 -0
  15. package/bin/trailhead.js +7 -5
  16. package/bin/trailhead.test.js +53 -11
  17. package/bin/unblock-docs.test.js +56 -0
  18. package/package.json +1 -1
  19. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  21. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  22. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  23. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  24. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  25. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  26. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  27. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  28. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  29. package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
  30. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  31. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  32. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  33. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  34. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  35. package/plugins/trailhead/hooks/mod/README.md +16 -7
  36. package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
  37. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  38. package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
  40. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  41. package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
  42. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  43. package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
  44. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  45. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  46. package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
  47. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  48. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  49. package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
  50. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  51. package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
  52. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
  53. package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
  54. package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
  55. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  56. package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
  57. package/plugins/trailhead/skills/_shared/techniques.md +6 -4
  58. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  59. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  60. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  61. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
  62. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
  63. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  64. package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
  65. package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
  67. package/plugins/trailhead/templates/trailhead-capture.js +418 -0
  68. package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
  69. package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
  70. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  71. package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
  72. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  73. package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
  74. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  75. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  76. 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
@@ -89,6 +90,19 @@ const CONTRACTS = Object.freeze({
89
90
  Object.freeze({ when: frozen(['needs_input']), field: 'questions', rule: 'non-empty' }),
90
91
  ]),
91
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
+ }),
92
106
  });
93
107
 
94
108
  // Technique -> field family.
@@ -99,6 +113,7 @@ const FAMILY_OF = Object.freeze({
99
113
  research: 'investigating',
100
114
  'codebase-map': 'investigating',
101
115
  plan: 'plan',
116
+ grill: 'grilling',
102
117
  });
103
118
 
104
119
  // Technique -> its closed set of status tokens.
@@ -296,14 +311,38 @@ function checkReport(text, technique) {
296
311
  return { action: 'normalise', verdict, problem: null, report: [...head, ...rest].join('\n'), fields, body };
297
312
  }
298
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
+
299
338
  function parseArgs(argv) {
300
- const opts = { technique: null, file: null };
339
+ const opts = { technique: null, file: null, taskOutput: null };
301
340
  for (let i = 0; i < argv.length; i++) {
302
341
  const a = argv[i];
303
- if (a === '--technique' || a === '--file') {
342
+ if (Object.prototype.hasOwnProperty.call(VALUE_FLAGS, a)) {
304
343
  const v = argv[i + 1];
305
344
  if (v === undefined || v === '' || v.startsWith('--')) throw new Error(`${a} needs a value`);
306
- opts[a === '--technique' ? 'technique' : 'file'] = v;
345
+ opts[VALUE_FLAGS[a]] = v;
307
346
  i++;
308
347
  } else {
309
348
  throw new Error(`unknown argument: ${a}`);
@@ -311,9 +350,18 @@ function parseArgs(argv) {
311
350
  }
312
351
  if (!opts.technique) throw new Error(`--technique <${Object.keys(TOKENS).join('|')}> is required`);
313
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');
314
354
  return opts;
315
355
  }
316
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
+
317
365
  function main(argv, io = {}) {
318
366
  const stdout = io.stdout || process.stdout;
319
367
  const stderr = io.stderr || process.stderr;
@@ -322,7 +370,7 @@ function main(argv, io = {}) {
322
370
  let result;
323
371
  try {
324
372
  const opts = parseArgs(argv);
325
- const text = opts.file !== null ? readFile(opts.file) : readStdin();
373
+ const text = readReport(opts, readFile, readStdin);
326
374
  result = checkReport(text, opts.technique);
327
375
  } catch (e) {
328
376
  stderr.write(`${JSON.stringify({ error: e.message })}\n`);
@@ -335,4 +383,4 @@ function main(argv, io = {}) {
335
383
  // exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
336
384
  if (require.main === module) process.exitCode = main(process.argv.slice(2));
337
385
 
338
- module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };
386
+ module.exports = { checkReport, reportFromTaskOutput, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF, VERIFY_CLASSES };