@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.
- package/CHANGELOG.md +74 -0
- package/README.md +275 -18
- package/bin/claude-packs.mjs +18 -10
- package/configurations/base/config.json +1 -1
- package/configurations/cpp/config.json +2 -1
- package/configurations/full-stack/config.json +2 -1
- package/configurations/go/config.json +3 -1
- package/configurations/node/config.json +3 -1
- package/configurations/python/config.json +3 -1
- package/configurations/rust/config.json +106 -0
- package/configurations/synth-creator/config.json +1 -1
- package/global/hooks/lib/shell-writes.mjs +2 -293
- package/lib/build-queue.mjs +22 -0
- package/lib/claude-md.mjs +4 -2
- package/lib/config-cli.mjs +1 -1
- package/lib/coverage.mjs +10 -2
- package/lib/doctor.mjs +14 -3
- package/lib/fix-plan.mjs +2 -1
- package/lib/found-folders.mjs +25 -0
- package/lib/global.mjs +3 -3
- package/lib/hook-proof.mjs +24 -7
- package/lib/kit-command.mjs +14 -0
- package/lib/layout.mjs +27 -3
- package/lib/logs.mjs +45 -5
- package/lib/new.mjs +1 -1
- package/lib/packs.mjs +4 -2
- package/lib/rule-tests.mjs +42 -0
- package/lib/scan-detect.mjs +41 -5
- package/lib/scan.mjs +5 -2
- package/lib/suggest.mjs +16 -3
- package/lib/sync.mjs +5 -2
- package/lib/workorder.mjs +26 -0
- package/package.json +1 -1
- package/packs/audio-core/hooks/param-id-lock.mjs +59 -16
- package/packs/audio-core/pack.json +5 -0
- package/packs/audio-core/rules/a-control-is-proved-through-its-input.md +26 -0
- package/packs/audio-core/rules/cpu-worst-case-per-block.md +3 -1
- package/packs/audio-core/rules/house-control-behaviours.md +1 -1
- package/packs/audio-core/rules/scoped-no-denormals.md +1 -1
- package/packs/audio-core/rules/straight-on-and-aligned.md +1 -1
- package/packs/audio-core/rules/ui-matches-reference-look.md +2 -0
- package/packs/audio-core/skills/analyse-reference-look/SKILL.md +25 -11
- package/packs/audio-core/skills/prove-control/SKILL.md +2 -0
- package/packs/base/checks/one-walk-per-template.json +1 -1
- package/packs/base/hooks/code-shape.mjs +10 -6
- package/packs/base/hooks/dispatch-once.mjs +58 -21
- package/packs/base/hooks/folder-law.mjs +3 -0
- package/packs/base/hooks/git-guard.mjs +22 -109
- package/packs/base/hooks/lesson-checks.mjs +9 -1
- package/packs/base/hooks/lib/code-shapes.mjs +11 -6
- package/packs/base/hooks/lib/crash-policy.mjs +47 -0
- package/packs/base/hooks/lib/dup-index.mjs +6 -1
- package/packs/base/hooks/lib/dup.mjs +2 -2
- package/packs/base/hooks/lib/event-log.mjs +4 -3
- package/packs/base/hooks/lib/git-adds.mjs +2 -1
- package/packs/base/hooks/lib/git-auth.mjs +44 -0
- package/packs/base/hooks/lib/git-guard-selftest.mjs +93 -0
- package/packs/base/hooks/lib/languages.mjs +5 -2
- package/packs/base/hooks/lib/layout.mjs +6 -2
- package/packs/base/hooks/lib/making-check-selftest.mjs +3 -0
- package/packs/base/hooks/lib/near-copies.mjs +1 -1
- package/packs/base/hooks/lib/program-read.mjs +6 -3
- package/packs/base/hooks/lib/rust-scan.mjs +139 -0
- package/packs/base/hooks/lib/shape-lists.mjs +31 -0
- package/packs/base/hooks/lib/shape-prose.mjs +78 -0
- package/packs/base/hooks/lib/shape.mjs +18 -39
- package/packs/base/hooks/lib/turn.mjs +6 -1
- package/packs/base/hooks/lib/unread-writes.mjs +24 -0
- package/packs/base/hooks/making-check.mjs +12 -7
- package/packs/base/hooks/run-hooks.mjs +15 -7
- package/packs/base/hooks/shape-law.mjs +11 -4
- package/packs/base/hooks/suite-once.mjs +22 -6
- package/packs/base/rules/a-control-is-seen-to-fire.md +1 -1
- package/packs/base/rules/behaviour-lives-with-its-kind.md +1 -1
- package/packs/base/rules/bug-fix-starts-red.md +1 -1
- package/packs/base/rules/field-map-over-if-runs.md +1 -1
- package/packs/base/rules/instances-by-index-identities-by-id.md +2 -2
- package/packs/base/rules/lookup-over-if-chains.md +1 -1
- package/packs/base/rules/plan-and-logs.md +1 -1
- package/packs/base/rules/refusals-are-judged.md +1 -0
- package/packs/base/rules/test-the-template-once.md +2 -1
- package/packs/base/rules/unattended-runs.md +15 -0
- package/packs/base/skills/new-project/SKILL.md +23 -0
- package/packs/base/skills/write-a-workorder/SKILL.md +41 -0
- package/packs/cpp/checks/cpp-find-with-the-library.json +2 -2
- package/packs/go/pack.json +5 -0
- package/packs/hook-proof/rules/a-refusal-is-exit-2.md +2 -2
- package/packs/instrument-shell/rules/render-the-extremes.md +2 -2
- package/packs/midi/checks/position-not-integrated.json +5 -1
- package/packs/postgres/checks/pg-bind-values.json +33 -0
- package/packs/postgres/checks/pg-concurrently-in-txn.json +18 -0
- package/packs/postgres/checks/pg-fk-not-valid.json +18 -0
- package/packs/postgres/checks/pg-index-concurrently.json +18 -0
- package/packs/postgres/checks/pg-no-float-money.json +19 -0
- package/packs/postgres/checks/pg-no-money.json +18 -0
- package/packs/postgres/checks/pg-no-required-column.json +18 -0
- package/packs/postgres/checks/pg-no-volatile-default.json +18 -0
- package/packs/postgres/checks/pg-not-exists.json +18 -0
- package/packs/postgres/checks/pg-timestamptz.json +18 -0
- package/packs/postgres/hooks/db-wipe-guard.mjs +95 -0
- package/packs/postgres/pack.json +48 -0
- package/packs/postgres/rules/postgres-migrations-never-stop-traffic.md +28 -0
- package/packs/postgres/rules/postgres-one-pool-per-process.md +21 -0
- package/packs/postgres/rules/postgres-queries-are-null-safe-and-bounded.md +26 -0
- package/packs/postgres/rules/postgres-types-say-what-they-hold.md +22 -0
- package/packs/postgres/rules/postgres-values-are-bound.md +130 -0
- package/packs/postgres/skills/postgres-safe-migration/SKILL.md +45 -0
- package/packs/reverse-engineering/rules/addresses-name-their-space.md +1 -0
- package/packs/reverse-engineering/rules/reproduction-or-original.md +1 -1
- package/packs/reverse-engineering/skills/gather-sources/SKILL.md +1 -0
- package/packs/rust/checks/rust-allow-needs-reason.json +16 -0
- package/packs/rust/checks/rust-blocking-in-async.json +32 -0
- package/packs/rust/checks/rust-if-chain-is-a-match.json +32 -0
- package/packs/rust/checks/rust-static-mut.json +32 -0
- package/packs/rust/checks/rust-string-as-error.json +34 -0
- package/packs/rust/checks/rust-stub-left-in.json +32 -0
- package/packs/rust/checks/rust-unsafe-needs-safety.json +31 -0
- package/packs/rust/checks/rust-unwrap-in-library.json +34 -0
- package/packs/rust/checks/rust-wildcard-dependency.json +16 -0
- package/packs/rust/clippy.toml +5 -0
- package/packs/rust/pack.json +27 -0
- package/packs/rust/rules/rust-async-never-blocks.md +20 -0
- package/packs/rust/rules/rust-borrow-before-you-clone.md +19 -0
- package/packs/rust/rules/rust-errors-are-returned.md +22 -0
- package/packs/rust/rules/rust-lints-are-the-gate.md +23 -0
- package/packs/rust/rules/rust-repeats-become-templates.md +28 -0
- package/packs/rust/rules/rust-unsafe-is-proved.md +21 -0
- package/packs/rust/skills/rust-extract-template/SKILL.md +50 -0
- package/packs/rust/skills/rust-port/SKILL.md +22 -0
- package/packs/rust/skills/rust-review/SKILL.md +24 -0
- package/packs/rust/tools/rust-lints.toml +19 -0
- package/packs/sound-design/skills/bounce-a-plugin/SKILL.md +40 -0
- package/packs/sound-design/tools/vst3host/build.sh +73 -0
- package/packs/sound-design/tools/vst3host/host_events.h +101 -0
- package/packs/sound-design/tools/vst3host/options.h +172 -0
- package/packs/sound-design/tools/vst3host/render.cpp +245 -0
- package/packs/sound-design/tools/vst3host/session.h +241 -0
- package/packs/synth-creator/hooks/build-guard.mjs +7 -7
- package/packs/synth-creator/hooks/protect-vendor.mjs +2 -1
- package/packs/synth-creator/hooks/stage-guard.mjs +1 -1
- package/packs/synth-creator/rules/timing-from-synthcore.md +1 -1
- package/packs/synth-creator/skills/build-through-the-queue/SKILL.md +4 -2
- package/packs/synth-creator/tools/buildq.mjs +74 -0
- package/prompts/workorder/PROMPT.md +53 -0
- package/prompts/workorder/run.sh +36 -0
- package/prompts/workorder/workorder.md +59 -0
- package/global/hooks/lib/program-read.mjs +0 -196
- 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
|
|
9
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
134
|
-
if (/\
|
|
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 &&
|
|
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 &&
|
|
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
|
|
149
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
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
|
-
|
|
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: `${
|
|
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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
46
|
-
|
|
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
|
}
|