@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
@@ -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
@@ -116,20 +124,80 @@ function bodyLines(text) {
116
124
  return t === '' ? [] : t.split('\n');
117
125
  }
118
126
 
119
- // A list entry: a top-level `- ` line plus the indented lines that follow it.
127
+ // A list entry: a top-level bullet (`- `, `* `, `+ `) or ordered item (`1. `, `1) `)
128
+ // plus the indented lines that follow it (a continuation paragraph may follow blank
129
+ // lines; the entry's end excludes trailing blanks). Ordered entries carry their number and
130
+ // delimiter (null for a bullet); an indented `1.` is a continuation, not an entry.
120
131
  function listEntries(lines, sec) {
121
132
  const entries = [];
122
133
  const end = contentEnd(lines, sec);
123
134
  for (let i = sec.from; i < end; i++) {
124
- if (!/^[-*+][ \t]/.test(lines[i])) continue;
135
+ const m = /^(?:[-*+]|(\d+)([.)]))[ \t]/.exec(lines[i]);
136
+ if (!m) continue;
125
137
  let j = i + 1;
126
- while (j < end && /^[ \t]+\S/.test(lines[j])) j++;
127
- entries.push({ start: i, end: j, text: lines.slice(i, j).join('\n') });
138
+ for (;;) {
139
+ // Blank lines belong to the entry only when an indented line follows them.
140
+ let k = j;
141
+ while (k < end && isBlank(lines[k])) k++;
142
+ if (k >= end || !/^[ \t]+\S/.test(lines[k])) break;
143
+ j = k + 1;
144
+ }
145
+ entries.push({
146
+ start: i,
147
+ end: j,
148
+ text: lines.slice(i, j).join('\n'),
149
+ num: m[1] === undefined ? null : Number(m[1]),
150
+ numText: m[1] === undefined ? '' : m[1],
151
+ delim: m[2] || null,
152
+ });
128
153
  i = j - 1;
129
154
  }
130
155
  return entries;
131
156
  }
132
157
 
158
+ // After removing entry `h`, the following ordered entries with the same delimiter are
159
+ // renumbered from h's own number. Entries separated only by blank lines (a loose list)
160
+ // stay in the run; a paragraph, a bullet or another delimiter ends it. A list that
161
+ // repeats one number (`1. 1. 1.`) is left alone, and zero padding is kept.
162
+ // Returns a new lines array.
163
+ function renumberAfter(lines, entries, h) {
164
+ if (h.num === null) return lines;
165
+ const run = [];
166
+ let prev = h;
167
+ for (const e of entries.filter((x) => x.start > h.start)) {
168
+ const gap = lines.slice(prev.end, e.start);
169
+ if (e.num === null || e.delim !== h.delim || !gap.every(isBlank)) break;
170
+ run.push(e);
171
+ prev = e;
172
+ }
173
+ if ([h, ...run].every((e) => e.num === h.num)) return lines;
174
+ const padded = (t) => /^0\d/.test(t);
175
+ const fixed = new Map(run.map((e, k) => {
176
+ const width = Math.max(...[h, e].filter((x) => padded(x.numText)).map((x) => x.numText.length), 0);
177
+ const num = String(h.num + k).padStart(width, '0');
178
+ return [e.start, `${num}${h.delim}${lines[e.start].replace(/^\d+[.)]/, '')}`];
179
+ }));
180
+ return lines.map((l, i) => (fixed.has(i) ? fixed.get(i) : l));
181
+ }
182
+
183
+ // The line range [from, to) to drop for entry `h`: the entry plus, in a loose list, one
184
+ // adjoining run of blank lines so no double blank or stray leading blank is left.
185
+ function removalRange(lines, entries, h, sec) {
186
+ const k = entries.indexOf(h);
187
+ const prev = entries[k - 1];
188
+ const next = entries[k + 1];
189
+ const blankGap = (from, to) => to > from && lines.slice(from, to).every(isBlank);
190
+ const blankAfter = next && blankGap(h.end, next.start);
191
+ const blankBefore = prev && blankGap(prev.end, h.start);
192
+ const tightBefore = prev && prev.end === h.start;
193
+ if (blankAfter && !tightBefore) return [h.start, next.start];
194
+ if (blankBefore && !next) return [prev.end, h.end];
195
+ // Sole entry of a spaced section before a heading: drop one trailing blank too.
196
+ const soleSpaced = !prev && !next && h.start > sec.from && isBlank(lines[h.start - 1]) && h.end < sec.to && isBlank(lines[h.end]);
197
+ if (soleSpaced && sec.to < lines.length) return [h.start, h.end + 1];
198
+ return [h.start, h.end];
199
+ }
200
+
133
201
  // The non-blank content lines of a section, or null when the section is missing.
134
202
  function readSection(body, name) {
135
203
  const { lines } = splitBody(String(body));
@@ -137,9 +205,83 @@ function readSection(body, name) {
137
205
  return sec ? lines.slice(sec.from, sec.to).filter((l) => !isBlank(l)) : null;
138
206
  }
139
207
 
140
- // 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? }.
141
279
  // Returns { body, changed, summary } or { error }.
142
280
  function applyOp(body, op) {
281
+ return applyStamped(body, op);
282
+ }
283
+
284
+ function applyCore(body, op) {
143
285
  const parts = splitBody(String(body));
144
286
  const { lines } = parts;
145
287
  const sec = findSection(lines, op.section);
@@ -179,13 +321,16 @@ function applyOp(body, op) {
179
321
  }
180
322
 
181
323
  if (op.kind === 'remove') {
182
- const hits = listEntries(lines, sec).filter((e) => e.text.includes(op.match));
324
+ const entries = listEntries(lines, sec);
325
+ const hits = entries.filter((e) => e.text.includes(op.match));
183
326
  if (hits.length === 0) {
184
327
  return { body: String(body), changed: false, summary: `no entry matching "${op.match}" in ${label}, nothing written` };
185
328
  }
186
329
  if (hits.length > 1) return { error: `ambiguous: ${hits.length} entries in ${label} contain "${op.match}"` };
187
330
  const h = hits[0];
188
- const next = [...lines.slice(0, h.start), ...lines.slice(h.end)];
331
+ const renumbered = renumberAfter(lines, entries, h);
332
+ const [from, to] = removalRange(lines, entries, h, sec);
333
+ const next = [...renumbered.slice(0, from), ...renumbered.slice(to)];
189
334
  return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `removed 1 entry from ${label}` };
190
335
  }
191
336
 
@@ -228,7 +373,7 @@ function secretProblem(text, guard) {
228
373
  // --- gh boundary -------------------------------------------------------------
229
374
 
230
375
  function defaultGh(args) {
231
- 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 });
232
377
  return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
233
378
  }
234
379
 
@@ -382,7 +527,7 @@ function parseArgs(argv) {
382
527
  for (let i = 1; i < argv.length; i++) {
383
528
  const flag = argv[i];
384
529
  if (flag === '--dry-run') { o.dryRun = true; continue; }
385
- 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}` };
386
531
  i++;
387
532
  if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
388
533
  const v = argv[i];
@@ -396,6 +541,10 @@ function parseArgs(argv) {
396
541
  }
397
542
  if (o.issue === undefined) return { error: 'usage: --issue is required' };
398
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
+ }
399
548
  const given = ['line', 'file', 'match'].filter((k) => o[k] !== undefined);
400
549
  const want = { append: 'line', replace: 'file', remove: 'match' }[kind];
401
550
  if (given.length !== 1 || given[0] !== want) return { error: `usage: ${kind} needs exactly --${want}` };
@@ -412,7 +561,7 @@ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {})
412
561
  if (o.kind === 'replace') {
413
562
  try { text = fs.readFileSync(o.file, 'utf8'); } catch (e) { err.write(`usage: cannot read --file ${o.file}: ${e.message}\n`); return 2; }
414
563
  }
415
- 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 };
416
565
  const prefix = `issue #${o.issue}`;
417
566
  let summary = '';
418
567
  let unchanged = false;
@@ -452,7 +601,7 @@ function noopFirst(o, compute, wasUnchanged, opts) {
452
601
  return !(r && r.error) && wasUnchanged();
453
602
  }
454
603
 
455
- 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 };
456
605
 
457
606
  if (require.main === module) {
458
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);