@haposoft/cafekit 0.14.2 → 0.15.1

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 (49) hide show
  1. package/README.md +80 -9
  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 +115 -1
  5. package/bin/install.js +21 -7
  6. package/bin/lib/backup.js +23 -3
  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 +3 -5
  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 +84 -47
  21. package/bin/phases/root-config.js +4 -2
  22. package/bin/phases/select-platform.js +92 -58
  23. package/bin/phases/summary.js +6 -1
  24. package/package.json +3 -2
  25. package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
  26. package/src/codex/AGENTS.md +30 -0
  27. package/src/codex/agents-gitignore +11 -0
  28. package/src/codex/gitignore +12 -0
  29. package/src/codex/hooks/agent.cjs +66 -0
  30. package/src/codex/hooks/docs-sync.cjs +66 -0
  31. package/src/codex/hooks/inspect-block.cjs +40 -0
  32. package/src/codex/hooks/lib/hook-context.cjs +93 -0
  33. package/src/codex/hooks/lib/inspect-paths.cjs +199 -0
  34. package/src/codex/hooks/lib/privacy-state.cjs +199 -0
  35. package/src/codex/hooks/lib/spec-receipt.cjs +78 -0
  36. package/src/codex/hooks/lib/spec-utils.cjs +50 -0
  37. package/src/codex/hooks/lib/state-store.cjs +166 -0
  38. package/src/codex/hooks/privacy-approval.cjs +59 -0
  39. package/src/codex/hooks/privacy-block.cjs +182 -0
  40. package/src/codex/hooks/rules.cjs +79 -0
  41. package/src/codex/hooks/session.cjs +114 -0
  42. package/src/codex/hooks/spec-gate.cjs +80 -0
  43. package/src/codex/hooks/spec-state.cjs +73 -0
  44. package/src/codex/hooks/state.cjs +157 -0
  45. package/src/codex/hooks/task-scaffold-guard.cjs +63 -0
  46. package/src/codex/hooks.json +122 -0
  47. package/src/codex/rules/hook-protocols.md +32 -0
  48. package/src/codex/rules/state-sync.md +41 -0
  49. package/src/codex/runtime.json +28 -0
@@ -0,0 +1,199 @@
1
+ 'use strict';
2
+
3
+ const BLOCKED_DIRS = new Set([
4
+ 'node_modules', 'dist', 'build', '.next', '.nuxt', '.output',
5
+ '__pycache__', '.venv', 'venv', 'vendor', 'target',
6
+ '.git', 'coverage', '.nyc_output'
7
+ ]);
8
+ const BROAD_GLOBS = [/^\*\*\/\*$/, /^\*\*\.\w+$/, /^\*\*\/\*\.\w+$/];
9
+ const PATH_KEYS = new RegExp(
10
+ '^(?:file_?paths?|paths?|search_?paths?|'
11
+ + '(?:source|target|destination)_?paths?|dirs?|director(?:y|ies)|'
12
+ + 'folders?|cwd|root|locations?|uris?)$',
13
+ 'i'
14
+ );
15
+ const GLOB_KEYS = /(?:^|_)(?:glob|globs|pattern|patterns)$/i;
16
+ function clean(value) {
17
+ return String(value || '').trim().replace(/^['"`]+|['"`,:]+$/g, '');
18
+ }
19
+ function blockedSegment(value) {
20
+ return clean(value)
21
+ .replace(/\\/g, '/')
22
+ .split('/')
23
+ .find((segment) => BLOCKED_DIRS.has(segment));
24
+ }
25
+ function isBroadGlob(value) {
26
+ const candidate = clean(value);
27
+ return BROAD_GLOBS.some((rule) => rule.test(candidate));
28
+ }
29
+ function collectStructured(value, key, paths, globs) {
30
+ if (typeof value === 'string') {
31
+ if (PATH_KEYS.test(key)) paths.push(value);
32
+ if (GLOB_KEYS.test(key)) globs.push(value);
33
+ return;
34
+ }
35
+ if (Array.isArray(value)) {
36
+ if (PATH_KEYS.test(key)) paths.push(...value.filter((item) => typeof item === 'string'));
37
+ if (GLOB_KEYS.test(key)) globs.push(...value.filter((item) => typeof item === 'string'));
38
+ for (const item of value) collectStructured(item, '', paths, globs);
39
+ return;
40
+ }
41
+ if (!value || typeof value !== 'object') return;
42
+ for (const [childKey, child] of Object.entries(value)) {
43
+ collectStructured(child, childKey, paths, globs);
44
+ }
45
+ }
46
+ function shellTokens(command) {
47
+ const tokens = [];
48
+ let token = '';
49
+ let quote = '';
50
+ for (let index = 0; index < command.length; index += 1) {
51
+ const char = command[index];
52
+ if (quote) {
53
+ if (char === quote) quote = '';
54
+ else if (char === '\\' && quote === '"' && index + 1 < command.length) {
55
+ token += command[index += 1];
56
+ } else token += char;
57
+ continue;
58
+ }
59
+ if (char === '"' || char === "'") {
60
+ quote = char;
61
+ } else if (char === '\n') {
62
+ if (token) tokens.push(token);
63
+ token = '';
64
+ tokens.push('|');
65
+ } else if (/\s/.test(char)) {
66
+ if (token) tokens.push(token);
67
+ token = '';
68
+ } else if (';&|'.includes(char)) {
69
+ if (token) tokens.push(token);
70
+ token = '';
71
+ const pair = command.slice(index, index + 2);
72
+ if (pair === '&&' || pair === '||') index += 1;
73
+ tokens.push('|');
74
+ } else {
75
+ token += char;
76
+ }
77
+ }
78
+ if (token) tokens.push(token);
79
+ return tokens;
80
+ }
81
+ function commandGroups(command) {
82
+ const groups = [[]];
83
+ for (const token of shellTokens(command)) {
84
+ if (token === '|') groups.push([]);
85
+ else groups[groups.length - 1].push(clean(token));
86
+ }
87
+ return groups.filter((group) => group.length);
88
+ }
89
+
90
+ function positional(tokens, optionsWithValue = new Set()) {
91
+ const out = [];
92
+ for (let index = 1; index < tokens.length; index += 1) {
93
+ const token = tokens[index];
94
+ if (optionsWithValue.has(token)) index += 1;
95
+ else if (!token.startsWith('-') || token === '-') out.push(token);
96
+ }
97
+ return out;
98
+ }
99
+
100
+ function readerOptionPaths(name, tokens) {
101
+ if (!['rg', 'grep', 'sed'].includes(name)) return [];
102
+ const paths = [];
103
+ for (let index = 1; index < tokens.length; index += 1) {
104
+ const token = tokens[index];
105
+ if (token === '-f' || token === '--file') {
106
+ if (tokens[index + 1]) paths.push(tokens[index + 1]);
107
+ index += 1;
108
+ } else if (token.startsWith('--file=')) {
109
+ paths.push(token.slice('--file='.length));
110
+ }
111
+ }
112
+ return paths;
113
+ }
114
+
115
+ function findRoots(tokens) {
116
+ const roots = [];
117
+ let index = 1;
118
+ while (['-H', '-L', '-P'].includes(tokens[index]) || /^-O\d+$/.test(tokens[index] || '')) {
119
+ index += 1;
120
+ }
121
+ if (tokens[index] === '-D') index += 2;
122
+ if (tokens[index] === '--') index += 1;
123
+ for (; index < tokens.length; index += 1) {
124
+ const token = tokens[index];
125
+ if (token.startsWith('-') || ['!', '(', ')'].includes(token)) break;
126
+ roots.push(token);
127
+ }
128
+ return roots;
129
+ }
130
+
131
+ function commandPaths(group, globs) {
132
+ const commandIndex = group.findIndex((token) => (
133
+ token && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)
134
+ ));
135
+ if (commandIndex < 0) return [];
136
+ const tokens = group.slice(commandIndex);
137
+ while (['sudo', 'env', 'time', 'command'].includes(tokens[0])) tokens.shift();
138
+ if (!tokens.length) return [];
139
+ const name = tokens[0].replace(/\\/g, '/').split('/').pop();
140
+ const optionPaths = readerOptionPaths(name, tokens);
141
+
142
+ for (let index = 1; index < tokens.length; index += 1) {
143
+ const token = tokens[index];
144
+ if (token === '-g' || token === '--glob' || token === '--iglob') {
145
+ if (tokens[index + 1]) globs.push(tokens[index + 1]);
146
+ } else if (/^--(?:i?glob)=/.test(token)) {
147
+ globs.push(token.slice(token.indexOf('=') + 1));
148
+ }
149
+ }
150
+
151
+ if (name === 'rg' || name === 'grep') {
152
+ const values = positional(tokens, new Set([
153
+ '-e', '--regexp', '-f', '--file', '-g', '--glob', '--iglob',
154
+ '-t', '--type', '-T', '--type-not'
155
+ ]));
156
+ const explicitPattern = tokens.some((token) => token === '-e' || token === '--regexp');
157
+ const fileMode = tokens.includes('--files');
158
+ return [...optionPaths, ...(explicitPattern || fileMode ? values : values.slice(1))];
159
+ }
160
+ if (name === 'sed') {
161
+ const values = positional(tokens, new Set(['-e', '--expression', '-f', '--file']));
162
+ const explicitScript = tokens.some((token) => (
163
+ ['-e', '--expression', '-f', '--file'].includes(token)
164
+ ));
165
+ return [...optionPaths, ...(explicitScript ? values : values.slice(1))];
166
+ }
167
+ if (name === 'find') return findRoots(tokens);
168
+ if (['cat', 'head', 'tail', 'ls', 'wc', 'du', 'tree', 'file', 'stat', 'cd', 'pushd', 'source', '.'].includes(name)) {
169
+ return positional(tokens, new Set(['-n', '--lines', '-c', '--bytes']));
170
+ }
171
+ return tokens.filter((token) => (
172
+ /^(?:\.{0,2}[\\/]|~[\\/])/.test(token)
173
+ || (token.includes('/') && blockedSegment(token))
174
+ ));
175
+ }
176
+
177
+ function inspectInput(input) {
178
+ const paths = [];
179
+ const globs = [];
180
+ collectStructured(input, '', paths, globs);
181
+ const command = typeof input?.command === 'string'
182
+ ? input.command
183
+ : typeof input?.cmd === 'string' ? input.cmd : '';
184
+ for (const group of commandGroups(command)) {
185
+ paths.push(...commandPaths(group, globs));
186
+ }
187
+ return {
188
+ broadGlob: globs.find(isBroadGlob),
189
+ blockedPath: paths.map((requested) => ({
190
+ requested,
191
+ blocked: blockedSegment(requested)
192
+ })).find((result) => result.blocked)
193
+ };
194
+ }
195
+
196
+ module.exports = {
197
+ BLOCKED_DIRS,
198
+ inspectInput
199
+ };
@@ -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
+ };