@marcomigozzi/trailhead 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.it.md +82 -25
  2. package/README.md +82 -25
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/capture-action.test.js +167 -0
  6. package/bin/capture-docs.test.js +32 -0
  7. package/bin/engine-route-docs.test.js +155 -0
  8. package/bin/facet-stamp-docs.test.js +79 -0
  9. package/bin/forced-mutation-docs.test.js +38 -0
  10. package/bin/grill-model-docs.test.js +155 -0
  11. package/bin/grilling-holistic-review-docs.test.js +7 -3
  12. package/bin/guard-axis-docs.test.js +69 -0
  13. package/bin/inbox-needs-info-docs.test.js +68 -0
  14. package/bin/lib/codex-projection.js +28 -19
  15. package/bin/lib/codex-projection.test.js +77 -20
  16. package/bin/lib/host-descriptor.js +1 -1
  17. package/bin/lib/host-descriptor.test.js +6 -0
  18. package/bin/needs-you-docs.test.js +9 -2
  19. package/bin/options-in-chat-docs.test.js +65 -0
  20. package/bin/pick-stress-test-docs.test.js +1 -1
  21. package/bin/pipeline-review-docs.test.js +118 -0
  22. package/bin/plan-review-budget-docs.test.js +63 -0
  23. package/bin/repo-target-docs.test.js +41 -0
  24. package/bin/report-status-docs.test.js +10 -2
  25. package/bin/seam-coverage-docs.test.js +46 -0
  26. package/bin/session-trust-docs.test.js +37 -0
  27. package/bin/standards-brief-docs.test.js +112 -0
  28. package/bin/surfaced-scope-docs.test.js +57 -0
  29. package/bin/test-once-docs.test.js +101 -0
  30. package/bin/test-run-docs.test.js +47 -0
  31. package/bin/trailhead.js +15 -8
  32. package/bin/trailhead.test.js +71 -17
  33. package/bin/unblock-docs.test.js +56 -0
  34. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  35. package/package.json +1 -1
  36. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  37. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  38. package/plugins/trailhead/agents/trailhead-codebase-map.md +1 -1
  39. package/plugins/trailhead/agents/trailhead-grill.md +41 -0
  40. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  41. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  42. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  43. package/plugins/trailhead/hooks/lib/capture.test.js +439 -0
  44. package/plugins/trailhead/hooks/lib/checkout-find.test.js +195 -0
  45. package/plugins/trailhead/hooks/lib/checkout-in.test.js +83 -7
  46. package/plugins/trailhead/hooks/lib/dashboard.test.js +19 -0
  47. package/plugins/trailhead/hooks/lib/gh-auth-preflight.js +89 -0
  48. package/plugins/trailhead/hooks/lib/gh-auth-preflight.test.js +132 -0
  49. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  50. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  51. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  52. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  53. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  54. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  55. package/plugins/trailhead/hooks/lib/inherit-session.js +17 -0
  56. package/plugins/trailhead/hooks/lib/issue-body.test.js +165 -0
  57. package/plugins/trailhead/hooks/lib/model-defaults-review.js +22 -1
  58. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +64 -1
  59. package/plugins/trailhead/hooks/lib/model-route.js +207 -0
  60. package/plugins/trailhead/hooks/lib/model-route.test.js +231 -0
  61. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  62. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  63. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  64. package/plugins/trailhead/hooks/lib/report-status.test.js +56 -1
  65. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  66. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  67. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  68. package/plugins/trailhead/hooks/mod/README.md +42 -17
  69. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  70. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  71. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  72. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  73. package/plugins/trailhead/hooks/mod/map-pane.tsx +204 -49
  74. package/plugins/trailhead/hooks/mod/my-tickets.ts +76 -0
  75. package/plugins/trailhead/hooks/mod/repo-pin.ts +113 -9
  76. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  77. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  79. package/plugins/trailhead/hooks/trailhead-check-update.js +20 -2
  80. package/plugins/trailhead/hooks/trailhead-check-update.test.js +60 -0
  81. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  83. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  84. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  85. package/plugins/trailhead/skills/_shared/capture-core.md +123 -0
  86. package/plugins/trailhead/skills/_shared/choices.md +2 -2
  87. package/plugins/trailhead/skills/_shared/configuration-reference.md +30 -16
  88. package/plugins/trailhead/skills/_shared/configuration.md +1 -1
  89. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  90. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  91. package/plugins/trailhead/skills/_shared/substrate-commands.md +19 -6
  92. package/plugins/trailhead/skills/_shared/substrate.md +4 -3
  93. package/plugins/trailhead/skills/_shared/surfaced-scope.md +23 -0
  94. package/plugins/trailhead/skills/_shared/teamwork.md +11 -5
  95. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  96. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +22 -6
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +6 -3
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +5 -2
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +28 -3
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -1
  103. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  104. package/plugins/trailhead/skills/_shared/techniques/verify.md +5 -1
  105. package/plugins/trailhead/skills/_shared/techniques.md +8 -6
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -4
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +29 -4
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +11 -6
  111. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +18 -3
  112. package/plugins/trailhead/skills/trailhead-work/SKILL.md +12 -9
  113. package/plugins/trailhead/skills/trailhead-work/references/auto.md +3 -1
  114. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  115. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +13 -13
  116. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  117. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  118. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  119. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  120. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  121. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  122. package/plugins/trailhead/templates/trailhead-capture.js +419 -0
  123. package/plugins/trailhead/templates/trailhead-checkout-find.js +257 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +78 -21
  125. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  126. package/plugins/trailhead/templates/trailhead-dashboard.js +15 -2
  127. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  128. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +161 -12
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  135. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  136. package/plugins/trailhead/templates/trailhead-report-status.js +57 -9
  137. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  138. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  139. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  140. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  141. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  142. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  143. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  144. package/plugins/trailhead/tests/map-pane-ui.test.ts +386 -50
  145. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  146. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  147. package/plugins/trailhead/tests/my-tickets.test.ts +66 -0
  148. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  149. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  150. package/plugins/trailhead/tests/repo-pin.test.ts +20 -5
  151. package/plugins/trailhead/tests/unscrub.ts +8 -0
  152. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  153. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
  154. package/plugins/trailhead/types/index.d.ts +7 -0
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-repo-target.js (#351). Run: node repo-target.test.js
3
+ // No framework: plain asserts, same style as isolation-answer.test.js.
4
+ // The gh/git runner is always injected: no test calls the real gh or writes to GitHub.
5
+ const assert = require('assert');
6
+ const { spawnSync } = require('child_process');
7
+ const path = require('path');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+
11
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-repo-target.js');
12
+ const { parseGithubSlug, parentSlug, decide, parseArgs, main, IDLE_WINDOW_MS, SLUG_RE } = require(scriptPath);
13
+
14
+ let passed = 0;
15
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
16
+
17
+ const HOUR = 3600 * 1000;
18
+ const T0 = new Date('2026-10-14T08:00:00.000Z');
19
+ const at = (ms) => new Date(T0.getTime() + ms);
20
+ const iso = (d) => d.toISOString();
21
+ const mktmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-repo-target-test-')));
22
+
23
+ // Fake runner. world: { view, originView, remotes, setDefault } ; records every call.
24
+ function fakeRun(world) {
25
+ const calls = [];
26
+ const run = (cmd, args) => {
27
+ calls.push([cmd, ...args]);
28
+ const fail = { status: 1, stdout: '', stderr: 'boom' };
29
+ if (cmd === 'git') {
30
+ return world.remotes === undefined ? fail
31
+ : { status: 0, stdout: world.remotes.map(([n, u]) => `remote.${n}.url ${u}`).join('\n') + '\n', stderr: '' };
32
+ }
33
+ if (args[0] === 'repo' && args[1] === 'set-default') return world.setDefault || { status: 0, stdout: '', stderr: '' };
34
+ if (args[0] === 'repo' && args[1] === 'view') {
35
+ const v = args.includes('--') ? world.originView : world.view;
36
+ return v ? { status: 0, stdout: JSON.stringify(v), stderr: '' } : fail;
37
+ }
38
+ return fail;
39
+ };
40
+ return { run, calls };
41
+ }
42
+
43
+ function runMain(argv, { cwd, now = T0, world }) {
44
+ const chunks = [];
45
+ const { run, calls } = fakeRun(world || {});
46
+ const realErr = process.stderr.write.bind(process.stderr);
47
+ const errChunks = [];
48
+ process.stderr.write = (s) => { errChunks.push(String(s)); return true; };
49
+ let code;
50
+ try {
51
+ code = main(argv, { cwd, now, run, stdout: { write: (s) => chunks.push(s) } });
52
+ } finally {
53
+ process.stderr.write = realErr;
54
+ }
55
+ return { code, lines: chunks.join('').split('\n').filter(Boolean).map((l) => JSON.parse(l)), calls, err: errChunks.join('') };
56
+ }
57
+
58
+ const recPath = (dir) => path.join(dir, '.trailhead', 'repo-target.json');
59
+ const writeRec = (dir, over = {}) => {
60
+ fs.mkdirSync(path.join(dir, '.trailhead'), { recursive: true });
61
+ fs.writeFileSync(recPath(dir), JSON.stringify({ target: 'cli/cli', recordedAt: iso(T0), usedAt: iso(T0), ...over }));
62
+ };
63
+ const forkWorld = (over = {}) => ({
64
+ view: { nameWithOwner: 'cli/cli', viewerPermission: 'READ' },
65
+ remotes: [['origin', 'https://github.com/fakeuser/cli.git'], ['upstream', 'git@github.com:cli/cli.git']],
66
+ originView: { nameWithOwner: 'fakeuser/cli', isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } },
67
+ ...over,
68
+ });
69
+
70
+ // --- URL parsing ---------------------------------------------------------------
71
+ ok('slug https', parseGithubSlug('https://github.com/o/n.git') === 'o/n');
72
+ ok('slug https no .git', parseGithubSlug('https://github.com/o/n') === 'o/n');
73
+ ok('slug https with token user', parseGithubSlug('https://x-access-token:abc@github.com/o/n.git') === 'o/n');
74
+ ok('slug scp ssh', parseGithubSlug('git@github.com:o/n.git') === 'o/n');
75
+ ok('slug ssh://', parseGithubSlug('ssh://git@github.com/o/n.git') === 'o/n');
76
+ ok('slug ssh:// with port', parseGithubSlug('ssh://git@github.com:22/o/n') === 'o/n');
77
+ ok('slug dotted name', parseGithubSlug('git@github.com:o/n.js.git') === 'o/n.js');
78
+ for (const bad of ['https://gitlab.com/o/n.git', 'git@gitlab.com:o/n.git', '/local/path', '', null, undefined, 'https://github.com/only']) {
79
+ ok(`slug non-GitHub/invalid -> null: ${bad}`, parseGithubSlug(bad) === null);
80
+ }
81
+ ok('parentSlug owner.login', parentSlug({ name: 'cli', owner: { login: 'cli' } }) === 'cli/cli');
82
+ ok('parentSlug nameWithOwner', parentSlug({ nameWithOwner: 'a/b' }) === 'a/b');
83
+ ok('parentSlug null', parentSlug(null) === null);
84
+
85
+ // --- decide (pure) -------------------------------------------------------------
86
+ {
87
+ ok('window is 8 hours', IDLE_WINDOW_MS === 8 * HOUR);
88
+ const f = (o = {}) => ({ target: 'o/n', permission: 'WRITE', originSlug: 'o/n', originInfo: null, remotes: ['o/n'], ...o });
89
+ const rec = (o = {}) => ({ target: 'o/n', recordedAt: iso(T0), usedAt: iso(T0), ...o });
90
+ ok('plain repo -> ok', decide(f(), null, T0).status === 'ok');
91
+ for (const p of ['ADMIN', 'MAINTAIN', 'WRITE']) ok(`permission ${p} -> ok`, decide(f({ permission: p }), null, T0).status === 'ok');
92
+ ok('unknown permission -> ok', decide(f({ permission: undefined }), null, T0).status === 'ok');
93
+ const fork = f({ target: 'cli/cli', originSlug: 'u/cli', originInfo: { isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } }, remotes: ['u/cli', 'cli/cli'] });
94
+ const d = decide(fork, null, T0);
95
+ ok('fork with parent resolved -> ask fork-parent', d.status === 'ask' && d.reason === 'fork-parent');
96
+ ok('fork-parent candidates: fork then parent', JSON.stringify(d.candidates) === '["u/cli","cli/cli"]');
97
+ ok('fork-parent is case-insensitive', decide({ ...fork, target: 'CLI/cli' }, null, T0).reason === 'fork-parent');
98
+ const own = f({ target: 'u/cli', originSlug: 'u/cli', originInfo: null });
99
+ ok('fork whose target is itself -> ok', decide(own, null, T0).status === 'ok');
100
+ ok('origin not a fork of target -> ok', decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: { isFork: true, parent: { name: 'cli', owner: { login: 'cli' } } } }), null, T0).status === 'ok');
101
+ ok('origin not a fork -> ok', decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: { isFork: false, parent: null } }), null, T0).status === 'ok');
102
+ // origin that cannot be verified -> skipped, never a silent ok (#351 I1)
103
+ const unv = decide(f({ target: 'x/y', originSlug: 'u/cli', originInfo: null, originExists: true }), null, T0);
104
+ ok('origin differs, lookup failed -> skipped', unv.status === 'skipped' && unv.reason === 'origin fork relation could not be verified');
105
+ const nonGh = decide(f({ target: 'x/y', originSlug: null, originInfo: null, originExists: true }), null, T0);
106
+ ok('origin not a github.com URL -> skipped', nonGh.status === 'skipped' && nonGh.reason === 'origin fork relation could not be verified');
107
+ ok('no origin remote at all -> ok', decide(f({ originSlug: null, originExists: false }), null, T0).status === 'ok');
108
+ ok('origin == target, exists -> ok', decide(f({ originExists: true }), null, T0).status === 'ok');
109
+ ok('no-write still asks before the unverified skip', decide(f({ permission: 'READ', target: 'x/y', originSlug: 'u/cli', originInfo: null, originExists: true, remotes: ['u/cli', 'x/y'] }), null, T0).reason === 'no-write');
110
+ const nw = decide(f({ permission: 'READ', target: 'x/y', originSlug: 'u/cli', remotes: ['u/cli', 'x/y'] }), null, T0);
111
+ ok('no write -> ask no-write', nw.status === 'ask' && nw.reason === 'no-write');
112
+ ok('no-write candidates: origin first, target included', JSON.stringify(nw.candidates) === '["u/cli","x/y"]');
113
+ ok('no-write with only the target remote', JSON.stringify(decide(f({ permission: 'NONE' }), null, T0).candidates) === '["o/n"]');
114
+ ok('fork-parent wins over no-write', decide({ ...fork, permission: 'READ' }, null, T0).reason === 'fork-parent');
115
+ // recorded answer
116
+ ok('record same target in window -> reuse', decide(fork, rec({ target: 'cli/cli' }), T0).status === 'reuse');
117
+ ok('reuse carries recordedAt', decide(fork, rec({ target: 'cli/cli' }), T0).recordedAt === iso(T0));
118
+ ok('reuse is case-insensitive', decide(fork, rec({ target: 'CLI/CLI' }), T0).status === 'reuse');
119
+ ok('exactly 8h reuses', decide(fork, rec({ target: 'cli/cli' }), at(8 * HOUR)).status === 'reuse');
120
+ ok('over 8h -> re-check', decide(fork, rec({ target: 'cli/cli' }), at(8 * HOUR + 1)).status === 'ask');
121
+ ok('target changed -> re-check', decide(fork, rec({ target: 'other/repo' }), T0).status === 'ask');
122
+ for (const [name, r] of [
123
+ ['non-object', 'x'], ['array', []], ['bad slug', rec({ target: 'nope' })], ['missing target', { recordedAt: iso(T0), usedAt: iso(T0) }],
124
+ ['malformed usedAt', rec({ target: 'cli/cli', usedAt: 'yesterday' })], ['future usedAt', rec({ target: 'cli/cli', usedAt: iso(at(HOUR)) })],
125
+ ['future recordedAt', rec({ target: 'cli/cli', recordedAt: iso(at(HOUR)) })], ['non-ISO', rec({ target: 'cli/cli', usedAt: '2026' })],
126
+ ]) ok(`invalid record -> re-check: ${name}`, decide(fork, r, T0).status === 'ask');
127
+ }
128
+
129
+ // --- parseArgs -----------------------------------------------------------------
130
+ {
131
+ const r = parseArgs(['record', '--repo', '/x', '--target', 'o/n']);
132
+ ok('parseArgs record', r.command === 'record' && r.repo === '/x' && r.target === 'o/n');
133
+ ok('parseArgs check', parseArgs(['check']).command === 'check');
134
+ ok('parseArgs clear', parseArgs(['clear', '--repo', '/x']).repo === '/x');
135
+ for (const bad of ['-x/y', '--exec/y', '.x/y', '_x/y', 'x/', '/y', 'a/b/c', 'x y/z']) ok(`SLUG_RE rejects ${bad}`, !SLUG_RE.test(bad));
136
+ for (const good of ['o/n', 'cli/cli', 'a-b/c.d_e', '0x/y']) ok(`SLUG_RE accepts ${good}`, SLUG_RE.test(good));
137
+ ok('parseArgs rejects a -x/y target', !!parseArgs(['record', '--target', '-x/y']).error);
138
+ for (const argv of [[], ['frob'], ['record'], ['record', '--target', 'nope'], ['record', '--target'], ['check', '--target', 'o/n'], ['clear', '--bogus', '1']]) {
139
+ ok(`parseArgs error: ${argv.join(' ')}`, !!parseArgs(argv).error);
140
+ }
141
+ }
142
+
143
+ // --- main: check ---------------------------------------------------------------
144
+ {
145
+ const dir = mktmp();
146
+ const plain = runMain(['check', '--repo', dir], { cwd: '/', world: {
147
+ view: { nameWithOwner: 'o/n', viewerPermission: 'ADMIN' }, remotes: [['origin', 'git@github.com:o/n.git']] } });
148
+ ok('plain repo: exit 0, ok, silent', plain.code === 0 && plain.lines[0].status === 'ok' && plain.lines[0].target === 'o/n');
149
+ ok('plain repo: no origin lookup, nothing written', plain.calls.length === 2 && !fs.existsSync(path.join(dir, '.trailhead')));
150
+
151
+ const a = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() });
152
+ ok('fork clone, parent resolved: ask fork-parent', a.lines[0].status === 'ask' && a.lines[0].reason === 'fork-parent');
153
+ ok('ask lists both candidates and the path', JSON.stringify(a.lines[0].candidates) === '["fakeuser/cli","cli/cli"]' && a.lines[0].path === recPath(dir));
154
+ ok('ask writes nothing', !fs.existsSync(recPath(dir)));
155
+ ok('ask: only read-only gh calls', a.calls.every((c) => c[0] === 'git' || (c[1] === 'repo' && c[2] !== 'set-default')));
156
+
157
+ const own = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld({
158
+ view: { nameWithOwner: 'fakeuser/cli', viewerPermission: 'ADMIN' } }) });
159
+ ok('fork resolving to itself: ok', own.lines[0].status === 'ok' && own.lines[0].target === 'fakeuser/cli');
160
+
161
+ const nw = runMain(['check', '--repo', dir], { cwd: '/', world: {
162
+ view: { nameWithOwner: 'o/n', viewerPermission: 'READ' }, remotes: [['origin', 'https://github.com/o/n.git']] } });
163
+ ok('no write permission: ask no-write', nw.lines[0].status === 'ask' && nw.lines[0].reason === 'no-write');
164
+
165
+ // recorded answer
166
+ writeRec(dir, { target: 'cli/cli' });
167
+ const re = runMain(['check', '--repo', dir], { cwd: '/', now: at(2 * HOUR), world: forkWorld() });
168
+ ok('recorded answer: reuse', re.lines[0].status === 'reuse' && re.lines[0].target === 'cli/cli' && re.lines[0].recordedAt === iso(T0));
169
+ ok('reuse slides usedAt, keeps recordedAt', JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).usedAt === iso(at(2 * HOUR))
170
+ && JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).recordedAt === iso(T0));
171
+ ok('slide keeps a long stretch alive', runMain(['check', '--repo', dir], { cwd: '/', now: at(9 * HOUR), world: forkWorld() }).lines[0].status === 'reuse');
172
+ ok('expired record re-checks', runMain(['check', '--repo', dir], { cwd: '/', now: at(30 * HOUR), world: forkWorld() }).lines[0].status === 'ask');
173
+ writeRec(dir, { target: 'someone/else' });
174
+ ok('target changed re-checks', runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() }).lines[0].status === 'ask');
175
+ fs.writeFileSync(recPath(dir), '{not json');
176
+ ok('corrupt record re-checks', runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld() }).lines[0].status === 'ask');
177
+
178
+ // failures never block
179
+ const g = runMain(['check', '--repo', dir], { cwd: '/', world: {} });
180
+ ok('gh failure: skipped, exit 0', g.code === 0 && g.lines[0].status === 'skipped' && g.lines[0].reason.length > 0);
181
+ const nogit = runMain(['check', '--repo', dir], { cwd: '/', world: { view: { nameWithOwner: 'o/n', viewerPermission: 'WRITE' } } });
182
+ ok('no remotes readable: target alone, ok', nogit.lines[0].status === 'ok');
183
+ const noOriginInfo = runMain(['check', '--repo', dir], { cwd: '/', world: forkWorld({ originView: undefined, view: { nameWithOwner: 'cli/cli', viewerPermission: 'WRITE' } }) });
184
+ ok('origin lookup fails: skipped, not a silent ok', noOriginInfo.lines[0].status === 'skipped' && /could not be verified/.test(noOriginInfo.lines[0].reason));
185
+ const ghes = runMain(['check', '--repo', dir], { cwd: '/', world: { view: { nameWithOwner: 'cli/cli', viewerPermission: 'WRITE' }, remotes: [['origin', 'git@ghe.example.com:u/cli.git']] } });
186
+ ok('origin on another host: skipped', ghes.lines[0].status === 'skipped' && /could not be verified/.test(ghes.lines[0].reason));
187
+ ok('origin lookup passes -- before the slug', a.calls.some((c) => c.join(' ') === 'gh repo view --json nameWithOwner,isFork,parent -- fakeuser/cli'));
188
+ }
189
+
190
+ // --- main: record --------------------------------------------------------------
191
+ {
192
+ const dir = mktmp();
193
+ const w = runMain(['record', '--repo', dir, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld() });
194
+ ok('record differing target: written, setDefault true', w.lines[0].status === 'written' && w.lines[0].setDefault === true && w.lines[0].target === 'fakeuser/cli');
195
+ ok('record calls gh repo set-default with the target', w.calls.some((c) => c.join(' ') === 'gh repo set-default -- fakeuser/cli'));
196
+ const s = JSON.parse(fs.readFileSync(recPath(dir), 'utf8'));
197
+ ok('record file shape', JSON.stringify(Object.keys(s)) === '["target","recordedAt","usedAt"]' && s.target === 'fakeuser/cli' && s.recordedAt === iso(T0));
198
+ ok('record in a non-git dir leaves .gitignore alone', !fs.existsSync(path.join(dir, '.gitignore')));
199
+ ok('record leaves no temp', fs.readdirSync(path.join(dir, '.trailhead')).join() === 'repo-target.json');
200
+
201
+ const same = runMain(['record', '--repo', dir, '--target', 'cli/cli'], { cwd: '/', now: at(HOUR), world: forkWorld() });
202
+ ok('record same as current: no set-default', same.lines[0].setDefault === false && !same.calls.some((c) => c.includes('set-default')));
203
+ ok('record replaces the file', JSON.parse(fs.readFileSync(recPath(dir), 'utf8')).target === 'cli/cli');
204
+
205
+ const dir2 = mktmp();
206
+ const bad = runMain(['record', '--repo', dir2, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld({ setDefault: { status: 1, stdout: '', stderr: 'no such repo\nmore' } }) });
207
+ ok('set-default failure: error with reason, exit 0', bad.code === 0 && bad.lines[0].status === 'error' && /no such repo/.test(bad.lines[0].reason));
208
+ ok('set-default failure: nothing written', !fs.existsSync(path.join(dir2, '.trailhead')));
209
+ const noView = runMain(['record', '--repo', dir2, '--target', 'fakeuser/cli'], { cwd: '/', world: {} });
210
+ ok('record with unresolvable gh: error, nothing written', noView.lines[0].status === 'error' && !fs.existsSync(path.join(dir2, '.trailhead')));
211
+ }
212
+
213
+ // --- main: record keeps the marker gitignored (#351 W1) ---------------------------
214
+ {
215
+ const IGN = '.trailhead/repo-target.json';
216
+ const gitDir = mktmp();
217
+ fs.mkdirSync(path.join(gitDir, '.git'));
218
+ const give = () => runMain(['record', '--repo', gitDir, '--target', 'cli/cli'], { cwd: '/', world: forkWorld() });
219
+ give();
220
+ ok('record creates .gitignore with the marker in a git repo', fs.readFileSync(path.join(gitDir, '.gitignore'), 'utf8') === IGN + '\n');
221
+ give();
222
+ ok('record is idempotent on .gitignore', fs.readFileSync(path.join(gitDir, '.gitignore'), 'utf8') === IGN + '\n');
223
+ const g2 = mktmp();
224
+ fs.mkdirSync(path.join(g2, '.git'));
225
+ fs.writeFileSync(path.join(g2, '.gitignore'), 'node_modules');
226
+ runMain(['record', '--repo', g2, '--target', 'cli/cli'], { cwd: '/', world: forkWorld() });
227
+ ok('record appends after a file without trailing newline', fs.readFileSync(path.join(g2, '.gitignore'), 'utf8') === 'node_modules\n' + IGN + '\n');
228
+ const g3 = mktmp();
229
+ fs.mkdirSync(path.join(g3, '.git'));
230
+ runMain(['record', '--repo', g3, '--target', 'fakeuser/cli'], { cwd: '/', world: forkWorld({ setDefault: { status: 1, stdout: '', stderr: 'x' } }) });
231
+ ok('a failed record does not touch .gitignore', !fs.existsSync(path.join(g3, '.gitignore')));
232
+ }
233
+
234
+ // --- main: clear, usage errors -------------------------------------------------
235
+ {
236
+ const dir = mktmp();
237
+ writeRec(dir);
238
+ ok('clear present', runMain(['clear', '--repo', dir], { cwd: '/' }).lines[0].status === 'cleared' && !fs.existsSync(recPath(dir)));
239
+ ok('clear absent', runMain(['clear', '--repo', dir], { cwd: '/' }).lines[0].status === 'absent');
240
+ for (const argv of [['record', '--target', 'nope'], ['record'], ['check', '--bogus', 'x'], ['frob'], []]) {
241
+ const d = mktmp();
242
+ const r = runMain(argv.length ? [argv[0], '--repo', d, ...argv.slice(1)] : [], { cwd: d });
243
+ ok(`usage error exit 2: ${argv.join(' ')}`, r.code === 2 && r.err.length > 0 && !fs.existsSync(path.join(d, '.trailhead')));
244
+ }
245
+ }
246
+
247
+ // --- end-to-end (no gh involved: usage error and clear only) -------------------
248
+ {
249
+ const dir = mktmp();
250
+ const bad = spawnSync('node', [scriptPath, 'record', '--repo', dir, '--target', 'nope'], { encoding: 'utf8' });
251
+ ok('e2e: usage error exits 2', bad.status === 2);
252
+ const c = spawnSync('node', [scriptPath, 'clear', '--repo', dir], { encoding: 'utf8' });
253
+ ok('e2e: clear absent prints one JSON line', c.status === 0 && JSON.parse(c.stdout.trim()).status === 'absent');
254
+ }
255
+
256
+ console.log(`repo-target.test.js: ${passed} checks passed`);
@@ -217,6 +217,9 @@ const frows = [
217
217
  ['codebase-map needs_input with questions', 'codebase-map', 'status: needs_input\nanswer: partial\nevidence: a.js:1\nopen questions: which root?\n', ['accept', 'needs_input', null, null, null]],
218
218
  ['plan ready, body is the rest', 'plan', 'status: ready\n\n## Steps\n1. a\n2. b\n\nVerification: x\n', ['accept', 'ready', null, {}, '## Steps\n1. a\n2. b\n\nVerification: x']],
219
219
  ['plan needs_input', 'plan', 'status: needs_input\nquestions:\n1. Which db?\n2. Keep v1?\n', ['accept', 'needs_input', null, { questions: '1. Which db?\n2. Keep v1?' }, null]],
220
+ ['grilling question round', 'grill', 'status: question\nsettled: none\nfrontier: Q1, Q2\nquestion: 1. Which db?\nrecommended: A\nWeak point: it costs more.\namendments: none\nconflicts: none\n', ['accept', 'question', null,
221
+ { settled: 'none', frontier: 'Q1, Q2', question: '1. Which db?', recommended: 'A\nWeak point: it costs more.', amendments: 'none', conflicts: 'none' }, null]],
222
+ ['grilling review round', 'grill', 'status: review\nsettled: Q1 A, because x\nfrontier: none\namendments: Q1: tighten the wording\nconflicts: Q1 vs Q2: reconcile them. Weak point: none\n', ['accept', 'review', null, null, null]],
220
223
  // --- normalise: layout, fields intact ---
221
224
  ['bold label and token, bullets', EX, '**Status:** DONE\n- **commits:** abc1234 feat: x\n- tests: ok\n- deviations: none\n- blockers: none\n', ['normalise', 'done', null,
222
225
  { commits: 'abc1234 feat: x', tests: 'ok', deviations: 'none', blockers: 'none' }, null,
@@ -253,6 +256,14 @@ const frows = [
253
256
  'A feature about statuses:\n```\nstatus: pending\n```\nDone.']],
254
257
  ['a blockquoted status line inside an answer is ignored', 'research', 'status: found\nanswer: see\n> status: pending\nevidence: e\nopen questions: none\n', ['accept', 'found', null, null, null]],
255
258
  ['prose after the last field becomes its value, so a done report reasks', EX, fullImpl('done', 'none', 'Note: hi\n'), ['reask', null, 'fields']],
259
+ ['grilling question without recommended', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
260
+ ['grilling question with an empty question', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: none\nrecommended: A\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
261
+ ['grilling question with conflicts', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\nrecommended: A\namendments: none\nconflicts: Q1 vs Q2\n', ['reask', null, 'fields']],
262
+ ['grilling question with amendments', 'grill', 'status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which db?\nrecommended: A\namendments: Q1: x\nconflicts: none\n', ['reask', null, 'fields']],
263
+ ['grilling review with a frontier', 'grill', 'status: review\nsettled: Q1 A\nfrontier: Q2\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
264
+ ['grilling review with a question', 'grill', 'status: review\nsettled: Q1 A\nfrontier: none\nquestion: 2. More?\namendments: none\nconflicts: none\n', ['reask', null, 'fields']],
265
+ ['grilling review without amendments', 'grill', 'status: review\nsettled: Q1 A\nfrontier: none\nconflicts: none\n', ['reask', null, 'fields']],
266
+ ['grilling: ready is an unknown token', 'grill', 'status: ready\nsettled: none\nfrontier: none\namendments: none\nconflicts: none\n', ['reask', null, 'unknown-token']],
256
267
  ['the same label twice', EX, fullImpl('done', 'none', 'tests: again\n'), ['reask', null, 'fields']],
257
268
  ];
258
269
  for (const [name, technique, input, expected] of frows) {
@@ -289,6 +300,13 @@ for (const [name, technique, input, expected] of frows) {
289
300
  ok('a report with an unterminated fence still parses its leading fields', checkReport('status: found\nanswer: a\nevidence: e\nopen questions: none\n```\nx', 'research').action === 'accept');
290
301
  }
291
302
 
303
+ {
304
+ const r = checkReport('status: review\nsettled: Q1 A\nfrontier: none\namendments: none\nconflicts: none\n', 'grill');
305
+ ok('grill: Weak point is not a field label and body is null', r.body === null && !('weak point' in r.fields));
306
+ const q = checkReport('status: question\nsettled: none\nfrontier: Q1\nquestion: 1. Which?\nrecommended: A\nWeak point: x\nWeak point: y\namendments: none\nconflicts: none\n', 'grill');
307
+ ok('grill: two Weak point lines do not trigger a reask', q.action === 'accept' && q.body === null);
308
+ }
309
+
292
310
  // --- misuse ---------------------------------------------------------------------
293
311
  ok('checkReport: unknown technique throws', (() => { try { checkReport('status: met', 'bogus'); } catch (e) { return true; } return false; })());
294
312
  ok('checkReport: missing technique throws', (() => { try { checkReport('status: met'); } catch (e) { return true; } return false; })());
@@ -317,7 +335,7 @@ ok('vocabulary: code-review tokens equal the ones code-review.md defines',
317
335
  const [lhs, rhs] = l.split('=>');
318
336
  return { when: [...lhs.matchAll(/`([^`]+)`/g)].map((m) => m[1]), field: /`([^`]+)`/.exec(rhs)[1], rule: /(non-empty|none)\.$/.exec(rhs)[1] };
319
337
  });
320
- for (const family of ['implementing', 'investigating', 'plan']) {
338
+ for (const family of ['implementing', 'investigating', 'plan', 'grilling']) {
321
339
  const block = blocks[family];
322
340
  ok(`contracts: source defines the ${family} block`, typeof block === 'string');
323
341
  ok(`contracts: ${family} tokens equal the source`, JSON.stringify(CONTRACTS[family].tokens) === JSON.stringify(words(block, 'statuses')));
@@ -411,7 +429,44 @@ const oneLine = (out) => { const lines = out.split('\n').filter(Boolean); return
411
429
  const jf = oneLine(f.out);
412
430
  ok('cli --file: a report over 64 KB arrives whole through a pipe', f.code === 0 && !!jf && jf.report === big);
413
431
  }
432
+ // --- --task-output: the report read from a background agent's transcript ----------
433
+ // The host's task .output is JSONL; one assistant message can span several lines
434
+ // (same message.id), and tool_use, user and bookkeeping lines sit between them.
435
+ const line = (o) => JSON.stringify(o);
436
+ const asst = (id, content) => line({ type: 'assistant', message: { id, role: 'assistant', content } });
437
+ const transcript = [
438
+ line({ type: 'user', message: { role: 'user', content: 'brief' } }),
439
+ asst('m1', [{ type: 'text', text: 'status: gaps_found\nearly draft' }]),
440
+ asst('m1', [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }]),
441
+ line({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'ok' }] } }),
442
+ asst('m2', [{ type: 'text', text: 'status: met\n' }]),
443
+ asst('m2', [{ type: 'text', text: 'final body\n' }]),
444
+ line({ type: 'attachment', attachment: {} }),
445
+ 'not json at all',
446
+ '',
447
+ ].join('\n');
448
+ {
449
+ const { reportFromTaskOutput } = require(scriptPath);
450
+ ok('task output: the last assistant message, its text blocks joined in order',
451
+ reportFromTaskOutput(transcript) === 'status: met\nfinal body\n');
452
+ ok('task output: a trailing tool_use-only message is skipped for the last one with text',
453
+ reportFromTaskOutput(`${transcript}\n${asst('m3', [{ type: 'tool_use', id: 't2', name: 'Read', input: {} }])}`) === 'status: met\nfinal body\n');
454
+ ok('task output: string content counts as text', reportFromTaskOutput(asst('m9', 'status: met\nx')) === 'status: met\nx');
455
+ ok('task output: no assistant text is null', reportFromTaskOutput(line({ type: 'user', message: { content: 'hi' } })) === null);
456
+ const r = cli(['--technique', 'verify', '--task-output', file('agent.output', transcript)]);
457
+ const j = oneLine(r.out);
458
+ ok('cli --task-output: exit 0, the report checked as accept', r.code === 0 && !!j && j.action === 'accept' && j.verdict === 'met' && j.report === 'status: met\nfinal body\n');
459
+ const target = file('agent.jsonl', transcript);
460
+ const link = path.join(tmp, 'linked.output');
461
+ fs.symlinkSync(target, link);
462
+ const s = oneLine(cli(['--technique', 'verify', '--task-output', link]).out);
463
+ ok('cli --task-output: follows the host symlink to the transcript', !!s && s.verdict === 'met');
464
+ }
414
465
  const usageErrors = [
466
+ ['--file and --task-output together', ['--technique', 'verify', '--file', file('g.md', 'status: met'), '--task-output', file('g.output', transcript)]],
467
+ ['--task-output without a value', ['--technique', 'verify', '--task-output']],
468
+ ['--task-output with no assistant text', ['--technique', 'verify', '--task-output', file('h.output', line({ type: 'user', message: { content: 'x' } }))]],
469
+ ['unreadable --task-output', ['--technique', 'verify', '--task-output', path.join(tmp, 'nope.output')]],
415
470
  ['no technique', ['--file', file('d.md', 'status: met')]],
416
471
  ['unknown technique', ['--technique', 'bogus', '--file', file('e.md', 'status: met')]],
417
472
  ['technique without a value', ['--technique']],
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ // #349: an exported GIT_DIR must not make the statusline describe another repo.
3
+ // Run: node statusline-git-env.test.js
4
+ const assert = require('assert');
5
+ const { spawnSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const script = path.resolve(__dirname, '..', '..', 'templates', 'statusline-trailhead.sh');
11
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-statusline-git-')));
12
+ const home = path.join(root, 'home');
13
+ fs.mkdirSync(home);
14
+
15
+ const cleanEnv = () => { const e = { ...process.env }; for (const k of Object.keys(e)) if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR)$/.test(k)) delete e[k]; return e; };
16
+ const git = (cwd, ...args) => {
17
+ const r = spawnSync('git', ['-C', cwd, '-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { encoding: 'utf8', env: cleanEnv() });
18
+ assert.strictEqual(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
19
+ };
20
+ const mk = (name, branch) => {
21
+ const d = path.join(root, name);
22
+ fs.mkdirSync(d);
23
+ git(d, 'init', '-q', '-b', branch);
24
+ git(d, 'commit', '-q', '--allow-empty', '-m', 'init');
25
+ return d;
26
+ };
27
+ const a = mk('alpha', 'branch-a');
28
+ const b = mk('beta', 'branch-b');
29
+
30
+ function render(env) {
31
+ const input = { model: { id: 'claude-opus-5-5', display_name: 'Test' }, workspace: { project_dir: b, current_dir: b } };
32
+ const r = spawnSync('bash', [script], {
33
+ input: JSON.stringify(input),
34
+ env: { ...cleanEnv(), NO_COLOR: '1', TRAILHEAD_STATUSLINE_USAGE: '0', HOME: home, CLAUDE_CONFIG_DIR: path.join(root, 'cfg'), ...env },
35
+ encoding: 'utf8',
36
+ });
37
+ return r.stdout;
38
+ }
39
+
40
+ const clean = render({});
41
+ assert.ok(clean.includes('beta') && clean.includes('branch-b'), `baseline: ${clean}`);
42
+ const hostile = render({ GIT_DIR: path.join(a, '.git'), GIT_WORK_TREE: a });
43
+ assert.ok(hostile.includes('beta'), `project follows the cwd, not GIT_DIR: ${hostile}`);
44
+ assert.ok(hostile.includes('branch-b') && !hostile.includes('branch-a'), `branch follows the cwd, not GIT_DIR: ${hostile}`);
45
+
46
+ fs.rmSync(root, { recursive: true, force: true });
47
+ console.log('3 passed');