cohorte 2.10.1 → 3.0.0-dev.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/assets/manifest.json +193 -0
- package/assets/migrations/config/README.md +12 -0
- package/assets/migrations/state/0001_init.sql +154 -0
- package/assets/prompts/README.md +11 -0
- package/assets/prompts/agents/fixer.md +7 -0
- package/assets/prompts/agents/implementer.md +12 -0
- package/assets/prompts/agents/reviewer.md +8 -0
- package/assets/prompts/agents/security-reviewer.md +7 -0
- package/assets/prompts/phases/build.md +5 -0
- package/assets/prompts/phases/review.md +5 -0
- package/assets/prompts/phases/test.md +5 -0
- package/assets/prompts/system.md +11 -0
- package/assets/schemas/agent-output.schema.json +225 -0
- package/assets/schemas/auth-status.schema.json +87 -0
- package/assets/schemas/command-result.schema.json +192 -0
- package/assets/schemas/commands.schema.json +952 -0
- package/assets/schemas/config.schema.json +2259 -0
- package/assets/schemas/doctor-report.schema.json +104 -0
- package/assets/schemas/events.schema.json +8514 -0
- package/assets/schemas/fake-script.schema.json +549 -0
- package/assets/schemas/inspect.schema.json +3133 -0
- package/assets/schemas/manifest.schema.json +79 -0
- package/assets/schemas/ownership.schema.json +52 -0
- package/assets/schemas/policy-verdict.schema.json +282 -0
- package/assets/schemas/project-model.schema.json +783 -0
- package/assets/schemas/project-status.schema.json +217 -0
- package/assets/schemas/reconcile-plan.schema.json +155 -0
- package/assets/schemas/run-diff.schema.json +177 -0
- package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
- package/assets/schemas/run-state.schema.json +1755 -0
- package/assets/schemas/runtime-capabilities.schema.json +1545 -0
- package/assets/schemas/sandbox-capabilities.schema.json +116 -0
- package/assets/schemas/skill.schema.json +87 -0
- package/assets/schemas/spec.schema.json +202 -0
- package/assets/schemas/tool-catalogue.schema.json +655 -0
- package/assets/schemas/trust-record.schema.json +38 -0
- package/assets/skills/README.md +9 -0
- package/assets/skills/inspect-surface.md +4 -0
- package/dist/agent-host.mjs +50 -0
- package/dist/agent-host.mjs.map +1 -0
- package/dist/bundle-manifest.json +102 -0
- package/dist/chunks/__host-DgGPyco9.mjs +18 -0
- package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
- package/dist/chunks/approve-Cv135UqI.mjs +19 -0
- package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
- package/dist/chunks/auth-BT5oVp76.mjs +132 -0
- package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
- package/dist/chunks/auth-D8rCVY33.mjs +84 -0
- package/dist/chunks/auth-D8rCVY33.mjs.map +1 -0
- package/dist/chunks/brainstorm-CU10T1tm.mjs +7 -0
- package/dist/chunks/brainstorm-CU10T1tm.mjs.map +1 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
- package/dist/chunks/compose-Cr0hP0qm.mjs +7303 -0
- package/dist/chunks/compose-Cr0hP0qm.mjs.map +1 -0
- package/dist/chunks/config-DefHA2Lw.mjs +96 -0
- package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
- package/dist/chunks/contract-B0ra2-bm.mjs +295 -0
- package/dist/chunks/contract-B0ra2-bm.mjs.map +1 -0
- package/dist/chunks/contract-vYbwYr0Y.mjs +123 -0
- package/dist/chunks/contract-vYbwYr0Y.mjs.map +1 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
- package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
- package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
- package/dist/chunks/discover-30Ciya_0.mjs +20 -0
- package/dist/chunks/discover-30Ciya_0.mjs.map +1 -0
- package/dist/chunks/doctor-DfCrKpfT.mjs +222 -0
- package/dist/chunks/doctor-DfCrKpfT.mjs.map +1 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
- package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
- package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
- package/dist/chunks/init-Okz4mlhW.mjs +30 -0
- package/dist/chunks/init-Okz4mlhW.mjs.map +1 -0
- package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
- package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
- package/dist/chunks/logs-Giq5sUau.mjs +25 -0
- package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
- package/dist/chunks/migrate-DRoFUg53.mjs +19 -0
- package/dist/chunks/migrate-DRoFUg53.mjs.map +1 -0
- package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
- package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
- package/dist/chunks/pause-BASBfKLO.mjs +16 -0
- package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
- package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
- package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
- package/dist/chunks/providers-Dx78imKj.mjs +19 -0
- package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
- package/dist/chunks/reconcile-BfD785gd.mjs +26 -0
- package/dist/chunks/reconcile-BfD785gd.mjs.map +1 -0
- package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
- package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
- package/dist/chunks/retry-CLrUem1H.mjs +15 -0
- package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
- package/dist/chunks/review-BDdjb6kB.mjs +17 -0
- package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
- package/dist/chunks/run-CgTWYlcd.mjs +82 -0
- package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
- package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
- package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
- package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
- package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
- package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
- package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
- package/dist/chunks/src-CId6kMsY.mjs +1689 -0
- package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
- package/dist/chunks/src-CfAOde7C.mjs +5403 -0
- package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
- package/dist/chunks/src-Cu3TxNob.mjs +783 -0
- package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
- package/dist/chunks/src-DcU4IXMe.mjs +117 -0
- package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
- package/dist/chunks/src-JxawBQBn.mjs +446 -0
- package/dist/chunks/src-JxawBQBn.mjs.map +1 -0
- package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
- package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
- package/dist/chunks/status-SMjiOMYy.mjs +29 -0
- package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
- package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
- package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
- package/dist/chunks/update-BXIppsbi.mjs +16 -0
- package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
- package/dist/cli.mjs +175 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +17 -32
- package/CHANGELOG.md +0 -1461
- package/bin/cli.js +0 -817
- package/bin/report.js +0 -141
- package/core/adapter/render.js +0 -415
- package/core/agents/implementer.template.md +0 -142
- package/core/agents/profile-reader.md +0 -50
- package/core/agents/release.md +0 -58
- package/core/agents/review.md +0 -179
- package/core/commands/cohorte-align-ds.md +0 -33
- package/core/commands/cohorte-audit.md +0 -58
- package/core/commands/cohorte-brainstorm.md +0 -70
- package/core/commands/cohorte-build.md +0 -219
- package/core/commands/cohorte-doctor.md +0 -194
- package/core/commands/cohorte-fix.md +0 -80
- package/core/commands/cohorte-fleet.md +0 -103
- package/core/commands/cohorte-init-pipeline.md +0 -31
- package/core/commands/cohorte-intake.md +0 -92
- package/core/commands/cohorte-patch.md +0 -118
- package/core/commands/cohorte-refactor.md +0 -59
- package/core/commands/cohorte-retro.md +0 -85
- package/core/commands/cohorte-review.md +0 -270
- package/core/commands/cohorte-ship.md +0 -122
- package/core/commands/cohorte-spec.md +0 -101
- package/core/commands/cohorte-update-pipeline.md +0 -165
- package/core/hooks/gate.py +0 -588
- package/core/runtimes/claude.json +0 -74
- package/core/runtimes/codex.json +0 -85
- package/core/runtimes/cursor.json +0 -76
- package/core/runtimes/gemini.json +0 -76
- package/core/runtimes/opencode.json +0 -73
- package/core/templates/agent-handoff.md +0 -34
- package/core/templates/brainstorm-return.md +0 -36
- package/core/templates/decisions.template.md +0 -42
- package/core/templates/design-brief.md +0 -44
- package/core/templates/patch.template.md +0 -86
- package/core/templates/pr-body.md +0 -25
- package/core/templates/review-feedback.md +0 -38
- package/core/templates/spec.template.md +0 -87
- package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
- package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
- package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
- package/core/templates/steps/init-pipeline/05-report.md +0 -12
- package/core/workflows/audit.js +0 -240
- package/core/workflows/loop.js +0 -641
- package/core/workflows/refactor.js +0 -276
- package/core/workflows/review.js +0 -456
- package/install.ps1 +0 -103
- package/install.sh +0 -114
- package/lib/doctor.js +0 -534
- package/lib/runtime.js +0 -139
- package/lib/versions.js +0 -149
- package/lib/yaml.js +0 -129
- package/profile/PIPELINE.template.md +0 -259
- package/profile/SCHEMA.md +0 -735
- package/profile/cohorte.config.template.yaml +0 -43
- package/scripts/assert-gate-hook.mjs +0 -75
- package/scripts/kanban-move.sh +0 -313
- package/scripts/metrics/collect.mjs +0 -531
- package/scripts/metrics/prices.json +0 -42
- package/scripts/new-feature.sh.template +0 -98
- package/scripts/preflight.sh +0 -124
- package/scripts/remove-feature.sh.template +0 -56
- package/scripts/test-adapter.mjs +0 -437
- package/scripts/test-gate.mjs +0 -443
- package/scripts/test-kanban.mjs +0 -304
- package/scripts/test-lib.mjs +0 -356
- package/scripts/test-metrics.mjs +0 -156
- package/scripts/test-workflows.mjs +0 -758
- package/scripts/validate-core.mjs +0 -325
package/scripts/test-gate.mjs
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Behavioural tests for core/hooks/gate.py — the PreToolUse gate.
|
|
3
|
-
//
|
|
4
|
-
// The gate is the one component that can BLOCK a user's command, and it is the
|
|
5
|
-
// only one with a pure, fully testable interface: a PreToolUse payload on stdin,
|
|
6
|
-
// a JSON permissionDecision (or nothing) on stdout. Until this file existed it
|
|
7
|
-
// had no test at all — every one of its shipped regressions (a Bash-only matcher
|
|
8
|
-
// leaving the phase gate dead, branch state resolved in the wrong checkout,
|
|
9
|
-
// unanswerable "ask"s in headless runs) reached users first.
|
|
10
|
-
//
|
|
11
|
-
// node scripts/test-gate.mjs
|
|
12
|
-
|
|
13
|
-
import { spawnSync, execFileSync } from "node:child_process";
|
|
14
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
15
|
-
import { tmpdir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
-
|
|
19
|
-
const root = fileURLToPath(new URL("..", import.meta.url));
|
|
20
|
-
const GATE = join(root, "core", "hooks", "gate.py");
|
|
21
|
-
|
|
22
|
-
const python = ["py", "python3", "python"].find((c) => {
|
|
23
|
-
try { return spawnSync(c, ["--version"], { stdio: "ignore" }).status === 0; }
|
|
24
|
-
catch { return false; }
|
|
25
|
-
});
|
|
26
|
-
if (!python) { console.error("test-gate: no python found on PATH"); process.exit(2); }
|
|
27
|
-
|
|
28
|
-
let failures = 0;
|
|
29
|
-
const check = (name, cond, detail = "") => {
|
|
30
|
-
if (cond) console.log(` ✓ ${name}`);
|
|
31
|
-
else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const tmps = [];
|
|
35
|
-
function scratch() {
|
|
36
|
-
const d = mkdtempSync(join(tmpdir(), "gate-"));
|
|
37
|
-
tmps.push(d);
|
|
38
|
-
mkdirSync(join(d, ".claude"), { recursive: true });
|
|
39
|
-
return d;
|
|
40
|
-
}
|
|
41
|
-
function writeConfig(dir, cfg) {
|
|
42
|
-
writeFileSync(join(dir, ".claude", "gate-config.json"), JSON.stringify(cfg));
|
|
43
|
-
}
|
|
44
|
-
function gitRepo(dir, branch) {
|
|
45
|
-
const git = (...a) => execFileSync("git", a, { cwd: dir, stdio: "ignore" });
|
|
46
|
-
git("init", "-q");
|
|
47
|
-
git("config", "user.email", "t@t.t");
|
|
48
|
-
git("config", "user.name", "t");
|
|
49
|
-
git("config", "commit.gpgsign", "false");
|
|
50
|
-
writeFileSync(join(dir, "f.txt"), "x");
|
|
51
|
-
git("add", "-A");
|
|
52
|
-
git("commit", "-qm", "init");
|
|
53
|
-
git("branch", "-M", "main");
|
|
54
|
-
if (branch && branch !== "main") git("checkout", "-qb", branch);
|
|
55
|
-
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: dir, encoding: "utf8" }).trim();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Run the hook with a payload. Returns { decision, reason, raw, status }.
|
|
59
|
-
function run(payload, { projectDir } = {}) {
|
|
60
|
-
const r = spawnSync(python, [GATE], {
|
|
61
|
-
input: JSON.stringify(payload),
|
|
62
|
-
encoding: "utf8",
|
|
63
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir || "" },
|
|
64
|
-
});
|
|
65
|
-
const raw = (r.stdout || "").trim();
|
|
66
|
-
if (!raw) return { decision: null, reason: null, raw, status: r.status };
|
|
67
|
-
try {
|
|
68
|
-
const o = JSON.parse(raw).hookSpecificOutput;
|
|
69
|
-
return { decision: o.permissionDecision, reason: o.permissionDecisionReason, raw, status: r.status };
|
|
70
|
-
} catch {
|
|
71
|
-
return { decision: "UNPARSEABLE", reason: null, raw, status: r.status };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const bash = (command, extra = {}) => ({ tool_name: "Bash", tool_input: { command }, ...extra });
|
|
75
|
-
const task = (subagent_type, extra = {}) => ({ tool_name: "Task", tool_input: { subagent_type }, ...extra });
|
|
76
|
-
|
|
77
|
-
const GATE_CFG = {
|
|
78
|
-
deny: ["node ace migration:fresh", "node ace db:wipe"],
|
|
79
|
-
ask: ["node ace migration:run", "psql"],
|
|
80
|
-
ask_on_default_branch: ["git commit", "git push", "docker compose"],
|
|
81
|
-
default_branch: "main",
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
// ── Bash command gating ──────────────────────────────────────────────────────
|
|
85
|
-
console.log("gate.py — Bash command gating");
|
|
86
|
-
{
|
|
87
|
-
const d = scratch(); writeConfig(d, GATE_CFG); gitRepo(d, "feature/x");
|
|
88
|
-
const at = p => ({ projectDir: d });
|
|
89
|
-
|
|
90
|
-
check("harmless command passes silently",
|
|
91
|
-
run(bash("ls -la"), at()).decision === null);
|
|
92
|
-
check("non-Bash, non-Task tool is ignored",
|
|
93
|
-
run({ tool_name: "Read", tool_input: { file_path: "x" } }, at()).decision === null);
|
|
94
|
-
check("deny pattern ⇒ deny",
|
|
95
|
-
run(bash("node ace migration:fresh"), at()).decision === "deny");
|
|
96
|
-
check("ask pattern ⇒ ask",
|
|
97
|
-
run(bash("node ace migration:run"), at()).decision === "ask");
|
|
98
|
-
|
|
99
|
-
// The headline capability: prefix-based settings.json rules cannot see this.
|
|
100
|
-
check("CHAINED command is caught (cd x && …)",
|
|
101
|
-
run(bash("cd apps/api && node ace migration:run"), at()).decision === "ask");
|
|
102
|
-
check("chained after a semicolon is caught",
|
|
103
|
-
run(bash("echo hi; node ace db:wipe"), at()).decision === "deny");
|
|
104
|
-
check("chained after a pipe is caught",
|
|
105
|
-
run(bash("cat x | psql"), at()).decision === "ask");
|
|
106
|
-
check("chained after || is caught",
|
|
107
|
-
run(bash("false || node ace migration:fresh"), at()).decision === "deny");
|
|
108
|
-
check("newline-separated is caught",
|
|
109
|
-
run(bash("echo a\nnode ace migration:run"), at()).decision === "ask");
|
|
110
|
-
|
|
111
|
-
check("whitespace is normalized before matching",
|
|
112
|
-
run(bash("node ace migration:run"), at()).decision === "ask");
|
|
113
|
-
check("deny wins over ask on the same segment",
|
|
114
|
-
run(bash("node ace migration:fresh"), at()).decision === "deny");
|
|
115
|
-
// Deny must win ACROSS segments too: with segment-order scanning, the benign ask
|
|
116
|
-
// surfaced first and the human's one confirm ran the hard-denied command behind it.
|
|
117
|
-
check("deny in a LATER segment wins over an earlier ask segment",
|
|
118
|
-
run(bash("node ace migration:run && node ace migration:fresh"), at()).decision === "deny");
|
|
119
|
-
check("deny in a later segment wins over an earlier branch-gated ask",
|
|
120
|
-
run(bash("git commit -m x && node ace db:wipe"), at()).decision === "deny");
|
|
121
|
-
// Matching is substring-on-the-whole-pattern, so a partial overlap is NOT a
|
|
122
|
-
// match — `migration:run` alone does not trigger `node ace migration:run`.
|
|
123
|
-
check("a partial overlap of a pattern does not gate",
|
|
124
|
-
run(bash("echo 'we never run migration:run here'"), at()).decision === null);
|
|
125
|
-
// …but the full pattern inside a quoted string DOES gate. Intentional: the gate
|
|
126
|
-
// cannot know a shell quote is inert (`sh -c "node ace db:wipe"` is real), so it
|
|
127
|
-
// over-gates rather than reasoning about quoting.
|
|
128
|
-
check("the full pattern inside a quoted string still gates (fail-safe over-gating)",
|
|
129
|
-
run(bash("echo \"node ace db:wipe\""), at()).decision === "deny");
|
|
130
|
-
check("…including when wrapped in sh -c, which really would execute",
|
|
131
|
-
run(bash("sh -c 'node ace db:wipe'"), at()).decision === "deny");
|
|
132
|
-
|
|
133
|
-
// bypassPermissions: nobody can answer a prompt.
|
|
134
|
-
check("ask in bypassPermissions ⇒ escalated to deny",
|
|
135
|
-
run(bash("node ace migration:run"), { ...at(), }).decision === "ask");
|
|
136
|
-
const unattended = run({ ...bash("node ace migration:run"), permission_mode: "bypassPermissions" }, at());
|
|
137
|
-
check("ask + bypassPermissions ⇒ deny", unattended.decision === "deny", unattended.decision);
|
|
138
|
-
check("…and the reason says why", /unattended/i.test(unattended.reason || ""), unattended.reason);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── branch-conditional gating ────────────────────────────────────────────────
|
|
142
|
-
console.log("gate.py — branch-conditional gating");
|
|
143
|
-
{
|
|
144
|
-
const main = scratch(); writeConfig(main, GATE_CFG); gitRepo(main, "main");
|
|
145
|
-
const feat = scratch(); writeConfig(feat, GATE_CFG); gitRepo(feat, "feature/x");
|
|
146
|
-
|
|
147
|
-
check("git commit on the default branch ⇒ ask",
|
|
148
|
-
run({ ...bash("git commit -m x"), cwd: main }, { projectDir: main }).decision === "ask");
|
|
149
|
-
check("git commit on a feature branch ⇒ free",
|
|
150
|
-
run({ ...bash("git commit -m x"), cwd: feat }, { projectDir: feat }).decision === null);
|
|
151
|
-
check("docker compose on a feature branch ⇒ free",
|
|
152
|
-
run({ ...bash("docker compose up"), cwd: feat }, { projectDir: feat }).decision === null);
|
|
153
|
-
|
|
154
|
-
// The 1.3.3 fix: git state must resolve at the payload's cwd (the worktree),
|
|
155
|
-
// not CLAUDE_PROJECT_DIR (the main checkout, usually on the default branch).
|
|
156
|
-
const cross = run({ ...bash("git commit -m x"), cwd: feat }, { projectDir: main });
|
|
157
|
-
check("branch resolves at the payload cwd, not CLAUDE_PROJECT_DIR",
|
|
158
|
-
cross.decision === null, `got ${cross.decision} (a worktree commit must not be gated)`);
|
|
159
|
-
|
|
160
|
-
// Fail-safe: no repo ⇒ unknown branch ⇒ gate.
|
|
161
|
-
const norepo = scratch(); writeConfig(norepo, GATE_CFG);
|
|
162
|
-
check("unknown branch (not a repo) ⇒ gated, to stay safe",
|
|
163
|
-
run({ ...bash("git commit -m x"), cwd: norepo }, { projectDir: norepo }).decision === "ask");
|
|
164
|
-
|
|
165
|
-
// 2.0.2: the tool's shell keeps no cwd between calls, so agents write
|
|
166
|
-
// `cd <other repo> && git commit`. Resolving the branch at the payload cwd
|
|
167
|
-
// judged that commit against the wrong checkout — and the refusal named a
|
|
168
|
-
// branch the command was never going to run on, so no approval could lift it.
|
|
169
|
-
const chained = run({ ...bash(`cd ${feat} && git commit -m x`), cwd: main }, { projectDir: main });
|
|
170
|
-
check("`cd <feature checkout> && git commit` follows the cd ⇒ free",
|
|
171
|
-
chained.decision === null, `got ${chained.decision}: ${chained.reason}`);
|
|
172
|
-
check("`cd <default-branch checkout> && git commit` still gated",
|
|
173
|
-
run({ ...bash(`cd ${main} && git commit -m x`), cwd: feat }, { projectDir: feat }).decision === "ask");
|
|
174
|
-
check("a cd only affects segments AFTER it",
|
|
175
|
-
run({ ...bash(`git commit -m x && cd ${feat}`), cwd: main }, { projectDir: main }).decision === "ask");
|
|
176
|
-
check("relative cd resolves against the payload cwd",
|
|
177
|
-
run({ ...bash("cd .. && git commit -m x"), cwd: join(feat, ".claude") },
|
|
178
|
-
{ projectDir: feat }).decision === null);
|
|
179
|
-
check("cd into a missing dir keeps the current cwd (still gated)",
|
|
180
|
-
run({ ...bash("cd /definitely/not/here && git commit -m x"), cwd: main },
|
|
181
|
-
{ projectDir: main }).decision === "ask");
|
|
182
|
-
check("an unresolvable cd ($(…)) keeps the current cwd (still gated)",
|
|
183
|
-
run({ ...bash("cd $(mktemp -d) && git commit -m x"), cwd: main },
|
|
184
|
-
{ projectDir: main }).decision === "ask");
|
|
185
|
-
check("`cd -` is never treated as a resolvable move",
|
|
186
|
-
run({ ...bash("cd - && git commit -m x"), cwd: main }, { projectDir: main }).decision === "ask");
|
|
187
|
-
check("a quoted cd path with a space is followed",
|
|
188
|
-
run({ ...bash(`cd "${feat}" && git commit -m x`), cwd: main }, { projectDir: main }).decision === null);
|
|
189
|
-
// A cd must not soften the unconditional tiers — those are branch-independent.
|
|
190
|
-
check("cd does not exempt a `deny` pattern",
|
|
191
|
-
run({ ...bash(`cd ${feat} && node ace db:wipe`), cwd: main }, { projectDir: main }).decision === "deny");
|
|
192
|
-
check("cd does not exempt an always-`ask` pattern",
|
|
193
|
-
run({ ...bash(`cd ${feat} && psql`), cwd: main }, { projectDir: main }).decision === "ask");
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ── config robustness ────────────────────────────────────────────────────────
|
|
197
|
-
console.log("gate.py — config robustness");
|
|
198
|
-
{
|
|
199
|
-
const none = scratch(); // no gate-config.json at all
|
|
200
|
-
check("missing gate-config.json ⇒ silent (never bricks a repo)",
|
|
201
|
-
run(bash("node ace migration:fresh"), { projectDir: none }).decision === null);
|
|
202
|
-
|
|
203
|
-
const bad = scratch();
|
|
204
|
-
writeFileSync(join(bad, ".claude", "gate-config.json"), "{ not json");
|
|
205
|
-
check("unparseable gate-config.json ⇒ silent",
|
|
206
|
-
run(bash("node ace migration:fresh"), { projectDir: bad }).decision === null);
|
|
207
|
-
|
|
208
|
-
const empty = scratch(); writeConfig(empty, { deny: [], ask: [], ask_on_default_branch: [] });
|
|
209
|
-
check("empty pattern lists ⇒ silent",
|
|
210
|
-
run(bash("node ace migration:fresh"), { projectDir: empty }).decision === null);
|
|
211
|
-
|
|
212
|
-
const r = spawnSync(python, [GATE], { input: "not json at all", encoding: "utf8" });
|
|
213
|
-
check("malformed stdin ⇒ exit 0, no output (never blocks on its own bug)",
|
|
214
|
-
r.status === 0 && (r.stdout || "").trim() === "", `status=${r.status} out=${r.stdout}`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── the preflight phase gate (Task dispatches) ───────────────────────────────
|
|
218
|
-
console.log("gate.py — preflight phase gate");
|
|
219
|
-
{
|
|
220
|
-
const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
|
|
221
|
-
const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
|
|
222
|
-
const head = gitRepo(d, "main");
|
|
223
|
-
const stamp = (epoch, sha) =>
|
|
224
|
-
writeFileSync(join(d, ".claude", "preflight.ok"), `${epoch} ${sha}\n`);
|
|
225
|
-
const now = () => Math.floor(Date.now() / 1000);
|
|
226
|
-
const at = { projectDir: d };
|
|
227
|
-
|
|
228
|
-
check("no stamp ⇒ ask", run(task("review"), at).decision === "ask");
|
|
229
|
-
check("…and the reason names the phase gate",
|
|
230
|
-
/phase gate/i.test(run(task("review"), at).reason || ""));
|
|
231
|
-
|
|
232
|
-
stamp(now(), head);
|
|
233
|
-
check("fresh stamp at the current HEAD ⇒ passes", run(task("review"), at).decision === null);
|
|
234
|
-
|
|
235
|
-
stamp(now() - 60 * 60, head);
|
|
236
|
-
check("stamp older than max_age_minutes ⇒ ask", run(task("review"), at).decision === "ask");
|
|
237
|
-
check("…and the reason says it is stale",
|
|
238
|
-
/min old/.test(run(task("review"), at).reason || ""));
|
|
239
|
-
|
|
240
|
-
stamp(now(), "0000000000000000000000000000000000000000");
|
|
241
|
-
const moved = run(task("review"), at);
|
|
242
|
-
check("stamp from a different HEAD ⇒ ask", moved.decision === "ask", moved.decision);
|
|
243
|
-
check("…and the reason says HEAD moved", /HEAD moved/.test(moved.reason || ""));
|
|
244
|
-
|
|
245
|
-
writeFileSync(join(d, ".claude", "preflight.ok"), "garbage\n");
|
|
246
|
-
check("unreadable stamp ⇒ ask, reported as unreadable",
|
|
247
|
-
/unreadable/.test(run(task("review"), at).reason || ""));
|
|
248
|
-
|
|
249
|
-
stamp(now(), head);
|
|
250
|
-
check("an unlisted subagent_type is not gated",
|
|
251
|
-
run(task("backend"), at).decision === null);
|
|
252
|
-
check("a Task with no subagent_type is not gated",
|
|
253
|
-
run({ tool_name: "Task", tool_input: {} }, at).decision === null);
|
|
254
|
-
|
|
255
|
-
// Unattended: an "ask" nobody can answer must become a deny (1.3.3 fix).
|
|
256
|
-
writeFileSync(join(d, ".claude", "preflight.ok"), "garbage\n");
|
|
257
|
-
const headless = run({ ...task("review"), permission_mode: "bypassPermissions" }, at);
|
|
258
|
-
check("stale stamp + bypassPermissions ⇒ deny, not an unanswerable ask",
|
|
259
|
-
headless.decision === "deny", headless.decision);
|
|
260
|
-
|
|
261
|
-
// Disabled / absent block ⇒ the phase gate must not fire at all.
|
|
262
|
-
const off = scratch(); writeConfig(off, { ...GATE_CFG, preflight: { enabled: false } });
|
|
263
|
-
check("preflight.enabled false ⇒ Task never gated",
|
|
264
|
-
run(task("review"), { projectDir: off }).decision === null);
|
|
265
|
-
const noblock = scratch(); writeConfig(noblock, GATE_CFG);
|
|
266
|
-
check("profile with no preflight block ⇒ Task never gated (older installs keep working)",
|
|
267
|
-
run(task("review"), { projectDir: noblock }).decision === null);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// ── the content digest (2.0.0): freshness keyed on code, not on HEAD ─────────
|
|
271
|
-
// Before this, the stamp recorded the HEAD sha — backwards on both sides. The
|
|
272
|
-
// reviewed tree is normally DIRTY, so committing already-verified code made the
|
|
273
|
-
// gate ask on a clean tree (and a committed stamp made it ask forever), while an
|
|
274
|
-
// implementer's edit between preflight and dispatch invalidated nothing.
|
|
275
|
-
console.log("gate.py — preflight content digest");
|
|
276
|
-
{
|
|
277
|
-
const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
|
|
278
|
-
const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
|
|
279
|
-
gitRepo(d, "main");
|
|
280
|
-
mkdirSync(join(d, "specs", "reports"), { recursive: true });
|
|
281
|
-
writeFileSync(join(d, "specs", "s.md"), "spec\n");
|
|
282
|
-
writeFileSync(join(d, "src.txt"), "code v1\n"); // uncommitted feature work
|
|
283
|
-
const git = (...a) => execFileSync("git", a, { cwd: d, stdio: "ignore" });
|
|
284
|
-
const at = { projectDir: d };
|
|
285
|
-
const runPreflight = () =>
|
|
286
|
-
spawnSync("sh", [join(root, "scripts", "preflight.sh"), join(d, "specs", "reports", "r.txt"), "true"],
|
|
287
|
-
{ cwd: d, encoding: "utf8" });
|
|
288
|
-
|
|
289
|
-
const pre = runPreflight();
|
|
290
|
-
const raw = execFileSync("cat", [join(d, ".claude", "preflight.ok")], { encoding: "utf8" }).trim();
|
|
291
|
-
check("preflight.sh stamps three fields (epoch, sha, digest)",
|
|
292
|
-
raw.split(/\s+/).length === 3, `${pre.status}: ${raw}`);
|
|
293
|
-
check("fresh stamp on a dirty tree ⇒ passes", run(task("review"), at).decision === null);
|
|
294
|
-
|
|
295
|
-
// The regression that started this: commit the very code the preflight verified.
|
|
296
|
-
git("add", "-A"); git("commit", "-qm", "wip");
|
|
297
|
-
const afterCommit = run(task("review"), at);
|
|
298
|
-
check("committing the verified code ⇒ still passes (HEAD moved, code did not)",
|
|
299
|
-
afterCommit.decision === null, `got ${afterCommit.decision} — ${afterCommit.reason}`);
|
|
300
|
-
|
|
301
|
-
// The pipeline's own writes must never invalidate its own stamp.
|
|
302
|
-
writeFileSync(join(d, "specs", "s.md"), "spec + DoD ticks\n");
|
|
303
|
-
writeFileSync(join(d, "specs", "reports", "r2.txt"), "report\n");
|
|
304
|
-
writeFileSync(join(d, ".claude", "pipeline-metrics.jsonl"), "{}\n");
|
|
305
|
-
check("spec ticks, report buffer and metrics writes ⇒ still passes",
|
|
306
|
-
run(task("review"), at).decision === null);
|
|
307
|
-
|
|
308
|
-
// …and a real edit must.
|
|
309
|
-
writeFileSync(join(d, "src.txt"), "code v2\n");
|
|
310
|
-
const edited = run(task("review"), at);
|
|
311
|
-
check("an uncommitted code edit ⇒ ask", edited.decision === "ask", edited.decision);
|
|
312
|
-
check("…and the reason says the code changed", /code changed/.test(edited.reason || ""));
|
|
313
|
-
|
|
314
|
-
// A brand-new untracked source file is a code change too (the sha never saw these).
|
|
315
|
-
writeFileSync(join(d, "src.txt"), "code v1\n");
|
|
316
|
-
writeFileSync(join(d, "extra.txt"), "new surface\n");
|
|
317
|
-
check("a new untracked source file ⇒ ask", run(task("review"), at).decision === "ask");
|
|
318
|
-
rmSync(join(d, "extra.txt"));
|
|
319
|
-
check("reverting to the verified content ⇒ passes again",
|
|
320
|
-
run(task("review"), at).decision === null);
|
|
321
|
-
|
|
322
|
-
// The hook must never touch the caller's index — it computes in a throwaway one.
|
|
323
|
-
const status = execFileSync("git", ["status", "--porcelain"], { cwd: d, encoding: "utf8" });
|
|
324
|
-
check("the gate leaves the real index untouched (nothing staged)",
|
|
325
|
-
!/^[MARCD]/m.test(status), status.trim());
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// ── worktree awareness (the 1.3.3 known_heads fix) ───────────────────────────
|
|
329
|
-
console.log("gate.py — worktree awareness");
|
|
330
|
-
{
|
|
331
|
-
const pf = { enabled: true, agents: ["review"], max_age_minutes: 30 };
|
|
332
|
-
const d = scratch(); writeConfig(d, { ...GATE_CFG, preflight: pf });
|
|
333
|
-
const mainHead = gitRepo(d, "main");
|
|
334
|
-
const wt = join(d, "..", `wt-${Math.abs(mainHead.charCodeAt(0))}-${tmps.length}`);
|
|
335
|
-
let wtHead = null;
|
|
336
|
-
try {
|
|
337
|
-
execFileSync("git", ["worktree", "add", "-q", "-b", "feature/w", wt], { cwd: d, stdio: "ignore" });
|
|
338
|
-
tmps.push(wt);
|
|
339
|
-
// The worktree MUST diverge, or its HEAD equals the main checkout's and the
|
|
340
|
-
// test passes against the single-HEAD implementation too — a vacuous test
|
|
341
|
-
// (mutation testing is how that was caught).
|
|
342
|
-
writeFileSync(join(wt, "g.txt"), "y");
|
|
343
|
-
execFileSync("git", ["add", "-A"], { cwd: wt, stdio: "ignore" });
|
|
344
|
-
execFileSync("git", ["commit", "-qm", "wt"], { cwd: wt, stdio: "ignore" });
|
|
345
|
-
wtHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: wt, encoding: "utf8" }).trim();
|
|
346
|
-
if (wtHead === mainHead) wtHead = null; // did not diverge ⇒ nothing to prove
|
|
347
|
-
} catch { /* worktree unsupported here — skip */ }
|
|
348
|
-
|
|
349
|
-
if (wtHead) {
|
|
350
|
-
// The preflight legitimately runs in the worktree while the Task dispatch
|
|
351
|
-
// fires from the main checkout (or vice versa). Comparing against a single
|
|
352
|
-
// HEAD flagged those as stale.
|
|
353
|
-
writeFileSync(join(d, ".claude", "preflight.ok"), `${Math.floor(Date.now() / 1000)} ${wtHead}\n`);
|
|
354
|
-
const r = run({ ...task("review"), cwd: d }, { projectDir: d });
|
|
355
|
-
check("a stamp from a linked worktree's HEAD is accepted", r.decision === null,
|
|
356
|
-
`got ${r.decision} — ${r.reason}`);
|
|
357
|
-
} else {
|
|
358
|
-
console.log(" – worktree test skipped (git worktree unavailable)");
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
// ---------------------------------------------------------------------------
|
|
363
|
-
// Runtime dialects. Four runtimes host this hook and none of them agree on the
|
|
364
|
-
// envelope. A verdict emitted in the wrong shape is read as "allow" by every one of
|
|
365
|
-
// them — the gate would look installed, print JSON, and block nothing. Two of them
|
|
366
|
-
// also have no confirmation tier, where an honest `ask` must become a `deny` rather
|
|
367
|
-
// than fall through.
|
|
368
|
-
console.log("gate.py — runtime dialects");
|
|
369
|
-
{
|
|
370
|
-
const d = scratch();
|
|
371
|
-
gitRepo(d, "main");
|
|
372
|
-
writeConfig(d, {
|
|
373
|
-
deny: ["rm -rf /"], ask: ["git push"], ask_on_default_branch: [],
|
|
374
|
-
default_branch: "main", preflight: { enabled: true, agents: ["review"] },
|
|
375
|
-
});
|
|
376
|
-
const raw = (payload, ...args) => {
|
|
377
|
-
const r = spawnSync(python, [GATE, ...args], {
|
|
378
|
-
input: JSON.stringify(payload), encoding: "utf8",
|
|
379
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: d },
|
|
380
|
-
});
|
|
381
|
-
let json = null;
|
|
382
|
-
try { json = JSON.parse((r.stdout || "").trim()); } catch { /* no verdict */ }
|
|
383
|
-
return { json, status: r.status };
|
|
384
|
-
};
|
|
385
|
-
const push = { tool_name: "Bash", tool_input: { command: "git push" }, cwd: d };
|
|
386
|
-
|
|
387
|
-
const cc = raw(push, "--runtime", "claude");
|
|
388
|
-
check("claude: ask stays an ask, in the PreToolUse envelope",
|
|
389
|
-
cc.json?.hookSpecificOutput?.permissionDecision === "ask");
|
|
390
|
-
check("no --runtime flag behaves exactly as claude (pre-adapter registrations)",
|
|
391
|
-
raw(push).json?.hookSpecificOutput?.permissionDecision === "ask");
|
|
392
|
-
|
|
393
|
-
const cx = raw(push, "--runtime", "codex");
|
|
394
|
-
check("codex: ask escalates to deny (its `ask` is parsed but never honoured)",
|
|
395
|
-
cx.json?.hookSpecificOutput?.permissionDecision === "deny");
|
|
396
|
-
check("…and the reason says why it was refused rather than queried",
|
|
397
|
-
/no confirmation tier/.test(cx.json?.hookSpecificOutput?.permissionDecisionReason || ""));
|
|
398
|
-
for (const tool_name of ['spawn_agent', 'Agent']) {
|
|
399
|
-
const dispatch = raw({ tool_name, tool_input: { agent_type: 'review' }, cwd: d }, '--runtime', 'codex');
|
|
400
|
-
check(`codex: ${tool_name} with agent_type is denied without a preflight stamp`,
|
|
401
|
-
dispatch.json?.hookSpecificOutput?.permissionDecision === 'deny'
|
|
402
|
-
&& /preflight/i.test(dispatch.json?.hookSpecificOutput?.permissionDecisionReason || ''));
|
|
403
|
-
check(`codex: ${tool_name} for an ungated implementer passes`,
|
|
404
|
-
raw({ tool_name, tool_input: { agent_type: 'backend' }, cwd: d }, '--runtime', 'codex').json === null);
|
|
405
|
-
}
|
|
406
|
-
const nativeCwd = spawnSync(python, [GATE, '--runtime', 'codex'], {
|
|
407
|
-
input: JSON.stringify({ tool_name: 'spawn_agent', tool_input: { agent_type: 'review' }, cwd: d }),
|
|
408
|
-
cwd: root, encoding: 'utf8',
|
|
409
|
-
env: { ...process.env, CLAUDE_PROJECT_DIR: '', COHORTE_PROJECT_DIR: '' },
|
|
410
|
-
});
|
|
411
|
-
check('codex: payload cwd locates project config without Claude environment variables',
|
|
412
|
-
JSON.parse(nativeCwd.stdout || '{}').hookSpecificOutput?.permissionDecision === 'deny');
|
|
413
|
-
|
|
414
|
-
// Cursor sends the command at the top level and names no tool.
|
|
415
|
-
const cu = raw({ hook_event_name: "beforeShellExecution", command: "git push", cwd: d },
|
|
416
|
-
"--runtime", "cursor");
|
|
417
|
-
check("cursor: its own envelope, and the top-level command is found",
|
|
418
|
-
cu.json?.permission === "ask" && typeof cu.json?.user_message === "string");
|
|
419
|
-
const cuDeny = raw({ hook_event_name: "beforeShellExecution", command: "rm -rf /", cwd: d },
|
|
420
|
-
"--runtime", "cursor");
|
|
421
|
-
check("cursor: a deny also exits 2 (its documented blocking code)",
|
|
422
|
-
cuDeny.json?.permission === "deny" && cuDeny.status === 2);
|
|
423
|
-
|
|
424
|
-
const ge = raw({ tool_name: "run_shell_command", tool_input: { command: "rm -rf /" }, cwd: d },
|
|
425
|
-
"--runtime", "gemini");
|
|
426
|
-
check("gemini: BeforeTool envelope, and run_shell_command is recognised as the shell",
|
|
427
|
-
ge.json?.decision === "deny" && typeof ge.json?.reason === "string");
|
|
428
|
-
|
|
429
|
-
// Gemini exposes each subagent as a tool of its own name, so the phase gate has to fire
|
|
430
|
-
// on `tool_name: review` — not only on Claude's `Task` + subagent_type shape.
|
|
431
|
-
const geDispatch = raw({ tool_name: "review", tool_input: {}, cwd: d }, "--runtime", "gemini");
|
|
432
|
-
check("gemini: a subagent-as-tool dispatch still hits the preflight phase gate",
|
|
433
|
-
geDispatch.json?.decision === "deny"
|
|
434
|
-
&& /preflight/i.test(geDispatch.json?.reason || ""));
|
|
435
|
-
check("an unrelated tool is never gated on any runtime",
|
|
436
|
-
raw({ tool_name: "read_file", tool_input: {}, cwd: d }, "--runtime", "gemini").json === null);
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
for (const d of tmps) { try { rmSync(d, { recursive: true, force: true }); } catch { /* best effort */ } }
|
|
440
|
-
|
|
441
|
-
console.log("");
|
|
442
|
-
if (failures) { console.error(`test-gate: ${failures} failure(s)`); process.exit(1); }
|
|
443
|
-
console.log("test-gate: OK");
|