@monte3l/groundwork 1.0.0-rc.2 → 1.0.0-rc.3

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 (170) hide show
  1. package/README.md +13 -5
  2. package/bin/m3l-groundwork.mjs +36 -2
  3. package/dist/assets.d.ts +10 -0
  4. package/dist/assets.js +14 -0
  5. package/dist/baseline-stage.d.ts +173 -0
  6. package/dist/baseline-stage.js +215 -0
  7. package/dist/caps.d.ts +4 -1
  8. package/dist/caps.js +17 -3
  9. package/dist/conflicts.d.ts +23 -2
  10. package/dist/conflicts.js +89 -11
  11. package/dist/customize-paths.d.ts +92 -0
  12. package/dist/customize-paths.js +115 -0
  13. package/dist/emit.d.ts +50 -1
  14. package/dist/emit.js +121 -13
  15. package/dist/fatal.d.ts +70 -0
  16. package/dist/fatal.js +132 -0
  17. package/dist/format-error.d.ts +113 -0
  18. package/dist/format-error.js +553 -0
  19. package/dist/fs-guard.d.ts +142 -0
  20. package/dist/fs-guard.js +222 -0
  21. package/dist/git.js +10 -1
  22. package/dist/harness/conformance.js +2 -0
  23. package/dist/harness/frontmatter.js +2 -13
  24. package/dist/harness/grade.js +37 -8
  25. package/dist/harness/rules.d.ts +20 -3
  26. package/dist/harness/rules.js +108 -6
  27. package/dist/harness/types.d.ts +13 -0
  28. package/dist/harness/types.js +3 -0
  29. package/dist/inventory.d.ts +77 -3
  30. package/dist/inventory.js +103 -12
  31. package/dist/jsonc.d.ts +49 -2
  32. package/dist/jsonc.js +140 -7
  33. package/dist/main.d.ts +62 -3
  34. package/dist/main.js +538 -67
  35. package/dist/merge-json.d.ts +47 -4
  36. package/dist/merge-json.js +120 -8
  37. package/dist/mode.js +12 -2
  38. package/dist/pack-stage.d.ts +210 -0
  39. package/dist/pack-stage.js +287 -0
  40. package/dist/packs.d.ts +19 -13
  41. package/dist/packs.js +231 -28
  42. package/dist/palette.d.ts +23 -0
  43. package/dist/palette.js +22 -0
  44. package/dist/plugin.d.ts +122 -6
  45. package/dist/plugin.js +687 -47
  46. package/dist/report.d.ts +21 -2
  47. package/dist/report.js +93 -5
  48. package/dist/staging.d.ts +176 -0
  49. package/dist/staging.js +375 -0
  50. package/dist/survey/fs-walk.d.ts +34 -2
  51. package/dist/survey/fs-walk.js +70 -5
  52. package/dist/survey/internal/blocked-path.d.ts +27 -0
  53. package/dist/survey/internal/blocked-path.js +129 -0
  54. package/dist/survey/internal/package-json.d.ts +13 -0
  55. package/dist/survey/internal/package-json.js +37 -0
  56. package/dist/survey/internal/read-guard.d.ts +178 -0
  57. package/dist/survey/internal/read-guard.js +276 -0
  58. package/dist/survey/survey-docs.d.ts +17 -2
  59. package/dist/survey/survey-docs.js +61 -21
  60. package/dist/survey/survey-harness.d.ts +20 -2
  61. package/dist/survey/survey-harness.js +87 -46
  62. package/dist/survey/survey-shape.d.ts +17 -2
  63. package/dist/survey/survey-shape.js +60 -46
  64. package/dist/survey/survey-toolchain.d.ts +16 -1
  65. package/dist/survey/survey-toolchain.js +69 -66
  66. package/dist/survey/survey.js +6 -4
  67. package/dist/survey/types.d.ts +79 -0
  68. package/dist/survey/types.js +2 -6
  69. package/dist/term.d.ts +80 -0
  70. package/dist/term.js +145 -0
  71. package/dist/tokens.js +2 -0
  72. package/dist/toolchain/conformance.js +2 -0
  73. package/dist/toolchain/grade.js +26 -8
  74. package/dist/toolchain/rules.d.ts +13 -4
  75. package/dist/toolchain/rules.js +16 -0
  76. package/dist/toolchain/tsconfig-chain.d.ts +2 -0
  77. package/dist/toolchain/tsconfig-chain.js +32 -8
  78. package/dist/toolchain/types.d.ts +16 -4
  79. package/dist/toolchain/types.js +3 -0
  80. package/package.json +4 -3
  81. package/plugin/skills/customize/SKILL.md +292 -18
  82. package/plugin/src/domain-map.ts +39 -14
  83. package/plugin/src/index.ts +5 -1
  84. package/plugin/src/kind-facet-map.ts +25 -8
  85. package/plugin/src/pack-map.ts +147 -25
  86. package/plugin/src/plugin-map.ts +236 -0
  87. package/templates/core/.claude/agents/Explore.md +0 -1
  88. package/templates/core/.claude/agents/code-implementer.md +4 -4
  89. package/templates/core/.claude/agents/code-reviewer.md +4 -4
  90. package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
  91. package/templates/core/.claude/agents/test-author.md +7 -5
  92. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
  93. package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
  94. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
  95. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
  96. package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
  97. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
  98. package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
  99. package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
  100. package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
  101. package/templates/core/.claude/rules/agent-dispatch.md +7 -0
  102. package/templates/core/.claude/rules/tests.md +2 -2
  103. package/templates/core/.claude/settings.json +5 -0
  104. package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
  105. package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
  106. package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
  107. package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
  108. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
  109. package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
  110. package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
  111. package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
  112. package/templates/core/.github/dependabot.yml +18 -0
  113. package/templates/core/.github/workflows/ci.yml +15 -15
  114. package/templates/core/.github/workflows/dependency-review.yml +2 -2
  115. package/templates/core/.github/workflows/security-audit.yml +10 -4
  116. package/templates/core/.prettierignore +4 -0
  117. package/templates/core/CLAUDE.md +53 -3
  118. package/templates/core/README.md +30 -10
  119. package/templates/core/_gitignore +17 -0
  120. package/templates/core/bin/check-exports.mjs +11 -5
  121. package/templates/core/bin/lib/agent-roster.mjs +1 -1
  122. package/templates/core/bin/lib/frontmatter.mjs +4 -2
  123. package/templates/core/bin/lib/harness-rules.mjs +169 -20
  124. package/templates/core/bin/lib/protected-paths.mjs +93 -5
  125. package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
  126. package/templates/core/bin/lib/verify-steps.mjs +29 -11
  127. package/templates/core/bin/verify.mjs +12 -0
  128. package/templates/core/eslint.config.js +5 -0
  129. package/templates/core/package.json +7 -7
  130. package/templates/core/vitest.config.ts +8 -1
  131. package/templates/packs/README.md +35 -24
  132. package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
  133. package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
  134. package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
  135. package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
  136. package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
  137. package/templates/packs/github/pack.json +19 -0
  138. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
  139. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
  140. package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
  141. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
  142. package/templates/packs/harness-extras/pack.json +18 -13
  143. package/templates/packs/publishing/files/.changeset/README.md +25 -0
  144. package/templates/packs/publishing/files/.changeset/config.json +7 -0
  145. package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
  146. package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
  147. package/templates/packs/publishing/files/REUSE.toml +28 -0
  148. package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
  149. package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
  150. package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
  151. package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
  152. package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
  153. package/templates/packs/publishing/pack.json +35 -0
  154. package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
  155. package/templates/packs/quality/pack.json +29 -0
  156. package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
  157. package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
  158. package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
  159. package/templates/packs/supply-chain/pack.json +19 -0
  160. package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
  161. package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
  162. package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
  163. package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
  164. package/templates/packs/worktrees/files/.worktreeinclude +11 -0
  165. package/templates/packs/worktrees/pack.json +64 -0
  166. package/templates/packs/statusline/pack.json +0 -31
  167. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
  168. /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
  169. /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
  170. /package/templates/packs/{harness-extras → quality}/files/bin/file-budget-baseline.json +0 -0
@@ -14,9 +14,31 @@
14
14
  * `SessionEnd` has no guaranteed abnormal-termination signal, so the fix is
15
15
  * on this read side, not a new write-side hook.
16
16
  *
17
+ * Rooted the same way the write hook is: the git toplevel of the payload's
18
+ * `cwd` (`resolveRoot` + `currentWorktree`), never a bare
19
+ * `CLAUDE_PROJECT_DIR`, which stays pinned to the session's original checkout
20
+ * inside a linked worktree. The artifact is session-keyed
21
+ * (`tmp/compact-handoff-<session_id>.json`): `findHandoffPath` reads this
22
+ * session's own file first, and only on `resume`/`startup` falls back to the
23
+ * newest other handoff (keyed or legacy unkeyed) for orphan recovery -- never
24
+ * on `compact`, where another session's file belongs to a live session. Only
25
+ * the file actually read is deleted.
26
+ *
27
+ * Orphan recovery honours an age window on OTHER sessions' files (by mtime;
28
+ * the session's own file has no age limit): younger than
29
+ * `ORPHAN_MIN_AGE_MS` (10 min) is skipped, since it may belong to a live
30
+ * session about to compact and reinject it itself; older than
31
+ * `STALE_THRESHOLD_MS` (24h) is skipped and pruned; between the two, the
32
+ * newest wins. Every candidate, own file included, must parse as a JSON
33
+ * object -- an empty/corrupt one is deleted and skipped, and one entry that
34
+ * vanishes or cannot be stat'd mid-scan is skipped rather than aborting the
35
+ * scan. Only a missing/unreadable `tmp/` itself means "nothing to recover".
36
+ *
17
37
  * A `resume`/`startup` read has no one-compaction freshness guarantee the
18
38
  * way a `compact` read does (it may be reading a handoff several sessions
19
- * old), so `formatHandoff` flags anything older than 24h as likely stale.
39
+ * old), so `formatHandoff` flags anything older than 24h as likely stale,
40
+ * and says "git status unavailable at capture time" when the write side
41
+ * recorded `uncommittedFiles: null` (git failed) rather than `[]` (clean).
20
42
  *
21
43
  * Advisory-only: always exits 0. A missing or unreadable artifact (first
22
44
  * compaction ever, or the write hook failed) means nothing to inject --
@@ -24,14 +46,31 @@
24
46
  * line every time the artifact is legitimately absent.
25
47
  */
26
48
  import process from "node:process";
27
- import { readFileSync, existsSync, unlinkSync, realpathSync } from "node:fs";
49
+ import {
50
+ readFileSync,
51
+ existsSync,
52
+ unlinkSync,
53
+ realpathSync,
54
+ readdirSync,
55
+ statSync,
56
+ } from "node:fs";
28
57
  import { fileURLToPath } from "node:url";
29
58
  import { join } from "node:path";
30
- import { HANDOFF_REL_PATH } from "./write-compact-handoff.mjs";
59
+ import {
60
+ HANDOFF_GLOB_PREFIX,
61
+ currentWorktree,
62
+ handoffRelPath,
63
+ resolveRoot,
64
+ } from "./write-compact-handoff.mjs";
31
65
 
32
- const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
66
+ /** Age past which a handoff is flagged stale, and another session's is pruned. */
67
+ export const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
33
68
 
34
- const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
69
+ /**
70
+ * Minimum mtime age before another session's handoff is eligible for orphan
71
+ * recovery -- a younger one may belong to a live session about to reinject it.
72
+ */
73
+ export const ORPHAN_MIN_AGE_MS = 10 * 60 * 1000;
35
74
 
36
75
  /**
37
76
  * True when `handoff.capturedAt` parses to a timestamp more than 24h before
@@ -82,7 +121,11 @@ export function formatHandoff(handoff, nowMs = Date.now()) {
82
121
  const uncommitted = Array.isArray(handoff.uncommittedFiles)
83
122
  ? handoff.uncommittedFiles
84
123
  : [];
85
- if (uncommitted.length > 0) {
124
+ if (handoff.uncommittedFiles === null) {
125
+ lines.push(
126
+ " • Uncommitted: unknown -- git status unavailable at capture time",
127
+ );
128
+ } else if (uncommitted.length > 0) {
86
129
  const shown = uncommitted.slice(0, 10);
87
130
  const more = uncommitted.length - shown.length;
88
131
  lines.push(
@@ -136,20 +179,108 @@ export function shouldReinject(input) {
136
179
 
137
180
  /**
138
181
  * @param {string} handoffPath absolute path to the handoff artifact
139
- * @returns {Record<string, any> | null} parsed payload, or null if absent
140
- * or unreadable/malformed
182
+ * @returns {Record<string, any> | null} parsed payload, or null if absent,
183
+ * unreadable, malformed, or not a JSON object
141
184
  */
142
185
  export function readHandoff(handoffPath) {
143
186
  if (!existsSync(handoffPath)) return null;
144
187
  try {
145
- return JSON.parse(readFileSync(handoffPath, "utf8"));
188
+ const parsed = JSON.parse(readFileSync(handoffPath, "utf8"));
189
+ return typeof parsed === "object" && parsed !== null ? parsed : null;
146
190
  } catch {
147
191
  return null;
148
192
  }
149
193
  }
150
194
 
151
- // Deliberately inlined in every hook rather than shared: this pack's hook
152
- // budget is exactly its three hooks, so a helper module would cost a slot.
195
+ /** `SessionStart` sources allowed to pick up another session's handoff. */
196
+ const ORPHAN_RECOVERY_SOURCES = new Set(["resume", "startup"]);
197
+
198
+ const HANDOFF_FILE = new RegExp(`^${HANDOFF_GLOB_PREFIX}.*\\.json$`);
199
+
200
+ /**
201
+ * Best-effort delete of a handoff candidate that must not be read again.
202
+ *
203
+ * @param {string} path
204
+ */
205
+ function prune(path) {
206
+ try {
207
+ unlinkSync(path);
208
+ } catch {
209
+ // Already gone or undeletable -- either way it is skipped, not read.
210
+ }
211
+ }
212
+
213
+ /**
214
+ * True when `path` holds a parseable handoff; an unparseable/empty one is
215
+ * pruned so it can never shadow a valid candidate on a later scan.
216
+ *
217
+ * @param {string} path
218
+ * @returns {boolean}
219
+ */
220
+ function isValidCandidate(path) {
221
+ if (readHandoff(path) !== null) return true;
222
+ prune(path);
223
+ return false;
224
+ }
225
+
226
+ /**
227
+ * Locate the handoff this session should re-inject.
228
+ *
229
+ * @param {string} root git toplevel the handoff lives under
230
+ * @param {unknown} sessionId the payload's `session_id`
231
+ * @param {string} source the payload's `source`
232
+ * @param {number} [nowMs] injectable for testing; defaults to `Date.now()`
233
+ * @returns {string | null} this session's own file when present and valid
234
+ * (any age); else, for `resume`/`startup` only, the newest-by-mtime valid
235
+ * other handoff in `tmp/` (keyed or legacy unkeyed) aged between
236
+ * `ORPHAN_MIN_AGE_MS` and `STALE_THRESHOLD_MS`; else null. Corrupt
237
+ * candidates and stale other-session files are pruned along the way.
238
+ */
239
+ export function findHandoffPath(root, sessionId, source, nowMs = Date.now()) {
240
+ const own = join(root, handoffRelPath(sessionId));
241
+ if (existsSync(own) && isValidCandidate(own)) return own;
242
+ if (!ORPHAN_RECOVERY_SOURCES.has(source)) return null;
243
+
244
+ const tmpDir = join(root, "tmp");
245
+ let entries;
246
+ try {
247
+ entries = readdirSync(tmpDir, { withFileTypes: true });
248
+ } catch {
249
+ // Missing/unreadable tmp/ -- nothing to recover.
250
+ return null;
251
+ }
252
+
253
+ /** @type {Array<{ path: string, mtimeMs: number }>} */
254
+ const eligible = [];
255
+ for (const entry of entries) {
256
+ if (!entry.isFile() || !HANDOFF_FILE.test(entry.name)) continue;
257
+ const candidate = join(tmpDir, entry.name);
258
+ if (candidate === own) continue;
259
+ let mtimeMs;
260
+ try {
261
+ mtimeMs = statSync(candidate).mtimeMs;
262
+ } catch {
263
+ // Vanished or unstat-able mid-scan -- skip this entry, keep scanning.
264
+ continue;
265
+ }
266
+ const ageMs = nowMs - mtimeMs;
267
+ if (ageMs < ORPHAN_MIN_AGE_MS) continue;
268
+ if (ageMs > STALE_THRESHOLD_MS) {
269
+ prune(candidate);
270
+ continue;
271
+ }
272
+ eligible.push({ path: candidate, mtimeMs });
273
+ }
274
+
275
+ eligible.sort((a, b) => b.mtimeMs - a.mtimeMs);
276
+ for (const { path } of eligible) {
277
+ if (isValidCandidate(path)) return path;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ // Deliberately inlined in every hook rather than shared, so each hook stays
283
+ // one self-contained file.
153
284
  // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
154
285
  // comparing them directly is false under any symlinked path and the body would
155
286
  // never run -- exit 0.
@@ -174,7 +305,9 @@ if (isEntryPoint()) {
174
305
 
175
306
  if (!shouldReinject(input)) process.exit(0);
176
307
 
177
- const handoffPath = join(root, HANDOFF_REL_PATH);
308
+ const root = currentWorktree(resolveRoot(input, process.env, process.cwd()));
309
+ const handoffPath = findHandoffPath(root, input.session_id, input.source);
310
+ if (handoffPath === null) process.exit(0);
178
311
  const handoff = readHandoff(handoffPath);
179
312
  if (handoff === null) process.exit(0);
180
313
 
@@ -187,7 +320,8 @@ if (isEntryPoint()) {
187
320
  process.stdout.write(JSON.stringify(output));
188
321
 
189
322
  // One-shot: a stale handoff re-injected after a SECOND compaction would
190
- // describe state from before the FIRST, no longer current. Consumed once.
323
+ // describe state from before the FIRST, no longer current. Consumed once --
324
+ // and only the file actually read, never another session's.
191
325
  try {
192
326
  unlinkSync(handoffPath);
193
327
  } catch {
@@ -2,7 +2,11 @@
2
2
  /**
3
3
  * statusLine: renders a fixed five-row layout -- session, model, context,
4
4
  * quota, work -- built entirely from the JSON Claude Code pipes to stdin
5
- * (code.claude.com/docs/en/statusline). The five-row guarantee is
5
+ * (code.claude.com/docs/en/statusline). Each row starts with a "gutter": a
6
+ * short, dim, fixed-width label (e.g. "session") that lines the rows up
7
+ * visually. A row with nothing to show renders a "placeholder" (a plain
8
+ * filler segment) after its gutter instead of going blank, so every row is
9
+ * always exactly one non-empty line. The five-row guarantee is
6
10
  * `renderStatusLine`'s own contract on the success path only: the CLI entry's
7
11
  * `catch` (bottom of this file) falls back to a single minimal `ctx --%` line
8
12
  * on a JSON-parse failure, by design -- the fallback must never itself risk
@@ -351,7 +355,7 @@ export function formatSessionNameSegment(payload) {
351
355
  * detached; only shown when there is no branch, so a rebase or bisect keeps
352
356
  * its git segment instead of silently losing it.
353
357
  * @returns {RowSegment | null} the branch segment. `main` is flagged as a
354
- * warning: the baseline's workflow is feature branches and PRs, never work
358
+ * warning: this project's workflow is feature branches and PRs, never work
355
359
  * directly on `main`.
356
360
  */
357
361
  export function formatBranchSegment(branchName, detachedSha = null) {
@@ -1,13 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PreCompact: writes a structured handoff artifact to `tmp/compact-handoff.json`
4
- * before Claude Code compacts the conversation.
3
+ * PreCompact: writes a structured handoff artifact to
4
+ * `tmp/compact-handoff-<session_id>.json` before Claude Code compacts the
5
+ * conversation.
5
6
  *
6
- * Durable artifacts outperform in-place summarization for long-running work:
7
- * post-compaction state reconstruction shouldn't depend on the summary
8
- * having retained it. `reinject-compact-handoff.mjs` (`SessionStart`,
9
- * matcher `compact|resume|startup`) reads this artifact back as
10
- * `additionalContext`.
7
+ * "Compaction" is Claude Code's process of shrinking a long conversation down
8
+ * to a summary once it gets too large for the model's context window, so the
9
+ * session can keep going. A "durable artifact" here just means a plain file
10
+ * written to disk: unlike the compaction summary itself, a file on disk
11
+ * survives intact no matter how well that summary captured the details, so
12
+ * reconstructing state after a compaction (branch, last commit, uncommitted
13
+ * files) doesn't depend on the summary having retained it.
14
+ * `reinject-compact-handoff.mjs` (`SessionStart`, matcher
15
+ * `compact|resume|startup`) reads this artifact back as `additionalContext`.
11
16
  *
12
17
  * Deliberately git/fs-only, no network calls (no lookup for a PR number) --
13
18
  * a PreCompact hook runs on the hot path of every compaction, so a network
@@ -16,7 +21,10 @@
16
21
  *
17
22
  * "Pending gates" is deliberately NOT a live re-run of `pnpm verify` (far
18
23
  * too slow for a hook) -- it's `git status --porcelain`, a fast, honest
19
- * proxy for "there is uncommitted work here."
24
+ * proxy for "there is uncommitted work here." `uncommittedFiles` is `null`
25
+ * when `git status` itself failed (not a repo, git missing) and `[]` only
26
+ * for a genuinely clean tree, so the reinject side can say "git status
27
+ * unavailable" instead of implying a clean worktree it never observed.
20
28
  *
21
29
  * "Journal paths" is best-effort: a hook has no documented way to address
22
30
  * the ephemeral session scratchpad directory a subagent may have journaled
@@ -24,10 +32,33 @@
24
32
  * gitignored `tmp/` scratch directory (real, cheap, deterministic), not a
25
33
  * claim of session-scratchpad discovery this hook cannot honestly make.
26
34
  *
35
+ * Rooted at the hook payload's `cwd`, not `CLAUDE_PROJECT_DIR`: Claude Code
36
+ * pins `CLAUDE_PROJECT_DIR` to the session's ORIGINAL checkout and does not
37
+ * move it into a linked worktree, so trusting it would record (and write
38
+ * into) the wrong checkout. `resolveRoot` prefers `cwd`, then
39
+ * `CLAUDE_PROJECT_DIR`, then `process.cwd()`; the file lands at that
40
+ * directory's git toplevel.
41
+ *
42
+ * The file is keyed by the payload's `session_id` (`handoffRelPath`) so
43
+ * concurrent sessions in one checkout never read or delete each other's
44
+ * handoff. A missing or path-unsafe id falls back to the legacy unkeyed
45
+ * `tmp/compact-handoff.json`.
46
+ *
47
+ * The write is atomic: the payload goes to `<handoff>.<pid>.partial` first
48
+ * and is then `renameSync`d over the final name, so a reader never sees a
49
+ * half-written file and a crash mid-write leaves only a `.partial` sibling
50
+ * (which the reinject side's `compact-handoff*.json` scan never matches).
51
+ * If the resolved directory itself no longer exists (a removed worktree), or
52
+ * is not inside a git repository at all (there is no git state to hand off,
53
+ * and a non-repo `cwd` may be `$HOME` itself), nothing is created -- only
54
+ * `tmp/` inside an existing git worktree may be.
55
+ *
27
56
  * Advisory-only: always exits 0. A write failure (e.g. `tmp/` unwritable)
28
- * is swallowed -- losing a handoff on this one compaction is a hint the
29
- * next session can't reconstruct as cheaply, not a fatal failure of the
30
- * turn in progress.
57
+ * never fails the turn -- losing a handoff on this one compaction is a hint
58
+ * the next session can't reconstruct as cheaply, not a fatal failure of the
59
+ * turn in progress -- but it is not silent either: one
60
+ * `write-compact-handoff: handoff not written (<reason>)` line goes to
61
+ * stderr.
31
62
  */
32
63
  import process from "node:process";
33
64
  import {
@@ -36,13 +67,53 @@ import {
36
67
  readdirSync,
37
68
  existsSync,
38
69
  realpathSync,
70
+ renameSync,
71
+ unlinkSync,
39
72
  } from "node:fs";
40
73
  import { fileURLToPath } from "node:url";
41
- import { join } from "node:path";
74
+ import { join, resolve } from "node:path";
42
75
  import { execFileSync } from "node:child_process";
43
76
 
44
- const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
45
- export const HANDOFF_REL_PATH = "tmp/compact-handoff.json";
77
+ /** Filename prefix shared by every keyed and legacy handoff artifact. */
78
+ export const HANDOFF_GLOB_PREFIX = "compact-handoff";
79
+
80
+ const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
81
+
82
+ /**
83
+ * Root-relative path of the handoff artifact for one session. Only a
84
+ * path-safe id (`[A-Za-z0-9_-]`, 1-128 chars) is embedded in the filename --
85
+ * anything else (missing, non-string, `../x`) maps to the legacy unkeyed
86
+ * name rather than letting payload text steer the write outside `tmp/`.
87
+ *
88
+ * @param {unknown} sessionId the hook payload's `session_id`
89
+ * @returns {string}
90
+ */
91
+ export function handoffRelPath(sessionId) {
92
+ return typeof sessionId === "string" && SAFE_SESSION_ID.test(sessionId)
93
+ ? `tmp/${HANDOFF_GLOB_PREFIX}-${sessionId}.json`
94
+ : `tmp/${HANDOFF_GLOB_PREFIX}.json`;
95
+ }
96
+
97
+ /**
98
+ * The directory the session is actually running in: the payload's `cwd`,
99
+ * else `env.CLAUDE_PROJECT_DIR`, else `fallbackCwd` (empty strings skipped).
100
+ * `input` may be any JSON value -- read defensively.
101
+ *
102
+ * @param {unknown} input the parsed hook payload
103
+ * @param {Record<string, string | undefined>} env
104
+ * @param {string} fallbackCwd
105
+ * @returns {string}
106
+ */
107
+ export function resolveRoot(input, env, fallbackCwd) {
108
+ const cwd =
109
+ typeof input === "object" && input !== null
110
+ ? /** @type {{ cwd?: unknown }} */ (input).cwd
111
+ : undefined;
112
+ if (typeof cwd === "string" && cwd !== "") return cwd;
113
+ const projectDir = env.CLAUDE_PROJECT_DIR;
114
+ if (typeof projectDir === "string" && projectDir !== "") return projectDir;
115
+ return fallbackCwd;
116
+ }
46
117
 
47
118
  /**
48
119
  * Trims only trailing whitespace/newlines, never leading -- `git status
@@ -55,12 +126,14 @@ export const HANDOFF_REL_PATH = "tmp/compact-handoff.json";
55
126
  * @returns {string | null} trailing-trimmed stdout, or null on any failure
56
127
  * (missing git, not a repo, command error) -- never throws.
57
128
  */
58
- export function runGit(args, cwd = root) {
129
+ export function runGit(args, cwd = process.cwd()) {
59
130
  try {
60
131
  return execFileSync("git", args, {
61
132
  cwd,
62
133
  encoding: "utf8",
63
134
  timeout: 5000,
135
+ // A non-repo `cwd` is an expected case, not an error worth printing.
136
+ stdio: ["ignore", "pipe", "ignore"],
64
137
  }).replace(/\s+$/, "");
65
138
  } catch {
66
139
  return null;
@@ -68,13 +141,38 @@ export function runGit(args, cwd = root) {
68
141
  }
69
142
 
70
143
  /** @returns {string} current branch, "" if unavailable */
71
- export function currentBranch(cwd = root) {
144
+ export function currentBranch(cwd = process.cwd()) {
72
145
  return runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "";
73
146
  }
74
147
 
75
- /** @returns {string} repo root of the worktree the hook is running in */
76
- export function currentWorktree(cwd = root) {
77
- return runGit(["rev-parse", "--show-toplevel"], cwd) ?? cwd;
148
+ /**
149
+ * Toplevel of the (possibly linked) worktree containing `cwd`, or `cwd`
150
+ * itself outside a repo. Derived from `--show-cdup` against `cwd` so the
151
+ * caller's own spelling of the path survives (`--show-toplevel` returns the
152
+ * symlink-resolved path, e.g. `/private/var/...` for `/var/...` on macOS);
153
+ * `--show-toplevel` is used only when that lexical walk disagrees with git.
154
+ *
155
+ * @param {string} [cwd]
156
+ * @param {string | null} [toplevel] `git rev-parse --show-toplevel` for
157
+ * `cwd`, when the caller already ran it (null: not a repo); run here when
158
+ * omitted
159
+ * @returns {string}
160
+ */
161
+ export function currentWorktree(
162
+ cwd = process.cwd(),
163
+ toplevel = runGit(["rev-parse", "--show-toplevel"], cwd),
164
+ ) {
165
+ if (toplevel === null || toplevel === "") return cwd;
166
+ const cdup = runGit(["rev-parse", "--show-cdup"], cwd);
167
+ if (cdup !== null) {
168
+ const logical = resolve(cwd, cdup);
169
+ try {
170
+ if (realpathSync(logical) === realpathSync(toplevel)) return logical;
171
+ } catch {
172
+ // Fall through to git's own answer.
173
+ }
174
+ }
175
+ return toplevel;
78
176
  }
79
177
 
80
178
  /**
@@ -82,7 +180,7 @@ export function currentWorktree(cwd = root) {
82
180
  * SHA and its `%G?` signature-verification code (`G`=good, `B`=bad,
83
181
  * `N`=unsigned, ...), or null if no commits/not a repo.
84
182
  */
85
- export function lastCommitInfo(cwd = root) {
183
+ export function lastCommitInfo(cwd = process.cwd()) {
86
184
  const raw = runGit(["log", "-1", "--format=%H%x09%G?"], cwd);
87
185
  if (raw === null || raw === "") return null;
88
186
  const [sha, signature] = raw.split("\t");
@@ -91,12 +189,15 @@ export function lastCommitInfo(cwd = root) {
91
189
  }
92
190
 
93
191
  /**
94
- * @returns {string[]} `git status --porcelain` lines -- a fast, honest proxy
95
- * for "there is uncommitted work here," not a gate re-run.
192
+ * @returns {string[] | null} `git status --porcelain` lines -- a fast, honest
193
+ * proxy for "there is uncommitted work here," not a gate re-run. `[]` for a
194
+ * clean tree; `null` when `git status` itself failed (not a repo, git
195
+ * missing), so a failure is never mistaken for "clean".
96
196
  */
97
- export function uncommittedFiles(cwd = root) {
197
+ export function uncommittedFiles(cwd = process.cwd()) {
98
198
  const raw = runGit(["status", "--porcelain"], cwd);
99
- if (raw === null || raw === "") return [];
199
+ if (raw === null) return null;
200
+ if (raw === "") return [];
100
201
  return raw.split("\n").filter((line) => line.length > 0);
101
202
  }
102
203
 
@@ -128,11 +229,16 @@ export function findScratchJournals(repoRoot) {
128
229
  /**
129
230
  * Build the full handoff payload from live git/fs state.
130
231
  *
131
- * @param {string} cwd
232
+ * @param {string} [cwd]
233
+ * @param {string} [worktree] the worktree toplevel containing `cwd`, when the
234
+ * caller already resolved it (`writeHandoff` does) -- resolved here via
235
+ * currentWorktree when omitted
132
236
  * @returns {Record<string, unknown>}
133
237
  */
134
- export function buildHandoff(cwd = root) {
135
- const worktree = currentWorktree(cwd);
238
+ export function buildHandoff(
239
+ cwd = process.cwd(),
240
+ worktree = currentWorktree(cwd),
241
+ ) {
136
242
  return {
137
243
  capturedAt: new Date().toISOString(),
138
244
  branch: currentBranch(cwd),
@@ -143,8 +249,91 @@ export function buildHandoff(cwd = root) {
143
249
  };
144
250
  }
145
251
 
146
- // Deliberately inlined in every hook rather than shared: this pack's hook
147
- // budget is exactly its three hooks, so a helper module would cost a slot.
252
+ /**
253
+ * One stderr line naming why no handoff was written -- advisory, so the
254
+ * failure is observable without failing the compaction.
255
+ *
256
+ * @param {string} reason
257
+ * @returns {null}
258
+ */
259
+ function reportNotWritten(reason) {
260
+ try {
261
+ process.stderr.write(
262
+ `write-compact-handoff: handoff not written (${reason})\n`,
263
+ );
264
+ } catch {
265
+ // A broken stderr must not turn an advisory hook into a failing one.
266
+ }
267
+ return null;
268
+ }
269
+
270
+ /**
271
+ * Write this session's handoff under the git toplevel of `resolveRoot(...)`,
272
+ * atomically (`<handoff>.<pid>.partial` then `renameSync` over the final
273
+ * name). Creates nothing when the resolved directory does not exist or is
274
+ * not inside a git repository -- there is no git state to hand off there,
275
+ * and writing anyway would litter an arbitrary directory (even `$HOME`).
276
+ * `git rev-parse --show-toplevel` runs once per write; its answer is threaded
277
+ * through to currentWorktree/buildHandoff.
278
+ *
279
+ * @param {unknown} input the parsed `PreCompact` hook payload
280
+ * @param {Record<string, string | undefined>} env
281
+ * @param {string} fallbackCwd
282
+ * @returns {string | null} absolute path written, or null on any failure
283
+ * (reported as one stderr line) -- never throws (advisory-only).
284
+ */
285
+ export function writeHandoff(input, env, fallbackCwd) {
286
+ let partialPath = null;
287
+ try {
288
+ const root = resolveRoot(input, env, fallbackCwd);
289
+ if (!existsSync(root)) {
290
+ return reportNotWritten("target directory does not exist");
291
+ }
292
+ const gitToplevel = runGit(["rev-parse", "--show-toplevel"], root);
293
+ if (gitToplevel === null || gitToplevel === "") {
294
+ return reportNotWritten("not a git repository");
295
+ }
296
+ const toplevel = currentWorktree(root, gitToplevel);
297
+ const sessionId =
298
+ typeof input === "object" && input !== null
299
+ ? /** @type {{ session_id?: unknown }} */ (input).session_id
300
+ : undefined;
301
+ const handoff = {
302
+ ...buildHandoff(toplevel, toplevel),
303
+ sessionId: typeof sessionId === "string" ? sessionId : null,
304
+ };
305
+ const handoffPath = join(toplevel, handoffRelPath(sessionId));
306
+ mkdirSync(join(toplevel, "tmp"), { recursive: true });
307
+ partialPath = `${handoffPath}.${process.pid}.partial`;
308
+ writeFileSync(partialPath, `${JSON.stringify(handoff, null, 2)}\n`);
309
+ renameSync(partialPath, handoffPath);
310
+ partialPath = null;
311
+ return handoffPath;
312
+ } catch (error) {
313
+ if (partialPath !== null) {
314
+ try {
315
+ unlinkSync(partialPath);
316
+ } catch {
317
+ // Best-effort -- a leftover `.partial` never matches the reinject scan.
318
+ }
319
+ }
320
+ // Advisory-only -- never block or fail a compaction over this.
321
+ const code =
322
+ typeof error === "object" && error !== null && "code" in error
323
+ ? error.code
324
+ : undefined;
325
+ return reportNotWritten(
326
+ typeof code === "string"
327
+ ? code
328
+ : error instanceof Error
329
+ ? error.message
330
+ : String(error),
331
+ );
332
+ }
333
+ }
334
+
335
+ // Deliberately inlined in every hook rather than shared, so each hook stays
336
+ // one self-contained file.
148
337
  // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
149
338
  // comparing them directly is false under any symlinked path and the body would
150
339
  // never run -- exit 0.
@@ -158,23 +347,17 @@ function isEntryPoint() {
158
347
 
159
348
  // Only run when invoked directly, not when imported for testing.
160
349
  if (isEntryPoint()) {
161
- // Drain stdin (Claude Code pipes the hook payload) even though this hook
162
- // doesn't need any field from it -- leaving it unread can leave the pipe
163
- // open under some harness/runtime combinations. `void` references the
164
- // loop variable explicitly rather than relying on a project's eslint
165
- // config exempting `^_`-prefixed vars for .mjs files, which this
166
- // baseline's own eslint.config.js only does for `.ts`.
167
- for await (const chunk of process.stdin) {
168
- void chunk;
169
- }
170
-
350
+ // Read the payload for `cwd`/`session_id`; a malformed payload degrades to
351
+ // `{}` (CLAUDE_PROJECT_DIR / process.cwd(), legacy unkeyed file).
352
+ const chunks = [];
353
+ for await (const chunk of process.stdin) chunks.push(chunk);
354
+ let input;
171
355
  try {
172
- const handoff = buildHandoff();
173
- const handoffPath = join(root, HANDOFF_REL_PATH);
174
- mkdirSync(join(root, "tmp"), { recursive: true });
175
- writeFileSync(handoffPath, `${JSON.stringify(handoff, null, 2)}\n`);
356
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
176
357
  } catch {
177
- // Advisory-only -- never block or fail a compaction over this.
358
+ input = {};
178
359
  }
360
+
361
+ writeHandoff(input, process.env, process.cwd());
179
362
  process.exit(0);
180
363
  }
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "name": "harness-extras",
4
- "description": "Compaction handoff hooks, a read-only Bash guard, a type-design analyzer agent, and a file-budget gate -- cut from the baseline purely to fit its hard caps, not because they failed the generalization test.",
4
+ "description": "Adds Claude Code session ergonomics on top of the baseline: a pair of hooks that carry a work summary across a context-window compaction, a hook that blocks non-read-only Bash commands from read-only review spokes, and a five-row statusLine (plus a per-subagent row renderer) -- the only documented surface carrying live context-window pressure.",
5
5
  "modes": ["fresh", "adopt"],
6
6
  "budget": {
7
- "agents": 1,
7
+ "agents": 0,
8
8
  "skills": 0,
9
- "hooks": 3,
9
+ "hooks": 6,
10
10
  "workflows": 0,
11
11
  "scripts": 0
12
12
  },
13
13
  "requires": {
14
- "paths": ["bin/lib/agent-roster.mjs", "bin/lib/report.mjs"]
14
+ "paths": ["bin/lib/agent-roster.mjs"]
15
15
  },
16
16
  "wiring": {
17
17
  "settings": {
@@ -51,15 +51,20 @@
51
51
  }
52
52
  ]
53
53
  },
54
- "packageScripts": {},
55
- "verifySteps": [
56
- {
57
- "id": "file-budget",
58
- "group": "build",
59
- "name": "Check file budget",
60
- "cmd": ["node", "bin/check-file-budget.mjs"]
54
+ "settingsTopLevel": {
55
+ "statusLine": {
56
+ "type": "command",
57
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/statusline.mjs\"",
58
+ "refreshInterval": 30,
59
+ "padding": 1
60
+ },
61
+ "subagentStatusLine": {
62
+ "type": "command",
63
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-statusline.mjs\""
61
64
  }
62
- ]
65
+ },
66
+ "packageScripts": {},
67
+ "verifySteps": []
63
68
  },
64
- "adoptNotes": "check-file-budget.mjs's ROOTS default assumes a flat src/+tests/ layout and its ceilings are this baseline's defaults -- in an adopted project, re-point ROOTS at the project's real source layout (or drop the gate) before wiring it. The hooks and the agent have no such dependency and install anywhere a .claude/ directory exists. The verify step assumes a bin/verify.mjs-shaped gate runner; if the project has none, install the other artifacts and report the gate as not installable rather than inventing one."
69
+ "adoptNotes": "The statusline and compaction hooks install anywhere a .claude/ directory exists, but guard-readonly-bash.mjs imports ../../bin/lib/agent-roster.mjs (the baseline's list of writer spokes) -- copy that file alongside it in an adopted project, or skip that one hook. The PreCompact hook writes tmp/compact-handoff-<session_id>.json under the git worktree the session runs in (from the hook payload's cwd, since CLAUDE_PROJECT_DIR does not follow a session into a linked worktree) -- fresh mode's baseline .gitignore already lists tmp/ unconditionally, but an adopted project's own .gitignore was never touched by adopt mode, so add tmp/ there too before this pack's hooks run, or the handoff artifact can end up committed. The statusline scripts read only the stdin payload, .git/HEAD (via node:fs, never a git subprocess) and process.availableMemory()/os.totalmem(), so they install anywhere a .claude/ directory exists -- no dependency on the baseline's file layout. The one real adopt risk is the two top-level settings keys: a project that already defines statusLine or subagentStatusLine collides, and its existing value must be shown and decided on, never overwritten -- if either is already set, skip that key and the three statusline scripts, and install the rest of this pack (the hooks) normally rather than failing the whole install. A .claude/settings.local.json or the user's own ~/.claude/settings.json statusLine also shadows the project one -- check both before concluding the pack is wired. rate_limits.spend_limit and prompt_cache need Claude Code v2.1.251+, and the per-subagent model/contextWindowSize/effort fields need v2.1.205+/v2.1.214+; each renders only when present, so an older Claude Code degrades to fewer segments rather than breaking. The scripts behave the same on macOS and Linux; the memory segment uses process.availableMemory() (Node 22+), and on macOS with an older Node it is hidden rather than shown from os.freemem(), which undercounts there. Below roughly 40 columns the layout drops segments by priority rather than wrapping. The size-ratchet gate and the type-design-analyzer agent that used to ship here are now the separate `quality` pack."
65
70
  }