agentxchain 0.8.6 → 0.8.8

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.
@@ -1,11 +1,15 @@
1
- import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs';
1
+ import { readFileSync, writeFileSync, existsSync, appendFileSync, unlinkSync } from 'fs';
2
2
  import { join, dirname } from 'path';
3
3
  import { spawn } from 'child_process';
4
4
  import { fileURLToPath } from 'url';
5
5
  import chalk from 'chalk';
6
6
  import { loadConfig, loadLock, LOCK_FILE } from '../lib/config.js';
7
+ import { safeWriteJson } from '../lib/safe-write.js';
7
8
  import { notifyHuman as sendNotification } from '../lib/notify.js';
8
9
  import { validateProject } from '../lib/validation.js';
10
+ import { resolveNextAgent, resolveExpectedClaimer } from '../lib/next-owner.js';
11
+
12
+ const PID_FILE = '.agentxchain-watch.pid';
9
13
 
10
14
  export async function watchCommand(opts) {
11
15
  if (opts.daemon && process.env.AGENTXCHAIN_WATCH_DAEMON !== '1') {
@@ -36,12 +40,15 @@ export async function watchCommand(opts) {
36
40
  console.log(chalk.dim(` Poll: ${interval}ms | TTL: ${ttlMinutes}min`));
37
41
  console.log(chalk.dim(` Mode: Local file watcher + trigger file`));
38
42
  console.log('');
43
+ writePidFile(root);
44
+
39
45
  console.log(chalk.cyan(' Watching lock.json... (Ctrl+C to stop)'));
40
46
  console.log(chalk.dim(' Note: In VS Code/Cursor, the Stop hook coordinates turns automatically.'));
41
47
  console.log(chalk.dim(' This watch process is a fallback for non-IDE environments.'));
42
48
  console.log('');
43
49
 
44
50
  let lastState = null;
51
+ let lastClaimedState = null;
45
52
 
46
53
  const tick = async () => {
47
54
  try {
@@ -50,17 +57,6 @@ export async function watchCommand(opts) {
50
57
 
51
58
  const stateKey = `${lock.holder}:${lock.turn_number}`;
52
59
 
53
- if (lock.holder && lock.holder !== 'human') {
54
- const expected = pickNextAgent(lock, config);
55
- if (!isValidClaimer(lock, config)) {
56
- log('warn', `Illegal claim detected: holder=${lock.holder}, expected=${expected}. Handing lock to HUMAN.`);
57
- blockOnIllegalClaim(root, lock, config, expected);
58
- sendNotification(`Illegal claim detected (${lock.holder}). Human intervention required.`);
59
- lastState = null;
60
- return;
61
- }
62
- }
63
-
64
60
  if (lock.holder && lock.holder !== 'human' && lock.claimed_at) {
65
61
  const elapsed = Date.now() - new Date(lock.claimed_at).getTime();
66
62
  const ttlMs = ttlMinutes * 60 * 1000;
@@ -85,11 +81,27 @@ export async function watchCommand(opts) {
85
81
  }
86
82
 
87
83
  if (lock.holder) {
84
+ // Validate claim ownership only once per new claimed state.
85
+ // With handoff-driven routing, TALK.md may change during an active turn.
86
+ // Re-validating every tick can produce false "illegal claim" alerts.
87
+ if (stateKey !== lastClaimedState && lock.holder !== 'human') {
88
+ const expected = pickNextAgent(root, lock, config);
89
+ if (!isValidClaimer(root, lock, config)) {
90
+ log('warn', `Illegal claim detected: holder=${lock.holder}, expected=${expected ?? 'none'}. Handing lock to HUMAN.`);
91
+ blockOnIllegalClaim(root, lock, config, expected);
92
+ sendNotification(`Illegal claim detected (${lock.holder}). Human intervention required.`);
93
+ lastState = null;
94
+ lastClaimedState = null;
95
+ return;
96
+ }
97
+ }
98
+
88
99
  if (stateKey !== lastState) {
89
100
  const name = config.agents[lock.holder]?.name || lock.holder;
90
101
  log('claimed', `${lock.holder} (${name}) working... (turn ${lock.turn_number})`);
91
102
  lastState = stateKey;
92
103
  }
104
+ lastClaimedState = stateKey;
93
105
  return;
94
106
  }
95
107
 
@@ -108,7 +120,15 @@ export async function watchCommand(opts) {
108
120
  }
109
121
  }
110
122
 
111
- const next = pickNextAgent(lock, config);
123
+ const resolved = resolveNextAgent(root, config, lock);
124
+ const next = resolved.next;
125
+ if (!next) {
126
+ log('warn', `No next owner (${resolved.source}). strict_next_owner requires TALK.md handoff. Handing lock to HUMAN.`);
127
+ blockOnMissingNext(root, lock, config, resolved.source);
128
+ sendNotification('No next owner in TALK.md. Human action required.');
129
+ lastState = null;
130
+ return;
131
+ }
112
132
  log('free', `Lock free (released by ${lock.last_released_by || 'none'}). Next: ${chalk.bold(next)}.`);
113
133
  writeTrigger(root, next, lock, config);
114
134
  lastState = stateKey;
@@ -121,29 +141,26 @@ export async function watchCommand(opts) {
121
141
  await tick();
122
142
  const timer = setInterval(tick, interval);
123
143
 
124
- process.on('SIGINT', () => {
144
+ const cleanup = () => {
125
145
  clearInterval(timer);
146
+ removePidFile(root);
126
147
  console.log('');
127
148
  log('stop', 'Watch stopped.');
128
149
  process.exit(0);
129
- });
130
- }
131
-
132
- function pickNextAgent(lock, config) {
133
- const agentIds = Object.keys(config.agents);
134
- if (agentIds.length === 0) return null;
135
- const lastAgent = lock.last_released_by;
150
+ };
136
151
 
137
- if (!lastAgent || !agentIds.includes(lastAgent)) return agentIds[0];
152
+ process.on('SIGINT', cleanup);
153
+ process.on('SIGTERM', cleanup);
154
+ }
138
155
 
139
- const lastIndex = agentIds.indexOf(lastAgent);
140
- return agentIds[(lastIndex + 1) % agentIds.length];
156
+ function pickNextAgent(root, lock, config) {
157
+ return resolveNextAgent(root, config, lock).next;
141
158
  }
142
159
 
143
- function isValidClaimer(lock, config) {
160
+ function isValidClaimer(root, lock, config) {
144
161
  if (!lock.holder || lock.holder === 'human') return true;
145
162
  if (!config.agents?.[lock.holder]) return false;
146
- const expected = pickNextAgent(lock, config);
163
+ const expected = resolveExpectedClaimer(root, config, lock).next;
147
164
  return lock.holder === expected;
148
165
  }
149
166
 
@@ -152,10 +169,10 @@ function forceRelease(root, lock, staleAgent, config) {
152
169
  const newLock = {
153
170
  holder: null,
154
171
  last_released_by: `system:ttl:${staleAgent}`,
155
- turn_number: lock.turn_number,
172
+ turn_number: lock.turn_number + 1,
156
173
  claimed_at: null
157
174
  };
158
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
175
+ safeWriteJson(lockPath, newLock);
159
176
 
160
177
  const logFile = config.log || 'log.md';
161
178
  const logPath = join(root, logFile);
@@ -167,12 +184,12 @@ function forceRelease(root, lock, staleAgent, config) {
167
184
  function writeTrigger(root, agentId, lock, config) {
168
185
  if (!agentId) return;
169
186
  const triggerPath = join(root, '.agentxchain-trigger.json');
170
- writeFileSync(triggerPath, JSON.stringify({
187
+ safeWriteJson(triggerPath, {
171
188
  agent: agentId,
172
189
  turn_number: lock.turn_number,
173
190
  triggered_at: new Date().toISOString(),
174
191
  project: config.project
175
- }, null, 2) + '\n');
192
+ });
176
193
  }
177
194
 
178
195
  function blockOnValidation(root, lock, config, validation) {
@@ -183,7 +200,7 @@ function blockOnValidation(root, lock, config, validation) {
183
200
  turn_number: lock.turn_number,
184
201
  claimed_at: new Date().toISOString()
185
202
  };
186
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
203
+ safeWriteJson(lockPath, newLock);
187
204
 
188
205
  const statePath = join(root, 'state.json');
189
206
  if (existsSync(statePath)) {
@@ -195,7 +212,7 @@ function blockOnValidation(root, lock, config, validation) {
195
212
  blocked: true,
196
213
  blocked_on: `validation: ${message}`
197
214
  };
198
- writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n');
215
+ safeWriteJson(statePath, nextState);
199
216
  } catch {}
200
217
  }
201
218
 
@@ -210,6 +227,39 @@ function blockOnValidation(root, lock, config, validation) {
210
227
  }
211
228
  }
212
229
 
230
+ function blockOnMissingNext(root, lock, config, source) {
231
+ const lockPath = join(root, LOCK_FILE);
232
+ const newLock = {
233
+ holder: 'human',
234
+ last_released_by: lock.last_released_by,
235
+ turn_number: lock.turn_number,
236
+ claimed_at: new Date().toISOString()
237
+ };
238
+ safeWriteJson(lockPath, newLock);
239
+
240
+ const statePath = join(root, 'state.json');
241
+ if (existsSync(statePath)) {
242
+ try {
243
+ const current = JSON.parse(readFileSync(statePath, 'utf8'));
244
+ const nextState = {
245
+ ...current,
246
+ blocked: true,
247
+ blocked_on: `missing-next-owner: ${source}`
248
+ };
249
+ safeWriteJson(statePath, nextState);
250
+ } catch {}
251
+ }
252
+
253
+ const logFile = config.log || 'log.md';
254
+ const logPath = join(root, logFile);
255
+ if (existsSync(logPath)) {
256
+ appendFileSync(
257
+ logPath,
258
+ `\n---\n\n### [system] (Watch) | Turn ${lock.turn_number}\n\n**Status:** Could not resolve next agent (${source}).\n\n**Action:** Add \`Next owner: <agent_id>\` to ${config.talk_file || 'TALK.md'}, or set \`rules.strict_next_owner\` to false for cyclic fallback.\n\n`
259
+ );
260
+ }
261
+ }
262
+
213
263
  function blockOnIllegalClaim(root, lock, config, expected) {
214
264
  const lockPath = join(root, LOCK_FILE);
215
265
  const newLock = {
@@ -218,7 +268,7 @@ function blockOnIllegalClaim(root, lock, config, expected) {
218
268
  turn_number: lock.turn_number,
219
269
  claimed_at: new Date().toISOString()
220
270
  };
221
- writeFileSync(lockPath, JSON.stringify(newLock, null, 2) + '\n');
271
+ safeWriteJson(lockPath, newLock);
222
272
 
223
273
  const statePath = join(root, 'state.json');
224
274
  if (existsSync(statePath)) {
@@ -229,7 +279,7 @@ function blockOnIllegalClaim(root, lock, config, expected) {
229
279
  blocked: true,
230
280
  blocked_on: `illegal-claim: expected ${expected}, got ${lock.holder}`
231
281
  };
232
- writeFileSync(statePath, JSON.stringify(nextState, null, 2) + '\n');
282
+ safeWriteJson(statePath, nextState);
233
283
  } catch {}
234
284
  }
235
285
 
@@ -268,8 +318,39 @@ function startWatchDaemon() {
268
318
  });
269
319
  child.unref();
270
320
 
321
+ const result = loadConfig();
322
+ if (result) {
323
+ writeFileSync(join(result.root, PID_FILE), String(child.pid));
324
+ }
325
+
271
326
  console.log('');
272
327
  console.log(chalk.green(` ✓ Watch started in daemon mode (PID: ${child.pid})`));
273
328
  console.log(chalk.dim(' Use `agentxchain stop` or kill the PID to stop it.'));
274
329
  console.log('');
275
330
  }
331
+
332
+ function writePidFile(root) {
333
+ try {
334
+ writeFileSync(join(root, PID_FILE), String(process.pid));
335
+ } catch {}
336
+ }
337
+
338
+ function removePidFile(root) {
339
+ try {
340
+ const pidPath = join(root, PID_FILE);
341
+ if (existsSync(pidPath)) unlinkSync(pidPath);
342
+ } catch {}
343
+ }
344
+
345
+ export function getWatchPid(root) {
346
+ try {
347
+ const pidPath = join(root, PID_FILE);
348
+ if (!existsSync(pidPath)) return null;
349
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
350
+ if (!Number.isFinite(pid)) return null;
351
+ process.kill(pid, 0);
352
+ return pid;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
package/src/lib/config.js CHANGED
@@ -1,36 +1,71 @@
1
1
  import { readFileSync, existsSync } from 'fs';
2
- import { join } from 'path';
2
+ import { join, parse as pathParse, resolve } from 'path';
3
+ import { safeParseJson, validateConfigSchema, validateLockSchema, validateStateSchema } from './schema.js';
3
4
 
4
5
  const CONFIG_FILE = 'agentxchain.json';
5
6
  const LOCK_FILE = 'lock.json';
6
7
  const STATE_FILE = 'state.json';
7
8
 
8
9
  export function findProjectRoot(startDir = process.cwd()) {
9
- let dir = startDir;
10
- while (dir !== '/') {
10
+ let dir = resolve(startDir);
11
+ const { root: fsRoot } = pathParse(dir);
12
+ while (true) {
11
13
  if (existsSync(join(dir, CONFIG_FILE))) return dir;
14
+ if (dir === fsRoot) return null;
12
15
  dir = join(dir, '..');
13
16
  }
14
- return null;
15
17
  }
16
18
 
17
19
  export function loadConfig(dir = process.cwd()) {
18
20
  const root = findProjectRoot(dir);
19
21
  if (!root) return null;
20
- const raw = readFileSync(join(root, CONFIG_FILE), 'utf8');
21
- return { root, config: JSON.parse(raw) };
22
+ const filePath = join(root, CONFIG_FILE);
23
+ let raw;
24
+ try {
25
+ raw = readFileSync(filePath, 'utf8');
26
+ } catch {
27
+ return null;
28
+ }
29
+ const result = safeParseJson(raw, validateConfigSchema);
30
+ if (!result.ok) {
31
+ console.error(` Warning: agentxchain.json has issues: ${result.errors.join(', ')}`);
32
+ return null;
33
+ }
34
+ return { root, config: result.data };
22
35
  }
23
36
 
24
37
  export function loadLock(root) {
25
- const path = join(root, LOCK_FILE);
26
- if (!existsSync(path)) return null;
27
- return JSON.parse(readFileSync(path, 'utf8'));
38
+ const filePath = join(root, LOCK_FILE);
39
+ if (!existsSync(filePath)) return null;
40
+ let raw;
41
+ try {
42
+ raw = readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return null;
45
+ }
46
+ const result = safeParseJson(raw, validateLockSchema);
47
+ if (!result.ok) {
48
+ console.error(` Warning: lock.json has issues: ${result.errors.join(', ')}`);
49
+ return null;
50
+ }
51
+ return result.data;
28
52
  }
29
53
 
30
54
  export function loadState(root) {
31
- const path = join(root, STATE_FILE);
32
- if (!existsSync(path)) return null;
33
- return JSON.parse(readFileSync(path, 'utf8'));
55
+ const filePath = join(root, STATE_FILE);
56
+ if (!existsSync(filePath)) return null;
57
+ let raw;
58
+ try {
59
+ raw = readFileSync(filePath, 'utf8');
60
+ } catch {
61
+ return null;
62
+ }
63
+ const result = safeParseJson(raw, validateStateSchema);
64
+ if (!result.ok) {
65
+ console.error(` Warning: state.json has issues: ${result.errors.join(', ')}`);
66
+ return null;
67
+ }
68
+ return result.data;
34
69
  }
35
70
 
36
71
  export { CONFIG_FILE, LOCK_FILE, STATE_FILE };
@@ -0,0 +1,12 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function filterAgents(config, specificId) {
4
+ if (specificId) {
5
+ if (!config.agents[specificId]) {
6
+ console.log(chalk.red(` Agent "${specificId}" not found in agentxchain.json`));
7
+ process.exit(1);
8
+ }
9
+ return { [specificId]: config.agents[specificId] };
10
+ }
11
+ return config.agents;
12
+ }
@@ -20,11 +20,13 @@ export function generateVSCodeFiles(dir, config) {
20
20
  }
21
21
 
22
22
  writeFileSync(join(hooksDir, 'agentxchain.json'), buildHooksJson());
23
+ writeFileSync(join(scriptsDir, 'agentxchain-hook-runtime.cjs'), buildHookRuntimeScript());
23
24
  writeFileSync(join(scriptsDir, 'agentxchain-session-start.sh'), SESSION_START_SCRIPT);
24
- writeFileSync(join(scriptsDir, 'agentxchain-stop.sh'), buildStopScript(config));
25
+ writeFileSync(join(scriptsDir, 'agentxchain-stop.sh'), buildStopScript());
25
26
  writeFileSync(join(scriptsDir, 'agentxchain-pre-tool.sh'), PRE_TOOL_SCRIPT);
26
27
 
27
28
  try {
29
+ chmodSync(join(scriptsDir, 'agentxchain-hook-runtime.cjs'), 0o755);
28
30
  chmodSync(join(scriptsDir, 'agentxchain-session-start.sh'), 0o755);
29
31
  chmodSync(join(scriptsDir, 'agentxchain-stop.sh'), 0o755);
30
32
  chmodSync(join(scriptsDir, 'agentxchain-pre-tool.sh'), 0o755);
@@ -188,23 +190,150 @@ function buildHooksJson() {
188
190
  return JSON.stringify(hooks, null, 2) + '\n';
189
191
  }
190
192
 
191
- function buildStopScript(config) {
192
- const verifyCmd = config.rules?.verify_command || '';
193
- const verifyBlock = verifyCmd
194
- ? `
195
- # Run verify command before allowing release
196
- if [ -z "$HOLDER" ] || [ "$HOLDER" = "null" ]; then
197
- VERIFY_CMD="${verifyCmd}"
198
- if [ -n "$VERIFY_CMD" ]; then
199
- if ! eval "$VERIFY_CMD" > /dev/null 2>&1; then
200
- echo '{"hookSpecificOutput":{"hookEventName":"Stop","decision":"block","reason":"Verification failed: '"$VERIFY_CMD"'. Fix the issue and release the lock."}}'
201
- exit 0
202
- fi
203
- fi
204
- fi
205
- `
206
- : '';
193
+ function buildHookRuntimeScript() {
194
+ return `#!/usr/bin/env node
195
+ const fs = require('fs');
196
+ const path = require('path');
197
+ const { spawnSync } = require('child_process');
198
+
199
+ function readJson(file) {
200
+ try {
201
+ return JSON.parse(fs.readFileSync(path.join(process.cwd(), file), 'utf8'));
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ function splitCommand(input) {
208
+ const out = [];
209
+ let current = '';
210
+ let quote = null;
211
+ let escape = false;
212
+ for (const char of String(input || '')) {
213
+ if (escape) {
214
+ current += char;
215
+ escape = false;
216
+ continue;
217
+ }
218
+ if (char === '\\\\') {
219
+ escape = true;
220
+ continue;
221
+ }
222
+ if (quote) {
223
+ if (char === quote) {
224
+ quote = null;
225
+ } else {
226
+ current += char;
227
+ }
228
+ continue;
229
+ }
230
+ if (char === '"' || char === "'") {
231
+ quote = char;
232
+ continue;
233
+ }
234
+ if (/\\s/.test(char)) {
235
+ if (current) {
236
+ out.push(current);
237
+ current = '';
238
+ }
239
+ continue;
240
+ }
241
+ current += char;
242
+ }
243
+ if (current) out.push(current);
244
+ return out;
245
+ }
246
+
247
+ function normalizeVerifyCommand(config) {
248
+ const raw = config?.rules?.verify_command;
249
+ if (Array.isArray(raw) && raw.every(part => typeof part === 'string' && part.length > 0)) {
250
+ return raw;
251
+ }
252
+ if (typeof raw !== 'string' || !raw.trim()) return null;
253
+ return splitCommand(raw.trim());
254
+ }
255
+
256
+ function normalizeAgentId(raw) {
257
+ if (!raw) return null;
258
+ let value = String(raw).trim();
259
+ value = value.replace(/[\`*_]/g, '').trim();
260
+ value = value.replace(/\\(.*?\\)/g, '').trim();
261
+ value = value.split(/[\\s,]+/)[0];
262
+ value = value.toLowerCase();
263
+ return /^[a-z0-9_-]+$/.test(value) ? value : null;
264
+ }
265
+
266
+ function parseNextOwnerFromTalk(talkPath, validAgentIds) {
267
+ try {
268
+ const text = fs.readFileSync(path.join(process.cwd(), talkPath), 'utf8');
269
+ const lines = text.split(/\\r?\\n/);
270
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
271
+ const line = lines[i].trim();
272
+ const match = line.match(/^(?:-|\\*)?\\s*\\**next\\s*owner\\**\\s*:\\s*(.+)$/i);
273
+ if (!match) continue;
274
+ const candidate = normalizeAgentId(match[1]);
275
+ if (candidate && validAgentIds.includes(candidate)) return candidate;
276
+ }
277
+ } catch {}
278
+ return null;
279
+ }
280
+
281
+ function resolveNextAgent(config, lock) {
282
+ const agentIds = Object.keys(config?.agents || {});
283
+ if (agentIds.length === 0) return null;
284
+ const talkFile = config?.talk_file || 'TALK.md';
285
+ const fromTalk = parseNextOwnerFromTalk(talkFile, agentIds);
286
+ if (fromTalk) return fromTalk;
287
+ const last = lock?.last_released_by;
288
+ if (last && agentIds.includes(last)) {
289
+ const idx = agentIds.indexOf(last);
290
+ return agentIds[(idx + 1) % agentIds.length];
291
+ }
292
+ return agentIds[0];
293
+ }
294
+
295
+ function outputJson(value) {
296
+ process.stdout.write(JSON.stringify(value));
297
+ }
298
+
299
+ const command = process.argv[2];
300
+ const config = readJson('agentxchain.json');
301
+ const lock = readJson('lock.json');
302
+ const state = readJson('state.json');
303
+
304
+ if (command === 'verify') {
305
+ const verifyArgs = normalizeVerifyCommand(config);
306
+ if (!verifyArgs || verifyArgs.length === 0) process.exit(0);
307
+ const result = spawnSync(verifyArgs[0], verifyArgs.slice(1), { stdio: 'ignore', cwd: process.cwd() });
308
+ process.exit(result.status === 0 ? 0 : 2);
309
+ }
310
+
311
+ if (command === 'next') {
312
+ process.stdout.write(resolveNextAgent(config, lock) || '');
313
+ process.exit(0);
314
+ }
315
+
316
+ if (command === 'next-name') {
317
+ const next = resolveNextAgent(config, lock);
318
+ process.stdout.write(next ? (config?.agents?.[next]?.name || next) : '');
319
+ process.exit(0);
320
+ }
321
+
322
+ if (command === 'session-start') {
323
+ if (!lock || !state) {
324
+ outputJson({ continue: true });
325
+ process.exit(0);
326
+ }
327
+ const context = \`AgentXchain context: Project=\${state.project || 'unknown'} | Phase=\${state.phase || 'unknown'} | Turn=\${lock.turn_number ?? 0} | Lock=\${lock.holder ?? 'none'} | Last released by=\${lock.last_released_by ?? 'none'} | Blocked=\${state.blocked ?? false}\`;
328
+ outputJson({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } });
329
+ process.exit(0);
330
+ }
331
+
332
+ outputJson({ continue: true });
333
+ `;
334
+ }
207
335
 
336
+ function buildStopScript() {
208
337
  return `#!/bin/bash
209
338
  INPUT=$(cat)
210
339
  STOP_HOOK_ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
@@ -222,34 +351,28 @@ fi
222
351
  LOCK=$(cat lock.json 2>/dev/null)
223
352
  HOLDER=$(echo "$LOCK" | jq -r '.holder // empty')
224
353
  TURN=$(echo "$LOCK" | jq -r '.turn_number // 0')
225
- ${verifyBlock}
226
354
  if [ -z "$HOLDER" ] || [ "$HOLDER" = "null" ]; then
227
- LAST=$(echo "$LOCK" | jq -r '.last_released_by // empty')
355
+ node "./scripts/agentxchain-hook-runtime.cjs" verify >/dev/null 2>&1
356
+ VERIFY_STATUS=$?
357
+ if [ "$VERIFY_STATUS" -eq 2 ]; then
358
+ echo '{"hookSpecificOutput":{"hookEventName":"Stop","decision":"block","reason":"Verification failed. Fix the issue and release the lock."}}'
359
+ exit 0
360
+ fi
361
+ fi
228
362
 
363
+ if [ -z "$HOLDER" ] || [ "$HOLDER" = "null" ]; then
229
364
  if [ ! -f "agentxchain.json" ]; then
230
365
  echo '{"continue":true}'
231
366
  exit 0
232
367
  fi
233
368
 
234
- NEXT=$(node -e "
235
- const cfg = JSON.parse(require('fs').readFileSync('agentxchain.json','utf8'));
236
- const ids = Object.keys(cfg.agents);
237
- const last = process.argv[1] || '';
238
- const idx = ids.indexOf(last);
239
- const next = ids[(idx + 1) % ids.length];
240
- process.stdout.write(next);
241
- " -- "$LAST" 2>/dev/null)
242
-
369
+ NEXT=$(node "./scripts/agentxchain-hook-runtime.cjs" next 2>/dev/null)
243
370
  if [ -z "$NEXT" ]; then
244
371
  echo '{"continue":true}'
245
372
  exit 0
246
373
  fi
247
374
 
248
- NEXT_NAME=$(node -e "
249
- const cfg = JSON.parse(require('fs').readFileSync('agentxchain.json','utf8'));
250
- const a = cfg.agents[process.argv[1]];
251
- process.stdout.write(a ? a.name : process.argv[1]);
252
- " -- "$NEXT" 2>/dev/null)
375
+ NEXT_NAME=$(node "./scripts/agentxchain-hook-runtime.cjs" next-name 2>/dev/null)
253
376
 
254
377
  echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"Stop\\",\\"decision\\":\\"block\\",\\"reason\\":\\"Turn $TURN complete. Next agent: $NEXT ($NEXT_NAME). Read lock.json, claim it, and do your work.\\"}}"
255
378
  elif [ "$HOLDER" = "human" ]; then
@@ -261,24 +384,8 @@ fi
261
384
  }
262
385
 
263
386
  const SESSION_START_SCRIPT = `#!/bin/bash
264
- if [ ! -f "lock.json" ] || [ ! -f "state.json" ]; then
265
- echo '{"continue":true}'
266
- exit 0
267
- fi
268
-
269
- LOCK=$(cat lock.json 2>/dev/null)
270
- STATE=$(cat state.json 2>/dev/null)
271
-
272
- HOLDER=$(echo "$LOCK" | jq -r '.holder // "none"')
273
- TURN=$(echo "$LOCK" | jq -r '.turn_number // 0')
274
- LAST=$(echo "$LOCK" | jq -r '.last_released_by // "none"')
275
- PHASE=$(echo "$STATE" | jq -r '.phase // "unknown"')
276
- BLOCKED=$(echo "$STATE" | jq -r '.blocked // false')
277
- PROJECT=$(echo "$STATE" | jq -r '.project // "unknown"')
278
-
279
- CONTEXT="AgentXchain context: Project=$PROJECT | Phase=$PHASE | Turn=$TURN | Lock=$HOLDER | Last released by=$LAST | Blocked=$BLOCKED"
280
-
281
- echo "{\\"hookSpecificOutput\\":{\\"hookEventName\\":\\"SessionStart\\",\\"additionalContext\\":\\"$CONTEXT\\"}}"
387
+ CONTEXT_JSON=$(node "./scripts/agentxchain-hook-runtime.cjs" session-start 2>/dev/null || echo '{"continue":true}')
388
+ printf '%s\n' "$CONTEXT_JSON"
282
389
  `;
283
390
 
284
391
  const PRE_TOOL_SCRIPT = `#!/bin/bash