@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.
Files changed (100) hide show
  1. package/README.md +86 -11
  2. package/bin/__tests__/codex-hooks.test.js +331 -0
  3. package/bin/__tests__/codex-native.test.js +458 -0
  4. package/bin/__tests__/installer-safety.test.js +259 -0
  5. package/bin/install.js +30 -8
  6. package/bin/lib/backup.js +82 -18
  7. package/bin/lib/codex-frontmatter.js +56 -0
  8. package/bin/lib/codex-install.js +195 -0
  9. package/bin/lib/context.js +65 -8
  10. package/bin/lib/copy-utils.js +1 -1
  11. package/bin/lib/i18n.js +12 -3
  12. package/bin/lib/managed-writer.js +17 -2
  13. package/bin/lib/manifest.js +21 -4
  14. package/bin/lib/opencode-install.js +21 -19
  15. package/bin/lib/path-safety.js +40 -0
  16. package/bin/lib/version-check.js +25 -12
  17. package/bin/phases/claude-runtime.js +60 -9
  18. package/bin/phases/codex-runtime.js +110 -0
  19. package/bin/phases/copy-payload.js +36 -16
  20. package/bin/phases/post-install.js +98 -48
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +105 -66
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/CLAUDE.md +1 -1
  26. package/src/claude/agents/code-auditor.md +1 -1
  27. package/src/claude/agents/debugger.md +1 -0
  28. package/src/claude/agents/god-developer.md +10 -1
  29. package/src/claude/agents/inspector.md +1 -0
  30. package/src/claude/agents/spec-maker.md +1 -1
  31. package/src/claude/agents/test-runner.md +2 -2
  32. package/src/claude/hooks/__tests__/privacy-block.test.js +50 -10
  33. package/src/claude/hooks/__tests__/spec-gate.test.js +75 -1
  34. package/src/claude/hooks/__tests__/state.test.js +65 -0
  35. package/src/claude/hooks/docs-sync.cjs +15 -21
  36. package/src/claude/hooks/inspect-block.cjs +1 -1
  37. package/src/claude/hooks/lib/config.cjs +0 -5
  38. package/src/claude/hooks/privacy-block.cjs +39 -62
  39. package/src/claude/hooks/spec-gate.cjs +18 -7
  40. package/src/claude/hooks/spec-state.cjs +4 -0
  41. package/src/claude/hooks/state.cjs +13 -3
  42. package/src/claude/hooks/usage.cjs +6 -0
  43. package/src/claude/rules/orchestrator.md +2 -2
  44. package/src/claude/rules/skill-domain-routing.md +27 -86
  45. package/src/claude/rules/skill-workflow-routing.md +11 -82
  46. package/src/claude/rules/state-sync.md +1 -1
  47. package/src/claude/runtime.json +7 -7
  48. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  49. package/src/claude/scripts/validate-spec-output.cjs +6 -0
  50. package/src/claude/skills/code-review/references/spec-compliance-review.md +1 -1
  51. package/src/claude/skills/develop/SKILL.md +26 -5
  52. package/src/claude/skills/develop/references/parallel-waves.md +112 -0
  53. package/src/claude/skills/develop/references/quality-gate.md +10 -2
  54. package/src/claude/skills/hotfix/references/parallel-patterns.md +1 -1
  55. package/src/claude/skills/inspect/SKILL.md +42 -113
  56. package/src/claude/skills/inspect/references/internal-inspection.md +16 -135
  57. package/src/claude/skills/specs/SKILL.md +134 -386
  58. package/src/claude/skills/specs/rules/tasks-generation.md +1 -1
  59. package/src/claude/skills/specs/templates/task.md +3 -1
  60. package/src/claude/skills/sync/SKILL.md +2 -2
  61. package/src/claude/skills/sync/references/sync-protocols.md +4 -4
  62. package/src/claude/skills/test/SKILL.md +1 -1
  63. package/src/claude/status.cjs +9 -6
  64. package/src/codex/AGENTS.md +30 -0
  65. package/src/codex/agents-gitignore +11 -0
  66. package/src/codex/gitignore +12 -0
  67. package/src/codex/hooks/agent.cjs +66 -0
  68. package/src/codex/hooks/docs-sync.cjs +66 -0
  69. package/src/codex/hooks/inspect-block.cjs +40 -0
  70. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  71. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  72. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  73. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  74. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  75. package/src/codex/hooks/lib/state-store.cjs +166 -0
  76. package/src/codex/hooks/privacy-approval.cjs +59 -0
  77. package/src/codex/hooks/privacy-block.cjs +182 -0
  78. package/src/codex/hooks/rules.cjs +79 -0
  79. package/src/codex/hooks/session.cjs +114 -0
  80. package/src/codex/hooks/spec-gate.cjs +80 -0
  81. package/src/codex/hooks/spec-state.cjs +73 -0
  82. package/src/codex/hooks/state.cjs +157 -0
  83. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  84. package/src/codex/hooks.json +122 -0
  85. package/src/codex/rules/hook-protocols.md +32 -0
  86. package/src/codex/rules/state-sync.md +41 -0
  87. package/src/codex/runtime.json +28 -0
  88. package/src/opencode/plugins/docs-sync.ts +10 -16
  89. package/src/claude/archive-command/code/SKILL.md +0 -55
  90. package/src/claude/archive-command/code/references/execution-loop.md +0 -21
  91. package/src/claude/archive-command/code.md +0 -17
  92. package/src/claude/archive-command/docs.md +0 -609
  93. package/src/claude/archive-command/review/codebase/parallel.md +0 -76
  94. package/src/claude/archive-command/spec-design.md +0 -247
  95. package/src/claude/archive-command/spec-init.md +0 -120
  96. package/src/claude/archive-command/spec-requirements.md +0 -138
  97. package/src/claude/archive-command/spec-status.md +0 -98
  98. package/src/claude/archive-command/spec-tasks.md +0 -173
  99. package/src/claude/archive-command/spec-validate.md +0 -118
  100. 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
+ }