@marcomigozzi/trailhead 0.14.0 → 0.15.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead issue-body editor (self-contained, ships in templates/ so it reaches
4
+ // every install channel). Owns the mechanics of the fail-safe body
5
+ // read-modify-write that Resolve bookkeeping repeats (a map's `Decisions so far`
6
+ // line, a facet or conventions header patch): the agent keeps the words, this
7
+ // keeps the guards. #253.
8
+ //
9
+ // CLI:
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]
12
+ // append adds --line as the last line of the section (a `_none yet_`-style
13
+ // placeholder that is the section's only content is dropped)
14
+ // replace swaps the section content for the text of --file
15
+ // remove drops the one list entry (a `- ` line plus its indented lines) that
16
+ // contains --match: zero matches = reported no-op, more than one = abort
17
+ // <name> matches a `## ` heading with its leading emoji/punctuation stripped
18
+ // (`Decisions so far` matches `## ✅ Decisions so far`); `:preamble` is the text
19
+ // before the first `## ` (the conventions header).
20
+ // --dry-run reads and computes (secret scan included) but writes nothing and
21
+ // prints the old and new byte sizes.
22
+ //
23
+ // Guards (guardedWrite, reused by trailhead-dashboard.js): the body is read with
24
+ // one retry; a non-zero exit, unparseable JSON, an empty or degenerate body, a
25
+ // missing section or an empty result aborts with nothing written. updatedAt is
26
+ // re-read just before the write (moved: re-read and re-apply once, then abort).
27
+ // GitHub has no conditional issue write, so a post-write check closes the rest of
28
+ // the window: the issue's userContentEdits count is recorded before the write and
29
+ // compared after it, with an exact read-back of the body; anything but our own
30
+ // single write is a `conflict` (exit 1, never silent). The text to insert is
31
+ // scanned with the shipped secret-guard, found next to the hooks; no guard
32
+ // module = fail closed.
33
+ //
34
+ // Prints exactly one line, never the body, on stdout:
35
+ // issue #N: appended 1 line to "Decisions so far" | aborted: <reason>
36
+ // Exit 0 on success and on a reported no-op; 1 on an abort; 2 on a usage error
37
+ // (message on stderr, nothing on stdout, nothing read or written).
38
+
39
+ const fs = require('fs');
40
+ const os = require('os');
41
+ const path = require('path');
42
+ const { spawnSync } = require('child_process');
43
+
44
+ const PREAMBLE = ':preamble';
45
+ const DEGENERATE_MAX = 20; // a body with fewer non-space characters is degenerate
46
+ const EDIT_HISTORY_HINT = "open the issue's edit history (the `edited` menu) and re-apply the lost lines";
47
+ // Whether GitHub records the original version as an extra edit on an issue's
48
+ // first edit (delta 2). Confirmed live (2026-10-14): a never-edited issue goes
49
+ // from 0 to 2 edits on its first body write, then +1 per write.
50
+ const FIRST_EDIT_DELTA2 = true;
51
+ // GitHub stops recording body edits once an issue holds this many: the count no
52
+ // longer moves, so the post-write check can only rely on the exact read-back.
53
+ const EDIT_HISTORY_CAP = 100;
54
+ const HISTORY_FULL_NOTE = ' (edit history full: post-write check by read-back only)';
55
+ const PLACEHOLDER_RE = /^\s*(?:[-*+]\s+)?[_*]*\(?\s*(?:none(?:\s+yet)?|nothing(?:\s+yet)?|n\/a|tbd|empty)\s*\)?[_*]*\.?\s*$/i;
56
+
57
+ // --- pure section splice ------------------------------------------------------
58
+
59
+ // Splits a body into lines, remembering the line ending and the final newline.
60
+ function splitBody(body) {
61
+ const eol = body.includes('\r\n') ? '\r\n' : '\n';
62
+ const finalNl = /\n$/.test(body);
63
+ const lines = body.split(/\r?\n/);
64
+ if (finalNl) lines.pop();
65
+ return { lines, eol, finalNl };
66
+ }
67
+
68
+ function joinBody({ lines, eol, finalNl }) {
69
+ return lines.join(eol) + (finalNl ? eol : '');
70
+ }
71
+
72
+ // Heading text with leading emoji/punctuation stripped, or null for a non-heading.
73
+ function headingName(line) {
74
+ const m = /^##[ \t]+(.*)$/.exec(line);
75
+ if (!m) return null;
76
+ return m[1].replace(/^[^\p{L}\p{N}]+/u, '').trim();
77
+ }
78
+
79
+ // Indexes of the `## ` headings outside fenced code blocks.
80
+ function headingIndexes(lines) {
81
+ const out = [];
82
+ let fence = null;
83
+ lines.forEach((line, i) => {
84
+ const f = /^ {0,3}(`{3,}|~{3,})/.exec(line);
85
+ if (fence) {
86
+ if (f && f[1][0] === fence.char && f[1].length >= fence.len && /^ {0,3}[`~]+\s*$/.test(line)) fence = null;
87
+ return;
88
+ }
89
+ if (f) { fence = { char: f[1][0], len: f[1].length }; return; }
90
+ if (headingName(line) !== null) out.push(i);
91
+ });
92
+ return out;
93
+ }
94
+
95
+ // Locates a section: { head, from, to } where lines[from..to) is its content
96
+ // (head is -1 for the preamble). null when the section does not exist.
97
+ function findSection(lines, name) {
98
+ const heads = headingIndexes(lines);
99
+ if (name === PREAMBLE) return { head: -1, from: 0, to: heads.length ? heads[0] : lines.length };
100
+ const k = heads.findIndex((i) => headingName(lines[i]) === name);
101
+ if (k < 0) return null;
102
+ return { head: heads[k], from: heads[k] + 1, to: k + 1 < heads.length ? heads[k + 1] : lines.length };
103
+ }
104
+
105
+ const isBlank = (l) => l.trim() === '';
106
+
107
+ // End (exclusive) of the section's content without its trailing blank lines.
108
+ function contentEnd(lines, sec) {
109
+ let end = sec.to;
110
+ while (end > sec.from && isBlank(lines[end - 1])) end--;
111
+ return end;
112
+ }
113
+
114
+ function bodyLines(text) {
115
+ const t = String(text).replace(/\r\n/g, '\n').replace(/\n+$/, '');
116
+ return t === '' ? [] : t.split('\n');
117
+ }
118
+
119
+ // A list entry: a top-level `- ` line plus the indented lines that follow it.
120
+ function listEntries(lines, sec) {
121
+ const entries = [];
122
+ const end = contentEnd(lines, sec);
123
+ for (let i = sec.from; i < end; i++) {
124
+ if (!/^[-*+][ \t]/.test(lines[i])) continue;
125
+ 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') });
128
+ i = j - 1;
129
+ }
130
+ return entries;
131
+ }
132
+
133
+ // The non-blank content lines of a section, or null when the section is missing.
134
+ function readSection(body, name) {
135
+ const { lines } = splitBody(String(body));
136
+ const sec = findSection(lines, name);
137
+ return sec ? lines.slice(sec.from, sec.to).filter((l) => !isBlank(l)) : null;
138
+ }
139
+
140
+ // op: { kind: 'append'|'replace'|'remove', section, line|text|match }.
141
+ // Returns { body, changed, summary } or { error }.
142
+ function applyOp(body, op) {
143
+ const parts = splitBody(String(body));
144
+ const { lines } = parts;
145
+ const sec = findSection(lines, op.section);
146
+ if (!sec) return { error: `section not found: ${op.section}` };
147
+ const nextHeading = sec.to < lines.length;
148
+ const label = op.section === PREAMBLE ? 'the preamble' : `"${op.section}"`;
149
+
150
+ if (op.kind === 'append') {
151
+ if (/[\r\n]/.test(op.line)) return { error: 'append takes a single line (no newline)' };
152
+ const end = contentEnd(lines, sec);
153
+ const content = lines.slice(sec.from, end).filter((l) => !isBlank(l));
154
+ let next;
155
+ if (content.length === 0) {
156
+ const insert = sec.head >= 0 ? ['', op.line] : [op.line];
157
+ next = [...lines.slice(0, sec.from), ...insert, ...lines.slice(sec.from)];
158
+ if (sec.head < 0 && nextHeading) next.splice(sec.from + 1, 0, '');
159
+ } else if (content.length === 1 && PLACEHOLDER_RE.test(content[0])) {
160
+ const at = lines.findIndex((l, i) => i >= sec.from && i < end && !isBlank(l));
161
+ next = [...lines.slice(0, at), op.line, ...lines.slice(at + 1)];
162
+ } else {
163
+ next = [...lines.slice(0, end), op.line, ...lines.slice(end)];
164
+ }
165
+ return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `appended 1 line to ${label}` };
166
+ }
167
+
168
+ if (op.kind === 'replace') {
169
+ const end = contentEnd(lines, sec);
170
+ const trailing = lines.slice(end, sec.to);
171
+ const content = bodyLines(op.text);
172
+ const leadBlank = sec.head >= 0 && (end > sec.from ? isBlank(lines[sec.from]) : true);
173
+ const fresh = content.length ? [...(leadBlank ? [''] : []), ...content] : [];
174
+ let tail = trailing;
175
+ const before = [...lines.slice(0, sec.from), ...fresh];
176
+ if (nextHeading && tail.length === 0 && before.length && !isBlank(before[before.length - 1])) tail = [''];
177
+ const next = [...before, ...tail, ...lines.slice(sec.to)];
178
+ return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `replaced ${label} (${content.length} lines)` };
179
+ }
180
+
181
+ if (op.kind === 'remove') {
182
+ const hits = listEntries(lines, sec).filter((e) => e.text.includes(op.match));
183
+ if (hits.length === 0) {
184
+ return { body: String(body), changed: false, summary: `no entry matching "${op.match}" in ${label}, nothing written` };
185
+ }
186
+ if (hits.length > 1) return { error: `ambiguous: ${hits.length} entries in ${label} contain "${op.match}"` };
187
+ const h = hits[0];
188
+ const next = [...lines.slice(0, h.start), ...lines.slice(h.end)];
189
+ return { body: joinBody({ ...parts, lines: next }), changed: true, summary: `removed 1 entry from ${label}` };
190
+ }
191
+
192
+ return { error: `unknown op: ${op.kind}` };
193
+ }
194
+
195
+ // --- secret guard ------------------------------------------------------------
196
+
197
+ function guardCandidates() {
198
+ return [
199
+ path.join(__dirname, '..', 'hooks', 'trailhead-secret-guard.js'),
200
+ path.join(__dirname, '..', '..', 'hooks', 'trailhead-secret-guard.js'),
201
+ ];
202
+ }
203
+
204
+ // The shipped secret-guard module ({ scan }), or null when none is found. Plugin
205
+ // and Codex layouts keep it at ../hooks, npm Claude at ../../hooks.
206
+ function loadGuard(candidates = guardCandidates()) {
207
+ for (const file of candidates) {
208
+ if (!fs.existsSync(file)) continue;
209
+ try {
210
+ const m = require(file);
211
+ if (m && typeof m.scan === 'function') return m;
212
+ } catch { /* try the next layout */ }
213
+ }
214
+ return null;
215
+ }
216
+
217
+ // null when the text is clean, else the abort reason (fail closed).
218
+ function secretProblem(text, guard) {
219
+ if (!guard || typeof guard.scan !== 'function') return 'secret-scan unavailable, use the prose fallback';
220
+ try {
221
+ const hit = guard.scan(text, {});
222
+ return hit ? `secret detected (${hit}) in the text to write, nothing written` : null;
223
+ } catch {
224
+ return 'secret-scan unavailable, use the prose fallback';
225
+ }
226
+ }
227
+
228
+ // --- gh boundary -------------------------------------------------------------
229
+
230
+ function defaultGh(args) {
231
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
232
+ return { status: r.status === null ? 1 : r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error ? String(r.error.message) : '') };
233
+ }
234
+
235
+ const repoArgs = (repo) => (repo ? ['-R', repo] : []);
236
+
237
+ // One read of { body, updatedAt }; any failure is { error }.
238
+ function readOnce(issue, { gh, repo }) {
239
+ const r = gh(['issue', 'view', String(issue), '--json', 'body,updatedAt', ...repoArgs(repo)]);
240
+ if (r.status !== 0) return { error: `gh issue view #${issue} failed: ${firstLine(r.stderr) || 'exit ' + r.status}` };
241
+ let data;
242
+ try { data = JSON.parse(r.stdout); } catch { return { error: `gh issue view #${issue} returned unparseable JSON` }; }
243
+ if (!data || typeof data.body !== 'string' || typeof data.updatedAt !== 'string') {
244
+ return { error: `gh issue view #${issue} returned no body` };
245
+ }
246
+ if (data.body.replace(/\s/g, '').length === 0) return { error: `issue #${issue} has an empty body, refusing to rewrite it` };
247
+ if (data.body.replace(/\s/g, '').length < DEGENERATE_MAX) return { error: `issue #${issue} has a degenerate body, refusing to rewrite it` };
248
+ return { body: data.body, updatedAt: data.updatedAt };
249
+ }
250
+
251
+ // Read with one retry (fail-safe rule: a failed or degenerate read is a hard abort).
252
+ function readIssue(issue, opts) {
253
+ const first = readOnce(issue, opts);
254
+ return first.error ? readOnce(issue, opts) : first;
255
+ }
256
+
257
+ function firstLine(text) {
258
+ return String(text || '').split('\n').find((l) => l.trim()) || '';
259
+ }
260
+
261
+ function resolveRepo(opts) {
262
+ if (opts.repo) return opts.repo;
263
+ const r = opts.gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
264
+ const slug = r.status === 0 ? r.stdout.trim() : '';
265
+ return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
266
+ }
267
+
268
+ const EDITS_QUERY = 'query($o:String!,$n:String!,$i:Int!){repository(owner:$o,name:$n){issue(number:$i){userContentEdits{totalCount}}}}';
269
+
270
+ // The issue's edit-history count, or null when it cannot be read.
271
+ function editCount(issue, opts, slug = resolveRepo(opts)) {
272
+ if (!slug) return null;
273
+ const [owner, name] = slug.split('/');
274
+ const r = opts.gh(['api', 'graphql', '-f', `query=${EDITS_QUERY}`, '-f', `o=${owner}`, '-f', `n=${name}`, '-F', `i=${issue}`]);
275
+ if (r.status !== 0) return null;
276
+ try {
277
+ const n = JSON.parse(r.stdout).data.repository.issue.userContentEdits.totalCount;
278
+ return Number.isInteger(n) ? n : null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ const norm = (s) => String(s).replace(/\r\n/g, '\n');
285
+
286
+ // Writes `text` with gh issue edit through a temp file that is always removed.
287
+ // Returns null on success, else a one-line reason (a temp-file fs failure included).
288
+ function ghWrite(issue, text, opts) {
289
+ let dir = null;
290
+ try {
291
+ dir = fs.mkdtempSync(path.join(opts.tmpdir || os.tmpdir(), 'trailhead-issue-body-'));
292
+ const file = path.join(dir, 'body.md');
293
+ fs.writeFileSync(file, text);
294
+ const r = opts.gh(['issue', 'edit', String(issue), '--body-file', file, ...repoArgs(opts.repo)]);
295
+ return r.status === 0 ? null : `gh issue edit #${issue} failed: ${firstLine(r.stderr) || 'exit ' + r.status}`;
296
+ } catch (e) {
297
+ return `could not stage the body in a temp file, nothing written: ${firstLine(e && e.message)}`;
298
+ } finally {
299
+ if (dir) {
300
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort cleanup */ }
301
+ }
302
+ }
303
+ }
304
+
305
+ // --- guarded whole-body write ------------------------------------------------
306
+
307
+ // input: the new body (string) or a function (currentBody) => string | { error },
308
+ // re-applied once on a fresh body when the issue moved under us. opts: { gh, repo,
309
+ // guard, scanText, dryRun, tmpdir, firstEditDelta2 }. scanText is what the secret
310
+ // scan sees (default: the whole new body).
311
+ // Returns { ok: true, oldSize, newSize, dryRun? } or { ok: false, reason, conflict? }.
312
+ function guardedWrite(issue, input, rawOpts = {}) {
313
+ const opts = { ...rawOpts, gh: rawOpts.gh || defaultGh, guard: rawOpts.guard === undefined ? loadGuard() : rawOpts.guard };
314
+ const fail = (reason, extra) => ({ ok: false, reason, ...extra });
315
+ const compute = typeof input === 'function' ? input : null;
316
+
317
+ // The repo slug is resolved once per write (the graphql edit-count reads need it).
318
+ const slug = opts.dryRun ? null : resolveRepo(opts);
319
+ if (!opts.dryRun && !slug) return fail('cannot resolve the repository for the post-write check, nothing written');
320
+
321
+ let cur = readIssue(issue, opts);
322
+ if (cur.error) return fail(cur.error);
323
+ let next;
324
+ let baseline = null;
325
+ for (let attempt = 0; attempt < 2; attempt++) {
326
+ const made = compute ? compute(cur.body) : input;
327
+ if (made && typeof made === 'object' && made.error) return fail(made.error);
328
+ if (typeof made !== 'string' || made.trim() === '') return fail('the resulting body would be empty, nothing written');
329
+ const problem = secretProblem(opts.scanText === undefined ? made : opts.scanText, opts.guard);
330
+ if (problem) return fail(problem);
331
+ // The edit-count baseline is read BEFORE the final updatedAt re-read: an edit
332
+ // landing after the baseline either shows in the re-read (moved: re-apply or
333
+ // abort) or in the post-write delta, never absorbed into the baseline.
334
+ if (!opts.dryRun) {
335
+ baseline = editCount(issue, opts, slug);
336
+ if (baseline === null) return fail('could not read the issue edit count for the post-write check, nothing written');
337
+ }
338
+ const check = readIssue(issue, opts);
339
+ if (check.error) return fail(check.error);
340
+ if (check.updatedAt === cur.updatedAt) { next = made; break; }
341
+ if (!compute) return fail(`issue #${issue} changed while preparing the write, nothing written`);
342
+ if (attempt === 1) return fail(`issue #${issue} changed twice while preparing the write, nothing written`);
343
+ cur = check;
344
+ }
345
+
346
+ const sizes = { oldSize: Buffer.byteLength(cur.body), newSize: Buffer.byteLength(next) };
347
+ if (opts.dryRun) return { ok: true, dryRun: true, ...sizes };
348
+
349
+ const err = ghWrite(issue, next, opts);
350
+ if (err) return fail(err);
351
+
352
+ const after = editCount(issue, opts, slug);
353
+ const back = readOnce(issue, opts);
354
+ if (after === null || back.error) {
355
+ return fail(`written, but the post-write check could not run (${back.error || 'edit count unreadable'}): verify the issue body`);
356
+ }
357
+ const readBackOk = norm(back.body) === norm(next);
358
+ // A full edit history hides every new edit from the count: read-back only.
359
+ if (baseline >= EDIT_HISTORY_CAP) {
360
+ if (!readBackOk) {
361
+ return fail('conflict: the body read back differs from what was written, and the edit history is full so GitHub has not recorded the overwritten text: verify the issue body', { conflict: true });
362
+ }
363
+ return { ok: true, historyFull: true, ...sizes };
364
+ }
365
+ const delta = after - baseline;
366
+ const firstEdit2 = (opts.firstEditDelta2 === undefined ? FIRST_EDIT_DELTA2 : opts.firstEditDelta2) && baseline === 0;
367
+ const cleanDelta = delta === 1 || (firstEdit2 && delta === 2);
368
+ if (!cleanDelta || !readBackOk) {
369
+ return fail(`conflict: a concurrent edit landed during the write, the overwritten text is in the issue's edit history: ${EDIT_HISTORY_HINT}`, { conflict: true });
370
+ }
371
+ return { ok: true, ...sizes };
372
+ }
373
+
374
+ // --- CLI ---------------------------------------------------------------------
375
+
376
+ const OPS = ['append', 'replace', 'remove'];
377
+
378
+ function parseArgs(argv) {
379
+ const kind = argv[0];
380
+ if (!OPS.includes(kind)) return { error: `usage: expected append, replace or remove, got: ${kind === undefined ? '(nothing)' : kind}` };
381
+ const o = { kind, dryRun: false };
382
+ for (let i = 1; i < argv.length; i++) {
383
+ const flag = argv[i];
384
+ if (flag === '--dry-run') { o.dryRun = true; continue; }
385
+ if (!['--issue', '--repo', '--section', '--line', '--file', '--match'].includes(flag)) return { error: `usage: unknown argument: ${flag}` };
386
+ i++;
387
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
388
+ const v = argv[i];
389
+ if (flag === '--issue') {
390
+ if (!/^[1-9]\d*$/.test(v)) return { error: `usage: --issue must be a positive integer, got: ${v}` };
391
+ o.issue = Number(v);
392
+ } else if (flag === '--repo') {
393
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
394
+ o.repo = v;
395
+ } else o[flag.slice(2)] = v;
396
+ }
397
+ if (o.issue === undefined) return { error: 'usage: --issue is required' };
398
+ if (!o.section) return { error: 'usage: --section is required' };
399
+ const given = ['line', 'file', 'match'].filter((k) => o[k] !== undefined);
400
+ const want = { append: 'line', replace: 'file', remove: 'match' }[kind];
401
+ if (given.length !== 1 || given[0] !== want) return { error: `usage: ${kind} needs exactly --${want}` };
402
+ if (kind === 'remove' && o.match === '') return { error: 'usage: --match must not be empty' };
403
+ return o;
404
+ }
405
+
406
+ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {}) {
407
+ const out = stdout || process.stdout;
408
+ const err = stderr || process.stderr;
409
+ const o = parseArgs(argv || []);
410
+ if (o.error) { err.write(o.error + '\n'); return 2; }
411
+ let text;
412
+ if (o.kind === 'replace') {
413
+ try { text = fs.readFileSync(o.file, 'utf8'); } catch (e) { err.write(`usage: cannot read --file ${o.file}: ${e.message}\n`); return 2; }
414
+ }
415
+ const op = { kind: o.kind, section: o.section, line: o.line, text, match: o.match };
416
+ const prefix = `issue #${o.issue}`;
417
+ let summary = '';
418
+ let unchanged = false;
419
+ const compute = (body) => {
420
+ const r = applyOp(body, op);
421
+ if (r.error) return { error: r.error };
422
+ summary = r.summary;
423
+ if (!r.changed) { unchanged = true; return r.body; }
424
+ unchanged = false;
425
+ return r.body;
426
+ };
427
+
428
+ // Scan only what is inserted, not the whole body.
429
+ const scanText = o.kind === 'append' ? o.line : o.kind === 'replace' ? text : '';
430
+ const wrapped = (body) => compute(body);
431
+ // A no-op remove must not write: resolve it on a plain read first.
432
+ const result = noopFirst(o, wrapped, () => unchanged, { gh: gh || defaultGh, repo: o.repo, guard, tmpdir });
433
+ if (result) {
434
+ out.write(`${prefix}: ${summary}\n`);
435
+ return 0;
436
+ }
437
+ const g = guardedWrite(o.issue, wrapped, { gh, repo: o.repo, guard, scanText: scanText === '' ? ' ' : scanText, dryRun: o.dryRun, tmpdir, firstEditDelta2 });
438
+ if (!g.ok) { out.write(`aborted: ${g.reason}\n`); return 1; }
439
+ if (g.dryRun) out.write(`${prefix}: dry-run: would have ${summary} (body ${g.oldSize} -> ${g.newSize} bytes), nothing written\n`);
440
+ else out.write(`${prefix}: ${summary}${g.historyFull ? HISTORY_FULL_NOTE : ''}\n`);
441
+ return 0;
442
+ }
443
+
444
+ // For `remove`, a body with no matching entry is a reported no-op: detect it on a
445
+ // read before entering the write path so nothing is written. Returns true when the
446
+ // op is a no-op (summary already set by compute).
447
+ function noopFirst(o, compute, wasUnchanged, opts) {
448
+ if (o.kind !== 'remove') return false;
449
+ const cur = readIssue(o.issue, opts);
450
+ if (cur.error) return false; // guardedWrite reports the read failure itself
451
+ const r = compute(cur.body);
452
+ return !(r && r.error) && wasUnchanged();
453
+ }
454
+
455
+ module.exports = { applyOp, readSection, defaultGh, PLACEHOLDER_RE, guardedWrite, loadGuard, main, parseArgs, readIssue, editCount, secretProblem, PREAMBLE, HISTORY_FULL_NOTE };
456
+
457
+ if (require.main === module) {
458
+ process.exit(main(process.argv.slice(2), {}));
459
+ }