@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.
- package/README.it.md +118 -30
- package/README.md +107 -29
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -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 +1 -1
- package/bin/lib/codex-projection.test.js +41 -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 +3 -3
- package/bin/trailhead.test.js +341 -1
- 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/hooks/lib/base-preflight.test.js +513 -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 +409 -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/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/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
- package/plugins/trailhead/hooks/mod/README.md +38 -6
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
- package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
- package/plugins/trailhead/hooks/mod/register.tsx +15 -2
- package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
- 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-search-guard.js +43 -7
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
- package/plugins/trailhead/skills/_shared/choices.md +4 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- 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 +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- 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/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 +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- 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 +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -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 +340 -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-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
- package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
- package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
- package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
- 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`
|
|
73
|
-
the issue) and the type, dim. Every assigned row shows its holder as `@login`; the ticket
|
|
74
|
-
in this session
|
|
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
|
|
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
|
-
|
|
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
|