@marcomigozzi/trailhead 0.14.0 → 0.16.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 +158 -34
- package/README.md +147 -33
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +6 -6
- package/bin/lib/codex-projection.test.js +43 -2
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +6 -6
- package/bin/trailhead.test.js +355 -16
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +209 -36
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
- package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
- package/plugins/trailhead/hooks/mod/register.tsx +312 -40
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
- package/plugins/trailhead/skills/_shared/choices.md +11 -4
- package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
- package/plugins/trailhead/skills/_shared/substrate.md +9 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
- package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
- package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- package/plugins/trailhead/types/index.d.ts +62 -3
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead plan review rounds (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm and Codex).
|
|
5
|
+
// Backs the autonomous run's "External review rounds" start-up ask and the per-round
|
|
6
|
+
// convergence decision of Cross-AI plan review. #284.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-plan-review-rounds.js ask --repo <project-root> [--host claude|codex]
|
|
10
|
+
// node trailhead-plan-review-rounds.js round --choice <config|1|2|3|until> --configured <n> --history <file>
|
|
11
|
+
// Prints one JSON line on stdout. Exit 0 ok / 1 error / 2 usage (message on stderr).
|
|
12
|
+
//
|
|
13
|
+
// Config: `plan_review` (off | on | comma list of CLIs, default off) and `plan_review.rounds`
|
|
14
|
+
// (positive integer, default 2), flat keys. The project file (<project-root>/.trailhead/config.json)
|
|
15
|
+
// and the global one (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) are read key by key,
|
|
16
|
+
// nearest wins. A non-positive or non-integer rounds value falls back to 2 with a warning.
|
|
17
|
+
//
|
|
18
|
+
// ask: resolves the candidate CLIs (`on` = the known roster, a list = only those), keeps the ones
|
|
19
|
+
// whose binary is on PATH (kimi only when `kimi --help` mentions --output-format) and drops the
|
|
20
|
+
// host's own CLI (claude by default, codex with --host codex). A name in an explicit list that is
|
|
21
|
+
// not in the roster (a custom reviewer) counts only when a binary of that name is on PATH; else it
|
|
22
|
+
// is listed under `unknown` with a warning and does not count.
|
|
23
|
+
// {"status":"skip","reason":"off"|"no-reviewer","configured":n,"unknown":[],"warnings":[]}
|
|
24
|
+
// {"status":"ask","configured":n,"reviewers":[],"unknown":[],"warnings":[],"options":[],"fallback":[]}
|
|
25
|
+
// {"status":"error","reason":<text>} (unreadable or invalid config; exit 1)
|
|
26
|
+
// `options` is the AskUserQuestion menu, at most four entries (the tool's cap); each entry is
|
|
27
|
+
// {"value":"config"|"1"|"2"|"3"|"until","label":<text>,"recommended"?:true}. With n in 1..3 it is
|
|
28
|
+
// config (Recommended), the other two counts, until; otherwise config, 1, 2, until (3 stays
|
|
29
|
+
// reachable through the typed Other answer). `fallback` is the numbered list for a host without a
|
|
30
|
+
// structured question tool: config, 1, 2, 3, until (same entry shape, all five).
|
|
31
|
+
//
|
|
32
|
+
// round: --history is a JSON array, one entry per review round done so far, each an array of the
|
|
33
|
+
// blocking concerns still open after that round; a concern is {"key","text"}: `key` a short stable
|
|
34
|
+
// id reused across rounds (compared trimmed, lower-cased, whitespace collapsed), `text` the full
|
|
35
|
+
// human-readable concern. A malformed history is a usage error (exit 2).
|
|
36
|
+
// {"status":"ok","decision":<d>,"rounds":<rounds done>,"surviving"?:[{key,text}]}
|
|
37
|
+
// decision, first match wins: converged (latest round has no blocking concern) | no-progress
|
|
38
|
+
// (choice until, round 2 or later: the latest round resolved none of the previous round's
|
|
39
|
+
// concerns) | ceiling (choice until: 5 rounds done) | exhausted (fixed choice: its rounds are
|
|
40
|
+
// spent) | continue. Every stop but converged carries `surviving`, the latest round's concerns.
|
|
41
|
+
// `config` resolves to --configured.
|
|
42
|
+
|
|
43
|
+
const fs = require('fs');
|
|
44
|
+
const os = require('os');
|
|
45
|
+
const path = require('path');
|
|
46
|
+
const { spawnSync } = require('child_process');
|
|
47
|
+
|
|
48
|
+
const CONFIG_REL = path.join('.trailhead', 'config.json');
|
|
49
|
+
const ROSTER = ['claude', 'gemini', 'codex', 'qwen', 'kimi', 'agy', 'opencode', 'cursor-agent', 'coderabbit'];
|
|
50
|
+
const DEFAULT_ROUNDS = 2;
|
|
51
|
+
const CEILING = 5;
|
|
52
|
+
const HOSTS = ['claude', 'codex'];
|
|
53
|
+
const CHOICES = ['config', '1', '2', '3', 'until'];
|
|
54
|
+
const VALUED = { ask: ['repo', 'host'], round: ['choice', 'configured', 'history'] };
|
|
55
|
+
|
|
56
|
+
class Usage extends Error {}
|
|
57
|
+
|
|
58
|
+
function parseArgs(argv) {
|
|
59
|
+
const command = argv[0];
|
|
60
|
+
if (!Object.keys(VALUED).includes(command)) return { error: `usage: first argument must be one of ${Object.keys(VALUED).join(', ')}` };
|
|
61
|
+
const out = { command };
|
|
62
|
+
for (let i = 1; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
const name = a.startsWith('--') ? a.slice(2) : null;
|
|
65
|
+
if (!name || !VALUED[command].includes(name)) return { error: `usage: unknown argument ${a}` };
|
|
66
|
+
const v = argv[++i];
|
|
67
|
+
if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
|
|
68
|
+
out[name] = v;
|
|
69
|
+
}
|
|
70
|
+
if (command === 'ask') {
|
|
71
|
+
if (!out.repo) return { error: 'usage: missing --repo <project-root>' };
|
|
72
|
+
if (out.host !== undefined && !HOSTS.includes(out.host)) return { error: `usage: --host must be one of ${HOSTS.join(', ')}` };
|
|
73
|
+
} else {
|
|
74
|
+
if (!out.choice || !CHOICES.includes(out.choice)) return { error: `usage: --choice must be one of ${CHOICES.join(', ')}` };
|
|
75
|
+
if (!out.configured || !/^[1-9]\d*$/.test(out.configured)) return { error: 'usage: --configured needs a positive integer' };
|
|
76
|
+
if (!out.history) return { error: 'usage: missing --history <file>' };
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
82
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
83
|
+
|
|
84
|
+
// Reads one config file. { cfg } (null when absent) or { error }.
|
|
85
|
+
function readFile(file, fs_) {
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err && err.code === 'ENOENT') return { cfg: null };
|
|
91
|
+
return { error: `cannot read ${file}: ${err && err.message ? err.message : err}` };
|
|
92
|
+
}
|
|
93
|
+
let cfg;
|
|
94
|
+
try {
|
|
95
|
+
cfg = JSON.parse(text);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { error: `${file} is not valid JSON` };
|
|
98
|
+
}
|
|
99
|
+
if (!isObject(cfg)) return { error: `${file} is not a JSON object` };
|
|
100
|
+
return { cfg };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function globalConfigPath(env) {
|
|
104
|
+
const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
|
|
105
|
+
return path.join(base, 'trailhead', 'config.json');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Effective { planReview, rounds } (undefined when unset) or { error }. Nearest file wins per key.
|
|
109
|
+
function readConfig(root, env, fs_) {
|
|
110
|
+
const files = [path.join(root, CONFIG_REL), globalConfigPath(env)];
|
|
111
|
+
const parsed = [];
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
const r = readFile(file, fs_);
|
|
114
|
+
if (r.error) return { error: r.error };
|
|
115
|
+
if (r.cfg) parsed.push({ file, cfg: r.cfg });
|
|
116
|
+
}
|
|
117
|
+
const out = {};
|
|
118
|
+
for (const [key, prop] of [['plan_review', 'planReview'], ['plan_review.rounds', 'rounds']]) {
|
|
119
|
+
const hit = parsed.find(({ cfg }) => has(cfg, key));
|
|
120
|
+
if (hit) out[prop] = { value: hit.cfg[key], file: hit.file };
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function resolveRounds(entry, warnings) {
|
|
126
|
+
if (!entry) return DEFAULT_ROUNDS;
|
|
127
|
+
const v = entry.value;
|
|
128
|
+
if (Number.isInteger(v) && v > 0) return v;
|
|
129
|
+
warnings.push(`plan_review.rounds in ${entry.file} must be a positive integer; using ${DEFAULT_ROUNDS}`);
|
|
130
|
+
return DEFAULT_ROUNDS;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Pure PATH scan: an executable regular file named `name` in some PATH directory.
|
|
134
|
+
function pathLookup(env, fs_) {
|
|
135
|
+
const dirs = String(env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
136
|
+
return (name) => dirs.some((dir) => {
|
|
137
|
+
const file = path.join(dir, name);
|
|
138
|
+
try {
|
|
139
|
+
return fs_.statSync(file).isFile() && (fs_.accessSync(file, fs_.constants.X_OK), true);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// `kimi --help` output, or null when it cannot run.
|
|
147
|
+
function runKimiHelp() {
|
|
148
|
+
const r = spawnSync('kimi', ['--help'], { encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
149
|
+
if (r.error || r.status !== 0) return null;
|
|
150
|
+
return `${r.stdout || ''}${r.stderr || ''}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const entry = (value, label, recommended) => ({ value, label, ...(recommended ? { recommended: true } : {}) });
|
|
154
|
+
const countLabel = (n) => `${n} round${n === 1 ? '' : 's'}`;
|
|
155
|
+
|
|
156
|
+
function buildMenus(n) {
|
|
157
|
+
const config = entry('config', `As in config (${n})`, true);
|
|
158
|
+
const until = entry('until', 'Until convergence (stop on a round with no progress, ceiling 5)');
|
|
159
|
+
const count = (k) => entry(String(k), countLabel(k));
|
|
160
|
+
const options = n >= 1 && n <= 3
|
|
161
|
+
? [config, ...[1, 2, 3].filter((k) => k !== n).map(count), until]
|
|
162
|
+
: [config, count(1), count(2), until];
|
|
163
|
+
const fallback = [config, count(1), count(2), count(3), until];
|
|
164
|
+
return { options, fallback };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function runAsk(parsed, cwd, env, fs_, deps) {
|
|
168
|
+
const cfg = readConfig(path.resolve(cwd, parsed.repo), env, fs_);
|
|
169
|
+
if (cfg.error) return { code: 1, result: { status: 'error', reason: cfg.error } };
|
|
170
|
+
const warnings = [];
|
|
171
|
+
const configured = resolveRounds(cfg.rounds, warnings);
|
|
172
|
+
const base = { configured, unknown: [], warnings };
|
|
173
|
+
|
|
174
|
+
const raw = cfg.planReview ? cfg.planReview.value : 'off';
|
|
175
|
+
if (typeof raw !== 'string') return { code: 1, result: { status: 'error', reason: `plan_review in ${cfg.planReview.file} must be a string (off, on or a comma list)` } };
|
|
176
|
+
const mode = raw.trim().toLowerCase();
|
|
177
|
+
if (mode === 'off') return { code: 0, result: { status: 'skip', reason: 'off', ...base } };
|
|
178
|
+
|
|
179
|
+
const explicit = mode !== 'on';
|
|
180
|
+
const names = explicit ? [...new Set(raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean))] : ROSTER;
|
|
181
|
+
if (names.length === 0) return { code: 1, result: { status: 'error', reason: `plan_review in ${cfg.planReview.file} names no CLI` } };
|
|
182
|
+
|
|
183
|
+
const which = deps.which || pathLookup(env, fs_);
|
|
184
|
+
const kimiHelp = deps.kimiHelp || runKimiHelp;
|
|
185
|
+
const host = parsed.host || 'claude';
|
|
186
|
+
const reviewers = [];
|
|
187
|
+
const unknown = [];
|
|
188
|
+
for (const name of names) {
|
|
189
|
+
if (name === host) continue;
|
|
190
|
+
const installed = which(name);
|
|
191
|
+
if (!ROSTER.includes(name)) {
|
|
192
|
+
if (installed) reviewers.push(name);
|
|
193
|
+
else {
|
|
194
|
+
unknown.push(name);
|
|
195
|
+
warnings.push(`plan_review names "${name}", which is not a known CLI and has no binary on PATH; it does not count (typo?)`);
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!installed) continue;
|
|
200
|
+
if (name === 'kimi') {
|
|
201
|
+
const help = kimiHelp();
|
|
202
|
+
if (typeof help !== 'string' || !help.includes('--output-format')) continue;
|
|
203
|
+
}
|
|
204
|
+
reviewers.push(name);
|
|
205
|
+
}
|
|
206
|
+
if (reviewers.length === 0) return { code: 0, result: { status: 'skip', reason: 'no-reviewer', ...base, unknown } };
|
|
207
|
+
return { code: 0, result: { status: 'ask', configured, reviewers, unknown, warnings, ...buildMenus(configured) } };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const norm = (key) => key.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
211
|
+
const filled = (v) => typeof v === 'string' && v.trim() !== '';
|
|
212
|
+
|
|
213
|
+
// Validates the history; throws Usage when malformed.
|
|
214
|
+
function validateHistory(h) {
|
|
215
|
+
if (!Array.isArray(h) || h.length === 0) throw new Usage('usage: history must be a non-empty JSON array, one entry per round');
|
|
216
|
+
h.forEach((round, i) => {
|
|
217
|
+
if (!Array.isArray(round)) throw new Usage(`usage: history[${i}] must be an array of concerns`);
|
|
218
|
+
round.forEach((c, j) => {
|
|
219
|
+
if (!isObject(c) || !filled(c.key) || !filled(c.text)) throw new Usage(`usage: history[${i}][${j}] must be {"key","text"} with non-empty strings`);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function decide(history, choice, configured) {
|
|
225
|
+
const rounds = history.length;
|
|
226
|
+
const latest = history[rounds - 1];
|
|
227
|
+
if (latest.length === 0) return { status: 'ok', decision: 'converged', rounds };
|
|
228
|
+
const stop = (decision) => ({ status: 'ok', decision, rounds, surviving: latest.map((c) => ({ key: c.key, text: c.text })) });
|
|
229
|
+
if (choice === 'until') {
|
|
230
|
+
const prev = rounds >= 2 ? history[rounds - 2] : [];
|
|
231
|
+
const latestKeys = new Set(latest.map((c) => norm(c.key)));
|
|
232
|
+
if (prev.length > 0 && prev.every((c) => latestKeys.has(norm(c.key)))) return stop('no-progress');
|
|
233
|
+
if (rounds >= CEILING) return stop('ceiling');
|
|
234
|
+
return { status: 'ok', decision: 'continue', rounds };
|
|
235
|
+
}
|
|
236
|
+
const limit = choice === 'config' ? configured : Number(choice);
|
|
237
|
+
if (rounds >= limit) return stop('exhausted');
|
|
238
|
+
return { status: 'ok', decision: 'continue', rounds };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runRound(parsed, cwd, fs_) {
|
|
242
|
+
const file = path.resolve(cwd, parsed.history);
|
|
243
|
+
let text;
|
|
244
|
+
try {
|
|
245
|
+
text = fs_.readFileSync(file, 'utf8');
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { code: 1, result: { status: 'error', reason: `cannot read ${file}: ${err && err.message ? err.message : err}` } };
|
|
248
|
+
}
|
|
249
|
+
let history;
|
|
250
|
+
try {
|
|
251
|
+
history = JSON.parse(text);
|
|
252
|
+
} catch (err) {
|
|
253
|
+
throw new Usage(`usage: ${file} is not valid JSON`);
|
|
254
|
+
}
|
|
255
|
+
validateHistory(history);
|
|
256
|
+
return { code: 0, result: decide(history, parsed.choice, Number(parsed.configured)) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function main(argv, { cwd, stdout, stderr, env, fs: fsMod, which, kimiHelp } = {}) {
|
|
260
|
+
const cwd_ = cwd || process.cwd();
|
|
261
|
+
const out = stdout || process.stdout;
|
|
262
|
+
const err = stderr || process.stderr;
|
|
263
|
+
const env_ = env || process.env;
|
|
264
|
+
const fs_ = fsMod || fs;
|
|
265
|
+
|
|
266
|
+
const parsed = parseArgs(argv || []);
|
|
267
|
+
if (parsed.error) {
|
|
268
|
+
err.write(parsed.error + '\n');
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
let run;
|
|
272
|
+
try {
|
|
273
|
+
run = parsed.command === 'ask' ? runAsk(parsed, cwd_, env_, fs_, { which, kimiHelp }) : runRound(parsed, cwd_, fs_);
|
|
274
|
+
} catch (e) {
|
|
275
|
+
if (e instanceof Usage) {
|
|
276
|
+
err.write(e.message + '\n');
|
|
277
|
+
return 2;
|
|
278
|
+
}
|
|
279
|
+
run = { code: 1, result: { status: 'error', reason: 'error: ' + (e && e.message ? e.message : String(e)) } };
|
|
280
|
+
}
|
|
281
|
+
out.write(JSON.stringify(run.result) + '\n');
|
|
282
|
+
return run.code;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
module.exports = { parseArgs, readConfig, decide, main };
|
|
286
|
+
|
|
287
|
+
if (require.main === module) {
|
|
288
|
+
process.exit(main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr, env: process.env }));
|
|
289
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead report status check (self-contained, ships in templates/ so it reaches
|
|
4
|
+
// every install channel). Checks the leading `status:` line of an engine-agent
|
|
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
|
|
7
|
+
// report's fields.
|
|
8
|
+
//
|
|
9
|
+
// CLI:
|
|
10
|
+
// node trailhead-report-status.js --technique <technique> [--file <path>]
|
|
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.
|
|
13
|
+
// Prints exactly one JSON line {"action","verdict","problem","report","fields","body"}:
|
|
14
|
+
// action accept | normalise | reask
|
|
15
|
+
// verdict the canonical token, or null on reask
|
|
16
|
+
// problem null | missing | unknown-token | conflicting | counts | fields (null iff not reask)
|
|
17
|
+
// report the original text on accept and reask; the normalised text on normalise
|
|
18
|
+
// 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
|
+
// body plan with `ready` only: the normalised report without its status line, the
|
|
21
|
+
// PLAN to post verbatim; null otherwise
|
|
22
|
+
// Exit 0 for all three actions; exit 2 on a usage error (missing or unknown
|
|
23
|
+
// technique, unknown flag, missing or unreadable file) with {"error": "..."} on
|
|
24
|
+
// stderr and nothing on stdout.
|
|
25
|
+
//
|
|
26
|
+
// The rules, in short: a candidate status line is outside code fences and
|
|
27
|
+
// blockquotes and not indented as code; after stripping a heading, list bullet and
|
|
28
|
+
// bold/underscore/backtick decoration it reads `status: <token>` where the token,
|
|
29
|
+
// lower-cased with spaces and hyphens as underscores, is exactly one of the
|
|
30
|
+
// technique's closed set. One valid token in the exact layout (status on line 1;
|
|
31
|
+
// for code-review also the counts line on line 2) is accepted; the same token in
|
|
32
|
+
// any other layout is normalised; anything else is a reask, never a guess.
|
|
33
|
+
// Fields: a field starts at a top-level line (same rules as a status line) reading
|
|
34
|
+
// `<label>:` after the same stripping; its value is the rest of that line plus every
|
|
35
|
+
// following line up to the next field label or the end. Labels are a closed list per
|
|
36
|
+
// family, matched case-insensitively; an empty value or `none` means none. A required
|
|
37
|
+
// field missing, a label given twice or a broken invariant is the `fields` problem.
|
|
38
|
+
// Problem precedence on mixed defects: missing > conflicting > unknown-token > counts > fields.
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
|
|
42
|
+
const frozen = (a) => Object.freeze([...a]);
|
|
43
|
+
|
|
44
|
+
// Hard-coded here; report-status.test.js asserts these equal the tokens parsed
|
|
45
|
+
// from skills/_shared/techniques/{verify,code-review}.md, which stay the source.
|
|
46
|
+
const REVIEW_TOKENS = Object.freeze({
|
|
47
|
+
verify: frozen(['met', 'gaps_found', 'inconclusive']),
|
|
48
|
+
'code-review': frozen(['clean', 'issues_found']),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// The return contract of each field family. Hard-coded here (the checker ships
|
|
52
|
+
// self-contained); report-status.test.js asserts these equal what scripts/agent-blocks.md
|
|
53
|
+
// states. An invariant says: for these statuses the field (or `body`, the report without
|
|
54
|
+
// its status line) is `none` / `non-empty`.
|
|
55
|
+
const CONTRACTS = Object.freeze({
|
|
56
|
+
implementing: Object.freeze({
|
|
57
|
+
tokens: frozen(['done', 'partial', 'blocked']),
|
|
58
|
+
required: frozen(['commits', 'tests', 'deviations', 'blockers']),
|
|
59
|
+
optional: frozen(['fidelity', 'surfaced scope']),
|
|
60
|
+
invariants: Object.freeze([
|
|
61
|
+
Object.freeze({ when: frozen(['done']), field: 'blockers', rule: 'none' }),
|
|
62
|
+
Object.freeze({ when: frozen(['partial', 'blocked']), field: 'blockers', rule: 'non-empty' }),
|
|
63
|
+
]),
|
|
64
|
+
}),
|
|
65
|
+
investigating: Object.freeze({
|
|
66
|
+
tokens: frozen(['found', 'inconclusive', 'needs_input']),
|
|
67
|
+
required: frozen(['answer', 'evidence', 'open questions']),
|
|
68
|
+
optional: frozen([]),
|
|
69
|
+
invariants: Object.freeze([
|
|
70
|
+
Object.freeze({ when: frozen(['needs_input']), field: 'open questions', rule: 'non-empty' }),
|
|
71
|
+
]),
|
|
72
|
+
}),
|
|
73
|
+
plan: Object.freeze({
|
|
74
|
+
tokens: frozen(['ready', 'needs_input']),
|
|
75
|
+
required: frozen([]),
|
|
76
|
+
optional: frozen(['questions']),
|
|
77
|
+
invariants: Object.freeze([
|
|
78
|
+
Object.freeze({ when: frozen(['ready']), field: 'body', rule: 'non-empty' }),
|
|
79
|
+
Object.freeze({ when: frozen(['needs_input']), field: 'questions', rule: 'non-empty' }),
|
|
80
|
+
]),
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Technique -> field family.
|
|
85
|
+
const FAMILY_OF = Object.freeze({
|
|
86
|
+
executor: 'implementing',
|
|
87
|
+
fix: 'implementing',
|
|
88
|
+
debug: 'investigating',
|
|
89
|
+
research: 'investigating',
|
|
90
|
+
'codebase-map': 'investigating',
|
|
91
|
+
plan: 'plan',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Technique -> its closed set of status tokens.
|
|
95
|
+
const TOKENS = Object.freeze({
|
|
96
|
+
...REVIEW_TOKENS,
|
|
97
|
+
...Object.fromEntries(Object.entries(FAMILY_OF).map(([t, f]) => [t, CONTRACTS[f].tokens])),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const COUNTS_RE = /^critical\s*:\s*(\d+)\s*·\s*warning\s*:\s*(\d+)\s*·\s*info\s*:\s*(\d+)$/i;
|
|
101
|
+
const BOM = '';
|
|
102
|
+
|
|
103
|
+
// Heading marks, a list bullet and bold/underscore/backtick decoration removed.
|
|
104
|
+
function strip(line) {
|
|
105
|
+
return line
|
|
106
|
+
.trim()
|
|
107
|
+
.replace(/^#{1,6}\s+/, '')
|
|
108
|
+
.replace(/^[-*+]\s+/, '')
|
|
109
|
+
.replace(/\*\*|__|`/g, '')
|
|
110
|
+
.trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Index set of the lines that may carry a status or counts line: outside fenced
|
|
114
|
+
// code blocks, outside blockquotes, not indented as code (4+ spaces or a tab).
|
|
115
|
+
function candidateLines(lines) {
|
|
116
|
+
const out = [];
|
|
117
|
+
let fence = null; // { char, len } while inside a fenced block
|
|
118
|
+
lines.forEach((line, i) => {
|
|
119
|
+
const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
120
|
+
if (fence) {
|
|
121
|
+
if (m && m[1][0] === fence.char && m[1].length >= fence.len && m[2].trim() === '') fence = null;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (m) {
|
|
125
|
+
fence = { char: m[1][0], len: m[1].length };
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (/^(?: {4,}|\t)/.test(line)) return;
|
|
129
|
+
if (/^ {0,3}>/.test(line)) return;
|
|
130
|
+
out.push(i);
|
|
131
|
+
});
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function statusOf(line) {
|
|
136
|
+
const s = strip(line);
|
|
137
|
+
const m = /^status\s*:(.*)$/i.exec(s);
|
|
138
|
+
if (!m) return null;
|
|
139
|
+
return m[1].trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function countsOf(line) {
|
|
143
|
+
const m = COUNTS_RE.exec(strip(line));
|
|
144
|
+
return m ? `critical: ${m[1]} · warning: ${m[2]} · info: ${m[3]}` : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const reask = (text, problem) => ({ action: 'reask', verdict: null, problem, report: text, fields: null, body: null });
|
|
148
|
+
|
|
149
|
+
const isNone = (v) => v === undefined || /^(none\.?)?$/i.test(v.trim());
|
|
150
|
+
|
|
151
|
+
// The fields of a report: label -> trimmed value, over the top-level lines that are not
|
|
152
|
+
// status lines. Returns { fields, duplicate }.
|
|
153
|
+
function parseFields(lines, cands, skip, contract) {
|
|
154
|
+
const allowed = new Set([...contract.required, ...contract.optional]);
|
|
155
|
+
const hits = [];
|
|
156
|
+
for (const i of cands) {
|
|
157
|
+
if (skip.has(i)) continue;
|
|
158
|
+
const m = /^([A-Za-z][A-Za-z ]*?)\s*:(.*)$/.exec(strip(lines[i]));
|
|
159
|
+
const label = m && m[1].replace(/\s+/g, ' ').toLowerCase();
|
|
160
|
+
if (label && allowed.has(label)) hits.push({ i, label, first: m[2].trim() });
|
|
161
|
+
}
|
|
162
|
+
const fields = {};
|
|
163
|
+
let duplicate = false;
|
|
164
|
+
hits.forEach((h, k) => {
|
|
165
|
+
const end = k + 1 < hits.length ? hits[k + 1].i : lines.length;
|
|
166
|
+
const tail = [];
|
|
167
|
+
for (let j = h.i + 1; j < end; j++) if (!skip.has(j)) tail.push(lines[j]);
|
|
168
|
+
if (Object.prototype.hasOwnProperty.call(fields, h.label)) duplicate = true;
|
|
169
|
+
else fields[h.label] = [h.first, ...tail].join('\n').trim();
|
|
170
|
+
});
|
|
171
|
+
return { fields, duplicate };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The fields problem for a verdict, or null when the contract holds.
|
|
175
|
+
function fieldsProblem(contract, verdict, fields, duplicate, body) {
|
|
176
|
+
if (duplicate) return true;
|
|
177
|
+
if (contract.required.some((f) => !Object.prototype.hasOwnProperty.call(fields, f))) return true;
|
|
178
|
+
return contract.invariants.some((inv) => {
|
|
179
|
+
if (!inv.when.includes(verdict)) return false;
|
|
180
|
+
const value = inv.field === 'body' ? body : fields[inv.field];
|
|
181
|
+
return inv.rule === 'none' ? !isNone(value) : isNone(value);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function checkReport(text, technique) {
|
|
186
|
+
const tokens = Object.prototype.hasOwnProperty.call(TOKENS, technique) ? TOKENS[technique] : null;
|
|
187
|
+
if (!tokens) throw new Error(`unknown technique: ${technique}`);
|
|
188
|
+
const original = typeof text === 'string' ? text : '';
|
|
189
|
+
const hasBom = original.startsWith(BOM);
|
|
190
|
+
const lines = (hasBom ? original.slice(1) : original).replace(/\r\n?/g, '\n').split('\n');
|
|
191
|
+
const cleanLayout = !hasBom && !original.includes('\r');
|
|
192
|
+
|
|
193
|
+
const cands = candidateLines(lines);
|
|
194
|
+
const statusLines = cands.map((i) => ({ i, token: statusOf(lines[i]) })).filter((c) => c.token !== null);
|
|
195
|
+
if (statusLines.length === 0) return reask(original, 'missing');
|
|
196
|
+
|
|
197
|
+
const valid = new Set(statusLines.filter((c) => tokens.includes(c.token)).map((c) => c.token));
|
|
198
|
+
const anyInvalid = statusLines.some((c) => !tokens.includes(c.token));
|
|
199
|
+
if (valid.size > 1 || (valid.size === 1 && anyInvalid)) return reask(original, 'conflicting');
|
|
200
|
+
if (valid.size === 0) return reask(original, 'unknown-token');
|
|
201
|
+
const verdict = [...valid][0];
|
|
202
|
+
|
|
203
|
+
let counts = [];
|
|
204
|
+
if (technique === 'code-review') {
|
|
205
|
+
counts = cands.map((i) => ({ i, text: countsOf(lines[i]) })).filter((c) => c.text !== null);
|
|
206
|
+
if (counts.length !== 1) return reask(original, 'counts');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Blank lines that led the original are not content: drop them with the status lines.
|
|
210
|
+
const lead = lines.findIndex((l) => l.trim() !== '');
|
|
211
|
+
const drop = new Set([...statusLines, ...counts].map((c) => c.i));
|
|
212
|
+
const rest = lines.filter((_, i) => !drop.has(i) && i >= lead);
|
|
213
|
+
|
|
214
|
+
let fields = {};
|
|
215
|
+
let body = null;
|
|
216
|
+
const family = Object.prototype.hasOwnProperty.call(FAMILY_OF, technique) ? FAMILY_OF[technique] : null;
|
|
217
|
+
if (family) {
|
|
218
|
+
const contract = CONTRACTS[family];
|
|
219
|
+
const statusIdx = new Set(statusLines.map((c) => c.i));
|
|
220
|
+
const parsed = parseFields(lines, cands, statusIdx, contract);
|
|
221
|
+
const text = rest.join('\n').replace(/^(?:[ \t]*\n)+/, '').replace(/\s+$/, '');
|
|
222
|
+
if (fieldsProblem(contract, verdict, parsed.fields, parsed.duplicate, text)) return reask(original, 'fields');
|
|
223
|
+
fields = parsed.fields;
|
|
224
|
+
if (family === 'plan' && verdict === 'ready') body = text;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const exact = cleanLayout && statusLines.length === 1 && statusLines[0].i === 0 && lines[0] === `status: ${verdict}`
|
|
228
|
+
&& (counts.length === 0 || (counts[0].i === 1 && lines[1] === counts[0].text));
|
|
229
|
+
if (exact) return { action: 'accept', verdict, problem: null, report: original, fields, body };
|
|
230
|
+
|
|
231
|
+
const head = [`status: ${verdict}`, ...counts.map((c) => c.text)];
|
|
232
|
+
return { action: 'normalise', verdict, problem: null, report: [...head, ...rest].join('\n'), fields, body };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseArgs(argv) {
|
|
236
|
+
const opts = { technique: null, file: null };
|
|
237
|
+
for (let i = 0; i < argv.length; i++) {
|
|
238
|
+
const a = argv[i];
|
|
239
|
+
if (a === '--technique' || a === '--file') {
|
|
240
|
+
const v = argv[i + 1];
|
|
241
|
+
if (v === undefined || v === '' || v.startsWith('--')) throw new Error(`${a} needs a value`);
|
|
242
|
+
opts[a === '--technique' ? 'technique' : 'file'] = v;
|
|
243
|
+
i++;
|
|
244
|
+
} else {
|
|
245
|
+
throw new Error(`unknown argument: ${a}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (!opts.technique) throw new Error(`--technique <${Object.keys(TOKENS).join('|')}> is required`);
|
|
249
|
+
if (!Object.prototype.hasOwnProperty.call(TOKENS, opts.technique)) throw new Error(`unknown technique: ${opts.technique}`);
|
|
250
|
+
return opts;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function main(argv, io = {}) {
|
|
254
|
+
const stdout = io.stdout || process.stdout;
|
|
255
|
+
const stderr = io.stderr || process.stderr;
|
|
256
|
+
const readFile = io.readFile || ((p) => fs.readFileSync(p, 'utf8'));
|
|
257
|
+
const readStdin = io.readStdin || (() => fs.readFileSync(0, 'utf8'));
|
|
258
|
+
let result;
|
|
259
|
+
try {
|
|
260
|
+
const opts = parseArgs(argv);
|
|
261
|
+
const text = opts.file !== null ? readFile(opts.file) : readStdin();
|
|
262
|
+
result = checkReport(text, opts.technique);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
stderr.write(`${JSON.stringify({ error: e.message })}\n`);
|
|
265
|
+
return 2;
|
|
266
|
+
}
|
|
267
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// exitCode, not process.exit(): exiting at once truncates piped stdout at 64 KB.
|
|
272
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
273
|
+
|
|
274
|
+
module.exports = { checkReport, parseArgs, main, TOKENS, CONTRACTS, FAMILY_OF };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
// trailhead UAT backfill (self-contained, ships in templates/ so it reaches every
|
|
4
|
+
// install channel). One-time, idempotent migration: tags every open ticket whose
|
|
5
|
+
// body has a `UAT of:` line with the `trailhead:uat` label, so a pending UAT
|
|
6
|
+
// follow-up opened before the label existed is still findable. #261.
|
|
7
|
+
//
|
|
8
|
+
// CLI:
|
|
9
|
+
// node trailhead-uat-backfill.js [--repo <owner/name>] [--marker <path>]
|
|
10
|
+
// --marker makes the run once per clone: a present marker exits `skipped` with no
|
|
11
|
+
// `gh` call; the helper writes it only on a clean run (label confirmed, list read
|
|
12
|
+
// ok, no failed edit).
|
|
13
|
+
//
|
|
14
|
+
// Prints exactly one JSON line on stdout:
|
|
15
|
+
// {"status":"ok"|"skipped"|"error","tagged":[n,...],"failed":[n,...],"reason":"..."}
|
|
16
|
+
// Exit 0 on ok or skipped, 1 on an error, 2 on a usage error (stderr).
|
|
17
|
+
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { spawnSync } = require('child_process');
|
|
21
|
+
|
|
22
|
+
const LABEL = 'trailhead:uat';
|
|
23
|
+
const COLOR = 'BFD4F2';
|
|
24
|
+
const DESCRIPTION = 'Pending UAT: an acceptance test still to run';
|
|
25
|
+
const LIMIT = 1000;
|
|
26
|
+
// A line that starts with `UAT of:` (a mid-line mention is not a pointer).
|
|
27
|
+
const UAT_LINE_RE = /^UAT of:/m;
|
|
28
|
+
|
|
29
|
+
function defaultGh(args) {
|
|
30
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
31
|
+
return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
|
|
35
|
+
|
|
36
|
+
function parseArgs(argv) {
|
|
37
|
+
const o = { repo: null, marker: null };
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
const flag = argv[i];
|
|
40
|
+
if (flag !== '--repo' && flag !== '--marker') return { error: `usage: unknown argument: ${flag}` };
|
|
41
|
+
i++;
|
|
42
|
+
if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
|
|
43
|
+
const v = argv[i];
|
|
44
|
+
if (flag === '--repo') {
|
|
45
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
|
|
46
|
+
o.repo = v;
|
|
47
|
+
} else {
|
|
48
|
+
o.marker = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return o;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
|
|
55
|
+
|
|
56
|
+
// Ensures the label exists: idempotent create, then a confirming read. A create
|
|
57
|
+
// failure is only "already exists" when the label is actually there.
|
|
58
|
+
function ensureLabel(gh, rArgs) {
|
|
59
|
+
gh(['label', 'create', LABEL, '--color', COLOR, '--description', DESCRIPTION, ...rArgs]);
|
|
60
|
+
const r = gh(['label', 'list', '--search', LABEL, '--json', 'name', ...rArgs]);
|
|
61
|
+
if (r.status !== 0) return `cannot confirm the ${LABEL} label: ${firstLine(r.stderr) || 'exit ' + r.status}`;
|
|
62
|
+
let list;
|
|
63
|
+
try { list = JSON.parse(r.stdout); } catch { return `cannot confirm the ${LABEL} label: unparseable label list`; }
|
|
64
|
+
return Array.isArray(list) && names(list).includes(LABEL) ? null : `the ${LABEL} label does not exist and could not be created`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function main(argv, { gh, stdout, stderr } = {}) {
|
|
68
|
+
const out = stdout || process.stdout;
|
|
69
|
+
const err = stderr || process.stderr;
|
|
70
|
+
const o = parseArgs(argv || []);
|
|
71
|
+
if (o.error) { err.write(o.error + '\n'); return 2; }
|
|
72
|
+
const run = gh || defaultGh;
|
|
73
|
+
const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
|
|
74
|
+
const fail = (reason) => { emit({ status: 'error', tagged: [], failed: [], reason }); return 1; };
|
|
75
|
+
|
|
76
|
+
if (o.marker && fs.existsSync(o.marker)) { emit({ status: 'skipped', tagged: [], failed: [] }); return 0; }
|
|
77
|
+
|
|
78
|
+
const rArgs = o.repo ? ['-R', o.repo] : [];
|
|
79
|
+
const problem = ensureLabel(run, rArgs);
|
|
80
|
+
if (problem) return fail(problem);
|
|
81
|
+
|
|
82
|
+
const list = run(['issue', 'list', '--state', 'open', '--search', `"UAT of:" in:body -label:${LABEL}`, '--limit', String(LIMIT), '--json', 'number,body,labels', ...rArgs]);
|
|
83
|
+
if (list.status !== 0) return fail(`gh issue list failed: ${firstLine(list.stderr) || 'exit ' + list.status}`);
|
|
84
|
+
let issues;
|
|
85
|
+
try { issues = JSON.parse(list.stdout); } catch { return fail('gh issue list returned unparseable JSON'); }
|
|
86
|
+
if (!Array.isArray(issues)) return fail('gh issue list returned unexpected JSON');
|
|
87
|
+
|
|
88
|
+
const tagged = [];
|
|
89
|
+
const failed = [];
|
|
90
|
+
for (const i of issues) {
|
|
91
|
+
if (names(i.labels).includes(LABEL)) continue;
|
|
92
|
+
if (!UAT_LINE_RE.test(String(i.body || '').replace(/\r/g, ''))) continue;
|
|
93
|
+
const r = run(['issue', 'edit', String(i.number), '--add-label', LABEL, ...rArgs]);
|
|
94
|
+
(r.status === 0 ? tagged : failed).push(i.number);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let reason;
|
|
98
|
+
if (o.marker && failed.length === 0) {
|
|
99
|
+
try {
|
|
100
|
+
fs.mkdirSync(path.dirname(o.marker), { recursive: true });
|
|
101
|
+
fs.writeFileSync(o.marker, new Date().toISOString() + '\n');
|
|
102
|
+
} catch (e) {
|
|
103
|
+
reason = `marker not written: ${e && e.message ? e.message : String(e)}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
emit(reason ? { status: 'ok', tagged, failed, reason } : { status: 'ok', tagged, failed });
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { main, parseArgs, UAT_LINE_RE };
|
|
111
|
+
|
|
112
|
+
if (require.main === module) {
|
|
113
|
+
process.exit(main(process.argv.slice(2), {}));
|
|
114
|
+
}
|