@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
@@ -4,35 +4,62 @@
4
4
  // install channel). The panel cannot move the session's directory, so it hands the
5
5
  // pinned checkout to `work` / `resume` as a trailing `--in=<token>` word and the
6
6
  // verb moves there first. This helper decodes the token, validates it and says
7
- // whether the session is already there. It fails closed: it never searches for
8
- // another checkout. #312.
7
+ // whether the session is already there. It fails closed: never a blind search for
8
+ // another checkout, only a lookup by the repo's origin remote when the path is gone
9
+ // and the token names the repo (trailhead-checkout-find.js). #312, #329.
9
10
  //
10
11
  // The token is base64url(path) (alphabet [A-Za-z0-9_-], no padding), so a path
11
- // with spaces, quotes or a leading `-` travels as one shell-safe word.
12
+ // with spaces, quotes or a leading `-` travels as one shell-safe word. It may carry
13
+ // the repo identity as a second part: base64url(path) + '.' + base64url(host/owner/name)
14
+ // (`.` is outside the alphabet, so an old helper rejects it and stays fail-closed).
12
15
  //
13
16
  // CLI:
14
- // node trailhead-checkout-in.js --in <b64url> --cwd <dir>
17
+ // node trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]
18
+ // `--roots` replaces the default search roots of the remote lookup (tests).
15
19
  //
16
20
  // Prints exactly one JSON line on stdout:
17
- // {"status":"here"|"move"|"invalid","path":"...","reason":"...","cd":"..."}
21
+ // {"status":"here"|"move"|"choose"|"invalid","path":"...","reason":"...","cd":"..."}
18
22
  // `cd` is present on `move` only: a ready-to-run, single-quote-escaped command.
19
- // Exit 0 for the three statuses, 2 on a usage error (stderr).
23
+ // `relocatedFrom` (the old path) is added to `here`/`move` when the checkout was
24
+ // found by its remote. `choose` (path gone, several checkouts match) carries
25
+ // `candidates` ([{path, kind}]) and `reason`.
26
+ // Exit 0 for the four statuses, 2 on a usage error (stderr).
20
27
 
21
28
  const fs = require('fs');
22
29
  const path = require('path');
23
30
  const { spawnSync } = require('child_process');
31
+ const { gitEnv } = require('./trailhead-git-env.js');
32
+
33
+ const { findCheckouts, parseRepoShape } = require('./trailhead-checkout-find.js');
24
34
 
25
35
  const PREFIX = '--in=';
26
36
 
27
- function encodeIn(p) {
28
- return PREFIX + Buffer.from(String(p), 'utf8').toString('base64url');
37
+ const b64 = (s) => Buffer.from(String(s), 'utf8').toString('base64url');
38
+
39
+ function encodeIn(p, repo) {
40
+ return PREFIX + b64(p) + (repo ? '.' + b64(repo) : '');
41
+ }
42
+
43
+ // One base64url part back to its string, or null when it is not canonical.
44
+ function decodePart(part) {
45
+ if (!/^[A-Za-z0-9_-]+$/.test(part)) return null;
46
+ const s = Buffer.from(part, 'base64url').toString('utf8');
47
+ // Round-trip guard: a part that does not re-encode to itself is not ours.
48
+ return s !== '' && b64(s) === part ? s : null;
29
49
  }
30
50
 
51
+ // {path, repo} (repo null for a one-part token), or null for a malformed token.
31
52
  function decodeIn(token) {
32
- if (typeof token !== 'string' || !/^[A-Za-z0-9_-]+$/.test(token)) return null;
33
- const p = Buffer.from(token, 'base64url').toString('utf8');
34
- // Round-trip guard: a token that does not re-encode to itself is not ours.
35
- return p !== '' && Buffer.from(p, 'utf8').toString('base64url') === token ? p : null;
53
+ if (typeof token !== 'string') return null;
54
+ const parts = token.split('.');
55
+ if (parts.length > 2) return null;
56
+ const p = decodePart(parts[0]);
57
+ if (p === null) return null;
58
+ if (parts.length === 1) return { path: p, repo: null };
59
+ const repo = decodePart(parts[1]);
60
+ // The repo part is `host/owner/name` by shape (the host may be dotless, e.g. `gitea`).
61
+ if (repo === null || parseRepoShape(repo) === null) return null;
62
+ return { path: p, repo };
36
63
  }
37
64
 
38
65
  function real(p) {
@@ -44,7 +71,7 @@ function real(p) {
44
71
  }
45
72
 
46
73
  function toplevelOf(dir) {
47
- const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
74
+ const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8', env: gitEnv() });
48
75
  if (r.status !== 0) return null;
49
76
  const top = r.stdout.replace(/\n$/, '');
50
77
  return top === '' ? null : real(top);
@@ -55,12 +82,8 @@ function cdCommand(p) {
55
82
  return `cd -- '${p.replace(/'/g, `'\\''`)}'`;
56
83
  }
57
84
 
58
- function check(token, cwd) {
59
- const p = decodeIn(token);
60
- if (p === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
61
- if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
62
- const canon = real(p);
63
- if (canon === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
85
+ // here/move for a path that must be a checkout root.
86
+ function resolveRoot(p, canon, cwd) {
64
87
  // The path must be a checkout root itself, never a folder inside one.
65
88
  if (toplevelOf(p) !== canon) return { status: 'invalid', path: p, reason: 'the path is not the root of a git checkout' };
66
89
  const here = real(cwd) !== null ? toplevelOf(cwd) : null;
@@ -68,6 +91,38 @@ function check(token, cwd) {
68
91
  return { status: 'move', path: p, cd: cdCommand(p) };
69
92
  }
70
93
 
94
+ // The path is gone: look the repo up by its remote (only when the token names it).
95
+ function relocate(old, repo, cwd, opts) {
96
+ const found = findCheckouts(parseRepoShape(repo), opts.roots ? { roots: opts.roots } : { cwd });
97
+ if (found.status === 'found') {
98
+ const root = resolveRoot(found.path, found.path, cwd);
99
+ return { ...root, relocatedFrom: old };
100
+ }
101
+ if (found.status === 'ambiguous') {
102
+ const cut = found.truncated ? ' (the search was cut short)' : '';
103
+ return {
104
+ status: 'choose',
105
+ path: old,
106
+ candidates: found.candidates,
107
+ reason: `the path does not exist and ${found.candidates.length === 1 ? 'a checkout' : 'several checkouts'} of ${repo} may match${cut}`,
108
+ };
109
+ }
110
+ return { status: 'invalid', path: old, reason: `the path does not exist and no checkout of ${repo} was found` };
111
+ }
112
+
113
+ function check(token, cwd, opts = {}) {
114
+ const d = decodeIn(token);
115
+ if (d === null) return { status: 'invalid', path: '', reason: 'the token does not decode to a path' };
116
+ const p = d.path;
117
+ if (!path.isAbsolute(p)) return { status: 'invalid', path: p, reason: 'the path is not absolute' };
118
+ const canon = real(p);
119
+ if (canon === null) {
120
+ if (d.repo === null) return { status: 'invalid', path: p, reason: 'the path does not exist' };
121
+ return relocate(p, d.repo, cwd, opts);
122
+ }
123
+ return resolveRoot(p, canon, cwd);
124
+ }
125
+
71
126
  function main(argv) {
72
127
  const get = (flag) => {
73
128
  const i = argv.indexOf(flag);
@@ -75,11 +130,13 @@ function main(argv) {
75
130
  };
76
131
  const token = get('--in');
77
132
  const cwd = get('--cwd');
133
+ const roots = get('--roots');
78
134
  if (token === null || cwd === null) {
79
- process.stderr.write('usage: trailhead-checkout-in.js --in <b64url> --cwd <dir>\n');
135
+ process.stderr.write('usage: trailhead-checkout-in.js --in <token> --cwd <dir> [--roots <a:b>]\n');
80
136
  return 2;
81
137
  }
82
- process.stdout.write(JSON.stringify(check(token, cwd)) + '\n');
138
+ const opts = roots === null ? {} : { roots: roots.split(':').filter((r) => r !== '') };
139
+ process.stdout.write(JSON.stringify(check(token, cwd, opts)) + '\n');
83
140
  return 0;
84
141
  }
85
142
 
@@ -23,6 +23,7 @@ const fs = require('fs');
23
23
  const path = require('path');
24
24
  const crypto = require('crypto');
25
25
  const { execFileSync } = require('child_process');
26
+ const { gitEnv } = require('./trailhead-git-env.js');
26
27
 
27
28
  // Nome del file di logica installato accanto al wrapper commit-msg.
28
29
  const CJS_NAME = 'trailhead-commit-msg.cjs';
@@ -47,7 +48,7 @@ function normalize(text) {
47
48
  // deve stampare rumore sul terminale dell'invocante, solo far fallire questa
48
49
  // chiamata (il chiamante decide come reagire).
49
50
  function defaultRunGit(args, cwd) {
50
- return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
51
+ return execFileSync('git', args, { cwd, encoding: 'utf8', env: gitEnv(), stdio: ['ignore', 'pipe', 'pipe'] }).trim();
51
52
  }
52
53
 
53
54
  // Risolve la hooksDir effettiva del repo (rispetta core.hooksPath e i
@@ -7,11 +7,18 @@
7
7
  // updatedAt check, post-write conflict check, secret scan). #253.
8
8
  //
9
9
  // CLI:
10
- // node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
10
+ // node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run] [--update-only]
11
11
  // --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
12
12
  // audit's only writer path); without it the marker is copied verbatim from the
13
13
  // existing body. --dry-run gathers and reads through gh, computes and scans, but
14
14
  // writes, creates, pins and labels nothing; it prints the old and new byte sizes.
15
+ // --update-only refreshes an existing dashboard and never opens one: with no open
16
+ // dashboard issue it creates, pins and labels nothing (the label ensure is skipped
17
+ // too) and prints `skipped: no dashboard issue (update-only)`, exit 0. A capture
18
+ // uses it so it cannot open a second issue; the check lives here, in the helper,
19
+ // so there is no check-then-call race. That is all it forbids: it never creates the
20
+ // dashboard. With a dashboard present it is a normal refresh (the label ensure and
21
+ // the re-pin run as usual, both idempotent).
15
22
  //
16
23
  // Singleton labels (dashboard, codebase, conventions): more than one open issue
17
24
  // aborts before any write, naming every duplicate by title and number.
@@ -23,6 +30,7 @@
23
30
  //
24
31
  // Prints exactly one line on stdout:
25
32
  // dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
33
+ // skipped: no dashboard issue (update-only)
26
34
  // aborted: <reason>
27
35
  // Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
28
36
  // A pin check or pin failure AFTER the body was written is not an abort: the line
@@ -266,10 +274,11 @@ function createDashboard(gh, repo, body, tmpdir) {
266
274
  // --- CLI ---------------------------------------------------------------------
267
275
 
268
276
  function parseArgs(argv) {
269
- const o = { repo: null, setLastAudit: null, dryRun: false };
277
+ const o = { repo: null, setLastAudit: null, dryRun: false, updateOnly: false };
270
278
  for (let i = 0; i < argv.length; i++) {
271
279
  const flag = argv[i];
272
280
  if (flag === '--dry-run') { o.dryRun = true; continue; }
281
+ if (flag === '--update-only') { o.updateOnly = true; continue; }
273
282
  if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
274
283
  i++;
275
284
  if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
@@ -304,6 +313,10 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
304
313
  const repo = o.repo || defaultRepo(run);
305
314
  if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
306
315
  const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
316
+ if (o.updateOnly && singles.dashboard === null) {
317
+ out.write('skipped: no dashboard issue (update-only)\n');
318
+ return 0;
319
+ }
307
320
  if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
308
321
  const snap = gatherSnapshot(run, repo, singles);
309
322
  const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
@@ -0,0 +1,40 @@
1
+ 'use strict';
2
+ // Repo-location environment for git spawns.
3
+ //
4
+ // Git gives these variables precedence over `-C` and the cwd, so an exported
5
+ // GIT_DIR (a hook, `git rebase --exec`, a parent shell) silently redirects every
6
+ // git call to another repository. Each git spawn in the plugin builds its env
7
+ // through gitEnv() so it always acts on the checkout it was pointed at.
8
+ //
9
+ // The names are `git rev-parse --local-env-vars` minus GIT_CONFIG*: those carry
10
+ // the parent's `-c` options and are meant to be inherited. A static list keeps
11
+ // this free of a git spawn; refresh it from that command when git adds a name.
12
+ // Names must be deleted: an empty string is not "unset" (`fatal: '' is not a git repository`).
13
+ //
14
+ // hooks/lib/trailhead-git-env.js is a byte-identical copy for the hooks (they
15
+ // install without the templates); a test keeps the two in sync.
16
+
17
+ const GIT_LOCATION_VARS = Object.freeze([
18
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
19
+ 'GIT_OBJECT_DIRECTORY',
20
+ 'GIT_DIR',
21
+ 'GIT_WORK_TREE',
22
+ 'GIT_IMPLICIT_WORK_TREE',
23
+ 'GIT_GRAFT_FILE',
24
+ 'GIT_INDEX_FILE',
25
+ 'GIT_NO_REPLACE_OBJECTS',
26
+ 'GIT_REPLACE_REF_BASE',
27
+ 'GIT_PREFIX',
28
+ 'GIT_SHALLOW_FILE',
29
+ 'GIT_COMMON_DIR',
30
+ ]);
31
+
32
+ // A new env object: `base` without the repo-location names, then `extra` on top.
33
+ // Never mutates `base` (process.env by default).
34
+ function gitEnv(base = process.env, extra = {}) {
35
+ const out = {};
36
+ for (const [k, v] of Object.entries(base)) if (!GIT_LOCATION_VARS.includes(k)) out[k] = v;
37
+ return { ...out, ...extra };
38
+ }
39
+
40
+ module.exports = { GIT_LOCATION_VARS, gitEnv };
@@ -0,0 +1,161 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead inbox needs-info classifier (self-contained, ships in templates/ so it
4
+ // reaches every install channel). Read-only: never writes to the tracker. #354.
5
+ //
6
+ // An inbox issue parked with `trailhead:needs-info` waits on its reporter. This
7
+ // helper sorts the parked issues so the inbox protocol can act on them:
8
+ // answered the reporter commented strictly after the latest label event
9
+ // timedOut no answer and at least N whole days since the label
10
+ // waiting no answer yet, inside the window
11
+ // unknown a read failed, no label event, or no usable reporter identity
12
+ //
13
+ // CLI:
14
+ // node trailhead-inbox-needs-info.js --repo <owner/name> --config-root <project root>
15
+ // N comes from `inbox.needs_info_days` (project config over the global one, default 14).
16
+ //
17
+ // Prints exactly one JSON line on stdout:
18
+ // {status, days, answered, timedOut, waiting, unknown, warnings}
19
+ // Exit 0 on ok, 1 on status error (the list read failed), 2 on a usage error (stderr).
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { spawnSync } = require('child_process');
25
+ const { gitEnv } = require('./trailhead-git-env.js');
26
+
27
+ const LABEL = 'trailhead:needs-info';
28
+ const KEY = 'inbox.needs_info_days';
29
+ const DEFAULT_DAYS = 14;
30
+ const DAY_MS = 24 * 60 * 60 * 1000;
31
+
32
+ function defaultGh(args) {
33
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
34
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
35
+ }
36
+
37
+ const firstLine = (s) => String(s || '').split('\n').find((l) => l.trim()) || '';
38
+
39
+ function parseArgs(argv) {
40
+ const o = { repo: null, configRoot: null };
41
+ for (let i = 0; i < argv.length; i++) {
42
+ const flag = argv[i];
43
+ if (flag !== '--repo' && flag !== '--config-root') return { error: `usage: unknown argument: ${flag}` };
44
+ i++;
45
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
46
+ const v = argv[i];
47
+ if (flag === '--repo') {
48
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
49
+ o.repo = v;
50
+ } else {
51
+ o.configRoot = v;
52
+ }
53
+ }
54
+ if (!o.repo) return { error: 'usage: missing --repo <owner/name>' };
55
+ if (!o.configRoot) return { error: 'usage: missing --config-root <project root>' };
56
+ return o;
57
+ }
58
+
59
+ // Lines of `gh api --paginate ... --jq '... | tojson'`: one JSON document per line.
60
+ // Any non-zero exit discards the whole read: a later page can fail after earlier ones printed.
61
+ function jsonLines(r) {
62
+ if (r.status !== 0) return null;
63
+ const out = [];
64
+ for (const line of r.stdout.split('\n')) {
65
+ if (!line.trim()) continue;
66
+ try { out.push(JSON.parse(line)); } catch { return null; }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
72
+ const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
73
+
74
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
75
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
76
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
77
+
78
+ function globalConfigPath(env) {
79
+ const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
80
+ return path.join(base, 'trailhead', 'config.json');
81
+ }
82
+
83
+ // { found, value } for the key in one parsed file: the flat key wins over the nested one.
84
+ function pick(cfg) {
85
+ if (has(cfg, KEY)) return { found: true, value: cfg[KEY] };
86
+ if (isObject(cfg.inbox) && has(cfg.inbox, 'needs_info_days')) return { found: true, value: cfg.inbox.needs_info_days };
87
+ return { found: false };
88
+ }
89
+
90
+ // Days from the nearest valid layer. A bad layer warns and falls to the next one: a timeout only
91
+ // ever produces an offer the maintainer confirms.
92
+ function resolveDays(root, env, fs_, warnings) {
93
+ for (const file of [path.join(root, '.trailhead', 'config.json'), globalConfigPath(env)]) {
94
+ let cfg;
95
+ try {
96
+ cfg = JSON.parse(fs_.readFileSync(file, 'utf8'));
97
+ } catch (e) {
98
+ if (e && e.code === 'ENOENT') continue;
99
+ warnings.push(e instanceof SyntaxError ? `${file} is not valid JSON; ignored` : `cannot read ${file}; ignored`);
100
+ continue;
101
+ }
102
+ if (!isObject(cfg)) { warnings.push(`${file} is not a JSON object; ignored`); continue; }
103
+ const hit = pick(cfg);
104
+ if (!hit.found) continue;
105
+ if (Number.isInteger(hit.value) && hit.value > 0) return hit.value;
106
+ warnings.push(`${KEY} in ${file} must be a positive integer; ignored`);
107
+ }
108
+ return DEFAULT_DAYS;
109
+ }
110
+
111
+ const usable = (login) => typeof login === 'string' && login !== '' && login.toLowerCase() !== 'ghost';
112
+
113
+ function main(argv, { gh, now, stdout, stderr, env, fs: fs_ } = {}) {
114
+ const out = stdout || process.stdout;
115
+ const err = stderr || process.stderr;
116
+ const o = parseArgs(argv || []);
117
+ if (o.error) { err.write(o.error + '\n'); return 2; }
118
+ const run = gh || defaultGh;
119
+ const nowMs = (now ? now() : new Date()).getTime();
120
+ const warnings = [];
121
+ const days = resolveDays(o.configRoot, env || process.env, fs_ || fs, warnings);
122
+ const emit = (obj) => out.write(JSON.stringify(obj) + '\n');
123
+
124
+ const list = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues?labels=${LABEL}&state=open&per_page=100`, '--jq', '.[] | select(.pull_request == null) | {number, title, author: .user.login} | tojson']));
125
+ if (!list) {
126
+ emit({ status: 'error', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings, reason: 'could not read the parked issues' });
127
+ return 1;
128
+ }
129
+
130
+ const res = { status: 'ok', days, answered: [], timedOut: [], waiting: [], unknown: [], warnings };
131
+ for (const i of list) {
132
+ const { number, title } = i;
133
+ const unknown = (reason) => res.unknown.push({ number, title, reason });
134
+ const events = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/events`, '--jq', `.[] | select(.event == "labeled" and .label.name == "${LABEL}") | {at: .created_at} | tojson`]));
135
+ if (!events) { unknown('could not read the label events'); continue; }
136
+ const times = events.map((e) => Date.parse(e.at)).filter((t) => !Number.isNaN(t));
137
+ if (!times.length) { unknown('no label event found'); continue; }
138
+ if (!usable(i.author)) { unknown('the reporter has no usable identity'); continue; }
139
+ const labeledMs = Math.max(...times);
140
+ const labeledAt = new Date(labeledMs).toISOString().replace('.000Z', 'Z');
141
+ const comments = jsonLines(run(['api', '--paginate', `repos/${o.repo}/issues/${number}/comments?since=${labeledAt}&per_page=100`, '--jq', '.[] | {author: .user.login, at: .created_at} | tojson']));
142
+ if (!comments) { unknown('could not read the comments'); continue; }
143
+ // `since` filters on update time, so the creation time is checked again.
144
+ const reporter = i.author.toLowerCase();
145
+ const answers = comments
146
+ .filter((c) => usable(c.author) && c.author.toLowerCase() === reporter && Date.parse(c.at) > labeledMs)
147
+ .sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
148
+ const ageDays = Math.max(0, Math.floor((nowMs - labeledMs) / DAY_MS));
149
+ if (answers.length) res.answered.push({ number, title, author: i.author, labeledAt, answeredAt: answers[0].at });
150
+ else if (ageDays >= days) res.timedOut.push({ number, title, author: i.author, labeledAt, ageDays });
151
+ else res.waiting.push({ number, title, labeledAt, ageDays });
152
+ }
153
+ emit(res);
154
+ return 0;
155
+ }
156
+
157
+ module.exports = { globalConfigPath, main, parseArgs, resolveDays, DEFAULT_DAYS };
158
+
159
+ if (require.main === module) {
160
+ process.exit(main(process.argv.slice(2), {}));
161
+ }
@@ -32,6 +32,7 @@ const fs = require('fs');
32
32
  const path = require('path');
33
33
  const crypto = require('crypto');
34
34
  const { execFileSync } = require('child_process');
35
+ const { gitEnv } = require('./trailhead-git-env.js');
35
36
 
36
37
  const STATE_DIR = '.trailhead';
37
38
  const STATE_FILE = 'isolation-answer.json';
@@ -124,7 +125,7 @@ function parseArgs(argv) {
124
125
  function resolveProjectRoot(dir) {
125
126
  try {
126
127
  const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
127
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
128
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv() }).trim();
128
129
  if (common && path.basename(common) === '.git') return path.dirname(common);
129
130
  return dir;
130
131
  } catch {
@@ -33,6 +33,7 @@ const fs = require('fs');
33
33
  const os = require('os');
34
34
  const path = require('path');
35
35
  const { execFileSync } = require('child_process');
36
+ const { gitEnv } = require('./trailhead-git-env.js');
36
37
 
37
38
  const CONFIG_REL = path.join('.trailhead', 'config.json');
38
39
  const SUFFIXES = ['.example', '.sample'];
@@ -98,8 +99,12 @@ function readCopyList(file, fs_) {
98
99
  return { state: 'set', list: value, warnings };
99
100
  }
100
101
 
102
+ // Su Codex lo script vive in <codexHome>/skills/trailhead/<dir>: la config globale sta lì, non in ~/.claude.
103
+ const CODEX_INSTALL = /[\\/]skills[\\/]trailhead[\\/](?:hooks|templates)$/;
104
+ const codexHome = () => (CODEX_INSTALL.test(__dirname) ? path.resolve(__dirname, '..', '..', '..') : null);
105
+
101
106
  function globalConfigPath(env) {
102
- const base = env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
107
+ const base = codexHome() || env.CLAUDE_CONFIG_DIR || path.join(env.HOME || os.homedir(), '.claude');
103
108
  return path.join(base, 'trailhead', 'config.json');
104
109
  }
105
110
 
@@ -131,7 +136,7 @@ const exists = (p, fs_) => {
131
136
  function suggest(from, fs_) {
132
137
  let tracked;
133
138
  try {
134
- tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
139
+ tracked = execFileSync('git', ['-C', from, 'ls-files', '-z'], { stdio: ['ignore', 'pipe', 'ignore'], env: gitEnv(), maxBuffer: 64 * 1024 * 1024 })
135
140
  .toString().split('\0').filter(Boolean);
136
141
  } catch (err) {
137
142
  return [];
@@ -143,7 +148,7 @@ function suggest(from, fs_) {
143
148
  const sibling = file.slice(0, -suffix.length);
144
149
  if (out.includes(sibling) || checkRelPath(sibling) !== null || !exists(path.join(from, sibling), fs_)) continue;
145
150
  try {
146
- execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore' });
151
+ execFileSync('git', ['-C', from, 'check-ignore', '-q', '--', sibling], { stdio: 'ignore', env: gitEnv() });
147
152
  out.push(sibling);
148
153
  } catch (err) {
149
154
  // not ignored (exit 1) or git error: not a suggestion
@@ -231,7 +236,7 @@ function main(argv, { cwd, stdout, env, fs: fsMod } = {}) {
231
236
  }
232
237
  }
233
238
 
234
- module.exports = { checkRelPath, readCopyList, parseArgs, main };
239
+ module.exports = { globalConfigPath, checkRelPath, readCopyList, parseArgs, main };
235
240
 
236
241
  if (require.main === module) {
237
242
  const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, env: process.env });