@monte3l/groundwork 0.0.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 (151) hide show
  1. package/README.md +23 -0
  2. package/bin/m3l-groundwork.mjs +10 -0
  3. package/dist/assets.d.ts +20 -0
  4. package/dist/assets.js +79 -0
  5. package/dist/caps.d.ts +25 -0
  6. package/dist/caps.js +69 -0
  7. package/dist/conflicts.d.ts +12 -0
  8. package/dist/conflicts.js +77 -0
  9. package/dist/emit.d.ts +7 -0
  10. package/dist/emit.js +42 -0
  11. package/dist/git.d.ts +3 -0
  12. package/dist/git.js +9 -0
  13. package/dist/harness/conformance.d.ts +20 -0
  14. package/dist/harness/conformance.js +18 -0
  15. package/dist/harness/frontmatter.d.ts +38 -0
  16. package/dist/harness/frontmatter.js +204 -0
  17. package/dist/harness/grade.d.ts +4 -0
  18. package/dist/harness/grade.js +105 -0
  19. package/dist/harness/rules.d.ts +55 -0
  20. package/dist/harness/rules.js +580 -0
  21. package/dist/harness/types.d.ts +32 -0
  22. package/dist/harness/types.js +9 -0
  23. package/dist/inventory.d.ts +63 -0
  24. package/dist/inventory.js +66 -0
  25. package/dist/jsonc.d.ts +14 -0
  26. package/dist/jsonc.js +83 -0
  27. package/dist/main.d.ts +24 -0
  28. package/dist/main.js +297 -0
  29. package/dist/merge-json.d.ts +74 -0
  30. package/dist/merge-json.js +135 -0
  31. package/dist/mode.d.ts +19 -0
  32. package/dist/mode.js +53 -0
  33. package/dist/packs.d.ts +61 -0
  34. package/dist/packs.js +186 -0
  35. package/dist/plugin.d.ts +23 -0
  36. package/dist/plugin.js +79 -0
  37. package/dist/report.d.ts +4 -0
  38. package/dist/report.js +323 -0
  39. package/dist/survey/fs-walk.d.ts +14 -0
  40. package/dist/survey/fs-walk.js +60 -0
  41. package/dist/survey/survey-docs.d.ts +4 -0
  42. package/dist/survey/survey-docs.js +69 -0
  43. package/dist/survey/survey-harness.d.ts +4 -0
  44. package/dist/survey/survey-harness.js +121 -0
  45. package/dist/survey/survey-shape.d.ts +4 -0
  46. package/dist/survey/survey-shape.js +182 -0
  47. package/dist/survey/survey-toolchain.d.ts +4 -0
  48. package/dist/survey/survey-toolchain.js +217 -0
  49. package/dist/survey/survey.d.ts +5 -0
  50. package/dist/survey/survey.js +21 -0
  51. package/dist/survey/types.d.ts +117 -0
  52. package/dist/survey/types.js +8 -0
  53. package/dist/tokens.d.ts +13 -0
  54. package/dist/tokens.js +13 -0
  55. package/dist/toolchain/conformance.d.ts +20 -0
  56. package/dist/toolchain/conformance.js +30 -0
  57. package/dist/toolchain/grade.d.ts +4 -0
  58. package/dist/toolchain/grade.js +244 -0
  59. package/dist/toolchain/rules.d.ts +118 -0
  60. package/dist/toolchain/rules.js +706 -0
  61. package/dist/toolchain/tsconfig-chain.d.ts +36 -0
  62. package/dist/toolchain/tsconfig-chain.js +116 -0
  63. package/dist/toolchain/types.d.ts +27 -0
  64. package/dist/toolchain/types.js +9 -0
  65. package/package.json +59 -0
  66. package/plugin/skills/customize/SKILL.md +305 -0
  67. package/plugin/src/domain-map.ts +134 -0
  68. package/plugin/src/index.ts +4 -0
  69. package/plugin/src/kind-facet-map.ts +174 -0
  70. package/plugin/src/pack-map.ts +65 -0
  71. package/templates/core/.claude/agents/Explore.md +43 -0
  72. package/templates/core/.claude/agents/code-implementer.md +258 -0
  73. package/templates/core/.claude/agents/code-reviewer.md +163 -0
  74. package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
  75. package/templates/core/.claude/agents/test-author.md +211 -0
  76. package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
  77. package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
  78. package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
  79. package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
  80. package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
  81. package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
  82. package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
  83. package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
  84. package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
  85. package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
  86. package/templates/core/.claude/rules/agent-dispatch.md +121 -0
  87. package/templates/core/.claude/rules/refactoring.md +52 -0
  88. package/templates/core/.claude/rules/src.md +114 -0
  89. package/templates/core/.claude/rules/tests.md +129 -0
  90. package/templates/core/.claude/settings.json +111 -0
  91. package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
  92. package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
  93. package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
  94. package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
  95. package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
  96. package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
  97. package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
  98. package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
  99. package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
  100. package/templates/core/.github/workflows/ci.yml +123 -0
  101. package/templates/core/.github/workflows/dependency-review.yml +26 -0
  102. package/templates/core/.github/workflows/security-audit.yml +54 -0
  103. package/templates/core/.node-version +1 -0
  104. package/templates/core/.prettierignore +5 -0
  105. package/templates/core/.prettierrc.json +4 -0
  106. package/templates/core/CLAUDE.md +127 -0
  107. package/templates/core/README.md +24 -0
  108. package/templates/core/_gitignore +19 -0
  109. package/templates/core/_npmrc +1 -0
  110. package/templates/core/bin/check-exports.mjs +92 -0
  111. package/templates/core/bin/check-harness.mjs +27 -0
  112. package/templates/core/bin/check-node-version.mjs +51 -0
  113. package/templates/core/bin/check-toolchain.mjs +20 -0
  114. package/templates/core/bin/lib/agent-roster.mjs +8 -0
  115. package/templates/core/bin/lib/frontmatter.mjs +210 -0
  116. package/templates/core/bin/lib/harness-rules.mjs +916 -0
  117. package/templates/core/bin/lib/protected-paths.mjs +23 -0
  118. package/templates/core/bin/lib/report.mjs +56 -0
  119. package/templates/core/bin/lib/signed-range.mjs +178 -0
  120. package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
  121. package/templates/core/bin/lib/verify-steps.mjs +131 -0
  122. package/templates/core/bin/lib/verify-steps.packs.json +1 -0
  123. package/templates/core/bin/lint-commit.mjs +50 -0
  124. package/templates/core/bin/strip-claude-trailers.mjs +25 -0
  125. package/templates/core/bin/verify.mjs +64 -0
  126. package/templates/core/commitlint.config.js +11 -0
  127. package/templates/core/docs/research/harness-refresh.md +27 -0
  128. package/templates/core/docs/research/typescript-refresh.md +32 -0
  129. package/templates/core/eslint.config.js +105 -0
  130. package/templates/core/knip.json +6 -0
  131. package/templates/core/lefthook.yml +39 -0
  132. package/templates/core/package.json +58 -0
  133. package/templates/core/pnpm-workspace.yaml +13 -0
  134. package/templates/core/src/index.ts +12 -0
  135. package/templates/core/tests/index.test.ts +8 -0
  136. package/templates/core/tsconfig.base.json +36 -0
  137. package/templates/core/tsconfig.build.json +10 -0
  138. package/templates/core/tsconfig.json +11 -0
  139. package/templates/core/vitest.config.ts +32 -0
  140. package/templates/packs/README.md +81 -0
  141. package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
  142. package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
  143. package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
  144. package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
  145. package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
  146. package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
  147. package/templates/packs/harness-extras/pack.json +65 -0
  148. package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
  149. package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
  150. package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
  151. package/templates/packs/statusline/pack.json +31 -0
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SessionStart (matcher `compact|resume|startup`): reads the handoff
4
+ * artifact `write-compact-handoff.mjs` (`PreCompact`) wrote and re-injects
5
+ * it as `additionalContext` -- so state reconstruction doesn't depend on the
6
+ * summary having retained it, whether the next session started because of a
7
+ * compaction, a `--resume`/`--continue`, or a fresh `startup` that inherits
8
+ * a worktree an earlier, since-killed session left dirty.
9
+ *
10
+ * `resume`/`startup` are included alongside `compact` because a
11
+ * `compact`-only registration would leave a handoff written by a
12
+ * `PreCompact` whose session was then killed (crash, OOM, Ctrl-C) sitting
13
+ * in `tmp/` forever -- the next session, on any source, would never see it.
14
+ * `SessionEnd` has no guaranteed abnormal-termination signal, so the fix is
15
+ * on this read side, not a new write-side hook.
16
+ *
17
+ * A `resume`/`startup` read has no one-compaction freshness guarantee the
18
+ * 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.
20
+ *
21
+ * Advisory-only: always exits 0. A missing or unreadable artifact (first
22
+ * compaction ever, or the write hook failed) means nothing to inject --
23
+ * silently no-op rather than surfacing a confusing "handoff not found"
24
+ * line every time the artifact is legitimately absent.
25
+ */
26
+ import process from "node:process";
27
+ import { readFileSync, existsSync, unlinkSync, realpathSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { join } from "node:path";
30
+ import { HANDOFF_REL_PATH } from "./write-compact-handoff.mjs";
31
+
32
+ const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
33
+
34
+ const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
35
+
36
+ /**
37
+ * True when `handoff.capturedAt` parses to a timestamp more than 24h before
38
+ * `nowMs`. A malformed or missing `capturedAt` is never treated as stale --
39
+ * silence here means "no age signal available", not "definitely fresh".
40
+ *
41
+ * @param {Record<string, any>} handoff
42
+ * @param {number} [nowMs] injectable for testing; defaults to `Date.now()`
43
+ * @returns {boolean}
44
+ */
45
+ export function isStale(handoff, nowMs = Date.now()) {
46
+ const capturedAt = handoff.capturedAt;
47
+ if (typeof capturedAt !== "string") return false;
48
+ const capturedMs = Date.parse(capturedAt);
49
+ if (Number.isNaN(capturedMs)) return false;
50
+ return nowMs - capturedMs > STALE_THRESHOLD_MS;
51
+ }
52
+
53
+ /**
54
+ * Render a handoff payload (as built by `write-compact-handoff.mjs`) into
55
+ * the `additionalContext` string.
56
+ *
57
+ * @param {Record<string, any>} handoff
58
+ * @param {number} [nowMs] injectable for testing; defaults to `Date.now()`
59
+ * @returns {string}
60
+ */
61
+ export function formatHandoff(handoff, nowMs = Date.now()) {
62
+ const lines = [
63
+ "Prior-session handoff -- state captured just before an earlier " +
64
+ "compaction, possibly from a session that has since ended:",
65
+ ` • Branch: \`${handoff.branch || "(unknown)"}\` at \`${
66
+ handoff.worktree || "(unknown)"
67
+ }\``,
68
+ ];
69
+
70
+ const lastCommit = handoff.lastCommit;
71
+ if (
72
+ lastCommit &&
73
+ typeof lastCommit === "object" &&
74
+ typeof lastCommit.sha === "string"
75
+ ) {
76
+ lines.push(
77
+ ` • Last commit: \`${lastCommit.sha.slice(0, 12)}\` ` +
78
+ `(signature: \`${lastCommit.signature ?? "?"}\`)`,
79
+ );
80
+ }
81
+
82
+ const uncommitted = Array.isArray(handoff.uncommittedFiles)
83
+ ? handoff.uncommittedFiles
84
+ : [];
85
+ if (uncommitted.length > 0) {
86
+ const shown = uncommitted.slice(0, 10);
87
+ const more = uncommitted.length - shown.length;
88
+ lines.push(
89
+ ` • Uncommitted (${uncommitted.length}): ${shown.join(", ")}` +
90
+ (more > 0 ? ` (+${more} more)` : ""),
91
+ );
92
+ }
93
+
94
+ const journals = Array.isArray(handoff.journals) ? handoff.journals : [];
95
+ if (journals.length > 0) {
96
+ lines.push(` • Scratchpad journal(s): ${journals.join(", ")}`);
97
+ }
98
+
99
+ if (isStale(handoff, nowMs)) {
100
+ lines.push(
101
+ " ⚠ This handoff is more than 24h old -- likely stale, verify " +
102
+ "carefully before trusting it.",
103
+ );
104
+ }
105
+
106
+ lines.push(
107
+ "Re-verify this against current `git status` before acting on it -- it " +
108
+ "is a snapshot from just before compaction, not necessarily still " +
109
+ "current.",
110
+ );
111
+ return lines.join("\n");
112
+ }
113
+
114
+ /** `SessionStart` `source` values this hook re-injects the handoff for. */
115
+ export const REINJECT_SOURCES = new Set(["compact", "resume", "startup"]);
116
+
117
+ /**
118
+ * Belt-and-suspenders alongside the settings.json
119
+ * `matcher: "compact|resume|startup"` registration -- if the harness ever
120
+ * routes an unmatched `SessionStart` here, stay silent rather than
121
+ * injecting a handoff into a `clear`/`fork` session it wasn't meant for.
122
+ * `input` can itself be `null` (valid JSON, e.g. a bare `null` payload) or
123
+ * any other shape -- read defensively rather than assume it's an object.
124
+ * Extracted from the CLI entry block so this check is unit-testable without
125
+ * spawning the script as a subprocess.
126
+ *
127
+ * @param {unknown} input the parsed `SessionStart` hook payload
128
+ * @returns {boolean} true when this SessionStart's source is one this hook
129
+ * should re-inject the handoff for (compaction, resume, or startup)
130
+ */
131
+ export function shouldReinject(input) {
132
+ if (typeof input !== "object" || input === null) return false;
133
+ const source = /** @type {{ source?: unknown }} */ (input).source;
134
+ return typeof source === "string" && REINJECT_SOURCES.has(source);
135
+ }
136
+
137
+ /**
138
+ * @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
141
+ */
142
+ export function readHandoff(handoffPath) {
143
+ if (!existsSync(handoffPath)) return null;
144
+ try {
145
+ return JSON.parse(readFileSync(handoffPath, "utf8"));
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
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.
153
+ // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
154
+ // comparing them directly is false under any symlinked path and the body would
155
+ // never run -- exit 0.
156
+ function isEntryPoint() {
157
+ try {
158
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ // Only run when invoked directly, not when imported for testing.
165
+ if (isEntryPoint()) {
166
+ const chunks = [];
167
+ for await (const chunk of process.stdin) chunks.push(chunk);
168
+ let input;
169
+ try {
170
+ input = JSON.parse(Buffer.concat(chunks).toString("utf8"));
171
+ } catch {
172
+ process.exit(0);
173
+ }
174
+
175
+ if (!shouldReinject(input)) process.exit(0);
176
+
177
+ const handoffPath = join(root, HANDOFF_REL_PATH);
178
+ const handoff = readHandoff(handoffPath);
179
+ if (handoff === null) process.exit(0);
180
+
181
+ const output = {
182
+ hookSpecificOutput: {
183
+ hookEventName: "SessionStart",
184
+ additionalContext: formatHandoff(handoff),
185
+ },
186
+ };
187
+ process.stdout.write(JSON.stringify(output));
188
+
189
+ // One-shot: a stale handoff re-injected after a SECOND compaction would
190
+ // describe state from before the FIRST, no longer current. Consumed once.
191
+ try {
192
+ unlinkSync(handoffPath);
193
+ } catch {
194
+ // Not fatal -- the next PreCompact overwrites it regardless.
195
+ }
196
+ process.exit(0);
197
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact: writes a structured handoff artifact to `tmp/compact-handoff.json`
4
+ * before Claude Code compacts the conversation.
5
+ *
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`.
11
+ *
12
+ * Deliberately git/fs-only, no network calls (no lookup for a PR number) --
13
+ * a PreCompact hook runs on the hot path of every compaction, so a network
14
+ * round-trip here would add latency to an already-slow moment. The branch
15
+ * name is enough to look up the PR when needed (`gh pr list --head <branch>`).
16
+ *
17
+ * "Pending gates" is deliberately NOT a live re-run of `pnpm verify` (far
18
+ * too slow for a hook) -- it's `git status --porcelain`, a fast, honest
19
+ * proxy for "there is uncommitted work here."
20
+ *
21
+ * "Journal paths" is best-effort: a hook has no documented way to address
22
+ * the ephemeral session scratchpad directory a subagent may have journaled
23
+ * to -- so this only lists journal-shaped files under this repo's own
24
+ * gitignored `tmp/` scratch directory (real, cheap, deterministic), not a
25
+ * claim of session-scratchpad discovery this hook cannot honestly make.
26
+ *
27
+ * 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.
31
+ */
32
+ import process from "node:process";
33
+ import {
34
+ writeFileSync,
35
+ mkdirSync,
36
+ readdirSync,
37
+ existsSync,
38
+ realpathSync,
39
+ } from "node:fs";
40
+ import { fileURLToPath } from "node:url";
41
+ import { join } from "node:path";
42
+ import { execFileSync } from "node:child_process";
43
+
44
+ const root = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
45
+ export const HANDOFF_REL_PATH = "tmp/compact-handoff.json";
46
+
47
+ /**
48
+ * Trims only trailing whitespace/newlines, never leading -- `git status
49
+ * --porcelain`'s first two columns are semantically meaningful status
50
+ * codes that can legitimately BE a leading space (` M` = modified in the
51
+ * worktree only, vs. `M ` = staged); a plain `.trim()` would silently
52
+ * corrupt that first line's status code.
53
+ *
54
+ * @param {string[]} args
55
+ * @returns {string | null} trailing-trimmed stdout, or null on any failure
56
+ * (missing git, not a repo, command error) -- never throws.
57
+ */
58
+ export function runGit(args, cwd = root) {
59
+ try {
60
+ return execFileSync("git", args, {
61
+ cwd,
62
+ encoding: "utf8",
63
+ timeout: 5000,
64
+ }).replace(/\s+$/, "");
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /** @returns {string} current branch, "" if unavailable */
71
+ export function currentBranch(cwd = root) {
72
+ return runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "";
73
+ }
74
+
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;
78
+ }
79
+
80
+ /**
81
+ * @returns {{ sha: string, signature: string } | null} the last commit's
82
+ * SHA and its `%G?` signature-verification code (`G`=good, `B`=bad,
83
+ * `N`=unsigned, ...), or null if no commits/not a repo.
84
+ */
85
+ export function lastCommitInfo(cwd = root) {
86
+ const raw = runGit(["log", "-1", "--format=%H%x09%G?"], cwd);
87
+ if (raw === null || raw === "") return null;
88
+ const [sha, signature] = raw.split("\t");
89
+ if (!sha) return null;
90
+ return { sha, signature: signature ?? "N" };
91
+ }
92
+
93
+ /**
94
+ * @returns {string[]} `git status --porcelain` lines -- a fast, honest proxy
95
+ * for "there is uncommitted work here," not a gate re-run.
96
+ */
97
+ export function uncommittedFiles(cwd = root) {
98
+ const raw = runGit(["status", "--porcelain"], cwd);
99
+ if (raw === null || raw === "") return [];
100
+ return raw.split("\n").filter((line) => line.length > 0);
101
+ }
102
+
103
+ /**
104
+ * Journal-shaped files under this repo's gitignored `tmp/` scratch
105
+ * directory -- best-effort, not a claim of ephemeral session-scratchpad
106
+ * discovery (see file header).
107
+ *
108
+ * @param {string} repoRoot
109
+ * @returns {string[]} repo-relative paths, sorted
110
+ */
111
+ export function findScratchJournals(repoRoot) {
112
+ const tmpDir = join(repoRoot, "tmp");
113
+ if (!existsSync(tmpDir)) return [];
114
+ let entries;
115
+ try {
116
+ entries = readdirSync(tmpDir, { withFileTypes: true });
117
+ } catch {
118
+ return [];
119
+ }
120
+ return entries
121
+ .filter(
122
+ (e) => e.isFile() && /journal/i.test(e.name) && e.name.endsWith(".md"),
123
+ )
124
+ .map((e) => `tmp/${e.name}`)
125
+ .sort();
126
+ }
127
+
128
+ /**
129
+ * Build the full handoff payload from live git/fs state.
130
+ *
131
+ * @param {string} cwd
132
+ * @returns {Record<string, unknown>}
133
+ */
134
+ export function buildHandoff(cwd = root) {
135
+ const worktree = currentWorktree(cwd);
136
+ return {
137
+ capturedAt: new Date().toISOString(),
138
+ branch: currentBranch(cwd),
139
+ worktree,
140
+ lastCommit: lastCommitInfo(cwd),
141
+ uncommittedFiles: uncommittedFiles(cwd),
142
+ journals: findScratchJournals(worktree),
143
+ };
144
+ }
145
+
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.
148
+ // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
149
+ // comparing them directly is false under any symlinked path and the body would
150
+ // never run -- exit 0.
151
+ function isEntryPoint() {
152
+ try {
153
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ // Only run when invoked directly, not when imported for testing.
160
+ 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
+
171
+ 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`);
176
+ } catch {
177
+ // Advisory-only -- never block or fail a compaction over this.
178
+ }
179
+ process.exit(0);
180
+ }