@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.
- package/README.it.md +67 -18
- package/README.md +67 -18
- package/bin/capture-action.test.js +167 -0
- package/bin/capture-docs.test.js +32 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/engine-route-docs.test.js +148 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -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 +30 -14
- package/bin/lib/codex-projection.test.js +81 -17
- package/bin/lib/host-descriptor.js +15 -2
- package/bin/lib/host-descriptor.test.js +24 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/report-status-docs.test.js +11 -3
- 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-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
- package/plugins/trailhead/agents/trailhead-grill.md +41 -0
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- 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 +72 -22
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +113 -0
- 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 +124 -2
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +22 -1
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +24 -10
- package/plugins/trailhead/hooks/mod/map-pane.tsx +124 -44
- 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/hooks/trailhead-secret-read-guard.js +40 -1
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +68 -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 +20 -18
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +2 -2
- 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 +9 -5
- 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 +4 -2
- package/plugins/trailhead/skills/_shared/techniques.md +7 -5
- 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 +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +3 -3
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +5 -4
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +13 -11
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +1 -1
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +11 -10
- package/plugins/trailhead/templates/statusline-trailhead.sh +3 -3
- 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 +123 -11
- package/plugins/trailhead/tests/map-pane-ui.test.ts +299 -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
|
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
|
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 };
|