@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.
- package/README.md +23 -0
- package/bin/m3l-groundwork.mjs +10 -0
- package/dist/assets.d.ts +20 -0
- package/dist/assets.js +79 -0
- package/dist/caps.d.ts +25 -0
- package/dist/caps.js +69 -0
- package/dist/conflicts.d.ts +12 -0
- package/dist/conflicts.js +77 -0
- package/dist/emit.d.ts +7 -0
- package/dist/emit.js +42 -0
- package/dist/git.d.ts +3 -0
- package/dist/git.js +9 -0
- package/dist/harness/conformance.d.ts +20 -0
- package/dist/harness/conformance.js +18 -0
- package/dist/harness/frontmatter.d.ts +38 -0
- package/dist/harness/frontmatter.js +204 -0
- package/dist/harness/grade.d.ts +4 -0
- package/dist/harness/grade.js +105 -0
- package/dist/harness/rules.d.ts +55 -0
- package/dist/harness/rules.js +580 -0
- package/dist/harness/types.d.ts +32 -0
- package/dist/harness/types.js +9 -0
- package/dist/inventory.d.ts +63 -0
- package/dist/inventory.js +66 -0
- package/dist/jsonc.d.ts +14 -0
- package/dist/jsonc.js +83 -0
- package/dist/main.d.ts +24 -0
- package/dist/main.js +297 -0
- package/dist/merge-json.d.ts +74 -0
- package/dist/merge-json.js +135 -0
- package/dist/mode.d.ts +19 -0
- package/dist/mode.js +53 -0
- package/dist/packs.d.ts +61 -0
- package/dist/packs.js +186 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +79 -0
- package/dist/report.d.ts +4 -0
- package/dist/report.js +323 -0
- package/dist/survey/fs-walk.d.ts +14 -0
- package/dist/survey/fs-walk.js +60 -0
- package/dist/survey/survey-docs.d.ts +4 -0
- package/dist/survey/survey-docs.js +69 -0
- package/dist/survey/survey-harness.d.ts +4 -0
- package/dist/survey/survey-harness.js +121 -0
- package/dist/survey/survey-shape.d.ts +4 -0
- package/dist/survey/survey-shape.js +182 -0
- package/dist/survey/survey-toolchain.d.ts +4 -0
- package/dist/survey/survey-toolchain.js +217 -0
- package/dist/survey/survey.d.ts +5 -0
- package/dist/survey/survey.js +21 -0
- package/dist/survey/types.d.ts +117 -0
- package/dist/survey/types.js +8 -0
- package/dist/tokens.d.ts +13 -0
- package/dist/tokens.js +13 -0
- package/dist/toolchain/conformance.d.ts +20 -0
- package/dist/toolchain/conformance.js +30 -0
- package/dist/toolchain/grade.d.ts +4 -0
- package/dist/toolchain/grade.js +244 -0
- package/dist/toolchain/rules.d.ts +118 -0
- package/dist/toolchain/rules.js +706 -0
- package/dist/toolchain/tsconfig-chain.d.ts +36 -0
- package/dist/toolchain/tsconfig-chain.js +116 -0
- package/dist/toolchain/types.d.ts +27 -0
- package/dist/toolchain/types.js +9 -0
- package/package.json +59 -0
- package/plugin/skills/customize/SKILL.md +305 -0
- package/plugin/src/domain-map.ts +134 -0
- package/plugin/src/index.ts +4 -0
- package/plugin/src/kind-facet-map.ts +174 -0
- package/plugin/src/pack-map.ts +65 -0
- package/templates/core/.claude/agents/Explore.md +43 -0
- package/templates/core/.claude/agents/code-implementer.md +258 -0
- package/templates/core/.claude/agents/code-reviewer.md +163 -0
- package/templates/core/.claude/agents/silent-failure-hunter.md +191 -0
- package/templates/core/.claude/agents/test-author.md +211 -0
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +123 -0
- package/templates/core/.claude/hooks/guard-double-background.mjs +113 -0
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +90 -0
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +88 -0
- package/templates/core/.claude/hooks/guard-js-extension.mjs +66 -0
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +105 -0
- package/templates/core/.claude/hooks/guard-protected-paths.mjs +45 -0
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +183 -0
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +119 -0
- package/templates/core/.claude/hooks/post-edit-verify.mjs +150 -0
- package/templates/core/.claude/rules/agent-dispatch.md +121 -0
- package/templates/core/.claude/rules/refactoring.md +52 -0
- package/templates/core/.claude/rules/src.md +114 -0
- package/templates/core/.claude/rules/tests.md +129 -0
- package/templates/core/.claude/settings.json +111 -0
- package/templates/core/.claude/skills/creating-prs/SKILL.md +132 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +117 -0
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +140 -0
- package/templates/core/.claude/skills/harness-guidance/references/official-sources.md +58 -0
- package/templates/core/.claude/skills/starting-work/SKILL.md +94 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +111 -0
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +143 -0
- package/templates/core/.claude/skills/typescript-guidance/references/typescript-sources.md +102 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +248 -0
- package/templates/core/.github/workflows/ci.yml +123 -0
- package/templates/core/.github/workflows/dependency-review.yml +26 -0
- package/templates/core/.github/workflows/security-audit.yml +54 -0
- package/templates/core/.node-version +1 -0
- package/templates/core/.prettierignore +5 -0
- package/templates/core/.prettierrc.json +4 -0
- package/templates/core/CLAUDE.md +127 -0
- package/templates/core/README.md +24 -0
- package/templates/core/_gitignore +19 -0
- package/templates/core/_npmrc +1 -0
- package/templates/core/bin/check-exports.mjs +92 -0
- package/templates/core/bin/check-harness.mjs +27 -0
- package/templates/core/bin/check-node-version.mjs +51 -0
- package/templates/core/bin/check-toolchain.mjs +20 -0
- package/templates/core/bin/lib/agent-roster.mjs +8 -0
- package/templates/core/bin/lib/frontmatter.mjs +210 -0
- package/templates/core/bin/lib/harness-rules.mjs +916 -0
- package/templates/core/bin/lib/protected-paths.mjs +23 -0
- package/templates/core/bin/lib/report.mjs +56 -0
- package/templates/core/bin/lib/signed-range.mjs +178 -0
- package/templates/core/bin/lib/toolchain-rules.mjs +1264 -0
- package/templates/core/bin/lib/verify-steps.mjs +131 -0
- package/templates/core/bin/lib/verify-steps.packs.json +1 -0
- package/templates/core/bin/lint-commit.mjs +50 -0
- package/templates/core/bin/strip-claude-trailers.mjs +25 -0
- package/templates/core/bin/verify.mjs +64 -0
- package/templates/core/commitlint.config.js +11 -0
- package/templates/core/docs/research/harness-refresh.md +27 -0
- package/templates/core/docs/research/typescript-refresh.md +32 -0
- package/templates/core/eslint.config.js +105 -0
- package/templates/core/knip.json +6 -0
- package/templates/core/lefthook.yml +39 -0
- package/templates/core/package.json +58 -0
- package/templates/core/pnpm-workspace.yaml +13 -0
- package/templates/core/src/index.ts +12 -0
- package/templates/core/tests/index.test.ts +8 -0
- package/templates/core/tsconfig.base.json +36 -0
- package/templates/core/tsconfig.build.json +10 -0
- package/templates/core/tsconfig.json +11 -0
- package/templates/core/vitest.config.ts +32 -0
- package/templates/packs/README.md +81 -0
- package/templates/packs/harness-extras/files/.claude/agents/type-design-analyzer.md +188 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +324 -0
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +197 -0
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +180 -0
- package/templates/packs/harness-extras/files/bin/check-file-budget.mjs +407 -0
- package/templates/packs/harness-extras/files/bin/file-budget-baseline.json +1 -0
- package/templates/packs/harness-extras/pack.json +65 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline-layout.mjs +365 -0
- package/templates/packs/statusline/files/.claude/hooks/statusline.mjs +996 -0
- package/templates/packs/statusline/files/.claude/hooks/subagent-statusline.mjs +203 -0
- 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
|
+
}
|