@jaimevalasek/aioson 1.9.2 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +44 -1
  3. package/package.json +1 -1
  4. package/src/cli.js +45 -1
  5. package/src/commands/op-capture.js +146 -0
  6. package/src/commands/op-forget.js +54 -0
  7. package/src/commands/op-identity.js +145 -0
  8. package/src/commands/op-list.js +105 -0
  9. package/src/commands/op-migrate.js +158 -0
  10. package/src/commands/op-promote.js +66 -0
  11. package/src/commands/op-reinforce.js +73 -0
  12. package/src/commands/op-show.js +71 -0
  13. package/src/commands/op-stubs.js +67 -0
  14. package/src/commands/preflight.js +6 -2
  15. package/src/commands/runtime.js +151 -0
  16. package/src/commands/state-save.js +61 -0
  17. package/src/commands/sync-agents-preflight.js +117 -3
  18. package/src/commands/workflow-next.js +64 -0
  19. package/src/handoff-contract.js +25 -0
  20. package/src/lib/agent-semantic-diff.js +199 -0
  21. package/src/operator-memory/conflict.js +202 -0
  22. package/src/operator-memory/decay.js +157 -0
  23. package/src/operator-memory/decision.js +274 -0
  24. package/src/operator-memory/identity.js +109 -0
  25. package/src/operator-memory/index-md.js +170 -0
  26. package/src/operator-memory/loader.js +106 -0
  27. package/src/operator-memory/proposal.js +179 -0
  28. package/src/operator-memory/prune.js +81 -0
  29. package/src/operator-memory/slug.js +90 -0
  30. package/src/operator-memory/storage.js +121 -0
  31. package/src/preflight-engine.js +91 -1
  32. package/template/.aioson/agents/dev.md +1 -1
  33. package/template/.aioson/agents/deyvin.md +3 -3
  34. package/template/.aioson/agents/manifests/pm.manifest.json +2 -1
  35. package/template/.aioson/agents/neo.md +1 -1
  36. package/template/.aioson/agents/orchestrator.md +4 -3
  37. package/template/.aioson/agents/pm.md +58 -6
  38. package/template/.aioson/agents/product.md +1 -1
  39. package/template/.aioson/agents/setup.md +1 -1
  40. package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
  41. package/template/.aioson/skills/process/aioson-spec-driven/references/artifact-map.md +2 -2
  42. package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
  43. package/template/AGENTS.md +23 -0
  44. package/template/CLAUDE.md +23 -0
  45. package/template/agents/_shared/memory-capture-directive.md +115 -0
@@ -28,6 +28,7 @@ const {
28
28
  buildContextPackage,
29
29
  evaluateReadiness,
30
30
  detectStaleDevState,
31
+ detectStaleDevStateRich,
31
32
  extractSpecVersion,
32
33
  extractLastCheckpoint,
33
34
  GATE_NAMES
@@ -69,8 +70,11 @@ async function runPreflight({ args, options = {}, logger }) {
69
70
  ? manifest.path
70
71
  : (artifacts.implementation_plan.exists ? artifacts.implementation_plan.path : null);
71
72
 
72
- // Stale dev-state detection (AC-SDLC-12)
73
- const staleDevStateWarning = devState.exists ? detectStaleDevState(devState, slug) : null;
73
+ // Stale dev-state detection (AC-SDLC-12 + F1 workflow-handoff-integrity v1.9.7).
74
+ // Use rich variant: cross-references features.md (orphan/done detection) and applies 30d TTL.
75
+ const staleDevStateWarning = devState.exists
76
+ ? await detectStaleDevStateRich(devState, slug, targetDir)
77
+ : null;
74
78
 
75
79
  // Determine mode
76
80
  const mode = slug
@@ -1222,6 +1222,9 @@ async function runAgentDone({ args, options = {}, logger, t }) {
1222
1222
  logger.log(`agent:done — ${normalizedAgent} | live session active, event logged | run: ${session.runKey} (${dbPath})`);
1223
1223
  }
1224
1224
 
1225
+ // F2 (workflow-handoff-integrity v1.9.5) — best-effort auto-advance workflow pointer
1226
+ await maybeAutoAdvanceWorkflow({ targetDir, normalizedAgent, options, logger, t });
1227
+
1225
1228
  if (isDocCreatingAgent(normalizedAgent)) {
1226
1229
  backupAiosonDocs(targetDir).catch(() => {});
1227
1230
  }
@@ -1279,6 +1282,9 @@ async function runAgentDone({ args, options = {}, logger, t }) {
1279
1282
  logger.log(`agent:done — ${normalizedAgent} | task: ${taskKey} | run: ${runKey} (${dbPath})`);
1280
1283
  }
1281
1284
 
1285
+ // F2 (workflow-handoff-integrity v1.9.5) — best-effort auto-advance workflow pointer
1286
+ await maybeAutoAdvanceWorkflow({ targetDir, normalizedAgent, options, logger, t });
1287
+
1282
1288
  if (isDocCreatingAgent(normalizedAgent)) {
1283
1289
  backupAiosonDocs(targetDir).catch(() => {});
1284
1290
  }
@@ -1299,6 +1305,150 @@ async function runAgentDone({ args, options = {}, logger, t }) {
1299
1305
  }
1300
1306
 
1301
1307
 
1308
+ /**
1309
+ * maybeAutoAdvanceWorkflow — F2 (workflow-handoff-integrity v1.9.5)
1310
+ *
1311
+ * Best-effort: when a workflow is active for the project AND the calling
1312
+ * agent has produced its canonical artifact on disk, internally invokes
1313
+ * `runWorkflowNext({ complete: <agent> })` so the pointer advances without
1314
+ * requiring every agent prompt to literal-call `aioson workflow:next`.
1315
+ *
1316
+ * Gating (DD-01 — workflow.state.json presence-detection):
1317
+ * - workflow.state.json absent OR `--no-auto-advance` flag → skip (backward-compat)
1318
+ * - workflow.state.json corrupt → log warning, skip (AC-F2-09 graceful degradation)
1319
+ * - agent unknown in handoff-contract CONTRACTS → log warning, skip (AC-F2-10)
1320
+ *
1321
+ * Idempotency (BR-01): `last_workflow_event_at` in workflow.state.json blocks
1322
+ * re-emission within a 1s window.
1323
+ *
1324
+ * Side effects (best-effort, every failure is non-fatal):
1325
+ * - reads `.aioson/context/workflow.state.json`
1326
+ * - writes `last_workflow_event_at` back to that file on success
1327
+ * - calls `runWorkflowNext` with quiet logger + `--json` to suppress prose
1328
+ * - emits ONE concise stdout line on success when not in --json mode
1329
+ *
1330
+ * @param {object} ctx
1331
+ * @param {string} ctx.targetDir Project root.
1332
+ * @param {string} ctx.normalizedAgent Agent name with leading `@`.
1333
+ * @param {object} ctx.options agent:done CLI options.
1334
+ * @param {object} ctx.logger Logger (logger.log + logger.error).
1335
+ * @param {Function} [ctx.t] Translation fn (passed through).
1336
+ * @returns {Promise<{advanced: boolean, skipped?: string, error?: string}>}
1337
+ */
1338
+ async function maybeAutoAdvanceWorkflow({ targetDir, normalizedAgent, options = {}, logger, t }) {
1339
+ // DD-01 opt-out — explicit --no-auto-advance disables, regardless of state.
1340
+ if (options['no-auto-advance'] || options.noAutoAdvance) {
1341
+ return { advanced: false, skipped: 'opt-out' };
1342
+ }
1343
+
1344
+ const statePath = path.join(targetDir, '.aioson', 'context', 'workflow.state.json');
1345
+
1346
+ // 1. Read workflow.state.json (graceful absent OR corrupt — AC-F2-02 / AC-F2-09).
1347
+ let state;
1348
+ try {
1349
+ const raw = await fs.readFile(statePath, 'utf8');
1350
+ state = JSON.parse(raw);
1351
+ } catch (err) {
1352
+ if (err.code === 'ENOENT') {
1353
+ return { advanced: false, skipped: 'no_active_workflow' };
1354
+ }
1355
+ if (!options.json && logger?.error) {
1356
+ logger.error(`[agent:done] workflow.state.json unreadable (${err.code || err.message}); fallback to backward-compat (no auto-advance)`);
1357
+ }
1358
+ return { advanced: false, skipped: 'state_corrupt', error: err.message };
1359
+ }
1360
+
1361
+ // 2. Inactive workflow → skip.
1362
+ if (!state || (!state.featureSlug && state.mode !== 'project') || state.current === null) {
1363
+ return { advanced: false, skipped: 'inactive_workflow' };
1364
+ }
1365
+
1366
+ // 3. Idempotency guard (BR-01 — 1s window).
1367
+ const now = Date.now();
1368
+ const lastEventAt = Number(state.last_workflow_event_at) || 0;
1369
+ if (now - lastEventAt < 1000) {
1370
+ return { advanced: false, skipped: 'idempotency_window' };
1371
+ }
1372
+
1373
+ // 4. Lookup canonical artifact via handoff-contract (DPC-03 — reuse CONTRACTS map).
1374
+ let artifacts;
1375
+ try {
1376
+ const { getCanonicalArtifactsForAgent } = require('../handoff-contract');
1377
+ artifacts = await getCanonicalArtifactsForAgent(normalizedAgent, targetDir, {
1378
+ mode: state.mode || 'feature',
1379
+ featureSlug: state.featureSlug,
1380
+ classification: state.classification
1381
+ });
1382
+ } catch (err) {
1383
+ if (!options.json && logger?.error) {
1384
+ logger.error(`[agent:done] handoff-contract lookup failed (${err.message}); skip auto-advance`);
1385
+ }
1386
+ return { advanced: false, skipped: 'contract_error', error: err.message };
1387
+ }
1388
+
1389
+ // AC-F2-10 — agent not registered in CONTRACTS.
1390
+ if (artifacts === null) {
1391
+ if (!options.json && logger?.error) {
1392
+ logger.error(`[agent:done] agent '${normalizedAgent}' not in handoff-contract CONTRACTS map; skip auto-advance`);
1393
+ }
1394
+ return { advanced: false, skipped: 'unknown_agent' };
1395
+ }
1396
+
1397
+ // Empty array — agent legitimately produces no canonical artifact (e.g. @committer, @dev).
1398
+ // Don't auto-advance; the workflow advances on explicit user action when needed.
1399
+ if (artifacts.length === 0) {
1400
+ return { advanced: false, skipped: 'no_canonical_artifact' };
1401
+ }
1402
+
1403
+ // 5. At least one declared artifact must exist on disk before we trust auto-advance.
1404
+ let anyExists = false;
1405
+ for (const artifactPath of artifacts) {
1406
+ try {
1407
+ await fs.access(artifactPath);
1408
+ anyExists = true;
1409
+ break;
1410
+ } catch { /* not found — try next */ }
1411
+ }
1412
+ if (!anyExists) {
1413
+ return { advanced: false, skipped: 'artifact_missing' };
1414
+ }
1415
+
1416
+ // 6. Internal invocation of runWorkflowNext (lazy require — circular safety).
1417
+ let result;
1418
+ try {
1419
+ const { runWorkflowNext } = require('./workflow-next');
1420
+ result = await runWorkflowNext({
1421
+ args: [targetDir],
1422
+ options: { complete: normalizedAgent.replace(/^@/, ''), json: true },
1423
+ logger: { log: () => {}, error: () => {}, warn: () => {} },
1424
+ t
1425
+ });
1426
+ } catch (err) {
1427
+ if (!options.json && logger?.error) {
1428
+ logger.error(`[agent:done] workflow:next failed (${err.message}); pointer unchanged`);
1429
+ }
1430
+ return { advanced: false, skipped: 'workflow_next_failed', error: err.message };
1431
+ }
1432
+
1433
+ // 7. Persist last_workflow_event_at for idempotency (best-effort).
1434
+ try {
1435
+ const refreshedRaw = await fs.readFile(statePath, 'utf8').catch(() => null);
1436
+ const refreshed = refreshedRaw ? JSON.parse(refreshedRaw) : state;
1437
+ refreshed.last_workflow_event_at = now;
1438
+ await fs.writeFile(statePath, `${JSON.stringify(refreshed, null, 2)}\n`);
1439
+ } catch { /* non-fatal */ }
1440
+
1441
+ // 8. Surface concise outcome — single line, AFTER existing standard log (AC-F2-02 preserved).
1442
+ if (!options.json && logger?.log && result?.ok) {
1443
+ const nextStage = result.next || result.nextStage || null;
1444
+ const tag = nextStage ? `→ ${nextStage}` : '(workflow complete)';
1445
+ logger.log(`[agent:done] auto-advanced ${tag}`);
1446
+ }
1447
+
1448
+ return { advanced: true, result };
1449
+ }
1450
+
1451
+
1302
1452
  async function runRuntimeSessionStart({ args, options = {}, logger, t }) {
1303
1453
  const targetDir = resolveTargetDir(args);
1304
1454
  const { db, dbPath, runtimeDir } = await openRuntimeDb(targetDir);
@@ -2074,6 +2224,7 @@ module.exports = {
2074
2224
  runRuntimeLog,
2075
2225
  runAgentDone,
2076
2226
  runAgentRecover,
2227
+ maybeAutoAdvanceWorkflow,
2077
2228
  runRuntimeSessionStart,
2078
2229
  runRuntimeSessionLog,
2079
2230
  runRuntimeSessionFinish,
@@ -212,8 +212,69 @@ async function runStateSave({ args, options = {}, logger }) {
212
212
  return result;
213
213
  }
214
214
 
215
+ /**
216
+ * runStateReset — F1 (workflow-handoff-integrity v1.9.7)
217
+ *
218
+ * Clears `.aioson/context/dev-state.md`. Idempotent: no-op when file is absent.
219
+ *
220
+ * Behavior:
221
+ * - Default: deletes the file outright.
222
+ * - With `--archive`: moves to `.aioson/runtime/devstate-history/{ISO}.md` for audit trail.
223
+ *
224
+ * Usage:
225
+ * aioson state:reset .
226
+ * aioson state:reset . --archive
227
+ * aioson state:reset . --archive --json
228
+ */
229
+ async function runStateReset({ args, options = {}, logger }) {
230
+ const targetDir = path.resolve(process.cwd(), args[0] || '.');
231
+ const statePath = path.join(contextDir(targetDir), 'dev-state.md');
232
+ const archive = Boolean(options.archive);
233
+
234
+ let archivedTo = null;
235
+ let removed = false;
236
+
237
+ try {
238
+ await fs.access(statePath);
239
+ } catch {
240
+ // AC-F1-03 — idempotent: no-op when absent.
241
+ const result = { ok: true, removed: false, archived: null, reason: 'no_state_file' };
242
+ if (options.json) return result;
243
+ logger.log('state:reset — no dev-state.md present; nothing to do.');
244
+ return result;
245
+ }
246
+
247
+ if (archive) {
248
+ const historyDir = path.join(targetDir, '.aioson', 'runtime', 'devstate-history');
249
+ await fs.mkdir(historyDir, { recursive: true });
250
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
251
+ archivedTo = path.join(historyDir, `${stamp}.md`);
252
+ const content = await fs.readFile(statePath, 'utf8');
253
+ await fs.writeFile(archivedTo, content, 'utf8');
254
+ }
255
+
256
+ await fs.unlink(statePath);
257
+ removed = true;
258
+
259
+ const result = {
260
+ ok: true,
261
+ removed,
262
+ archived: archivedTo ? path.relative(targetDir, archivedTo) : null
263
+ };
264
+
265
+ if (options.json) return result;
266
+
267
+ if (archivedTo) {
268
+ logger.log(`state:reset — dev-state.md archived to ${result.archived} and removed.`);
269
+ } else {
270
+ logger.log('state:reset — dev-state.md removed (no archive).');
271
+ }
272
+ return result;
273
+ }
274
+
215
275
  module.exports = {
216
276
  runStateSave,
277
+ runStateReset,
217
278
  CONTEXT_TYPE_MAP,
218
279
  MAX_CONTEXT,
219
280
  parseContextFlag
@@ -22,6 +22,7 @@ const path = require('node:path');
22
22
 
23
23
  const { CHAIN_AGENTS, FEATURE_DOSSIER_HEADER, extractSection } = require('./dossier-audit');
24
24
  const dossierTelemetry = require('../lib/dossier-telemetry');
25
+ const { diffAgentFile } = require('../lib/agent-semantic-diff');
25
26
 
26
27
  // Active Learning Loop Phase 6 — workspace ↔ template parity checks for the
27
28
  // `.aioson/` artifacts this feature ships. Phase 1 dev's documented decision
@@ -105,6 +106,90 @@ function checkLearningLoopTemplateParity(projectRoot) {
105
106
  return issues;
106
107
  }
107
108
 
109
+ /**
110
+ * checkSemanticParity — T5 (workflow-handoff-integrity v1.9.8)
111
+ *
112
+ * Detects semantic drift between `.aioson/agents/{agent}.md` and
113
+ * `template/.aioson/agents/{agent}.md`: headers (presence + order), section
114
+ * content (hash), and frontmatter fields. Catches 981a8fd-style migration
115
+ * incompleteness that the original Feature-dossier-length check misses.
116
+ *
117
+ * Mode-aware severity (per PMD-04 / DD-03):
118
+ * - Default (local dev): returns issues with severity='warning' (caller is non-blocking).
119
+ * - `AIOSON_PREPUBLISH=true`: returns issues with severity='error' (caller blocks publish).
120
+ *
121
+ * Returns array of issue objects.
122
+ */
123
+ function checkSemanticParity(projectRoot) {
124
+ const issues = [];
125
+ const isPrepublish = process.env.AIOSON_PREPUBLISH === 'true';
126
+ const severity = isPrepublish ? 'error' : 'warning';
127
+
128
+ for (const agent of CHAIN_AGENTS) {
129
+ const workspacePath = path.join(projectRoot, '.aioson', 'agents', `${agent}.md`);
130
+ const templatePath = path.join(projectRoot, 'template', '.aioson', 'agents', `${agent}.md`);
131
+ const workspaceRaw = readFileOrEmpty(workspacePath);
132
+ const templateRaw = readFileOrEmpty(templatePath);
133
+
134
+ const diff = diffAgentFile(workspaceRaw, templateRaw);
135
+ if (!diff) continue;
136
+
137
+ if (diff.missingFile) {
138
+ // AC-T5-08 — file removed in one side, still present in the other.
139
+ issues.push({
140
+ agent,
141
+ kind: 'missing_file',
142
+ side: diff.missingFile,
143
+ severity,
144
+ hint: diff.missingFile === 'workspace'
145
+ ? `template/.aioson/agents/${agent}.md exists but workspace/.aioson/agents/${agent}.md does not — sync would create it (likely fine, unless workspace deletion was intentional)`
146
+ : `workspace/.aioson/agents/${agent}.md exists but template/.aioson/agents/${agent}.md does not — workspace edits are unpropagated; copy to template OR delete workspace file`
147
+ });
148
+ continue;
149
+ }
150
+
151
+ if (diff.missingInTemplate.length > 0) {
152
+ issues.push({
153
+ agent, kind: 'sections_missing_in_template', sections: diff.missingInTemplate, severity,
154
+ hint: `Workspace has sections not in template: ${diff.missingInTemplate.map((s) => `'${s}'`).join(', ')}. Likely unpropagated workspace edits (981a8fd pattern).`
155
+ });
156
+ }
157
+ if (diff.missingInWorkspace.length > 0) {
158
+ issues.push({
159
+ agent, kind: 'sections_missing_in_workspace', sections: diff.missingInWorkspace, severity,
160
+ hint: `Template has sections not in workspace: ${diff.missingInWorkspace.map((s) => `'${s}'`).join(', ')}. Workspace lost content OR template added new sections post-sync.`
161
+ });
162
+ }
163
+ if (diff.reordered) {
164
+ issues.push({
165
+ agent, kind: 'sections_reordered', severity,
166
+ hint: 'Section order differs between workspace and template — review for unintended structural drift.'
167
+ });
168
+ }
169
+ if (diff.divergedSections.length > 0) {
170
+ issues.push({
171
+ agent, kind: 'section_content_diverged', sections: diff.divergedSections.map((d) => d.header), severity,
172
+ hint: `Section content hash differs (not cosmetic): ${diff.divergedSections.map((d) => `'${d.header}'`).join(', ')}. Investigate before sync to avoid 981a8fd-style migration regression.`
173
+ });
174
+ }
175
+ if (diff.frontmatter) {
176
+ const fm = diff.frontmatter;
177
+ if (fm.missingInTemplate.length > 0) {
178
+ issues.push({ agent, kind: 'frontmatter_missing_in_template', fields: fm.missingInTemplate, severity });
179
+ }
180
+ if (fm.missingInWorkspace.length > 0) {
181
+ issues.push({ agent, kind: 'frontmatter_missing_in_workspace', fields: fm.missingInWorkspace, severity });
182
+ }
183
+ if (fm.valueChanged.length > 0) {
184
+ issues.push({ agent, kind: 'frontmatter_value_changed', changes: fm.valueChanged, severity });
185
+ }
186
+ }
187
+ }
188
+
189
+ return issues;
190
+ }
191
+
192
+
108
193
  function checkParity(projectRoot) {
109
194
  const violations = [];
110
195
  for (const agent of CHAIN_AGENTS) {
@@ -132,8 +217,11 @@ function checkParity(projectRoot) {
132
217
  async function main(projectRoot = process.cwd()) {
133
218
  const violations = checkParity(projectRoot);
134
219
  const learningLoopIssues = checkLearningLoopTemplateParity(projectRoot);
220
+ // T5 (workflow-handoff-integrity v1.9.8) — semantic parity check on top of length check.
221
+ const semanticIssues = checkSemanticParity(projectRoot);
222
+ const isPrepublish = process.env.AIOSON_PREPUBLISH === 'true';
135
223
 
136
- if (violations.length === 0 && learningLoopIssues.length === 0) {
224
+ if (violations.length === 0 && learningLoopIssues.length === 0 && semanticIssues.length === 0) {
137
225
  return 0;
138
226
  }
139
227
 
@@ -166,11 +254,37 @@ async function main(projectRoot = process.cwd()) {
166
254
  });
167
255
  }
168
256
 
169
- return 1;
257
+ // T5 semantic drift — warning-only locally, error in pre-publish mode.
258
+ if (semanticIssues.length > 0) {
259
+ const banner = isPrepublish
260
+ ? '[sync:agents BLOCKED] Semantic parity drift detected (pre-publish hard fail — AIOSON_PREPUBLISH=true):'
261
+ : '[sync:agents WARN] Semantic parity drift detected (warning only — non-blocking for local dev):';
262
+ process.stderr.write(`${banner}\n`);
263
+ for (const issue of semanticIssues) {
264
+ const detail = issue.sections ? ` [${issue.sections.join(', ')}]`
265
+ : issue.fields ? ` [${issue.fields.join(', ')}]`
266
+ : issue.changes ? ` [${issue.changes.map((c) => c.key).join(', ')}]`
267
+ : '';
268
+ process.stderr.write(` - @${issue.agent}: ${issue.kind}${detail}\n`);
269
+ if (issue.hint) process.stderr.write(` → ${issue.hint}\n`);
270
+ }
271
+ await dossierTelemetry.emitDossierEvent(projectRoot, {
272
+ agent: 'sync-agents-preflight',
273
+ type: 'semantic_parity_violation',
274
+ summary: `${semanticIssues.length} semantic drift issue(s) (mode: ${isPrepublish ? 'prepublish-fail' : 'local-warn'})`,
275
+ meta: { issues: semanticIssues, prepublish: isPrepublish }
276
+ });
277
+ }
278
+
279
+ // Block only when there are HARD failures: length violations + learning-loop issues
280
+ // always block (existing behavior). Semantic drift blocks ONLY in pre-publish mode.
281
+ const hardBlock = violations.length > 0 || learningLoopIssues.length > 0
282
+ || (isPrepublish && semanticIssues.length > 0);
283
+ return hardBlock ? 1 : 0;
170
284
  }
171
285
 
172
286
  if (require.main === module) {
173
287
  main().then((code) => process.exit(code));
174
288
  }
175
289
 
176
- module.exports = { checkParity, checkLearningLoopTemplateParity, main };
290
+ module.exports = { checkParity, checkLearningLoopTemplateParity, checkSemanticParity, main };
@@ -967,6 +967,57 @@ async function activateStage(targetDir, state, locale, tool, explicitAgent = nul
967
967
  };
968
968
  }
969
969
 
970
+ /**
971
+ * F3 (workflow-handoff-integrity v1.9.6) — pending-decisions guard.
972
+ *
973
+ * Reads `.aioson/plans/{slug}/manifest.md` frontmatter. If `status` matches
974
+ * `pending-<X>-decisions`, throws a hard error recommending the agent that
975
+ * resolves those decisions. `--force` overrides.
976
+ *
977
+ * Whitelist (DD-02): known agents are [architect, product, pm, qa]. Unknown
978
+ * captured groups still block but are flagged as unrecognized so typos don't
979
+ * silently route to nonexistent agents.
980
+ *
981
+ * Errors:
982
+ * - WORKFLOW_NEXT_PENDING_DECISIONS — pending state detected, advance blocked.
983
+ *
984
+ * @param {string} targetDir Project root.
985
+ * @param {string|null} slug Feature slug (null in project mode → no-op).
986
+ * @param {boolean} force When true, skip the check (--force override).
987
+ * @returns {Promise<void>} Resolves silently when no pending decisions block; throws otherwise.
988
+ */
989
+ const PENDING_STATE_WHITELIST = ['architect', 'product', 'pm', 'qa'];
990
+
991
+ async function assertManifestNotPending(targetDir, slug, force) {
992
+ if (force) return; // AC-F3-03 — explicit override.
993
+ if (!slug) return; // AC-F3-04 — no feature context, nothing to guard.
994
+ const manifestPath = path.join(targetDir, '.aioson', 'plans', slug, 'manifest.md');
995
+ let content;
996
+ try {
997
+ content = await fs.readFile(manifestPath, 'utf8');
998
+ } catch {
999
+ return; // AC-F3-04 — no manifest (e.g. MICRO without Sheldon stage), skip.
1000
+ }
1001
+ const status = parseFrontmatterValue(content, 'status');
1002
+ if (!status) return; // No status field → nothing to assert.
1003
+ const match = String(status).match(/^pending-(.+)-decisions$/);
1004
+ if (!match) return; // AC-F3-02 — only pending-*-decisions pattern blocks.
1005
+ const captured = match[1].toLowerCase();
1006
+ const known = PENDING_STATE_WHITELIST.includes(captured);
1007
+ const recommendation = known
1008
+ ? `Próximo agente recomendado: @${captured}.`
1009
+ : `Estado desconhecido '${captured}' — whitelist atual: ${PENDING_STATE_WHITELIST.map((a) => `@${a}`).join(', ')}.`;
1010
+ const err = new Error(
1011
+ `[workflow:next] Gate blocked: ${slug} manifest tem status 'pending-${captured}-decisions'. ${recommendation} Use --force para override.`
1012
+ );
1013
+ err.code = 'WORKFLOW_NEXT_PENDING_DECISIONS';
1014
+ err.slug = slug;
1015
+ err.pendingState = captured;
1016
+ err.knownState = known;
1017
+ throw err;
1018
+ }
1019
+
1020
+
970
1021
  async function runWorkflowNext({ args, options, logger, t }) {
971
1022
  if (options.status || options.suggest) {
972
1023
  const { runWorkflowStatus } = require('./workflow-status');
@@ -988,6 +1039,17 @@ async function runWorkflowNext({ args, options, logger, t }) {
988
1039
  let completedStage = null;
989
1040
 
990
1041
  if (options.complete || options['complete-current']) {
1042
+ // F3 (workflow-handoff-integrity v1.9.6) — pending-decisions guard.
1043
+ // Hard error if sheldon manifest has unresolved decisions; --force overrides.
1044
+ try {
1045
+ await assertManifestNotPending(targetDir, state.featureSlug, Boolean(options.force));
1046
+ } catch (err) {
1047
+ if (err && err.code === 'WORKFLOW_NEXT_PENDING_DECISIONS') {
1048
+ logErrorLine(err.message);
1049
+ }
1050
+ throw err;
1051
+ }
1052
+
991
1053
  let finalized;
992
1054
  try {
993
1055
  finalized = await finalizeCurrentStage(
@@ -1274,6 +1336,8 @@ module.exports = {
1274
1336
  applySkip,
1275
1337
  activateStage,
1276
1338
  runWorkflowNext,
1339
+ assertManifestNotPending,
1340
+ PENDING_STATE_WHITELIST,
1277
1341
  shouldRouteToValidator,
1278
1342
  detectUnsubstantiatedCompletions
1279
1343
  };
@@ -405,6 +405,30 @@ async function getBlockingRevisions(targetDir, featureSlug) {
405
405
  }
406
406
  }
407
407
 
408
+ /**
409
+ * getCanonicalArtifactsForAgent
410
+ *
411
+ * Public lookup helper used by `runAgentDone` (F2 — workflow-handoff-integrity v1.9.5)
412
+ * to determine which artifact paths an agent is expected to produce. Returns the
413
+ * paths declared by the agent's contract in CONTRACTS, fully resolved against the
414
+ * workflow state.
415
+ *
416
+ * @param {string} agent Agent name (with or without leading `@`).
417
+ * @param {string} targetDir Project root path (absolute).
418
+ * @param {object} state Workflow state: { mode, featureSlug, classification }.
419
+ * @returns {string[]|null} Array of absolute artifact paths, or `null` when the
420
+ * agent is not registered in CONTRACTS. An empty array
421
+ * means the agent produces no canonical artifact (e.g.
422
+ * `@committer`, `@dev`) — auto-emit should be skipped.
423
+ */
424
+ async function getCanonicalArtifactsForAgent(agent, targetDir, state) {
425
+ const normalizedAgent = String(agent || '').replace(/^@/, '').toLowerCase();
426
+ if (!normalizedAgent) return null;
427
+ const contract = CONTRACTS[normalizedAgent];
428
+ if (!contract) return null;
429
+ return await resolveArtifacts(contract, targetDir, state || {});
430
+ }
431
+
408
432
  module.exports = {
409
433
  parseFrontmatterValue,
410
434
  readProjectClassification,
@@ -413,5 +437,6 @@ module.exports = {
413
437
  validateHandoffContract,
414
438
  formatContractError,
415
439
  getBlockingRevisions,
440
+ getCanonicalArtifactsForAgent,
416
441
  CONTRACTS
417
442
  };