@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.
- package/README.md +13 -5
- package/bin/m3l-groundwork.mjs +36 -2
- package/dist/assets.d.ts +10 -0
- package/dist/assets.js +14 -0
- package/dist/baseline-stage.d.ts +173 -0
- package/dist/baseline-stage.js +215 -0
- package/dist/caps.d.ts +4 -1
- package/dist/caps.js +17 -3
- package/dist/conflicts.d.ts +23 -2
- package/dist/conflicts.js +89 -11
- package/dist/customize-paths.d.ts +92 -0
- package/dist/customize-paths.js +115 -0
- package/dist/emit.d.ts +50 -1
- package/dist/emit.js +121 -13
- package/dist/fatal.d.ts +70 -0
- package/dist/fatal.js +132 -0
- package/dist/format-error.d.ts +113 -0
- package/dist/format-error.js +553 -0
- package/dist/fs-guard.d.ts +142 -0
- package/dist/fs-guard.js +222 -0
- package/dist/git.js +10 -1
- package/dist/harness/conformance.js +2 -0
- package/dist/harness/frontmatter.js +2 -13
- package/dist/harness/grade.js +37 -8
- package/dist/harness/rules.d.ts +20 -3
- package/dist/harness/rules.js +108 -6
- package/dist/harness/types.d.ts +13 -0
- package/dist/harness/types.js +3 -0
- package/dist/inventory.d.ts +77 -3
- package/dist/inventory.js +103 -12
- package/dist/jsonc.d.ts +49 -2
- package/dist/jsonc.js +140 -7
- package/dist/main.d.ts +62 -3
- package/dist/main.js +538 -67
- package/dist/merge-json.d.ts +47 -4
- package/dist/merge-json.js +120 -8
- package/dist/mode.js +12 -2
- package/dist/pack-stage.d.ts +210 -0
- package/dist/pack-stage.js +287 -0
- package/dist/packs.d.ts +19 -13
- package/dist/packs.js +231 -28
- package/dist/palette.d.ts +23 -0
- package/dist/palette.js +22 -0
- package/dist/plugin.d.ts +122 -6
- package/dist/plugin.js +687 -47
- package/dist/report.d.ts +21 -2
- package/dist/report.js +93 -5
- package/dist/staging.d.ts +176 -0
- package/dist/staging.js +375 -0
- package/dist/survey/fs-walk.d.ts +34 -2
- package/dist/survey/fs-walk.js +70 -5
- package/dist/survey/internal/blocked-path.d.ts +27 -0
- package/dist/survey/internal/blocked-path.js +129 -0
- package/dist/survey/internal/package-json.d.ts +13 -0
- package/dist/survey/internal/package-json.js +37 -0
- package/dist/survey/internal/read-guard.d.ts +178 -0
- package/dist/survey/internal/read-guard.js +276 -0
- package/dist/survey/survey-docs.d.ts +17 -2
- package/dist/survey/survey-docs.js +61 -21
- package/dist/survey/survey-harness.d.ts +20 -2
- package/dist/survey/survey-harness.js +87 -46
- package/dist/survey/survey-shape.d.ts +17 -2
- package/dist/survey/survey-shape.js +60 -46
- package/dist/survey/survey-toolchain.d.ts +16 -1
- package/dist/survey/survey-toolchain.js +69 -66
- package/dist/survey/survey.js +6 -4
- package/dist/survey/types.d.ts +79 -0
- package/dist/survey/types.js +2 -6
- package/dist/term.d.ts +80 -0
- package/dist/term.js +145 -0
- package/dist/tokens.js +2 -0
- package/dist/toolchain/conformance.js +2 -0
- package/dist/toolchain/grade.js +26 -8
- package/dist/toolchain/rules.d.ts +13 -4
- package/dist/toolchain/rules.js +16 -0
- package/dist/toolchain/tsconfig-chain.d.ts +2 -0
- package/dist/toolchain/tsconfig-chain.js +32 -8
- package/dist/toolchain/types.d.ts +16 -4
- package/dist/toolchain/types.js +3 -0
- package/package.json +4 -3
- package/plugin/skills/customize/SKILL.md +292 -18
- package/plugin/src/domain-map.ts +39 -14
- package/plugin/src/index.ts +5 -1
- package/plugin/src/kind-facet-map.ts +25 -8
- package/plugin/src/pack-map.ts +147 -25
- package/plugin/src/plugin-map.ts +236 -0
- package/templates/core/.claude/agents/Explore.md +0 -1
- package/templates/core/.claude/agents/code-implementer.md +4 -4
- package/templates/core/.claude/agents/code-reviewer.md +4 -4
- package/templates/core/.claude/agents/silent-failure-hunter.md +2 -2
- package/templates/core/.claude/agents/test-author.md +7 -5
- package/templates/core/.claude/hooks/guard-branch-isolation.mjs +56 -10
- package/templates/core/.claude/hooks/guard-double-background.mjs +13 -8
- package/templates/core/.claude/hooks/guard-git-push-signed.mjs +12 -9
- package/templates/core/.claude/hooks/guard-hub-src-writes.mjs +1890 -38
- package/templates/core/.claude/hooks/guard-js-extension.mjs +2 -1
- package/templates/core/.claude/hooks/guard-no-commonjs.mjs +7 -5
- package/templates/core/.claude/hooks/guard-secret-writes.mjs +7 -5
- package/templates/core/.claude/hooks/inject-decision-gate.mjs +12 -9
- package/templates/core/.claude/hooks/post-edit-verify.mjs +276 -98
- package/templates/core/.claude/rules/agent-dispatch.md +7 -0
- package/templates/core/.claude/rules/tests.md +2 -2
- package/templates/core/.claude/settings.json +5 -0
- package/templates/core/.claude/skills/finishing-work/SKILL.md +58 -10
- package/templates/core/.claude/skills/harness-guidance/SKILL.md +16 -7
- package/templates/core/.claude/skills/starting-work/SKILL.md +5 -0
- package/templates/core/.claude/skills/triaging-ci/SKILL.md +9 -8
- package/templates/core/.claude/skills/typescript-guidance/SKILL.md +137 -20
- package/templates/core/.claude/skills/typescript-guidance/references/area-catalog.md +135 -0
- package/templates/core/.claude/skills/typescript-guidance/references/tooling-sources.md +113 -0
- package/templates/core/.claude/skills/writing-commits/SKILL.md +2 -2
- package/templates/core/.github/dependabot.yml +18 -0
- package/templates/core/.github/workflows/ci.yml +15 -15
- package/templates/core/.github/workflows/dependency-review.yml +2 -2
- package/templates/core/.github/workflows/security-audit.yml +10 -4
- package/templates/core/.prettierignore +4 -0
- package/templates/core/CLAUDE.md +53 -3
- package/templates/core/README.md +30 -10
- package/templates/core/_gitignore +17 -0
- package/templates/core/bin/check-exports.mjs +11 -5
- package/templates/core/bin/lib/agent-roster.mjs +1 -1
- package/templates/core/bin/lib/frontmatter.mjs +4 -2
- package/templates/core/bin/lib/harness-rules.mjs +169 -20
- package/templates/core/bin/lib/protected-paths.mjs +93 -5
- package/templates/core/bin/lib/toolchain-rules.mjs +187 -26
- package/templates/core/bin/lib/verify-steps.mjs +29 -11
- package/templates/core/bin/verify.mjs +12 -0
- package/templates/core/eslint.config.js +5 -0
- package/templates/core/package.json +7 -7
- package/templates/core/vitest.config.ts +8 -1
- package/templates/packs/README.md +35 -24
- package/templates/packs/github/files/.claude/skills/reviewing-dependabot-prs/SKILL.md +133 -0
- package/templates/packs/github/files/.claude/skills/triaging-scan-alerts/SKILL.md +88 -0
- package/templates/packs/github/files/.claude/skills/watching-pr-checks/SKILL.md +94 -0
- package/templates/packs/github/files/.github/workflows/claude-pr-review.yml +267 -0
- package/templates/packs/github/files/.github/workflows/claude.yml +106 -0
- package/templates/packs/github/pack.json +19 -0
- package/templates/packs/harness-extras/files/.claude/hooks/guard-readonly-bash.mjs +1122 -65
- package/templates/packs/harness-extras/files/.claude/hooks/reinject-compact-handoff.mjs +147 -13
- package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline.mjs +6 -2
- package/templates/packs/harness-extras/files/.claude/hooks/write-compact-handoff.mjs +227 -44
- package/templates/packs/harness-extras/pack.json +18 -13
- package/templates/packs/publishing/files/.changeset/README.md +25 -0
- package/templates/packs/publishing/files/.changeset/config.json +7 -0
- package/templates/packs/publishing/files/.github/release-tools/package.json +9 -0
- package/templates/packs/publishing/files/.github/workflows/release.yml +293 -0
- package/templates/packs/publishing/files/REUSE.toml +28 -0
- package/templates/packs/publishing/files/bin/check-dts-deps.mjs +234 -0
- package/templates/packs/publishing/files/bin/check-license-headers.mjs +217 -0
- package/templates/packs/publishing/files/bin/check-publish-version.mjs +149 -0
- package/templates/packs/publishing/files/bin/lib/npm-publish-args.mjs +86 -0
- package/templates/packs/publishing/files/bin/pnpm-publish-shim.mjs +86 -0
- package/templates/packs/publishing/pack.json +35 -0
- package/templates/packs/{harness-extras → quality}/files/bin/check-file-budget.mjs +6 -4
- package/templates/packs/quality/pack.json +29 -0
- package/templates/packs/supply-chain/files/.github/workflows/gitleaks.yml +52 -0
- package/templates/packs/supply-chain/files/.github/workflows/scorecard.yml +48 -0
- package/templates/packs/supply-chain/files/.gitleaks.toml +2 -0
- package/templates/packs/supply-chain/pack.json +19 -0
- package/templates/packs/worktrees/files/.claude/hooks/ensure-worktree-deps.mjs +283 -0
- package/templates/packs/worktrees/files/.claude/hooks/guard-worktree-only.mjs +245 -0
- package/templates/packs/worktrees/files/.claude/hooks/repair-core-bare.mjs +335 -0
- package/templates/packs/worktrees/files/.claude/skills/working-in-worktrees/SKILL.md +139 -0
- package/templates/packs/worktrees/files/.worktreeinclude +11 -0
- package/templates/packs/worktrees/pack.json +64 -0
- package/templates/packs/statusline/pack.json +0 -31
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/statusline-layout.mjs +0 -0
- /package/templates/packs/{statusline → harness-extras}/files/.claude/hooks/subagent-statusline.mjs +0 -0
- /package/templates/packs/{harness-extras → quality}/files/.claude/agents/type-design-analyzer.md +0 -0
- /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 {
|
|
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 {
|
|
59
|
+
import {
|
|
60
|
+
HANDOFF_GLOB_PREFIX,
|
|
61
|
+
currentWorktree,
|
|
62
|
+
handoffRelPath,
|
|
63
|
+
resolveRoot,
|
|
64
|
+
} from "./write-compact-handoff.mjs";
|
|
31
65
|
|
|
32
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
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).
|
|
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:
|
|
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
|
|
4
|
-
* before Claude Code compacts the
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
45
|
-
export const
|
|
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 =
|
|
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 =
|
|
144
|
+
export function currentBranch(cwd = process.cwd()) {
|
|
72
145
|
return runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd) ?? "";
|
|
73
146
|
}
|
|
74
147
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
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 =
|
|
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
|
|
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 =
|
|
197
|
+
export function uncommittedFiles(cwd = process.cwd()) {
|
|
98
198
|
const raw = runGit(["status", "--porcelain"], cwd);
|
|
99
|
-
if (raw === null
|
|
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(
|
|
135
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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":
|
|
7
|
+
"agents": 0,
|
|
8
8
|
"skills": 0,
|
|
9
|
-
"hooks":
|
|
9
|
+
"hooks": 6,
|
|
10
10
|
"workflows": 0,
|
|
11
11
|
"scripts": 0
|
|
12
12
|
},
|
|
13
13
|
"requires": {
|
|
14
|
-
"paths": ["bin/lib/agent-roster.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
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
|
|
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": "
|
|
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
|
}
|