@haposoft/cafekit 0.14.1 → 0.15.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 +86 -11
- package/bin/__tests__/codex-hooks.test.js +331 -0
- package/bin/__tests__/codex-native.test.js +458 -0
- package/bin/__tests__/installer-safety.test.js +259 -0
- package/bin/install.js +30 -8
- package/bin/lib/backup.js +82 -18
- package/bin/lib/codex-frontmatter.js +56 -0
- package/bin/lib/codex-install.js +195 -0
- package/bin/lib/context.js +65 -8
- package/bin/lib/copy-utils.js +1 -1
- package/bin/lib/i18n.js +12 -3
- package/bin/lib/managed-writer.js +17 -2
- package/bin/lib/manifest.js +21 -4
- package/bin/lib/opencode-install.js +21 -19
- package/bin/lib/path-safety.js +40 -0
- package/bin/lib/version-check.js +25 -12
- package/bin/phases/claude-runtime.js +60 -9
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +98 -48
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +105 -66
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/CLAUDE.md +1 -1
- package/src/claude/agents/code-auditor.md +1 -1
- package/src/claude/agents/debugger.md +1 -0
- package/src/claude/agents/god-developer.md +10 -1
- package/src/claude/agents/inspector.md +1 -0
- package/src/claude/agents/spec-maker.md +1 -1
- package/src/claude/agents/test-runner.md +2 -2
- package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
- package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
- package/src/claude/hooks/__tests__/state.test.js +65 -0
- package/src/claude/hooks/docs-sync.cjs +15 -21
- package/src/claude/hooks/inspect-block.cjs +1 -1
- package/src/claude/hooks/lib/config.cjs +0 -5
- package/src/claude/hooks/privacy-block.cjs +39 -62
- package/src/claude/hooks/spec-gate.cjs +18 -7
- package/src/claude/hooks/spec-state.cjs +4 -0
- package/src/claude/hooks/state.cjs +13 -3
- package/src/claude/hooks/usage.cjs +6 -0
- package/src/claude/rules/orchestrator.md +2 -2
- package/src/claude/rules/skill-domain-routing.md +27 -86
- package/src/claude/rules/skill-workflow-routing.md +11 -82
- package/src/claude/rules/state-sync.md +1 -1
- package/src/claude/runtime.json +7 -7
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- package/src/claude/scripts/validate-spec-output.cjs +6 -0
- package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
- package/src/claude/skills/develop/SKILL.md +26 -5
- package/src/claude/skills/develop/references/parallel-waves.md +112 -0
- package/src/claude/skills/develop/references/quality-gate.md +10 -2
- package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
- package/src/claude/skills/inspect/SKILL.md +42 -113
- package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
- package/src/claude/skills/specs/SKILL.md +134 -386
- package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
- package/src/claude/skills/specs/templates/task.md +3 -1
- package/src/claude/skills/sync/SKILL.md +2 -2
- package/src/claude/skills/sync/references/sync-protocols.md +4 -4
- package/src/claude/skills/test/SKILL.md +1 -1
- package/src/claude/status.cjs +9 -6
- package/src/codex/AGENTS.md +30 -0
- package/src/codex/agents-gitignore +11 -0
- package/src/codex/gitignore +12 -0
- package/src/codex/hooks/agent.cjs +66 -0
- package/src/codex/hooks/docs-sync.cjs +66 -0
- package/src/codex/hooks/inspect-block.cjs +40 -0
- package/src/codex/hooks/lib/hook-context.cjs +93 -0
- package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
- package/src/codex/hooks/lib/privacy-state.cjs +199 -0
- package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
- package/src/codex/hooks/lib/spec-utils.cjs +50 -0
- package/src/codex/hooks/lib/state-store.cjs +166 -0
- package/src/codex/hooks/privacy-approval.cjs +59 -0
- package/src/codex/hooks/privacy-block.cjs +182 -0
- package/src/codex/hooks/rules.cjs +79 -0
- package/src/codex/hooks/session.cjs +114 -0
- package/src/codex/hooks/spec-gate.cjs +80 -0
- package/src/codex/hooks/spec-state.cjs +73 -0
- package/src/codex/hooks/state.cjs +157 -0
- package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
- package/src/codex/hooks.json +122 -0
- package/src/codex/rules/hook-protocols.md +32 -0
- package/src/codex/rules/state-sync.md +41 -0
- package/src/codex/runtime.json +28 -0
- package/src/opencode/plugins/docs-sync.ts +10 -16
- package/src/claude/archive-command/code/SKILL.md +0 -55
- package/src/claude/archive-command/code/references/execution-loop.md +0 -21
- package/src/claude/archive-command/code.md +0 -17
- package/src/claude/archive-command/docs.md +0 -609
- package/src/claude/archive-command/review/codebase/parallel.md +0 -76
- package/src/claude/archive-command/spec-design.md +0 -247
- package/src/claude/archive-command/spec-init.md +0 -120
- package/src/claude/archive-command/spec-requirements.md +0 -138
- package/src/claude/archive-command/spec-status.md +0 -98
- package/src/claude/archive-command/spec-tasks.md +0 -173
- package/src/claude/archive-command/spec-validate.md +0 -118
- package/src/claude/archive-command/test.md +0 -8
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
const APPROVAL_PREFIX = 'APPROVE CAFEKIT PRIVACY ';
|
|
9
|
+
function stateDir(projectRoot) {
|
|
10
|
+
return path.join(projectRoot, '.codex', 'hooks', '.privacy');
|
|
11
|
+
}
|
|
12
|
+
function hash(value) {
|
|
13
|
+
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
function pathKey(filePath, sessionCwd) {
|
|
16
|
+
const requested = path.isAbsolute(filePath)
|
|
17
|
+
? filePath
|
|
18
|
+
: path.resolve(sessionCwd, filePath);
|
|
19
|
+
let target = requested;
|
|
20
|
+
try {
|
|
21
|
+
target = fs.realpathSync(requested);
|
|
22
|
+
} catch {
|
|
23
|
+
// Missing or virtual paths are still bound to their normalized request path.
|
|
24
|
+
}
|
|
25
|
+
return hash(path.normalize(target));
|
|
26
|
+
}
|
|
27
|
+
function requestPaths(filePaths, filePath) {
|
|
28
|
+
const values = Array.isArray(filePaths) ? filePaths : [filePath];
|
|
29
|
+
return [...new Set(values.filter((value) => typeof value === 'string' && value.trim()))];
|
|
30
|
+
}
|
|
31
|
+
function requestPathKeys(filePaths, sessionCwd) {
|
|
32
|
+
return [...new Set(filePaths.map((filePath) => pathKey(filePath, sessionCwd)))].sort();
|
|
33
|
+
}
|
|
34
|
+
function samePathKeys(left, right) {
|
|
35
|
+
return Array.isArray(left)
|
|
36
|
+
&& left.length === right.length
|
|
37
|
+
&& left.every((value, index) => value === right[index]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function displayNames(filePaths) {
|
|
41
|
+
const names = [...new Set(filePaths.map((filePath) => path.basename(filePath)))].sort();
|
|
42
|
+
return names.length <= 3
|
|
43
|
+
? names.join(', ')
|
|
44
|
+
: `${names.slice(0, 3).join(', ')} (+${names.length - 3} more)`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function atomicWrite(file, payload) {
|
|
48
|
+
const dir = path.dirname(file);
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
50
|
+
const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
51
|
+
fs.writeFileSync(temp, `${JSON.stringify(payload)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
52
|
+
fs.renameSync(temp, file);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readJson(file) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanup(projectRoot, now = Date.now()) {
|
|
64
|
+
const dir = stateDir(projectRoot);
|
|
65
|
+
if (!fs.existsSync(dir)) return;
|
|
66
|
+
for (const name of fs.readdirSync(dir)) {
|
|
67
|
+
if (!/^(pending|token)-[a-f0-9]+\.json$/.test(name)) continue;
|
|
68
|
+
const file = path.join(dir, name);
|
|
69
|
+
const record = readJson(file);
|
|
70
|
+
if (!record || Number(record.expiresAt) <= now) {
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(file);
|
|
73
|
+
} catch {
|
|
74
|
+
// Another hook may have consumed it.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function clearState(projectRoot) {
|
|
81
|
+
const dir = stateDir(projectRoot);
|
|
82
|
+
try {
|
|
83
|
+
if (!fs.existsSync(dir)) return;
|
|
84
|
+
for (const name of fs.readdirSync(dir)) {
|
|
85
|
+
if (/^(pending|token)-[a-f0-9]+\.json$/.test(name)) {
|
|
86
|
+
try { fs.unlinkSync(path.join(dir, name)); } catch { /* concurrently consumed */ }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// Session boundaries invalidate best-effort state without blocking startup.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createPending({
|
|
95
|
+
projectRoot,
|
|
96
|
+
sessionCwd,
|
|
97
|
+
sessionId,
|
|
98
|
+
filePaths,
|
|
99
|
+
filePath,
|
|
100
|
+
toolName
|
|
101
|
+
}) {
|
|
102
|
+
cleanup(projectRoot);
|
|
103
|
+
if (!sessionId) throw new Error('A Codex session ID is required');
|
|
104
|
+
const paths = requestPaths(filePaths, filePath);
|
|
105
|
+
if (paths.length === 0) throw new Error('At least one sensitive path is required');
|
|
106
|
+
const requestId = crypto.randomBytes(12).toString('hex');
|
|
107
|
+
const record = {
|
|
108
|
+
version: 2,
|
|
109
|
+
requestId,
|
|
110
|
+
sessionId: String(sessionId || ''),
|
|
111
|
+
pathKeys: requestPathKeys(paths, sessionCwd),
|
|
112
|
+
displayName: displayNames(paths),
|
|
113
|
+
toolName: String(toolName || ''),
|
|
114
|
+
expiresAt: Date.now() + TTL_MS
|
|
115
|
+
};
|
|
116
|
+
atomicWrite(path.join(stateDir(projectRoot), `pending-${requestId}.json`), record);
|
|
117
|
+
return record;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function approvePending({ projectRoot, sessionId, requestId }) {
|
|
121
|
+
cleanup(projectRoot);
|
|
122
|
+
if (!sessionId) return { ok: false, reason: 'session' };
|
|
123
|
+
if (!/^[a-f0-9]{24}$/.test(requestId)) return { ok: false, reason: 'invalid' };
|
|
124
|
+
|
|
125
|
+
const pendingFile = path.join(stateDir(projectRoot), `pending-${requestId}.json`);
|
|
126
|
+
const record = readJson(pendingFile);
|
|
127
|
+
if (!record) return { ok: false, reason: 'missing' };
|
|
128
|
+
if (record.sessionId !== String(sessionId || '')) return { ok: false, reason: 'session' };
|
|
129
|
+
if (Number(record.expiresAt) <= Date.now()) return { ok: false, reason: 'expired' };
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
fs.unlinkSync(pendingFile);
|
|
133
|
+
} catch {
|
|
134
|
+
return { ok: false, reason: 'consumed' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
atomicWrite(path.join(stateDir(projectRoot), `token-${requestId}.json`), {
|
|
138
|
+
version: 2,
|
|
139
|
+
requestId,
|
|
140
|
+
sessionId: record.sessionId,
|
|
141
|
+
pathKeys: record.pathKeys,
|
|
142
|
+
toolName: record.toolName,
|
|
143
|
+
expiresAt: Math.min(record.expiresAt, Date.now() + TTL_MS)
|
|
144
|
+
});
|
|
145
|
+
return { ok: true, displayName: record.displayName };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function consumeToken({
|
|
149
|
+
projectRoot,
|
|
150
|
+
sessionCwd,
|
|
151
|
+
sessionId,
|
|
152
|
+
filePaths,
|
|
153
|
+
filePath,
|
|
154
|
+
toolName
|
|
155
|
+
}) {
|
|
156
|
+
cleanup(projectRoot);
|
|
157
|
+
if (!sessionId) return false;
|
|
158
|
+
const dir = stateDir(projectRoot);
|
|
159
|
+
if (!fs.existsSync(dir)) return false;
|
|
160
|
+
const paths = requestPaths(filePaths, filePath);
|
|
161
|
+
if (paths.length === 0) return false;
|
|
162
|
+
const expectedPaths = requestPathKeys(paths, sessionCwd);
|
|
163
|
+
|
|
164
|
+
for (const name of fs.readdirSync(dir)) {
|
|
165
|
+
if (!/^token-[a-f0-9]{24}\.json$/.test(name)) continue;
|
|
166
|
+
const tokenFile = path.join(dir, name);
|
|
167
|
+
const token = readJson(tokenFile);
|
|
168
|
+
if (
|
|
169
|
+
!token ||
|
|
170
|
+
token.sessionId !== String(sessionId || '') ||
|
|
171
|
+
!samePathKeys(token.pathKeys, expectedPaths) ||
|
|
172
|
+
token.toolName !== String(toolName || '') ||
|
|
173
|
+
Number(token.expiresAt) <= Date.now()
|
|
174
|
+
) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const claimed = `${tokenFile}.claimed-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
179
|
+
try {
|
|
180
|
+
fs.renameSync(tokenFile, claimed);
|
|
181
|
+
fs.unlinkSync(claimed);
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
// Only one concurrent tool invocation can rename and consume this token.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = {
|
|
191
|
+
APPROVAL_PREFIX,
|
|
192
|
+
TTL_MS,
|
|
193
|
+
pathKey,
|
|
194
|
+
createPending,
|
|
195
|
+
approvePending,
|
|
196
|
+
consumeToken,
|
|
197
|
+
cleanup,
|
|
198
|
+
clearState
|
|
199
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const EVIDENCE_NAMES = [
|
|
7
|
+
'Evidence',
|
|
8
|
+
'Task Test Plan & Verification Evidence',
|
|
9
|
+
'Verification & Evidence'
|
|
10
|
+
];
|
|
11
|
+
const PASS_MARKER = /^\s*Verification:\s*PASS\s*$/m;
|
|
12
|
+
const LEGACY_SUCCESS = /^\s*(?:PASS(?:ED)?|✓)(?:\s*:|$)|exit\s+code\s*[:=]?\s*0\b/im;
|
|
13
|
+
const EXPLICIT_FAILURE = /\bFAIL(?:ED|URE|URES|ING)?\b|tests?\s+failed|exit\s+code\s*[:=]?\s*[1-9]\d*|\b0\s+tests?\b/i;
|
|
14
|
+
|
|
15
|
+
function evidenceBody(text) {
|
|
16
|
+
const lines = text.split('\n');
|
|
17
|
+
let start = -1;
|
|
18
|
+
let level = 0;
|
|
19
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
20
|
+
const match = lines[index].match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
21
|
+
if (match && EVIDENCE_NAMES.includes(match[2])) {
|
|
22
|
+
start = index + 1;
|
|
23
|
+
level = match[1].length;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (start < 0) return null;
|
|
28
|
+
let end = lines.length;
|
|
29
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
30
|
+
const heading = lines[index].match(/^(#{1,6})\s+/);
|
|
31
|
+
if (heading && heading[1].length <= level) {
|
|
32
|
+
end = index;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return lines.slice(start, end).join('\n');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeTaskFile(featureDir, taskPath) {
|
|
40
|
+
const target = path.resolve(featureDir, taskPath);
|
|
41
|
+
const relative = path.relative(featureDir, target);
|
|
42
|
+
return !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
43
|
+
? target
|
|
44
|
+
: null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function checkReceipt(featureDir, taskPath, task) {
|
|
48
|
+
const failures = [];
|
|
49
|
+
const file = safeTaskFile(featureDir, taskPath);
|
|
50
|
+
if (!file || !fs.existsSync(file)) return ['a'];
|
|
51
|
+
let text = '';
|
|
52
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return ['a']; }
|
|
53
|
+
|
|
54
|
+
const status = text.split('\n')
|
|
55
|
+
.find((line) => /^\s*(?:\*\*)?Status(?:\*\*)?\s*:/i.test(line));
|
|
56
|
+
if (!status || !/\bdone\b/i.test(status)) failures.push('a');
|
|
57
|
+
|
|
58
|
+
const body = evidenceBody(text);
|
|
59
|
+
if (body === null) {
|
|
60
|
+
failures.push('b');
|
|
61
|
+
} else if (
|
|
62
|
+
/\{\{[^}]+\}\}/.test(body)
|
|
63
|
+
|| EXPLICIT_FAILURE.test(body)
|
|
64
|
+
|| !(PASS_MARKER.test(body) || LEGACY_SUCCESS.test(body))
|
|
65
|
+
) {
|
|
66
|
+
failures.push('c');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const completedAt = task?.completed_at;
|
|
70
|
+
if (
|
|
71
|
+
typeof completedAt !== 'string'
|
|
72
|
+
|| !/^\d{4}-\d{2}-\d{2}T/.test(completedAt)
|
|
73
|
+
|| Number.isNaN(Date.parse(completedAt))
|
|
74
|
+
) failures.push('d');
|
|
75
|
+
return failures;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { checkReceipt };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveProjectPath } = require('./hook-context.cjs');
|
|
6
|
+
|
|
7
|
+
function specsDirectory(projectRoot, runtime) {
|
|
8
|
+
return resolveProjectPath(
|
|
9
|
+
projectRoot,
|
|
10
|
+
runtime.paths?.specs,
|
|
11
|
+
'specs'
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function findActiveSpec(projectRoot, runtime) {
|
|
16
|
+
const specsDir = specsDirectory(projectRoot, runtime);
|
|
17
|
+
if (!fs.existsSync(specsDir)) return null;
|
|
18
|
+
const entries = fs.readdirSync(specsDir, { withFileTypes: true })
|
|
19
|
+
.filter((entry) => entry.isDirectory())
|
|
20
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
21
|
+
|
|
22
|
+
for (const entry of entries) {
|
|
23
|
+
const file = path.join(specsDir, entry.name, 'spec.json');
|
|
24
|
+
if (!fs.existsSync(file)) continue;
|
|
25
|
+
try {
|
|
26
|
+
const spec = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
27
|
+
if (spec.status === 'in_progress' || spec.status === 'in-progress') {
|
|
28
|
+
return { featureName: entry.name, spec, specsDir };
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Ignore an unrelated malformed spec and continue discovery.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function taskStatusMap(spec) {
|
|
38
|
+
return Object.fromEntries(
|
|
39
|
+
Object.entries(spec.task_registry || {}).map(([taskPath, task]) => [
|
|
40
|
+
taskPath,
|
|
41
|
+
task?.status || 'pending'
|
|
42
|
+
])
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = {
|
|
47
|
+
findActiveSpec,
|
|
48
|
+
specsDirectory,
|
|
49
|
+
taskStatusMap
|
|
50
|
+
};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
const { atomicWrite } = require('./hook-context.cjs');
|
|
8
|
+
|
|
9
|
+
const MAX_ARCHIVES = 5;
|
|
10
|
+
const MAX_AGENTS = 3;
|
|
11
|
+
const MAX_FILES = 5;
|
|
12
|
+
const WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
13
|
+
|
|
14
|
+
function sessionKey(sessionId) {
|
|
15
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return null;
|
|
16
|
+
return crypto.createHash('sha256').update(sessionId).digest('hex');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stateDir(projectRoot, sessionId) {
|
|
20
|
+
const key = sessionKey(sessionId);
|
|
21
|
+
return key
|
|
22
|
+
? path.join(projectRoot, '.codex', 'session-state', key)
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function acquireLock(dir) {
|
|
27
|
+
const lock = path.join(dir, '.write-lock');
|
|
28
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
29
|
+
const deadline = Date.now() + 3000;
|
|
30
|
+
|
|
31
|
+
while (Date.now() < deadline) {
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(lock);
|
|
34
|
+
return () => {
|
|
35
|
+
try { fs.rmdirSync(lock); } catch { /* already released */ }
|
|
36
|
+
};
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if (error.code !== 'EEXIST') return null;
|
|
39
|
+
try {
|
|
40
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > 15000) {
|
|
41
|
+
fs.rmSync(lock, { recursive: true, force: true });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
Atomics.wait(WAIT_BUFFER, 0, 0, 20);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function withStateLock(projectRoot, sessionId, callback) {
|
|
54
|
+
const dir = stateDir(projectRoot, sessionId);
|
|
55
|
+
if (!dir) return false;
|
|
56
|
+
const release = acquireLock(dir);
|
|
57
|
+
if (!release) return false;
|
|
58
|
+
try {
|
|
59
|
+
callback(dir);
|
|
60
|
+
return true;
|
|
61
|
+
} finally {
|
|
62
|
+
release();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readData(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'data.json'), 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return { todos: [], agentResults: [] };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runGit(projectRoot, args) {
|
|
75
|
+
try {
|
|
76
|
+
return execFileSync('git', args, {
|
|
77
|
+
cwd: projectRoot,
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
timeout: 3000,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
81
|
+
}).trim();
|
|
82
|
+
} catch {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function refreshGit(data, projectRoot) {
|
|
88
|
+
data.branch = runGit(projectRoot, ['branch', '--show-current'])
|
|
89
|
+
|| runGit(projectRoot, ['rev-parse', '--short', 'HEAD']);
|
|
90
|
+
const changed = runGit(projectRoot, ['diff', '--name-only', 'HEAD'])
|
|
91
|
+
|| runGit(projectRoot, ['diff', '--name-only']);
|
|
92
|
+
const untracked = runGit(projectRoot, ['ls-files', '--others', '--exclude-standard']);
|
|
93
|
+
data.modifiedFiles = [...new Set(
|
|
94
|
+
`${changed}\n${untracked}`.split('\n').map((item) => item.trim()).filter(Boolean)
|
|
95
|
+
)].slice(0, MAX_FILES);
|
|
96
|
+
data.timestamp = new Date().toISOString();
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function markdown(data) {
|
|
101
|
+
const todos = Array.isArray(data.todos) ? data.todos : [];
|
|
102
|
+
const done = todos.filter((todo) => ['completed', 'done'].includes(todo.status));
|
|
103
|
+
const pending = todos.filter((todo) => !['completed', 'done'].includes(todo.status));
|
|
104
|
+
const agents = (data.agentResults || []).slice(-MAX_AGENTS);
|
|
105
|
+
const files = data.modifiedFiles || [];
|
|
106
|
+
const lines = [
|
|
107
|
+
'# Session State',
|
|
108
|
+
`<!-- Generated: ${data.timestamp || new Date().toISOString()} -->`,
|
|
109
|
+
`<!-- Branch: ${data.branch || 'unknown'} -->`,
|
|
110
|
+
'',
|
|
111
|
+
'## What Worked (Verified)',
|
|
112
|
+
...(done.length ? done.map((todo) => `- ${todo.content}`) : ['- (No completed tasks recorded)']),
|
|
113
|
+
'',
|
|
114
|
+
"## What's Left",
|
|
115
|
+
...(pending.length ? pending.map((todo) => `- [ ] ${todo.content}`) : ['- (No pending tasks recorded)']),
|
|
116
|
+
''
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
if (data.lastToolEvent) {
|
|
120
|
+
lines.push('## Latest Tracked Tool', `- ${data.lastToolEvent}`, '');
|
|
121
|
+
}
|
|
122
|
+
if (data.lastAssistant) {
|
|
123
|
+
lines.push('## Latest Assistant Message', data.lastAssistant, '');
|
|
124
|
+
}
|
|
125
|
+
for (const agent of agents) {
|
|
126
|
+
lines.push(
|
|
127
|
+
`## Agent Result: ${agent.type || 'unknown'} (${agent.id || 'unknown'})`,
|
|
128
|
+
agent.message || '- Completed without a result message.',
|
|
129
|
+
''
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
lines.push(
|
|
133
|
+
'## Key Files Modified',
|
|
134
|
+
...(files.length ? files.map((file) => `- ${file}`) : ['- (No file changes detected)']),
|
|
135
|
+
''
|
|
136
|
+
);
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function writeData(dir, data) {
|
|
141
|
+
data.agentResults = (data.agentResults || []).slice(-MAX_AGENTS);
|
|
142
|
+
atomicWrite(path.join(dir, 'data.json'), `${JSON.stringify(data, null, 2)}\n`);
|
|
143
|
+
atomicWrite(path.join(dir, 'latest.md'), markdown(data));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function archive(dir) {
|
|
147
|
+
const latest = path.join(dir, 'latest.md');
|
|
148
|
+
if (!fs.existsSync(latest)) return;
|
|
149
|
+
const archiveDir = path.join(dir, 'archive');
|
|
150
|
+
fs.mkdirSync(archiveDir, { recursive: true });
|
|
151
|
+
const stamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14);
|
|
152
|
+
fs.copyFileSync(latest, path.join(archiveDir, `${stamp}-${process.pid}.md`));
|
|
153
|
+
const files = fs.readdirSync(archiveDir).filter((name) => name.endsWith('.md')).sort();
|
|
154
|
+
while (files.length > MAX_ARCHIVES) {
|
|
155
|
+
fs.unlinkSync(path.join(archiveDir, files.shift()));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
archive,
|
|
161
|
+
readData,
|
|
162
|
+
refreshGit,
|
|
163
|
+
stateDir,
|
|
164
|
+
withStateLock,
|
|
165
|
+
writeData
|
|
166
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const {
|
|
5
|
+
APPROVAL_PREFIX,
|
|
6
|
+
approvePending,
|
|
7
|
+
cleanup
|
|
8
|
+
} = require('./lib/privacy-state.cjs');
|
|
9
|
+
const {
|
|
10
|
+
getHookContext,
|
|
11
|
+
readPayload
|
|
12
|
+
} = require('./lib/hook-context.cjs');
|
|
13
|
+
|
|
14
|
+
function context(message) {
|
|
15
|
+
process.stdout.write(`${JSON.stringify({
|
|
16
|
+
hookSpecificOutput: {
|
|
17
|
+
hookEventName: 'UserPromptSubmit',
|
|
18
|
+
additionalContext: message
|
|
19
|
+
}
|
|
20
|
+
})}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const data = readPayload();
|
|
25
|
+
if (!data) process.exit(0);
|
|
26
|
+
const { projectRoot } = getHookContext(data);
|
|
27
|
+
const prompt = String(data.prompt || '').trim();
|
|
28
|
+
cleanup(projectRoot);
|
|
29
|
+
|
|
30
|
+
if (!prompt.startsWith(APPROVAL_PREFIX)) process.exit(0);
|
|
31
|
+
if (!data.session_id) {
|
|
32
|
+
context('CafeKit privacy approval rejected: this hook invocation had no Codex session ID.');
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
const requestId = prompt.slice(APPROVAL_PREFIX.length);
|
|
36
|
+
if (`${APPROVAL_PREFIX}${requestId}` !== prompt || !/^[a-f0-9]{24}$/.test(requestId)) {
|
|
37
|
+
context('CafeKit privacy approval rejected: the prompt did not exactly match a pending request.');
|
|
38
|
+
process.exit(0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = approvePending({
|
|
42
|
+
projectRoot,
|
|
43
|
+
sessionId: data.session_id,
|
|
44
|
+
requestId
|
|
45
|
+
});
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
context('CafeKit privacy approval rejected: request missing, expired, consumed, or from another session.');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
context(
|
|
52
|
+
`User approved one retry for the pending sensitive access (${result.displayName}). ` +
|
|
53
|
+
`The token is session-bound, path-bound, tool-bound, time-limited, and consumed atomically.`
|
|
54
|
+
);
|
|
55
|
+
process.exit(0);
|
|
56
|
+
} catch {
|
|
57
|
+
context('CafeKit privacy approval failed safely; no token was issued.');
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|