@thedecipherist/claude-packs 1.0.0 → 1.1.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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +275 -18
  3. package/bin/claude-packs.mjs +18 -10
  4. package/configurations/base/config.json +1 -1
  5. package/configurations/cpp/config.json +2 -1
  6. package/configurations/full-stack/config.json +2 -1
  7. package/configurations/go/config.json +3 -1
  8. package/configurations/node/config.json +3 -1
  9. package/configurations/python/config.json +3 -1
  10. package/configurations/rust/config.json +106 -0
  11. package/configurations/synth-creator/config.json +1 -1
  12. package/global/hooks/lib/shell-writes.mjs +2 -293
  13. package/lib/build-queue.mjs +22 -0
  14. package/lib/claude-md.mjs +4 -2
  15. package/lib/config-cli.mjs +1 -1
  16. package/lib/coverage.mjs +10 -2
  17. package/lib/doctor.mjs +14 -3
  18. package/lib/fix-plan.mjs +2 -1
  19. package/lib/found-folders.mjs +25 -0
  20. package/lib/global.mjs +3 -3
  21. package/lib/hook-proof.mjs +24 -7
  22. package/lib/kit-command.mjs +14 -0
  23. package/lib/layout.mjs +27 -3
  24. package/lib/logs.mjs +45 -5
  25. package/lib/new.mjs +1 -1
  26. package/lib/packs.mjs +4 -2
  27. package/lib/rule-tests.mjs +42 -0
  28. package/lib/scan-detect.mjs +41 -5
  29. package/lib/scan.mjs +5 -2
  30. package/lib/suggest.mjs +16 -3
  31. package/lib/sync.mjs +5 -2
  32. package/lib/workorder.mjs +26 -0
  33. package/package.json +1 -1
  34. package/packs/audio-core/hooks/param-id-lock.mjs +59 -16
  35. package/packs/audio-core/pack.json +5 -0
  36. package/packs/audio-core/rules/a-control-is-proved-through-its-input.md +26 -0
  37. package/packs/audio-core/rules/cpu-worst-case-per-block.md +3 -1
  38. package/packs/audio-core/rules/house-control-behaviours.md +1 -1
  39. package/packs/audio-core/rules/scoped-no-denormals.md +1 -1
  40. package/packs/audio-core/rules/straight-on-and-aligned.md +1 -1
  41. package/packs/audio-core/rules/ui-matches-reference-look.md +2 -0
  42. package/packs/audio-core/skills/analyse-reference-look/SKILL.md +25 -11
  43. package/packs/audio-core/skills/prove-control/SKILL.md +2 -0
  44. package/packs/base/checks/one-walk-per-template.json +1 -1
  45. package/packs/base/hooks/code-shape.mjs +10 -6
  46. package/packs/base/hooks/dispatch-once.mjs +58 -21
  47. package/packs/base/hooks/folder-law.mjs +3 -0
  48. package/packs/base/hooks/git-guard.mjs +22 -109
  49. package/packs/base/hooks/lesson-checks.mjs +9 -1
  50. package/packs/base/hooks/lib/code-shapes.mjs +11 -6
  51. package/packs/base/hooks/lib/crash-policy.mjs +47 -0
  52. package/packs/base/hooks/lib/dup-index.mjs +6 -1
  53. package/packs/base/hooks/lib/dup.mjs +2 -2
  54. package/packs/base/hooks/lib/event-log.mjs +4 -3
  55. package/packs/base/hooks/lib/git-adds.mjs +2 -1
  56. package/packs/base/hooks/lib/git-auth.mjs +44 -0
  57. package/packs/base/hooks/lib/git-guard-selftest.mjs +93 -0
  58. package/packs/base/hooks/lib/languages.mjs +5 -2
  59. package/packs/base/hooks/lib/layout.mjs +6 -2
  60. package/packs/base/hooks/lib/making-check-selftest.mjs +3 -0
  61. package/packs/base/hooks/lib/near-copies.mjs +1 -1
  62. package/packs/base/hooks/lib/program-read.mjs +6 -3
  63. package/packs/base/hooks/lib/rust-scan.mjs +139 -0
  64. package/packs/base/hooks/lib/shape-lists.mjs +31 -0
  65. package/packs/base/hooks/lib/shape-prose.mjs +78 -0
  66. package/packs/base/hooks/lib/shape.mjs +18 -39
  67. package/packs/base/hooks/lib/turn.mjs +6 -1
  68. package/packs/base/hooks/lib/unread-writes.mjs +24 -0
  69. package/packs/base/hooks/making-check.mjs +12 -7
  70. package/packs/base/hooks/run-hooks.mjs +15 -7
  71. package/packs/base/hooks/shape-law.mjs +11 -4
  72. package/packs/base/hooks/suite-once.mjs +22 -6
  73. package/packs/base/rules/a-control-is-seen-to-fire.md +1 -1
  74. package/packs/base/rules/behaviour-lives-with-its-kind.md +1 -1
  75. package/packs/base/rules/bug-fix-starts-red.md +1 -1
  76. package/packs/base/rules/field-map-over-if-runs.md +1 -1
  77. package/packs/base/rules/instances-by-index-identities-by-id.md +2 -2
  78. package/packs/base/rules/lookup-over-if-chains.md +1 -1
  79. package/packs/base/rules/plan-and-logs.md +1 -1
  80. package/packs/base/rules/refusals-are-judged.md +1 -0
  81. package/packs/base/rules/test-the-template-once.md +2 -1
  82. package/packs/base/rules/unattended-runs.md +15 -0
  83. package/packs/base/skills/new-project/SKILL.md +23 -0
  84. package/packs/base/skills/write-a-workorder/SKILL.md +41 -0
  85. package/packs/cpp/checks/cpp-find-with-the-library.json +2 -2
  86. package/packs/go/pack.json +5 -0
  87. package/packs/hook-proof/rules/a-refusal-is-exit-2.md +2 -2
  88. package/packs/instrument-shell/rules/render-the-extremes.md +2 -2
  89. package/packs/midi/checks/position-not-integrated.json +5 -1
  90. package/packs/postgres/checks/pg-bind-values.json +33 -0
  91. package/packs/postgres/checks/pg-concurrently-in-txn.json +18 -0
  92. package/packs/postgres/checks/pg-fk-not-valid.json +18 -0
  93. package/packs/postgres/checks/pg-index-concurrently.json +18 -0
  94. package/packs/postgres/checks/pg-no-float-money.json +19 -0
  95. package/packs/postgres/checks/pg-no-money.json +18 -0
  96. package/packs/postgres/checks/pg-no-required-column.json +18 -0
  97. package/packs/postgres/checks/pg-no-volatile-default.json +18 -0
  98. package/packs/postgres/checks/pg-not-exists.json +18 -0
  99. package/packs/postgres/checks/pg-timestamptz.json +18 -0
  100. package/packs/postgres/hooks/db-wipe-guard.mjs +95 -0
  101. package/packs/postgres/pack.json +48 -0
  102. package/packs/postgres/rules/postgres-migrations-never-stop-traffic.md +28 -0
  103. package/packs/postgres/rules/postgres-one-pool-per-process.md +21 -0
  104. package/packs/postgres/rules/postgres-queries-are-null-safe-and-bounded.md +26 -0
  105. package/packs/postgres/rules/postgres-types-say-what-they-hold.md +22 -0
  106. package/packs/postgres/rules/postgres-values-are-bound.md +130 -0
  107. package/packs/postgres/skills/postgres-safe-migration/SKILL.md +45 -0
  108. package/packs/reverse-engineering/rules/addresses-name-their-space.md +1 -0
  109. package/packs/reverse-engineering/rules/reproduction-or-original.md +1 -1
  110. package/packs/reverse-engineering/skills/gather-sources/SKILL.md +1 -0
  111. package/packs/rust/checks/rust-allow-needs-reason.json +16 -0
  112. package/packs/rust/checks/rust-blocking-in-async.json +32 -0
  113. package/packs/rust/checks/rust-if-chain-is-a-match.json +32 -0
  114. package/packs/rust/checks/rust-static-mut.json +32 -0
  115. package/packs/rust/checks/rust-string-as-error.json +34 -0
  116. package/packs/rust/checks/rust-stub-left-in.json +32 -0
  117. package/packs/rust/checks/rust-unsafe-needs-safety.json +31 -0
  118. package/packs/rust/checks/rust-unwrap-in-library.json +34 -0
  119. package/packs/rust/checks/rust-wildcard-dependency.json +16 -0
  120. package/packs/rust/clippy.toml +5 -0
  121. package/packs/rust/pack.json +27 -0
  122. package/packs/rust/rules/rust-async-never-blocks.md +20 -0
  123. package/packs/rust/rules/rust-borrow-before-you-clone.md +19 -0
  124. package/packs/rust/rules/rust-errors-are-returned.md +22 -0
  125. package/packs/rust/rules/rust-lints-are-the-gate.md +23 -0
  126. package/packs/rust/rules/rust-repeats-become-templates.md +28 -0
  127. package/packs/rust/rules/rust-unsafe-is-proved.md +21 -0
  128. package/packs/rust/skills/rust-extract-template/SKILL.md +50 -0
  129. package/packs/rust/skills/rust-port/SKILL.md +22 -0
  130. package/packs/rust/skills/rust-review/SKILL.md +24 -0
  131. package/packs/rust/tools/rust-lints.toml +19 -0
  132. package/packs/sound-design/skills/bounce-a-plugin/SKILL.md +40 -0
  133. package/packs/sound-design/tools/vst3host/build.sh +73 -0
  134. package/packs/sound-design/tools/vst3host/host_events.h +101 -0
  135. package/packs/sound-design/tools/vst3host/options.h +172 -0
  136. package/packs/sound-design/tools/vst3host/render.cpp +245 -0
  137. package/packs/sound-design/tools/vst3host/session.h +241 -0
  138. package/packs/synth-creator/hooks/build-guard.mjs +7 -7
  139. package/packs/synth-creator/hooks/protect-vendor.mjs +2 -1
  140. package/packs/synth-creator/hooks/stage-guard.mjs +1 -1
  141. package/packs/synth-creator/rules/timing-from-synthcore.md +1 -1
  142. package/packs/synth-creator/skills/build-through-the-queue/SKILL.md +4 -2
  143. package/packs/synth-creator/tools/buildq.mjs +74 -0
  144. package/prompts/workorder/PROMPT.md +53 -0
  145. package/prompts/workorder/run.sh +36 -0
  146. package/prompts/workorder/workorder.md +59 -0
  147. package/global/hooks/lib/program-read.mjs +0 -196
  148. package/global/hooks/lib/program-writes.mjs +0 -177
@@ -5,8 +5,8 @@
5
5
  // branch's upstream, and `remote add/set-url`, `clone`, `submodule` and `config` store it as a remote; the token then
6
6
  // sits in .git/config in plain text. Pushing to a token URL WITHOUT -u stores nothing and is allowed.
7
7
  //
8
- // THE HOUSE LAWS, when the project switches them on (.claude/layout.json "guards": { "git": { "house": true } };
9
- // a project whose history is on main moves to the branch flow first, then enables them):
8
+ // THE HOUSE LAWS: a new install has them (KF-8). A project installed before 1.1.0 switches them on itself, with .claude/layout.json
9
+ // "enable": ["guards/git-house"] or "guards": { "git": { "house": true } }; one whose history is on main moves to the branch flow first:
10
10
  // 1. No `git add -A`, `add .`, `add --all`, `commit -a`. Stage by path.
11
11
  // 2. No checkout / restore / stash / reset --hard / clean over uncommitted work; no override lets one through.
12
12
  // `git restore --staged` (unstage only) passes. The project's safe form, if any, is named (guards.git.safeRestore).
@@ -23,14 +23,14 @@
23
23
  // first word (after VAR=value) is git or gh. `cd` and `git -C` move the folder the dirty-tree and trunk checks look at.
24
24
  // The laws hold in every repo a command reaches. `--selftest` proves both halves.
25
25
  import { execFileSync } from 'node:child_process';
26
- import { readFileSync } from 'node:fs';
27
26
  import { homedir } from 'node:os';
28
27
  import { join, normalize, resolve } from 'node:path';
29
28
  import { fileURLToPath } from 'node:url';
30
29
  import { readLayout } from './lib/layout.mjs';
31
30
  import { claudeRoot } from './lib/project.mjs';
32
31
  import { asHook, isMain } from './lib/hook-log.mjs';
33
- import { forRepo, sshOwner, stopVerdict, unpushed, unpushedMessage } from './lib/pushed.mjs';
32
+ import { NEW_INSTALL_GIT, ghLaw, pushLaw } from './lib/git-auth.mjs';
33
+ import { forRepo, stopVerdict, unpushed, unpushedMessage } from './lib/pushed.mjs';
34
34
 
35
35
  // ---------- always: a token never stored ----------
36
36
  const URL_USER = /\b[a-z][\w+.-]*:\/\/([^\s/@'"]+)@/gi;
@@ -83,6 +83,7 @@ const real = {
83
83
  branch: (cwd) => (gitOut(cwd, 'rev-parse', '--abbrev-ref', 'HEAD') ?? '').trim(),
84
84
  hasHead: (cwd) => gitOut(cwd, 'rev-parse', '-q', '--verify', 'HEAD') !== null,
85
85
  remoteUrl: (cwd, name) => gitOut(cwd, 'remote', 'get-url', name)?.trim() || null,
86
+ merging: (cwd) => gitOut(cwd, 'rev-parse', '-q', '--verify', 'MERGE_HEAD') !== null,
86
87
  };
87
88
  // What a push sends to: the URL in the command, or the URL of the remote it names (null when neither is known).
88
89
  const pushTarget = (w, here, env) => { const a = w.slice(w.indexOf('push') + 1).filter((x) => !x.startsWith('-')); return !a.length ? null : /[:/]/.test(a[0]) ? a[0] : env.remoteUrl?.(here, a[0]) ?? null; };
@@ -106,17 +107,20 @@ export function house(cmd, cwd = '.', env = real, cfg = {}) {
106
107
  while (w.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w[0]) || ['env', 'command', 'time', 'nohup', 'exec'].includes(w[0]))) w = w.slice(1);
107
108
  if (!w.length || !['git', 'gh'].includes(w[0].split('/').pop())) continue;
108
109
  const p = w.join(' ');
109
- if (/\bgit\b.*\badd\b.*(\s-A\b|\s--all\b|\s\.(\s|$))/.test(p) || (/\bgit\b.*\bcommit\b.*\s-[a-zA-Z]*a[a-zA-Z]*\b(?!\S)/.test(p) && !p.includes('--amend'))) {
110
+ const sub = w[0].split('/').pop() === 'git' ? subOf(w) : null; // the subcommand, never a word in an argument: `git rev-parse x^{commit}` commits nothing
111
+ const dryRun = sub === 'add' && /\s(-n|--dry-run)(\s|$)/.test(p); // KF-10: it stages nothing
112
+ if ((sub === 'add' && !dryRun && /(\s-A\b|\s--all\b|\s\.(\s|$))/.test(p)) || (sub === 'commit' && /\s-[a-zA-Z]*a[a-zA-Z]*\b(?!\S)/.test(p) && !p.includes('--amend'))) {
110
113
  return 'Stage by explicit path: `git add <paths>` and `git commit -- <paths>`. `add -A`, `add .` and `commit -a` sweep in other work.';
111
114
  }
112
- for (const never of c.neverStage ?? []) if (/\bgit\b.*\badd\b/.test(p) && neverRx(never).test(p)) return `Nothing under ${never} is ever committed.`;
115
+ for (const never of c.neverStage ?? []) if (sub === 'add' && neverRx(never).test(p)) return `Nothing under ${never} is ever committed.`;
113
116
  const undo = p.match(/\bgit\b\s+(?:-C\s+\S+\s+)?(checkout|restore|stash|clean|reset)\b(.*)/);
114
117
  if (undo) {
115
118
  const [, verb, rest] = undo;
116
119
  const harmless = (verb === 'stash' && /\b(list|show|pop|apply)\b/.test(rest)) ||
117
120
  (verb === 'restore' && /\s--staged\b/.test(rest) && !/\s(--worktree|-W)\b/.test(rest)) ||
118
121
  (verb === 'reset' && !rest.includes('--hard')) || (verb === 'checkout' && /\s-b\b/.test(rest)) ||
119
- (verb === 'clean' && /\s-[a-zA-Z]*n/.test(rest));
122
+ (verb === 'clean' && /\s-[a-zA-Z]*n/.test(rest)) ||
123
+ (verb === 'checkout' && /\s--(?:theirs|ours)\b/.test(rest) && env.merging?.(here)); // KF-21: a conflict resolved, not an undo
120
124
  if (!harmless) {
121
125
  const d = env.dirty(here);
122
126
  if (d.length) {
@@ -130,29 +134,27 @@ export function house(cmd, cwd = '.', env = real, cfg = {}) {
130
134
  if (/\bgit\b.*\bbranch\b.*\s(-D|-d|--delete)\b/.test(p) || /\bgit\b.*\bpush\b.*(\s--delete\b|\s:[\w/.-]+)/.test(p)) {
131
135
  return 'A branch is never deleted. Branches are the record of the build; they stay, here and on GitHub.';
132
136
  }
133
- if (/\bgit\b.*\b(rebase|filter-branch)\b/.test(p) || /\bgit\b.*\bcommit\b.*--amend/.test(p)) return 'Nothing is rebased or amended. Make a new commit.';
134
- if (/\bgit\b.*\s(--no-verify|-n)(\s|$)/.test(p) && /\bgit\b.*\b(commit|push|merge|am)\b/.test(p)) {
137
+ if (['rebase', 'filter-branch'].includes(sub) || (sub === 'commit' && p.includes('--amend'))) return 'Nothing is rebased or amended. Make a new commit.';
138
+ if (['commit', 'push', 'merge', 'am'].includes(sub) && /\s(--no-verify|-n)(\s|$)/.test(p)) {
135
139
  return 'Never --no-verify (or -n): the pre-commit hook is the gate. When it refuses, fix what it names; if a commit must hold something that fails the gates (a planted fault for a test), do not commit it: work on an uncommitted copy.';
136
140
  }
137
- if (c.refuseTrunk && /\bgit\b.*\bcommit\b/.test(p) && (c.trunks ?? []).includes(env.branch(here)) && env.hasHead(here)) {
141
+ if (c.refuseTrunk && sub === 'commit' && (c.trunks ?? []).includes(env.branch(here)) && env.hasHead(here)) {
138
142
  return `This is the trunk. Every task is built on its own branch and merged when complete: ${c.branchStart}.`;
139
143
  }
140
144
  // WB-107: an agent's isolated worktree cannot push (its harness refuses), so the lead pushes its branch; Stop still holds it
141
- if (!pushedBefore && env.unpushed && subOf(w) === 'commit' && !/\/\.claude\/worktrees\/agent-[^/]+(\/|$)/.test(here) && !(c.trunkNeedsYes && (c.trunks ?? []).includes(env.branch(here)))) {
145
+ if (!pushedBefore && env.unpushed && sub === 'commit' && !/\/\.claude\/worktrees\/agent-[^/]+(\/|$)/.test(here) && !(c.trunkNeedsYes && (c.trunks ?? []).includes(env.branch(here)))) {
142
146
  const u = env.unpushed(here, c);
143
147
  if (u?.error) env.warn?.(`git-guard: GitHub was not checked before this commit (${u.error}); the commit goes ahead unchecked. Push when it is back (KWB-26).`);
144
148
  else if (u?.commits?.length) return unpushedMessage(u, c);
145
149
  }
146
150
  if (/\bgit\b.*\bpush\b/.test(p)) {
147
151
  if (/\s(-f|--force|--force-with-lease)\b/.test(p)) return 'Never force a push. Nothing pushed is rebased or amended.';
148
- const ssh = sshOwner(pushTarget(w, here, env)) === owner; // over SSH the key picks the account; the URL names the repo (KWB-39)
149
- if (owner && !ssh && !new RegExp(`github\\.com/${esc(owner)}/[\\w.-]+`).test(p)) {
150
- return `A push must name the repo: git push https://x-access-token:$${tok}@github.com/${owner}/<repo>.git <branch>, or a remote whose URL is git@github.com:${owner}/<repo>.git (SSH). ${c.accountNote}`;
151
- }
152
- if (!ssh && !p.includes(`$${tok}`) && !p.includes(`\${${tok}}`)) return `A push names the token as $${tok}, never a pasted one.`;
152
+ const refused = pushLaw(p, pushTarget(w, here, env), c);
153
+ if (refused) return refused;
153
154
  pushedBefore = true;
154
155
  }
155
- if (c.ghAuth !== 'cli' && /(^|\s)gh\s/.test(p) && !cmd.includes(`GH_TOKEN=$${tok}`)) return `Every \`gh\` names the token: source ~/.env && GH_TOKEN=$${tok} gh ... ${c.accountNote}`;
156
+ const ghRefused = ghLaw(cmd, p, c);
157
+ if (ghRefused) return ghRefused;
156
158
  if (/\bgh\s+repo\s+create\b/.test(p) && ((owner && !p.includes(`${owner}/`)) || !p.includes('--private'))) return `A repo is created as ${owner}/<name> --private.`;
157
159
  if (/\bgit\b.*\b(remote\s+(add|set-url)|config)\b/.test(p) && new RegExp(`x-access-token|ghp_|github_pat_|${esc(tok)}`).test(p)) {
158
160
  return 'The token is never stored in a remote URL or a config file. Name it on the push only.';
@@ -167,7 +169,8 @@ function main(text) {
167
169
  try { payload = JSON.parse(text); } catch { return 0; }
168
170
  const command = payload.tool_input?.command ?? '';
169
171
  // the project's laws: its own layout (a worktree with its own layout.json is judged by it, else the project's)
170
- const cfg = { ...DEFAULTS, ...(readLayout(claudeRoot(join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), '.claude', 'layout.json'))).guards?.git ?? {}) };
172
+ const layout = readLayout(claudeRoot(join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), '.claude', 'layout.json')));
173
+ const cfg = { ...DEFAULTS, ...(layout.enable?.includes('guards/git-house') ? NEW_INSTALL_GIT : {}), ...(layout.guards?.git ?? {}) }; // KF-8: an existing project opts in
171
174
  if (payload.hook_event_name === 'Stop') {
172
175
  if (!cfg.house) return 0;
173
176
  const v = stopVerdict(resolve(process.env.CLAUDE_PROJECT_DIR ?? process.cwd()), cfg, payload.session_id);
@@ -195,95 +198,5 @@ function main(text) {
195
198
  return 2;
196
199
  }
197
200
 
198
- async function selftest() {
199
- const bad = [];
200
- // the token law (Sub37 WB-3)
201
- const tokenCases = [
202
- ['git push -u https://x-access-token:abc@github.com/a/b.git work/x', true],
203
- ['git push --set-upstream https://me:$TOKEN@github.com/a/b.git main', true],
204
- ['cd ../b && git -C ../b push origin x -u https://u:p@github.com/a/b.git', true],
205
- ['git remote set-url origin https://ghp_abc@github.com/a/b.git', true],
206
- ['git remote add o https://u:${T}@github.com/a/b.git', true],
207
- ['git clone https://x-access-token:abc@github.com/a/b.git', true],
208
- ['git config remote.origin.url https://u:t@github.com/a/b.git', true],
209
- ['git push https://x-access-token:$T@github.com/a/b.git work/x', false],
210
- ['git push -u origin work/x', false],
211
- ['git remote set-url origin https://github.com/a/b.git', false],
212
- ['git remote add o git@github-house:HouseOwner/b.git', false],
213
- ['git clone https://me@github.com/a/b.git', false],
214
- ['echo https://u:p@x.com/y', false],
215
- ];
216
- for (const [cmd, block] of tokenCases) if ((problem(cmd) !== null) !== block) bad.push(`token law: ${cmd}`);
217
- if (problem('git push -u https://u:SECRET@h/x.git b')?.includes('SECRET')) bad.push('the refusal prints the token');
218
-
219
- // the house laws
220
- const isDirty = { dirty: () => [' M design/gen_panel.py'], branch: () => 'step/C1-the-ribbon', hasHead: () => true };
221
- const clean = { ...isDirty, dirty: () => [] };
222
- const on = (branch, env = clean) => ({ ...env, branch: typeof branch === 'function' ? branch : () => branch });
223
- const push = 'git push https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/HouseOwner/app.git main';
224
- const remote = (url) => ({ ...clean, remoteUrl: () => url }); // a remote whose URL is url (KWB-39: SSH names the repo)
225
- const mustBlock = [
226
- ['git add -A', clean], ['git add .', clean], ["git commit -am 'x'", clean], ['git add ~/firmware/korg/m1.bin', clean],
227
- ['git -C /Users/someone/projects/other add -A', clean], ['cd ~/projects/other && git add -A', clean],
228
- ['cd ~/projects/other && git checkout -- tools/bank/kept.json', isDirty],
229
- ['git checkout -- design/gen_panel.py', isDirty], ['git stash', isDirty], ['git restore .', isDirty], ['git reset --hard HEAD~1', isDirty],
230
- ['git clean -fd', isDirty], ['GUARD_OK=1 git checkout -- x.h', isDirty], ['GUARD_OK=1 git restore build/Testing/Temporary/LastTest.log', isDirty],
231
- ['GUARD_OK=1 git restore --staged --worktree a.md', isDirty], ['git restore --staged --worktree a.md', isDirty],
232
- ['git push', clean], ['git push origin main', clean], [`${push} --force`, clean],
233
- ['git push https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/other-org/app.git main', clean],
234
- ['gh repo create HouseOwner/app --private', clean],
235
- ['source ~/.env && GH_TOKEN=$PERSONAL_GITHUB_TOKEN gh repo create app --private', clean],
236
- ['git remote add origin https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/HouseOwner/app.git', clean],
237
- [`git push https://x-access-token:ghp_${'a'.repeat(30)}@github.com/HouseOwner/app.git main`, clean],
238
- ['git branch -D step/C1-x', clean], ['git push origin --delete step/C1-x', clean], ['git rebase main', clean], ['git commit --amend -m x', clean],
239
- ["git commit -q -m 'bench' --no-verify", clean], ['GUARD_OK=1 git commit --no-verify -m x -- a.h', clean],
240
- ['git push --no-verify https://x@github.com/HouseOwner/app.git main', clean],
241
- ['git push -u origin work/x', remote('git@github.com:other-org/app.git')], ['git push origin work/x', remote('https://github.com/HouseOwner/app.git')],
242
- ['git push git@github.com:other-org/app.git work/x', clean], ['git push -f origin work/x', remote('git@github.com:HouseOwner/app.git')],
243
- ];
244
- const mustPass = [
245
- ['git add build.md src/Row.h', isDirty], ["git commit -m 'C1: row clock' -- build.md src/Row.h", isDirty],
246
- ['cd ~/projects/other && git add tools/bank/kept.json', isDirty], ['git status', isDirty], ['git checkout -- x.h', clean],
247
- ['git restore --staged x.h', isDirty], ['python3 tools/safe_restore.py x.h', isDirty], ['git stash list', isDirty],
248
- ['git checkout -b c2-filter', isDirty], [push, isDirty],
249
- ['source ~/.env && GH_TOKEN=$PERSONAL_GITHUB_TOKEN gh repo create HouseOwner/app --private', clean],
250
- ['cmake --build build --target Synth_Standalone -j8', isDirty], ['git log --oneline -6 | sed -n 1,3p', isDirty],
251
- ["git branch --list 'step/U5*' | tr -d ' *'", isDirty], ["grep -n 'def push' tools/step.py", isDirty], ["echo 'git push origin main'", isDirty],
252
- ["python3 - <<'EOF'\nprint('git add -A')\nEOF", isDirty],
253
- ['git push -u origin work/x', remote('git@github.com:HouseOwner/app.git')], ['git push origin work/x', remote('git@github-house:HouseOwner/app.git')],
254
- ['git push git@github.com:HouseOwner/app.git work/x', clean], ['git push ssh://git@github.com/HouseOwner/app.git work/x', clean],
255
- ];
256
- const OWNED = { owner: 'HouseOwner' };
257
- for (const [cmd, env] of mustBlock) if (house(cmd, '.', env, OWNED) === null) bad.push(`NOT BLOCKED: ${cmd}`);
258
- for (const [cmd, env] of mustPass) { const r = house(cmd, '.', env, OWNED); if (r !== null) bad.push(`WRONGLY BLOCKED: ${cmd} -> ${r}`); }
259
- const TRUNK = { refuseTrunk: true };
260
- for (const cmd of ["git commit -m 'x' -- a.h", "GUARD_OK=1 git commit -m 'x' -- a.h"]) if (house(cmd, '.', on('main'), TRUNK) === null) bad.push(`NOT BLOCKED on trunk: ${cmd}`);
261
- if (house("git commit -m 'x' -- a.h", '.', on('main')) !== null) bad.push('a commit on main with the trunk refusal off (the default)');
262
- if (house("git commit -m 'first' -- a.h", '.', { ...on('main'), hasHead: () => false }, TRUNK) !== null) bad.push('the first commit');
263
- const byDir = on((c) => (c.endsWith('wt') ? 'work/x' : 'main'));
264
- if (house('cd /r/wt && git commit --no-edit', '/r', byDir, TRUNK) !== null) bad.push('a commit after cd into a worktree on a branch');
265
- if (house('git -C /r/wt commit --no-edit', '/r', byDir, TRUNK) !== null) bad.push('git -C a worktree on a branch');
266
- if (house('cd /r && git commit --no-edit', '/r/wt', byDir, TRUNK) === null) bad.push('cd onto the trunk then commit');
267
- // WB-107: an agent's isolated worktree cannot push (its harness refuses), so push-then-commit is the lead's there; elsewhere it holds
268
- const ahead = { ...on('work/x'), unpushed: () => ({ branch: 'work/x', url: 'https://github.com/HouseOwner/app.git', commits: [{ sha: 'abc1234', subject: 'x' }] }) };
269
- if (house("git commit -m x -- a.h", '/r/.claude/worktrees/agent-a1b2', ahead) !== null) bad.push('a commit in an agent worktree with its branch ahead of GitHub (WB-107)');
270
- if (house("git commit -m x -- a.h", '/r/.claude/worktrees/night', ahead) === null) bad.push('NOT BLOCKED: a commit in a lead worktree with its branch ahead of GitHub');
271
- if (house("git commit -m x -- a.h", '/r', ahead) === null) bad.push('NOT BLOCKED: a commit in the main checkout with its branch ahead of GitHub');
272
- const other = { owner: 'Acme', tokenVar: 'ACME_TOKEN', trunks: ['trunk'], safeRestore: 'python3 tools/safe_restore.py <paths>', branchStart: 'python3 tools/step.py start <STEP> "task"', refuseTrunk: true };
273
- for (const [cmd, env, want] of [
274
- ['git push https://x-access-token:$ACME_TOKEN@github.com/Acme/app.git b', clean, null], [push, clean, 'Acme'],
275
- ['git commit -m x -- a', on('trunk'), 'tools/step.py'], ['git commit -m x -- a', on('main'), null], ['git stash', isDirty, 'safe_restore.py'],
276
- ]) {
277
- const r = house(cmd, '.', env, other);
278
- if ((r === null) !== (want === null) || (want && !r.includes(want))) bad.push(`with a project's own values: ${cmd} -> ${r}`);
279
- }
280
- // the pushed laws (KWB-26), on a real repo and a bare "GitHub" beside it
281
- const pushed = (await import('./lib/git-guard-selftest.mjs')).pushedCases(fileURLToPath(import.meta.url));
282
- for (const [label, got, want] of pushed) if (got !== want) bad.push(`pushed law: ${label} (got ${got}, wanted ${want})`);
283
- for (const b of bad) console.error(`git-guard selftest FAILED: ${b}`);
284
- if (!bad.length) console.log(`git-guard selftest: OK (${tokenCases.length + 1} token cases, ${mustBlock.length + 7} house refusals, ${mustPass.length + 4} passes, ${pushed.length} pushed-law cases)`);
285
- return bad.length ? 1 : 0;
286
- }
287
-
288
201
  export const hook = main; // run-hooks.mjs calls it with the event's text (KWB-24)
289
- if (isMain(import.meta.url)) process.exit(process.argv.includes('--selftest') ? await selftest() : await asHook('git-guard', main));
202
+ if (isMain(import.meta.url)) process.exit(process.argv.includes('--selftest') ? await (await import('./lib/git-guard-selftest.mjs')).selftest(fileURLToPath(import.meta.url), { problem, house }) : await asHook('git-guard', main));
@@ -7,6 +7,8 @@
7
7
  // "mode": "forbid" (default) | "require" | "needs", "needs": ["<regex>", ...] }
8
8
  // "code": true blanks comments and strings before matching; "strings": true blanks comments only (a check that reads string literals);
9
9
  // "prose": true blanks what a reader does not read as prose in markdown: code, URLs, frontmatter (a check on public text)
10
+ // "language": "rust" blanks with that row of the language table instead, for a language C's blanker misreads (a Rust lifetime reads as a char
11
+ // literal there, KWB-75); "notInTests": true also leaves out what that language marks as a test, for a test that lives in the file it tests
10
12
  // mode "needs": when the pattern appears, every regex in "needs" must appear as well (an entry file that listens also
11
13
  // registers shutdown; a gateway that creates a MongoClient also caches and names it). A write is refused only for a need
12
14
  // it newly leaves missing.
@@ -18,6 +20,7 @@ import { writesOf } from './lib/writes.mjs';
18
20
  import { blankCode } from './lib/cpp-scan.mjs';
19
21
  import { isContent } from './lib/content.mjs';
20
22
  import { blankProse } from './lib/prose.mjs';
23
+ import { languageNamed } from './lib/languages.mjs';
21
24
  import { matchesAny } from './lib/glob.mjs';
22
25
  import { conceptGlobs, readLayout, resolveAliases } from './lib/layout.mjs';
23
26
  import { insideProject, checkoutRoot, claudeRoot } from './lib/project.mjs';
@@ -62,7 +65,12 @@ export function loadChecks(root = ROOT) {
62
65
 
63
66
  // What a check reads: one blanking per kind. strings: comments blanked, strings kept.
64
67
  const BLANKING = { prose: blankProse, code: blankCode, strings: (text) => blankCode(text, true) };
65
- const subjectOf = (c, text) => { const kind = Object.keys(BLANKING).find((k) => c[k]); return kind ? BLANKING[kind](text) : text; };
68
+ function subjectOf(c, text) {
69
+ const row = c.language && languageNamed(c.language);
70
+ if (row) return c.notInTests && row.withoutTests ? row.withoutTests(row.blank(text)) : row.blank(text);
71
+ const kind = Object.keys(BLANKING).find((k) => c[k]);
72
+ return kind ? BLANKING[kind](text) : text;
73
+ }
66
74
 
67
75
  // Every match: its line number and that line's text.
68
76
  function linesOf(check, text) {
@@ -40,7 +40,9 @@ export function statementsOf(text, lang) {
40
40
  const words = tokens.map((t) => t.t);
41
41
  const isCode = lang.commands ? words.length >= 2 : words.some((t) => t === '(' || t === '=');
42
42
  if (tokens.length >= (lang.commands ? 2 : 3) && words.some(isName) && isCode) {
43
- out.push({ tokens, shape: shapeOf(words, lang), line: lineAt(starts, tokens[0].at), end: lineAt(starts, tokens.at(-1).at) });
43
+ // its line is where its first word is: the `),` a lambda's call leaves behind is the end of the last line, not this statement's start (KF-27)
44
+ const first = tokens.find((t) => /^[A-Za-z_\d"'*&]/.test(t.t)) ?? tokens[0];
45
+ out.push({ tokens, shape: shapeOf(words, lang), line: lineAt(starts, first.at), end: lineAt(starts, tokens.at(-1).at) });
44
46
  }
45
47
  tokens = [];
46
48
  };
@@ -50,9 +52,10 @@ export function statementsOf(text, lang) {
50
52
  if (lang.lines && depth === 0 && tokens.length && code.slice(last, m.index).includes('\n') && !CONTINUES.test(tokens.at(-1).t) && t !== '.') close();
51
53
  tokens.push({ t, at: m.index, to: m.index + t.length });
52
54
  last = m.index + t.length;
53
- if (t === '(' || t === '[') depth += 1;
54
- else if (t === ')' || t === ']') depth = Math.max(depth - 1, 0);
55
- if ((t === ';' && depth === 0) || t === '{' || t === '}') close();
55
+ const dataBrace = lang.braces === false && (t === '{' || t === '}'); // a Python set or dict is one value, not a block (KF-14)
56
+ if (t === '(' || t === '[' || (dataBrace && t === '{')) depth += 1;
57
+ else if (t === ')' || t === ']' || (dataBrace && t === '}')) depth = Math.max(depth - 1, 0);
58
+ if ((t === ';' && depth === 0) || (!dataBrace && (t === '{' || t === '}'))) close();
56
59
  }
57
60
  close();
58
61
  return out;
@@ -102,12 +105,14 @@ function runShapes(list, lang) {
102
105
  // One function called again and again on other arguments, in a row.
103
106
  function rowShapes(list, lang) {
104
107
  const out = [];
105
- const callee = (s) => ((lang.commands || s.tokens[1]?.t === '(') && isName(s.tokens[0].t) ? `${s.tokens[0].t} ${s.shape}` : null);
108
+ // A language whose calls are methods says so: `app.route (...)` six times is a table too, on one receiver.
109
+ const method = (s) => lang.methodRows && ['.', '::'].includes(s.tokens[1]?.t) && s.tokens[3]?.t === '(' && isName(s.tokens[2].t);
110
+ const callee = (s) => ((lang.commands || s.tokens[1]?.t === '(' || method(s)) && isName(s.tokens[0].t) ? `${s.tokens.slice(0, method(s) ? 3 : 1).map((t) => t.t).join('')} ${s.shape}` : null);
106
111
  for (let i = 0; i < list.length;) {
107
112
  const key = callee(list[i]);
108
113
  let j = i + 1;
109
114
  while (key && j < list.length && callee(list[j]) === key) j += 1;
110
- if (j - i >= MIN_ROWS) out.push({ id: `t:${j - i}:${key}`, kind: 'rows', line: list[i].line, end: list[j - 1].end, what: `${list[i].tokens[0].t} (...)`, nodes: j - i });
115
+ if (j - i >= MIN_ROWS) out.push({ id: `t:${j - i}:${key}`, kind: 'rows', line: list[i].line, end: list[j - 1].end, what: `${key.split(' ')[0]} (...)`, nodes: j - i });
111
116
  i = j;
112
117
  }
113
118
  return out;
@@ -0,0 +1,47 @@
1
+ // crash-policy: what a call does when a hook crashed on it (KF-18). Only exit 2 blocks, so a crash used to let the call through in
2
+ // silence, and doctor said so hours later. Now a crash on a tool call refuses that call once and says why, at the time; the same call
3
+ // again goes through, logged, because a broken hook must never stop the write that fixes it (its own bad config, say). An event with
4
+ // nothing to refuse (Stop, a prompt) is told with a systemMessage. A crash is never a verdict on the work, and has its own log.
5
+ // crashVerdict(root, ev, judged) -> { code?, stderr?, stdout? } judged: the runner's [{ hook, code, reason }]
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+
10
+ const HELD = join('.claude', 'state', 'crash-held.json');
11
+ const HOLD_MS = 60 * 60 * 1000;
12
+ const ANSWERS = new Set([0, 2]);
13
+ const TOOL_EVENTS = new Set(['PreToolUse', 'PostToolUse', undefined]);
14
+
15
+ const firstLine = (reason) => (reason ?? '').split('\n')[0].replace(/^run-hooks: \S+ failed: /, '') || 'no message';
16
+ const say = (c) => `${c.hook} crashed, so this call was not judged by it: ${firstLine(c.reason)}`;
17
+ const keyOf = (ev, hook) => createHash('sha1').update(JSON.stringify([hook, ev.hook_event_name, ev.tool_name, ev.tool_input])).digest('hex').slice(0, 16);
18
+ const notice = (message) => JSON.stringify({ systemMessage: message });
19
+
20
+ function readHeld(root) {
21
+ try { return JSON.parse(readFileSync(join(root, HELD), 'utf8')); } catch { return {}; } // not a refusal: no list yet
22
+ }
23
+
24
+ // Records the keys; false when they cannot be kept, since a call refused with no memory of it could never go through.
25
+ function hold(root, held, keys) {
26
+ if (!existsSync(join(root, '.claude'))) return false;
27
+ const now = Date.now();
28
+ const kept = Object.fromEntries(Object.entries(held).filter(([, t]) => now - t < HOLD_MS));
29
+ for (const k of keys) kept[k] = now;
30
+ mkdirSync(join(root, '.claude', 'state'), { recursive: true });
31
+ writeFileSync(join(root, HELD), JSON.stringify(kept));
32
+ return true;
33
+ }
34
+
35
+ export function crashVerdict(root, ev, judged) {
36
+ const crashed = judged.filter((h) => !ANSWERS.has(h.code));
37
+ if (!crashed.length) return {};
38
+ const said = crashed.map(say).join('\n');
39
+ const after = `Clear it once fixed: \`logs clear ${crashed[0].hook} fixed|superseded "<why>"\`.`;
40
+ if (!ev.tool_name || !TOOL_EVENTS.has(ev.hook_event_name)) return { stdout: notice(`${said}\n${after}`) };
41
+ const held = readHeld(root);
42
+ const keys = crashed.map((c) => keyOf(ev, c.hook));
43
+ if (keys.every((k) => held[k])) return { stdout: notice(`${said}\nThe call went through unjudged, and the crash is logged (\`logs crashes\`). ${after}`) };
44
+ if (!hold(root, held, keys)) return { stdout: notice(said) };
45
+ const ran = ev.hook_event_name === 'PostToolUse' ? 'The call has already run.' : 'The call has not run.';
46
+ return { code: 2, stderr: `${said}\n${ran} This is a fault in the hook, not a verdict on your work. The same call again goes through unjudged, and is logged. Report the fault. ${after}\n` };
47
+ }
@@ -176,6 +176,11 @@ function chains(pairs, lines) {
176
176
  return out;
177
177
  }
178
178
 
179
+ // using lines, includes, imports and one-line constants name things: two files that open alike hold no logic to extract, and writing the names out is worse (KF-32).
180
+ const DECLARATION = /^\s*(?:$|using\b|#\s*include\b|import\b|from\s+\S+\s+import\b|namespace\b|[{}]\s*$|(?:static\s+|inline\s+)*(?:constexpr|const)\b[^(){}]*=[^(){}]*;\s*$)/;
181
+ const MIN_LOGIC_LINES = 3;
182
+ const holdsLogic = (text, { start, end }) => text.split('\n').slice(start - 1, end).filter((l) => !DECLARATION.test(l)).length >= MIN_LOGIC_LINES;
183
+
179
184
  export function judgeWrites(root, writes, { layout = {}, shape = null, ignore = [] } = {}) {
180
185
  const index = loadIndex(root, { layout, shape });
181
186
  const maybe = clonesIn(index, writes, { ignore });
@@ -192,7 +197,7 @@ export function judgeWrites(root, writes, { layout = {}, shape = null, ignore =
192
197
  const paths = [...new Set(maybe.map((c) => c.other.file))].filter((f) => !written.has(real(f)));
193
198
  const r = scan(root, { extra, ignore, layout, shape, paths });
194
199
  if (r.error) return { clones: [], error: r.error, index };
195
- return { clones: writes.flatMap((w) => (w.ranges ?? [w.range]).flatMap((range) => clonesOf(r.clones, w.path, range))), index };
200
+ return { clones: writes.flatMap((w) => (w.ranges ?? [w.range]).flatMap((range) => clonesOf(r.clones, w.path, range)).filter((c) => holdsLogic(w.after, c.at))), index };
196
201
  } finally {
197
202
  rmSync(tmp, { recursive: true, force: true });
198
203
  }
@@ -20,8 +20,8 @@ import { fileURLToPath } from 'node:url';
20
20
  import { matchesAny } from './glob.mjs';
21
21
 
22
22
  export const LIMITS = { tokens: 50, lines: 6, gap: 2 };
23
- export const SOURCE = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|inl|mm|m|py|js|mjs|cjs|jsx|ts|tsx)$/i;
24
- const FORMATS = 'c,c-header,cpp,cpp-header,objectivec,python,javascript,jsx,typescript,tsx';
23
+ export const SOURCE = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|inl|mm|m|py|js|mjs|cjs|jsx|ts|tsx|rs)$/i;
24
+ const FORMATS = 'c,c-header,cpp,cpp-header,objectivec,python,javascript,jsx,typescript,tsx,rust';
25
25
  // A header and a source are one language: a function copied from a .cpp into a .h is a copy (jscpd compares within a format otherwise).
26
26
  const CROSS = 'c,c-header,cpp,cpp-header,objectivec;javascript,jsx,typescript,tsx';
27
27
  // Generated files, resources and vendored or built code are never judged (and never a match).
@@ -1,6 +1,6 @@
1
1
  // The detail behind hooks.log. Every call is one line of .claude/logs/events.jsonl: who, where, what was attempted, and each hook's time, exit code and reason. Every refusal
2
- // and crash is also a line of .claude/logs/refusals.jsonl, with an id, the branch and commit, the kit's version and the hash of the hook that decided, so it can be judged
3
- // later: `claude-packs logs mark <id> right|wrong "<why>"`. Bug logs only record the refusals somebody complained about; this records them all. Secrets are masked. File
2
+ // is also a line of .claude/logs/refusals.jsonl, with an id, the branch and commit, the kit's version and the hash of the hook that decided, so it can be judged
3
+ // later: `claude-packs logs mark <id> right|wrong "<why>"`. A crash refused nothing and is no verdict on the work, so it has its own log, crashes.jsonl (KF-40). Bug logs only record the refusals somebody complained about; this records them all. Secrets are masked. File
4
4
  // contents are not kept, only their size. Logs rotate and are never cleared.
5
5
  // logCall(root, text, [{ hook, ms, code, reason, out }], append, total)
6
6
  import { createHash } from 'node:crypto';
@@ -10,6 +10,7 @@ import { maskSecrets } from './secret-patterns.mjs';
10
10
 
11
11
  export const EVENTS = join('.claude', 'logs', 'events.jsonl');
12
12
  export const REFUSALS = join('.claude', 'logs', 'refusals.jsonl');
13
+ export const CRASHES = join('.claude', 'logs', 'crashes.jsonl');
13
14
  const KEEP = { command: 4000, prompt: 1000, reason: 4000, out: 500 };
14
15
 
15
16
  const cut = (text, n) => { const t = maskSecrets(text ?? ''); return t.length > n ? `${t.slice(0, n)}... [${t.length} chars]` : t; };
@@ -61,7 +62,7 @@ export function logCall(root, text, hooks, append, total) {
61
62
  append(root, EVENTS, `${JSON.stringify({ t, ...where, ...what, ...(total ? { ms: Math.round(total) } : {}), hooks: judged })}\n`);
62
63
  for (const h of judged.filter((x) => x.code !== 0)) {
63
64
  const id = createHash('sha1').update(`${t}${h.hook}${h.reason ?? ''}`).digest('hex').slice(0, 8);
64
- append(root, REFUSALS, `${JSON.stringify({ id, t, hook: h.hook, code: h.code, reason: h.reason, ...where, ...what, ...headOf(ev.cwd ?? root), ...lawOf(root, h.hook) })}\n`);
65
+ append(root, h.code === 2 ? REFUSALS : CRASHES, `${JSON.stringify({ id, t, hook: h.hook, code: h.code, reason: h.reason, ...where, ...what, ...headOf(ev.cwd ?? root), ...lawOf(root, h.hook) })}\n`);
65
66
  }
66
67
  } catch { /* the log never stops a hook */ }
67
68
  }
@@ -14,7 +14,8 @@ export function gitAdds(command, cwd) {
14
14
  if (w[at] === '-C') dir = resolve(dir, w[at + 1] ?? '.');
15
15
  at += ['-C', '-c'].includes(w[at]) ? 2 : 1;
16
16
  }
17
- if (w[at] === 'add') out.push({ dir, args: w.slice(at + 1) });
17
+ const args = w.slice(at + 1);
18
+ if (w[at] === 'add' && !args.some((a) => a === '-n' || a === '--dry-run')) out.push({ dir, args }); // a dry run stages nothing (KF-10)
18
19
  }
19
20
  return out;
20
21
  }
@@ -0,0 +1,44 @@
1
+ // git-auth: how a push and a `gh` call prove who they act as, for git-guard's house laws (KF-12). The laws used to assume one machine: a
2
+ // token variable, and `GH_TOKEN=$X` passed as text even with X unset, which proves nothing about the account. A project either
3
+ // names a token (ghAuth "token") or uses this machine's own git and gh login (ghAuth "cli"), and a refusal says which login that is.
4
+ // NEW_INSTALL_GIT what a new install's guards.git holds (KF-8); lib/layout.mjs writes it, "enable": ["guards/git-house"] applies it
5
+ // pushLaw(p, target, c) a refusal for a push, or null. p: the words of the push; target: its URL; c: guards.git
6
+ // ghLaw(cmd, p, c, login) a refusal for a `gh` call, or null. cmd: the whole command, since a `source` before it may set the token
7
+ import { execFileSync } from 'node:child_process';
8
+ import { sshOwner } from './pushed.mjs';
9
+
10
+ export const NEW_INSTALL_GIT = { house: true, ghAuth: 'cli', trunkNeedsYes: true };
11
+
12
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13
+ const LOADS_ENV = /(?:^|[;&|(\s])(?:source|\.)\s+\S/;
14
+
15
+ export function pushLaw(p, target, c) {
16
+ const { owner, tokenVar: tok } = c;
17
+ const cli = c.ghAuth === 'cli';
18
+ const ssh = !owner || sshOwner(target) === owner; // over SSH the key picks the account; the URL names the repo (KWB-39). No owner: no repo to hold it to
19
+ const named = new RegExp(`github\\.com/${esc(owner ?? '')}/[\\w.-]+`);
20
+ if (owner && !ssh && !named.test(p) && !(cli && named.test(target ?? ''))) {
21
+ return `A push must name the repo: git push https://x-access-token:$${tok}@github.com/${owner}/<repo>.git <branch>, or a remote whose URL is git@github.com:${owner}/<repo>.git (SSH). ${c.accountNote}`;
22
+ }
23
+ if (!cli && !ssh && !p.includes(`$${tok}`) && !p.includes(`\${${tok}}`)) return `A push names the token as $${tok}, never a pasted one.`;
24
+ return null;
25
+ }
26
+
27
+ function loggedInAs() {
28
+ try {
29
+ return execFileSync('gh', ['config', 'get', 'user', '-h', 'github.com'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 3000 }).trim();
30
+ } catch {
31
+ return ''; // no gh here, or no login: the refusal says less, and still refuses
32
+ }
33
+ }
34
+
35
+ export function ghLaw(cmd, p, c, login = loggedInAs) {
36
+ if (c.ghAuth === 'cli' || !/(^|\s)gh\s/.test(p)) return null;
37
+ const tok = c.tokenVar;
38
+ const named = cmd.includes(`GH_TOKEN=$${tok}`);
39
+ if (named && (process.env[tok] || LOADS_ENV.test(cmd))) return null;
40
+ const who = login();
41
+ const wayOut = `If this machine's own login is the right account${who ? ` (gh is logged in as ${who})` : ''}, set "ghAuth": "cli" in guards.git in .claude/layout.json, and no call names a token.`;
42
+ if (named) return `$${tok} is not set here, so GH_TOKEN would be empty and gh would act as whichever account is logged in. ${wayOut}`;
43
+ return `Every \`gh\` names the token: source ~/.env && GH_TOKEN=$${tok} gh ... ${c.accountNote} ${wayOut}`;
44
+ }
@@ -97,3 +97,96 @@ export function pushedCases(hookFile) {
97
97
  rmSync(top, { recursive: true, force: true });
98
98
  return cases;
99
99
  }
100
+ // The token law and the house laws, read from the hook's own exports.
101
+ export async function selftest(hookFile, { problem, house }) {
102
+ const bad = [];
103
+ // the token law (Sub37 WB-3)
104
+ const tokenCases = [
105
+ ['git push -u https://x-access-token:abc@github.com/a/b.git work/x', true],
106
+ ['git push --set-upstream https://me:$TOKEN@github.com/a/b.git main', true],
107
+ ['cd ../b && git -C ../b push origin x -u https://u:p@github.com/a/b.git', true],
108
+ ['git remote set-url origin https://ghp_abc@github.com/a/b.git', true],
109
+ ['git remote add o https://u:${T}@github.com/a/b.git', true],
110
+ ['git clone https://x-access-token:abc@github.com/a/b.git', true],
111
+ ['git config remote.origin.url https://u:t@github.com/a/b.git', true],
112
+ ['git push https://x-access-token:$T@github.com/a/b.git work/x', false],
113
+ ['git push -u origin work/x', false],
114
+ ['git remote set-url origin https://github.com/a/b.git', false],
115
+ ['git remote add o git@github-house:HouseOwner/b.git', false],
116
+ ['git clone https://me@github.com/a/b.git', false],
117
+ ['echo https://u:p@x.com/y', false],
118
+ ];
119
+ for (const [cmd, block] of tokenCases) if ((problem(cmd) !== null) !== block) bad.push(`token law: ${cmd}`);
120
+ if (problem('git push -u https://u:SECRET@h/x.git b')?.includes('SECRET')) bad.push('the refusal prints the token');
121
+
122
+ // the house laws
123
+ const isDirty = { dirty: () => [' M design/gen_panel.py'], branch: () => 'step/C1-the-ribbon', hasHead: () => true };
124
+ const clean = { ...isDirty, dirty: () => [] };
125
+ const on = (branch, env = clean) => ({ ...env, branch: typeof branch === 'function' ? branch : () => branch });
126
+ const push = 'git push https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/HouseOwner/app.git main';
127
+ const remote = (url) => ({ ...clean, remoteUrl: () => url }); // a remote whose URL is url (KWB-39: SSH names the repo)
128
+ const mustBlock = [
129
+ ['git add -A', clean], ['git add .', clean], ["git commit -am 'x'", clean], ['git add ~/firmware/korg/m1.bin', clean],
130
+ ['git -C /Users/someone/projects/other add -A', clean], ['cd ~/projects/other && git add -A', clean],
131
+ ['cd ~/projects/other && git checkout -- tools/bank/kept.json', isDirty],
132
+ ['git checkout -- design/gen_panel.py', isDirty], ['git stash', isDirty], ['git restore .', isDirty], ['git reset --hard HEAD~1', isDirty],
133
+ ['git clean -fd', isDirty], ['GUARD_OK=1 git checkout -- x.h', isDirty], ['GUARD_OK=1 git restore build/Testing/Temporary/LastTest.log', isDirty],
134
+ ['GUARD_OK=1 git restore --staged --worktree a.md', isDirty], ['git restore --staged --worktree a.md', isDirty],
135
+ ['git push', clean], ['git push origin main', clean], [`${push} --force`, clean],
136
+ ['git push https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/other-org/app.git main', clean],
137
+ ['gh repo create HouseOwner/app --private', clean],
138
+ ['source ~/.env && GH_TOKEN=$PERSONAL_GITHUB_TOKEN gh repo create app --private', clean],
139
+ ['git remote add origin https://x-access-token:$PERSONAL_GITHUB_TOKEN@github.com/HouseOwner/app.git', clean],
140
+ [`git push https://x-access-token:ghp_${'a'.repeat(30)}@github.com/HouseOwner/app.git main`, clean],
141
+ ['git branch -D step/C1-x', clean], ['git push origin --delete step/C1-x', clean], ['git rebase main', clean], ['git commit --amend -m x', clean],
142
+ ["git commit -q -m 'bench' --no-verify", clean], ['GUARD_OK=1 git commit --no-verify -m x -- a.h', clean],
143
+ ['git push --no-verify https://x@github.com/HouseOwner/app.git main', clean],
144
+ ['git push -u origin work/x', remote('git@github.com:other-org/app.git')], ['git push origin work/x', remote('https://github.com/HouseOwner/app.git')],
145
+ ['git push git@github.com:other-org/app.git work/x', clean], ['git push -f origin work/x', remote('git@github.com:HouseOwner/app.git')],
146
+ ['git checkout --theirs a.txt', isDirty],
147
+ ];
148
+ const mustPass = [
149
+ ['git add build.md src/Row.h', isDirty], ["git commit -m 'C1: row clock' -- build.md src/Row.h", isDirty],
150
+ ['cd ~/projects/other && git add tools/bank/kept.json', isDirty], ['git status', isDirty], ['git checkout -- x.h', clean],
151
+ ['git restore --staged x.h', isDirty], ['python3 tools/safe_restore.py x.h', isDirty], ['git stash list', isDirty],
152
+ ['git checkout -b c2-filter', isDirty], [push, isDirty],
153
+ ['source ~/.env && GH_TOKEN=$PERSONAL_GITHUB_TOKEN gh repo create HouseOwner/app --private', clean],
154
+ ['cmake --build build --target Synth_Standalone -j8', isDirty], ['git log --oneline -6 | sed -n 1,3p', isDirty],
155
+ ["git branch --list 'step/U5*' | tr -d ' *'", isDirty], ["grep -n 'def push' tools/step.py", isDirty], ["echo 'git push origin main'", isDirty],
156
+ ["python3 - <<'EOF'\nprint('git add -A')\nEOF", isDirty],
157
+ ['git push -u origin work/x', remote('git@github.com:HouseOwner/app.git')], ['git push origin work/x', remote('git@github-house:HouseOwner/app.git')],
158
+ ['git push git@github.com:HouseOwner/app.git work/x', clean], ['git push ssh://git@github.com/HouseOwner/app.git work/x', clean],
159
+ ['git add -n .', clean], ['git add --dry-run -A', clean], ['git checkout --theirs a.txt', { ...isDirty, merging: () => true }],
160
+ ];
161
+ const OWNED = { owner: 'HouseOwner' };
162
+ for (const [cmd, env] of mustBlock) if (house(cmd, '.', env, OWNED) === null) bad.push(`NOT BLOCKED: ${cmd}`);
163
+ for (const [cmd, env] of mustPass) { const r = house(cmd, '.', env, OWNED); if (r !== null) bad.push(`WRONGLY BLOCKED: ${cmd} -> ${r}`); }
164
+ const TRUNK = { refuseTrunk: true };
165
+ for (const cmd of ["git commit -m 'x' -- a.h", "GUARD_OK=1 git commit -m 'x' -- a.h"]) if (house(cmd, '.', on('main'), TRUNK) === null) bad.push(`NOT BLOCKED on trunk: ${cmd}`);
166
+ if (house('git rev-parse HEAD^{commit}', '.', on('main'), TRUNK) !== null) bad.push('a word in an argument read as a commit on the trunk');
167
+ if (house("git commit -m 'x' -- a.h", '.', on('main')) !== null) bad.push('a commit on main with the trunk refusal off (the default)');
168
+ if (house("git commit -m 'first' -- a.h", '.', { ...on('main'), hasHead: () => false }, TRUNK) !== null) bad.push('the first commit');
169
+ const byDir = on((c) => (c.endsWith('wt') ? 'work/x' : 'main'));
170
+ if (house('cd /r/wt && git commit --no-edit', '/r', byDir, TRUNK) !== null) bad.push('a commit after cd into a worktree on a branch');
171
+ if (house('git -C /r/wt commit --no-edit', '/r', byDir, TRUNK) !== null) bad.push('git -C a worktree on a branch');
172
+ if (house('cd /r && git commit --no-edit', '/r/wt', byDir, TRUNK) === null) bad.push('cd onto the trunk then commit');
173
+ // WB-107: an agent's isolated worktree cannot push (its harness refuses), so push-then-commit is the lead's there; elsewhere it holds
174
+ const ahead = { ...on('work/x'), unpushed: () => ({ branch: 'work/x', url: 'https://github.com/HouseOwner/app.git', commits: [{ sha: 'abc1234', subject: 'x' }] }) };
175
+ if (house("git commit -m x -- a.h", '/r/.claude/worktrees/agent-a1b2', ahead) !== null) bad.push('a commit in an agent worktree with its branch ahead of GitHub (WB-107)');
176
+ if (house("git commit -m x -- a.h", '/r/.claude/worktrees/night', ahead) === null) bad.push('NOT BLOCKED: a commit in a lead worktree with its branch ahead of GitHub');
177
+ if (house("git commit -m x -- a.h", '/r', ahead) === null) bad.push('NOT BLOCKED: a commit in the main checkout with its branch ahead of GitHub');
178
+ const other = { owner: 'Acme', tokenVar: 'ACME_TOKEN', trunks: ['trunk'], safeRestore: 'python3 tools/safe_restore.py <paths>', branchStart: 'python3 tools/step.py start <STEP> "task"', refuseTrunk: true };
179
+ for (const [cmd, env, want] of [
180
+ ['git push https://x-access-token:$ACME_TOKEN@github.com/Acme/app.git b', clean, null], [push, clean, 'Acme'],
181
+ ['git commit -m x -- a', on('trunk'), 'tools/step.py'], ['git commit -m x -- a', on('main'), null], ['git stash', isDirty, 'safe_restore.py'],
182
+ ]) {
183
+ const r = house(cmd, '.', env, other);
184
+ if ((r === null) !== (want === null) || (want && !r.includes(want))) bad.push(`with a project's own values: ${cmd} -> ${r}`);
185
+ }
186
+ // the pushed laws (KWB-26), on a real repo and a bare "GitHub" beside it
187
+ const pushed = pushedCases(hookFile);
188
+ for (const [label, got, want] of pushed) if (got !== want) bad.push(`pushed law: ${label} (got ${got}, wanted ${want})`);
189
+ for (const b of bad) console.error(`git-guard selftest FAILED: ${b}`);
190
+ if (!bad.length) console.log(`git-guard selftest: OK (${tokenCases.length + 1} token cases, ${mustBlock.length + 7} house refusals, ${mustPass.length + 4} passes, ${pushed.length} pushed-law cases)`);
191
+ return bad.length ? 1 : 0;
192
+ }
@@ -2,6 +2,7 @@
2
2
  // to blank a file's comments and strings, where a statement ends, what is not code, and how a function is defined. A new language is one row.
3
3
  // languageOf(ext) -> the row, or null ALL_CODE: every extension the table reads
4
4
  import { blankCode } from './cpp-scan.mjs';
5
+ import { blankRust, ownDefinitions, withoutTests } from './rust-scan.mjs';
5
6
 
6
7
  const spaces = (s) => s.replace(/[^\n]/g, ' ');
7
8
 
@@ -47,12 +48,13 @@ export const LANGUAGES = [
47
48
  define: /^(?:[A-Za-z_][\w:<>,*& ]*[\s*&])?([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:const\s*)?(?:noexcept\s*)?\{/gm },
48
49
  { name: 'js', exts: ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.mts', '.cts'], blank: blankCode, lines: true, notCode: /^[ \t]*import\b[^;]*;?$/gm, minTokens: 60,
49
50
  define: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function\*?\s+([A-Za-z_$][\w$]*)|(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>|class\s+([A-Za-z_$][\w$]*))/gm },
50
- { name: 'python', exts: ['.py', '.pyi'], blank: blanker({ line: ['#'], raw: ['"""', "'''"] }), lines: true, notCode: /^[ \t]*(?:from\s+\S+\s+)?import\b.*$/gm, minTokens: 45,
51
+ { name: 'python', exts: ['.py', '.pyi'], blank: blanker({ line: ['#'], raw: ['"""', "'''"] }), lines: true, braces: false, notCode: /^[ \t]*(?:from\s+\S+\s+)?import\b.*$/gm, minTokens: 45,
51
52
  define: /^(?:async\s+)?(?:def|class)\s+([A-Za-z_]\w*)/gm },
52
53
  { name: 'shell', exts: ['.sh', '.bash', '.zsh', '.ksh'], blank: blanker({ line: ['#'], hashWord: true, heredoc: true }), lines: true, commands: true, minTokens: 30,
53
54
  token: /"\s*"|'\s*'|[^\s;|&()<>{}]+|[;|&()<>{}]/g, notCode: /^[ \t]*(?:\.|source|set|shopt|trap|cd)\s.*$/gm, define: /^(?:function\s+)?([A-Za-z_][\w-]*)\s*(?:\(\s*\))?\s*\{/gm },
54
55
  { name: 'go', exts: ['.go'], blank: blanker({ ...SLASHES, raw: ['`'] }), lines: true, notCode: /^(?:package\b.*|import\s*\([^)]*\)|import\b.*)$/gm, minTokens: 50, define: /^func\s+([A-Za-z_]\w*)/gm },
55
- { name: 'rust', exts: ['.rs'], blank: blanker({ ...SLASHES, quotes: ['"'] }), notCode: lineStart('use|mod|extern|pub use'), minTokens: 60, define: /^(?:pub(?:\([a-z]+\))?\s+)?(?:async\s+)?fn\s+([A-Za-z_]\w*)/gm },
56
+ { name: 'rust', exts: ['.rs'], blank: blankRust, withoutTests, ownDefinitions, methodRows: true, notCode: lineStart('use|mod|extern|pub use'), minTokens: 60,
57
+ define: /^[ \t]*(?:pub(?:\([^)]*\))?\s+)?(?:default\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s+([A-Za-z_]\w*)/gm },
56
58
  { name: 'java', exts: ['.java', '.cs', '.scala', '.dart'], blank: blanker(SLASHES), notCode: lineStart('import|package|using|namespace'), minTokens: 60,
57
59
  define: /^[ \t]*(?:public|private|protected|internal|static|final|async|override|virtual|\s)+[\w<>[\],\s]+\s([A-Za-z_]\w*)\s*\([^;{}]*\)\s*(?:throws [\w, ]+)?\s*\{/gm },
58
60
  { name: 'kotlin', exts: ['.kt', '.kts', '.swift'], blank: blanker({ ...SLASHES, raw: ['"""'] }), lines: true, notCode: lineStart('import|package'), minTokens: 50, define: /^(?:(?:public|private|internal)\s+)?(?:fun|func)\s+([A-Za-z_]\w*)/gm },
@@ -63,4 +65,5 @@ export const LANGUAGES = [
63
65
 
64
66
  const BY_EXT = new Map(LANGUAGES.flatMap((l) => l.exts.map((e) => [e, l])));
65
67
  export const languageOf = (ext) => BY_EXT.get(ext) ?? null;
68
+ export const languageNamed = (name) => LANGUAGES.find((l) => l.name === name) ?? null;
66
69
  export const ALL_CODE = new Set(BY_EXT.keys());
@@ -41,9 +41,13 @@ export function globsForConcepts(layout, concepts) {
41
41
  }
42
42
 
43
43
  // Folders that may hold source code: every folder except docs/tools, deepest first.
44
+ // src/ is a code folder unless the layout names folders inside it: then a file belongs in one of those (KWB-78: a Python project's src/new.py was refused).
44
45
  export function codeFolders(layout) {
45
- return Object.entries(layout.folders ?? {})
46
- .filter(([name]) => !['docs', 'tools', 'src'].includes(name))
46
+ const folders = Object.entries(layout.folders ?? {});
47
+ const src = layout.folders?.src?.path;
48
+ const split = folders.some(([, f]) => f.path.startsWith(`${src}/`));
49
+ return folders
50
+ .filter(([name]) => !['docs', 'tools'].includes(name) && !(name === 'src' && split))
47
51
  .map(([, f]) => f.path)
48
52
  .sort((a, b) => b.length - a.length);
49
53
  }