@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  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 +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -2,10 +2,13 @@
2
2
  'use strict';
3
3
  // trailhead commit-msg hook sync (self-contained, ships in templates/ so it
4
4
  // reaches every install channel: bin/trailhead.js copies templates/ wholesale
5
- // for Claude/npm and Codex alike). Installs the sibling template
6
- // trailhead-commit-msg into the repo's git hooks dir, upgrades it in place
7
- // when an OLDER trailhead hook is already there (recognised by its own
8
- // header line, never by trusting the filename), and never touches a foreign
5
+ // for Claude/npm and Codex alike). Installs the sibling templates into the
6
+ // repo's git hooks dir: trailhead-commit-msg (a small sh wrapper, installed as
7
+ // commit-msg) and trailhead-commit-msg.cjs (the logic, installed beside it; a
8
+ // .cjs is always CommonJS, so a "type": "module" repo cannot break it, #257).
9
+ // Upgrades in place when an OLDER trailhead hook (the legacy single-file JS
10
+ // one, or an outdated wrapper/.cjs) is already there, recognised by its own
11
+ // header line, never by trusting the filename, and never touches a foreign
9
12
  // (non-trailhead) hook. #185.
10
13
  //
11
14
  // CLI: node trailhead-commit-msg-sync.js [--repo <dir>] (default cwd)
@@ -21,13 +24,17 @@ const path = require('path');
21
24
  const crypto = require('crypto');
22
25
  const { execFileSync } = require('child_process');
23
26
 
24
- // La prima riga riconosciuta di ogni versione dell'hook trailhead: basta che
25
- // una delle prime 5 righe (trim) inizi con questa stringa.
26
- const TRAILHEAD_MARK = '// trailhead commit-msg hook (git).';
27
+ // Nome del file di logica installato accanto al wrapper commit-msg.
28
+ const CJS_NAME = 'trailhead-commit-msg.cjs';
29
+
30
+ // La riga riconosciuta di ogni versione dell'hook trailhead: basta che una
31
+ // delle prime 5 righe (trim) inizi con una di queste stringhe. La prima è
32
+ // l'hook JS monofile legacy, la seconda il wrapper sh (layout #257).
33
+ const TRAILHEAD_MARKS = ['// trailhead commit-msg hook (git).', '# trailhead commit-msg hook (git).'];
27
34
 
28
35
  function isTrailheadHook(text) {
29
36
  const lines = String(text == null ? '' : text).split('\n').slice(0, 5);
30
- return lines.some((line) => line.trim().startsWith(TRAILHEAD_MARK));
37
+ return lines.some((line) => TRAILHEAD_MARKS.some((mark) => line.trim().startsWith(mark)));
31
38
  }
32
39
 
33
40
  // Uniforma i fine riga per il confronto di contenuto: CRLF -> LF, non tocca
@@ -65,10 +72,36 @@ function resolveHooksTarget({ repoDir, runGit = defaultRunGit }) {
65
72
  return { hooksDir, hooksPathSet };
66
73
  }
67
74
 
68
- // Applica la sincronizzazione vera e propria dentro hooksDir. fs è iniettato
69
- // per testabilità (default: il modulo fs reale).
70
- function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = fs }) {
75
+ // Scrittura atomica: temp file unico nella stessa dir, poi rename sul
76
+ // destinazione (mai un truncate in place).
77
+ function writeAtomic(fsMod, hooksDir, finalPath, text) {
78
+ const tmpPath = path.join(hooksDir, `.commit-msg.trailhead-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
79
+ try {
80
+ fsMod.writeFileSync(tmpPath, text);
81
+ fsMod.chmodSync(tmpPath, 0o755);
82
+ fsMod.renameSync(tmpPath, finalPath);
83
+ } catch (err) {
84
+ try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
85
+ throw err;
86
+ }
87
+ }
88
+
89
+ // true se il file esiste, non è un symlink e ha esattamente il contenuto atteso.
90
+ function matchesTemplate(fsMod, filePath, text) {
91
+ try {
92
+ if (fsMod.lstatSync(filePath).isSymbolicLink()) return false;
93
+ return normalize(fsMod.readFileSync(filePath, 'utf8')) === normalize(text);
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ // Applica la sincronizzazione vera e propria dentro hooksDir. templateText è il
100
+ // wrapper sh (installato come commit-msg), cjsText la logica (installata come
101
+ // trailhead-commit-msg.cjs). fs è iniettato per testabilità.
102
+ function syncCommitMsgHook({ hooksDir, templateText, cjsText, hooksPathSet, fs: fsMod = fs }) {
71
103
  const hookPath = path.join(hooksDir, 'commit-msg');
104
+ const cjsPath = path.join(hooksDir, CJS_NAME);
72
105
 
73
106
  if (!fsMod.existsSync(hookPath)) {
74
107
  if (hooksPathSet) {
@@ -79,8 +112,9 @@ function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = f
79
112
  };
80
113
  }
81
114
  fsMod.mkdirSync(hooksDir, { recursive: true });
82
- fsMod.writeFileSync(hookPath, templateText);
83
- fsMod.chmodSync(hookPath, 0o755);
115
+ // Prima la logica, poi il wrapper: mai un wrapper senza il suo script.
116
+ writeAtomic(fsMod, hooksDir, cjsPath, cjsText);
117
+ writeAtomic(fsMod, hooksDir, hookPath, templateText);
84
118
  return { status: 'installed', hook: hookPath, message: 'installed the trailhead commit-msg hook.' };
85
119
  }
86
120
 
@@ -94,22 +128,18 @@ function syncCommitMsgHook({ hooksDir, templateText, hooksPathSet, fs: fsMod = f
94
128
  return { status: 'foreign', hook: hookPath, message: 'a foreign commit-msg hook is present; left untouched.' };
95
129
  }
96
130
 
97
- if (normalize(existingText) === normalize(templateText)) {
131
+ const wrapperCurrent = normalize(existingText) === normalize(templateText);
132
+ const cjsCurrent = matchesTemplate(fsMod, cjsPath, cjsText);
133
+
134
+ if (wrapperCurrent && cjsCurrent) {
98
135
  if ((stat.mode & 0o777) !== 0o755) fsMod.chmodSync(hookPath, 0o755);
99
136
  return { status: 'current', hook: hookPath, message: 'the trailhead commit-msg hook is already current.' };
100
137
  }
101
138
 
102
- // Upgrade atomico: scrive su un temp file unico nella stessa dir, poi rename
103
- // sopra l'hook esistente (mai un truncate in place).
104
- const tmpPath = path.join(hooksDir, `.commit-msg.trailhead-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
105
- try {
106
- fsMod.writeFileSync(tmpPath, templateText);
107
- fsMod.chmodSync(tmpPath, 0o755);
108
- fsMod.renameSync(tmpPath, hookPath);
109
- } catch (err) {
110
- try { fsMod.unlinkSync(tmpPath); } catch { /* best effort cleanup */ }
111
- throw err;
112
- }
139
+ // Upgrade: logica per prima, wrapper per ultimo (mai un wrapper senza script).
140
+ if (!cjsCurrent) writeAtomic(fsMod, hooksDir, cjsPath, cjsText);
141
+ if (!wrapperCurrent) writeAtomic(fsMod, hooksDir, hookPath, templateText);
142
+ else if ((stat.mode & 0o777) !== 0o755) fsMod.chmodSync(hookPath, 0o755);
113
143
  return { status: 'upgraded', hook: hookPath, message: 'upgraded the trailhead commit-msg hook to the current template.' };
114
144
  }
115
145
 
@@ -153,7 +183,8 @@ function main(argv, { cwd, stdout } = {}) {
153
183
  hooksDirForError = target.hooksDir;
154
184
  const templatePath = path.join(__dirname, 'trailhead-commit-msg');
155
185
  const templateText = fs.readFileSync(templatePath, 'utf8');
156
- const result = syncCommitMsgHook({ hooksDir: target.hooksDir, templateText, hooksPathSet: target.hooksPathSet, fs });
186
+ const cjsText = fs.readFileSync(path.join(__dirname, CJS_NAME), 'utf8');
187
+ const result = syncCommitMsgHook({ hooksDir: target.hooksDir, templateText, cjsText, hooksPathSet: target.hooksPathSet, fs });
157
188
  writeResult(out, result);
158
189
  return 0;
159
190
  } catch (err) {
@@ -163,7 +194,7 @@ function main(argv, { cwd, stdout } = {}) {
163
194
  }
164
195
  }
165
196
 
166
- module.exports = { resolveHooksTarget, isTrailheadHook, normalize, syncCommitMsgHook, parseArgs, main };
197
+ module.exports = { CJS_NAME, resolveHooksTarget, isTrailheadHook, normalize, syncCommitMsgHook, parseArgs, main };
167
198
 
168
199
  if (require.main === module) {
169
200
  const code = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead commit-msg hook logic (git, CommonJS). Host-independent guardrail:
4
+ // installed beside the sh wrapper .git/hooks/commit-msg (which calls it) at repo
5
+ // first-use so trailhead's commit discipline is enforced on EVERY `git commit`,
6
+ // whatever agent host drives it (Codex has no hook bus; this git hook is how the
7
+ // commit-guard survives there). Decision #16.
8
+ //
9
+ // The .cjs extension is deliberate (#257): Node takes the module format of an
10
+ // extensionless file from the nearest package.json "type", and under .git/hooks/
11
+ // that is the user repo's, so a "type": "module" repo would load this as ESM and
12
+ // fail on require(). A .cjs file is always CommonJS.
13
+ //
14
+ // Self-contained by design: detached from the trailhead install, it inlines the
15
+ // same rules as plugins/trailhead/hooks/lib/commit-message-check.js. That
16
+ // lib's test drives this hook end-to-end so the copies cannot drift.
17
+ //
18
+ // git passes the commit-message file path as argv[2] (the wrapper forwards it).
19
+ // On a violation: print the reason to stderr and exit VIOLATION (3); the wrapper
20
+ // maps exactly that code to exit 1 (git aborts the commit); any code other than
21
+ // 0 or 3 (a crash at load) is reported as the one-line "inactive" notice on
22
+ // stderr and exits 0. Otherwise exit 0. Crash-safe: any unexpected error exits
23
+ // 0 (never wedge a commit).
24
+ //
25
+ // Note: a merge/revert/fixup message already fails the conventional-subject
26
+ // rule below, so it needs no special case; `git commit --amend` keeps whatever
27
+ // trailer the original message carried; `git commit --no-verify` bypasses this
28
+ // hook entirely, as before.
29
+
30
+ const fs = require('fs');
31
+ const path = require('path');
32
+
33
+ const VIOLATION = 3; // codice dedicato, mappato a 1 dal wrapper sh
34
+ const TYPES = 'feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert';
35
+ const CONVENTIONAL = new RegExp(`^(${TYPES})(\\([^)]+\\))?!?:\\s.+`);
36
+ const CO_AUTHORED = /co-authored-by\s*:/i;
37
+ const SCISSORS = '# ------------------------ >8';
38
+ const REFS_LINE = /^\s*refs\s*:(.*)$/i;
39
+
40
+ function messageRegion(raw) {
41
+ const text = String(raw == null ? '' : raw);
42
+ const i = text.indexOf(SCISSORS);
43
+ return i === -1 ? text : text.slice(0, i);
44
+ }
45
+ function subjectOf(raw) {
46
+ for (const line of messageRegion(raw).split('\n')) {
47
+ if (line.trim() === '' || line.startsWith('#')) continue;
48
+ return line.trim();
49
+ }
50
+ return '';
51
+ }
52
+
53
+ // Mirrors parseSessionTicket() in commit-message-check.js: parses the first
54
+ // line of a .trailhead/session-ticket marker (`#<n> <title>`).
55
+ function parseSessionTicket(text) {
56
+ const raw = String(text == null ? '' : text).replace(/^/, '');
57
+ const firstLine = raw.split('\n')[0].replace(/\r$/, '');
58
+ const m = firstLine.match(/^#([1-9]\d*)(?:\s|$)/);
59
+ return m ? Number(m[1]) : null;
60
+ }
61
+
62
+ // Mirrors hasRefsFor() in commit-message-check.js.
63
+ function hasRefsFor(message, n) {
64
+ const target = new RegExp('#' + n + '(?![\\w])');
65
+ for (const line of messageRegion(message).split('\n')) {
66
+ if (line.trim().startsWith('#')) continue;
67
+ const m = line.match(REFS_LINE);
68
+ if (m && target.test(m[1])) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ // Reads the session-ticket marker at the worktree top-level. git runs
74
+ // commit-msg from the worktree top-level (linked worktrees included), so
75
+ // process.cwd() is the right root here. Its own try/catch: a read error
76
+ // (marker absent, unreadable, etc.) must never skip the other rules below,
77
+ // it just leaves the Refs rule off.
78
+ function readTicket() {
79
+ try {
80
+ const markerPath = path.join(process.cwd(), '.trailhead', 'session-ticket');
81
+ return parseSessionTicket(fs.readFileSync(markerPath, 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ try {
88
+ const file = process.argv[2];
89
+ const raw = file ? fs.readFileSync(file, 'utf8') : '';
90
+ const region = messageRegion(raw);
91
+
92
+ if (CO_AUTHORED.test(region)) {
93
+ process.stderr.write('trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.\n');
94
+ process.exit(VIOLATION);
95
+ }
96
+ const subject = subjectOf(raw);
97
+ if (subject && !CONVENTIONAL.test(subject)) {
98
+ process.stderr.write(`trailhead: commit subject must be Conventional Commits: <type>(<scope>)?: <subject>. Valid types: ${TYPES.replace(/\|/g, ', ')}.\n`);
99
+ process.exit(VIOLATION);
100
+ }
101
+ if (subject.length > 72) {
102
+ process.stderr.write('trailhead: commit subject must be 72 characters or less.\n');
103
+ process.exit(VIOLATION);
104
+ }
105
+
106
+ const ticket = readTicket();
107
+ if (Number.isInteger(ticket) && ticket > 0 && !hasRefsFor(raw, ticket)) {
108
+ process.stderr.write(`trailhead: this commit is on ticket #${ticket} (per .trailhead/session-ticket) but has no "Refs: #${ticket}" trailer. Add "Refs: #${ticket}" as the last line of the message and commit again. If this commit is not part of ticket #${ticket}, remove the stale .trailhead/session-ticket marker first.\n`);
109
+ process.exit(VIOLATION);
110
+ }
111
+ } catch {
112
+ process.exit(0); // never wedge a commit on an unexpected error
113
+ }
114
+ process.exit(0);
@@ -0,0 +1,360 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead dashboard regeneration (self-contained, ships in templates/ so it
4
+ // reaches every install channel). Gathers the tracker data the pinned
5
+ // `trailhead:dashboard` issue indexes, renders the whole body deterministically
6
+ // and writes it through trailhead-issue-body.js's guarded write (fail-safe read,
7
+ // updatedAt check, post-write conflict check, secret scan). #253.
8
+ //
9
+ // CLI:
10
+ // node trailhead-dashboard.js [--repo <owner/name>] [--set-last-audit <ISO-8601 UTC>] [--dry-run]
11
+ // --set-last-audit replaces the `<!-- trailhead:last-audit ... -->` marker (the
12
+ // audit's only writer path); without it the marker is copied verbatim from the
13
+ // existing body. --dry-run gathers and reads through gh, computes and scans, but
14
+ // writes, creates, pins and labels nothing; it prints the old and new byte sizes.
15
+ //
16
+ // Singleton labels (dashboard, codebase, conventions): more than one open issue
17
+ // aborts before any write, naming every duplicate by title and number.
18
+ //
19
+ // Self-heal: ensures the `trailhead:dashboard` label; creates the issue directly
20
+ // with the final scanned body when it is missing (never an empty or placeholder
21
+ // body) and pins it; re-pins it when unpinned. Never closes or prunes a map: the
22
+ // flags are signals, the offers stay with the agent.
23
+ //
24
+ // Prints exactly one line on stdout:
25
+ // dashboard #33: 4 maps, whiteboard 1, flags: map-14 exhausted, map-98 near cap 92/100 (body 21034 -> 2210 bytes)
26
+ // aborted: <reason>
27
+ // Exit 0 on success, 1 on an abort or a conflict, 2 on a usage error (stderr).
28
+ // A pin check or pin failure AFTER the body was written is not an abort: the line
29
+ // says `written, pin check failed: <reason>` (or `written, pin failed ...`) and the
30
+ // exit is 0, since the body is in place; the agent surfaces the pin problem.
31
+
32
+ const fs = require('fs');
33
+ const os = require('os');
34
+ const path = require('path');
35
+ const issueBody = require('./trailhead-issue-body.js');
36
+
37
+ const { guardedWrite, loadGuard, secretProblem, readSection, PLACEHOLDER_RE } = issueBody;
38
+
39
+ const LABEL = 'trailhead:dashboard';
40
+ const TITLE = '📊 trailhead dashboard';
41
+ const NEAR_CAP = 90;
42
+ const CAP = 100;
43
+ const RESOLVED_SHOWN = 10;
44
+ const AUDIT_LINE_RE = /^<!-- trailhead:last-audit [^ ]* -->[ \t\r]*$/;
45
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
46
+ // List-query limits; a list that comes back full is reported as truncated.
47
+ const LIMITS = { maps: 200, scoped: 1000, whiteboard: 200, resolved: 100, inbox: 1000 };
48
+ const NOT_RESOLVED_LABELS = ['trailhead:superseded', 'trailhead:out-of-scope'];
49
+
50
+ // --- pure render -------------------------------------------------------------
51
+
52
+ // A title safe to put inside a markdown link text, on one line. Comment
53
+ // delimiters are neutralised so a title can never forge the last-audit marker.
54
+ function linkText(title) {
55
+ return String(title).replace(/\s+/g, ' ').trim()
56
+ .replace(/<!--/g, '&lt;!--').replace(/-->/g, '--&gt;')
57
+ .replace(/([\[\]])/g, '\\$1');
58
+ }
59
+
60
+ // The last-audit marker the dashboard itself wrote: a line of its own (the last
61
+ // such line), never text embedded in another line.
62
+ function extractAudit(body) {
63
+ const hits = String(body).split('\n').filter((l) => AUDIT_LINE_RE.test(l));
64
+ return hits.length ? hits[hits.length - 1].trim() : null;
65
+ }
66
+
67
+ const issueUrl = (repo, n) => `https://github.com/${repo}/issues/${n}`;
68
+ const link = (repo, n, text) => `[${text}](${issueUrl(repo, n)})`;
69
+
70
+ // The flags of one map, from its raw numbers. exhausted needs a zero open-scoped
71
+ // count AND a confirmed-empty fog; prunable needs the bounded closed-edge check.
72
+ function mapFlags(m) {
73
+ const flags = [];
74
+ if (m.openScoped === 0 && m.fog === false) flags.push('exhausted · closeable');
75
+ if (m.total >= NEAR_CAP) {
76
+ const base = `${m.total >= CAP ? 'at cap' : 'near cap'} · ${m.total}/${CAP}`;
77
+ flags.push(m.closedEdges === true ? `${base} · prunable` : base);
78
+ }
79
+ return flags;
80
+ }
81
+
82
+ // Classifies the open whiteboard tickets with the panel's precedence: unverified
83
+ // (quarantined first), paused, blocked, in progress (assigned), frontier
84
+ // (everything else). A ticket lands in exactly one bucket. #263.
85
+ function classify(whiteboard) {
86
+ const out = { frontier: [], inProgress: [], paused: [], blocked: [], unverified: [] };
87
+ for (const t of whiteboard) {
88
+ if (t.labels.includes('trailhead:unverified')) out.unverified.push(t);
89
+ else if (t.labels.includes('trailhead:paused')) out.paused.push(t);
90
+ else if (t.labels.includes('trailhead:blocked')) out.blocked.push(t);
91
+ else if (t.assignees && t.assignees.length) out.inProgress.push(t);
92
+ else out.frontier.push(t);
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // snapshot: { repo, maps[], whiteboard[], resolved[], inbox, anchors, lastAudit }.
98
+ function renderDashboard(s) {
99
+ const lines = [];
100
+ lines.push("The pinned index of this repo's trailhead surface. Regenerate any time with `/trailhead:dashboard`; it also self-heals on `/trailhead`, `/trailhead:map`, and `/trailhead:whiteboard`.");
101
+ lines.push('', '## 🗺 Maps');
102
+ if (s.maps.length === 0) lines.push('_No open maps._');
103
+ for (const m of s.maps) {
104
+ lines.push(`- ${link(s.repo, m.number, linkText(m.title))}: ${m.openScoped} open${mapFlags(m).map((f) => ` · ${f}`).join('')}`);
105
+ }
106
+ const wb = classify(s.whiteboard);
107
+ lines.push('', '## 🧷 Whiteboard (map-less tickets)');
108
+ if (s.whiteboard.length === 0) lines.push('_Nothing on the whiteboard._');
109
+ for (const [label, list] of [['Frontier', wb.frontier], ['In progress', wb.inProgress], ['Paused', wb.paused], ['Blocked', wb.blocked], ['Unverified', wb.unverified]]) {
110
+ if (list.length === 0) continue;
111
+ lines.push(`${label} (${list.length}):`);
112
+ for (const t of list) lines.push(`- ${link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)}`);
113
+ }
114
+ if (s.resolved.length) {
115
+ lines.push('', `_Recently resolved: ${s.resolved.map((t) => link(s.repo, t.number, `${linkText(t.title)} #${t.number}`)).join(', ')}_`);
116
+ }
117
+ lines.push('', '## 📊 Counts');
118
+ lines.push(`- Untriaged inbox: ${s.inbox}${(s.truncated || []).includes('inbox') ? '+' : ''}`);
119
+ lines.push(`- Whiteboard frontier: ${wb.frontier.length}`);
120
+ lines.push(`- Open maps: ${s.maps.length}`);
121
+ const anchors = [['Codebase', s.anchors.codebase], ['Conventions', s.anchors.conventions]]
122
+ .filter(([, n]) => n).map(([name, n]) => link(s.repo, n, `${name} #${n}`));
123
+ if (anchors.length) lines.push('', `Anchors: ${anchors.join(' · ')}`);
124
+ if (s.lastAudit) lines.push('', s.lastAudit);
125
+ return lines.join('\n') + '\n';
126
+ }
127
+
128
+ // --- gather ------------------------------------------------------------------
129
+
130
+ class GatherError extends Error {}
131
+
132
+ function ghJson(gh, args) {
133
+ const r = gh(args);
134
+ if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 3).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
135
+ try { return JSON.parse(r.stdout); } catch { throw new GatherError(`gh ${args.slice(0, 3).join(' ')} returned unparseable JSON`); }
136
+ }
137
+
138
+ function ghText(gh, args) {
139
+ const r = gh(args);
140
+ if (r.status !== 0) throw new GatherError(`gh ${args.slice(0, 2).join(' ')} failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}`);
141
+ return r.stdout.trim();
142
+ }
143
+
144
+ const names = (labels) => (labels || []).map((l) => (typeof l === 'string' ? l : l.name));
145
+
146
+ function issueList(gh, repo, extra) {
147
+ return ghJson(gh, ['issue', 'list', ...extra, '-R', repo]);
148
+ }
149
+
150
+ function gatherMap(gh, repo, m, truncated) {
151
+ const scoped = issueList(gh, repo, ['--label', `trailhead:map-${m.number}`, '--state', 'open', '--json', 'number', '--limit', String(LIMITS.scoped)]);
152
+ if (scoped.length >= LIMITS.scoped) truncated.push(`map-${m.number} scoped`);
153
+ const total = parseInt(ghText(gh, ['api', `repos/${repo}/issues/${m.number}`, '--jq', '.sub_issues_summary.total']), 10);
154
+ if (!Number.isInteger(total)) throw new GatherError(`map #${m.number}: unreadable sub-issue total`);
155
+ const out = { number: m.number, title: m.title, openScoped: scoped.length, total, fog: null, closedEdges: null };
156
+ if (out.openScoped === 0) {
157
+ // Candidate only: confirm the fog (`## Not yet specified`) is empty.
158
+ const view = ghJson(gh, ['issue', 'view', String(m.number), '--json', 'body', '-R', repo]);
159
+ const fog = readSection(String(view.body || ''), 'Not yet specified');
160
+ // A missing section is unconfirmed (fog stays null: no exhausted flag), only
161
+ // a present and empty one confirms there is no fog left.
162
+ out.fog = fog === null ? null : fog.some((l) => !PLACEHOLDER_RE.test(l));
163
+ }
164
+ if (total >= NEAR_CAP) {
165
+ const edges = ghJson(gh, ['api', `repos/${repo}/issues/${m.number}/sub_issues?per_page=100`]);
166
+ out.closedEdges = Array.isArray(edges) && edges.some((e) => e && e.state === 'closed');
167
+ }
168
+ return out;
169
+ }
170
+
171
+ // Singleton labels: exactly one open issue each. More than one is ambiguous and
172
+ // never resolved by picking one (#274).
173
+ const SINGLETON_LIMIT = 10;
174
+ const SINGLETON_LABELS = [LABEL, 'trailhead:codebase', 'trailhead:conventions'];
175
+
176
+ // Looks up every singleton label first and aborts (GatherError) naming all the
177
+ // duplicated ones at once, before anything is written. Returns the single
178
+ // number per label, or null when there is none.
179
+ function lookupSingletons(gh, repo) {
180
+ const found = SINGLETON_LABELS.map((label) => ({
181
+ label,
182
+ list: issueList(gh, repo, ['--label', label, '--state', 'open', '--json', 'number,title', '--limit', String(SINGLETON_LIMIT)]),
183
+ }));
184
+ const dupes = found.filter((f) => f.list.length > 1);
185
+ if (dupes.length) {
186
+ // Control characters are stripped so the one-line stdout stays safe; a list
187
+ // that filled the fetch limit may be cut, so say more may exist.
188
+ const safe = (title) => linkText(title).replace(/[\x00-\x1f\x7f]/g, '');
189
+ const named = dupes.map((f) => `${f.label}: ${f.list.map((i) => `${safe(i.title)} (#${i.number})`).join(', ')}${f.list.length >= SINGLETON_LIMIT ? ', and possibly more' : ''}`).join('; ');
190
+ throw new GatherError(`ambiguous singleton issues, close the duplicates and keep one: ${named}`);
191
+ }
192
+ const num = (label) => { const f = found.find((x) => x.label === label); return f.list.length ? f.list[0].number : null; };
193
+ return { dashboard: num(LABEL), codebase: num('trailhead:codebase'), conventions: num('trailhead:conventions') };
194
+ }
195
+
196
+ function gatherSnapshot(gh, repo, singles) {
197
+ const one = singles || lookupSingletons(gh, repo);
198
+ const truncated = [];
199
+ const capped = (list, limit, name) => { if (list.length >= limit) truncated.push(name); return list; };
200
+ const maps = capped(issueList(gh, repo, ['--label', 'trailhead:map', '--state', 'open', '--json', 'number,title', '--limit', String(LIMITS.maps)]), LIMITS.maps, 'maps')
201
+ .sort((a, b) => a.number - b.number)
202
+ .map((m) => gatherMap(gh, repo, m, truncated));
203
+ const whiteboard = capped(issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'open', '--json', 'number,title,labels,assignees', '--limit', String(LIMITS.whiteboard)]), LIMITS.whiteboard, 'whiteboard')
204
+ .map((t) => ({ number: t.number, title: t.title, labels: names(t.labels), assignees: (t.assignees || []).map((a) => a.login) }));
205
+ // Most recently updated first, so the newest closures sit inside the fetched
206
+ // window even on a repo with a long closed history; then ordered by closedAt.
207
+ const closed = issueList(gh, repo, ['--label', 'trailhead:whiteboard', '--state', 'closed', '--search', 'sort:updated-desc', '--json', 'number,title,closedAt,labels', '--limit', String(LIMITS.resolved)]);
208
+ const resolved = closed
209
+ .filter((t) => !names(t.labels).some((n) => NOT_RESOLVED_LABELS.includes(n)))
210
+ .sort((a, b) => String(b.closedAt).localeCompare(String(a.closedAt)))
211
+ .slice(0, RESOLVED_SHOWN)
212
+ .map((t) => ({ number: t.number, title: t.title }));
213
+ // A full window only matters when it could not fill the shown list.
214
+ if (closed.length >= LIMITS.resolved && resolved.length < RESOLVED_SHOWN) truncated.push('resolved');
215
+ const openIssues = capped(issueList(gh, repo, ['--state', 'open', '--json', 'number,labels', '--limit', String(LIMITS.inbox)]), LIMITS.inbox, 'inbox');
216
+ const inbox = openIssues.filter((i) => !names(i.labels).some((n) => n.startsWith('trailhead:'))).length;
217
+ const anchors = { codebase: one.codebase, conventions: one.conventions };
218
+ return { repo, maps, whiteboard, resolved, inbox, anchors, lastAudit: null, truncated };
219
+ }
220
+
221
+ // --- summary and self-heal ---------------------------------------------------
222
+
223
+ function flagSummary(maps) {
224
+ const parts = [];
225
+ for (const m of maps) {
226
+ for (const f of mapFlags(m)) parts.push(`map-${m.number} ${f.replace(' · closeable', '').replace(/ · /g, ' ')}`);
227
+ }
228
+ return parts.length ? parts.join(', ') : 'none';
229
+ }
230
+
231
+ function summaryLine(number, snap) {
232
+ const cut = snap.truncated && snap.truncated.length ? `, truncated: ${snap.truncated.join(', ')}` : '';
233
+ return `dashboard${number === null ? '' : ' #' + number}: ${snap.maps.length} maps, whiteboard ${snap.whiteboard.length}, flags: ${flagSummary(snap.maps)}${cut}`;
234
+ }
235
+
236
+ function isPinned(gh, repo, number) {
237
+ const [owner, name] = repo.split('/');
238
+ const q = 'query($o:String!,$n:String!){repository(owner:$o,name:$n){pinnedIssues(first:3){nodes{issue{number}}}}}';
239
+ const data = ghJson(gh, ['api', 'graphql', '-f', `query=${q}`, '-f', `o=${owner}`, '-f', `n=${name}`]);
240
+ const nodes = (((data.data || {}).repository || {}).pinnedIssues || {}).nodes || [];
241
+ return nodes.some((n) => n && n.issue && n.issue.number === number);
242
+ }
243
+
244
+ // Pins the issue; returns null on success or a short failure note.
245
+ function pin(gh, repo, number) {
246
+ const node = gh(['api', `repos/${repo}/issues/${number}`, '--jq', '.node_id']);
247
+ if (node.status !== 0 || !node.stdout.trim()) return 'pin failed (node id)';
248
+ const r = gh(['api', 'graphql', '-f', 'query=mutation($id:ID!){ pinIssue(input:{issueId:$id}){ issue{ number } } }', '-F', `id=${node.stdout.trim()}`]);
249
+ return r.status === 0 ? null : 'pin failed (pinned slots full?)';
250
+ }
251
+
252
+ function createDashboard(gh, repo, body, tmpdir) {
253
+ const dir = fs.mkdtempSync(path.join(tmpdir || os.tmpdir(), 'trailhead-dashboard-'));
254
+ const file = path.join(dir, 'body.md');
255
+ try {
256
+ fs.writeFileSync(file, body);
257
+ const r = gh(['issue', 'create', '--title', TITLE, '--label', LABEL, '--body-file', file, '-R', repo]);
258
+ if (r.status !== 0) return { error: `gh issue create failed: ${(r.stderr || '').split('\n').find((l) => l.trim()) || 'exit ' + r.status}` };
259
+ const m = /\/issues\/(\d+)\s*$/.exec(r.stdout.trim());
260
+ return m ? { number: Number(m[1]) } : { error: 'gh issue create returned no issue url' };
261
+ } finally {
262
+ fs.rmSync(dir, { recursive: true, force: true });
263
+ }
264
+ }
265
+
266
+ // --- CLI ---------------------------------------------------------------------
267
+
268
+ function parseArgs(argv) {
269
+ const o = { repo: null, setLastAudit: null, dryRun: false };
270
+ for (let i = 0; i < argv.length; i++) {
271
+ const flag = argv[i];
272
+ if (flag === '--dry-run') { o.dryRun = true; continue; }
273
+ if (flag !== '--repo' && flag !== '--set-last-audit') return { error: `usage: unknown argument: ${flag}` };
274
+ i++;
275
+ if (i >= argv.length) return { error: `usage: ${flag} requires a value` };
276
+ const v = argv[i];
277
+ if (flag === '--repo') {
278
+ if (!/^[^/\s]+\/[^/\s]+$/.test(v)) return { error: `usage: --repo must be owner/name, got: ${v}` };
279
+ o.repo = v;
280
+ } else {
281
+ if (!ISO_RE.test(v)) return { error: `usage: --set-last-audit must be an ISO-8601 UTC time like 2026-10-14T09:30:00Z, got: ${v}` };
282
+ o.setLastAudit = v;
283
+ }
284
+ }
285
+ return o;
286
+ }
287
+
288
+ function defaultRepo(gh) {
289
+ const r = gh(['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']);
290
+ const slug = r.status === 0 ? r.stdout.trim() : '';
291
+ return /^[^/\s]+\/[^/\s]+$/.test(slug) ? slug : null;
292
+ }
293
+
294
+ function main(argv, { gh, stdout, stderr, guard, tmpdir, firstEditDelta2 } = {}) {
295
+ const out = stdout || process.stdout;
296
+ const err = stderr || process.stderr;
297
+ const o = parseArgs(argv || []);
298
+ if (o.error) { err.write(o.error + '\n'); return 2; }
299
+ const run = gh || issueBody.defaultGh;
300
+ const g = guard === undefined ? loadGuard() : guard;
301
+ const abort = (reason) => { out.write(`aborted: ${reason}\n`); return 1; };
302
+
303
+ try {
304
+ const repo = o.repo || defaultRepo(run);
305
+ if (!repo) return abort('cannot resolve the repository (pass --repo owner/name)');
306
+ const singles = lookupSingletons(run, repo); // fail closed on duplicates, before any write
307
+ if (!o.dryRun) run(['label', 'create', LABEL, '--color', '5319E7', '--description', 'Pinned index of the trailhead surface', '-R', repo]); // idempotent, failure = already there
308
+ const snap = gatherSnapshot(run, repo, singles);
309
+ const marker = o.setLastAudit ? `<!-- trailhead:last-audit ${o.setLastAudit} -->` : null;
310
+ const number = singles.dashboard;
311
+
312
+ if (number === null) {
313
+ const body = renderDashboard({ ...snap, lastAudit: marker });
314
+ const problem = secretProblem(body, g);
315
+ if (problem) return abort(problem);
316
+ if (o.dryRun) {
317
+ out.write(`${summaryLine(null, snap)} (dry-run: would create and pin, body ${Buffer.byteLength(body)} bytes), nothing written\n`);
318
+ return 0;
319
+ }
320
+ const created = createDashboard(run, repo, body, tmpdir);
321
+ if (created.error) return abort(created.error);
322
+ const note = pin(run, repo, created.number);
323
+ out.write(`${summaryLine(created.number, snap)} (created${note ? ', ' + note : ' and pinned'}, body ${Buffer.byteLength(body)} bytes)\n`);
324
+ return 0;
325
+ }
326
+
327
+ const w = guardedWrite(number, (current) => {
328
+ const carried = marker || extractAudit(current);
329
+ return renderDashboard({ ...snap, lastAudit: carried });
330
+ }, { gh: run, repo, guard: g, dryRun: o.dryRun, tmpdir, firstEditDelta2 });
331
+ if (!w.ok) return abort(w.reason);
332
+ const sizes = `body ${w.oldSize} -> ${w.newSize} bytes`;
333
+ if (w.dryRun) {
334
+ out.write(`${summaryLine(number, snap)} (dry-run, ${sizes}), nothing written\n`);
335
+ return 0;
336
+ }
337
+ // The body is already written: a pin check or pin failure is reported next to
338
+ // that fact (exit 0), never as an abort that looks like nothing was written.
339
+ let note = w.historyFull ? issueBody.HISTORY_FULL_NOTE.replace(/^ \((.*)\)$/, ', $1') : '';
340
+ try {
341
+ if (!isPinned(run, repo, number)) {
342
+ const failed = pin(run, repo, number);
343
+ note += failed ? `, written, ${failed}` : ', re-pinned';
344
+ }
345
+ } catch (e) {
346
+ note += `, written, pin check failed: ${e && e.message ? e.message : String(e)}`;
347
+ }
348
+ out.write(`${summaryLine(number, snap)} (${sizes}${note})\n`);
349
+ return 0;
350
+ } catch (e) {
351
+ if (e instanceof GatherError) return abort(e.message);
352
+ return abort(`unexpected error: ${e && e.message ? e.message : String(e)}`);
353
+ }
354
+ }
355
+
356
+ module.exports = { renderDashboard, mapFlags, classify, gatherSnapshot, parseArgs, main };
357
+
358
+ if (require.main === module) {
359
+ process.exit(main(process.argv.slice(2), {}));
360
+ }