@marcomigozzi/trailhead 0.14.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  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 +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-uat-setup.js. Run: node uat-setup.test.js
3
+ // No framework: plain asserts, same style as base-preflight.test.js. Real temp dirs.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-uat-setup.js');
11
+ const { main, parseArgs } = require(scriptPath);
12
+
13
+ let passed = 0;
14
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
15
+ const eq = (name, actual, expected) => {
16
+ assert.deepStrictEqual(actual, expected, `${name}: got ${JSON.stringify(actual)}, want ${JSON.stringify(expected)}`);
17
+ passed++;
18
+ };
19
+
20
+ const tmpDirs = [];
21
+ const mktmp = () => {
22
+ const d = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-uat-setup-test-')));
23
+ tmpDirs.push(d);
24
+ return d;
25
+ };
26
+ const writeConfig = (root, obj) => {
27
+ fs.mkdirSync(path.join(root, '.trailhead'), { recursive: true });
28
+ fs.writeFileSync(path.join(root, '.trailhead', 'config.json'), typeof obj === 'string' ? obj : JSON.stringify(obj));
29
+ };
30
+
31
+ function run(argv, { env = {}, cwd = '/' } = {}) {
32
+ const chunks = [];
33
+ const errChunks = [];
34
+ const realErr = process.stderr.write.bind(process.stderr);
35
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
36
+ let code;
37
+ try {
38
+ code = main(argv, { cwd, env: { HOME: '/nonexistent-home', ...env }, stdout: { write: (s) => chunks.push(s) } });
39
+ } finally {
40
+ process.stderr.write = realErr;
41
+ }
42
+ const lines = chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l));
43
+ return { code, out: lines[0], lines, err: errChunks.join('') };
44
+ }
45
+
46
+ const emptyHome = mktmp();
47
+ const envNoGlobal = { CLAUDE_CONFIG_DIR: emptyHome };
48
+ const plan = (dir, extra = [], env = {}) => run(['plan', '--repo', dir, ...extra], { env: { ...envNoGlobal, ...env } });
49
+ const go = (dir, extra = [], env = {}) => run(['run', '--repo', dir, ...extra], { env: { ...envNoGlobal, ...env } });
50
+ const proj = (cfg) => { const d = mktmp(); if (cfg !== undefined) writeConfig(d, cfg); return d; };
51
+
52
+ // --- usage ----------------------------------------------------------------------
53
+ {
54
+ ok('parseArgs: missing command', parseArgs([]).error);
55
+ ok('parseArgs: unknown command', parseArgs(['nope', '--repo', '/x']).error);
56
+ ok('parseArgs: missing --repo', parseArgs(['plan']).error);
57
+ ok('parseArgs: unknown flag', parseArgs(['plan', '--repo', '/x', '--bogus']).error);
58
+ ok('parseArgs: valueless flag', parseArgs(['plan', '--repo']).error);
59
+ ok('parseArgs: --only and --from are exclusive', parseArgs(['run', '--repo', '/x', '--only', '0', '--from', '1']).error);
60
+ ok('parseArgs: bad timeout', parseArgs(['run', '--repo', '/x', '--ready-timeout', 'abc']).error);
61
+ for (const v of ['Infinity', '1e999', '-1', '0', 'NaN']) {
62
+ ok(`parseArgs: --step-timeout ${v} rejected`, parseArgs(['run', '--repo', '/x', '--step-timeout', v]).error);
63
+ ok(`parseArgs: --ready-timeout ${v} rejected`, parseArgs(['run', '--repo', '/x', '--ready-timeout', v]).error);
64
+ }
65
+ ok('parseArgs: fractional timeout accepted', !parseArgs(['run', '--repo', '/x', '--step-timeout', '0.5']).error);
66
+ const inf = run(['run', '--repo', '/x', '--step-timeout', 'Infinity']);
67
+ eq('infinite timeout exits 2 with no stdout', [inf.code, inf.lines.length], [2, 0]);
68
+ const r = run([]);
69
+ eq('usage error exits 2', r.code, 2);
70
+ ok('usage error writes stderr, no stdout', r.err.includes('usage') && r.lines.length === 0);
71
+ }
72
+
73
+ // --- config reading -------------------------------------------------------------
74
+ {
75
+ eq('unset: unconfigured', plan(proj()).out.status, 'unconfigured');
76
+ eq('no uat key: unconfigured', plan(proj({ testing: { webapp: true } })).out.status, 'unconfigured');
77
+ const none = plan(proj({ uat: { setup: [] } }));
78
+ eq('[]: none', [none.out.status, none.code], ['none', 0]);
79
+ eq('[]: run is none too', go(proj({ uat: { setup: [] } })).out.status, 'none');
80
+ eq('unconfigured run runs nothing', go(proj()).out.status, 'unconfigured');
81
+ const nested = plan(proj({ uat: { setup: [{ name: 'a', run: 'true' }] } }));
82
+ eq('nested: ready to plan', [nested.out.status, nested.out.steps.length], ['ready', 1]);
83
+ const flat = plan(proj({ 'uat.setup': [{ name: 'a', run: 'true' }, { name: 'b', run: 'true' }] }));
84
+ eq('flat: read', flat.out.steps.length, 2);
85
+ const both = plan(proj({ uat: { setup: [{ name: 'n', run: 'true' }] }, 'uat.setup': [{ name: 'f', run: 'true' }] }));
86
+ eq('both: nested wins', both.out.steps[0].name, 'n');
87
+ ok('both: a warning', both.out.warnings.some((w) => /nested/.test(w)));
88
+ const bad = plan(proj('{not json'));
89
+ eq('unparseable file: invalid', bad.out.status, 'invalid');
90
+ }
91
+
92
+ // --- project replaces global, key by key ----------------------------------------
93
+ {
94
+ const home = mktmp();
95
+ fs.mkdirSync(path.join(home, 'trailhead'));
96
+ fs.writeFileSync(path.join(home, 'trailhead', 'config.json'), JSON.stringify({
97
+ uat: { setup: [{ name: 'global', run: 'true' }], accounts: { qa: { user: 'g@x.io', secret: '$G_PW' } } },
98
+ }));
99
+ const env = { CLAUDE_CONFIG_DIR: home };
100
+ const onlyGlobal = run(['plan', '--repo', proj()], { env });
101
+ eq('global only: setup from global', onlyGlobal.out.steps[0].name, 'global');
102
+ const p = proj({ uat: { setup: [{ name: 'project', run: 'true' }] } });
103
+ const mixed = run(['plan', '--repo', p], { env });
104
+ eq('project setup replaces global setup', mixed.out.steps.map((s) => s.name), ['project']);
105
+ eq('accounts still resolved from global', mixed.out.accounts.map((a) => a.role), ['qa']);
106
+ const empty = run(['plan', '--repo', proj({ uat: { setup: [] } })], { env });
107
+ eq('project [] replaces a global list', empty.out.status, 'none');
108
+ const wt = mktmp();
109
+ const viaRoot = run(['plan', '--repo', wt, '--config-root', p], { env });
110
+ eq('--config-root supplies the project file', viaRoot.out.steps[0].name, 'project');
111
+ }
112
+
113
+ // --- validation -----------------------------------------------------------------
114
+ {
115
+ const invalid = (name, cfg) => {
116
+ const r = go(proj(cfg));
117
+ eq(`invalid: ${name}`, [r.out.status, r.code], ['invalid', 0]);
118
+ ok(`invalid: ${name} has a message`, typeof r.out.message === 'string' && r.out.message.length > 0);
119
+ };
120
+ invalid('setup not an array', { uat: { setup: 'make up' } });
121
+ invalid('step not an object', { uat: { setup: ['make up'] } });
122
+ invalid('step without run', { uat: { setup: [{ name: 'a' }] } });
123
+ invalid('step without name', { uat: { setup: [{ run: 'true' }] } });
124
+ invalid('empty name', { uat: { setup: [{ name: ' ', run: 'true' }] } });
125
+ invalid('non-string check', { uat: { setup: [{ name: 'a', run: 'true', check: 1 }] } });
126
+ invalid('unknown key', { uat: { setup: [{ name: 'a', run: 'true', extra: 1 }] } });
127
+ invalid('accounts not an object', { uat: { accounts: [] } });
128
+ invalid('uat is a string', { uat: 'x' });
129
+ invalid('uat is an array', { uat: [] });
130
+ invalid('uat is null', { uat: null });
131
+ invalid('account without secret', { uat: { setup: [], accounts: { qa: { user: 'u' } } } });
132
+ invalid('account without user', { uat: { setup: [], accounts: { qa: { secret: '$X' } } } });
133
+ invalid('literal secret rejected', { uat: { setup: [], accounts: { qa: { user: 'u', secret: 'hunter2' } } } });
134
+ invalid('secret embedded in text rejected', { uat: { setup: [], accounts: { qa: { user: 'u', secret: 'pw-$X' } } } });
135
+ invalid('unknown account key', { uat: { setup: [], accounts: { qa: { user: 'u', secret: '$X', extra: 1 } } } });
136
+ const okAcc = plan(proj({ uat: { setup: [], accounts: { qa: { user: 'u@x.io', secret: '${QA_PW}' }, dev: { user: '$DEV_USER', secret: '$DEV_PW' } } } }), [], { QA_PW: 'sekret-1' });
137
+ eq('literal user and bare refs accepted', okAcc.out.status, 'none');
138
+ // invalid accounts must not run an otherwise good setup
139
+ const marker = path.join(mktmp(), 'ran');
140
+ const r = go(proj({ uat: { setup: [{ name: 'a', run: `touch ${marker}` }], accounts: { qa: { user: 'u', secret: 'literal' } } } }));
141
+ eq('invalid accounts: nothing runs', [r.out.status, fs.existsSync(marker)], ['invalid', false]);
142
+ }
143
+
144
+ // --- plan -----------------------------------------------------------------------
145
+ {
146
+ const d = proj({ uat: {
147
+ setup: [
148
+ { name: 'db', run: 'docker compose up -d db', check: 'true', note: 'ask before using the Pixel' },
149
+ { name: 'app', run: 'DEVICE=$ADB_DEVICE npm start --token ${API_TOKEN}' },
150
+ ],
151
+ accounts: { qa: { user: 'qa@x.io', secret: '$QA_PW' }, dev: { user: '$DEV_USER', secret: '${DEV_PW}' } },
152
+ } });
153
+ const r = plan(d, [], { ADB_DEVICE: 'emulator-5554', QA_PW: 'topsecret' });
154
+ eq('plan: steps', r.out.steps.map((s) => [s.index, s.name, s.note || null, s.check]), [[0, 'db', 'ask before using the Pixel', true], [1, 'app', null, false]]);
155
+ eq('plan: step env presence', r.out.steps[1].env, [{ name: 'ADB_DEVICE', set: true }, { name: 'API_TOKEN', set: false }]);
156
+ eq('plan: accounts', r.out.accounts, [
157
+ { role: 'qa', user: 'qa@x.io', userEnv: null, secretEnv: { name: 'QA_PW', set: true } },
158
+ { role: 'dev', user: null, userEnv: { name: 'DEV_USER', set: false }, secretEnv: { name: 'DEV_PW', set: false } },
159
+ ]);
160
+ ok('plan: never prints a value', !JSON.stringify(r.out).includes('emulator-5554') && !JSON.stringify(r.out).includes('topsecret'));
161
+ const marker = path.join(mktmp(), 'ran');
162
+ plan(proj({ uat: { setup: [{ name: 'a', run: `touch ${marker}` }] } }));
163
+ ok('plan never runs anything', !fs.existsSync(marker));
164
+ }
165
+
166
+ // --- run ------------------------------------------------------------------------
167
+ {
168
+ const dir = mktmp();
169
+ const f = (n) => path.join(dir, n);
170
+ const d = proj({ uat: { setup: [
171
+ { name: 'up', run: `touch ${f('up')}`, check: `test -f ${f('up')}` },
172
+ { name: 'always', run: `echo x >> ${f('log')}` },
173
+ ] } });
174
+ const first = go(d);
175
+ eq('run: failing check runs the step, then ready', [first.out.status, first.out.steps.map((s) => s.result)], ['ready', ['ran', 'ran']]);
176
+ const second = go(d);
177
+ eq('run: passing check skips, no check always runs', second.out.steps.map((s) => s.result), ['skipped', 'ran']);
178
+ eq('run: always step ran twice', fs.readFileSync(f('log'), 'utf8').split('\n').filter(Boolean).length, 2);
179
+ ok('run: ok steps carry no output', second.out.steps.every((s) => s.output === undefined));
180
+ }
181
+ {
182
+ const dir = mktmp();
183
+ const f = (n) => path.join(dir, n);
184
+ const d = proj({ uat: { setup: [
185
+ { name: 's0', run: `touch ${f('s0')}` },
186
+ { name: 's1', run: 'echo boom-visible; exit 3' },
187
+ { name: 's2', run: `touch ${f('s2')}` },
188
+ ] } });
189
+ const r = go(d);
190
+ eq('failed: status and failedStep', [r.out.status, r.out.failedStep], ['failed', 1]);
191
+ eq('failed: step result carries code and output', [r.out.steps[1].result, r.out.steps[1].code, r.out.steps[1].output.trim()], ['failed', 3, 'boom-visible']);
192
+ eq('failed: stops at the first failure', [fs.existsSync(f('s0')), fs.existsSync(f('s2')), r.out.steps.length], [true, false, 2]);
193
+ fs.unlinkSync(f('s0'));
194
+ const from = go(d, ['--from', '1']);
195
+ eq('--from skips the earlier steps', [from.out.steps[0].index, fs.existsSync(f('s0'))], [1, false]);
196
+ const only = go(proj({ uat: { setup: [{ name: 'a', run: `touch ${f('oa')}` }, { name: 'b', run: `touch ${f('ob')}` }] } }), ['--only', '1']);
197
+ eq('--only runs exactly one', [only.out.status, only.out.steps.map((s) => s.index), fs.existsSync(f('oa')), fs.existsSync(f('ob'))], ['ready', [1], false, true]);
198
+ eq('--only out of range: invalid', go(d, ['--only', '9']).out.status, 'invalid');
199
+ const cwdProbe = go(proj({ uat: { setup: [{ name: 'cwd', run: 'pwd > cwd.txt' }] } }), []);
200
+ ok('run cwd is --repo', cwdProbe.out.status === 'ready');
201
+ }
202
+ {
203
+ const dir = mktmp();
204
+ const marker = path.join(dir, 'ran');
205
+ const d = proj({ uat: { setup: [
206
+ { name: 'needs', run: `touch ${marker}; echo $MISSING_ONE ${'${MISSING_TWO}'}`, check: 'false' },
207
+ ] } });
208
+ const r = go(d, [], { MISSING_ONE: '' });
209
+ eq('missing-env: failed and names the variables', [r.out.status, r.out.steps[0].result, r.out.steps[0].missing], ['failed', 'missing-env', ['MISSING_ONE', 'MISSING_TWO']]);
210
+ ok('missing-env runs nothing', !fs.existsSync(marker));
211
+ }
212
+ {
213
+ // shell-local $names are not env references: single quotes and \$ escape
214
+ const marker = path.join(mktmp(), 'ran');
215
+ const pl = plan(proj({ uat: { setup: [{ name: 'local', run: "for f in a b; do echo \\$f; done; awk '{print $NF}' </dev/null; echo '$QUOTED'; echo \\$ESC; echo \"\\$ESC2\"; echo $REAL", check: "echo '${Q2}' >/dev/null" }] } }), [], { REAL: 'v' });
216
+ eq('plan: only unquoted, unescaped refs listed', pl.out.steps[0].env.map((e) => e.name), ['REAL']);
217
+ const loc = go(proj({ uat: { setup: [{ name: 'local', run: `awk '{print $NF}' </dev/null; echo '$NOPE_UNSET' >/dev/null; echo \\$NOPE_UNSET2 >/dev/null; touch ${marker}` }] } }));
218
+ eq('run: single-quoted and escaped refs need no env', [loc.out.status, loc.out.steps[0].result, fs.existsSync(marker)], ['ready', 'ran', true]);
219
+ const dq = go(proj({ uat: { setup: [{ name: 'dq', run: 'echo "$DQ_MISSING"' }] } }));
220
+ eq('double-quoted ref still required', [dq.out.steps[0].result, dq.out.steps[0].missing], ['missing-env', ['DQ_MISSING']]);
221
+ const sq = go(proj({ uat: { setup: [{ name: 'sq', run: "echo 'it''s' $SQ_MISSING" }] } }));
222
+ eq('ref after adjacent quotes still required', sq.out.steps[0].missing, ['SQ_MISSING']);
223
+ }
224
+ {
225
+ // un commento di shell non apre span tra apici e i suoi riferimenti sono ignorati
226
+ const marker = path.join(mktmp(), 'ran');
227
+ const cm = go(proj({ uat: { setup: [{ name: 'cm', run: "# don't forget\necho dev=$DEVICE_ID" }] } }));
228
+ eq('apostrophe in a comment: later ref still required', [cm.out.steps[0].result, cm.out.steps[0].missing], ['missing-env', ['DEVICE_ID']]);
229
+ const cref = go(proj({ uat: { setup: [{ name: 'cref', run: `echo hi # uses $COMMENT_ONLY\ntouch ${marker}` }] } }));
230
+ eq('ref inside a comment is not required', [cref.out.steps[0].result, fs.existsSync(marker)], ['ran', true]);
231
+ const mid = go(proj({ uat: { setup: [{ name: 'mid', run: 'echo a#b $HASH_MISSING' }] } }));
232
+ eq('# not at a word start is no comment', mid.out.steps[0].missing, ['HASH_MISSING']);
233
+ const dqh = go(proj({ uat: { setup: [{ name: 'dqh', run: 'echo "# $DQH_MISSING"' }] } }));
234
+ eq('# inside double quotes is no comment', dqh.out.steps[0].missing, ['DQH_MISSING']);
235
+ // quote aperta a fine testo: fail closed, ogni riferimento conta
236
+ const open = go(proj({ uat: { setup: [{ name: 'open', run: "echo it's $OPEN_MISSING '$OPEN_QUOTED'" }] } }));
237
+ eq('unbalanced single quote: every ref required', open.out.steps[0].missing, ['OPEN_MISSING', 'OPEN_QUOTED']);
238
+ const openD = go(proj({ uat: { setup: [{ name: 'openD', run: "echo \"x $OPEND_MISSING '$OPEND_Q'" }] } }));
239
+ eq('unbalanced double quote: every ref required', openD.out.steps[0].missing, ['OPEND_MISSING', 'OPEND_Q']);
240
+ }
241
+ {
242
+ const big = parseArgs(['run', '--repo', '/x', '--step-timeout', '86401']);
243
+ ok('parseArgs: --step-timeout above 86400 rejected', big.error);
244
+ ok('parseArgs: --ready-timeout above 86400 rejected', parseArgs(['run', '--repo', '/x', '--ready-timeout', '86401']).error);
245
+ ok('parseArgs: 86400 accepted', !parseArgs(['run', '--repo', '/x', '--step-timeout', '86400', '--ready-timeout', '86400']).error);
246
+ eq('oversized timeout exits 2', run(['run', '--repo', '/x', '--ready-timeout', '999999']).code, 2);
247
+ }
248
+ {
249
+ // a tiny fractional timeout still becomes a valid >= 1 ms spawn timeout
250
+ const r = go(proj({ uat: { setup: [{ name: 'hang', run: 'sleep 30' }] } }), ['--step-timeout', '0.0001']);
251
+ eq('sub-millisecond timeout: timeout result', r.out.steps[0].result, 'timeout');
252
+ }
253
+ {
254
+ // a mid-run exception keeps the steps already collected
255
+ const gone = mktmp();
256
+ const prev = process.env.TMPDIR;
257
+ process.env.TMPDIR = gone;
258
+ try {
259
+ const r = go(proj({ uat: { setup: [
260
+ { name: 'a', run: 'true', check: `rm -rf ${gone}` },
261
+ { name: 'b', run: 'true' },
262
+ ] } }));
263
+ eq('mid-run exception: invalid with message', [r.out.status, typeof r.out.message], ['invalid', 'string']);
264
+ eq('mid-run exception: collected steps kept', r.out.steps.map((s) => [s.index, s.result]), [[0, 'skipped']]);
265
+ } finally {
266
+ if (prev === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = prev;
267
+ }
268
+ }
269
+ {
270
+ // the poll after run caps each check at the remaining ready time
271
+ const dir = mktmp();
272
+ const t0 = Date.now();
273
+ const r = go(proj({ uat: { setup: [{ name: 'slow', run: `touch ${dir}/ran`, check: `test -f ${dir}/ran && sleep 30` }] } }), ['--ready-timeout', '1']);
274
+ eq('slow check while polling: not-ready', r.out.steps[0].result, 'not-ready');
275
+ ok('poll check capped at the remaining ready time', Date.now() - t0 < 8000);
276
+ }
277
+ {
278
+ // not-ready: the run succeeds but the check never passes
279
+ const d = proj({ uat: { setup: [{ name: 'srv', run: 'true', check: 'false' }] } });
280
+ const t0 = Date.now();
281
+ const r = go(d, ['--ready-timeout', '1']);
282
+ eq('not-ready', [r.out.status, r.out.steps[0].result], ['failed', 'not-ready']);
283
+ ok('not-ready waited for the timeout', Date.now() - t0 >= 900);
284
+ // ready after a delayed check
285
+ const dir = mktmp();
286
+ const d2 = proj({ uat: { setup: [{ name: 'srv', run: `(sleep 1; touch ${dir}/up) >/dev/null 2>&1 &`, check: `test -f ${dir}/up` }] } });
287
+ const r2 = go(d2, ['--ready-timeout', '10']);
288
+ eq('a backgrounded process is detected through check', [r2.out.status, r2.out.steps[0].result], ['ready', 'ran']);
289
+ }
290
+ {
291
+ // timeouts
292
+ const t0 = Date.now();
293
+ const r = go(proj({ uat: { setup: [{ name: 'hang', run: 'sleep 30' }] } }), ['--step-timeout', '1']);
294
+ eq('hung run: timeout', [r.out.status, r.out.steps[0].result], ['failed', 'timeout']);
295
+ ok('hung run killed promptly', Date.now() - t0 < 10000);
296
+ // a hung check counts as failing before run: the step runs
297
+ const dir = mktmp();
298
+ const r2 = go(proj({ uat: { setup: [{ name: 'c', run: `touch ${dir}/ran`, check: 'sleep 30' }] } }), ['--step-timeout', '1', '--ready-timeout', '1']);
299
+ eq('hung check before run: step runs', fs.existsSync(path.join(dir, 'ran')), true);
300
+ eq('hung check while polling: not-ready', r2.out.steps[0].result, 'not-ready');
301
+ // a backgrounded child holding stdout must not hang the helper
302
+ const t1 = Date.now();
303
+ const r3 = go(proj({ uat: { setup: [{ name: 'bg', run: 'sleep 4 &' }] } }));
304
+ eq('background child does not block', r3.out.status, 'ready');
305
+ ok('returns before the background child ends', Date.now() - t1 < 3500);
306
+ }
307
+ {
308
+ // output: bounded tail and redaction
309
+ const big = go(proj({ uat: { setup: [{ name: 'big', run: 'yes aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | head -c 20000000; exit 1' }] } }));
310
+ ok('large output stays bounded', big.out.steps[0].output.length <= 2100);
311
+ const red = go(proj({ uat: {
312
+ setup: [{ name: 'leak', run: 'echo "token=$API_TOKEN pw=$QA_PW"; exit 1' }],
313
+ accounts: { qa: { user: 'u', secret: '$QA_PW' } },
314
+ } }), [], { API_TOKEN: 'abc123', QA_PW: 'zzz-secret' });
315
+ const o = red.out.steps[0].output;
316
+ ok('output redacts referenced values to $NAME', o.includes('token=$API_TOKEN') && o.includes('pw=$QA_PW') && !o.includes('abc123') && !o.includes('zzz-secret'));
317
+ const overlap = go(proj({ uat: { setup: [{ name: 'o', run: 'echo $SHORT $LONG; exit 1' }] } }), [], { SHORT: 'abc', LONG: 'abcdef' });
318
+ const oo = overlap.out.steps[0].output;
319
+ ok('overlapping values redact whole (longest first)', oo.includes('$SHORT $LONG') && !oo.includes('def'));
320
+ }
321
+
322
+ // --- end to end -----------------------------------------------------------------
323
+ {
324
+ const d = proj({ uat: { setup: [{ name: 'a', run: 'true' }] } });
325
+ const out = execFileSync('node', [scriptPath, 'run', '--repo', d], {
326
+ env: { ...process.env, CLAUDE_CONFIG_DIR: emptyHome, HOME: '/nonexistent-home' },
327
+ }).toString();
328
+ const parsed = JSON.parse(out.trim());
329
+ eq('e2e: ready', parsed.status, 'ready');
330
+ eq('e2e: one JSON line', out.trim().split('\n').length, 1);
331
+ let code = 0;
332
+ try { execFileSync('node', [scriptPath], { stdio: 'ignore' }); } catch (err) { code = err.status; }
333
+ eq('e2e: usage error exits 2', code, 2);
334
+ ok('e2e: helper is executable', (fs.statSync(scriptPath).mode & 0o111) !== 0);
335
+ }
336
+
337
+ // --- cleanup --------------------------------------------------------------------
338
+ for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true });
339
+
340
+ console.log(`uat-setup.test.js: ${passed} checks passed`);
@@ -213,6 +213,51 @@ for (const argv of [
213
213
  ok('clear absent: absent, exit 0', r2.code === 0 && r2.lines[0].status === 'absent');
214
214
  }
215
215
 
216
+ // --- clear also removes the session-ticket marker (#253) ---------------------
217
+ {
218
+ const ticketPath = (dir) => path.join(dir, '.trailhead', 'session-ticket');
219
+ const seed = (dir, { state, marker }) => {
220
+ fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
221
+ if (state) fs.writeFileSync(statePath(dir), '{}');
222
+ if (marker) fs.writeFileSync(ticketPath(dir), '253\n');
223
+ };
224
+ const combos = [
225
+ [true, true, 'cleared', 'removed'],
226
+ [true, false, 'cleared', 'absent'],
227
+ [false, true, 'absent', 'removed'],
228
+ [false, false, 'absent', 'absent'],
229
+ ];
230
+ for (const [state, marker, wantStatus, wantMarker] of combos) {
231
+ const dir = mktmp();
232
+ seed(dir, { state, marker });
233
+ const r = run(['clear', '--repo', dir], { cwd: '/' });
234
+ ok(`clear state=${state} marker=${marker}: exit 0, one line`, r.code === 0 && r.lines.length === 1);
235
+ ok(`clear state=${state} marker=${marker}: status ${wantStatus}`, r.lines[0].status === wantStatus);
236
+ ok(`clear state=${state} marker=${marker}: sessionTicket ${wantMarker}`, r.lines[0].sessionTicket === wantMarker);
237
+ ok(`clear state=${state} marker=${marker}: both files gone`, !fs.existsSync(statePath(dir)) && !fs.existsSync(ticketPath(dir)));
238
+ }
239
+ // partial failure: the first removal throws, the second still runs
240
+ {
241
+ const dir = mktmp();
242
+ seed(dir, { state: true, marker: true });
243
+ const broken = { ...fs, unlinkSync: (p) => { if (p === statePath(dir)) throw new Error('boom state'); return fs.unlinkSync(p); } };
244
+ const r = run(['clear', '--repo', dir], { cwd: '/', fsMod: broken });
245
+ ok('clear partial failure: exit 0', r.code === 0);
246
+ ok('clear partial failure: state skipped with message', r.lines[0].status === 'skipped' && /boom state/.test(r.lines[0].message));
247
+ ok('clear partial failure: marker still removed', r.lines[0].sessionTicket === 'removed' && !fs.existsSync(ticketPath(dir)));
248
+ ok('clear partial failure: state file kept', fs.existsSync(statePath(dir)));
249
+ }
250
+ // the marker failing never skips the work-state removal
251
+ {
252
+ const dir = mktmp();
253
+ seed(dir, { state: true, marker: true });
254
+ const broken = { ...fs, unlinkSync: (p) => { if (p === ticketPath(dir)) throw new Error('boom marker'); return fs.unlinkSync(p); } };
255
+ const r = run(['clear', '--repo', dir], { cwd: '/', fsMod: broken });
256
+ ok('clear marker failure: state cleared, marker skipped', r.code === 0 && r.lines[0].status === 'cleared' && r.lines[0].sessionTicket === 'skipped');
257
+ ok('clear marker failure: state file gone', !fs.existsSync(statePath(dir)));
258
+ }
259
+ }
260
+
216
261
  // --- pure functions ----------------------------------------------------------
217
262
  {
218
263
  ok('sanitizeState: non-object -> {}', JSON.stringify(sanitizeState(null)) === '{}' && JSON.stringify(sanitizeState('x')) === '{}');
@@ -69,9 +69,21 @@ only where no mod does (it says the panel is not available). Claude Code only: t
69
69
  projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex-projection.js`).
70
70
 
71
71
  - **What it shows**: the map as a link with its destination (dim), then `Frontier · n`,
72
- `In progress · n` and `Blocked · n` (an empty group is left out). A row is `#n title` (a link to
73
- the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket in play
74
- in this session (work state, not orphaned) is marked `▸` in the accent; blocked rows are dim. A
72
+ `In progress · n`, `UAT · n`, `Paused · n` and `Blocked · n` (an empty group is left out). A row is `#n title`
73
+ (a link to the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
74
+ in play in this session is marked `▸` in the accent and says `this session` in place of its holder;
75
+ blocked rows are dim. The `ownTicket` atom keeps that mark when another session's work state takes
76
+ over the shared file (a state stamped with another session leaves it; a missing file clears it only
77
+ when the last state read was this session's, i.e. its own Resolve); with no own ticket the work
78
+ state decides, as before. A ticket carrying `trailhead:paused` (set by pause, cleared by resume or a
79
+ `work`/`quick` claim) shows once, under `Paused`, between In progress and Blocked, with its holder
80
+ when assigned and never a `work` button; the label wins over `trailhead:blocked` and an assignee. A
81
+ ticket carrying `trailhead:uat` (a pending UAT follow-up, a marker never a state) shows once, under `UAT`,
82
+ between In progress and Paused, whatever its state (`paused` wins), keeping its `work` button or holder, with a
83
+ dim `tests <parent title> #<n>` line under it. The parent comes from the `UAT of:` line of its body, read once
84
+ per refresh by `gh issue list --label trailhead:uat --label trailhead:map-<n>` (`--label trailhead:whiteboard`
85
+ on the whiteboard view) `--json number,body`, capped like the ticket list at 200: beyond that, or when the read
86
+ fails, a UAT row is drawn without its parent line (the rows still group by label). A
75
87
  frontier row has a `work` button that clears the conversation (`/clear`) and then runs
76
88
  `/trailhead:work <n>` in the new one, both through `$.command.run`, which the host queues behind a
77
89
  running turn; the row shows `queued` until the calls settle (across the reset too), and a
@@ -84,9 +96,12 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
84
96
  closes the panel. A status too long for the room beside the buttons moves to a row of its own under them. Titles come from GitHub and are untrusted:
85
97
  flattened, stripped of control, zero-width and bidi characters, only drawn. Other states: `Loading tickets…`
86
98
  on the first fetch, `No active map. Run /trailhead:map to pick one.`, `No open tickets. Fog may remain: /trailhead:map`.
87
- - **Maps list and viewing**: with more than one open map (`gh issue list --label trailhead:map --state open`),
99
+ - **Maps list and viewing**: with more than one open map (one `gh api graphql` call for the open
100
+ `trailhead:map` issues with their sub-issue counts, bounded at 100 maps, no pagination),
88
101
  a `Maps · n` group sits under the header: one row per map, sorted by number, `#n title` cut to the width.
89
- The active map has `●` in the accent and an `active` aside; the viewed map is bold; every other row is a
102
+ Each row ends with a dim `<open> open / <total>` count; if the call fails or is unreadable the pane falls
103
+ back to `gh issue list --label trailhead:map --state open` and draws the names only.
104
+ The active map has `●` in the accent and an `active` aside (after the counts, `7 open / 23 · active`); the viewed map is bold; every other row is a
90
105
  `Button`. A press shows that map's tickets, burn-down and `work` buttons (browse); it never writes
91
106
  `.trailhead/active-map`. Pressing the active map's row, or `back to active` (in the dim line
92
107
  `viewing #n · active #m` shown while another map is viewed), goes back. A `work` press on a viewed
@@ -98,6 +113,23 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
98
113
  `back` button). The maps read is the 4th of a refresh; a failure of it alone keeps the last list and sets no
99
114
  footer error. Each refresh captures its target (viewed, else active) once and rechecks it before every state
100
115
  write, so a refresh in flight when the view changes never commits the old map's tickets.
116
+ - **Whiteboard row**: above the Maps list (in the map view, its loading state and the no-active-map view,
117
+ even with a single map) a `Whiteboard` button switches the pane to the repo index, the `isDashboard` atom.
118
+ Beside the name it carries the whiteboard's counts in a map row's shape, dim: `<open> open / <total>`
119
+ (open whiteboard tickets over all of them). They come from the maps read's single GraphQL call (two
120
+ aliased `totalCount` connections on `trailhead:whiteboard`), never a per-ticket query. If that call
121
+ fails or its whiteboard fields are unreadable the row is drawn name-only, no counts and no error (the
122
+ `whiteboard` field of the `mapPane` atom is `null`; a failed read never keeps stale counts).
123
+ - **Dashboard view**: the `Whiteboard` row (the header has no button for it) opens the repo index. It draws a `Dashboard` title (a link
124
+ to the pinned `trailhead:dashboard` issue when there is one) with a `map` button that goes back to the map
125
+ shown before (the view is kept), a `Maps · n` group with every open map pressable (a press leaves the
126
+ dashboard and views that map, the active one marked `●`), and a `Whiteboard · <n> open · <m> assigned` section (counted
127
+ over the listed tickets; `200+ open` when the list is capped): the open
128
+ `trailhead:ticket` + `trailhead:whiteboard` tickets as Frontier / In progress / UAT / Paused / Blocked, with the same rows,
129
+ holders and `work` buttons (the work verb takes a whiteboard ticket as is). Unverified ones are counted only.
130
+ Its reads replace the map's in a refresh: the whiteboard list, `gh issue list --label trailhead:dashboard
131
+ --json number,url` and the maps list; a failed list keeps the last one with the footer reason, a failed index
132
+ read alone keeps the last link. Data in the `dashboardPane` atom; the mode survives `/clear` like the view.
101
133
  - **Burn-down**: below the groups, a `Burn-down` section shows the shown map's open-ticket count
102
134
  over time, from its first ticket to now. A dim line gives `open <o> · done <d> · removed <r> · peak <p>`
103
135
  (`removed` = closed with `trailhead:superseded` or `trailhead:out-of-scope`; `done` = other closed
@@ -141,7 +173,7 @@ projection leaves the `panel` verb out (`CODEX_MOD_ONLY_VERBS` in `bin/lib/codex
141
173
  - **Constants** (`map-pane.tsx`): `PANE_ID = 'trailhead-map'`, `PANE_TITLE = 'Map'`,
142
174
  `MIN_REFRESH_MS = 15_000`, `TIMER_MS = 180_000`, `GH_TIMEOUT_MS = 20_000`,
143
175
  `PANEL_COMMAND = 'trailhead:panel'`, `MAPS_LIMIT = 100` (not exported); `map-pane-view.ts`: `LIST_LIMIT = 200`, `HISTORY_LIMIT = 5000`.
144
- - **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `pendingWork`, `paneAutoOpened`. Module variables hold the queue and
176
+ - **Atoms**: `mapPane` (with `maps`, the open maps), `activeMap`, `viewedMap` (`null` follows the active map), `isDashboard`, `dashboardPane`, `pendingWork`, `paneAutoOpened`. Module variables hold the queue and
145
177
  the timers, what `/clear` carries over (`carried`) and the `work` press in flight (`workInFlight`),
146
178
  because `/clear` empties the atoms while they are needed.
147
179
  - **Validator note**: `claude plugin validate` follows `$` only inside one file and refuses two