@jaimevalasek/aioson 1.37.0 → 1.37.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.
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+ const path=require('node:path');
3
+ const {initManifest,loadManifest,resolveAgentExecution}=require('../agent-execution/manifest');
4
+ const {dispatch,readState}=require('../agent-execution/dispatcher');
5
+ const {openRuntimeDb,getExecutionSnapshot,listExecutionEvents}=require('../runtime-store');
6
+ async function resolveAllAgents(manifest,catalogLoader){
7
+ const entries=await Promise.all(Object.keys(manifest.agents).map(async id=>[id,await resolveAgentExecution(manifest,id,{}, {catalogLoader})]));
8
+ return Object.fromEntries(entries);
9
+ }
10
+ function resolutionErrors(resolutions){
11
+ return Object.entries(resolutions).filter(([,value])=>!value.ok).map(([id,value])=>({path:`$.agents.${id}.model`,message:value.reason,candidates:value.candidates||[],supported:value.supported||[]}));
12
+ }
13
+ async function runAgentExecution({args,options={},logger,catalogLoader}){
14
+ const projectDir=path.resolve(process.cwd(),args[0]||'.'); const sub=options.sub||'show'; const feature=String(options.feature||options.slug||'').trim();
15
+ if(!feature)return {ok:false,reason:'feature_required',message:'Use --feature=<slug>'};
16
+ if(sub==='init'){const created=await initManifest(projectDir,feature,String(options.host||'codex')); const result={ok:true,feature,path:path.relative(projectDir,created.path),digest:created.digest,manifest:created.manifest,availability:'validated_at_dispatch'}; if(!options.json)logger.log(`Agent execution manifest: ${result.path}\nValidate: aioson agent:execution:validate . --feature=${feature}`); return result;}
17
+ const loaded=await loadManifest(projectDir,feature);
18
+ if(sub==='validate'){
19
+ let resolutions={};let errors=loaded.errors||[];
20
+ if(loaded.exists&&loaded.ok){resolutions=await resolveAllAgents(loaded.manifest,catalogLoader);errors=resolutionErrors(resolutions)}
21
+ const result={ok:Boolean(loaded.exists&&loaded.ok&&!errors.length),feature,path:path.relative(projectDir,loaded.path),legacy:!loaded.exists,digest:loaded.digest||null,errors,resolutions,availability:'validated_at_dispatch'};
22
+ if(!options.json)(result.ok?logger.log(`Valid: ${result.path} (models resolved against the current runtime catalog).`):logger.error(JSON.stringify(result.errors,null,2)));return result;
23
+ }
24
+ if(sub==='show'){
25
+ if(!loaded.exists)return {ok:true,legacy:true,feature,path:path.relative(projectDir,loaded.path),message:'Manifest absent; legacy configured-default remains active.'};
26
+ if(!loaded.ok)return {ok:false,feature,errors:loaded.errors};
27
+ const agents=await resolveAllAgents(loaded.manifest,catalogLoader);const errors=resolutionErrors(agents);
28
+ return {ok:!errors.length,feature,path:path.relative(projectDir,loaded.path),digest:loaded.digest,agents,errors,availability:'validated_at_dispatch'};
29
+ }
30
+ if(sub==='dispatch')return dispatch({projectDir,feature,agent:options.agent||'dev',runId:options['run-id'],promptPath:options.prompt,catalogLoader});
31
+ if(sub==='resume'){const state=await readState(projectDir,feature);if(!state)return {ok:false,reason:'state_missing'};return dispatch({projectDir,feature,agent:options.agent||state.attempts.at(-1)?.agent||'dev',runId:state.run_id,promptPath:options.prompt,catalogLoader});}
32
+ if(sub==='status'){const {db}=await openRuntimeDb(projectDir);try{return{ok:true,schema_version:1,feature,runs:getExecutionSnapshot(db,{feature,agent:options.agent,state:options.state,limit:options.limit})}}finally{db.close()}}
33
+ if(sub==='events'){const state=await readState(projectDir,feature);const run=String(options.run||state?.attempts?.at(-1)?.telemetry_run_id||'');if(!run)return{ok:false,reason:'telemetry_run_required'};const {db}=await openRuntimeDb(projectDir);try{const owned=getExecutionSnapshot(db,{feature,limit:200}).some(item=>item.telemetry_run_id===run);if(!owned)return{ok:false,reason:'telemetry_run_not_found'};return{ok:true,schema_version:1,trust:'sanitized_untrusted_output',feature,telemetry_run_id:run,...listExecutionEvents(db,run,{after:options.after,limit:options.limit})}}finally{db.close()}}
34
+ return {ok:false,reason:'invalid_subcommand',valid:['init','validate','show','dispatch','resume','status','events']};
35
+ }
36
+ module.exports={runAgentExecution};
@@ -23,14 +23,27 @@ const path = require('node:path');
23
23
  const { resolveIdentity } = require('../operator-memory/identity');
24
24
  const { ensureStorageTree, recordIdentityActivity, openIndexDb, getStorageRoot } = require('../operator-memory/storage');
25
25
  const { deriveSlug, fingerprintProposal } = require('../operator-memory/slug');
26
- const { captureSignal, readProposal, VALID_SIGNAL_TYPES } = require('../operator-memory/proposal');
27
- const { promoteProposal } = require('../operator-memory/decision');
26
+ const { captureSignal, readProposal, deleteProposal, VALID_SIGNAL_TYPES } = require('../operator-memory/proposal');
27
+ const { promoteProposal, readDecision, reinforceDecision } = require('../operator-memory/decision');
28
28
  const { emitDossierEvent } = require('../lib/dossier-telemetry');
29
29
 
30
30
  const CONFIRMATIONS_JSONL = '.aioson/runtime/session-confirmations.jsonl';
31
31
 
32
32
  const PROMOTION_THRESHOLD = 2;
33
33
 
34
+ /**
35
+ * Detections required before a signal promotes to a decision, by signal type.
36
+ *
37
+ * `confirmation` needs 2x — the user accepting the same non-obvious approach twice
38
+ * is what turns it into a signal. `authorization` / `exclusion` / `correction` are
39
+ * single explicit standing decisions ("pode sempre X", "nunca X", "pare de X"), so
40
+ * they promote on first detection. Mirrors the taxonomy in
41
+ * agents/_shared/memory-capture-directive.md.
42
+ */
43
+ function promotionThresholdFor(signalType) {
44
+ return signalType === 'confirmation' ? PROMOTION_THRESHOLD : 1;
45
+ }
46
+
34
47
  function existsCheckFactory(identity) {
35
48
  return (slug) => {
36
49
  const existing = readProposal(identity, slug);
@@ -130,7 +143,23 @@ First detection writes to proposals/{slug}.md. Second detection promotes to deci
130
143
 
131
144
  const count = result.proposal.detected_count;
132
145
 
133
- if (count >= PROMOTION_THRESHOLD) {
146
+ // Idempotent re-detection: a signal already promoted to a decision is reinforced,
147
+ // not re-promoted — re-promotion would duplicate the FTS row and reset promoted_at.
148
+ const existingDecision = readDecision(resolved.identity, slug);
149
+ if (existingDecision) {
150
+ let reinforced = null;
151
+ try { reinforced = reinforceDecision(resolved.identity, slug); } catch { /* best-effort */ }
152
+ // captureSignal re-created a stray proposal (the decision had none) — drop it.
153
+ try { deleteProposal(resolved.identity, slug); } catch { /* best-effort */ }
154
+ const auditLine = `✔ Memory reforçada: '${proposal}'. aioson op:forget ${slug} p/ desfazer.`;
155
+ if (options.json) {
156
+ return { ok: true, promoted: false, reinforced: true, slug, identity: resolved.identity, reinforcement_count: reinforced ? reinforced.reinforcement_count : undefined };
157
+ }
158
+ if (logger) logger.log(auditLine);
159
+ return { ok: true, promoted: false, reinforced: true, slug };
160
+ }
161
+
162
+ if (count >= promotionThresholdFor(signal)) {
134
163
  // Promote to decision
135
164
  let decision;
136
165
  try {
@@ -166,6 +195,7 @@ First detection writes to proposals/{slug}.md. Second detection promotes to deci
166
195
 
167
196
  module.exports = {
168
197
  runOpCapture,
198
+ promotionThresholdFor,
169
199
  PROMOTION_THRESHOLD,
170
200
  CONFIRMATIONS_JSONL
171
201
  };
@@ -5,11 +5,9 @@
5
5
  * Phase 5 (v1.16.0). User-driven action when decay prompt fires.
6
6
  */
7
7
 
8
- const fs = require('node:fs');
9
8
  const { resolveIdentity } = require('../operator-memory/identity');
10
9
  const { ensureStorageTree } = require('../operator-memory/storage');
11
- const { readDecision, decisionPath, serializeDecision } = require('../operator-memory/decision');
12
- const { regenerateIndex } = require('../operator-memory/index-md');
10
+ const { reinforceDecision } = require('../operator-memory/decision');
13
11
  const { emitDossierEvent } = require('../lib/dossier-telemetry');
14
12
 
15
13
  async function runOpReinforce({ args = [], options = {}, logger }) {
@@ -31,30 +29,20 @@ async function runOpReinforce({ args = [], options = {}, logger }) {
31
29
 
32
30
  const resolved = resolveIdentity();
33
31
  ensureStorageTree(resolved.identity);
34
- const decision = readDecision(resolved.identity, slug);
35
- if (!decision) {
32
+
33
+ // Surgical in-place reinforce: bumps reinforcement_count + refreshes
34
+ // last_reinforced and regenerates the index, preserving title/body/trigger-quotes
35
+ // byte-for-byte. (The old re-serialize path double-wrapped them, because the merged
36
+ // body from readDecision already contained the "# title" and "## Trigger quotes".)
37
+ const reinforced = reinforceDecision(resolved.identity, slug);
38
+ if (!reinforced) {
36
39
  const err = `op:reinforce — decision '${slug}' not found for identity ${resolved.identity}.`;
37
40
  if (options.json) return { ok: false, error: err };
38
41
  if (logger && logger.error) logger.error(err);
39
42
  return { ok: false, exitCode: 1, error: err };
40
43
  }
41
44
 
42
- const now = new Date().toISOString();
43
- const previous = decision.last_reinforced;
44
- const updated = {
45
- ...decision,
46
- slug,
47
- last_reinforced: now,
48
- reinforcement_count: Number(decision.reinforcement_count || 0) + 1
49
- };
50
- // serialize keeps quotes + body + frontmatter intact
51
- const out = serializeDecision({ ...updated, body: decision.body, title: decision.body?.split('\n')[0]?.replace(/^# /, '') || slug });
52
- const filePath = decisionPath(resolved.identity, slug);
53
- const tmp = `${filePath}.tmp`;
54
- fs.writeFileSync(tmp, out, 'utf8');
55
- fs.renameSync(tmp, filePath);
56
-
57
- try { regenerateIndex(resolved.identity); } catch { /* non-fatal */ }
45
+ const { last_reinforced: now, previous, reinforcement_count } = reinforced;
58
46
 
59
47
  await emitDossierEvent(targetDir, {
60
48
  agent: 'op-reinforce',
@@ -64,7 +52,7 @@ async function runOpReinforce({ args = [], options = {}, logger }) {
64
52
  });
65
53
 
66
54
  if (options.json) {
67
- return { ok: true, slug, last_reinforced: now, previous, reinforcement_count: updated.reinforcement_count };
55
+ return { ok: true, slug, last_reinforced: now, previous, reinforcement_count };
68
56
  }
69
57
  if (logger) logger.log(`op:reinforce — '${slug}' last_reinforced refreshed to ${now}.`);
70
58
  return { ok: true, slug };
@@ -7,7 +7,8 @@
7
7
  // instead of re-interpreting the config in prose. Read-only; never mutates state.
8
8
 
9
9
  const fs = require('node:fs/promises');
10
- const path = require('node:path');
10
+ const path = require('node:path');
11
+ const { loadManifest, resolveAgentExecution } = require('../agent-execution/manifest');
11
12
  const {
12
13
  readVerificationConfig,
13
14
  resolveHost,
@@ -63,7 +64,7 @@ async function detectClassification(targetDir, slug) {
63
64
  return null;
64
65
  }
65
66
 
66
- async function runVerificationPlan({ args, options = {}, logger }) {
67
+ async function runVerificationPlan({ args, options = {}, logger, catalogLoader }) {
67
68
  const targetDir = path.resolve(process.cwd(), args[0] || '.');
68
69
  const slug = (options.feature || options.slug || '').toString().trim() || null;
69
70
 
@@ -79,8 +80,12 @@ async function runVerificationPlan({ args, options = {}, logger }) {
79
80
  }
80
81
  const trigger = triggerRaw;
81
82
 
82
- const config = await readVerificationConfig(targetDir);
83
- const host = resolveHost(config, options.host);
83
+ const config = await readVerificationConfig(targetDir);
84
+ const host = resolveHost(config, options.host);
85
+ const executionManifest = slug ? await loadManifest(targetDir, slug) : { exists: false, legacy: true, ok: true };
86
+ if (executionManifest.exists && !executionManifest.ok) {
87
+ return { ok: false, reason: 'agent_execution_manifest_invalid', errors: executionManifest.errors, manifest_path: executionManifest.path };
88
+ }
84
89
 
85
90
  let classification = (options.classification || '').toString().trim().toUpperCase();
86
91
  if (!CLASSIFICATIONS.includes(classification)) {
@@ -91,13 +96,24 @@ async function runVerificationPlan({ args, options = {}, logger }) {
91
96
  const context = { trigger, classification, sensitiveSurface };
92
97
  const agents = [];
93
98
  for (const agentId of VERIFICATION_AGENTS) {
94
- const dispatch = getAgentDispatch(config, agentId, host);
95
- const entry = {
99
+ const dispatch = getAgentDispatch(config, agentId, host);
100
+ const execution = executionManifest.exists ? await resolveAgentExecution(executionManifest.manifest, agentId, {}, { catalogLoader }) : null;
101
+ if (execution && !execution.ok) {
102
+ return { ok: false, reason: 'model_resolution_failed', agent: agentId, resolution: execution };
103
+ }
104
+ const entry = {
96
105
  agent: agentId,
97
106
  run: shouldRunForTrigger(config, agentId, context),
98
- mode: dispatch ? dispatch.mode : 'native',
99
- model: dispatch ? dispatch.model : 'configured-default',
100
- report: resolveAgentReportPath(config, agentId, slug || '{slug}')
107
+ host: execution ? execution.host : (dispatch ? dispatch.host : host),
108
+ mode: execution ? execution.mode : (dispatch ? dispatch.mode : 'native'),
109
+ model: execution ? execution.model : (dispatch ? dispatch.model : 'configured-default'),
110
+ model_requested: execution ? execution.model_requested : (dispatch ? dispatch.model : 'configured-default'),
111
+ model_resolved: execution ? execution.model_resolved : (dispatch ? dispatch.model : 'configured-default'),
112
+ model_resolution_strategy: execution ? execution.model_resolution_strategy : 'legacy',
113
+ reasoning_effort: execution ? (execution.reasoning_effort || null) : null,
114
+ report: execution ? execution.report.replace('{run_id}', '{run_id}') : resolveAgentReportPath(config, agentId, slug || '{slug}'),
115
+ execution_source: execution ? 'manifest' : 'legacy',
116
+ execution: execution || undefined
101
117
  };
102
118
  if (agentId === 'validator') {
103
119
  const cc = getCrossCheck(config, agentId);
@@ -115,8 +131,9 @@ async function runVerificationPlan({ args, options = {}, logger }) {
115
131
  sensitive_surface: sensitiveSurface,
116
132
  agents,
117
133
  phase_loop: getPhaseLoop(config),
118
- budget: getBudget(config)
119
- };
134
+ budget: getBudget(config)
135
+ };
136
+ result.agent_execution = executionManifest.exists ? { source: 'manifest', path: executionManifest.path, digest: executionManifest.digest } : { source: 'legacy' };
120
137
  result.continuation_directive = buildContinuationDirective(trigger, result.phase_loop);
121
138
 
122
139
  if (options.json) return result;
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  const fs = require('node:fs/promises');
11
- const path = require('node:path');
11
+ const path = require('node:path');
12
+ const { initManifest, loadManifest } = require('../agent-execution/manifest');
12
13
  const {
13
14
  detectClassification,
14
15
  scanArtifacts,
@@ -751,7 +752,7 @@ async function performRunnerTransition(targetDir, suggestion, tool, requestedMod
751
752
  async function runWorkflowExecute({ args, options = {}, logger }) {
752
753
  const targetDir = path.resolve(process.cwd(), args[0] || '.');
753
754
  const slug = options.feature ? String(options.feature).trim() : null;
754
- const tool = options.tool ? String(options.tool).trim() : 'claude';
755
+ const tool = options.tool ? String(options.tool).trim() : 'claude';
755
756
  const requestedMode = options.mode ? String(options.mode).trim() : null;
756
757
  const dryRun = Boolean(options['dry-run'] || options.dry);
757
758
  // --step (disarm) is by definition record-only: it writes the disarmed scheme
@@ -788,9 +789,20 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
788
789
  return failure;
789
790
  }
790
791
 
791
- const parallelGuard = laneIndex !== null
792
+ const parallelGuard = laneIndex !== null
792
793
  ? await runLaneGuardPreflight(targetDir, laneIndex)
793
- : null;
794
+ : null;
795
+
796
+ let agentExecution = await loadManifest(targetDir, slug);
797
+ if (!dryRun && !agentExecution.exists) {
798
+ await initManifest(targetDir, slug, tool);
799
+ agentExecution = await loadManifest(targetDir, slug);
800
+ }
801
+ if (agentExecution.exists && !agentExecution.ok) {
802
+ const failure = { ok: false, reason: 'agent_execution_manifest_invalid', errors: agentExecution.errors, manifest_path: agentExecution.path };
803
+ if (!options.json) logger.error(`Invalid agent execution manifest: ${agentExecution.path}`);
804
+ return failure;
805
+ }
794
806
 
795
807
  if (parallelGuard && !parallelGuard.ok && !parallelGuard.skipped) {
796
808
  if (parallelGuard.reason === 'lane_not_found') {
@@ -882,7 +894,8 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
882
894
  status_snapshot: statusSnapshot,
883
895
  suggestion: statusSnapshot && statusSnapshot.suggestion ? statusSnapshot.suggestion : null,
884
896
  resume_command: resumeCommand,
885
- agentic_policy: agenticPolicy,
897
+ agentic_policy: agenticPolicy,
898
+ agent_execution: agentExecution.exists ? { path: agentExecution.path, digest: agentExecution.digest } : { source: 'legacy' },
886
899
  parallel_guard: parallelGuard
887
900
  };
888
901
 
@@ -953,7 +966,8 @@ async function runWorkflowExecute({ args, options = {}, logger }) {
953
966
  status_snapshot: statusSnapshot,
954
967
  suggestion: statusSnapshot && statusSnapshot.suggestion ? statusSnapshot.suggestion : null,
955
968
  resume_command: resumeCommand,
956
- agentic_policy: agenticPolicy,
969
+ agentic_policy: agenticPolicy,
970
+ agent_execution: { path: agentExecution.path, digest: agentExecution.digest },
957
971
  parallel_guard: parallelGuard
958
972
  };
959
973
 
@@ -16,7 +16,10 @@
16
16
 
17
17
  const crypto = require('node:crypto');
18
18
 
19
- const DEFAULT_CHECK_TIMEOUT_MS = 120000;
19
+ // Repository-wide quality gates can legitimately exceed two minutes on
20
+ // Windows. Keep the CLI --timeout override, but give deterministic contract
21
+ // checks enough headroom to avoid classifying a healthy full suite as failed.
22
+ const DEFAULT_CHECK_TIMEOUT_MS = 300000;
20
23
 
21
24
  /**
22
25
  * Normaliza uma linha de erro para assinatura estável (D7):
@@ -226,6 +226,46 @@ function promoteProposal({ identity, proposal: proposalData }) {
226
226
  return decision;
227
227
  }
228
228
 
229
+ /**
230
+ * Reinforce an existing decision in place: bump reinforcement_count and refresh
231
+ * last_reinforced (PMD-11). Used when a signal already promoted is re-detected —
232
+ * strengthens the memory instead of re-promoting (which would duplicate the FTS
233
+ * row and reset promoted_at).
234
+ *
235
+ * Surgical frontmatter edit — preserves title, body and trigger quotes byte-for-byte
236
+ * (unlike a full re-serialize, which cannot recover the title from the merged body).
237
+ * Returns { slug, last_reinforced, previous, reinforcement_count } or null if absent.
238
+ */
239
+ function reinforceDecision(identity, slug) {
240
+ const filePath = decisionPath(identity, slug);
241
+ if (!fs.existsSync(filePath)) return null;
242
+
243
+ const content = fs.readFileSync(filePath, 'utf8');
244
+ const parsed = parseDecisionFile(content);
245
+ const previous = (parsed && parsed.last_reinforced) || null;
246
+ const nextCount = Number((parsed && parsed.reinforcement_count) || 0) + 1;
247
+ const now = new Date().toISOString();
248
+
249
+ // Replace only the first (frontmatter) occurrence of each field; `.` never
250
+ // spans newlines, so the body is untouched.
251
+ const updated = content
252
+ .replace(/^(reinforcement_count:[ \t]*).*$/m, `$1${nextCount}`)
253
+ .replace(/^(last_reinforced:[ \t]*).*$/m, `$1${now}`);
254
+
255
+ const tmpPath = `${filePath}.tmp`;
256
+ fs.writeFileSync(tmpPath, updated, 'utf8');
257
+ fs.renameSync(tmpPath, filePath);
258
+
259
+ // Post-write: regenerate MEMORY.md index (best-effort — it surfaces the
260
+ // reinforced date). FTS body/category are unchanged, so no FTS write needed.
261
+ try {
262
+ const { regenerateIndex } = require('./index-md');
263
+ regenerateIndex(identity);
264
+ } catch { /* index regen failure is non-fatal */ }
265
+
266
+ return { slug, last_reinforced: now, previous, reinforcement_count: nextCount };
267
+ }
268
+
229
269
  /**
230
270
  * Soft-delete a decision or proposal to history/.
231
271
  * Returns { mode: 'decision'|'proposal'|'noop', archivedPath: string|null }.
@@ -267,6 +307,7 @@ function forgetEntry(identity, slug) {
267
307
 
268
308
  module.exports = {
269
309
  promoteProposal,
310
+ reinforceDecision,
270
311
  forgetEntry,
271
312
  readDecision,
272
313
  decisionPath,
@@ -176,7 +176,7 @@ async function openRuntimeDb(targetDir, options = {}) {
176
176
  FOREIGN KEY (run_key) REFERENCES agent_runs(run_key) ON DELETE CASCADE
177
177
  );
178
178
 
179
- CREATE TABLE IF NOT EXISTS execution_events (
179
+ CREATE TABLE IF NOT EXISTS execution_events (
180
180
  id INTEGER PRIMARY KEY AUTOINCREMENT,
181
181
  task_key TEXT,
182
182
  run_key TEXT,
@@ -199,7 +199,51 @@ async function openRuntimeDb(targetDir, options = {}) {
199
199
  created_at TEXT NOT NULL,
200
200
  FOREIGN KEY (task_key) REFERENCES tasks(task_key) ON DELETE SET NULL,
201
201
  FOREIGN KEY (run_key) REFERENCES agent_runs(run_key) ON DELETE CASCADE
202
- );
202
+ );
203
+
204
+ CREATE TABLE IF NOT EXISTS agent_execution_runs (
205
+ telemetry_run_id TEXT PRIMARY KEY,
206
+ dispatcher_run_id TEXT NOT NULL,
207
+ attempt_id TEXT NOT NULL,
208
+ feature TEXT NOT NULL,
209
+ agent TEXT NOT NULL,
210
+ host TEXT NOT NULL,
211
+ model TEXT NOT NULL,
212
+ model_requested TEXT,
213
+ model_resolved TEXT,
214
+ reasoning_effort TEXT,
215
+ model_resolution_strategy TEXT,
216
+ catalog_source TEXT,
217
+ state TEXT NOT NULL DEFAULT 'queued',
218
+ pid INTEGER,
219
+ process_fingerprint TEXT,
220
+ report_path TEXT,
221
+ report_digest TEXT,
222
+ reason TEXT,
223
+ truncated_bytes INTEGER NOT NULL DEFAULT 0,
224
+ dropped_events INTEGER NOT NULL DEFAULT 0,
225
+ created_at TEXT NOT NULL,
226
+ updated_at TEXT NOT NULL,
227
+ finished_at TEXT,
228
+ UNIQUE(feature, agent, dispatcher_run_id, attempt_id)
229
+ );
230
+
231
+ CREATE TABLE IF NOT EXISTS agent_execution_events (
232
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
233
+ telemetry_run_id TEXT NOT NULL,
234
+ sequence_no INTEGER NOT NULL,
235
+ schema_version INTEGER NOT NULL DEFAULT 1,
236
+ event_type TEXT NOT NULL,
237
+ stream TEXT,
238
+ safe_summary TEXT NOT NULL DEFAULT '',
239
+ payload_json TEXT,
240
+ bytes INTEGER NOT NULL DEFAULT 0,
241
+ created_at TEXT NOT NULL,
242
+ UNIQUE(telemetry_run_id, sequence_no),
243
+ FOREIGN KEY (telemetry_run_id) REFERENCES agent_execution_runs(telemetry_run_id) ON DELETE CASCADE
244
+ );
245
+ CREATE INDEX IF NOT EXISTS idx_agent_execution_events_cursor ON agent_execution_events(telemetry_run_id, sequence_no);
246
+ CREATE INDEX IF NOT EXISTS idx_agent_execution_runs_feature_updated ON agent_execution_runs(feature, updated_at DESC);
203
247
 
204
248
  CREATE TABLE IF NOT EXISTS artifacts (
205
249
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -650,8 +694,14 @@ function normalizeTaskStatus(value, fallback) {
650
694
  return VALID_TASK_STATUSES.has(candidate) ? candidate : fallback;
651
695
  }
652
696
 
653
- function ensureLegacyColumns(db) {
654
- const taskColumns = db.prepare('PRAGMA table_info(tasks)').all();
697
+ function ensureLegacyColumns(db) {
698
+ const executionRunColumns = db.prepare('PRAGMA table_info(agent_execution_runs)').all();
699
+ const executionRunColumnNames = new Set(executionRunColumns.map((column) => column.name));
700
+ for (const column of ['model_requested', 'model_resolved', 'reasoning_effort', 'model_resolution_strategy', 'catalog_source']) {
701
+ if (!executionRunColumnNames.has(column)) db.exec(`ALTER TABLE agent_execution_runs ADD COLUMN ${column} TEXT`);
702
+ }
703
+
704
+ const taskColumns = db.prepare('PRAGMA table_info(tasks)').all();
655
705
  const taskColumnNames = new Set(taskColumns.map((column) => column.name));
656
706
 
657
707
  if (!taskColumnNames.has('task_kind')) {
@@ -2619,9 +2669,115 @@ function insertEvolutionLog(db, opts) {
2619
2669
  return id;
2620
2670
  }
2621
2671
 
2622
- function listEvolutionLog(db, limit = 20) {
2672
+ function listEvolutionLog(db, limit = 20) {
2623
2673
  return db.prepare('SELECT * FROM evolution_log ORDER BY applied_at DESC LIMIT ?').all(limit);
2624
- }
2674
+ }
2675
+
2676
+ const EXECUTION_TERMINAL_STATES = new Set(['passed', 'failed', 'cancelled']);
2677
+ const EXECUTION_TRANSITIONS = {
2678
+ queued: new Set(['running', 'waiting_report', 'paused', 'failed', 'cancelled']),
2679
+ running: new Set(['waiting_report', 'paused', 'failed', 'cancelled']),
2680
+ waiting_report: new Set(['passed', 'failed', 'correcting', 'paused']),
2681
+ correcting: new Set(['running', 'paused', 'failed', 'cancelled']),
2682
+ paused: new Set(['running', 'cancelled'])
2683
+ };
2684
+
2685
+ function executionCorrelation(input) {
2686
+ return {
2687
+ feature: String(input.feature), agent: String(input.agent),
2688
+ dispatcherRunId: String(input.dispatcher_run_id || input.run_id),
2689
+ attemptId: String(input.attempt_id)
2690
+ };
2691
+ }
2692
+
2693
+ function findExecutionRun(db, input) {
2694
+ const c = executionCorrelation(input);
2695
+ return db.prepare(`SELECT * FROM agent_execution_runs WHERE feature=? AND agent=? AND dispatcher_run_id=? AND attempt_id=?`)
2696
+ .get(c.feature, c.agent, c.dispatcherRunId, c.attemptId) || null;
2697
+ }
2698
+
2699
+ function createExecutionRun(db, input) {
2700
+ const c = executionCorrelation(input); const now = nowIso();
2701
+ const id = input.telemetry_run_id || `aex-${slugify(c.feature)}-${require('node:crypto').randomUUID()}`;
2702
+ const modelResolved = String(input.model_resolved || input.model);
2703
+ const modelRequested = String(input.model_requested || input.model || '');
2704
+ db.prepare(`INSERT OR IGNORE INTO agent_execution_runs
2705
+ (telemetry_run_id,dispatcher_run_id,attempt_id,feature,agent,host,model,model_requested,model_resolved,reasoning_effort,model_resolution_strategy,catalog_source,state,created_at,updated_at)
2706
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id,c.dispatcherRunId,c.attemptId,c.feature,c.agent,String(input.host),modelResolved,modelRequested,modelResolved,input.reasoning_effort?String(input.reasoning_effort):null,input.model_resolution_strategy?String(input.model_resolution_strategy):null,input.catalog_source?String(input.catalog_source):null,'queued',now,now);
2707
+ const run = findExecutionRun(db,input);
2708
+ appendExecutionEvent(db,run.telemetry_run_id,{type:'run_created',safe_summary:'Execution queued'});
2709
+ return run;
2710
+ }
2711
+
2712
+ function appendExecutionEvent(db, runOrCorrelation, event) {
2713
+ const run = typeof runOrCorrelation === 'string'
2714
+ ? db.prepare('SELECT * FROM agent_execution_runs WHERE telemetry_run_id=?').get(runOrCorrelation)
2715
+ : findExecutionRun(db,runOrCorrelation);
2716
+ if (!run) throw new Error('execution_run_not_found');
2717
+ const allowed = new Set(['run_created','process_started','state_changed','output','retry','fallback','timeout','report_attached','output_truncated','recovered','diagnostic']);
2718
+ if (!allowed.has(event.type)) throw new Error('execution_event_type_invalid');
2719
+ const sanitize=(value,depth=0)=>{if(depth>4)return'[TRUNCATED]';if(typeof value==='string')return value.replace(/(authorization\s*[:=]\s*(?:bearer\s+)?|api[_-]?key\s*[:=]\s*|token\s*[:=]\s*|password\s*[:=]\s*)[^\s,;]+/gi,'$1[REDACTED]').slice(0,2000);if(Array.isArray(value))return value.slice(0,25).map(v=>sanitize(v,depth+1));if(value&&typeof value==='object'){const out={};for(const key of Object.keys(value).slice(0,25))out[String(key).slice(0,100)]=sanitize(value[key],depth+1);return out}return value};
2720
+ const safeSummary=sanitize(String(event.safe_summary||''));const safePayload=event.payload?sanitize(event.payload):null;
2721
+ const tx=db.transaction(()=>{const seq=db.prepare('SELECT COALESCE(MAX(sequence_no),0)+1 n FROM agent_execution_events WHERE telemetry_run_id=?').get(run.telemetry_run_id).n;
2722
+ db.prepare(`INSERT INTO agent_execution_events(telemetry_run_id,sequence_no,event_type,stream,safe_summary,payload_json,bytes,created_at) VALUES(?,?,?,?,?,?,?,?)`)
2723
+ .run(run.telemetry_run_id,seq,event.type,event.stream||null,safeSummary,safePayload?JSON.stringify(safePayload).slice(0,16384):null,Math.min(Math.max(Number(event.bytes||0),0),16384),nowIso());return seq;});
2724
+ return tx();
2725
+ }
2726
+
2727
+ function attachExecutionProcess(db, correlation, process) {
2728
+ const run=findExecutionRun(db,correlation); if(!run)throw new Error('execution_run_not_found');
2729
+ if(run.pid && run.pid!==process.pid)throw new Error('execution_process_conflict');
2730
+ db.prepare('UPDATE agent_execution_runs SET pid=?,process_fingerprint=?,state=?,updated_at=? WHERE telemetry_run_id=?')
2731
+ .run(Number(process.pid),String(process.fingerprint||''),'running',nowIso(),run.telemetry_run_id);
2732
+ appendExecutionEvent(db,run.telemetry_run_id,{type:'process_started',safe_summary:`Process ${process.pid} started`});
2733
+ return db.prepare('SELECT * FROM agent_execution_runs WHERE telemetry_run_id=?').get(run.telemetry_run_id);
2734
+ }
2735
+
2736
+ function transitionExecutionRun(db, correlation, next, reason=null) {
2737
+ const run=findExecutionRun(db,correlation); if(!run)throw new Error('execution_run_not_found');
2738
+ if(run.state===next)return run;if(EXECUTION_TERMINAL_STATES.has(run.state)||!EXECUTION_TRANSITIONS[run.state]?.has(next))throw new Error('execution_state_transition_invalid');
2739
+ const safeReason=String(reason||'').replace(/(token|password|api[_-]?key)\s*[:=]\s*\S+/gi,'$1=[REDACTED]').slice(0,500)||null;
2740
+ const now=nowIso();db.prepare('UPDATE agent_execution_runs SET state=?,reason=?,updated_at=?,finished_at=? WHERE telemetry_run_id=? AND state=?')
2741
+ .run(next,safeReason,now,EXECUTION_TERMINAL_STATES.has(next)?now:null,run.telemetry_run_id,run.state);
2742
+ appendExecutionEvent(db,run.telemetry_run_id,{type:'state_changed',safe_summary:`${run.state} -> ${next}`,payload:{state:next,reason}});
2743
+ return db.prepare('SELECT * FROM agent_execution_runs WHERE telemetry_run_id=?').get(run.telemetry_run_id);
2744
+ }
2745
+
2746
+ function attachExecutionReport(db, correlation, report) {
2747
+ const run=findExecutionRun(db,correlation);if(!run)throw new Error('execution_run_not_found');
2748
+ if(report.attempt_id!==run.attempt_id||report.run_id!==run.dispatcher_run_id)throw new Error('execution_report_binding_invalid');
2749
+ db.prepare('UPDATE agent_execution_runs SET report_path=?,report_digest=?,updated_at=? WHERE telemetry_run_id=?')
2750
+ .run(String(report.path||''),String(report.digest||''),nowIso(),run.telemetry_run_id);
2751
+ appendExecutionEvent(db,run.telemetry_run_id,{type:'report_attached',safe_summary:'Bound report attached'});return findExecutionRun(db,correlation);
2752
+ }
2753
+
2754
+ function getExecutionSnapshot(db, filters={}) {
2755
+ const where=[],args=[];for(const [column,key] of [['feature','feature'],['agent','agent'],['state','state']])if(filters[key]){where.push(`${column}=?`);args.push(String(filters[key]));}
2756
+ const limit=Math.min(Math.max(Number(filters.limit)||50,1),200);
2757
+ return db.prepare(`SELECT * FROM agent_execution_runs ${where.length?'WHERE '+where.join(' AND '):''} ORDER BY updated_at DESC LIMIT ?`).all(...args,limit);
2758
+ }
2759
+
2760
+ function listExecutionEvents(db, runId, options={}) {
2761
+ const after=Math.max(Number(options.after)||0,0),limit=Math.min(Math.max(Number(options.limit)||100,1),500);
2762
+ const rows=db.prepare('SELECT * FROM agent_execution_events WHERE telemetry_run_id=? AND sequence_no>? ORDER BY sequence_no LIMIT ?').all(runId,after,limit+1);
2763
+ const hasMore=rows.length>limit,events=hasMore?rows.slice(0,limit):rows;
2764
+ return {events,next_cursor:events.at(-1)?.sequence_no||after,has_more:hasMore};
2765
+ }
2766
+
2767
+ function reconcileExecutionRun(db, correlation, probe=()=>false) {
2768
+ const run=findExecutionRun(db,correlation);if(!run)throw new Error('execution_run_not_found');
2769
+ if(EXECUTION_TERMINAL_STATES.has(run.state)||run.state==='paused')return run;
2770
+ if(!run.pid)return transitionExecutionRun(db,correlation,'paused','spawn_identity_missing');
2771
+ const observed=probe({pid:run.pid,fingerprint:run.process_fingerprint,created_at:run.created_at});
2772
+ const alive=observed===true?false:Boolean(observed?.alive&&observed?.fingerprint&&observed.fingerprint===run.process_fingerprint);
2773
+ if(alive)return transitionExecutionRun(db,correlation,'paused','detached_process');
2774
+ return transitionExecutionRun(db,correlation,'paused','process_not_alive');
2775
+ }
2776
+
2777
+ function pruneExecutionTelemetry(db, options={}) {
2778
+ const days=Math.max(Number(options.retentionDays)||14,1),cutoff=new Date(Date.now()-days*86400000).toISOString(),batch=Math.min(Math.max(Number(options.batch)||100,1),1000);
2779
+ return db.prepare(`DELETE FROM agent_execution_runs WHERE telemetry_run_id IN (SELECT telemetry_run_id FROM agent_execution_runs WHERE updated_at < ? AND state IN ('passed','failed','cancelled','paused') ORDER BY updated_at LIMIT ?)` ).run(cutoff,batch).changes;
2780
+ }
2625
2781
 
2626
2782
  module.exports = {
2627
2783
  resolveRuntimePaths,
@@ -2725,5 +2881,8 @@ module.exports = {
2725
2881
  listDynamicTools,
2726
2882
  // Evolution Log CRUD
2727
2883
  insertEvolutionLog,
2728
- listEvolutionLog
2729
- };
2884
+ listEvolutionLog
2885
+ ,createExecutionRun, findExecutionRun, attachExecutionProcess, transitionExecutionRun,
2886
+ appendExecutionEvent, attachExecutionReport, getExecutionSnapshot, listExecutionEvents,
2887
+ reconcileExecutionRun, pruneExecutionTelemetry
2888
+ };
@@ -149,7 +149,7 @@ Also check `.aioson/plans/{slug}/manifest.md` before any implementation:
149
149
 
150
150
  ## Phase loop (auto-continue)
151
151
 
152
- @dev runs a phased plan as **one continuous drive to the end of the feature, not one phase per turn.** Auto-continue is imperative (`phase_loop.auto_continue`): when a phase's gate is clean, go straight into the next phase — never stop to ask "continue?", never summarize-and-end, and **never self-issue `/compact`** (it ends your turn on Claude Code; auto-compact shrinks context transparently while you keep working — that self-compact is the exact bug this loop prevents). Per phase: `harness:check`, then `aioson verification:plan . --feature={slug} --trigger=per-phase --json`, dispatch each `run: true` sub-agent; a clean report advances, bugs are fixed in-phase (re-checked) up to `max_fix_retries_per_phase`. `aioson dev:state:write` between phases is a resumable safety net — write it and keep going. Full runtime smoke runs once at end-of-feature. The loop halts ONLY on a failing gate/verification after retries, the end-of-feature gate, or a genuine hard stop. Full protocol: `.aioson/docs/dev/phase-loop.md`.
152
+ @dev runs a phased plan as **one continuous drive to the end of the feature, not one phase per turn.** Auto-continue is imperative (`phase_loop.auto_continue`): when a phase's gate is clean, go straight into the next phase — never stop to ask "continue?", never summarize-and-end, and **never self-issue `/compact`**. Per phase: `harness:check`, then `aioson verification:plan . --feature={slug} --trigger=per-phase --json`; dispatch each `run: true` entry through `aioson agent:execution:dispatch`, never by an assumed prompt-only subagent. `unsupported_capability` pauses honestly. A clean schema-valid report advances; bugs are fixed in-phase up to `max_fix_retries_per_phase`. `aioson dev:state:write` between phases is a resumable safety net. Full runtime smoke runs once at end-of-feature. Full protocol: `.aioson/docs/dev/phase-loop.md`.
153
153
 
154
154
  ## Context size detection
155
155
 
@@ -339,7 +339,8 @@ Only `@product` asks (the kickoff). Downstream agents (`@sheldon`/`@orchestrator
339
339
 
340
340
  **Autopilot actions** (per `.aioson/docs/autopilot-handoff.md`):
341
341
  1. Finish the PRD, the `features.md` line, and — MICRO (`→ @dev`) — the `## Dev handoff producer` `dev-state.md`.
342
- 2. Seed the contract (idempotent): `aioson workflow:execute . --feature={slug} --seed --tool=claude`. **Check the result.** A `different_active_feature` failure means another feature still holds `workflow.state.json`: surface it (close/pause it or `aioson feature:sweep .`) and stop with the manual handoff — a failed seed never arms the chain.
342
+ 2. Seed the contract (idempotent): `aioson workflow:execute . --feature={slug} --seed --tool=claude`. **Check the result.** A `different_active_feature` failure means another feature still holds `workflow.state.json`: surface it (close/pause it or `aioson feature:sweep .`) and stop with the manual handoff — a failed seed never arms the chain.
343
+ The seed creates `agent-execution-{slug}.json`; mention editable defaults. Aliases are not model IDs.
343
344
  3. Register closing duties (`agent:epilogue`/`agent:done`), emit `Autopilot: @product done → invoking @<next> (Ctrl+C to interrupt)`.
344
345
  4. Invoke the lane's next stage: SMALL → `Skill(aioson:agent:sheldon)`; MEDIUM → `Skill(aioson:agent:orchestrator)`; MICRO → `Skill(aioson:agent:dev)`; site → `Skill(aioson:agent:copywriter)`. Task: `"continue feature {slug} — autopilot handoff from @product"`.
345
346
 
@@ -35,7 +35,13 @@ aioson workflow:execute . --feature={slug} --seed --tool=<tool>
35
35
 
36
36
  **Seed failure is a stop condition.** The seeding agent must check the command result: a `different_active_feature` failure means another feature is genuinely active in `workflow.state.json` — surface it to the user (close/pause it, or `aioson feature:sweep .`) and stop with the manual handoff. Never continue the chain as if autopilot were armed when the seed failed.
37
37
 
38
- The headless/tracked runner `aioson workflow:execute . --feature={slug} --tool=<tool> --agentic` (without `--seed`) is the same contract but also advances checkpoints from the CLI — use it for non-interactive runs. Prompt-level `Skill(...)` chaining is how interactive Claude Code / codex sessions consume the scheme.
38
+ The headless/tracked runner `aioson workflow:execute . --feature={slug} --tool=<tool> --agentic` (without `--seed`) is the same contract but also advances checkpoints from the CLI — use it for non-interactive runs. Prompt-level `Skill(...)` chaining is how interactive Claude Code / codex sessions consume the scheme.
39
+
40
+ Execution selection lives in `.aioson/context/agent-execution-{slug}.json`. Validate it before code with `aioson agent:execution:validate`; use `agent:execution:dispatch|resume` for execution. Generated manifests default to `external`: the installed Claude/Codex/OpenCode CLI runs headlessly in a fresh process and writes a bound report. Native subagent/fresh-session modes require an explicit bridge capability; prompt-level chaining is not evidence. The core cannot force a client to open a visible interactive chat window.
41
+
42
+ Codex entries may use a canonical slug or a human form such as `"model": "GPT 5.6 Terra"`, plus an optional `"reasoning_effort": "high"`. Validation resolves the current local Codex model catalog in conservative tiers: exact slug, normalized display name, unique alias, then a uniquely safe fuzzy match. Version numbers must remain identical, and ambiguous matches pause before process spawn. The manifest remains unchanged; state, reports, CLI output, and telemetry keep `model_requested`, `model_resolved`, and `model_resolution_strategy` separately. When the catalog is unavailable, only `configured-default` and literal model IDs are accepted as unverified fallbacks. Explicit reasoning effort is never silently downgraded or moved to another provider.
43
+
44
+ Cross-repository writes are opt-in per agent through `writable_roots`. Every path must exist, be a directory, contain no traversal, and is canonicalized before dispatch and recorded in state/report. Codex maps roots to repeated `exec --add-dir <absolute>` argv; Claude maps to `--add-dir`. OpenCode currently has no verified additional-writable-root flag, so a non-empty list returns `host_capability_missing` rather than widening access silently.
39
45
 
40
46
  ## Routing — deterministic, never LLM-chosen
41
47
 
@@ -21,9 +21,8 @@ After finishing each phase:
21
21
  ```bash
22
22
  aioson verification:plan . --feature={slug} --trigger=per-phase --json
23
23
  ```
24
- For every agent with `run: true`, dispatch it as a sub-agent on the plan's top-level `host` plus that agent's `mode` / `model`, scoped to this phase's changed files:
25
- - `mode: native` → an in-harness sub-agent. On Claude Code use the Task tool with that `model` tier (e.g. `sonnet-4.6`); on codex/opencode use their own configured model. The sub-agent writes its `report` file (e.g. `qa-report-{slug}.md`) and returns it to @dev.
26
- - `mode: external` → only the explicitly configured cross-vendor auditor (`cross_check`); never spawn one otherwise.
24
+ For every agent with `run: true`, use `aioson agent:execution:dispatch . --feature={slug} --agent={agent} --json`. The resolved manifest is authoritative for host/model/mode. Validate model aliases before dispatch and preserve the distinct requested/resolved model, resolution strategy, and optional reasoning effort in state and reports; ambiguity or an unsupported effort is a real pause before spawn. A `unsupported_capability` or `manifest_invalid` result is also a real pause: never imitate a sub-agent or fresh session in prose. Only `external` execution backed by an installed CLI, or a native capability explicitly exposed by the current harness, may run.
25
+ - `mode: external` → the portable default: an installed host CLI runs headlessly in a fresh process, waits for exit, and must write the bound JSON report. This creates an isolated headless context, not a new interactive chat window.
27
26
  Read the report: **PASS** → continue. **Bugs** → fix them within this phase, re-run `harness:check`, and re-dispatch — up to `phase_loop.max_fix_retries_per_phase` times, then stop and surface the failure instead of advancing.
28
27
  4. **Checkpoint, then keep going — do NOT end the turn.** Write the cold-start packet as a crash/interrupt safety net:
29
28
  ```bash