@marcomigozzi/trailhead 0.17.0 → 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 (116) hide show
  1. package/README.it.md +28 -18
  2. package/README.md +28 -18
  3. package/bin/base-preflight-docs.test.js +4 -1
  4. package/bin/bulk-cross-reference-docs.test.js +36 -0
  5. package/bin/engine-route-docs.test.js +7 -0
  6. package/bin/facet-stamp-docs.test.js +79 -0
  7. package/bin/forced-mutation-docs.test.js +38 -0
  8. package/bin/guard-axis-docs.test.js +69 -0
  9. package/bin/inbox-needs-info-docs.test.js +68 -0
  10. package/bin/lib/codex-projection.js +8 -8
  11. package/bin/lib/codex-projection.test.js +19 -3
  12. package/bin/needs-you-docs.test.js +9 -2
  13. package/bin/options-in-chat-docs.test.js +65 -0
  14. package/bin/pick-stress-test-docs.test.js +1 -1
  15. package/bin/pipeline-review-docs.test.js +118 -0
  16. package/bin/plan-review-budget-docs.test.js +63 -0
  17. package/bin/repo-target-docs.test.js +41 -0
  18. package/bin/seam-coverage-docs.test.js +46 -0
  19. package/bin/standards-brief-docs.test.js +112 -0
  20. package/bin/test-once-docs.test.js +101 -0
  21. package/bin/test-run-docs.test.js +47 -0
  22. package/bin/trailhead.js +9 -4
  23. package/bin/trailhead.test.js +18 -6
  24. package/bin/yes-accepts-recommended-docs.test.js +54 -0
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -1
  28. package/plugins/trailhead/agents/trailhead-grill.md +2 -2
  29. package/plugins/trailhead/agents/trailhead-plan.md +1 -1
  30. package/plugins/trailhead/agents/trailhead-verify.md +1 -0
  31. package/plugins/trailhead/hooks/lib/audit-walk.test.js +62 -0
  32. package/plugins/trailhead/hooks/lib/git-env-mod.test.js +55 -0
  33. package/plugins/trailhead/hooks/lib/git-env-spawn.test.js +73 -0
  34. package/plugins/trailhead/hooks/lib/git-env.test.js +51 -0
  35. package/plugins/trailhead/hooks/lib/global-config-dir.test.js +48 -0
  36. package/plugins/trailhead/hooks/lib/heredoc-mod.test.js +85 -0
  37. package/plugins/trailhead/hooks/lib/inbox-needs-info.test.js +179 -0
  38. package/plugins/trailhead/hooks/lib/issue-body.test.js +81 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +45 -1
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +272 -0
  41. package/plugins/trailhead/hooks/lib/repo-target.test.js +256 -0
  42. package/plugins/trailhead/hooks/lib/statusline-git-env.test.js +47 -0
  43. package/plugins/trailhead/hooks/lib/test-run.test.js +757 -0
  44. package/plugins/trailhead/hooks/lib/trailhead-git-env.js +40 -0
  45. package/plugins/trailhead/hooks/mod/README.md +30 -14
  46. package/plugins/trailhead/hooks/mod/auto-run.ts +2 -1
  47. package/plugins/trailhead/hooks/mod/git-env.ts +31 -0
  48. package/plugins/trailhead/hooks/mod/heredoc.ts +79 -0
  49. package/plugins/trailhead/hooks/mod/map-pane-view.ts +3 -1
  50. package/plugins/trailhead/hooks/mod/map-pane.tsx +144 -45
  51. package/plugins/trailhead/hooks/mod/repo-pin.ts +105 -4
  52. package/plugins/trailhead/hooks/mod/work-state-locate.ts +2 -1
  53. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  54. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +28 -14
  55. package/plugins/trailhead/hooks/trailhead-check-update.js +3 -1
  56. package/plugins/trailhead/hooks/trailhead-labels-guard.js +2 -2
  57. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +1 -1
  58. package/plugins/trailhead/hooks/trailhead-test-guard.js +232 -0
  59. package/plugins/trailhead/hooks/trailhead-test-guard.test.js +172 -0
  60. package/plugins/trailhead/skills/_shared/capture-core.md +5 -0
  61. package/plugins/trailhead/skills/_shared/configuration-reference.md +12 -0
  62. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  63. package/plugins/trailhead/skills/_shared/substrate-commands.md +11 -2
  64. package/plugins/trailhead/skills/_shared/substrate.md +3 -2
  65. package/plugins/trailhead/skills/_shared/teamwork.md +5 -3
  66. package/plugins/trailhead/skills/_shared/techniques/code-review-pipeline.md +24 -0
  67. package/plugins/trailhead/skills/_shared/techniques/code-review.md +18 -5
  68. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +2 -2
  69. package/plugins/trailhead/skills/_shared/techniques/debug.md +1 -1
  70. package/plugins/trailhead/skills/_shared/techniques/grilling.md +3 -3
  71. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +16 -4
  72. package/plugins/trailhead/skills/_shared/techniques/tdd.md +4 -2
  73. package/plugins/trailhead/skills/_shared/techniques/verify.md +2 -0
  74. package/plugins/trailhead/skills/_shared/techniques.md +3 -3
  75. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  76. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  77. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +8 -3
  78. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +17 -2
  79. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -0
  80. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  81. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +2 -0
  82. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -7
  83. package/plugins/trailhead/templates/statusline-trailhead.sh +10 -3
  84. package/plugins/trailhead/templates/trailhead-audit-walk.js +56 -4
  85. package/plugins/trailhead/templates/trailhead-auto-merge.js +2 -1
  86. package/plugins/trailhead/templates/trailhead-auto-queue.js +2 -1
  87. package/plugins/trailhead/templates/trailhead-auto-slot.js +2 -1
  88. package/plugins/trailhead/templates/trailhead-base-preflight.js +10 -5
  89. package/plugins/trailhead/templates/trailhead-capture.js +2 -1
  90. package/plugins/trailhead/templates/trailhead-checkout-find.js +3 -2
  91. package/plugins/trailhead/templates/trailhead-checkout-in.js +2 -1
  92. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +2 -1
  93. package/plugins/trailhead/templates/trailhead-git-env.js +40 -0
  94. package/plugins/trailhead/templates/trailhead-inbox-needs-info.js +161 -0
  95. package/plugins/trailhead/templates/trailhead-isolation-answer.js +2 -1
  96. package/plugins/trailhead/templates/trailhead-isolation-copy.js +9 -4
  97. package/plugins/trailhead/templates/trailhead-issue-body.js +92 -6
  98. package/plugins/trailhead/templates/trailhead-merge-ticket.js +2 -1
  99. package/plugins/trailhead/templates/trailhead-needs-you.js +38 -4
  100. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +261 -7
  101. package/plugins/trailhead/templates/trailhead-repo-target.js +302 -0
  102. package/plugins/trailhead/templates/trailhead-test-run.js +752 -0
  103. package/plugins/trailhead/templates/trailhead-uat-backfill.js +2 -1
  104. package/plugins/trailhead/templates/trailhead-uat-followup.js +2 -1
  105. package/plugins/trailhead/templates/trailhead-uat-setup.js +9 -4
  106. package/plugins/trailhead/templates/trailhead-unblock.js +2 -1
  107. package/plugins/trailhead/tests/auto-run-ui.test.ts +2 -1
  108. package/plugins/trailhead/tests/git-env.test.ts +20 -0
  109. package/plugins/trailhead/tests/map-pane-ui.test.ts +268 -17
  110. package/plugins/trailhead/tests/map-pane-view.test.ts +6 -0
  111. package/plugins/trailhead/tests/map-pane-write.test.ts +57 -1
  112. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +3 -2
  113. package/plugins/trailhead/tests/repo-pin-relocate.test.ts +175 -0
  114. package/plugins/trailhead/tests/unscrub.ts +8 -0
  115. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +3 -2
  116. package/plugins/trailhead/tests/work-state-worktree.test.ts +2 -1
@@ -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 });
@@ -8,7 +8,8 @@
8
8
  //
9
9
  // CLI:
10
10
  // node trailhead-issue-body.js <append|replace|remove> --issue <n> [--repo <owner/name>]
11
- // --section "<name>" (--line "<text>" | --file <path> | --match "<text>") [--dry-run]
11
+ // --section "<name>" (--line "<text>" | --file <path> | --match "<text>")
12
+ // [--stamp <sha>] [--dry-run]
12
13
  // append adds --line as the last line of the section (a `_none yet_`-style
13
14
  // placeholder that is the section's only content is dropped)
14
15
  // replace swaps the section content for the text of --file
@@ -17,6 +18,12 @@
17
18
  // <name> matches a `## ` heading with its leading emoji/punctuation stripped
18
19
  // (`Decisions so far` matches `## ✅ Decisions so far`); `:preamble` is the text
19
20
  // before the first `## ` (the conventions header).
21
+ // --stamp <sha> (7 to 40 lowercase hex) sets the section's `_as of <sha>_` line, the
22
+ // commit a codebase facet was written or last patched against: the first content
23
+ // line of the section, then a blank line. Applied only when given (map and
24
+ // conventions patches omit it) and written in the same single guarded write as the
25
+ // patch. Without it an existing stamp is kept; a no-op `remove` writes nothing and
26
+ // does not stamp. Not valid on `:preamble`. #360.
20
27
  // --dry-run reads and computes (secret scan included) but writes nothing and
21
28
  // prints the old and new byte sizes.
22
29
  //
@@ -40,6 +47,7 @@ const fs = require('fs');
40
47
  const os = require('os');
41
48
  const path = require('path');
42
49
  const { spawnSync } = require('child_process');
50
+ const { gitEnv } = require('./trailhead-git-env.js');
43
51
 
44
52
  const PREAMBLE = ':preamble';
45
53
  const DEGENERATE_MAX = 20; // a body with fewer non-space characters is degenerate
@@ -197,9 +205,83 @@ function readSection(body, name) {
197
205
  return sec ? lines.slice(sec.from, sec.to).filter((l) => !isBlank(l)) : null;
198
206
  }
199
207
 
200
- // op: { kind: 'append'|'replace'|'remove', section, line|text|match }.
208
+ // --- facet stamp -------------------------------------------------------------
209
+
210
+ const STAMP_RE = /^_as of ([0-9a-f]{7,40})_$/;
211
+ const STAMP_VALUE_RE = /^[0-9a-f]{7,40}$/;
212
+ const stampLine = (sha) => `_as of ${sha}_`;
213
+
214
+ // The first non-blank line of lines[from..to) when it is a stamp: { at, sha }, else null.
215
+ function stampIn(lines, from, to) {
216
+ for (let i = from; i < to; i++) {
217
+ if (isBlank(lines[i])) continue;
218
+ const m = STAMP_RE.exec(lines[i].trim());
219
+ return m ? { at: i, sha: m[1] } : null;
220
+ }
221
+ return null;
222
+ }
223
+
224
+ // `## ` sections of a body as { name, sha, text }: sha is the stamp or null, text
225
+ // the section content. Fence-aware (headingIndexes), so a fenced `## ` is no facet.
226
+ function facetsOf(body) {
227
+ const { lines } = splitBody(String(body));
228
+ const heads = headingIndexes(lines);
229
+ return heads.map((h, k) => {
230
+ const to = k + 1 < heads.length ? heads[k + 1] : lines.length;
231
+ const stamp = stampIn(lines, h + 1, to);
232
+ return { name: headingName(lines[h]), sha: stamp ? stamp.sha : null, text: lines.slice(h + 1, to).join('\n') };
233
+ });
234
+ }
235
+
236
+ // Runs the op with the stamp set aside, then writes the supplied stamp (else the
237
+ // old one, else the file's own for a replace) back as the section's first content.
238
+ function applyStamped(body, op) {
239
+ if (op.stamp !== undefined && !STAMP_VALUE_RE.test(op.stamp)) return { error: 'stamp must be 7 to 40 lowercase hex characters' };
240
+ if (op.section === PREAMBLE) return op.stamp === undefined ? applyCore(body, op) : { error: 'a stamp does not apply to the preamble' };
241
+ const parts = splitBody(String(body));
242
+ const sec = findSection(parts.lines, op.section);
243
+ if (!sec) return { error: `section not found: ${op.section}` };
244
+ const old = stampIn(parts.lines, sec.from, sec.to);
245
+ let text = op.text;
246
+ let own = null;
247
+ if (op.kind === 'replace') {
248
+ const tl = bodyLines(text);
249
+ const t = stampIn(tl, 0, tl.length);
250
+ if (t) {
251
+ own = t.sha;
252
+ tl.splice(t.at, t.at + 1 < tl.length && isBlank(tl[t.at + 1]) ? 2 : 1);
253
+ text = tl.join('\n');
254
+ }
255
+ }
256
+ const sha = op.stamp !== undefined ? op.stamp : old ? old.sha : own;
257
+ if (!old && sha === null) return applyCore(body, { ...op, text });
258
+ // Strip the old stamp line and the one blank line after it.
259
+ let stripped = parts;
260
+ if (old) {
261
+ const drop = old.at + 1 < sec.to && isBlank(parts.lines[old.at + 1]) ? 2 : 1;
262
+ stripped = { ...parts, lines: [...parts.lines.slice(0, old.at), ...parts.lines.slice(old.at + drop)] };
263
+ }
264
+ const r = applyCore(joinBody(stripped), { ...op, text });
265
+ if (r.error) return r;
266
+ if (!r.changed) return { ...r, body: String(body) };
267
+ const out = splitBody(r.body);
268
+ const ns = findSection(out.lines, op.section);
269
+ let at = ns.from;
270
+ while (at < ns.to && isBlank(out.lines[at])) at++;
271
+ if (at === ns.to) at = ns.from;
272
+ const add = [stampLine(sha)];
273
+ if (at >= out.lines.length || !isBlank(out.lines[at])) add.push('');
274
+ const lines = [...out.lines.slice(0, at), ...add, ...out.lines.slice(at)];
275
+ return { ...r, body: joinBody({ ...out, lines }) };
276
+ }
277
+
278
+ // op: { kind: 'append'|'replace'|'remove', section, line|text|match, stamp? }.
201
279
  // Returns { body, changed, summary } or { error }.
202
280
  function applyOp(body, op) {
281
+ return applyStamped(body, op);
282
+ }
283
+
284
+ function applyCore(body, op) {
203
285
  const parts = splitBody(String(body));
204
286
  const { lines } = parts;
205
287
  const sec = findSection(lines, op.section);
@@ -291,7 +373,7 @@ function secretProblem(text, guard) {
291
373
  // --- gh boundary -------------------------------------------------------------
292
374
 
293
375
  function defaultGh(args) {
294
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
376
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
295
377
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
296
378
  }
297
379
 
@@ -445,7 +527,7 @@ function parseArgs(argv) {
445
527
  for (let i = 1; i < argv.length; i++) {
446
528
  const flag = argv[i];
447
529
  if (flag === '--dry-run') { o.dryRun = true; continue; }
448
- if (!['--issue', '--repo', '--section', '--line', '--file', '--match'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
530
+ if (!['--issue', '--repo', '--section', '--line', '--file', '--match', '--stamp'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
449
531
  i++;
450
532
  if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
451
533
  const v = argv[i];
@@ -459,6 +541,10 @@ function parseArgs(argv) {
459
541
  }
460
542
  if (o.issue === undefined) return { error: 'usage: --issue is required' };
461
543
  if (!o.section) return { error: 'usage: --section is required' };
544
+ if (o.stamp !== undefined) {
545
+ if (!STAMP_VALUE_RE.test(o.stamp)) return { error: `usage: --stamp must be 7 to 40 lowercase hex characters, got: ${o.stamp}` };
546
+ if (o.section === PREAMBLE) return { error: 'usage: --stamp does not apply to :preamble' };
547
+ }
462
548
  const given = ['line', 'file', 'match'].filter((k) => o[k] !== undefined);
463
549
  const want = { append: 'line', replace: 'file', remove: 'match' }[kind];
464
550
  if (given.length !== 1 || given[0] !== want) return { error: `usage: ${kind} needs exactly --${want}` };
@@ -475,7 +561,7 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
475
561
  if (o.kind === 'replace') {
476
562
  try { text = fs.readFileSync(o.file, 'utf8'); } catch (e) { err.write(`usage: cannot read --file ${o.file}: ${e.message}\n`); return 2; }
477
563
  }
478
- const op = { kind: o.kind, section: o.section, line: o.line, text, match: o.match };
564
+ const op = { kind: o.kind, section: o.section, line: o.line, text, match: o.match, stamp: o.stamp };
479
565
  const prefix = `issue #${o.issue}`;
480
566
  let summary = '';
481
567
  let unchanged = false;
@@ -515,7 +601,7 @@ function noopFirst(o, compute, wasUnchanged, opts) {
515
601
  return !(r && r.error) && wasUnchanged();
516
602
  }
517
603
 
518
- module.exports = { applyOp, readSection, defaultGh, PLACEHOLDER_RE, guardedWrite, loadGuard, main, parseArgs, readIssue, editCount, secretProblem, PREAMBLE, HISTORY_FULL_NOTE };
604
+ module.exports = { applyOp, facetsOf, readSection, defaultGh, PLACEHOLDER_RE, guardedWrite, loadGuard, main, parseArgs, readIssue, editCount, secretProblem, PREAMBLE, HISTORY_FULL_NOTE };
519
605
 
520
606
  if (require.main === module) {
521
607
  process.exit(main(process.argv.slice(2), {}));
@@ -40,6 +40,7 @@ const fs = require('fs');
40
40
  const os = require('os');
41
41
  const path = require('path');
42
42
  const { spawnSync } = require('child_process');
43
+ const { gitEnv } = require('./trailhead-git-env.js');
43
44
  const { guardedWrite, loadGuard, secretProblem } = require('./trailhead-issue-body.js');
44
45
 
45
46
  const MAX_PAGES = 100; // 100 pages x 100 issues
@@ -52,7 +53,7 @@ const NEEDS_YOU_DESC = 'Needs you: an autonomous run parked it for the human';
52
53
  const TITLE_PREFIX = 'Merge the branches left unmerged: ';
53
54
 
54
55
  function defaultGh(args) {
55
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
56
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
56
57
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
57
58
  }
58
59
 
@@ -7,6 +7,7 @@
7
7
  // CLI:
8
8
  // node trailhead-needs-you.js park --issue <n> --reason <text> --repo <owner/name>
9
9
  // node trailhead-needs-you.js clear --issue <n> --repo <owner/name>
10
+ // node trailhead-needs-you.js sweep --repo <owner/name>
10
11
  //
11
12
  // park, in this order (so any partial failure leaves the ticket visible: a label is
12
13
  // never set on a ticket still claimed by the run):
@@ -21,15 +22,19 @@
21
22
  // 5. unassign `@me` when the ticket is assigned;
22
23
  // 6. add the label last.
23
24
  // clear: when the ticket carries the label, remove it; otherwise a no-op.
25
+ // sweep: remove the label from every CLOSED issue that still carries it, however it was
26
+ // closed (a Resolve, a hand close, the web UI): a closed ticket never waits on the human.
24
27
  //
25
28
  // Prints exactly one JSON line on stdout:
26
29
  // park: {"status":"ok","issue":n,"commented":bool,"unassigned":bool,"labelled":bool}
27
30
  // clear: {"status":"ok","issue":n,"cleared":bool}
31
+ // sweep: {"status":"ok","cleared":[n,...]} (an error also carries the cleared list)
28
32
  // error: {"status":"error","reason":"..."}
29
33
  // Exit 0 on ok, 1 on an error (any failed or unparseable gh step), 2 on a usage error
30
34
  // (stderr).
31
35
 
32
36
  const { spawnSync } = require('child_process');
37
+ const { gitEnv } = require('./trailhead-git-env.js');
33
38
  const fs = require('fs');
34
39
  const path = require('path');
35
40
 
@@ -37,9 +42,10 @@ const LABEL = 'trailhead:needs-you';
37
42
  const LABEL_COLOR = 'D93F0B';
38
43
  const LABEL_DESC = 'Needs you: an autonomous run parked it for the human';
39
44
  const MARKER = 'NEEDS YOU';
45
+ const SWEEP_LIMIT = 1000;
40
46
 
41
47
  function defaultGh(args) {
42
- const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
48
+ const r = spawnSync('gh', args, { encoding: 'utf8', env: gitEnv(), maxBuffer: 64 * 1024 * 1024 });
43
49
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
44
50
  }
45
51
 
@@ -82,8 +88,8 @@ function secretProblem(text, guard) {
82
88
 
83
89
  function parseArgs(argv) {
84
90
  const cmd = argv[0];
85
- if (cmd !== 'park' && cmd !== 'clear') return { error: `usage: expected park or clear, got: ${cmd === undefined ? '(nothing)' : cmd}` };
86
- const allowed = cmd === 'park' ? ['--issue', '--reason', '--repo'] : ['--issue', '--repo'];
91
+ if (!['park', 'clear', 'sweep'].includes(cmd)) return { error: `usage: expected park, clear or sweep, got: ${cmd === undefined ? '(nothing)' : cmd}` };
92
+ const allowed = { park: ['--issue', '--reason', '--repo'], clear: ['--issue', '--repo'], sweep: ['--repo'] }[cmd];
87
93
  const o = { cmd, issue: null, reason: null, repo: null };
88
94
  for (let i = 1; i < argv.length; i++) {
89
95
  const flag = argv[i];
@@ -102,7 +108,7 @@ function parseArgs(argv) {
102
108
  }
103
109
  }
104
110
  if (!o.repo) return { error: `usage: ${cmd} requires --repo` };
105
- if (!o.issue) return { error: `usage: ${cmd} requires --issue` };
111
+ if (cmd !== 'sweep' && !o.issue) return { error: `usage: ${cmd} requires --issue` };
106
112
  if (cmd === 'park' && (o.reason === null || !o.reason.trim())) return { error: 'usage: park requires a non-empty --reason' };
107
113
  return o;
108
114
  }
@@ -124,6 +130,32 @@ function readIssue(run, o) {
124
130
  };
125
131
  }
126
132
 
133
+ // Numeri delle issue chiuse che portano ancora la label, ordinati; oppure { error }.
134
+ function listClosedLabelled(run, repo) {
135
+ const r = run(['issue', 'list', '--state', 'closed', '--label', LABEL, '--json', 'number', '--limit', String(SWEEP_LIMIT), '-R', repo]);
136
+ if (r.status !== 0) return { error: `gh issue list failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
137
+ let j;
138
+ try { j = JSON.parse(r.stdout); } catch { return { error: 'gh issue list returned unparseable JSON' }; }
139
+ if (!Array.isArray(j)) return { error: 'gh issue list returned unexpected JSON' };
140
+ return { numbers: j.map((i) => i && i.number).filter((n) => Number.isInteger(n)).sort((a, b) => a - b) };
141
+ }
142
+
143
+ // Toglie la label da ogni issue chiusa; continua dopo un errore e lo riporta alla fine.
144
+ function sweep(run, repo, emit) {
145
+ const list = listClosedLabelled(run, repo);
146
+ if (list.error) { emit({ status: 'error', reason: list.error, cleared: [] }); return 1; }
147
+ const results = list.numbers.map((n) => ({ n, r: run(['issue', 'edit', String(n), '--remove-label', LABEL, '-R', repo]) }));
148
+ const cleared = results.filter((x) => x.r.status === 0).map((x) => x.n);
149
+ const failed = results.filter((x) => x.r.status !== 0);
150
+ if (failed.length) {
151
+ const reason = failed.map((x) => `#${x.n}: ${firstLine(x.r.stderr) || 'exit ' + x.r.status}`).join('; ');
152
+ emit({ status: 'error', reason: `gh issue edit (remove label) failed for ${reason}`, cleared });
153
+ return 1;
154
+ }
155
+ emit({ status: 'ok', cleared });
156
+ return 0;
157
+ }
158
+
127
159
  // True when the latest NEEDS YOU comment already carries this reason.
128
160
  function alreadyCommented(comments, reason) {
129
161
  const mine = comments.filter((b) => String(b).replace(/\r/g, '').split('\n')[0] === MARKER);
@@ -143,6 +175,8 @@ function main(argv, { gh, guard, stdout, stderr } = {}) {
143
175
  const R = ['-R', o.repo];
144
176
  const n = String(o.issue);
145
177
 
178
+ if (o.cmd === 'sweep') return sweep(run, o.repo, emit);
179
+
146
180
  if (o.cmd === 'clear') {
147
181
  const cur = readIssue(run, o);
148
182
  if (cur.error) return fail(cur.error);