@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.
- package/README.it.md +57 -10
- package/README.md +57 -10
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/grill-model-docs.test.js +155 -0
- package/bin/grilling-holistic-review-docs.test.js +7 -3
- package/bin/lib/codex-projection.js +22 -13
- package/bin/lib/codex-projection.test.js +58 -17
- package/bin/lib/host-descriptor.js +1 -1
- package/bin/lib/host-descriptor.test.js +6 -0
- package/bin/report-status-docs.test.js +10 -2
- package/bin/session-trust-docs.test.js +37 -0
- package/bin/surfaced-scope-docs.test.js +57 -0
- package/bin/trailhead.js +7 -5
- package/bin/trailhead.test.js +53 -11
- package/bin/unblock-docs.test.js +56 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
- package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
- package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
- package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
- package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +84 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
- package/plugins/trailhead/hooks/lib/model-route.js +207 -0
- package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
- package/plugins/trailhead/hooks/mod/README.md +16 -7
- package/plugins/trailhead/hooks/mod/map-pane.tsx +72 -16
- package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
- package/plugins/trailhead/hooks/mod/repo-pin.ts +8 -5
- package/plugins/trailhead/hooks/trailhead-check-update.js +17 -1
- package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
- package/plugins/trailhead/skills/_shared/capture-core.md +118 -0
- package/plugins/trailhead/skills/_shared/choices.md +2 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +18 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +8 -4
- package/plugins/trailhead/skills/_shared/substrate.md +1 -1
- package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
- package/plugins/trailhead/skills/_shared/teamwork.md +6 -2
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +5 -2
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +26 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques.md +6 -4
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +4 -4
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +11 -9
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +10 -10
- package/plugins/trailhead/templates/trailhead-capture.js +418 -0
- package/plugins/trailhead/templates/trailhead-checkout-find.js +256 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +76 -20
- package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
- package/plugins/trailhead/templates/trailhead-issue-body.js +69 -6
- package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
- package/plugins/trailhead/tests/map-pane-ui.test.ts +122 -37
- package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
- 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:
|
|
8
|
-
// another checkout
|
|
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 <
|
|
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
|
-
//
|
|
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
|
-
|
|
28
|
-
|
|
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'
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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 <
|
|
134
|
+
process.stderr.write('usage: trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]\n');
|
|
80
135
|
return 2;
|
|
81
136
|
}
|
|
82
|
-
|
|
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 `-
|
|
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
|
-
|
|
127
|
+
const m = /^(?:[-*+]|(\d+)([.)]))[ \t]/.exec(lines[i]);
|
|
128
|
+
if (!m) continue;
|
|
125
129
|
let j = i + 1;
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
|
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 };
|