@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,407 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Per-file size ratchet for `src/` and `tests/` — the file scope matches
4
+ * `vitest.config.ts`'s `coverage.include` (`.ts` sources, excluding `index.ts`
5
+ * barrels and `.d.ts` files), since the hazard this guards against is
6
+ * specific to `perFile: true` v8 coverage: a large implementation file binds
7
+ * to every test file that exercises it, and once both grow past a point,
8
+ * retrofitting a split becomes structurally difficult to do in one PR.
9
+ *
10
+ * A flat ceiling is not viable once a project has real debt, so this is a
11
+ * **ratchet**, not a cap: a committed baseline (`bin/file-budget-
12
+ * baseline.json`) is the sparse "debt list" of files that already exceeded
13
+ * their ceiling when this gate was adopted. A baselined file may shrink
14
+ * freely but never **grow** past its recorded size; any file not in the
15
+ * baseline must stay under the ceiling from the start. `--update`
16
+ * regenerates the baseline from current sizes, dropping any entry that no
17
+ * longer exceeds its ceiling and adding any newly-over-ceiling file — an
18
+ * explicit, reviewed-diff social contract: a PR that baselines a new
19
+ * oversized file is asking its reviewer to accept that debt, not silently
20
+ * evading the gate.
21
+ *
22
+ * `ROOTS` defaults to this baseline's flat `src/`/`tests/` layout. If
23
+ * `/customize` or a later refactor moves to a `packages/*` monorepo shape,
24
+ * update `ROOTS` to list each package's `src`/`tests` pair.
25
+ *
26
+ * Usage:
27
+ * node bin/check-file-budget.mjs # verify (fails on growth/new-over-ceiling)
28
+ * node bin/check-file-budget.mjs --update # rewrite the baseline from current sizes
29
+ * node bin/check-file-budget.mjs --ref <ref> # verify a committed ref instead of the working tree (no checkout/worktree required); incompatible with --update
30
+ */
31
+ import process from "node:process";
32
+ import {
33
+ readFileSync,
34
+ writeFileSync,
35
+ readdirSync,
36
+ existsSync,
37
+ realpathSync,
38
+ } from "node:fs";
39
+ import { execFileSync } from "node:child_process";
40
+ import { join, relative } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+ import { parseJsonFlag, createReporter, repoRoot } from "./lib/report.mjs";
43
+
44
+ const root = repoRoot();
45
+ const baselineRel = "bin/file-budget-baseline.json";
46
+ const baselinePath = join(root, baselineRel);
47
+
48
+ /** Each root's `src`/`tests` pair to scan, relative to the repo root. */
49
+ export const ROOTS = [{ src: "src", tests: "tests" }];
50
+
51
+ /** Ceiling for a coverage-eligible `src` file not in the baseline. */
52
+ export const SRC_CEILING_BYTES = 25_000;
53
+ /** Ceiling for a `tests` file not in the baseline. */
54
+ export const TEST_CEILING_BYTES = 60_000;
55
+
56
+ /** `error.code` for a caught filesystem error, or `undefined` if it isn't one. */
57
+ function errnoCodeOf(error) {
58
+ return typeof error === "object" && error !== null && "code" in error
59
+ ? String(/** @type {{ code: unknown }} */ (error).code)
60
+ : undefined;
61
+ }
62
+
63
+ /**
64
+ * Recursively collect files under `dir` for which `matches(relPath)` is
65
+ * true, pruning `dist`/`node_modules` subtrees. A missing `dir` yields no
66
+ * files rather than throwing — a project without a `tests/` directory yet
67
+ * is not an error here.
68
+ *
69
+ * @param {string} dir absolute directory to walk
70
+ * @param {(relPath: string) => boolean} matches called with the path
71
+ * relative to the repo root
72
+ * @returns {string[]} repo-relative paths, sorted
73
+ */
74
+ export function walkMatching(dir, matches) {
75
+ const results = [];
76
+ const skipDirs = new Set(["dist", "node_modules"]);
77
+
78
+ function recurse(current) {
79
+ let entries;
80
+ try {
81
+ entries = readdirSync(current, { withFileTypes: true });
82
+ } catch (cause) {
83
+ if (errnoCodeOf(cause) === "ENOENT") return;
84
+ throw cause;
85
+ }
86
+ for (const entry of entries) {
87
+ if (entry.isDirectory()) {
88
+ if (skipDirs.has(entry.name)) continue;
89
+ recurse(join(current, entry.name));
90
+ } else if (entry.isFile()) {
91
+ const rel = relative(root, join(current, entry.name));
92
+ if (matches(rel)) results.push(rel);
93
+ }
94
+ }
95
+ }
96
+
97
+ recurse(dir);
98
+ return results.sort();
99
+ }
100
+
101
+ /**
102
+ * True for a `.ts` file that is part of `vitest.config.ts`'s coverage set —
103
+ * neither a declaration file nor a barrel named exactly `index.ts`.
104
+ *
105
+ * @param {string} relPath repo-relative path
106
+ * @returns {boolean}
107
+ */
108
+ export function isCoverageEligibleSrcFile(relPath) {
109
+ if (!relPath.endsWith(".ts") || relPath.endsWith(".d.ts")) return false;
110
+ return !relPath.endsWith("/index.ts") && relPath !== "index.ts";
111
+ }
112
+
113
+ /**
114
+ * @param {string} relPath repo-relative path
115
+ * @returns {boolean}
116
+ */
117
+ export function isTestFile(relPath) {
118
+ return relPath.endsWith(".test.ts");
119
+ }
120
+
121
+ /**
122
+ * Classify a `git ls-tree`-reported path the same way
123
+ * {@link collectBudgetEntries}'s walk classifies a filesystem path — but
124
+ * from a bare repo-relative string, since `--ref` mode has no directory to
125
+ * walk.
126
+ *
127
+ * @param {string} relPath repo-relative path
128
+ * @returns {"src" | "test" | null}
129
+ */
130
+ export function classifyRefPath(relPath) {
131
+ for (const { src, tests } of ROOTS) {
132
+ if (relPath.startsWith(`${src}/`) && isCoverageEligibleSrcFile(relPath))
133
+ return "src";
134
+ if (relPath.startsWith(`${tests}/`) && isTestFile(relPath)) return "test";
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * @typedef {Object} BudgetEntry
141
+ * @property {string} path repo-relative
142
+ * @property {number} bytes current size
143
+ * @property {"src" | "test"} category
144
+ */
145
+
146
+ /**
147
+ * @param {BudgetEntry} entry
148
+ * @returns {number} the ceiling that applies when `entry.path` is not baselined
149
+ */
150
+ function ceilingFor(entry) {
151
+ return entry.category === "src" ? SRC_CEILING_BYTES : TEST_CEILING_BYTES;
152
+ }
153
+
154
+ /**
155
+ * Collect every file this gate scopes, with its current byte size.
156
+ *
157
+ * @returns {BudgetEntry[]}
158
+ */
159
+ export function collectBudgetEntries() {
160
+ /** @type {BudgetEntry[]} */
161
+ const entries = [];
162
+
163
+ for (const { src, tests } of ROOTS) {
164
+ for (const rel of walkMatching(
165
+ join(root, src),
166
+ isCoverageEligibleSrcFile,
167
+ )) {
168
+ entries.push({
169
+ path: rel,
170
+ bytes: Buffer.byteLength(readFileSync(join(root, rel)), "utf8"),
171
+ category: "src",
172
+ });
173
+ }
174
+ for (const rel of walkMatching(join(root, tests), isTestFile)) {
175
+ entries.push({
176
+ path: rel,
177
+ bytes: Buffer.byteLength(readFileSync(join(root, rel)), "utf8"),
178
+ category: "test",
179
+ });
180
+ }
181
+ }
182
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
183
+ }
184
+
185
+ /**
186
+ * {@link collectBudgetEntries}'s equivalent for a committed ref, read via
187
+ * `git` plumbing instead of `node:fs` — no checkout or worktree required.
188
+ *
189
+ * @param {string} ref a ref resolvable by `git` (branch, tag, SHA, `origin/*`)
190
+ * @returns {BudgetEntry[]}
191
+ * @throws {Error} if `ref` cannot be resolved, or `git` fails for any reason
192
+ */
193
+ export function collectBudgetEntriesAtRef(ref) {
194
+ const pathspecs = ROOTS.flatMap(({ src, tests }) => [`${src}/`, `${tests}/`]);
195
+ const listing = execFileSync(
196
+ "git",
197
+ ["ls-tree", "-r", "--name-only", ref, "--", ...pathspecs],
198
+ { cwd: root, encoding: "utf8" },
199
+ );
200
+
201
+ /** @type {BudgetEntry[]} */
202
+ const entries = [];
203
+ for (const relPath of listing.split("\n")) {
204
+ if (relPath === "") continue;
205
+ const category = classifyRefPath(relPath);
206
+ if (category === null) continue;
207
+ const size = execFileSync("git", ["cat-file", "-s", `${ref}:${relPath}`], {
208
+ cwd: root,
209
+ encoding: "utf8",
210
+ });
211
+ entries.push({ path: relPath, bytes: Number(size.trim()), category });
212
+ }
213
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
214
+ }
215
+
216
+ /**
217
+ * {@link readFileSync}/`JSON.parse` of `bin/file-budget-baseline.json`, but
218
+ * against a committed ref instead of the working tree. A baseline absent at
219
+ * `ref` yields `{}`; a present-but-invalid baseline's `JSON.parse` failure
220
+ * propagates uncaught, same as the working-tree path.
221
+ *
222
+ * @param {string} ref a ref resolvable by `git`
223
+ * @returns {Record<string, number>}
224
+ */
225
+ export function readBaselineAtRef(ref) {
226
+ try {
227
+ execFileSync("git", ["cat-file", "-e", `${ref}:${baselineRel}`], {
228
+ cwd: root,
229
+ encoding: "utf8",
230
+ });
231
+ } catch {
232
+ return {};
233
+ }
234
+ const text = execFileSync("git", ["show", `${ref}:${baselineRel}`], {
235
+ cwd: root,
236
+ encoding: "utf8",
237
+ });
238
+ return JSON.parse(text);
239
+ }
240
+
241
+ /**
242
+ * Read `--ref <value>` out of an argv array.
243
+ *
244
+ * @param {string[]} argv
245
+ * @returns {string | undefined}
246
+ */
247
+ export function parseRefArg(argv) {
248
+ const i = argv.indexOf("--ref");
249
+ return i >= 0 ? argv[i + 1] : undefined;
250
+ }
251
+
252
+ /**
253
+ * Compare current entries against the committed baseline.
254
+ *
255
+ * @param {BudgetEntry[]} entries
256
+ * @param {Record<string, number>} baseline path -> recorded byte ceiling
257
+ * @returns {{ violations: Array<{ path: string, bytes: number, limit: number, baselined: boolean }> }}
258
+ */
259
+ export function checkBudget(entries, baseline) {
260
+ const violations = [];
261
+ for (const entry of entries) {
262
+ const recorded = baseline[entry.path];
263
+ if (recorded !== undefined) {
264
+ if (entry.bytes > recorded) {
265
+ violations.push({
266
+ path: entry.path,
267
+ bytes: entry.bytes,
268
+ limit: recorded,
269
+ baselined: true,
270
+ });
271
+ }
272
+ continue;
273
+ }
274
+ const ceiling = ceilingFor(entry);
275
+ if (entry.bytes > ceiling) {
276
+ violations.push({
277
+ path: entry.path,
278
+ bytes: entry.bytes,
279
+ limit: ceiling,
280
+ baselined: false,
281
+ });
282
+ }
283
+ }
284
+ return { violations };
285
+ }
286
+
287
+ /**
288
+ * Build the regenerated baseline: every entry currently over its ceiling,
289
+ * keyed to its exact current size. Entries that no longer exceed their
290
+ * ceiling (shrunk, or deleted) are dropped — the baseline only ever tracks
291
+ * live debt.
292
+ *
293
+ * @param {BudgetEntry[]} entries
294
+ * @returns {Record<string, number>} key-sorted
295
+ */
296
+ export function buildBaseline(entries) {
297
+ /** @type {Record<string, number>} */
298
+ const next = {};
299
+ for (const entry of entries) {
300
+ if (entry.bytes > ceilingFor(entry)) next[entry.path] = entry.bytes;
301
+ }
302
+ return Object.fromEntries(
303
+ Object.entries(next).sort(([a], [b]) => a.localeCompare(b)),
304
+ );
305
+ }
306
+
307
+ // `import.meta.url` is symlink-resolved but `process.argv[1]` is not, so
308
+ // comparing them directly is false under any symlinked path and the gate would
309
+ // never run -- exiting 0, a green check that checked nothing.
310
+ function isEntryPoint() {
311
+ try {
312
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
313
+ } catch {
314
+ return false;
315
+ }
316
+ }
317
+
318
+ if (isEntryPoint()) {
319
+ const argv = process.argv.slice(2);
320
+ const reporter = createReporter(parseJsonFlag(argv));
321
+ const ref = parseRefArg(argv);
322
+
323
+ if (ref !== undefined && argv.includes("--update")) {
324
+ reporter.fail(
325
+ "--update cannot be combined with --ref -- there is no committed " +
326
+ "blob to write a regenerated baseline into. Run --update on a " +
327
+ "checked-out working tree instead.",
328
+ );
329
+ reporter.finish();
330
+ process.exit(1);
331
+ }
332
+
333
+ let entries;
334
+ try {
335
+ entries = ref ? collectBudgetEntriesAtRef(ref) : collectBudgetEntries();
336
+ } catch (cause) {
337
+ const message = cause instanceof Error ? cause.message : String(cause);
338
+ reporter.fail(
339
+ ref
340
+ ? `Could not scan the tracked roots at ${ref}: ${message}`
341
+ : `Could not scan ${relative(root, root)}: ${message}`,
342
+ );
343
+ reporter.finish();
344
+ process.exit(1);
345
+ }
346
+
347
+ if (argv.includes("--update")) {
348
+ const next = buildBaseline(entries);
349
+ writeFileSync(baselinePath, `${JSON.stringify(next, null, 2)}\n`);
350
+ const count = Object.keys(next).length;
351
+ reporter.ok(
352
+ `updated ${baselineRel} (${count} ${count === 1 ? "entry" : "entries"})`,
353
+ );
354
+ reporter.finish();
355
+ process.exit(0);
356
+ }
357
+
358
+ /** @type {Record<string, number>} */
359
+ let baseline = {};
360
+ if (ref !== undefined) {
361
+ try {
362
+ baseline = readBaselineAtRef(ref);
363
+ } catch (cause) {
364
+ reporter.fail(
365
+ `Could not parse ${baselineRel} at ${ref}: ${cause instanceof Error ? cause.message : String(cause)}`,
366
+ );
367
+ reporter.finish();
368
+ process.exit(1);
369
+ }
370
+ } else if (existsSync(baselinePath)) {
371
+ try {
372
+ baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
373
+ } catch (cause) {
374
+ reporter.fail(
375
+ `Could not parse ${baselineRel}: ${cause instanceof Error ? cause.message : String(cause)}`,
376
+ );
377
+ reporter.finish();
378
+ process.exit(1);
379
+ }
380
+ }
381
+
382
+ const { violations } = checkBudget(entries, baseline);
383
+ for (const v of violations) {
384
+ if (v.baselined) {
385
+ reporter.fail(
386
+ `${v.path}: ${v.bytes} bytes -- grew past its baselined ceiling of ${v.limit} ` +
387
+ `(bin/file-budget-baseline.json). Split the file before adding more to it.`,
388
+ );
389
+ } else {
390
+ reporter.fail(
391
+ `${v.path}: ${v.bytes} bytes -- exceeds the ${v.limit}-byte ceiling and is not in the ` +
392
+ `baseline. Split it, or if the size is deliberate, run ` +
393
+ `\`node bin/check-file-budget.mjs --update\` and explain why in the PR body.`,
394
+ );
395
+ }
396
+ }
397
+
398
+ if (violations.length > 0) {
399
+ reporter.finish();
400
+ process.exit(1);
401
+ }
402
+
403
+ reporter.ok(
404
+ `${entries.length} file(s) checked against the size ratchet -- none exceed their limit.`,
405
+ );
406
+ reporter.finish();
407
+ }
@@ -0,0 +1,65 @@
1
+ {
2
+ "schemaVersion": 1,
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.",
5
+ "modes": ["fresh", "adopt"],
6
+ "budget": {
7
+ "agents": 1,
8
+ "skills": 0,
9
+ "hooks": 3,
10
+ "workflows": 0,
11
+ "scripts": 0
12
+ },
13
+ "requires": {
14
+ "paths": ["bin/lib/agent-roster.mjs", "bin/lib/report.mjs"]
15
+ },
16
+ "wiring": {
17
+ "settings": {
18
+ "PreCompact": [
19
+ {
20
+ "hooks": [
21
+ {
22
+ "type": "command",
23
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/write-compact-handoff.mjs\"",
24
+ "timeout": 30
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "SessionStart": [
30
+ {
31
+ "matcher": "compact|resume|startup",
32
+ "hooks": [
33
+ {
34
+ "type": "command",
35
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/reinject-compact-handoff.mjs\"",
36
+ "timeout": 30
37
+ }
38
+ ]
39
+ }
40
+ ],
41
+ "PreToolUse": [
42
+ {
43
+ "matcher": "Bash",
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard-readonly-bash.mjs\"",
48
+ "timeout": 30
49
+ }
50
+ ]
51
+ }
52
+ ]
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"]
61
+ }
62
+ ]
63
+ },
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."
65
+ }