@marcomigozzi/trailhead 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead UAT setup (self-contained, ships in templates/ so it reaches every
4
+ // install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
5
+ // and Codex). Reads the project's `uat.setup` / `uat.accounts` config, reports
6
+ // what a UAT needs, and runs the setup steps in order. #252.
7
+ //
8
+ // CLI:
9
+ // node trailhead-uat-setup.js plan --repo <working-root> [--config-root <project-root>]
10
+ // node trailhead-uat-setup.js run --repo <working-root> [--config-root <project-root>]
11
+ // [--from <i>] [--only <i>] [--ready-timeout <s>] [--step-timeout <s>]
12
+ // --config-root is the original checkout the effective config was loaded from
13
+ // (defaults to --repo); a worktree/clone workspace never re-resolves config.
14
+ //
15
+ // Config: `uat.setup` (ordered array of {name, run, check?, note?}) and `uat.accounts`
16
+ // ({role: {user, secret}}, secret a bare $ENV reference, never a literal), nested
17
+ // ({"uat":{"setup":[...]}}) or flat ({"uat.setup":[...]}); nested wins when a file
18
+ // carries both. The project file (read from --config-root) replaces the global one
19
+ // (${CLAUDE_CONFIG_DIR:-~/.claude}/trailhead/config.json) key by key as a whole value.
20
+ //
21
+ // plan: validates, lists the steps (index, name, note, check present) and the $ENV
22
+ // names each step / account references with whether each is set. Runs nothing, prints
23
+ // no value.
24
+ // run: per step, every referenced $NAME / ${NAME} must be set and non-empty (else
25
+ // missing-env, nothing runs); a passing `check` -> skipped; else `run` via `sh -c` at
26
+ // --repo (non-zero -> failed); with a `check`, poll it after `run` until it passes or
27
+ // --ready-timeout (default 60 s) elapses (-> not-ready). Each run/check invocation is
28
+ // killed after --step-timeout (default 600 s): a run -> timeout; a check counts as
29
+ // failing; while polling, each check is capped at the remaining ready time. Stops at the
30
+ // first failing step. A `run`'s output goes to a temp file, never a pipe, so a backgrounded
31
+ // process (a dev server) cannot block the helper; it should redirect its own output, else
32
+ // it keeps writing to that (deleted) file. A `check`'s output is ignored.
33
+ // `$NAME` / `${NAME}` inside single quotes, in a `#` comment or written `\$NAME` is not a
34
+ // reference; with an unbalanced quote every `$NAME` counts. Timeouts are capped at 86400 s.
35
+ // Prints one JSON line:
36
+ // {"status","steps":[{index,name,result,code?,output?,missing?}],"failedStep"?,
37
+ // "accounts"?,"warnings":[],"message"?}
38
+ // status: unconfigured | none | invalid | ready | failed
39
+ // result: skipped | ran | failed | missing-env | not-ready | timeout
40
+ // Only the failing step carries `output`: the last 2000 chars, with the value of every
41
+ // referenced variable redacted to its $NAME (best effort, not a guarantee).
42
+ // Fail closed: only ready and none let the caller go on; unconfigured goes to the probe.
43
+ // Exit 0 whenever a JSON line is printed; exit 2 only on a usage error.
44
+
45
+ const fs = require('fs');
46
+ const os = require('os');
47
+ const path = require('path');
48
+ const { spawnSync } = require('child_process');
49
+
50
+ const CONFIG_REL = path.join('.trailhead', 'config.json');
51
+ const OUTPUT_TAIL = 2000;
52
+ const READ_BYTES = 65536;
53
+ const POLL_MS = 500;
54
+ const DEFAULT_READY_S = 60;
55
+ const DEFAULT_STEP_S = 600;
56
+ const MAX_TIMEOUT_S = 86400;
57
+ const STEP_KEYS = ['name', 'run', 'check', 'note'];
58
+ const ACCOUNT_KEYS = ['user', 'secret'];
59
+ const VALUED = ['repo', 'config-root', 'from', 'only', 'ready-timeout', 'step-timeout'];
60
+ const COMMANDS = ['plan', 'run'];
61
+ const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
62
+ const BARE_REF = /^(?:\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\})$/;
63
+
64
+ function parseArgs(argv) {
65
+ const command = argv[0];
66
+ if (!COMMANDS.includes(command)) return { error: `usage: first argument must be one of ${COMMANDS.join(', ')}` };
67
+ const out = { command };
68
+ for (let i = 1; i < argv.length; i++) {
69
+ const a = argv[i];
70
+ const name = a.startsWith('--') ? a.slice(2) : null;
71
+ if (!name || !VALUED.includes(name)) return { error: `usage: unknown argument ${a}` };
72
+ const v = argv[++i];
73
+ if (v === undefined || v.startsWith('--')) return { error: `usage: ${a} needs a value` };
74
+ out[name] = v;
75
+ }
76
+ if (!out.repo) return { error: 'usage: missing --repo <working-root>' };
77
+ for (const k of ['from', 'only']) {
78
+ if (out[k] !== undefined && !/^\d+$/.test(out[k])) return { error: `usage: --${k} needs a non-negative integer` };
79
+ }
80
+ for (const k of ['ready-timeout', 'step-timeout']) {
81
+ if (out[k] !== undefined && !(Number.isFinite(Number(out[k])) && Number(out[k]) > 0 && Number(out[k]) <= MAX_TIMEOUT_S)) return { error: `usage: --${k} needs a positive number of seconds, at most ${MAX_TIMEOUT_S}` };
82
+ }
83
+ if (out.from !== undefined && out.only !== undefined) return { error: 'usage: --from and --only are exclusive' };
84
+ return out;
85
+ }
86
+
87
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
88
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
89
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
90
+
91
+ // Reads and parses one config file. { state: 'unset' } | { state: 'ok', cfg } | { state: 'invalid', message }
92
+ function readFile(file, fs_) {
93
+ let text;
94
+ try {
95
+ text = fs_.readFileSync(file, 'utf8');
96
+ } catch (err) {
97
+ if (err && err.code === 'ENOENT') return { state: 'unset' };
98
+ return { state: 'invalid', message: `cannot read ${file}: ${err && err.message ? err.message : err}` };
99
+ }
100
+ let cfg;
101
+ try {
102
+ cfg = JSON.parse(text);
103
+ } catch (err) {
104
+ return { state: 'invalid', message: `${file} is not valid JSON` };
105
+ }
106
+ if (!isObject(cfg)) return { state: 'invalid', message: `${file} is not a JSON object` };
107
+ if (has(cfg, 'uat') && !isObject(cfg.uat)) return { state: 'invalid', message: `uat in ${file} must be an object` };
108
+ return { state: 'ok', cfg };
109
+ }
110
+
111
+ // Picks `uat.<key>` out of a parsed config: { found, value, warning? }.
112
+ function pick(cfg, key, file) {
113
+ const nested = isObject(cfg.uat) && has(cfg.uat, key);
114
+ const flat = has(cfg, `uat.${key}`);
115
+ if (!nested && !flat) return { found: false };
116
+ const out = { found: true, value: nested ? cfg.uat[key] : cfg[`uat.${key}`] };
117
+ if (nested && flat) out.warning = `${file} sets uat.${key} in both nested and flat form; the nested form wins`;
118
+ return out;
119
+ }
120
+
121
+ function globalConfigPath(env) {
122
+ const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
123
+ return path.join(base, 'trailhead', 'config.json');
124
+ }
125
+
126
+ function validateSetup(value, where) {
127
+ if (!Array.isArray(value)) return `uat.setup in ${where} must be an array of steps`;
128
+ for (let i = 0; i < value.length; i++) {
129
+ const s = value[i];
130
+ if (!isObject(s)) return `uat.setup[${i}] in ${where} must be an object`;
131
+ const extra = Object.keys(s).find((k) => !STEP_KEYS.includes(k));
132
+ if (extra) return `uat.setup[${i}] in ${where} has an unknown key: ${extra}`;
133
+ if (!nonEmpty(s.name)) return `uat.setup[${i}] in ${where} needs a non-empty string name`;
134
+ if (!nonEmpty(s.run)) return `uat.setup[${i}] in ${where} needs a non-empty string run`;
135
+ for (const k of ['check', 'note']) {
136
+ if (has(s, k) && !nonEmpty(s[k])) return `uat.setup[${i}].${k} in ${where} must be a non-empty string`;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function validateAccounts(value, where) {
143
+ if (!isObject(value)) return `uat.accounts in ${where} must be an object of role -> {user, secret}`;
144
+ for (const [role, a] of Object.entries(value)) {
145
+ if (!isObject(a)) return `uat.accounts.${role} in ${where} must be an object`;
146
+ const extra = Object.keys(a).find((k) => !ACCOUNT_KEYS.includes(k));
147
+ if (extra) return `uat.accounts.${role} in ${where} has an unknown key: ${extra}`;
148
+ if (!nonEmpty(a.user)) return `uat.accounts.${role} in ${where} needs a non-empty string user`;
149
+ if (typeof a.secret !== 'string' || !BARE_REF.test(a.secret)) {
150
+ return `uat.accounts.${role}.secret in ${where} must be a bare $ENV reference ($NAME or \${NAME}), never a literal`;
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ const VALIDATORS = { setup: validateSetup, accounts: validateAccounts };
157
+
158
+ // Effective { setup, accounts } (undefined when unset) or { error }. The project file
159
+ // replaces the global one key by key as a whole value.
160
+ function readConfig(configRoot, env, fs_, warnings) {
161
+ const files = [path.join(configRoot, CONFIG_REL), globalConfigPath(env)];
162
+ const parsed = [];
163
+ for (const file of files) {
164
+ const r = readFile(file, fs_);
165
+ if (r.state === 'invalid') return { error: r.message };
166
+ parsed.push({ file, cfg: r.state === 'ok' ? r.cfg : null });
167
+ }
168
+ const out = {};
169
+ for (const key of ['setup', 'accounts']) {
170
+ for (const { file, cfg } of parsed) {
171
+ if (!cfg) continue;
172
+ const p = pick(cfg, key, file);
173
+ if (!p.found) continue;
174
+ const problem = VALIDATORS[key](p.value, file);
175
+ if (problem) return { error: problem };
176
+ if (p.warning) warnings.push(p.warning);
177
+ out[key] = p.value;
178
+ break;
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+
184
+ // Text with the spans that are never env references blanked out: single-quoted spans
185
+ // and backslash-escaped characters (`\$`) and comments (a `#` at a word start outside quotes,
186
+ // to end of line). A single quote inside double quotes is literal. Fail closed: a quote span
187
+ // still open at the end drops every exemption, so every `$NAME` counts.
188
+ function liveText(t) {
189
+ let out = '';
190
+ let single = false;
191
+ let double = false;
192
+ for (let i = 0; i < t.length; i++) {
193
+ const c = t[i];
194
+ if (single) {
195
+ if (c === "'") single = false;
196
+ out += ' ';
197
+ } else if (c === '\\') {
198
+ i++;
199
+ out += ' ';
200
+ } else if (c === '#' && !double && (i === 0 || /[\s;&|()]/.test(t[i - 1]))) {
201
+ while (i < t.length && t[i] !== '\n') { out += ' '; i++; }
202
+ if (i < t.length) out += '\n';
203
+ } else if (c === "'" && !double) {
204
+ single = true;
205
+ out += ' ';
206
+ } else {
207
+ if (c === '"') double = !double;
208
+ out += c;
209
+ }
210
+ }
211
+ return single || double ? t : out;
212
+ }
213
+
214
+ function refsOf(...texts) {
215
+ const names = [];
216
+ for (const t of texts) {
217
+ if (typeof t !== 'string') continue;
218
+ for (const m of liveText(t).matchAll(REF)) {
219
+ const n = m[1] || m[2];
220
+ if (!names.includes(n)) names.push(n);
221
+ }
222
+ }
223
+ return names;
224
+ }
225
+
226
+ const bareName = (s) => { const m = BARE_REF.exec(s); return m ? m[1] || m[2] : null; };
227
+
228
+ function describe(setup, accounts, env) {
229
+ const state = (name) => ({ name, set: typeof env[name] === 'string' && env[name] !== '' });
230
+ const steps = setup.map((s, index) => ({
231
+ index,
232
+ name: s.name,
233
+ ...(s.note ? { note: s.note } : {}),
234
+ check: Boolean(s.check),
235
+ env: refsOf(s.run, s.check).map(state),
236
+ }));
237
+ const accts = Object.entries(accounts).map(([role, a]) => {
238
+ const userEnv = bareName(a.user);
239
+ return { role, user: userEnv ? null : a.user, userEnv: userEnv ? state(userEnv) : null, secretEnv: state(bareName(a.secret)) };
240
+ });
241
+ return { steps, accounts: accts };
242
+ }
243
+
244
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
245
+
246
+ // Redacts the value of every referenced variable to its $NAME, longest value first.
247
+ function redactor(names, env) {
248
+ const pairs = names
249
+ .filter((n) => typeof env[n] === 'string' && env[n] !== '')
250
+ .map((n) => [n, env[n]])
251
+ .sort((a, b) => b[1].length - a[1].length);
252
+ return (text) => pairs.reduce((t, [n, v]) => t.split(v).join(`$${n}`), text);
253
+ }
254
+
255
+ // Runs `sh -c command` at repo; the output of a `run` goes to a temp file (a check's is ignored). Returns
256
+ // { code, timedOut, tail } where tail is the bounded last chunk of the output.
257
+ function sh(command, repo, env, timeoutMs, wantOutput) {
258
+ const timeout = Math.max(1, Math.ceil(timeoutMs));
259
+ if (!wantOutput) {
260
+ const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: 'ignore', timeout, killSignal: 'SIGKILL' });
261
+ const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
262
+ return { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
263
+ }
264
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-uat-'));
265
+ const file = path.join(dir, 'out');
266
+ const fd = fs.openSync(file, 'w');
267
+ try {
268
+ const r = spawnSync('sh', ['-c', command], { cwd: repo, env, stdio: ['ignore', fd, fd], timeout, killSignal: 'SIGKILL' });
269
+ const timedOut = Boolean(r.error && r.error.code === 'ETIMEDOUT');
270
+ const res = { code: r.status, timedOut, spawnError: r.error && !timedOut ? r.error.message : null, tail: '' };
271
+ if (wantOutput) {
272
+ const size = fs.fstatSync(fd).size;
273
+ const len = Math.min(size, READ_BYTES);
274
+ const buf = Buffer.alloc(len);
275
+ const rfd = fs.openSync(file, 'r');
276
+ try { fs.readSync(rfd, buf, 0, len, size - len); } finally { fs.closeSync(rfd); }
277
+ res.tail = buf.toString('utf8');
278
+ }
279
+ return res;
280
+ } finally {
281
+ fs.closeSync(fd);
282
+ fs.rmSync(dir, { recursive: true, force: true });
283
+ }
284
+ }
285
+
286
+ const passes = (r) => !r.timedOut && !r.spawnError && r.code === 0;
287
+
288
+ function finalTail(text, redact, extra) {
289
+ const t = redact(text) + (extra || '');
290
+ return t.length > OUTPUT_TAIL ? '...' + t.slice(t.length - OUTPUT_TAIL) : t;
291
+ }
292
+
293
+ function runStep(step, index, ctx) {
294
+ const { repo, env, stepMs, readyMs, redact } = ctx;
295
+ const base = { index, name: step.name };
296
+ const missing = refsOf(step.run, step.check).filter((n) => typeof env[n] !== 'string' || env[n] === '');
297
+ if (missing.length > 0) return { ...base, result: 'missing-env', missing };
298
+ if (step.check && passes(sh(step.check, repo, env, stepMs, false))) return { ...base, result: 'skipped' };
299
+ const r = sh(step.run, repo, env, stepMs, true);
300
+ if (r.timedOut) return { ...base, result: 'timeout', output: finalTail(r.tail, redact, `\n(timed out after ${stepMs / 1000}s)`) };
301
+ if (r.spawnError || r.code !== 0) {
302
+ return { ...base, result: 'failed', code: r.code, output: finalTail(r.tail, redact, r.spawnError ? `\n${r.spawnError}` : '') };
303
+ }
304
+ if (!step.check) return { ...base, result: 'ran' };
305
+ const deadline = Date.now() + readyMs;
306
+ for (;;) {
307
+ const remaining = deadline - Date.now();
308
+ if (passes(sh(step.check, repo, env, Math.max(1, Math.min(stepMs, remaining)), false))) return { ...base, result: 'ran' };
309
+ const left = deadline - Date.now();
310
+ if (left <= 0) break;
311
+ sleep(Math.min(POLL_MS, left));
312
+ }
313
+ return { ...base, result: 'not-ready', output: finalTail(r.tail, redact, `\n(check "${step.check}" did not pass within ${readyMs / 1000}s)`) };
314
+ }
315
+
316
+ function execute(parsed, cwd, env, fs_) {
317
+ const repo = path.resolve(cwd, parsed.repo);
318
+ const configRoot = path.resolve(cwd, parsed['config-root'] || parsed.repo);
319
+ const warnings = [];
320
+ const result = { status: 'invalid', steps: [], warnings };
321
+ const cfg = readConfig(configRoot, env, fs_, warnings);
322
+ if (cfg.error) return { ...result, message: cfg.error };
323
+
324
+ const accounts = cfg.accounts || {};
325
+ const merged = { ...process.env, ...env };
326
+ const view = describe(cfg.setup || [], accounts, merged);
327
+ const accountsOut = view.accounts.length > 0 ? { accounts: view.accounts } : {};
328
+ if (cfg.setup === undefined) return { ...result, status: 'unconfigured', ...accountsOut };
329
+ if (cfg.setup.length === 0) return { ...result, status: 'none', ...accountsOut };
330
+
331
+ if (parsed.command === 'plan') return { ...result, status: 'ready', steps: view.steps, ...accountsOut };
332
+
333
+ const total = cfg.setup.length;
334
+ const pickIndex = parsed.only !== undefined ? Number(parsed.only) : parsed.from !== undefined ? Number(parsed.from) : 0;
335
+ if (pickIndex >= total) return { ...result, message: `step index ${pickIndex} is out of range (${total} steps)` };
336
+ const indexes = parsed.only !== undefined ? [pickIndex] : cfg.setup.map((_, i) => i).filter((i) => i >= pickIndex);
337
+
338
+ const names = [...new Set([...view.steps.flatMap((s) => s.env.map((e) => e.name)), ...view.accounts.flatMap((a) => [a.userEnv, a.secretEnv].filter(Boolean).map((e) => e.name))])];
339
+ const ctx = {
340
+ repo,
341
+ env: merged,
342
+ stepMs: Number(parsed['step-timeout'] || DEFAULT_STEP_S) * 1000,
343
+ readyMs: Number(parsed['ready-timeout'] || DEFAULT_READY_S) * 1000,
344
+ redact: redactor(names, merged),
345
+ };
346
+ for (const i of indexes) {
347
+ let r;
348
+ try {
349
+ r = runStep(cfg.setup[i], i, ctx);
350
+ } catch (err) {
351
+ return { ...result, message: 'error: ' + (err && err.message ? err.message : String(err)) };
352
+ }
353
+ result.steps.push(r);
354
+ if (!['skipped', 'ran'].includes(r.result)) return { ...result, status: 'failed', failedStep: i };
355
+ }
356
+ return { ...result, status: 'ready' };
357
+ }
358
+
359
+ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
360
+ const cwd_ = cwd || process.cwd();
361
+ const out = stdout || process.stdout;
362
+ const env_ = env || process.env;
363
+ const fs_ = fsMod || fs;
364
+
365
+ const parsed = parseArgs(argv || []);
366
+ if (parsed.error) {
367
+ process.stderr.write(parsed.error + '\n');
368
+ return 2;
369
+ }
370
+ let result;
371
+ try {
372
+ result = execute(parsed, cwd_, env_, fs_);
373
+ } catch (err) {
374
+ result = { status: 'invalid', steps: [], warnings: [], message: 'error: ' + (err && err.message ? err.message : String(err)) };
375
+ }
376
+ out.write(JSON.stringify(result) + '\n');
377
+ return 0;
378
+ }
379
+
380
+ module.exports = { parseArgs, readConfig, main };
381
+
382
+ if (require.main === module) {
383
+ const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
384
+ process.exit(code);
385
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead work state (self-contained, ships in templates/ so it reaches every
4
+ // install channel: bin/trailhead.js copies templates/ wholesale for Claude/npm
5
+ // and Codex). Writes and removes <repo>/.trailhead/work-state.json, the state
6
+ // of the ticket in play that the skills update at every step. #205.
7
+ //
8
+ // CLI:
9
+ // node trailhead-work-state.js set [--repo <dir>] [--map <n>|none] [--ticket <n>]
10
+ // [--title <t>] [--step <step>] [--auto true|false] [--auto-done <n>]
11
+ // node trailhead-work-state.js clear [--repo <dir>]
12
+ // `clear` removes both work-state.json and the session-ticket marker, each
13
+ // attempted independently.
14
+ // --repo defaults to cwd. `set` merges the given fields onto the existing file
15
+ // (only --step at each step change); a --ticket other than the one on file
16
+ // starts from an empty state. File shape:
17
+ // { map, ticket, title, step, auto, autoDone, session, updatedAt }
18
+ // autoDone: tickets resolved so far in the autonomous run (non-negative
19
+ // integer, default 0; reset to 0 whenever auto is false).
20
+ // Prints one JSON line {"status","path","state"?,"sessionTicket"?,"message"?}.
21
+ // status: written | cleared | absent | skipped
22
+ // sessionTicket (clear only): removed | absent | skipped
23
+ // Exit 0 on every normal outcome and on any unexpected I/O error (it must
24
+ // never block the caller: status skipped with the message); exit 2 only on a
25
+ // usage or validation error (nothing is written).
26
+
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+
31
+ const STATE_DIR = '.trailhead';
32
+ const STATE_FILE = 'work-state.json';
33
+ const TICKET_MARKER = 'session-ticket';
34
+ const STEPS = ['discuss', 'plan', 'execute', 'verify', 'resolve', 'paused'];
35
+ const TITLE_MAX = 200;
36
+
37
+ const isPosInt = (v) => Number.isInteger(v) && v > 0;
38
+
39
+ // Strict parse of a positive integer from a CLI string; null if invalid.
40
+ function parsePosInt(text) {
41
+ return /^[1-9]\d*$/.test(text) ? Number(text) : null;
42
+ }
43
+
44
+ // Strict parse of a non-negative integer from a CLI string; null if invalid.
45
+ function parseNonNegInt(text) {
46
+ return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : null;
47
+ }
48
+
49
+ // Title on a single line: control characters and newlines become spaces,
50
+ // spaces collapsed, trimmed, capped at TITLE_MAX characters.
51
+ function cleanTitle(text) {
52
+ return String(text).replace(/[\u0000-\u001f\u007f\s]+/g, ' ').trim().slice(0, TITLE_MAX);
53
+ }
54
+
55
+ // Keeps only the valid fields of an arbitrary object: an invalid field is
56
+ // dropped (treated as absent), never carried into the new state.
57
+ function sanitizeState(raw) {
58
+ const out = {};
59
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out;
60
+ if (raw.map === null || isPosInt(raw.map)) out.map = raw.map;
61
+ if (isPosInt(raw.ticket)) out.ticket = raw.ticket;
62
+ if (typeof raw.title === 'string') out.title = cleanTitle(raw.title);
63
+ if (STEPS.includes(raw.step)) out.step = raw.step;
64
+ if (typeof raw.auto === 'boolean') out.auto = raw.auto;
65
+ if (Number.isInteger(raw.autoDone) && raw.autoDone >= 0) out.autoDone = raw.autoDone;
66
+ return out;
67
+ }
68
+
69
+ // Merges the patch onto the existing state. A different ticket starts from
70
+ // empty (no inherited title/map). Returns the normalised state, or
71
+ // { error } when ticket or step is missing.
72
+ function mergeState(existing, patch, { session = null, now = new Date() } = {}) {
73
+ const base = sanitizeState(existing);
74
+ const start = patch.ticket !== undefined && base.ticket !== undefined && base.ticket !== patch.ticket ? {} : base;
75
+ const merged = { ...start, ...patch };
76
+ if (merged.ticket === undefined) return { error: 'usage: set needs a ticket (pass --ticket)' };
77
+ if (merged.step === undefined) return { error: 'usage: set needs a step (pass --step)' };
78
+ const auto = merged.auto === undefined ? false : merged.auto;
79
+ return {
80
+ map: merged.map === undefined ? null : merged.map,
81
+ ticket: merged.ticket,
82
+ title: merged.title === undefined || merged.title === '' ? null : merged.title,
83
+ step: merged.step,
84
+ auto,
85
+ // A run that is not autonomous has no counter.
86
+ autoDone: auto && merged.autoDone !== undefined ? merged.autoDone : 0,
87
+ session,
88
+ updatedAt: now.toISOString(),
89
+ };
90
+ }
91
+
92
+ function parseArgs(argv) {
93
+ const command = argv[0];
94
+ if (command !== 'set' && command !== 'clear') {
95
+ return { error: `usage: expected "set" or "clear", got: ${command === undefined ? '(nothing)' : command}` };
96
+ }
97
+ let repo = null;
98
+ const patch = {};
99
+ const allowed = command === 'set' ? ['--repo', '--map', '--ticket', '--title', '--step', '--auto', '--auto-done'] : ['--repo'];
100
+ for (let i = 1; i < argv.length; i++) {
101
+ const flag = argv[i];
102
+ if (!allowed.includes(flag)) return { error: `usage: unknown argument: ${flag}` };
103
+ i++;
104
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
105
+ const value = argv[i];
106
+ if (flag === '--repo') repo = value;
107
+ else if (flag === '--map') {
108
+ if (value === 'none') patch.map = null;
109
+ else if (parsePosInt(value) !== null) patch.map = parsePosInt(value);
110
+ else return { error: `usage: --map must be a positive integer or "none", got: ${value}` };
111
+ } else if (flag === '--ticket') {
112
+ if (parsePosInt(value) === null) return { error: `usage: --ticket must be a positive integer, got: ${value}` };
113
+ patch.ticket = parsePosInt(value);
114
+ } else if (flag === '--title') patch.title = cleanTitle(value);
115
+ else if (flag === '--step') {
116
+ if (!STEPS.includes(value)) return { error: `usage: --step must be one of ${STEPS.join(', ')}, got: ${value}` };
117
+ patch.step = value;
118
+ } else if (flag === '--auto') {
119
+ if (value !== 'true' && value !== 'false') return { error: `usage: --auto must be true or false, got: ${value}` };
120
+ patch.auto = value === 'true';
121
+ } else if (flag === '--auto-done') {
122
+ if (parseNonNegInt(value) === null) return { error: `usage: --auto-done must be a non-negative integer, got: ${value}` };
123
+ patch.autoDone = parseNonNegInt(value);
124
+ }
125
+ }
126
+ return { command, repo, patch };
127
+ }
128
+
129
+ // Reads the existing file; missing or corrupt = no state (never a crash).
130
+ function readExisting(file, fsMod) {
131
+ try {
132
+ return JSON.parse(fsMod.readFileSync(file, 'utf8'));
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ // Atomic write: a unique temp in the same dir, then rename over the file
139
+ // (never an in-place truncate); the temp is removed if anything fails.
140
+ function writeAtomic(dir, file, text, fsMod) {
141
+ fsMod.mkdirSync(dir, { recursive: true });
142
+ const tmp = path.join(dir, `.${STATE_FILE}.${process.pid}-${crypto.randomBytes(4).toString('hex')}.tmp`);
143
+ try {
144
+ fsMod.writeFileSync(tmp, text);
145
+ fsMod.renameSync(tmp, file);
146
+ } catch (err) {
147
+ try { fsMod.unlinkSync(tmp); } catch { /* pulizia best effort */ }
148
+ throw err;
149
+ }
150
+ }
151
+
152
+ // Removes one file; never throws. Returns { status, message? } where status is
153
+ // doneLabel (removed), absentLabel (not there) or skipped (I/O error).
154
+ function removeFile(file, fsMod, doneLabel, absentLabel) {
155
+ try {
156
+ if (!fsMod.existsSync(file)) return { status: absentLabel };
157
+ fsMod.unlinkSync(file);
158
+ return { status: doneLabel };
159
+ } catch (err) {
160
+ return { status: 'skipped', message: 'error: ' + (err && err.message ? err.message : String(err)) };
161
+ }
162
+ }
163
+
164
+ function writeResult(stdout, result) {
165
+ stdout.write(JSON.stringify(result) + '\n');
166
+ }
167
+
168
+ function main(argv, { cwd, stdout, env, fs: fsMod, now } = {}) {
169
+ const cwd_ = cwd || process.cwd();
170
+ const out = stdout || process.stdout;
171
+ const env_ = env || process.env;
172
+ const fs_ = fsMod || fs;
173
+
174
+ const parsed = parseArgs(argv || []);
175
+ if (parsed.error) {
176
+ process.stderr.write(parsed.error + '\n');
177
+ return 2;
178
+ }
179
+
180
+ const repoDir = parsed.repo ? path.resolve(cwd_, parsed.repo) : cwd_;
181
+ const dir = path.join(repoDir, STATE_DIR);
182
+ const file = path.join(dir, STATE_FILE);
183
+ try {
184
+ if (parsed.command === 'clear') {
185
+ // Both markers are removed independently: a missing or failing first
186
+ // removal never skips the second (#253).
187
+ const state = removeFile(file, fs_, 'cleared', 'absent');
188
+ const marker = removeFile(path.join(dir, TICKET_MARKER), fs_, 'removed', 'absent');
189
+ const result = { status: state.status, path: file, sessionTicket: marker.status };
190
+ const message = [state.message, marker.message].filter(Boolean).join('; ');
191
+ if (message) result.message = message;
192
+ writeResult(out, result);
193
+ return 0;
194
+ }
195
+
196
+ const sessionEnv = env_.CLAUDE_CODE_SESSION_ID;
197
+ const session = typeof sessionEnv === 'string' && sessionEnv !== '' ? sessionEnv : null;
198
+ const state = mergeState(readExisting(file, fs_), parsed.patch, { session, now: now || new Date() });
199
+ if (state.error) {
200
+ process.stderr.write(state.error + '\n');
201
+ return 2;
202
+ }
203
+ writeAtomic(dir, file, JSON.stringify(state, null, 2) + '\n', fs_);
204
+ writeResult(out, { status: 'written', path: file, state });
205
+ return 0;
206
+ } catch (err) {
207
+ writeResult(out, { status: 'skipped', path: file, message: 'error: ' + (err && err.message ? err.message : String(err)) });
208
+ return 0;
209
+ }
210
+ }
211
+
212
+ module.exports = { STEPS, sanitizeState, mergeState, parseArgs, main };
213
+
214
+ if (require.main === module) {
215
+ const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });
216
+ process.exit(code);
217
+ }