@haposoft/cafekit 0.18.1 → 0.18.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/bin/__tests__/adv-skill.test.js +371 -0
- package/bin/__tests__/agent-authority.test.js +38 -0
- package/bin/__tests__/agent-instructions.test.js +54 -0
- package/bin/__tests__/claude-plugin-build.test.js +151 -0
- package/bin/__tests__/claude-settings.test.js +123 -0
- package/bin/__tests__/codex-ask-guard.test.js +123 -0
- package/bin/__tests__/codex-ask-projection.test.js +51 -0
- package/bin/__tests__/codex-hooks-ownership.test.js +80 -1
- package/bin/__tests__/codex-hooks.test.js +812 -12
- package/bin/__tests__/codex-implicit-policy.test.js +160 -0
- package/bin/__tests__/codex-native-hook-boundaries.test.js +136 -0
- package/bin/__tests__/codex-native.test.js +198 -58
- package/bin/__tests__/codex-plugin-build.test.js +230 -0
- package/bin/__tests__/codex-plugin-context.test.js +135 -0
- package/bin/__tests__/codex-plugin-hooks.test.js +220 -0
- package/bin/__tests__/codex-projection-parity.test.js +314 -0
- package/bin/__tests__/codex-quiet-hooks.test.js +170 -0
- package/bin/__tests__/codex-research-projection.test.js +70 -0
- package/bin/__tests__/codex-runtime-layout.test.js +81 -0
- package/bin/__tests__/codex-setup-agents.test.js +216 -0
- package/bin/__tests__/codex-specs-projection.test.js +76 -0
- package/bin/__tests__/develop-contract.test.js +1 -1
- package/bin/__tests__/docs-sync-on-demand.test.js +25 -20
- package/bin/__tests__/inspect-paths.test.js +183 -0
- package/bin/__tests__/installer-safety.test.js +335 -0
- package/bin/__tests__/legacy-hooks-retired.test.js +81 -21
- package/bin/__tests__/migrate-apply.test.js +373 -0
- package/bin/__tests__/migrate-cli.test.js +152 -0
- package/bin/__tests__/migrate-merged.test.js +269 -0
- package/bin/__tests__/migrate-plan.test.js +271 -0
- package/bin/__tests__/omp-bridge.test.js +147 -17
- package/bin/__tests__/omp-hooks.test.js +35 -5
- package/bin/__tests__/orca-session.test.js +7 -0
- package/bin/__tests__/plugin-safety-keys.test.js +121 -0
- package/bin/__tests__/privacy-paths.test.js +501 -0
- package/bin/__tests__/usage-hook-retired.test.js +29 -9
- package/bin/__tests__/vibe-run.test.js +215 -0
- package/bin/__tests__/vibe-skill.test.js +549 -0
- package/bin/install.js +124 -1
- package/bin/lib/backup.js +43 -24
- package/bin/lib/codex-install.js +141 -12
- package/bin/lib/context.js +6 -1
- package/bin/lib/lock.js +98 -18
- package/bin/lib/managed-writer.js +16 -10
- package/bin/lib/migrate-apply.js +202 -0
- package/bin/lib/migrate-merged.js +342 -0
- package/bin/lib/migrate-plan.js +419 -0
- package/bin/lib/plugin-inventory.js +33 -0
- package/bin/phases/claude-runtime.js +93 -25
- package/bin/phases/claude-settings.js +22 -1
- package/bin/phases/codex-hooks.js +134 -33
- package/bin/phases/codex-runtime.js +110 -85
- package/bin/phases/copy-payload.js +50 -4
- package/bin/phases/omp-runtime.js +4 -26
- package/bin/phases/root-config.js +5 -2
- package/package.json +1 -1
- package/src/claude/CLAUDE.md +5 -1
- package/src/claude/agents/advisor.md +55 -0
- package/src/claude/agents/debugger.md +2 -3
- package/src/claude/agents/deployer.md +2 -1
- package/src/claude/agents/docs-keeper.md +12 -0
- package/src/claude/agents/inspector.md +1 -1
- package/src/claude/agents/project-manager.md +2 -2
- package/src/claude/agents/researcher.md +0 -1
- package/src/claude/agents/spec-maker.md +4 -1
- package/src/claude/gitignore +3 -0
- package/src/claude/hooks/__tests__/installed-root.test.js +111 -0
- package/src/claude/hooks/__tests__/plugin-context.test.js +96 -0
- package/src/claude/hooks/__tests__/plugin-options.test.js +141 -0
- package/src/claude/hooks/__tests__/plugin-state-isolation.test.js +125 -0
- package/src/claude/hooks/__tests__/precompact.test.js +213 -11
- package/src/claude/hooks/__tests__/privacy-block.test.js +60 -9
- package/src/claude/hooks/__tests__/session-writeenv.test.js +25 -0
- package/src/claude/hooks/__tests__/setup-statusline.test.js +179 -0
- package/src/claude/hooks/__tests__/spec-gate.test.js +227 -11
- package/src/claude/hooks/__tests__/spec-state-touched.test.js +204 -8
- package/src/claude/hooks/__tests__/state.test.js +207 -7
- package/src/claude/hooks/__tests__/update-check.test.js +88 -0
- package/src/claude/hooks/agent.cjs +11 -7
- package/src/claude/hooks/inspect-block.cjs +20 -66
- package/src/claude/hooks/lib/hook-state-dir.cjs +19 -1
- package/src/claude/hooks/lib/inspect-paths.cjs +304 -0
- package/src/claude/hooks/lib/parser.cjs +1 -1
- package/src/claude/hooks/lib/plugin-options.cjs +47 -0
- package/src/claude/hooks/lib/privacy-command-analysis.cjs +317 -24
- package/src/claude/hooks/lib/privacy-paths.cjs +673 -0
- package/src/claude/hooks/lib/project-root.cjs +15 -0
- package/src/claude/hooks/lib/rules-reservation.cjs +36 -0
- package/src/claude/hooks/lib/spec-touch.cjs +80 -0
- package/src/claude/hooks/lib/update-check.cjs +77 -0
- package/src/claude/hooks/plugin-context.cjs +80 -0
- package/src/claude/hooks/precompact.cjs +8 -15
- package/src/claude/hooks/privacy-block.cjs +25 -206
- package/src/claude/hooks/rules.cjs +10 -29
- package/src/claude/hooks/session.cjs +37 -117
- package/src/claude/hooks/spec-gate.cjs +9 -26
- package/src/claude/hooks/spec-state.cjs +44 -59
- package/src/claude/hooks/state.cjs +101 -52
- package/src/claude/migration-manifest.json +14 -2
- package/src/claude/plugin/commands/setup-statusline.md +12 -0
- package/src/claude/plugin/core.md +71 -0
- package/src/claude/rules/hook-protocols.md +1 -1
- package/src/claude/rules/manage-docs.md +1 -2
- package/src/claude/rules/review-audit-self-decision.md +1 -1
- package/src/claude/rules/skill-workflow-routing.md +1 -1
- package/src/claude/rules/state-sync.md +14 -0
- package/src/claude/rules/workflow.md +1 -1
- package/src/claude/runtime.json +0 -3
- package/src/claude/scripts/generate-skill-catalog.cjs +7 -2
- package/src/claude/scripts/provenance.cjs +54 -16
- package/src/claude/scripts/setup-statusline.cjs +88 -0
- package/src/claude/settings/settings.json +1 -1
- package/src/claude/skills/adv/SKILL.md +139 -0
- package/src/claude/skills/adv/references/optional-modes.md +41 -0
- package/src/claude/skills/adv/references/ultra-mode.md +29 -0
- package/src/claude/skills/ai-multimodal/scripts/check_setup.py +8 -68
- package/src/claude/skills/ai-multimodal/scripts/gemini_batch_process.py +6 -28
- package/src/claude/skills/brainstorm/SKILL.md +1 -1
- package/src/claude/skills/code-review/SKILL.md +4 -1
- package/src/claude/skills/code-review/references/pr-fix-reply.md +42 -0
- package/src/claude/skills/develop/SKILL.md +5 -3
- package/src/claude/skills/develop/references/quality-gate.md +1 -1
- package/src/claude/skills/fix/SKILL.md +2 -2
- package/src/claude/skills/git/SKILL.md +5 -3
- package/src/claude/skills/git/references/merge-pr.md +125 -0
- package/src/claude/skills/git/references/worktree-blueprint.md +1 -1
- package/src/claude/skills/specs/SKILL.md +2 -2
- package/src/claude/skills/specs/references/review.md +1 -1
- package/src/claude/skills/specs/references/templates.md +2 -0
- package/src/claude/skills/vibe/SKILL.md +69 -0
- package/src/claude/skills/vibe/references/pipeline-skill-integration.md +34 -0
- package/src/claude/skills/vibe/references/report-templates.md +84 -0
- package/src/claude/skills/vibe/references/subagent-orchestration.md +23 -0
- package/src/codex/AGENTS.md +9 -1
- package/src/codex/hooks/ask-guard.cjs +54 -0
- package/src/codex/hooks/inspect-block.cjs +15 -8
- package/src/codex/hooks/lib/hook-context.cjs +118 -4
- package/src/codex/hooks/lib/inspect-paths.cjs +1 -199
- package/src/codex/hooks/lib/plugin-options.cjs +2 -0
- package/src/codex/hooks/lib/privacy-paths.cjs +1 -0
- package/src/codex/hooks/lib/privacy-state.cjs +116 -17
- package/src/codex/hooks/lib/rules-reservation.cjs +38 -0
- package/src/codex/hooks/lib/secret-keywords.cjs +1 -0
- package/src/codex/hooks/lib/spec-session-touch.cjs +100 -0
- package/src/codex/hooks/lib/spec-touch.cjs +3 -0
- package/src/codex/hooks/lib/state-store.cjs +5 -4
- package/src/codex/hooks/lib/update-check.cjs +1 -0
- package/src/codex/hooks/precompact.cjs +40 -0
- package/src/codex/hooks/privacy-approval.cjs +28 -17
- package/src/codex/hooks/privacy-block.cjs +11 -216
- package/src/codex/hooks/rules.cjs +14 -34
- package/src/codex/hooks/secret-output-guardrail.cjs +1 -1
- package/src/codex/hooks/session.cjs +59 -11
- package/src/codex/hooks/spec-gate.cjs +21 -6
- package/src/codex/hooks/spec-state.cjs +32 -6
- package/src/codex/hooks/state.cjs +25 -4
- package/src/codex/hooks.json +39 -7
- package/src/codex/plugin/core.md +76 -0
- package/src/codex/plugin/hooks/plugin-context.cjs +63 -0
- package/src/codex/plugin/skills/setup-agents/SKILL.md +17 -0
- package/src/codex/rules/hook-protocols.md +13 -6
- package/src/codex/scripts/setup-agents.cjs +124 -0
- package/src/common/AGENTS.md +1 -1
- package/src/omp/extensions/{cafekit-bridge.mjs → cafekit-bridge.js} +85 -9
- package/src/omp/hooks/privacy-block.cjs +26 -207
- package/src/codex/rules/state-sync.md +0 -60
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Install the CafeKit agents shipped in the Codex plugin into the user's Codex agents
|
|
6
|
+
* directory ($CODEX_HOME/agents, default ~/.codex/agents). Codex loads no agents from a
|
|
7
|
+
* plugin, so this runs once after the plugin is installed or updated.
|
|
8
|
+
*
|
|
9
|
+
* Every shipped file starts with a marker naming the plugin, its version and the sha256
|
|
10
|
+
* of the body below it. A file counts as CafeKit's own only when its marker hash still
|
|
11
|
+
* matches its body; only those are replaced. Anything else stays byte for byte:
|
|
12
|
+
* a missing marker, an edited body, or a symlink.
|
|
13
|
+
*
|
|
14
|
+
* A file it cannot read is kept and reported, and the other agents still install.
|
|
15
|
+
* Takes no arguments: the target is never chosen by the caller.
|
|
16
|
+
* Exit: 0 done, 1 failure, 2 usage.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const crypto = require('crypto');
|
|
23
|
+
const { spawnSync } = require('child_process');
|
|
24
|
+
|
|
25
|
+
const MARKER = /^# CafeKit agent: plugin=(\S+) version=(\S+) sha256=([0-9a-f]{64})\n/;
|
|
26
|
+
|
|
27
|
+
const sha256 = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
28
|
+
|
|
29
|
+
/** 'absent' | 'symlink' | 'foreign' | 'modified' | 'own' */
|
|
30
|
+
function ownership(file) {
|
|
31
|
+
let stat;
|
|
32
|
+
try {
|
|
33
|
+
stat = fs.lstatSync(file);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT') return 'absent';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (stat.isSymbolicLink()) return 'symlink';
|
|
39
|
+
if (!stat.isFile()) return 'foreign';
|
|
40
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
41
|
+
const marker = text.match(MARKER);
|
|
42
|
+
if (!marker) return 'foreign';
|
|
43
|
+
return sha256(text.slice(marker[0].length)) === marker[3] ? 'own' : 'modified';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Write beside the target and rename over it, so a reader never sees half a file.
|
|
47
|
+
function writeAtomic(file, data, mode) {
|
|
48
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
49
|
+
try {
|
|
50
|
+
fs.writeFileSync(temp, data, { mode, flag: 'wx' });
|
|
51
|
+
fs.chmodSync(temp, mode);
|
|
52
|
+
fs.renameSync(temp, file);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
fs.rmSync(temp, { force: true });
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Codex reads agents from its home, so a session started with CODEX_HOME installs there.
|
|
60
|
+
// A relative CODEX_HOME is refused rather than guessed, so it never falls back to ~/.codex.
|
|
61
|
+
function codexHome() {
|
|
62
|
+
const configured = process.env.CODEX_HOME;
|
|
63
|
+
if (!configured) return path.join(os.homedir(), '.codex');
|
|
64
|
+
if (!path.isAbsolute(configured)) throw new Error(`CODEX_HOME must be an absolute path: ${configured}`);
|
|
65
|
+
return configured;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function repositoryRoot() {
|
|
69
|
+
const result = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 5000 });
|
|
70
|
+
return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : process.cwd();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function main(argv) {
|
|
74
|
+
if (argv.length > 0) {
|
|
75
|
+
process.stderr.write('setup-agents takes no arguments; it installs into $CODEX_HOME/agents (default ~/.codex/agents).\n');
|
|
76
|
+
return 2;
|
|
77
|
+
}
|
|
78
|
+
const sourceDir = path.join(__dirname, '..', 'agents');
|
|
79
|
+
const targetDir = path.join(codexHome(), 'agents');
|
|
80
|
+
const repoAgents = path.join(repositoryRoot(), '.codex', 'agents');
|
|
81
|
+
const files = fs.readdirSync(sourceDir).filter((name) => name.endsWith('.toml')).sort();
|
|
82
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
83
|
+
|
|
84
|
+
const counts = {};
|
|
85
|
+
for (const name of files) {
|
|
86
|
+
const shipped = fs.readFileSync(path.join(sourceDir, name), 'utf8');
|
|
87
|
+
const target = path.join(targetDir, name);
|
|
88
|
+
let state;
|
|
89
|
+
try {
|
|
90
|
+
state = ownership(target);
|
|
91
|
+
} catch {
|
|
92
|
+
state = 'unreadable';
|
|
93
|
+
}
|
|
94
|
+
let action;
|
|
95
|
+
if (state === 'absent') {
|
|
96
|
+
writeAtomic(target, shipped, 0o644);
|
|
97
|
+
action = 'written';
|
|
98
|
+
} else if (state === 'own') {
|
|
99
|
+
if (fs.readFileSync(target, 'utf8') === shipped) {
|
|
100
|
+
action = 'unchanged';
|
|
101
|
+
} else {
|
|
102
|
+
writeAtomic(target, shipped, fs.statSync(target).mode & 0o777);
|
|
103
|
+
action = 'replaced';
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
action = { symlink: 'kept (symlink)', foreign: 'kept (not installed by CafeKit)', modified: 'kept (modified)', unreadable: 'kept (unreadable)' }[state];
|
|
107
|
+
}
|
|
108
|
+
counts[action] = (counts[action] || 0) + 1;
|
|
109
|
+
process.stdout.write(`${action}: ${target}\n`);
|
|
110
|
+
if (fs.existsSync(path.join(repoAgents, name))) {
|
|
111
|
+
process.stdout.write(`collision: ${path.join(repoAgents, name)} has the same agent name in this repository\n`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const summary = Object.entries(counts).map(([action, count]) => `${count} ${action}`).join(', ');
|
|
115
|
+
process.stdout.write(`CafeKit agents in ${targetDir}: ${summary}\n`);
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
process.exitCode = main(process.argv.slice(2));
|
|
121
|
+
} catch (error) {
|
|
122
|
+
process.stderr.write(`setup-agents failed: ${error.message}\n`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
}
|
package/src/common/AGENTS.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
keeps canonical execution proof in its final inline `## Receipt`.
|
|
9
9
|
- Specs uses three user decisions: GATE-SCOPE for scope, GATE-REVIEW for adversarial findings,
|
|
10
10
|
and GATE-DONE for completion. Planning never starts implementation; implementation
|
|
11
|
-
requires a new explicit user invocation.
|
|
11
|
+
requires a new explicit user invocation. A vibe run the user invokes delegates these gates as the state-sync rule's vibe-run exception states.
|
|
12
12
|
- Synchronize only observed task state with surgical edits. Never invent proof,
|
|
13
13
|
readiness, approval, review independence, or completed work.
|
|
14
14
|
- `NO_TESTS` and `0 tests + exit 0` do not pass when the task requires automated tests.
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* payload the scripts read, runs the scripts as child processes, and translates their
|
|
10
10
|
* verdicts back into omp's contract.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Facts read from the installed omp binary drive the design:
|
|
13
|
+
* - omp auto-loads only `<name>.{ts,js}` and `<dir>/index.{ts,js}` from `.omp/extensions/`, so this
|
|
14
|
+
* file is `.js`; an `.mjs` bridge is never loaded and every gate is silently off.
|
|
15
|
+
* - omp is a Bun binary: `process.execPath` is omp itself, not node, so the hooks are run
|
|
16
|
+
* with a `node` found on PATH (see hookRunner).
|
|
13
17
|
* - omp fails CLOSED at `tool_call`: a handler that throws or exceeds
|
|
14
18
|
* `extensionHandlers.toolCallTimeoutMs` (default 30000) is replaced by omp's own
|
|
15
19
|
* `{ block: true, reason: "Extension … timed out" }`. The bridge therefore keeps a
|
|
@@ -26,7 +30,7 @@
|
|
|
26
30
|
|
|
27
31
|
import { spawn } from 'node:child_process';
|
|
28
32
|
import { randomUUID } from 'node:crypto';
|
|
29
|
-
import { existsSync } from 'node:fs';
|
|
33
|
+
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs';
|
|
30
34
|
import path from 'node:path';
|
|
31
35
|
import { fileURLToPath } from 'node:url';
|
|
32
36
|
|
|
@@ -38,16 +42,16 @@ export const BRIDGE_TIMEOUT_MS = 8000;
|
|
|
38
42
|
* Mirrors src/claude/settings/settings.json; `omp-bridge.test.js` fails if the two drift.
|
|
39
43
|
*/
|
|
40
44
|
export const DISPATCH = {
|
|
41
|
-
session_start: { event: 'SessionStart', hooks: ['session.cjs', '
|
|
45
|
+
session_start: { event: 'SessionStart', hooks: ['session.cjs', 'state.cjs'] },
|
|
42
46
|
session_compact: { event: 'SessionStart', hooks: ['session.cjs'], source: 'compact' },
|
|
43
47
|
session_before_compact: { event: 'PreCompact', hooks: ['precompact.cjs'] },
|
|
44
48
|
input: { event: 'UserPromptSubmit', hooks: ['secret-output-guardrail.cjs', 'rules.cjs', 'spec-state.cjs'] },
|
|
45
49
|
tool_call: { event: 'PreToolUse', hooks: ['privacy-block.cjs', 'inspect-block.cjs'] },
|
|
46
|
-
tool_result: { event: 'PostToolUse', hooks: ['state.cjs'] },
|
|
50
|
+
tool_result: { event: 'PostToolUse', hooks: ['state.cjs', 'spec-state.cjs'] },
|
|
47
51
|
session_stop: { event: 'Stop', hooks: ['spec-gate.cjs', 'state.cjs'] },
|
|
48
52
|
};
|
|
49
53
|
|
|
50
|
-
/**
|
|
54
|
+
/** Fallback id per extension load, for an omp whose context has no session manager. */
|
|
51
55
|
const SESSION_ID = `omp-${randomUUID()}`;
|
|
52
56
|
|
|
53
57
|
export function hooksDir() {
|
|
@@ -55,13 +59,37 @@ export function hooksDir() {
|
|
|
55
59
|
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'hooks');
|
|
56
60
|
}
|
|
57
61
|
|
|
62
|
+
function sessionIdFrom(ctx) {
|
|
63
|
+
try {
|
|
64
|
+
const id = ctx?.sessionManager?.getSessionId?.();
|
|
65
|
+
return typeof id === 'string' && id ? id : null;
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Only the file paths of a tool result's details. An edit's details also carry the old and
|
|
71
|
+
* new text of every file it changed, which no hook needs on stdin.
|
|
72
|
+
*/
|
|
73
|
+
function resultPaths(details) {
|
|
74
|
+
if (!details || typeof details !== 'object') return {};
|
|
75
|
+
const paths = (list) => (Array.isArray(list) ? list.filter((entry) => typeof entry?.path === 'string').map((entry) => ({ path: entry.path })) : undefined);
|
|
76
|
+
const out = {};
|
|
77
|
+
if (typeof details.path === 'string') out.path = details.path;
|
|
78
|
+
const perFile = paths(details.perFileResults);
|
|
79
|
+
if (perFile) out.perFileResults = perFile;
|
|
80
|
+
const changes = paths(details.fileChanges);
|
|
81
|
+
if (changes) out.fileChanges = changes;
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
/** Build the Claude-shaped payload a CafeKit hook reads on stdin. */
|
|
59
86
|
export function shapePayload(kind, event, ctx) {
|
|
60
87
|
const entry = DISPATCH[kind];
|
|
61
88
|
const cwd = ctx?.cwd || process.cwd();
|
|
62
89
|
const payload = {
|
|
63
90
|
hook_event_name: entry.event,
|
|
64
|
-
|
|
91
|
+
// omp's own id first: session_stop carries it, and touch and state must stay in one place.
|
|
92
|
+
session_id: event?.session_id || sessionIdFrom(ctx) || SESSION_ID,
|
|
65
93
|
cwd,
|
|
66
94
|
};
|
|
67
95
|
switch (kind) {
|
|
@@ -85,7 +113,7 @@ export function shapePayload(kind, event, ctx) {
|
|
|
85
113
|
case 'tool_result':
|
|
86
114
|
payload.tool_name = event?.toolName ?? event?.tool_name ?? '';
|
|
87
115
|
payload.tool_input = event?.input ?? {};
|
|
88
|
-
payload.tool_response = { content: event?.content ?? [], isError: event?.isError === true };
|
|
116
|
+
payload.tool_response = { content: event?.content ?? [], isError: event?.isError === true, details: resultPaths(event?.details) };
|
|
89
117
|
payload.tool_use_id = event?.toolCallId ?? null;
|
|
90
118
|
break;
|
|
91
119
|
case 'session_stop':
|
|
@@ -135,12 +163,54 @@ export function interpretVerdict({ code, stdout = '', stderr = '' }, hookName) {
|
|
|
135
163
|
return { block: Boolean(block), reason: block?.reason, inject: inject.join('\n').trim() };
|
|
136
164
|
}
|
|
137
165
|
|
|
166
|
+
function isInside(root, target) {
|
|
167
|
+
const rel = path.relative(root, target);
|
|
168
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function realOrSelf(target) {
|
|
172
|
+
try { return realpathSync(target); } catch { return path.resolve(target); }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The executable that runs a hook. Under node that is `process.execPath`. Under Bun (omp)
|
|
177
|
+
* it is the first `node` on PATH that sits in an absolute PATH entry outside the project,
|
|
178
|
+
* so a repository cannot plant its own `node` (`.`, `node_modules/.bin`) in front of the
|
|
179
|
+
* gates. Returns `{ command }` or `{ error }`; never throws.
|
|
180
|
+
*/
|
|
181
|
+
export function hookRunner({ cwd = process.cwd(), env = process.env, bun = process.versions?.bun, platform = process.platform } = {}) {
|
|
182
|
+
if (!bun) return { command: process.execPath };
|
|
183
|
+
const project = realOrSelf(cwd);
|
|
184
|
+
const names = platform === 'win32'
|
|
185
|
+
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean).map((ext) => `node${ext.toLowerCase()}`)
|
|
186
|
+
: ['node'];
|
|
187
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
188
|
+
for (const dir of String(env.PATH || env.Path || '').split(delimiter)) {
|
|
189
|
+
if (!dir || !path.isAbsolute(dir)) continue;
|
|
190
|
+
for (const name of names) {
|
|
191
|
+
const candidate = path.join(dir, name);
|
|
192
|
+
try {
|
|
193
|
+
if (!statSync(candidate).isFile()) continue;
|
|
194
|
+
if (platform !== 'win32') accessSync(candidate, constants.X_OK);
|
|
195
|
+
} catch { continue; }
|
|
196
|
+
if (isInside(project, realOrSelf(candidate))) continue;
|
|
197
|
+
return { command: candidate };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { error: 'node was not found on PATH outside the project' };
|
|
201
|
+
}
|
|
202
|
+
|
|
138
203
|
/** Run one hook as a child process with a hard budget. Never throws. */
|
|
139
204
|
export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOUT_MS } = {}) {
|
|
140
205
|
return new Promise((resolve) => {
|
|
206
|
+
const runner = hookRunner({ cwd });
|
|
207
|
+
if (runner.error) {
|
|
208
|
+
resolve({ code: null, signal: null, stdout: '', stderr: runner.error, timedOut: false, failed: true });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
141
211
|
let child;
|
|
142
212
|
try {
|
|
143
|
-
child = spawn(
|
|
213
|
+
child = spawn(runner.command, [hookPath], {
|
|
144
214
|
cwd,
|
|
145
215
|
env: { ...process.env, PROJECT_ROOT: cwd },
|
|
146
216
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -159,7 +229,7 @@ export function dispatchHook(hookPath, payload, { cwd, timeoutMs = BRIDGE_TIMEOU
|
|
|
159
229
|
child.stdout.on('data', (c) => out.push(c));
|
|
160
230
|
child.stderr.on('data', (c) => err.push(c));
|
|
161
231
|
child.on('error', (error) => { clearTimeout(timer); finish({ code: null, stdout: '', stderr: String(error?.message || error), timedOut: false, failed: true }); });
|
|
162
|
-
child.on('close', (code) => { clearTimeout(timer); finish({ code, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: false, failed: false }); });
|
|
232
|
+
child.on('close', (code, signal) => { clearTimeout(timer); finish({ code, signal, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8'), timedOut: false, failed: false }); });
|
|
163
233
|
try { child.stdin.write(JSON.stringify(payload)); child.stdin.end(); } catch { /* close handler still runs */ }
|
|
164
234
|
});
|
|
165
235
|
}
|
|
@@ -198,6 +268,12 @@ export async function runEvent(kind, event, ctx, { timeoutMs = BRIDGE_TIMEOUT_MS
|
|
|
198
268
|
}
|
|
199
269
|
const verdict = interpretVerdict(result, hookName);
|
|
200
270
|
if (verdict.block) return { block: true, reason: verdict.reason, hook: hookName };
|
|
271
|
+
// A gate that dies without a verdict (a broken node shim, a crash, a kill) must not
|
|
272
|
+
// read as an allow: only 0 and 2 are answers a gate gives on purpose.
|
|
273
|
+
if (gating && result.code !== 0 && result.code !== 2) {
|
|
274
|
+
const how = result.signal ? `was killed by ${result.signal}` : `exited with code ${result.code}`;
|
|
275
|
+
return { block: true, reason: `CafeKit gate ${hookName} ${how}`, hook: hookName };
|
|
276
|
+
}
|
|
201
277
|
if (verdict.inject) injections.push(verdict.inject);
|
|
202
278
|
}
|
|
203
279
|
return { block: false, inject: injections.join('\n\n') };
|
|
@@ -14,32 +14,23 @@
|
|
|
14
14
|
* Exit: 0 (the JSON hook output carries the permission decision)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const fs = require('fs');
|
|
19
|
-
const path = require('path');
|
|
20
|
-
const { commandAccess } = require('./lib/privacy-command-analysis.cjs');
|
|
17
|
+
const EVALUATION_FAILURE = 'Sensitive access could not be evaluated safely.';
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
/\.pgpass$/i,
|
|
33
|
-
/kubeconfig/i,
|
|
34
|
-
/\.keystore$/i,
|
|
35
|
-
/\.jks$/i,
|
|
36
|
-
/auth\.json$/i,
|
|
37
|
-
/token(s)?\.json$/i
|
|
38
|
-
];
|
|
19
|
+
function askForPermission(reason) {
|
|
20
|
+
process.stdout.write(JSON.stringify({
|
|
21
|
+
hookSpecificOutput: {
|
|
22
|
+
hookEventName: 'PreToolUse',
|
|
23
|
+
// omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
|
|
24
|
+
permissionDecision: 'deny',
|
|
25
|
+
permissionDecisionReason: reason
|
|
26
|
+
}
|
|
27
|
+
}) + '\n');
|
|
28
|
+
}
|
|
39
29
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
30
|
+
try {
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const { sensitivePaths, displayNames } = require('./lib/privacy-paths.cjs');
|
|
33
|
+
const { projectRoot: resolveProjectRoot } = require('./lib/project-root.cjs');
|
|
43
34
|
|
|
44
35
|
const { runtimeDirName, runtimeDir, runtimePath } = require('./lib/runtime-dir.cjs');
|
|
45
36
|
function readRuntime(cwd) {
|
|
@@ -51,200 +42,28 @@ try {
|
|
|
51
42
|
}
|
|
52
43
|
}
|
|
53
44
|
|
|
54
|
-
function isSafe(filePath) {
|
|
55
|
-
const base = path.basename(filePath);
|
|
56
|
-
return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function isSensitive(filePath) {
|
|
60
|
-
const base = path.basename(filePath);
|
|
61
|
-
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Resolve a symlink to its real target so a harmless-looking name that points
|
|
66
|
-
* at a sensitive file cannot slip through the basename check. Returns null
|
|
67
|
-
* when the path cannot be resolved (missing file, broken link) — fail-open to
|
|
68
|
-
* the original-path check in that case. See isSymlinkToSensitive for dangling handling.
|
|
69
|
-
*/
|
|
70
|
-
function resolveTarget(filePath, cwd) {
|
|
71
|
-
try {
|
|
72
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
73
|
-
return fs.realpathSync(requested);
|
|
74
|
-
} catch {
|
|
75
|
-
return null;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* When realpath fails, check whether the requested path is a symlink (or chain)
|
|
81
|
-
* whose lexical target is sensitive. Dangling link to protected target -> true (fail-closed ask);
|
|
82
|
-
* dangling benign link or link to allowed example/template -> false (allow).
|
|
83
|
-
* Limit hops to avoid loops; malformed/looping -> fail closed.
|
|
84
|
-
* TOCTOU note: check and use are not atomic; target could be swapped between this check and the actual read.
|
|
85
|
-
* This is a best-effort guardrail, not an arbitrary-shell security boundary. For strong isolation use OS sandbox.
|
|
86
|
-
*/
|
|
87
|
-
function isSymlinkToSensitive(filePath, cwd) {
|
|
88
|
-
try {
|
|
89
|
-
const requested = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
|
90
|
-
let current = requested;
|
|
91
|
-
let hops = 0;
|
|
92
|
-
const MAX_HOPS = 8;
|
|
93
|
-
while (hops < MAX_HOPS) {
|
|
94
|
-
let stat;
|
|
95
|
-
try {
|
|
96
|
-
stat = fs.lstatSync(current);
|
|
97
|
-
} catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
if (!stat.isSymbolicLink()) return false;
|
|
101
|
-
let linkTarget;
|
|
102
|
-
try {
|
|
103
|
-
linkTarget = fs.readlinkSync(current);
|
|
104
|
-
} catch {
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
if (isSensitive(linkTarget) && !isSafe(linkTarget)) return true;
|
|
108
|
-
if (isSafe(linkTarget)) return false;
|
|
109
|
-
const next = path.isAbsolute(linkTarget) ? linkTarget : path.resolve(path.dirname(current), linkTarget);
|
|
110
|
-
if (next === current) return true;
|
|
111
|
-
try {
|
|
112
|
-
const real = fs.realpathSync(next);
|
|
113
|
-
if (isSensitive(real) && !isSafe(real)) return true;
|
|
114
|
-
if (isSafe(real)) return false;
|
|
115
|
-
return false;
|
|
116
|
-
} catch {
|
|
117
|
-
current = next;
|
|
118
|
-
hops += 1;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return true;
|
|
123
|
-
} catch {
|
|
124
|
-
return true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function extractBashPaths(command) {
|
|
129
|
-
// The analyzer parses the command and returns the operands of every reading
|
|
130
|
-
// command it recognises, nested shells and `find -exec` included. Treating
|
|
131
|
-
// every unresolved expansion as sensitive, as this did before, asked on
|
|
132
|
-
// `cat $FILE`, `wc -l *.cjs` and `cat ~/.zshrc` alike. Noise is what trains
|
|
133
|
-
// people to click through the prompt that matters.
|
|
134
|
-
// A heredoc whose delimiter is quoted (<<'BODY') is inert: the shell
|
|
135
|
-
// expands nothing inside it, so no word in the body is ever read. The
|
|
136
|
-
// analyzer has no notion of heredocs and parses the body as shell, so prose
|
|
137
|
-
// that quotes a command -- a PR body carrying `cat .env` in backticks --
|
|
138
|
-
// came back as a real command substitution. Blank those bodies out first.
|
|
139
|
-
// An unquoted delimiter (<<BODY) does expand, so it is left to the
|
|
140
|
-
// analyzer.
|
|
141
|
-
const scrubbed = String(command).replace(
|
|
142
|
-
/(<<-?[ \t]*)(['"])([A-Za-z_]\w*)\2[^\n]*\n[\s\S]*?\n[ \t]*\3\b/g,
|
|
143
|
-
'$1$2$3$2'
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
const paths = new Set(commandAccess(scrubbed).paths);
|
|
147
|
-
|
|
148
|
-
// Two shapes the analyzer cannot return. It drops any word carrying an
|
|
149
|
-
// expansion, so `~/.ssh/id_rsa` and `certs/*.pem` still have to be matched
|
|
150
|
-
// by name. And it only walks commands it recognises, so an unknown tool
|
|
151
|
-
// handed a secret through a file-shaped option, such as
|
|
152
|
-
// `docker compose --env-file .env`, is invisible to it.
|
|
153
|
-
const FILE_OPTION = /^--?[\w-]*(?:file|config|key|cert|identity)$/i;
|
|
154
|
-
const tokens = scrubbed.split(/[\s|;&<>"'()`]+/);
|
|
155
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
156
|
-
const token = tokens[index];
|
|
157
|
-
const shaped = /^~|[*?[{]/.test(token);
|
|
158
|
-
const optionFed = index > 0 && FILE_OPTION.test(tokens[index - 1]);
|
|
159
|
-
if (!shaped && !optionFed) continue;
|
|
160
|
-
const value = token.replace(/^~/, '').replace(/^["'()`]+|["'(),]+$/g, '');
|
|
161
|
-
if (value && isSensitive(value) && !isSafe(value)) paths.add(value);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return [...paths];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function extractPaths(toolName, input) {
|
|
168
|
-
const paths = [];
|
|
169
|
-
if (!input) return paths;
|
|
170
|
-
|
|
171
|
-
for (const key of ['file_path', 'path']) {
|
|
172
|
-
if (typeof input[key] === 'string' && input[key].trim()) {
|
|
173
|
-
paths.push(input[key].trim());
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
for (const key of ['paths', 'search_paths']) {
|
|
178
|
-
if (Array.isArray(input[key])) {
|
|
179
|
-
paths.push(...input[key].filter(Boolean));
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (toolName === 'Bash' && typeof input.command === 'string') {
|
|
184
|
-
paths.push(...extractBashPaths(input.command));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return paths.filter(Boolean);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function askForPermission(filePath) {
|
|
191
|
-
const basename = path.basename(filePath);
|
|
192
|
-
process.stdout.write(JSON.stringify({
|
|
193
|
-
hookSpecificOutput: {
|
|
194
|
-
hookEventName: 'PreToolUse',
|
|
195
|
-
// omp has no ask state on tool_call, so an ask under Claude becomes a denial here.
|
|
196
|
-
permissionDecision: 'deny',
|
|
197
|
-
permissionDecisionReason: `Sensitive file access requires approval: ${basename}`
|
|
198
|
-
}
|
|
199
|
-
}) + '\n');
|
|
200
|
-
}
|
|
201
|
-
|
|
202
45
|
const stdin = fs.readFileSync(0, 'utf8').trim();
|
|
203
|
-
if (!stdin)
|
|
46
|
+
if (!stdin) {
|
|
47
|
+
askForPermission(EVALUATION_FAILURE);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
204
50
|
|
|
205
51
|
const { normalizeHookPayload } = require('./lib/hook-payload.cjs');
|
|
206
52
|
const data = normalizeHookPayload(JSON.parse(stdin));
|
|
207
53
|
const toolName = data.tool_name || '';
|
|
208
54
|
const toolInput = data.tool_input || {};
|
|
209
55
|
const cwd = typeof data.cwd === 'string' && data.cwd.trim() ? data.cwd : process.cwd();
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
if (runtime.privacyBlock === false) process.exit(0);
|
|
213
|
-
|
|
214
|
-
const paths = extractPaths(toolName, toolInput);
|
|
215
|
-
if (!paths.length) process.exit(0);
|
|
56
|
+
const projectRoot = resolveProjectRoot(data);
|
|
57
|
+
const runtime = readRuntime(projectRoot);
|
|
216
58
|
|
|
217
|
-
for
|
|
218
|
-
|
|
59
|
+
// A plugin is trusted once for every repository, so a repository cannot switch it off.
|
|
60
|
+
if (runtime.privacyBlock === false && !require('./lib/plugin-options.cjs').isPlugin()) process.exit(0);
|
|
219
61
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (target) {
|
|
223
|
-
if (isSensitive(target) && !isSafe(target)) {
|
|
224
|
-
askForPermission(filePath);
|
|
225
|
-
process.exit(0);
|
|
226
|
-
}
|
|
227
|
-
if (isSafe(target)) continue;
|
|
228
|
-
} else {
|
|
229
|
-
if (isSymlinkToSensitive(filePath, cwd)) {
|
|
230
|
-
askForPermission(filePath);
|
|
231
|
-
process.exit(0);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (isSafe(filePath) || !isSensitive(filePath)) continue;
|
|
236
|
-
askForPermission(filePath);
|
|
237
|
-
process.exit(0);
|
|
238
|
-
}
|
|
62
|
+
const paths = sensitivePaths(toolName, toolInput, cwd);
|
|
63
|
+
if (paths.length) askForPermission(`Sensitive file access requires approval: ${displayNames(paths)}`);
|
|
239
64
|
|
|
240
65
|
process.exit(0);
|
|
241
66
|
} catch (_) {
|
|
242
|
-
|
|
243
|
-
hookSpecificOutput: {
|
|
244
|
-
hookEventName: 'PreToolUse',
|
|
245
|
-
permissionDecision: 'deny',
|
|
246
|
-
permissionDecisionReason: 'Sensitive access could not be evaluated safely.'
|
|
247
|
-
}
|
|
248
|
-
}) + '\n');
|
|
67
|
+
askForPermission(EVALUATION_FAILURE);
|
|
249
68
|
process.exit(0);
|
|
250
69
|
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# Process-first state synchronization
|
|
2
|
-
|
|
3
|
-
## Primary file state
|
|
4
|
-
|
|
5
|
-
For new work, `specs/<feature>/plan.md` and its direct-child
|
|
6
|
-
`task-NN-*.md` files are canonical, hand-editable state. Task files stay flat
|
|
7
|
-
beside the plan; indexes and host task views are disposable projections.
|
|
8
|
-
|
|
9
|
-
Each task has exactly one `Status:` field using `pending`, `in_progress`,
|
|
10
|
-
`paused`, `blocked`, or `done`. The controller is the sole writer of task
|
|
11
|
-
status and proof. A task's final `## Receipt` is its only canonical execution
|
|
12
|
-
proof.
|
|
13
|
-
|
|
14
|
-
## Surgical synchronization
|
|
15
|
-
|
|
16
|
-
Before changing state:
|
|
17
|
-
|
|
18
|
-
1. read the plan row and the task's Outcome, Acceptance, Dependencies,
|
|
19
|
-
Verification Plan, Status, and Receipt;
|
|
20
|
-
2. confirm every dependency and owned path at the requested boundary;
|
|
21
|
-
3. edit only the observed Status, concrete blocker, implemented checkbox, or
|
|
22
|
-
Receipt; and
|
|
23
|
-
4. re-read the file and confirm exactly one Status field and one Receipt
|
|
24
|
-
section.
|
|
25
|
-
|
|
26
|
-
Starting or resuming changes only Status to `in_progress`. Blocking records a
|
|
27
|
-
specific cause without creating proof. Do not replace a whole packet to make a
|
|
28
|
-
state transition.
|
|
29
|
-
|
|
30
|
-
Set `Status: done` only after the inline Receipt passes the
|
|
31
|
-
`validateCanonicalReceipt` contract: exact command, `Exit: 0`,
|
|
32
|
-
`Verification: PASS`, runtime-derived Base and Head values, and a non-empty
|
|
33
|
-
fenced block of current command output.
|
|
34
|
-
|
|
35
|
-
A done task is bound to live Base and Head only while its own task file has a copy in
|
|
36
|
-
`HEAD` that its current bytes no longer match. A file that is committed and unchanged,
|
|
37
|
-
and a file that was never committed at all, are both validated on structure alone: the
|
|
38
|
-
first has a baseline it still matches, the second has no baseline to drift from, and
|
|
39
|
-
committing a packet is the user's choice that a gitignored specs root removes entirely.
|
|
40
|
-
Work elsewhere in the tree never reopens a receipt, because Base is the last commit
|
|
41
|
-
touching anything outside the specs root, so every finished receipt records an older
|
|
42
|
-
Base by construction. Every structural check runs in all cases.
|
|
43
|
-
The gate detects drift, not invention: a valid
|
|
44
|
-
Base and Head pair costs one command and no verification run, so neither mode proves
|
|
45
|
-
that the command was executed. Missing, stale, contradictory,
|
|
46
|
-
placeholder, copied, failure, or zero-test evidence leaves the task unfinished.
|
|
47
|
-
|
|
48
|
-
Synchronization never fabricates commands, output, timestamps, completed
|
|
49
|
-
work, reviewer identity, user approval, or product readiness. A passing command
|
|
50
|
-
proves only the boundary it executed.
|
|
51
|
-
|
|
52
|
-
## Human gates and workflow ownership
|
|
53
|
-
|
|
54
|
-
- GATE-SCOPE records the user's scope decision before the plan is written.
|
|
55
|
-
- GATE-REVIEW records the user's disposition of deduplicated adversarial findings.
|
|
56
|
-
- Develop starts only after a new explicit user invocation and executes one
|
|
57
|
-
unblocked task at a time.
|
|
58
|
-
- Sync changes only observed file state and never starts implementation.
|
|
59
|
-
- GATE-DONE occurs after current receipts and limitations are shown; the user decides
|
|
60
|
-
whether the requested feature is complete.
|