@marcomigozzi/trailhead 0.8.1 → 0.10.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 +20 -17
- package/README.md +20 -17
- package/bin/lib/codex-projection.js +14 -6
- package/bin/lib/codex-projection.test.js +46 -2
- package/bin/trailhead.js +21 -10
- package/bin/trailhead.test.js +326 -0
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/commands/prune.md +6 -0
- package/plugins/trailhead/commands/quick.md +1 -1
- package/plugins/trailhead/hooks/hooks.json +4 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +102 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +99 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +221 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +222 -0
- package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.js +61 -35
- package/plugins/trailhead/hooks/trailhead-issue-injection-scanner.test.js +76 -0
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +4 -15
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +31 -0
- package/plugins/trailhead/skills/_shared/choices.md +5 -1
- package/plugins/trailhead/skills/_shared/configuration-reference.md +23 -16
- package/plugins/trailhead/skills/_shared/configuration.md +1 -1
- package/plugins/trailhead/skills/_shared/load-first.md +2 -0
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/principles.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +9 -7
- package/plugins/trailhead/skills/_shared/substrate-commands.md +41 -5
- package/plugins/trailhead/skills/_shared/substrate.md +10 -6
- package/plugins/trailhead/skills/_shared/teamwork.md +19 -12
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +13 -4
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +3 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +12 -2
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
- package/plugins/trailhead/skills/trailhead-manage/references/pruning.md +48 -0
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +4 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +18 -16
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -2
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +12 -9
package/bin/trailhead.js
CHANGED
|
@@ -213,14 +213,15 @@ function claudePaths(configDir) {
|
|
|
213
213
|
settings: path.join(configDir, 'settings.json'),
|
|
214
214
|
};
|
|
215
215
|
}
|
|
216
|
-
const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-secret-read-guard.js', 'trailhead-check-update.js'];
|
|
216
|
+
const HOOK_FILES = ['trailhead-commit-guard.js', 'trailhead-issue-injection-scanner.js', 'trailhead-secret-guard.js', 'trailhead-install-guard.js', 'trailhead-search-guard.js', 'trailhead-secret-read-guard.js', 'trailhead-body-guard.js', 'trailhead-check-update.js'];
|
|
217
217
|
// Runtime libs the hook scripts require (trailhead-commit-guard.js does
|
|
218
218
|
// require('./lib/commit-message-check.js'); trailhead-search-guard.js and
|
|
219
219
|
// trailhead-secret-read-guard.js both do require('./lib/shell-scan.js'), #135
|
|
220
|
-
// follow-up
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
220
|
+
// follow-up; trailhead-body-guard.js and trailhead-secret-guard.js both do
|
|
221
|
+
// require('./lib/gh-subcommand.js'), #153 follow-up). Copied by name, never a
|
|
222
|
+
// recursive sweep: the Claude hooks/lib dir is shared with other plugins, so
|
|
223
|
+
// we must not clobber a co-tenant's lib nor ship our own *.test.js files.
|
|
224
|
+
const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js', 'gh-subcommand.js'];
|
|
224
225
|
|
|
225
226
|
// Copy the hook scripts and their runtime libs into a destination hooks dir
|
|
226
227
|
// (Claude's ~/.claude/hooks or Codex's skills/trailhead/hooks). Shared by both
|
|
@@ -231,7 +232,7 @@ const HOOK_LIB_FILES = ['commit-message-check.js', 'shell-scan.js'];
|
|
|
231
232
|
// live too, matching skills/commands. Never symlink the whole hooks/ dir: it is
|
|
232
233
|
// shared with other plugins, so only the curated allowlist is touched. rmrf the
|
|
233
234
|
// dest first so a reinstall over an existing file or symlink never hits EEXIST.
|
|
234
|
-
function copyHookScripts(destHooksDir, { useSymlink = false } = {}) {
|
|
235
|
+
function copyHookScripts(destHooksDir, { useSymlink = false, exclude = [] } = {}) {
|
|
235
236
|
ensure(destHooksDir);
|
|
236
237
|
const put = (src, dest) => {
|
|
237
238
|
rmrf(dest);
|
|
@@ -242,7 +243,7 @@ function copyHookScripts(destHooksDir, { useSymlink = false } = {}) {
|
|
|
242
243
|
fs.chmodSync(dest, 0o755);
|
|
243
244
|
}
|
|
244
245
|
};
|
|
245
|
-
for (const f of HOOK_FILES) put(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
|
|
246
|
+
for (const f of HOOK_FILES.filter((f) => !exclude.includes(f))) put(path.join(SRC, 'hooks', f), path.join(destHooksDir, f));
|
|
246
247
|
if (HOOK_LIB_FILES.length) {
|
|
247
248
|
const libDest = path.join(destHooksDir, 'lib');
|
|
248
249
|
ensure(libDest);
|
|
@@ -329,6 +330,7 @@ function uninstallClaude(configDir) {
|
|
|
329
330
|
stripHook(s, 'PreToolUse', 'trailhead-install-guard.js');
|
|
330
331
|
stripHook(s, 'PreToolUse', 'trailhead-search-guard.js');
|
|
331
332
|
stripHook(s, 'PreToolUse', 'trailhead-secret-read-guard.js');
|
|
333
|
+
stripHook(s, 'PreToolUse', 'trailhead-body-guard.js');
|
|
332
334
|
stripHook(s, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
|
|
333
335
|
stripHook(s, 'SessionStart', 'trailhead-check-update.js');
|
|
334
336
|
writeJSON(P.settings, s);
|
|
@@ -375,6 +377,7 @@ function installClaude(configDir, { useSymlink }) {
|
|
|
375
377
|
addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-secret-guard.js')),
|
|
376
378
|
addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-install-guard.js')),
|
|
377
379
|
addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-search-guard.js')),
|
|
380
|
+
addHook(s, 'PreToolUse', 'Bash', hookCmd('trailhead-body-guard.js')),
|
|
378
381
|
addHook(s, 'PreToolUse', 'Read|Bash', hookCmd('trailhead-secret-read-guard.js')),
|
|
379
382
|
addHook(s, 'PostToolUse', 'Bash', hookCmd('trailhead-issue-injection-scanner.js')),
|
|
380
383
|
addHook(s, 'SessionStart', '', hookCmd('trailhead-check-update.js')),
|
|
@@ -572,9 +575,12 @@ function installCodex(configDir, { useSymlink }) {
|
|
|
572
575
|
const pkgVersion = (readJSON(path.join(SRC, '.claude-plugin', 'plugin.json')).version || '').trim();
|
|
573
576
|
if (pkgVersion) fs.writeFileSync(L.versionFile, pkgVersion + '\n');
|
|
574
577
|
|
|
575
|
-
// Hooks: copy the
|
|
576
|
-
// ~/.codex/hooks.json (same shape as Claude's
|
|
577
|
-
|
|
578
|
+
// Hooks: copy the guard scripts this host actually uses into the skill dir
|
|
579
|
+
// and register them in ~/.codex/hooks.json (same shape as Claude's
|
|
580
|
+
// settings.json hooks block). search-guard is excluded: it is
|
|
581
|
+
// Claude-Code-specific (see codexHookEntries), so it is neither copied nor
|
|
582
|
+
// registered here.
|
|
583
|
+
copyHookScripts(L.hooksScriptsDir, { useSymlink, exclude: ['trailhead-search-guard.js'] });
|
|
578
584
|
const h = readJSON(L.hooksJson);
|
|
579
585
|
for (const e of codexHookEntries(L.hooksScriptsDir)) addHook(h, e.event, e.matcher, e.command);
|
|
580
586
|
writeJSON(L.hooksJson, h);
|
|
@@ -679,8 +685,13 @@ function uninstallCodex(configDir) {
|
|
|
679
685
|
stripHook(h, 'PreToolUse', 'trailhead-commit-guard.js');
|
|
680
686
|
stripHook(h, 'PreToolUse', 'trailhead-secret-guard.js');
|
|
681
687
|
stripHook(h, 'PreToolUse', 'trailhead-install-guard.js');
|
|
688
|
+
// search-guard is no longer projected to Codex (#169: it is
|
|
689
|
+
// Claude-Code-specific), so it is never registered by a current install;
|
|
690
|
+
// this strip is retained only to clean up a stale entry left by a
|
|
691
|
+
// pre-#169 install that did register it.
|
|
682
692
|
stripHook(h, 'PreToolUse', 'trailhead-search-guard.js');
|
|
683
693
|
stripHook(h, 'PreToolUse', 'trailhead-secret-read-guard.js');
|
|
694
|
+
stripHook(h, 'PreToolUse', 'trailhead-body-guard.js');
|
|
684
695
|
stripHook(h, 'PostToolUse', 'trailhead-issue-injection-scanner.js');
|
|
685
696
|
stripHook(h, 'SessionStart', 'trailhead-check-update.js');
|
|
686
697
|
writeJSON(L.hooksJson, h);
|
package/bin/trailhead.test.js
CHANGED
|
@@ -134,6 +134,315 @@ for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-v
|
|
|
134
134
|
ok(`source: ${cl}/SKILL.md does NOT duplicate the core-list signature line`, !fs.readFileSync(path.join(sourceSkillsDir, cl, 'SKILL.md'), 'utf8').includes(loadFirstSignature));
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// --- #146: single-sourced ticket-language directive (anti-drift) -------------
|
|
138
|
+
// The standing ticket-language rule (write Issue prose + commit bodies in
|
|
139
|
+
// config.ticket.language, decoupled from the chat language) lives in ONE place,
|
|
140
|
+
// _shared/ticket-language.md; it is listed in the load contract's six-file core
|
|
141
|
+
// and REFERENCED (never restated) by the engine references. Mirrors the #139
|
|
142
|
+
// load-first invariant so the directive can't silently drift.
|
|
143
|
+
ok('codex: _shared/ticket-language.md projected', fs.existsSync(path.join(codexDir, 'skills', '_shared', 'ticket-language.md')));
|
|
144
|
+
for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
|
|
145
|
+
ok(`codex: ${cl}/../_shared/ticket-language.md resolves as a sibling`, fs.existsSync(path.join(codexDir, 'skills', cl, '..', '_shared', 'ticket-language.md')));
|
|
146
|
+
}
|
|
147
|
+
// The single source states the rule's substance: the canonical config key AND
|
|
148
|
+
// the decoupling clause (the heart of the rule), not merely a heading.
|
|
149
|
+
const ticketLangSourcePath = path.join(sourceSkillsDir, '_shared', 'ticket-language.md');
|
|
150
|
+
const ticketLangSource = fs.readFileSync(ticketLangSourcePath, 'utf8');
|
|
151
|
+
const ticketLangKey = '`config.ticket.language`';
|
|
152
|
+
const ticketLangDecoupling = '**independent of the language the agent converses in**';
|
|
153
|
+
ok('source: ticket-language.md states the rule (config.ticket.language + decoupling clause)',
|
|
154
|
+
ticketLangSource.includes(ticketLangKey) && ticketLangSource.includes(ticketLangDecoupling));
|
|
155
|
+
// The load contract lists it in the six-file core (item 2), by path reference.
|
|
156
|
+
const loadFirstSource = fs.readFileSync(loadFirstSourcePath, 'utf8');
|
|
157
|
+
ok('source: load-first.md references ../_shared/ticket-language.md', loadFirstSource.includes('../_shared/ticket-language.md'));
|
|
158
|
+
// The engine references point AT the single source (path reference), not restate it.
|
|
159
|
+
const ticketLangAutoSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md'), 'utf8');
|
|
160
|
+
const ticketLangCaptureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
|
|
161
|
+
ok('source: auto.md references ticket-language.md', ticketLangAutoSource.includes('ticket-language.md'));
|
|
162
|
+
ok('source: capture.md references ticket-language.md', ticketLangCaptureSource.includes('ticket-language.md'));
|
|
163
|
+
// Source-level anti-drift: the substantive decoupling clause lives in EXACTLY
|
|
164
|
+
// one file across the whole skills tree, _shared/ticket-language.md. A global
|
|
165
|
+
// single-occurrence scan (not an enumerated per-file list) so any future doc
|
|
166
|
+
// that restates the verbatim directive is caught, including reachable
|
|
167
|
+
// shared-core references beyond the load contract and the two engine references
|
|
168
|
+
// (e.g. configuration-reference.md, choices.md). The legitimate paraphrases in
|
|
169
|
+
// configuration-reference.md/capture.md reword the rule and do not match this
|
|
170
|
+
// bolded verbatim clause, so they don't trip the scan.
|
|
171
|
+
const mdFilesUnder = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
172
|
+
const full = path.join(dir, e.name);
|
|
173
|
+
return e.isDirectory() ? mdFilesUnder(full) : (e.name.endsWith('.md') ? [full] : []);
|
|
174
|
+
});
|
|
175
|
+
const ticketLangClauseFiles = mdFilesUnder(sourceSkillsDir).filter((f) => fs.readFileSync(f, 'utf8').includes(ticketLangDecoupling));
|
|
176
|
+
ok('source: ticket-language clause appears in exactly one skills-tree file', ticketLangClauseFiles.length === 1);
|
|
177
|
+
ok('source: that one file is _shared/ticket-language.md', ticketLangClauseFiles.length === 1 && path.basename(ticketLangClauseFiles[0]) === 'ticket-language.md');
|
|
178
|
+
|
|
179
|
+
// --- #147: chart's close anchors the next step to /trailhead:work -----------
|
|
180
|
+
// Source-level invariant: charting.md renders a codified next-step block at the
|
|
181
|
+
// chart/adopt close, led by /clear, anchored to /trailhead:work (the map-frontier
|
|
182
|
+
// verb) with /trailhead:quick only as the off-map alternative, single-sourced
|
|
183
|
+
// once in Mode 1 step 6 with Mode 1-bis reaching it via "As Mode 1, steps 4-6".
|
|
184
|
+
const chartingSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'charting.md'), 'utf8');
|
|
185
|
+
ok('source: charting.md close carries a codified next-step block', /Next-step block \(the close of chart/.test(chartingSource));
|
|
186
|
+
// Structural: the rendered blockquote must LEAD with a standalone /clear bullet
|
|
187
|
+
// (guards the "never demote /clear to a trailing parenthetical" regression #147 exists for).
|
|
188
|
+
ok('source: charting.md next-step block leads with a standalone /clear bullet', /> \*\*Prossimo passo:\*\*\n> - `\/clear`/.test(chartingSource));
|
|
189
|
+
// Structural: /trailhead:work is the primary bullet, carrying the number (name in prose).
|
|
190
|
+
ok('source: charting.md next-step block makes /trailhead:work the primary move with the number', /`\/trailhead:work <numero>` per lavorare \*\*<nome/.test(chartingSource));
|
|
191
|
+
// Structural: /trailhead:quick is demoted to the last "in alternativa ... fuori mappa" bullet.
|
|
192
|
+
ok('source: charting.md next-step block demotes /trailhead:quick to the off-map alternative bullet', /> - in alternativa `\/trailhead:quick[^\n]*fuori mappa/.test(chartingSource));
|
|
193
|
+
ok('source: charting.md next-step block presents several independent tickets as a set to choose from', chartingSource.includes('set to choose from'));
|
|
194
|
+
ok('source: charting.md next-step block frames quick as the off-map alternative', chartingSource.includes('only as the off-map alternative'));
|
|
195
|
+
ok('source: charting.md single-sources the block via the "As Mode 1, steps 4-6" reference', /As Mode 1, steps 4[–-]6/.test(chartingSource));
|
|
196
|
+
const chartSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'SKILL.md'), 'utf8');
|
|
197
|
+
ok('source: trailhead-chart SKILL.md config-offer points at the next-step block', chartSkillSource.includes('next-step block') && chartSkillSource.includes('/trailhead:work'));
|
|
198
|
+
|
|
199
|
+
// --- #149: capture confirmation anchors the next step to /trailhead:work -----
|
|
200
|
+
// Source-level invariant, mirroring #147 for chart: capture.md's confirmation-line
|
|
201
|
+
// rule names /trailhead:work as the anchor (full engine, keeps the map record) and
|
|
202
|
+
// demotes /trailhead:quick to the off-map / no-split alternative, offered alongside,
|
|
203
|
+
// never in its place. Localized + ordered assertions (not broad substrings): the anchor
|
|
204
|
+
// clause precedes the quick-demotion clause within the Confirmation line. Targets the
|
|
205
|
+
// capture confirmation line specifically, NOT a blanket /trailhead:quick ban
|
|
206
|
+
// (session-handoff.md legitimately offers /trailhead:quick <n> for the next loose ticket).
|
|
207
|
+
const captureSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-capture', 'references', 'capture.md'), 'utf8');
|
|
208
|
+
ok('source: capture.md confirmation anchors the next step to /trailhead:work', /[Aa]nchor the next step to `\/trailhead:work <n>`, always/.test(captureSource));
|
|
209
|
+
ok('source: capture.md demotes /trailhead:quick to only the no-split alternative, after the work anchor',
|
|
210
|
+
/[Aa]nchor the next step to `\/trailhead:work <n>`[\s\S]{0,400}`\/trailhead:quick <n>` is \*\*only\*\* the no-split alternative/.test(captureSource));
|
|
211
|
+
ok('source: capture.md says quick is offered alongside work, never in its place', captureSource.includes('never in its place'));
|
|
212
|
+
ok('source: capture.md keeps /trailhead:work the anchor even for a whiteboard capture', /whiteboard[\s\S]{0,200}the anchor stays `\/trailhead:work <n>`/.test(captureSource));
|
|
213
|
+
|
|
214
|
+
// --- #179: `quick <n>` on a map ticket is never "off the map" ----------------
|
|
215
|
+
// Source-level invariant: `quick <n>` on an EXISTING ticket does not leave the
|
|
216
|
+
// map (it keeps its trailhead:map-<n> label and sub-issue edge, and dependent-
|
|
217
|
+
// unblocking still runs); "off the map" describes only `quick "<text>"`, which
|
|
218
|
+
// opens a NEW whiteboard ticket. capture.md, session-handoff.md, and
|
|
219
|
+
// trailhead-work/SKILL.md must no longer carry the old "that same work done
|
|
220
|
+
// off the map" phrasing, nor describe `quick <n>` itself as off the map.
|
|
221
|
+
const sessionHandoffSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'session-handoff.md'), 'utf8');
|
|
222
|
+
const workSkillSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'SKILL.md'), 'utf8');
|
|
223
|
+
const offMapQuickN = /`?quick <n>`?[^.\n]{0,80}off the map/i;
|
|
224
|
+
// The explicit "never describe ... as off the map" clause legitimately puts
|
|
225
|
+
// `quick <n>` near "off the map" in the same sentence, so strip it before
|
|
226
|
+
// running the negative proximity check below.
|
|
227
|
+
const neverDescribeClause = /[Nn]ever describe `quick <n>`[^\n]*?off the map\.?/;
|
|
228
|
+
const sessionHandoffMinusClause = sessionHandoffSource.replace(neverDescribeClause, '');
|
|
229
|
+
ok('source: capture.md drops the old "that same work done off the map" phrasing', !captureSource.includes('that same work done off the map'));
|
|
230
|
+
ok('source: session-handoff.md drops the old "that same work done off the map" phrasing', !sessionHandoffSource.includes('that same work done off the map'));
|
|
231
|
+
ok('source: trailhead-work SKILL.md drops the old "that same work done off the map" phrasing', !workSkillSourceFor179.includes('that same work done off the map'));
|
|
232
|
+
ok('source: capture.md never describes quick <n> as off the map', !offMapQuickN.test(captureSource));
|
|
233
|
+
ok('source: session-handoff.md never describes quick <n> as off the map (outside the explicit never-clause)', !offMapQuickN.test(sessionHandoffMinusClause));
|
|
234
|
+
ok('source: trailhead-work SKILL.md never describes quick <n> as off the map', !offMapQuickN.test(workSkillSourceFor179));
|
|
235
|
+
ok('source: session-handoff.md explicitly says never to describe quick <n> on a map ticket as off the map',
|
|
236
|
+
neverDescribeClause.test(sessionHandoffSource));
|
|
237
|
+
ok('source: capture.md says a map ticket stays on its map', /a map ticket stays on its map/.test(captureSource));
|
|
238
|
+
const ticketEnginesSourceFor179 = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
|
|
239
|
+
ok('source: ticket-engines.md never labels quick generically as off-map', !/`quick`[^.\n]{0,40}off-map/.test(ticketEnginesSourceFor179));
|
|
240
|
+
|
|
241
|
+
// --- #151: eager edge drop at close (superseded / out-of-scope only) ---------
|
|
242
|
+
// Source-level invariant: closing a ticket as trailhead:superseded or
|
|
243
|
+
// trailhead:out-of-scope drops its native sub-issue edge at close (reusing the
|
|
244
|
+
// 404-tolerant DELETE from #150), while a genuinely-resolved close leaves the
|
|
245
|
+
// edge in place (it feeds GitHub's native progress bar; /trailhead:prune is its
|
|
246
|
+
// only shedder). Asserts the canonical rule, every operative close site, and a
|
|
247
|
+
// NEGATIVE invariant on the resolved-close path.
|
|
248
|
+
const substrateCommandsSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'substrate-commands.md'), 'utf8');
|
|
249
|
+
ok('source: substrate-commands.md documents the eager edge drop at close (superseded / out-of-scope only)',
|
|
250
|
+
/[Ee]ager edge drop at close \(superseded \/ out-of-scope only\)/.test(substrateCommandsSource));
|
|
251
|
+
ok('source: substrate-commands.md fixes the label -> DELETE -> close ordering for the eager drop',
|
|
252
|
+
/apply the state label first, then DELETE the edge, then `gh issue close`/.test(substrateCommandsSource));
|
|
253
|
+
ok('source: substrate-commands.md eager drop excludes resolved closes (progress bar / prune)',
|
|
254
|
+
// Proximity-scoped so each conjunct is load-bearing on the NEW paragraph's own
|
|
255
|
+
// text, not on the pre-existing "progress bar" mentions elsewhere in the file.
|
|
256
|
+
/Only these two closes shed eagerly[\s\S]{0,300}progress bar/.test(substrateCommandsSource) &&
|
|
257
|
+
/Resolved edges stay[\s\S]{0,160}`\/trailhead:prune`'s job/.test(substrateCommandsSource));
|
|
258
|
+
|
|
259
|
+
const substrateSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'substrate.md'), 'utf8');
|
|
260
|
+
ok('source: substrate.md out-of-scope label notes the edge is dropped at close',
|
|
261
|
+
/`trailhead:out-of-scope`[\s\S]{0,260}native sub-issue edge is dropped at close/.test(substrateSource));
|
|
262
|
+
ok('source: substrate.md superseded label notes the edge is dropped at close',
|
|
263
|
+
/`trailhead:superseded`[\s\S]{0,260}native sub-issue edge is dropped at close/.test(substrateSource));
|
|
264
|
+
|
|
265
|
+
const teamworkSource = fs.readFileSync(path.join(sourceSkillsDir, '_shared', 'teamwork.md'), 'utf8');
|
|
266
|
+
ok('source: teamwork.md supersede close drops the native sub-issue edge',
|
|
267
|
+
/Supersede the original[\s\S]{0,400}drop its native sub-issue edge from the map/.test(teamworkSource));
|
|
268
|
+
|
|
269
|
+
const workSkillSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'SKILL.md'), 'utf8');
|
|
270
|
+
ok('source: trailhead-work SKILL.md out-of-scope close drops the native sub-issue edge',
|
|
271
|
+
/out of scope\*\* \(label, then \*\*drop its native sub-issue edge\*\*/.test(workSkillSource));
|
|
272
|
+
|
|
273
|
+
const inboxSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-chart', 'references', 'inbox.md'), 'utf8');
|
|
274
|
+
ok('source: inbox.md out-of-scope close drops the native sub-issue edge',
|
|
275
|
+
/`trailhead:out-of-scope`, \*\*drop its native sub-issue edge\*\*/.test(inboxSource));
|
|
276
|
+
ok('source: inbox.md dropped-fog close drops any native sub-issue edge',
|
|
277
|
+
/drop any native sub-issue edge to reclaim a slot/.test(inboxSource));
|
|
278
|
+
|
|
279
|
+
// NEGATIVE invariant: the resolved-close paths (build/bug Resolve) must NOT
|
|
280
|
+
// carry an eager edge-drop instruction; resolved edges stay for the progress bar.
|
|
281
|
+
const ticketEnginesSource = fs.readFileSync(path.join(sourceSkillsDir, 'trailhead-work', 'references', 'ticket-engines.md'), 'utf8');
|
|
282
|
+
ok('source: ticket-engines.md resolved-close path does NOT drop the native sub-issue edge',
|
|
283
|
+
!/drop (its|any) native sub-issue edge/.test(ticketEnginesSource));
|
|
284
|
+
|
|
285
|
+
// --- #178: drive-mode ask (AI-driven vs human-driven) at every UAT start -----
|
|
286
|
+
// Single-sourced in techniques/acceptance-testing.md as the "Drive mode" bullet;
|
|
287
|
+
// every other call-site (ticket-engines.md, auto.md, choices.md) references it
|
|
288
|
+
// by name ("drive-mode ask") rather than restating its clauses. Extract the
|
|
289
|
+
// bullet's own span (its marker line through the end of its indented ` - `
|
|
290
|
+
// sub-bullets) and pin each clause inside that span, mirroring the #139/#146
|
|
291
|
+
// source-level anti-drift style.
|
|
292
|
+
function extractBulletSpan(source, markerLine) {
|
|
293
|
+
const lines = source.split('\n');
|
|
294
|
+
const startIdx = lines.findIndex((l) => l.includes(markerLine));
|
|
295
|
+
if (startIdx === -1) return '';
|
|
296
|
+
let endIdx = startIdx + 1;
|
|
297
|
+
while (endIdx < lines.length && lines[endIdx].startsWith(' - ')) endIdx++;
|
|
298
|
+
return lines.slice(startIdx, endIdx).join('\n');
|
|
299
|
+
}
|
|
300
|
+
const driveModeMarker = '- **Drive mode: ask who drives the UAT.**';
|
|
301
|
+
const acceptanceTestingSourcePath = path.join(sourceSkillsDir, '_shared', 'techniques', 'acceptance-testing.md');
|
|
302
|
+
const acceptanceTestingSource = fs.readFileSync(acceptanceTestingSourcePath, 'utf8');
|
|
303
|
+
const driveModeSpan = extractBulletSpan(acceptanceTestingSource, driveModeMarker);
|
|
304
|
+
ok('source: acceptance-testing.md carries the Drive-mode bullet', driveModeSpan.length > 0);
|
|
305
|
+
for (const phrase of [
|
|
306
|
+
'**before any step runs**',
|
|
307
|
+
'**wait for an explicit answer**',
|
|
308
|
+
'**silence or an ambiguous reply re-asks**',
|
|
309
|
+
'**One-path skip**',
|
|
310
|
+
'**state the reason in one line**',
|
|
311
|
+
'VERIFY',
|
|
312
|
+
'only for the steps it cannot perform',
|
|
313
|
+
'runs unchanged',
|
|
314
|
+
'**in both modes**',
|
|
315
|
+
'acceptance.browser: off',
|
|
316
|
+
'without the confirm gate',
|
|
317
|
+
]) {
|
|
318
|
+
ok(`source: acceptance-testing.md Drive-mode span pins "${phrase}"`, driveModeSpan.includes(phrase));
|
|
319
|
+
}
|
|
320
|
+
// The three start paths.
|
|
321
|
+
ok('source: Drive-mode span names build/bug Verify as a start path', driveModeSpan.includes('Verify'));
|
|
322
|
+
ok("source: Drive-mode span names the Pending-UAT guard's option (a) as a start path", driveModeSpan.includes('Pending-UAT guard'));
|
|
323
|
+
ok('source: Drive-mode span names a UAT follow-up (UAT of:) as a start path', driveModeSpan.includes('UAT of:'));
|
|
324
|
+
// The three performability classes.
|
|
325
|
+
ok('source: Drive-mode span names the physical-device/hardware performability class', driveModeSpan.includes('a physical device or hardware'));
|
|
326
|
+
ok('source: Drive-mode span names the credentials/accounts performability class', driveModeSpan.includes('credentials or accounts it does not hold'));
|
|
327
|
+
ok('source: Drive-mode span names the human sensory-judgement performability class', driveModeSpan.includes('a human sensory judgement'));
|
|
328
|
+
|
|
329
|
+
// The "Pending UAT must stay tracked" bullet references the ask for BOTH (a)
|
|
330
|
+
// run-now and (b) the follow-up (grouped: two mentions in the same bullet).
|
|
331
|
+
const pendingUatBulletMarker = '**Pending UAT must stay tracked: a closed ticket is not tracking.**';
|
|
332
|
+
const pendingUatLine = acceptanceTestingSource.split('\n').find((l) => l.includes(pendingUatBulletMarker)) || '';
|
|
333
|
+
ok('source: Pending-UAT bullet exists', pendingUatLine.length > 0);
|
|
334
|
+
ok('source: Pending-UAT bullet references the drive-mode ask for both (a) and the follow-up',
|
|
335
|
+
(pendingUatLine.match(/drive-mode ask/g) || []).length >= 2);
|
|
336
|
+
|
|
337
|
+
// Single-source anti-drift: the bullet's own signature phrase appears in
|
|
338
|
+
// exactly one file across the whole skills tree (mirrors #146).
|
|
339
|
+
const driveModeSignature = 'ask who drives the UAT';
|
|
340
|
+
const driveModeSignatureFiles = mdFilesUnder(sourceSkillsDir).filter((f) => fs.readFileSync(f, 'utf8').includes(driveModeSignature));
|
|
341
|
+
ok('source: "ask who drives the UAT" signature appears in exactly one skills-tree file', driveModeSignatureFiles.length === 1);
|
|
342
|
+
ok('source: that one file is techniques/acceptance-testing.md', driveModeSignatureFiles.length === 1 && driveModeSignatureFiles[0] === acceptanceTestingSourcePath);
|
|
343
|
+
|
|
344
|
+
// The Codex install projects acceptance-testing.md with the Drive-mode bullet.
|
|
345
|
+
ok('codex: skills/_shared/techniques/acceptance-testing.md is projected with the Drive-mode bullet',
|
|
346
|
+
fs.readFileSync(path.join(codexDir, 'skills', '_shared', 'techniques', 'acceptance-testing.md'), 'utf8').includes(driveModeMarker));
|
|
347
|
+
|
|
348
|
+
// --- #178 (2/4): route UAT follow-ups by pointer, engines through the ask ----
|
|
349
|
+
// A new type-independent section, keyed on the `UAT of:` pointer rather than
|
|
350
|
+
// the ticket's type label, must sit before the first per-type `## `decision``
|
|
351
|
+
// heading (work/quick/auto all dispatch through this file).
|
|
352
|
+
function extractSection(source, headingMarker) {
|
|
353
|
+
const start = source.indexOf(headingMarker);
|
|
354
|
+
if (start === -1) return '';
|
|
355
|
+
const rest = source.slice(start + headingMarker.length);
|
|
356
|
+
const nextHeadingIdx = rest.search(/\n## /);
|
|
357
|
+
return headingMarker + (nextHeadingIdx === -1 ? rest : rest.slice(0, nextHeadingIdx));
|
|
358
|
+
}
|
|
359
|
+
const uatFollowupHeadingMarker = '## UAT follow-ups (any type)';
|
|
360
|
+
const decisionHeadingMarker = '## `decision`';
|
|
361
|
+
ok('source: ticket-engines.md has the "UAT follow-ups (any type)" section', ticketEnginesSource.includes(uatFollowupHeadingMarker));
|
|
362
|
+
ok('source: the UAT follow-ups section precedes the first `decision` heading',
|
|
363
|
+
ticketEnginesSource.indexOf(uatFollowupHeadingMarker) !== -1 &&
|
|
364
|
+
ticketEnginesSource.indexOf(uatFollowupHeadingMarker) < ticketEnginesSource.indexOf(decisionHeadingMarker));
|
|
365
|
+
const uatFollowupSection = extractSection(ticketEnginesSource, uatFollowupHeadingMarker);
|
|
366
|
+
for (const phrase of ['UAT of:', 'whatever its type label', 'trailhead:seed', '`VERIFY` comment on the follow-up', 'acceptance-testing.md']) {
|
|
367
|
+
ok(`source: the UAT follow-ups section contains "${phrase}"`, uatFollowupSection.includes(phrase));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Both the build and bug engines' Verify step, and both Resolve steps'
|
|
371
|
+
// Pending-UAT guard, must route through the drive-mode ask by name (the
|
|
372
|
+
// single source stays acceptance-testing.md; these reference it).
|
|
373
|
+
const verifyStepLines = ticketEnginesSource.split('\n').filter((l) => l.trim().startsWith('4. **Verify**'));
|
|
374
|
+
ok('source: ticket-engines.md carries both the build and bug Verify step lines', verifyStepLines.length === 2);
|
|
375
|
+
ok('source: both Verify step lines reference the drive-mode ask', verifyStepLines.length === 2 && verifyStepLines.every((l) => l.includes('drive-mode ask')));
|
|
376
|
+
|
|
377
|
+
const pendingUatGuardLines = ticketEnginesSource.split('\n').filter((l) => l.includes('Pending-UAT guard (before the close)'));
|
|
378
|
+
ok('source: ticket-engines.md carries both build and bug Pending-UAT guard lines', pendingUatGuardLines.length === 2);
|
|
379
|
+
ok('source: both Pending-UAT guard lines reference the drive-mode ask', pendingUatGuardLines.length === 2 && pendingUatGuardLines.every((l) => l.includes('drive-mode ask')));
|
|
380
|
+
|
|
381
|
+
// auto.md mentions the ask, AND (plan-review addition) a single localized span
|
|
382
|
+
// states its outcomes TOGETHER: takes it without the confirm gate, picks
|
|
383
|
+
// AI-driven when any step is agent-performable, never fakes a human-only step,
|
|
384
|
+
// and sets the ticket aside listing them in the run-end summary.
|
|
385
|
+
const autoSourcePath = path.join(sourceSkillsDir, 'trailhead-work', 'references', 'auto.md');
|
|
386
|
+
const autoSource = fs.readFileSync(autoSourcePath, 'utf8');
|
|
387
|
+
ok('source: auto.md mentions the drive-mode ask', autoSource.includes('drive-mode ask'));
|
|
388
|
+
ok('source: auto.md states the drive-mode-ask outcomes together (grouped, not scattered)',
|
|
389
|
+
/drive-mode ask[\s\S]{0,120}without the confirm gate[\s\S]{0,300}AI-driven[\s\S]{0,200}agent-performable[\s\S]{0,300}never fake[\s\S]{0,300}run-end summary/.test(autoSource));
|
|
390
|
+
|
|
391
|
+
// choices.md carries the call-site row for the drive-mode ask.
|
|
392
|
+
const choicesSourcePath = path.join(sourceSkillsDir, '_shared', 'choices.md');
|
|
393
|
+
const choicesSource = fs.readFileSync(choicesSourcePath, 'utf8');
|
|
394
|
+
ok('source: choices.md call-site table carries the drive-mode ask row', /\|[^\n]*[Dd]rive-mode ask[^\n]*\|/.test(choicesSource));
|
|
395
|
+
|
|
396
|
+
// --- #178 (3/4): acceptance.browser off keeps AI-driven UAT via commands ----
|
|
397
|
+
const configReferencePath = path.join(sourceSkillsDir, '_shared', 'configuration-reference.md');
|
|
398
|
+
const configReferenceSource = fs.readFileSync(configReferencePath, 'utf8');
|
|
399
|
+
ok('source: configuration-reference.md acceptance.browser explanation clarifies off still runs AI-driven checks via commands',
|
|
400
|
+
/\*\*acceptance\.browser\.\*\*[\s\S]{0,1000}`off`[\s\S]{0,300}AI-driven[\s\S]{0,300}commands/.test(configReferenceSource));
|
|
401
|
+
ok('source: configuration-reference.md guided-setup menu no longer labels the off option "Guided UAT only"',
|
|
402
|
+
!configReferenceSource.includes('Guided UAT only'));
|
|
403
|
+
ok('source: configuration-reference.md guided-setup menu relabels the off option "No browser"',
|
|
404
|
+
/Acceptance testing[\s\S]{0,200}No browser/.test(configReferenceSource));
|
|
405
|
+
|
|
406
|
+
// --- #178 (4/4): mirror the UAT drive-mode ask in READMEs and /docs ---------
|
|
407
|
+
const readmeSource = fs.readFileSync(path.join(repoRoot, 'README.md'), 'utf8');
|
|
408
|
+
const readmeItSource = fs.readFileSync(path.join(repoRoot, 'README.it.md'), 'utf8');
|
|
409
|
+
const ticketTypesSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'ticket-types.md'), 'utf8');
|
|
410
|
+
const workflowSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'workflow.md'), 'utf8');
|
|
411
|
+
const configurationMdxSource = fs.readFileSync(path.join(repoRoot, 'site', 'src', 'content', 'docs', 'docs', 'configuration.mdx'), 'utf8');
|
|
412
|
+
|
|
413
|
+
ok('README.md build row frames acceptance testing as asking who drives', /🔨 `build`[\s\S]{0,400}who drives/.test(readmeSource));
|
|
414
|
+
ok('README.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(readmeSource));
|
|
415
|
+
ok('README.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeSource));
|
|
416
|
+
|
|
417
|
+
ok('README.it.md build row frames acceptance testing as asking who drives (chi guida)', /🔨 `build`[\s\S]{0,400}chi guida/.test(readmeItSource));
|
|
418
|
+
ok('README.it.md worked example frames the login-flow acceptance as AI-driven', /chi guida[\s\S]{0,100}AI-driven/.test(readmeItSource));
|
|
419
|
+
ok('README.it.md acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(readmeItSource));
|
|
420
|
+
|
|
421
|
+
ok('site ticket-types.md build row frames acceptance testing as asking who drives', /`build`[\s\S]{0,400}who drives/.test(ticketTypesSource));
|
|
422
|
+
ok('site workflow.md worked example frames the login-flow acceptance as AI-driven', /asked who drives[\s\S]{0,100}AI-driven/.test(workflowSource));
|
|
423
|
+
ok('site configuration.mdx acceptance.browser row says off still runs AI-driven checks', /`acceptance\.browser`[\s\S]{0,400}AI-driven/.test(configurationMdxSource));
|
|
424
|
+
|
|
425
|
+
// --- #178 fix: keep the drive-mode ask ahead of URL resolution --------------
|
|
426
|
+
// Code review found the acceptance.browser clause made the drive-mode ask
|
|
427
|
+
// CONDITIONAL on URL resolution ("fall back to the drive-mode ask ... only
|
|
428
|
+
// when none can be determined"), contradicting the single source
|
|
429
|
+
// (acceptance-testing.md: the ask fires before any step runs, always) and
|
|
430
|
+
// conflating it with the URL-not-found fallback (ask for the URL, or guided
|
|
431
|
+
// UAT). The clause must instead state the ask always comes first and never
|
|
432
|
+
// gate it on URL detection.
|
|
433
|
+
ok('source: configuration-reference.md acceptance.browser clause does NOT make the drive-mode ask conditional on URL resolution',
|
|
434
|
+
!configReferenceSource.includes('fall back to the drive-mode ask'));
|
|
435
|
+
ok('source: configuration-reference.md acceptance.browser clause states the drive-mode ask always comes first',
|
|
436
|
+
/\*\*acceptance\.browser\.\*\*[\s\S]{0,200}drive-mode ask[\s\S]{0,120}always comes first/.test(configReferenceSource));
|
|
437
|
+
|
|
438
|
+
// The run-end summary's enumerated set-aside category list is missing the new
|
|
439
|
+
// human-only-UAT-steps class (added by #178); it must be listed there too,
|
|
440
|
+
// matching the wording already used for it at line 29 ("human-only steps").
|
|
441
|
+
const runEndSummarySection = extractSection(autoSource, '## Run-end summary');
|
|
442
|
+
ok('source: auto.md Run-end summary section exists', runEndSummarySection.length > 0);
|
|
443
|
+
ok('source: auto.md Run-end summary "what remains" list includes human-only UAT steps',
|
|
444
|
+
/what remains[\s\S]{0,400}human-only UAT steps/.test(runEndSummarySection));
|
|
445
|
+
|
|
137
446
|
// --- codex hooks (#29) --------------------------------------------------------
|
|
138
447
|
const codexHooksJsonPath = path.join(codexDir, 'hooks.json');
|
|
139
448
|
ok('codex: hooks.json exists', fs.existsSync(codexHooksJsonPath));
|
|
@@ -148,6 +457,16 @@ ok('codex: hooks.json registers check-update under SessionStart', (codexHooksJso
|
|
|
148
457
|
ok('codex: skills/trailhead/hooks/trailhead-secret-guard.js exists', fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-secret-guard.js')));
|
|
149
458
|
ok('codex: skills/trailhead/hooks/lib/commit-message-check.js exists (commit-guard require target)',
|
|
150
459
|
fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'commit-message-check.js')));
|
|
460
|
+
ok('codex: skills/trailhead/hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
|
|
461
|
+
fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'gh-subcommand.js')));
|
|
462
|
+
// #169: search-guard is Claude-Code-specific (it only prevents arming
|
|
463
|
+
// Claude's Read() deny rule under bypass permissions, a mechanism Codex
|
|
464
|
+
// lacks), so it must neither be registered nor copied on a Codex install.
|
|
465
|
+
ok('codex: hooks.json does NOT register search-guard under any event', !codexHooksJsonStr.includes('trailhead-search-guard.js'));
|
|
466
|
+
ok('codex: skills/trailhead/hooks/trailhead-search-guard.js does NOT exist',
|
|
467
|
+
!fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'trailhead-search-guard.js')));
|
|
468
|
+
ok('codex: skills/trailhead/hooks/lib/shell-scan.js still exists (secret-read-guard require target)',
|
|
469
|
+
fs.existsSync(path.join(codexDir, 'skills', 'trailhead', 'hooks', 'lib', 'shell-scan.js')));
|
|
151
470
|
|
|
152
471
|
const codexConfigTomlPath = path.join(codexDir, 'config.toml');
|
|
153
472
|
ok('codex: config.toml exists', fs.existsSync(codexConfigTomlPath));
|
|
@@ -271,11 +590,18 @@ ok('claude: trailhead-work/../_shared/load-first.md resolves', fs.existsSync(pat
|
|
|
271
590
|
for (const cl of ['trailhead', 'trailhead-chart', 'trailhead-work', 'trailhead-view', 'trailhead-capture', 'trailhead-manage']) {
|
|
272
591
|
ok(`claude: ${cl} SKILL.md references ../_shared/load-first.md`, fs.readFileSync(path.join(claudeDir, 'skills', cl, 'SKILL.md'), 'utf8').includes('../_shared/load-first.md'));
|
|
273
592
|
}
|
|
593
|
+
|
|
594
|
+
// #146: single-sourced ticket-language directive, projected on Claude too.
|
|
595
|
+
ok('claude: skills/_shared/ticket-language.md exists', fs.existsSync(path.join(claudeDir, 'skills', '_shared', 'ticket-language.md')));
|
|
596
|
+
ok('claude: trailhead-work/../_shared/ticket-language.md resolves', fs.existsSync(path.join(claudeDir, 'skills', 'trailhead-work', '..', '_shared', 'ticket-language.md')));
|
|
597
|
+
|
|
274
598
|
ok('claude: commands/trailhead/work.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'work.md')));
|
|
275
599
|
ok('claude: commands/trailhead/auto.md exists', fs.existsSync(path.join(claudeDir, 'commands', 'trailhead', 'auto.md')));
|
|
276
600
|
ok('claude: hooks/trailhead-commit-guard.js exists', fs.existsSync(path.join(claudeDir, 'hooks', 'trailhead-commit-guard.js')));
|
|
277
601
|
ok('claude: hooks/lib/commit-message-check.js exists (commit-guard require target)',
|
|
278
602
|
fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'commit-message-check.js')));
|
|
603
|
+
ok('claude: hooks/lib/gh-subcommand.js exists (body-guard/secret-guard require target, #153)',
|
|
604
|
+
fs.existsSync(path.join(claudeDir, 'hooks', 'lib', 'gh-subcommand.js')));
|
|
279
605
|
// Regression: the commit-guard does require('./lib/commit-message-check.js'), so
|
|
280
606
|
// it only loads if the lib was copied alongside it. Run it with a benign Bash
|
|
281
607
|
// payload and assert it does not crash with a missing-module error.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marcomigozzi/trailhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues. Map-based onboarding + a discuss→plan→execute→verify engine, all on the Issues. Installs the trailhead skill, commands, and hooks into your agent's config dir.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"trailhead": "bin/trailhead.js"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trailhead",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Start and drive large projects as a map of tickets on GitHub Issues, resolving one at a time. Map-based onboarding + a discuss→plan→execute→verify engine with atomic commits, all on the Issues. Ships as a dispatcher plus cohesion-clustered skills over a shared core; depends on nothing beyond an authenticated gh CLI.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marco Migozzi",
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Prune a map's closed and orphan sub-issue edges to reclaim slots under GitHub's 100-cap (references kept)
|
|
3
|
+
argument-hint: "[map]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Invoke the `trailhead` skill for the **prune** action: call the Skill tool with skill name `trailhead-manage` and arguments `prune $ARGUMENTS`, then **read the skill's `references/pruning.md` and follow it**: that file holds the prune protocol (resolve the target map, read the sub-issue count, reconcile the map's native edges bidirectionally, preview and confirm, then apply, keeping every reference). Do not re-implement the behaviour here. The skill is the single source of truth.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Work one ticket whole,
|
|
2
|
+
description: Work one ticket whole, never splits: a new whiteboard ticket from text, or an existing one; grills only if needed
|
|
3
3
|
argument-hint: "[ticket | \"text\"]"
|
|
4
4
|
---
|
|
5
5
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// gh-subcommand.js: shared gh-subcommand detector, extracted from
|
|
3
|
+
// trailhead-body-guard.js's ghBodyWrite and trailhead-secret-guard.js's
|
|
4
|
+
// ghIssueWrite (#153 follow-up), so both guards share ONE token walk instead
|
|
5
|
+
// of drifting copies.
|
|
6
|
+
//
|
|
7
|
+
// Bug fixed here (#153): the original inline walk only treated `-R`/`--repo`
|
|
8
|
+
// as a two-token (value-taking) global gh flag. Any OTHER two-token global
|
|
9
|
+
// flag (e.g. `gh --hostname h issue edit 5 --body ""`) desynced the walk:
|
|
10
|
+
// after skipping `--hostname`, the index landed on its value `h`, which is
|
|
11
|
+
// not a `-`-prefixed flag so the loop stopped there, and `h` was mistaken for
|
|
12
|
+
// the subcommand. The write became invisible to both guards (a missed
|
|
13
|
+
// empty-body block in body-guard, an unscanned secret in secret-guard).
|
|
14
|
+
//
|
|
15
|
+
// Follow-up fix (#153): a first pass tolerated a stray value token after any
|
|
16
|
+
// unknown flag UNLESS that token was itself a flag or a subcommand keyword
|
|
17
|
+
// (issue/pr/api). That guard reintroduced the exact same desync whenever the
|
|
18
|
+
// flag's VALUE literally equalled a subcommand keyword, e.g.
|
|
19
|
+
// `gh --hostname api issue edit 5` mis-resolved `sub` to `api` (the flag
|
|
20
|
+
// value), hiding the real `issue edit` write again.
|
|
21
|
+
//
|
|
22
|
+
// Fix: drop the keyword-based guard entirely and model flag arity instead.
|
|
23
|
+
// gh's only value-taking global flag before a subcommand is -R/--repo; a
|
|
24
|
+
// small, explicit set of global flags are boolean (take no value). Every
|
|
25
|
+
// OTHER flag token is treated as value-taking, so its value can never be
|
|
26
|
+
// mistaken for the subcommand - including when that value happens to equal
|
|
27
|
+
// a subcommand keyword like `issue`/`pr`/`api`.
|
|
28
|
+
//
|
|
29
|
+
// Second follow-up fix (#153): treating every unknown flag as value-taking
|
|
30
|
+
// reopened the MIRROR case - an unknown BOOLEAN flag directly before the
|
|
31
|
+
// subcommand (e.g. `gh --foo issue edit 5`) now gets treated as value-taking
|
|
32
|
+
// and swallows the subcommand itself as if it were the flag's value, landing
|
|
33
|
+
// `sub` on the verb (`edit`) instead of `issue`. The two failure directions
|
|
34
|
+
// (a flag's VALUE colliding with a subcommand keyword, vs. an unknown
|
|
35
|
+
// BOOLEAN flag consuming the subcommand) cannot both be resolved by a
|
|
36
|
+
// positional walk alone: `gh --X <tok> issue edit` and `gh --X issue edit`
|
|
37
|
+
// are structurally ambiguous without knowing --X's arity in advance, and gh
|
|
38
|
+
// itself rejects unknown flags so neither shape occurs with the real `gh`
|
|
39
|
+
// binary. But the guard defends against any `gh`-named binary, so it must
|
|
40
|
+
// not depend on that.
|
|
41
|
+
//
|
|
42
|
+
// Design principle: keep the value-taking primary walk (it is correct for
|
|
43
|
+
// every KNOWN two-token flag, e.g. -R/--repo, and for the common case of an
|
|
44
|
+
// unknown two-token flag), then ADD a fail-safe fallback that scans forward
|
|
45
|
+
// for a recognized write-subcommand keyword whenever the walk did not land
|
|
46
|
+
// on one. Erring toward DETECTION (scanning/considering a write) is the safe
|
|
47
|
+
// direction for a security guard; under-detection (missing a real write) is
|
|
48
|
+
// the dangerous one. The downstream verb gate in each guard still prevents a
|
|
49
|
+
// stray keyword token in a genuinely non-write command from being
|
|
50
|
+
// misclassified as a write.
|
|
51
|
+
//
|
|
52
|
+
// Self-contained. No requires.
|
|
53
|
+
|
|
54
|
+
// gh's boolean global flags (take NO value). gh's only value-taking global
|
|
55
|
+
// flag before a subcommand is -R/--repo; every other flag token here is
|
|
56
|
+
// treated as value-taking, so an unknown two-token global flag's value can
|
|
57
|
+
// never be mistaken for the subcommand (#153) - including when that value
|
|
58
|
+
// happens to equal a subcommand keyword like `issue`/`pr`/`api`.
|
|
59
|
+
const BOOLEAN_GLOBAL_FLAGS = new Set(['-h', '--help', '--version']);
|
|
60
|
+
|
|
61
|
+
// The subcommands the guards care about; used by the fail-safe fallback
|
|
62
|
+
// below to recover from an unknown boolean flag swallowing the subcommand.
|
|
63
|
+
const WRITE_SUBCOMMANDS = new Set(['issue', 'pr', 'api']);
|
|
64
|
+
|
|
65
|
+
// Parse a raw shell command string and locate the gh subcommand/verb.
|
|
66
|
+
// Returns { sub, verb } (verb may be undefined) or null when this is not a
|
|
67
|
+
// gh invocation at all.
|
|
68
|
+
function parseGhSubcommand(cmd) {
|
|
69
|
+
const toks = String(cmd).trim().split(/\s+/);
|
|
70
|
+
let i = 0;
|
|
71
|
+
while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
|
|
72
|
+
if (i >= toks.length || !/(^|\/)gh$/.test(toks[i])) return null; // the gh binary
|
|
73
|
+
const start = i + 1; // first token after `gh`
|
|
74
|
+
i = start;
|
|
75
|
+
// Primary positional walk. -R/--repo (and any non-boolean, non-glued flag)
|
|
76
|
+
// is treated as value-taking: consume one following non-flag token so an
|
|
77
|
+
// unknown two-token global flag's value cannot shift the subcommand (#153).
|
|
78
|
+
while (i < toks.length && toks[i].startsWith('-')) {
|
|
79
|
+
const flag = toks[i];
|
|
80
|
+
i++; // the flag token itself
|
|
81
|
+
if (flag.includes('=') || BOOLEAN_GLOBAL_FLAGS.has(flag)) continue; // no separate value
|
|
82
|
+
if (i < toks.length && !toks[i].startsWith('-')) i++; // consume its value
|
|
83
|
+
}
|
|
84
|
+
let sub = toks[i];
|
|
85
|
+
let verb = toks[i + 1];
|
|
86
|
+
// Fail-safe fallback: if the walk did NOT land on a recognized write
|
|
87
|
+
// subcommand, an unknown *boolean* flag before the subcommand may have
|
|
88
|
+
// consumed the subcommand itself as if it were the flag's value (the mirror
|
|
89
|
+
// of the value-collision case). Scan forward for the first recognized
|
|
90
|
+
// subcommand keyword and use it. Over-detection is the safe direction for a
|
|
91
|
+
// guard; under-detection (missing a real write) is the dangerous one (#153).
|
|
92
|
+
// The downstream guards still gate on the verb, so a stray keyword token in a
|
|
93
|
+
// non-write command does not become a spurious write.
|
|
94
|
+
if (!WRITE_SUBCOMMANDS.has(sub)) {
|
|
95
|
+
for (let j = start; j < toks.length; j++) {
|
|
96
|
+
if (WRITE_SUBCOMMANDS.has(toks[j])) { sub = toks[j]; verb = toks[j + 1]; break; }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { sub, verb };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = { parseGhSubcommand, BOOLEAN_GLOBAL_FLAGS, WRITE_SUBCOMMANDS };
|