@haposoft/cafekit 0.14.2 → 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 +80 -9
- 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 +115 -1
- package/bin/install.js +21 -7
- package/bin/lib/backup.js +23 -3
- 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 +3 -5
- package/bin/phases/codex-runtime.js +110 -0
- package/bin/phases/copy-payload.js +36 -16
- package/bin/phases/post-install.js +84 -47
- package/bin/phases/root-config.js +4 -2
- package/bin/phases/select-platform.js +92 -58
- package/bin/phases/summary.js +6 -1
- package/package.json +3 -2
- package/src/claude/scripts/generate-skill-catalog.cjs +8 -3
- 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
|
@@ -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
|
+
};
|