@worca/app 1.2.0 → 1.3.0-rc.2

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 (104) hide show
  1. package/README.md +42 -0
  2. package/agents/memoryDefragmenter.meta.json +24 -0
  3. package/agents/worca-cc-code-reviewer.md +6 -1
  4. package/agents/worca-cc-implementer.md +6 -1
  5. package/agents/worca-cc-memory-defragmenter.md +32 -0
  6. package/agents/worca-cc-planner.md +5 -1
  7. package/package.json +5 -2
  8. package/src/cli/render.mjs +36 -0
  9. package/src/cli/worca-cc.mjs +137 -8
  10. package/src/core/agent-registry.mjs +12 -34
  11. package/src/core/artifacts.mjs +132 -8
  12. package/src/core/ask/catalog.mjs +32 -7
  13. package/src/core/ask/comment-deps.mjs +5 -2
  14. package/src/core/ask/events.mjs +65 -2
  15. package/src/core/ask/limits.mjs +9 -0
  16. package/src/core/ask/mcp-stdio.mjs +10 -0
  17. package/src/core/ask/memory-deps.mjs +107 -0
  18. package/src/core/ask/metrics-deps.mjs +124 -0
  19. package/src/core/ask/metrics-proposal.mjs +175 -0
  20. package/src/core/ask/prompt.mjs +53 -10
  21. package/src/core/ask/proposal.mjs +49 -2
  22. package/src/core/ask/spawn.mjs +21 -4
  23. package/src/core/ask/store.mjs +14 -5
  24. package/src/core/ask/tool-deps.mjs +26 -2
  25. package/src/core/ask/tools.mjs +439 -6
  26. package/src/core/ask/turn.mjs +163 -4
  27. package/src/core/ask/workflow-deps.mjs +226 -0
  28. package/src/core/auto/classify.mjs +352 -0
  29. package/src/core/auto/fingerprint.mjs +141 -0
  30. package/src/core/auto/match.mjs +30 -0
  31. package/src/core/auto/model.mjs +23 -0
  32. package/src/core/auto/proposal.mjs +132 -0
  33. package/src/core/auto/recipes.mjs +75 -0
  34. package/src/core/auto/repo-look.mjs +46 -0
  35. package/src/core/claude-runner.mjs +132 -11
  36. package/src/core/config.mjs +120 -3
  37. package/src/core/db.mjs +44 -1
  38. package/src/core/diff-comments.mjs +55 -9
  39. package/src/core/frontmatter.mjs +75 -0
  40. package/src/core/git-info.mjs +233 -26
  41. package/src/core/graph/builtin-workflows.mjs +50 -0
  42. package/src/core/graph/executor.mjs +11 -3
  43. package/src/core/index-html.mjs +17 -0
  44. package/src/core/memory-store.mjs +441 -0
  45. package/src/core/memory-sync.mjs +300 -0
  46. package/src/core/metrics/ledger.mjs +47 -0
  47. package/src/core/metrics/lock.mjs +117 -0
  48. package/src/core/metrics/read.mjs +303 -0
  49. package/src/core/metrics/record.mjs +389 -0
  50. package/src/core/metrics/sync.mjs +1100 -0
  51. package/src/core/onboarding.mjs +99 -0
  52. package/src/core/orchestrator.mjs +394 -7
  53. package/src/core/phases.mjs +16 -3
  54. package/src/core/pipeline-delete.mjs +1 -1
  55. package/src/core/plugin-store.mjs +2 -10
  56. package/src/core/preflight.mjs +2 -3
  57. package/src/core/projects.mjs +16 -1
  58. package/src/core/run-harness.mjs +458 -32
  59. package/src/core/run-report.mjs +896 -0
  60. package/src/core/settings.mjs +162 -0
  61. package/src/core/sources.mjs +4 -1
  62. package/src/core/store.mjs +5 -0
  63. package/src/core/workflow-export.mjs +2 -0
  64. package/src/core/workflow-share.mjs +1 -0
  65. package/src/core/workflows.mjs +43 -23
  66. package/src/core/workspaces.mjs +37 -8
  67. package/src/shared/graph/agent-meta.mjs +5 -2
  68. package/src/shared/graph/assemble.mjs +455 -0
  69. package/src/shared/graph/flow-layout.mjs +249 -0
  70. package/src/shared/graph/geometry.mjs +48 -28
  71. package/src/shared/graph/isomorphic.mjs +101 -0
  72. package/src/shared/report-reasons.mjs +58 -0
  73. package/src/shared/team-metrics/aggregate.mjs +341 -0
  74. package/src/shared/team-metrics/workspace-match.mjs +13 -0
  75. package/ui/public/about-links.mjs +21 -0
  76. package/ui/public/app.js +3715 -479
  77. package/ui/public/artifact-view.mjs +135 -0
  78. package/ui/public/ask-model.mjs +18 -1
  79. package/ui/public/ask-panel.mjs +1359 -214
  80. package/ui/public/ask-run-card.mjs +209 -0
  81. package/ui/public/assets/worca-logo-mask.png +0 -0
  82. package/ui/public/assets/worca-mark-mask.png +0 -0
  83. package/ui/public/auto-build.mjs +95 -0
  84. package/ui/public/auto-proposal.mjs +174 -0
  85. package/ui/public/comment-thread.mjs +55 -0
  86. package/ui/public/getting-started.mjs +261 -0
  87. package/ui/public/graph/composer.mjs +41 -5
  88. package/ui/public/graph/inspector.mjs +3 -1
  89. package/ui/public/graph/model.mjs +1 -0
  90. package/ui/public/graph/run-hosts.mjs +73 -12
  91. package/ui/public/graph/view.mjs +218 -50
  92. package/ui/public/guide-spot.mjs +215 -0
  93. package/ui/public/index.html +423 -25
  94. package/ui/public/memory-view.mjs +192 -0
  95. package/ui/public/node-tunables.mjs +201 -0
  96. package/ui/public/report-run.mjs +75 -0
  97. package/ui/public/results-view.mjs +25 -0
  98. package/ui/public/source-pane.mjs +16 -2
  99. package/ui/public/stats-view.mjs +2 -2
  100. package/ui/public/style.css +1450 -303
  101. package/ui/public/team-metrics-surfaces.mjs +452 -0
  102. package/ui/public/team-metrics-view.mjs +533 -0
  103. package/ui/public/thinking-orb.mjs +46 -8
  104. package/ui/server.mjs +1282 -193
@@ -19,7 +19,7 @@ import { homedir } from 'node:os';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { join, basename, resolve, sep, relative } from 'node:path';
21
21
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
- import { readFile, writeFile, readdir, mkdir, realpath } from 'node:fs/promises';
22
+ import { readFile, writeFile, readdir, mkdir, realpath, rename } from 'node:fs/promises';
23
23
 
24
24
  import { generateTitle } from './title.mjs';
25
25
  import {
@@ -38,7 +38,10 @@ import { worcaHome } from './projects.mjs';
38
38
  import {
39
39
  runRootMode, getProjectsRoot,
40
40
  pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
41
+ memoryCaps,
41
42
  } from './settings.mjs';
43
+ import { mountDirs, mountMemory, syncBack, memoryTotals, validateMemoryScope, withStoreLock, memoryMountPath, MEMORY_RULES_REL, MEMORY_INJECTED_ENTRY } from './memory-sync.mjs';
44
+ import { memoryRoot, renderMemoryBlock, bumpScopeState } from './memory-store.mjs';
42
45
  import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
43
46
  import {
44
47
  writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
@@ -67,6 +70,7 @@ import {
67
70
  resolveFailure, isTerminal, markTerminal, answerFromDecision,
68
71
  REASON, pauseConsequences, describePauseReason,
69
72
  } from './failure-policy.mjs';
73
+ import { recordRunMetrics } from './metrics/record.mjs';
70
74
 
71
75
  // worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
72
76
  // latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
@@ -557,6 +561,11 @@ export function normalizeClarifyAnswer(payload, questions) {
557
561
  }));
558
562
  }
559
563
 
564
+ // Upper bound for one RunHarness._git call. Matches worktree.mjs's slow-git
565
+ // budget (SLOW_GIT_TIMEOUT_MS): `diff --cached` on a large agent change is the
566
+ // slowest command issued here, and it legitimately takes seconds, never minutes.
567
+ const HARNESS_GIT_TIMEOUT_MS = 120_000;
568
+
560
569
  export class RunHarness extends EventEmitter {
561
570
  constructor(opts) {
562
571
  super();
@@ -612,6 +621,10 @@ export class RunHarness extends EventEmitter {
612
621
  }
613
622
  this.agentsDir = this.opts.agentsDir || DEFAULT_AGENTS_DIR;
614
623
  this.auto = !!this.opts.auto;
624
+ // Auto workflow (spec D3/D15): the human-in-the-loop switch. `--yes` (auto
625
+ // mode) implies it is off. It changes nothing on a saved workflow — only an
626
+ // Auto run reads it (proposal question, clarifier stage, agent questions).
627
+ this.humanInLoop = !this.auto && this.opts.humanInLoop !== false;
615
628
  this.stepModels = null; // { planner:{model,effort}, refiner:{...}, ... } | null until run()
616
629
  // Guardrails: resolved by _resolveGuardrails() from run() AND resume(); null
617
630
  // until then, so dispatcher tests that bypass run() get claudeOpts without
@@ -662,17 +675,36 @@ export class RunHarness extends EventEmitter {
662
675
  this.pauseAbort = new AbortController(); // aborts ONLY node children on pause
663
676
  this.pauseReason = null; // WHY the run paused: 'cost_pipeline'|'cost_total'|'error'|<usage-limit line>|null
664
677
  this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
678
+ // Team metrics (§4.4 interventions). Resume runs on a NEW instance, so the counters are
679
+ // stamped into the persisted resume point at every pause and re-seeded in resume().
680
+ this._metricsIv = { questions: 0, pauses: 0, resumes: 0, lastPauseReason: null, lastPauseDetail: null };
681
+ this._metricsRecorded = false;
665
682
  this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
666
683
  this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
667
684
  this._modeRecorded = false; // resume(): the row recorded a run-root mode (a setup-incomplete point may not)
668
685
  this._pauseGate = null; // gate context snapshot when paused at a gate
669
686
  this._resumeNodeSessions = null; // nodeId -> sessionId map, set by resume() (Task 5)
670
687
  this.resumeOpts = this.opts.resume || null; // { row, resumePoint, steps } from readPipelineForResume
688
+ // Agent memory (agent-memory-design.md §7.3): the defragment run option. A resumed run reads
689
+ // it back from its resume point — the resume sites pass no run options (B10). The API and the
690
+ // CLI validated already; this throw is the programming-error backstop (never a 400).
691
+ this.memoryScope = this.opts.memoryScope || this.resumeOpts?.resumePoint?.memoryScope || null;
692
+ {
693
+ const wf = this.resumeOpts?.resumePoint?.workflowId || this.workflowId;
694
+ const reason = validateMemoryScope({ workflowId: wf, memoryScope: this.memoryScope, isWorkspace: this.isWorkspace });
695
+ if (reason) throw new Error(reason);
696
+ }
671
697
  this.pendingQuestion = null; // { id, resolve, reject, kind }
672
698
  this._recovery = null; // class -> in-flight Promise<'retry'|'pause'> (same-class dedupe)
673
699
  this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
674
700
  this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
675
701
  this.agentPrompts = null;
702
+ this.memory = null; // { root, mount, dirs, baseline } after _mountMemory
703
+ this.memoryBlock = ''; // the ## Worca memory pointer block, rendered once per mount (files load natively — no per-spawn re-render)
704
+ this.memoryChanges = []; // Change[] — the durable ledger's `changes`
705
+ this._memoryWarned = new Set();
706
+ this._memoryTail = null; // per-run sync chain: one syncBack at a time (F1)
707
+ this._ledgerSeq = 0; // monotonic: two ledger writes must never share a temp name
676
708
  this.toolInstruction = '';
677
709
  // Cap for the in-worktree graphify build (macOS has no timeout(1)).
678
710
  // Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
@@ -717,6 +749,7 @@ export class RunHarness extends EventEmitter {
717
749
  // detached run throws TypeError on the first this.state.branches[key] = … .
718
750
  branches: {},
719
751
  checkpointRefs: {},
752
+ memoryMount: null, // <runCwd>/.claude/rules/worca after _mountMemory (native rules: inside every spawn's cwd)
720
753
  pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
721
754
  pauseDetail: null, // mirrors this.pauseDetail
722
755
  // Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
@@ -743,6 +776,18 @@ export class RunHarness extends EventEmitter {
743
776
  this._log('orchestrator', 'warn', `answer() ignored: no pending question with id ${id}`);
744
777
  return false;
745
778
  }
779
+ if (pq.validate) {
780
+ // A question that carries a validator (the Auto proposal) stays OPEN on a
781
+ // malformed payload (spec §5.4); the awaiting code receives the CLEAN value.
782
+ const clean = pq.validate(payload);
783
+ if (clean == null) {
784
+ this._log('orchestrator', 'warn', `answer() ignored: malformed payload for ${id} — the question stays open`);
785
+ return false;
786
+ }
787
+ this.pendingQuestion = null;
788
+ pq.resolve(clean);
789
+ return true;
790
+ }
746
791
  this.pendingQuestion = null;
747
792
  pq.resolve(payload);
748
793
  return true;
@@ -891,7 +936,7 @@ export class RunHarness extends EventEmitter {
891
936
  // fan-out forcing + the v1 stepper manifest; v2 = resolveGraph +
892
937
  // buildGraphManifest. It yields the manifest the UI renders, the agent-key
893
938
  // set the preflight and skills gates walk, and the workflow's id/name.
894
- const topology = await this._resolveTopology(registry);
939
+ let topology = await this._resolveTopology(registry);
895
940
  if (!topology?.manifest || !topology.agentKeys || !topology.workflow?.id) throw new Error('engine hook contract: _resolveTopology must return { manifest, agentKeys, workflow:{id,name} }');
896
941
  // §9.4: hard-fail BEFORE the stepper is STAMPED / createPipeline / worktree
897
942
  // (the manifest is built inside the hook, which tolerates unknown keys) —
@@ -1040,6 +1085,15 @@ export class RunHarness extends EventEmitter {
1040
1085
  if (!this.resumeOpts) this._kickoffTitleGeneration();
1041
1086
  this._checkAbort();
1042
1087
 
1088
+ // 3b') Auto workflow (spec §5.3): the run row and the run root exist, so the
1089
+ // engine may now DECIDE the graph (classifier call, proposal question, reuse
1090
+ // or create) and re-stamp the manifest. Returns null for a saved workflow.
1091
+ // `topology` is consumed AFTER this point (collectRequiredSkills, the workflow
1092
+ // audit line), so the adopted graph is what they see.
1093
+ const decided = await this._decideTopology();
1094
+ if (decided) topology = decided;
1095
+ this._checkAbort();
1096
+
1043
1097
  // 3c) Build the knowledge graph INSIDE each worktree so agents can query it.
1044
1098
  if (this.isWorkspace) await this._buildWorktreeGraphAll();
1045
1099
  else await this._buildWorktreeGraph();
@@ -1087,6 +1141,12 @@ export class RunHarness extends EventEmitter {
1087
1141
  await this._assembleContext(resolvedSkills);
1088
1142
  }
1089
1143
  this._checkAbort();
1144
+ // 3f) Agent memory: mount the store into <runCwd>/.claude/rules/worca — the CLI loads it
1145
+ // natively — and render the pointer block every spawn carries. Pure fs work, both modes,
1146
+ // mock included. AFTER 3e: the assembly rewrites injectedPaths and the mount registers
1147
+ // itself into that map.
1148
+ await this._mountMemory();
1149
+ this._checkAbort();
1090
1150
  // D7: every setup step above is done — a pause from here on has nothing to
1091
1151
  // replay, so _completePaused strips any `setupIncomplete` stamp instead.
1092
1152
  this._setupDone = true;
@@ -1110,7 +1170,9 @@ export class RunHarness extends EventEmitter {
1110
1170
  await this._persist();
1111
1171
  await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
1112
1172
  await this._buildResults(); // refs + worktree still live here
1173
+ await this._stampDefrag(); // AFTER the final sync inside _buildResults counted the defragmenter's writes
1113
1174
  await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
1175
+ await this._recordRunMetrics('done');
1114
1176
  this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
1115
1177
  return { status: 'done', pipelineDir: this.pipeline.dir };
1116
1178
  } catch (err) {
@@ -1153,6 +1215,7 @@ export class RunHarness extends EventEmitter {
1153
1215
  await this._buildResults({ stage: true });
1154
1216
  await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
1155
1217
  }
1218
+ await this._recordRunMetrics('stopped');
1156
1219
  this._emit('done', {
1157
1220
  status: 'stopped',
1158
1221
  pipelineDir: this.pipeline?.dir || null,
@@ -1193,6 +1256,7 @@ export class RunHarness extends EventEmitter {
1193
1256
  await this._buildResults({ stage: true });
1194
1257
  await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
1195
1258
  }
1259
+ await this._recordRunMetrics('error', err);
1196
1260
  this._emit('done', {
1197
1261
  status: 'error',
1198
1262
  pipelineDir: this.pipeline?.dir || null,
@@ -1260,6 +1324,13 @@ export class RunHarness extends EventEmitter {
1260
1324
  recordArtifact(row.id, RUN_LOG_KIND, RUN_LOG_FILE);
1261
1325
  this.stepModels = rp.stepModels || null;
1262
1326
  this.workflowId = rp.workflowId || this.workflowId;
1327
+ // Pauses are counted only in _completePaused, so a crash-resume of an `interrupted`
1328
+ // run adds a resume but no pause (§4.4 decision 6).
1329
+ const iv = rp.interventions && typeof rp.interventions === 'object' ? rp.interventions : {};
1330
+ this._metricsIv = {
1331
+ questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: (iv.resumes | 0) + 1,
1332
+ lastPauseReason: iv.lastPauseReason ?? null, lastPauseDetail: iv.lastPauseDetail ?? null,
1333
+ };
1263
1334
  // The saved point carries the pause that produced it; a resumed run is running.
1264
1335
  this._clearPauseReason();
1265
1336
  // Rehydrate the run's selection BEFORE re-resolving so resume enforces the
@@ -1377,6 +1448,18 @@ export class RunHarness extends EventEmitter {
1377
1448
  await appendAudit(this.pipeline.dir, rehydrated.audit);
1378
1449
  this._emit('state', this.getState());
1379
1450
  this._rehydrated = true;
1451
+ // The pause that parked this run is over; it must not colour a later failure
1452
+ // or stop (§4.4 decision 2).
1453
+ this._metricsIv.lastPauseReason = null;
1454
+ this._metricsIv.lastPauseDetail = null;
1455
+
1456
+ // 3a') Auto workflow (spec §5.6): a run that paused BEFORE its graph was
1457
+ // decided re-enters the decision HERE — before the setup replay, so the
1458
+ // skills gate and the context assembly below see the ADOPTED agent keys, not
1459
+ // the bootstrap's empty set. null for a saved workflow and for an Auto run
1460
+ // that already adopted (rp.workflowId is then the real id).
1461
+ await this._decideTopology({ resume: rp });
1462
+ this._checkAbort();
1380
1463
 
1381
1464
  // ── setup replay (D7): a converted setup failure paused this run before its
1382
1465
  // checkout / graph / skills gate existed. Re-run exactly what run() never
@@ -1417,6 +1500,10 @@ export class RunHarness extends EventEmitter {
1417
1500
  }
1418
1501
  }
1419
1502
 
1503
+ // Agent memory on resume (§5): capture what the interrupted execution wrote,
1504
+ // then remount fresh from the store.
1505
+ await this._mountMemory({ resume: true });
1506
+
1420
1507
  const dispatched = await this._engineRun({ resume: rp, rehydrated });
1421
1508
  this._checkAbort();
1422
1509
  if (dispatched === 'paused') return await this._completePaused();
@@ -1427,7 +1514,9 @@ export class RunHarness extends EventEmitter {
1427
1514
  await this._persist();
1428
1515
  await appendAudit(this.pipeline.dir, `Pipeline finished with status **done**.`);
1429
1516
  await this._buildResults(); // refs + worktree still live here
1517
+ await this._stampDefrag(); // AFTER the final sync inside _buildResults counted the defragmenter's writes
1430
1518
  await this._reportToSource(); // task-source write-back (never throws, spec §7.5)
1519
+ await this._recordRunMetrics('done');
1431
1520
  this._emit('done', { status: 'done', pipelineDir: this.pipeline.dir });
1432
1521
  return { status: 'done', pipelineDir: this.pipeline.dir };
1433
1522
  } catch (err) {
@@ -1462,6 +1551,7 @@ export class RunHarness extends EventEmitter {
1462
1551
  await this._buildResults({ stage: true });
1463
1552
  await this._reportToSource(); // statusToResult('stopped') -> 'failed' (design PR12: no longer success-only)
1464
1553
  }
1554
+ await this._recordRunMetrics('stopped');
1465
1555
  this._emit('done', { status: 'stopped', pipelineDir: this.pipeline?.dir || null });
1466
1556
  return { status: 'stopped', pipelineDir: this.pipeline?.dir || null };
1467
1557
  }
@@ -1497,6 +1587,7 @@ export class RunHarness extends EventEmitter {
1497
1587
  await this._buildResults({ stage: true });
1498
1588
  await this._reportToSource(); // statusToResult('error') -> 'failed' (design PR12: no longer success-only)
1499
1589
  }
1590
+ await this._recordRunMetrics('error', err);
1500
1591
  this._emit('done', { status: 'error', pipelineDir: this.pipeline?.dir || null });
1501
1592
  return { status: 'error', pipelineDir: this.pipeline?.dir || null, error: message };
1502
1593
  } finally {
@@ -1754,6 +1845,244 @@ export class RunHarness extends EventEmitter {
1754
1845
  return rc;
1755
1846
  }
1756
1847
 
1848
+ /** Absolute `<pipeline.dir>/memory.json` — the durable memory ledger (amendment A2). */
1849
+ _memoryLedgerPath() { return join(this.pipeline.dir, 'memory.json'); }
1850
+
1851
+ /** Mount the memory store into this run — best-effort. Memory is additive (spec §4.3):
1852
+ * a store/mount fs failure degrades the run to "no memory" (no pointer block, no sync) and is
1853
+ * logged + audited; it never pauses the run at 'setup', where the replay would hit the
1854
+ * same error again. EXCEPTION (amendment B8): a DEFRAGMENT run (`this.memoryScope` set) IS
1855
+ * its mount — the error is rethrown and run()'s setup failure policy parks the run
1856
+ * paused/error with setupIncomplete, so a retryable mount failure can resume. */
1857
+ async _mountMemory({ resume = false } = {}) {
1858
+ if (!this.pipeline?.dir) return;
1859
+ try { await this._mountMemoryUnguarded({ resume }); }
1860
+ catch (err) {
1861
+ this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null;
1862
+ if (existsSync(this._memoryLedgerPath())) await this._writeMemoryLedger({ neutralised: true });
1863
+ const why = String(err?.message || err).split('\n')[0];
1864
+ // A defragment run IS its mount (B8): rethrow, and run()'s setup failure policy parks the run
1865
+ // as paused/error with setupIncomplete — a retryable mount failure resumes, a persistent one
1866
+ // stays visible. An ordinary run degrades to "no memory" as in P1.
1867
+ if (this.memoryScope) {
1868
+ await appendAudit(this.pipeline.dir, `Memory: not mounted (${why}) — a defragment run cannot continue.`).catch(() => {});
1869
+ throw new Error(`memory not mounted: ${why}`);
1870
+ }
1871
+ this._log('memory', 'warn', `memory not mounted: ${why} — this run's agents see no memory and their memory writes are not captured`);
1872
+ await appendAudit(this.pipeline.dir, `Memory: not mounted (${why}).`).catch(() => {});
1873
+ }
1874
+ }
1875
+
1876
+ /**
1877
+ * Mount the memory store into this run at `<runCwd>/.claude/rules/worca` — INSIDE the cwd of
1878
+ * every spawn, where Claude Code discovers rules natively (run root on a detached workspace
1879
+ * run, the primary worktree otherwise). Always recomputed, never the ledger's absolute path.
1880
+ * On resume, the previous segment's ledger is read first and its mount is synced back BEFORE
1881
+ * anything else (§5 "resume of a paused run"): the sync is pure fs and needs neither git nor
1882
+ * the tracked guard, so a guard that fails only NOW (git broken, the previous segment's agent
1883
+ * staged the mount) can never lose the interrupted segment's writes.
1884
+ */
1885
+ async _mountMemoryUnguarded({ resume }) {
1886
+ const root = memoryRoot();
1887
+ // Pre-setup there is no run cwd, and the LIVE checkout must never take a mount.
1888
+ const cwd = this.runCwd || null;
1889
+ if (!cwd || cwd === this.projectDir) throw new Error('no run cwd to mount into');
1890
+ const mount = memoryMountPath(cwd);
1891
+ // §8.8 scope of the record: 'runRoot' when the cwd IS the run root, else the member whose
1892
+ // checkout is the cwd (the primary member on single and legacy-workspace runs).
1893
+ const scope = (this.runRoot && cwd === this.runRoot) ? 'runRoot'
1894
+ : ([...this.workDirs.entries()].find(([, d]) => d === cwd)?.[0] ?? null);
1895
+ if (!scope) throw new Error(`run cwd ${cwd} is neither the run root nor a member checkout`);
1896
+ const dirs = mountDirs({ members: this.members, isWorkspace: this.isWorkspace, memoryScope: this.memoryScope });
1897
+ const onError = (p, err) => this._memoryReadWarn(p, err);
1898
+ if (resume) {
1899
+ let ledger = null;
1900
+ try { ledger = JSON.parse(await readFile(this._memoryLedgerPath(), 'utf8')); } catch { ledger = null; }
1901
+ if (ledger && ledger.baseline && Array.isArray(ledger.dirs)) {
1902
+ this.memoryChanges = Array.isArray(ledger.changes) ? ledger.changes : [];
1903
+ // A run paused BEFORE the native-rules revision still has its files at the ledger's
1904
+ // old path (<pipeline.dir>/memory): sync THAT dir once, so nothing the interrupted
1905
+ // execution wrote is lost; the recomputed path is used from here on.
1906
+ const prev = (typeof ledger.mount === 'string' && ledger.mount !== mount && existsSync(ledger.mount)) ? ledger.mount : mount;
1907
+ try {
1908
+ await this._syncMemoryWith({ mount: prev, dirs: ledger.dirs, baseline: ledger.baseline, nodeId: 'resume', executionId: null, agentKey: null, label: 'the interrupted execution' });
1909
+ } catch (err) {
1910
+ // Defensive — syncBack does not reject today (every fs error is per-file or routed
1911
+ // through onError). If it ever does: do NOT remount over unsynced writes; keep the
1912
+ // PREVIOUS mount + baseline so the next execution's sync retries them.
1913
+ this._log('memory', 'warn', `memory: the interrupted execution's writes could not be synced (${err?.message || err}); keeping the previous mount`);
1914
+ this.memory = { root, mount: prev, dirs: ledger.dirs, baseline: ledger.baseline };
1915
+ this.state.memoryMount = prev;
1916
+ // Register ONLY when the kept mount is the one inside this run's cwd: a mount there must
1917
+ // stay excluded from the commit and removed at teardown, while the pre-revision
1918
+ // <pipeline.dir>/memory path is outside every checkout and needs no §8.8 record.
1919
+ if (prev === mount) await this._registerMemoryMount(scope);
1920
+ this._refreshMemoryBlock();
1921
+ return;
1922
+ }
1923
+ }
1924
+ }
1925
+ // A checkout that TRACKS the mount path would have its committed files overwritten, excluded
1926
+ // from the commit and deleted at teardown — refuse, like the skill mount's trackedNames guard.
1927
+ // `:(icase)`: on a case-insensitive file system a repo tracking `.Claude/rules/worca` would
1928
+ // otherwise pass the guard and have those files rm'd through the case-folded path. The
1929
+ // detached workspace run root has no git and no check.
1930
+ if (scope !== 'runRoot') {
1931
+ const tracked = await this._git(['ls-files', '--', `:(icase)${MEMORY_RULES_REL}`], { cwd });
1932
+ if (!tracked.ok) throw new Error(`cannot tell whether the checkout tracks ${MEMORY_RULES_REL} (git ls-files: ${tracked.stderr.trim() || `exit ${tracked.code}`})`);
1933
+ // The way out differs: an ordinary run has nowhere else to go (its checkout IS the project's),
1934
+ // a defragment targets a scope and can be started from any other project's checkout.
1935
+ if (tracked.stdout.trim()) throw new Error(`the checkout tracks ${MEMORY_RULES_REL} — untrack it${this.memoryScope ? ' (or start the defragment from another project)' : ''}`);
1936
+ }
1937
+ // Register BEFORE anything touches the disk: a mount that fails half-way (EACCES, a Windows
1938
+ // EBUSY past the retries) leaves files under the cwd, and only the §8.8 entry keeps them out
1939
+ // of the commit and gets them removed at teardown. Idempotent, harmless on failure.
1940
+ await this._registerMemoryMount(scope);
1941
+ // gitIgnore: the mount now lives INSIDE a checkout an AGENT runs git in. `<mount>/.gitignore`
1942
+ // = `*` makes it invisible to an agent's own `git add -A`, to a staging pre-commit hook, to
1943
+ // snapshotWorktreePatch's bare `git add -A` and to the reviewer's `git status`. The §8.8
1944
+ // `:(exclude)` pathspec stays as defence in depth. Harmless at a non-git run root.
1945
+ const m = await mountMemory({ root, mount, dirs, onError, gitIgnore: true });
1946
+ this.memory = { root, mount, dirs, baseline: m.baseline };
1947
+ this.state.memoryMount = mount;
1948
+ this._refreshMemoryBlock();
1949
+ await this._writeMemoryLedger();
1950
+ this._log('memory', 'info', `Memory mounted at ${mount}: ${m.files} file(s) across ${dirs.length} scope(s)`);
1951
+ }
1952
+
1953
+ /**
1954
+ * §8.8: the mount rides `injectedPaths[<scope>]` as a `kind:'memory'` entry — excluded from the
1955
+ * commit, the intent-to-add staging and the three result diffs (_excludePathspecs), removed at
1956
+ * teardown (removeInjectedPaths), never rescued (sync-back is its rescue). Idempotent: a resume
1957
+ * re-assembly rewrites the map without it, so it is re-added here; persisted into run.json on
1958
+ * detached runs so the boot sweep and pipeline-delete see the same set. Under legacy the map was
1959
+ * always {} — the memory entry is the ONE legacy pathspec, and the legacy `git add -A` becomes
1960
+ * `git add -A -- . :(exclude).claude/rules/worca` (§10's byte-identical contract, amended: memory
1961
+ * has been mounted in both modes since P1, and an unexcluded mount would be committed).
1962
+ */
1963
+ async _registerMemoryMount(scope) {
1964
+ const map = { ...(this.injectedPaths || {}) };
1965
+ map[scope] = [...(map[scope] || []).filter((e) => e?.kind !== 'memory'), { ...MEMORY_INJECTED_ENTRY }];
1966
+ this.injectedPaths = map;
1967
+ if (this.runRoot) await updateRunManifest(this.runRoot, { injectedPaths: map }).catch(() => {});
1968
+ }
1969
+
1970
+ /** The `## Worca memory` pointer block: heading, one-line intro, one `Label — /abs/dir:` line per
1971
+ * mounted scope. Depends on dirs + mount only (never on file contents), so one render per mount. */
1972
+ _refreshMemoryBlock() {
1973
+ if (!this.memory) { this.memoryBlock = ''; return; }
1974
+ this.memoryBlock = renderMemoryBlock(this.memory.dirs.map((d) => ({ label: d.label, dir: join(this.memory.mount, d.rel) })));
1975
+ }
1976
+
1977
+ /** The `onError` every memory listing gets. A junk NAME is not an I/O failure — phrasing it
1978
+ * as "cannot read" sends the user hunting a broken disk instead of renaming a file. */
1979
+ _memoryReadWarn(p, err) {
1980
+ if (err?.code === 'ENAME') this._memoryWarn(`memory: ignored ${p} (invalid name — not a memory file)`);
1981
+ else this._memoryWarn(`memory: cannot read ${p}: ${err?.code || err?.message || err}`);
1982
+ }
1983
+
1984
+ /** Record-once warnings: the pointer block is rendered once per mount. */
1985
+ _memoryWarn(text) {
1986
+ if (this._memoryWarned.has(text)) return;
1987
+ this._memoryWarned.add(text);
1988
+ this._log('memory', 'warn', text);
1989
+ }
1990
+
1991
+ /** After ONE execution (orchestrator._afterExecution) — never rejects, and serialised per
1992
+ * run: the job reads `this.memory` when the PREVIOUS sync has published its baseline.
1993
+ * (Composite slices and parallel branches finish together; two syncs diffing against one
1994
+ * baseline would both write and both report the same files.) */
1995
+ _syncMemory(nc, ctx) {
1996
+ if (!this.memory) return Promise.resolve(null);
1997
+ const job = async () => {
1998
+ if (!this.memory) return null;
1999
+ try {
2000
+ return await this._syncMemoryWith({ ...this.memory, nodeId: ctx.nodeId, executionId: ctx.executionId, agentKey: nc?.key ?? null, label: nc?.key || ctx.label || ctx.nodeId });
2001
+ } catch (err) {
2002
+ this._log('memory', 'warn', `memory sync failed after ${ctx.executionId}: ${err?.message || err}`);
2003
+ return null;
2004
+ }
2005
+ };
2006
+ this._memoryTail = (this._memoryTail || Promise.resolve()).then(job, job);
2007
+ return this._memoryTail;
2008
+ }
2009
+
2010
+ async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label }) {
2011
+ const now = new Date().toISOString();
2012
+ const res = await syncBack({
2013
+ root: memoryRoot(), mount, dirs, baseline, source: `${this.memoryScope ? 'defrag' : 'run'}:${this.pipeline.id}`, now,
2014
+ caps: memoryCaps(), onWarn: (w) => this._log('memory', 'warn', w),
2015
+ onError: (p, err) => this._memoryReadWarn(p, err),
2016
+ });
2017
+ // `mount` equals this.memory.mount for every in-run sync and for a same-process resume;
2018
+ // an OLD instance can never race a resumed one because the scheduler drains in-flight
2019
+ // executions before the run reports 'paused' (scheduler.mjs, the pause drain).
2020
+ if (this.memory && this.memory.mount === mount) this.memory.baseline = res.baseline;
2021
+ if (res.total || res.rejected.length) {
2022
+ this.memoryChanges.push({
2023
+ executionId, nodeId, agentKey, at: now,
2024
+ added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected,
2025
+ });
2026
+ const head = `Memory: +${res.added.length} ~${res.modified.length} -${res.deleted.length}` +
2027
+ `${res.rejected.length ? ` (${res.rejected.length} rejected)` : ''} by ${label}`;
2028
+ const name = (r) => `${r.scope}/${r.name}.md`;
2029
+ const details = [
2030
+ ...res.added.map((r) => `added ${name(r)}`), ...res.modified.map((r) => `updated ${name(r)}`),
2031
+ ...res.deleted.map((r) => `deleted ${name(r)}`), ...res.rejected.map((r) => `rejected ${name(r)} — ${r.reason}`),
2032
+ ];
2033
+ this._log('memory', 'info', `${head}: ${details.join('; ')}`, { nodeId, executionId });
2034
+ await appendAudit(this.pipeline.dir, `${head}: ${details.join('; ')}`).catch(() => {});
2035
+ }
2036
+ if (this.memory && this.memory.mount === mount) await this._writeMemoryLedger();
2037
+ return res;
2038
+ }
2039
+
2040
+ /** `{ mount, dirs, baseline, changes }` — best-effort, atomic via temp + rename. */
2041
+ async _writeMemoryLedger({ neutralised = false } = {}) {
2042
+ if (!this.pipeline?.dir || (!this.memory && !neutralised)) return;
2043
+ const file = this._memoryLedgerPath();
2044
+ // `neutralised` (after a mount failure) keeps the change history but writes no dirs and
2045
+ // no baseline, so a later resume has nothing stale to diff against (a missing mount dir
2046
+ // must never read as "the run deleted every file").
2047
+ const payload = neutralised
2048
+ ? { mount: null, dirs: [], baseline: {}, changes: this.memoryChanges }
2049
+ : { mount: this.memory.mount, dirs: this.memory.dirs, baseline: this.memory.baseline, changes: this.memoryChanges };
2050
+ const tmp = `${file}.tmp-${process.pid}-${++this._ledgerSeq}`;
2051
+ try { await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); await rename(tmp, file); }
2052
+ catch (err) { this._log('memory', 'warn', `memory ledger not written: ${err?.message || err}`); }
2053
+ }
2054
+
2055
+ /** A finished defragment run resets the scope's counters (spec §5, §7): called on the `done`
2056
+ * arms only, after _buildResults' final sync. `this.memory.dirs[0]` is the one mounted scope.
2057
+ * Amendment B31: a run whose ledger holds ANY rejected write did not produce the scope the
2058
+ * agent intended — leave the counters alone (health stays `due`) and say so, loudly.
2059
+ * `withStoreLock` is a per-process promise chain: it serialises this stamp against this
2060
+ * process's own syncs only, not against another worca process. */
2061
+ async _stampDefrag() {
2062
+ if (!this.memoryScope || !this.memory?.dirs?.length) return;
2063
+ const d = this.memory.dirs[0];
2064
+ const { rejected } = memoryTotals(this.memoryChanges);
2065
+ if (rejected) {
2066
+ this._log('memory', 'warn', `Memory: ${d.label} — ${rejected} write(s) were rejected during this defragment; counters NOT reset (see the rejections above)`);
2067
+ await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragment finished with ${rejected} rejected write(s) — counters not reset.`).catch(() => {});
2068
+ return;
2069
+ }
2070
+ const now = new Date().toISOString();
2071
+ try {
2072
+ await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0 }));
2073
+ this._log('memory', 'info', `Memory: ${d.label} defragmented — write counter reset`);
2074
+ await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragmented by this run.`).catch(() => {});
2075
+ } catch (err) {
2076
+ this._log('memory', 'warn', `memory: defrag stamp failed: ${err?.message || err}`);
2077
+ }
2078
+ }
2079
+
2080
+ /** The run-summary shape (§6). null when nothing changed, so results.json is unchanged for such runs. */
2081
+ memorySummary() {
2082
+ if (!this.memoryChanges.length) return null;
2083
+ return { changes: this.memoryChanges, totals: memoryTotals(this.memoryChanges) };
2084
+ }
2085
+
1757
2086
  /**
1758
2087
  * §8.18 / gate V5: parse `claude --help` ONCE per run and assert `--mcp-config`.
1759
2088
  * On absence, degrade gracefully — skip the flag, warn loudly naming the required
@@ -1920,7 +2249,12 @@ export class RunHarness extends EventEmitter {
1920
2249
  // branch carries no changes (the staging in _stageWorkingTree is intent-to-add
1921
2250
  // for the reviewer's diff only — it never creates a commit). On error/stop this
1922
2251
  // is what captures the partial work made up to that point.
1923
- const commit = await this._commitWork(info);
2252
+ const key = this.members[0]?.projectKey ?? null;
2253
+ const injected = key ? (this.injectedPaths?.[key] ?? []) : [];
2254
+ const commit = await this._commitWork(info, this.state.branch, { excludePathspecs: this._excludePathspecs(key) });
2255
+ // The memory mount (the one legacy injected path) was synced at _buildResults; remove it now
2256
+ // so it rides neither the retained-work snapshot nor an outlived checkout.
2257
+ await removeInjectedPaths(info.worktreeDir, injected);
1924
2258
  const retained = await this._recordCommitFailure(commit, { info, branchRecord: this.state.branch });
1925
2259
  if (retained) {
1926
2260
  await this._snapshotRetained(info);
@@ -1970,7 +2304,8 @@ export class RunHarness extends EventEmitter {
1970
2304
  for (const [projectKey_, info] of entries) {
1971
2305
  if (!info || !info.worktreeDir) continue;
1972
2306
  const branchRecord = (this.state.branches && this.state.branches[projectKey_]) || null;
1973
- const commit = await this._commitWork(info, branchRecord);
2307
+ const commit = await this._commitWork(info, branchRecord, { excludePathspecs: this._excludePathspecs(projectKey_) });
2308
+ await removeInjectedPaths(info.worktreeDir, this.injectedPaths?.[projectKey_] ?? []);
1974
2309
  if (await this._recordCommitFailure(commit, { key: projectKey_, info, branchRecord })) {
1975
2310
  anyRetained = true;
1976
2311
  await this._snapshotRetained(info, projectKey_);
@@ -2018,7 +2353,8 @@ export class RunHarness extends EventEmitter {
2018
2353
  * Still skipped entirely when the run paused (§8.13) — the caller guards.
2019
2354
  *
2020
2355
  * Under `legacy` this delegates to today's _teardownWorktree / _teardownWorktreeAll
2021
- * verbatim and does nothing else. Under `detached`, per member, in NORMATIVE order:
2356
+ * verbatim, except that both now commit with the §8.8 exclusion set (the memory mount)
2357
+ * and remove the mount after the commit attempt. Under `detached`, per member, in NORMATIVE order:
2022
2358
  * 1. modified-mount rescue (§8.20) — read-only, so it survives any later failure
2023
2359
  * 2. strip every claudeMdSection fenced block (must precede the commit — that
2024
2360
  * file is deliberately NOT in the exclusion pathspecs)
@@ -2260,8 +2596,12 @@ export class RunHarness extends EventEmitter {
2260
2596
  * (defaults to the scalar this.state.branch; a workspace member passes its own
2261
2597
  * state.branches[projectKey] so per-member SHAs are recorded distinctly).
2262
2598
  * @param {{excludePathspecs?:string[]}} [opts] §8.8 exclusion set for this
2263
- * worktree. With the DEFAULT empty array — every legacy run — the method keeps
2264
- * today's bare `git add -A` byte-identically (§10 rollback contract).
2599
+ * worktree. Since the native-rules revision every run passes one (the memory mount),
2600
+ * so the argv is `git add -A -- . :(exclude).claude/rules/worca`, preceded by a
2601
+ * `git rm -r --cached --ignore-unmatch -- .claude/rules/worca` that unstages anything an
2602
+ * agent force-staged there (`git add -A` with an exclude never unstages); the DEFAULT empty
2603
+ * array (a refused mount) still reproduces the bare `git add -A`. The detached
2604
+ * `_teardownRunRoot` path commits through this same method, so it is covered too.
2265
2605
  * @returns {Promise<{ok:true,committed:boolean,sha:string|null}|
2266
2606
  * {ok:false,step:'status'|'add'|'commit',message:string,fromStderr:boolean}>}
2267
2607
  * `fromStderr` records whether `message` embeds real stderr bytes (vs. the
@@ -2289,6 +2629,15 @@ export class RunHarness extends EventEmitter {
2289
2629
  this._log('git', 'info', 'No changes to commit (working tree clean).');
2290
2630
  return { ok: true, committed: false, sha: null };
2291
2631
  }
2632
+ // `git add -A -- . :(exclude)X` does not UNSTAGE what is already in the index: an agent that
2633
+ // ran `git add -f .claude/rules/worca` (the one way past the mount's `.gitignore` sentinel)
2634
+ // would otherwise put memory files on the kept branch, and on a resume the tracked guard would
2635
+ // then refuse the mount for the rest of the run. Drop the exclusion set from the index first —
2636
+ // a no-op (exit 0) when nothing under it is staged, thanks to --ignore-unmatch.
2637
+ if (excludePathspecs.length) {
2638
+ await this._git(['rm', '-r', '--cached', '-q', '--ignore-unmatch', '--',
2639
+ ...excludePathspecs.map((s) => s.replace(/^:\(exclude\)/, ''))], gitOpts);
2640
+ }
2292
2641
  const add = excludePathspecs.length
2293
2642
  ? await this._git(['add', '-A', '--', '.', ...excludePathspecs], gitOpts)
2294
2643
  : await this._git(['add', '-A'], gitOpts);
@@ -2319,8 +2668,9 @@ export class RunHarness extends EventEmitter {
2319
2668
  gitOpts,
2320
2669
  );
2321
2670
  }
2322
- if (!commit.ok && excludePathspecs.length) {
2323
- // §8.8 (detached runs — the same scope as the exclusion set): a failing hook
2671
+ if (!commit.ok && this.runRootMode === 'detached') {
2672
+ // §8.8 (detached runs only — legacy keeps its verbatim commit even now that it carries an
2673
+ // exclusion set): a failing hook
2324
2674
  // must never silently delete an agent's work. Teardown removeWorktree(force:true)s
2325
2675
  // the checkout right after a successful commit, so this commit is the ONLY thing
2326
2676
  // that carries the work onto the kept branch. A diff artifact does now survive
@@ -2329,7 +2679,9 @@ export class RunHarness extends EventEmitter {
2329
2679
  // rebase or push. Detached worktrees make hook failure MORE likely (§8.1:
2330
2680
  // husky/lint-staged resolve through an ancestor node_modules today and do not
2331
2681
  // detached). Retry ONCE with hooks disabled for that invocation only, logging
2332
- // both facts.
2682
+ // both facts. The gate is the MODE, not the exclusion set: before the native-rules
2683
+ // revision a detached default-workflow run recorded no injected path at all and was
2684
+ // silently excluded from the retry — exactly the runs §8.1 is about.
2333
2685
  const hookErr = commit.stderr.trim() || `exit ${commit.code}`;
2334
2686
  this._log('git', 'warn', `commit failed with hooks enabled: ${hookErr}`, errStreamAttr(commit.stderr));
2335
2687
  const retry = await this._git(
@@ -2556,7 +2908,7 @@ export class RunHarness extends EventEmitter {
2556
2908
  * Freezes the active-time clock while blocked on the user (active-time-only).
2557
2909
  * @returns {Promise<any>} the answer payload
2558
2910
  */
2559
- async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo }) {
2911
+ async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow, validate }) {
2560
2912
  this._checkAbort();
2561
2913
  // No interactive prompt may OPEN on a pausing run. pause() rejects only the
2562
2914
  // prompt that is currently open; a queued ask (a parallel sibling's questions
@@ -2587,7 +2939,9 @@ export class RunHarness extends EventEmitter {
2587
2939
  // trail — reads these fields instead of parsing the id (MAJ-11).
2588
2940
  ...(deliveryNo != null ? { deliveryNo } : {}),
2589
2941
  ...(holdNo != null ? { holdNo } : {}),
2942
+ ...(workflow !== undefined ? { workflow } : {}),
2590
2943
  });
2944
+ this._metricsIv.questions += 1;
2591
2945
 
2592
2946
  try {
2593
2947
  if (this.auto) {
@@ -2597,6 +2951,11 @@ export class RunHarness extends EventEmitter {
2597
2951
  // pauses the run (errors never end one), so 'pause' is the answer.
2598
2952
  return { decision: 'pause' };
2599
2953
  }
2954
+ if (kind === 'workflow') {
2955
+ // Auto workflow under --yes: the proposal is accepted as proposed (spec D3).
2956
+ this._log('orchestrator', 'info', `auto-accepting workflow proposal ${id}`);
2957
+ return { decision: 'accept' };
2958
+ }
2600
2959
  if (kind === 'clarify' || kind === 'questions') {
2601
2960
  this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
2602
2961
  return {
@@ -2610,7 +2969,7 @@ export class RunHarness extends EventEmitter {
2610
2969
  return { decision: 'continue' };
2611
2970
  }
2612
2971
  return await new Promise((resolveP, rejectP) => {
2613
- this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP };
2972
+ this.pendingQuestion = { id, kind, resolve: resolveP, reject: rejectP, validate: typeof validate === 'function' ? validate : null };
2614
2973
  });
2615
2974
  } finally {
2616
2975
  // Resume only the rows that are STILL running AND only while the run has not
@@ -2718,6 +3077,10 @@ export class RunHarness extends EventEmitter {
2718
3077
  // bound signal kills the staging before git can touch the index.
2719
3078
  // INSIDE the try: the stopped path calls _buildResults from run()'s catch, so
2720
3079
  // anything that escaped here would reject run() itself.
3080
+ // The final sync runs BEFORE the two early returns below (`!members.length`,
3081
+ // `noPatch && stage && !listed`), so results.json.memory is absent on those paths;
3082
+ // the ledger (memory.json) is the durable carrier and History reads it.
3083
+ if (this.memory) await this._syncMemory(null, { nodeId: 'final', executionId: null, label: 'the run end' }).catch(() => {});
2721
3084
  if (stage) await this._stageWorkingTree({ ignoreAbort: true });
2722
3085
  const reviews = readPipelineExtras(this.pipeline.id).reviews || [];
2723
3086
  // Unified iteration over workDirs + checkpointRefs — the ref map is filled in
@@ -2764,12 +3127,14 @@ export class RunHarness extends EventEmitter {
2764
3127
  // no-op run must not lose them (review of PR #376). The 0-byte
2765
3128
  // diff-patch.patch is still never written on any path.
2766
3129
  if (noPatch && stage && !listed) return;
3130
+ const memory = this.memorySummary();
2767
3131
  if (members.length === 1 && !this.isWorkspace) {
3132
+ if (memory) members[0].results.memory = memory;
2768
3133
  await persistResults(this.pipeline.dir, members[0].results);
2769
3134
  if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches[0].patch);
2770
3135
  } else {
2771
3136
  const perProject = buildPerProject(members);
2772
- const results = { summary: rollupSummary(perProject), perProject };
3137
+ const results = { summary: rollupSummary(perProject), perProject, ...(memory ? { memory } : {}) };
2773
3138
  await persistResults(this.pipeline.dir, results);
2774
3139
  if (!noPatch) await persistDiffPatch(this.pipeline.dir, patches.map((p) => `# ${p.key}\n${p.patch}`).join('\n\n'));
2775
3140
  }
@@ -2809,6 +3174,19 @@ export class RunHarness extends EventEmitter {
2809
3174
  }
2810
3175
  }
2811
3176
 
3177
+ /** Team metrics (team-metrics-design.md §4.5): one record per terminal run. Idempotent per
3178
+ * instance and fail-soft — a metrics failure is a log line, never a run failure. */
3179
+ async _recordRunMetrics(status, error = null) {
3180
+ if (this._metricsRecorded) return null;
3181
+ this._metricsRecorded = true;
3182
+ try {
3183
+ return await recordRunMetrics(this, { status, error });
3184
+ } catch (err) {
3185
+ try { this._log('metrics', 'warn', `team metrics: ${err?.message || err}`); } catch { /* never */ }
3186
+ return null;
3187
+ }
3188
+ }
3189
+
2812
3190
  /** Single-project checkpoint: own repo + commit, record the scalar ref + state. */
2813
3191
  async _ensureGitCheckpoint() {
2814
3192
  this.checkpointRef = await this._ensureGitCheckpointFor(this.projectDir);
@@ -2877,8 +3255,8 @@ export class RunHarness extends EventEmitter {
2877
3255
  // setup never ran, in which case staging must be a NO-OP — the old single arm
2878
3256
  // fell back to this.workDir, which pre-setup is the user's LIVE checkout.
2879
3257
  for (const [key, dir] of this.workDirs.entries()) {
2880
- // §8.8: an empty exclusion set (every legacy run) reproduces today's argv
2881
- // byte-identically — `--` with no trailing pathspec is a no-op for git add.
3258
+ // §8.8: the exclusion set is the memory mount in both modes (and the skill mount under
3259
+ // detached); an empty set (a refused mount) reproduces the bare argv.
2882
3260
  const ex = this._excludePathspecs(key);
2883
3261
  const args = ex.length ? ['add', '-A', '-N', '--', '.', ...ex] : ['add', '-A', '-N'];
2884
3262
  const res = await this._git(args, { cwd: dir, ignoreAbort });
@@ -2894,8 +3272,9 @@ export class RunHarness extends EventEmitter {
2894
3272
  * `kind:'claudeMdSection'` entries are deliberately EXCLUDED from the set — their
2895
3273
  * file is the user's tracked CLAUDE.md, and a blanket `:(exclude)CLAUDE.md` would
2896
3274
  * silently strip the agent's legitimate edits (teardown strips the fence instead).
2897
- * Returns [] under legacy and through Phase 2 (this.injectedPaths is always {}),
2898
- * which is what makes every legacy argv byte-identical.
3275
+ * Under legacy the set holds exactly the memory mount (`_registerMemoryMount`), so
3276
+ * `git add -A -- . :(exclude).claude/rules/worca` is the legacy argv since the
3277
+ * native-rules revision.
2899
3278
  * @param {string} projectKey
2900
3279
  * @returns {string[]}
2901
3280
  */
@@ -2909,9 +3288,17 @@ export class RunHarness extends EventEmitter {
2909
3288
 
2910
3289
  /**
2911
3290
  * Run a git command in the project dir. Never throws; returns
2912
- * { ok, code, stdout, stderr }. Honors the abort signal.
3291
+ * { ok, code, stdout, stderr }. Honors the abort signal. Bounded by
3292
+ * `timeoutMs` (default HARNESS_GIT_TIMEOUT_MS): the commands issued here are
3293
+ * local (init/add/status/rev-parse/commit/diff --cached), and a git that does
3294
+ * not come back in that time is stuck, not working — it is SIGKILLed and
3295
+ * reported as `{ ok: false, stderr: 'git timed out' }`, which every caller
3296
+ * already handles as a failed git step. Without this bound a wedged git on
3297
+ * the stop/teardown path (which deliberately ignores the abort signal) held
3298
+ * the whole process, and under `npm test` the runner, until the CI job's
3299
+ * 30-minute limit killed it.
2913
3300
  */
2914
- _git(args, { cwd, ignoreAbort = false } = {}) {
3301
+ _git(args, { cwd, ignoreAbort = false, timeoutMs = HARNESS_GIT_TIMEOUT_MS } = {}) {
2915
3302
  return new Promise((resolveP) => {
2916
3303
  let child;
2917
3304
  try {
@@ -2929,12 +3316,26 @@ export class RunHarness extends EventEmitter {
2929
3316
  }
2930
3317
  let stdout = '';
2931
3318
  let stderr = '';
3319
+ let settled = false;
3320
+ const done = (val) => {
3321
+ if (settled) return;
3322
+ settled = true;
3323
+ clearTimeout(timer);
3324
+ resolveP(val);
3325
+ };
3326
+ const timer = timeoutMs > 0
3327
+ ? setTimeout(() => {
3328
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
3329
+ // A grandchild (hook, alias, pager) that inherited the pipes would keep
3330
+ // them — and this process's event loop — open after git itself is dead.
3331
+ try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* best effort */ }
3332
+ done({ ok: false, code: -1, stdout, stderr: stderr ? `git timed out: ${stderr}` : 'git timed out' });
3333
+ }, timeoutMs)
3334
+ : null;
2932
3335
  child.stdout?.on('data', (d) => (stdout += d.toString()));
2933
3336
  child.stderr?.on('data', (d) => (stderr += d.toString()));
2934
- child.on('error', (err) =>
2935
- resolveP({ ok: false, code: -1, stdout, stderr: stderr || err.message }),
2936
- );
2937
- child.on('close', (code) => resolveP({ ok: code === 0, code: code ?? -1, stdout, stderr }));
3337
+ child.on('error', (err) => done({ ok: false, code: -1, stdout, stderr: stderr || err.message }));
3338
+ child.on('close', (code) => done({ ok: code === 0, code: code ?? -1, stdout, stderr }));
2938
3339
  });
2939
3340
  }
2940
3341
 
@@ -3096,7 +3497,7 @@ export class RunHarness extends EventEmitter {
3096
3497
  /**
3097
3498
  * @param {string} kind
3098
3499
  * @param {string} path
3099
- * @param {{nodeId?:string, executionId?:string, port?:string|null}|null} [attr]
3500
+ * @param {{nodeId?:string, executionId?:string, port?:string|null, cycle?:number|null}|null} [attr]
3100
3501
  * v2 attribution (§5.7). Omitted keys are omitted from the event, so every
3101
3502
  * 2-arg v1 call emits the byte-identical `{kind, path}` payload it always did.
3102
3503
  */
@@ -3106,15 +3507,19 @@ export class RunHarness extends EventEmitter {
3106
3507
  if (attr.nodeId != null) evt.nodeId = attr.nodeId;
3107
3508
  if (attr.executionId != null) evt.executionId = attr.executionId;
3108
3509
  if (attr.port != null) evt.port = attr.port;
3510
+ if (attr.cycle != null) evt.cycle = attr.cycle;
3109
3511
  }
3110
3512
  this._emit('artifact', evt);
3111
- // Phase 3.9: ALSO index FS markdown/extra paths so pipeline-delete (Task 3.13)
3112
- // can unlink the EXACT files later (best-effort; never blocks a run). Skip the
3113
- // synthetic 'pipeline'/'clarify' kinds (clarify lives in the clarify table;
3114
- // 'pipeline' is the dir itself). plan/review markdown live under
3115
- // <store>/<key>/{plans,reviews} (store-root-relative); checklist/webui live in
3116
- // the pipeline dir (dir-relative).
3117
- if (!this.pipeline || !path || kind === 'pipeline' || kind === 'clarify' || kind === 'questions') return;
3513
+ // ALSO index FS markdown/extra paths so pipeline-delete can unlink the EXACT
3514
+ // files later, per-step attribution rides along (best-effort; never blocks a
3515
+ // run). Every kind with a durable on-disk relPath is recorded. Skipped:
3516
+ // 'pipeline' (the run DIR itself, no single file) and 'questions' (a scratch
3517
+ // file the orchestrator deletes once the round is answered — the Q&A lives in
3518
+ // the step_questions table, so an index row would only ever 404). The WS
3519
+ // event above still carries 'questions' for the live view. plan/review
3520
+ // markdown live under <store>/<key>/{plans,reviews} (store-root-relative);
3521
+ // prompt/checklist/webui live in the pipeline dir (dir-relative).
3522
+ if (!this.pipeline || !path || kind === 'pipeline' || kind === 'questions') return;
3118
3523
  let relPath = null;
3119
3524
  const pdir = this.pipeline.dir;
3120
3525
  if (path.startsWith(pdir + sep)) {
@@ -3128,7 +3533,13 @@ export class RunHarness extends EventEmitter {
3128
3533
  // Indexed with '/' on every OS: the row is a store-layout key, not a native
3129
3534
  // path (pipeline-delete re-roots 'plans/…' / 'reviews/…' under the store),
3130
3535
  // so a Windows-native 'reviews\\x.md' would silently miss that re-rooting.
3131
- if (relPath) recordArtifact(this.pipeline.id, kind, relPath.split(sep).join('/'));
3536
+ if (relPath) {
3537
+ recordArtifact(this.pipeline.id, kind, relPath.split(sep).join('/'), {
3538
+ stepKey: attr?.executionId ?? null,
3539
+ nodeId: attr?.nodeId ?? null,
3540
+ cycle: attr?.cycle ?? null,
3541
+ });
3542
+ }
3132
3543
  }
3133
3544
 
3134
3545
  /** Translate a low-level claude/mock event into a pipeline 'log' event. */
@@ -3680,6 +4091,8 @@ export class RunHarness extends EventEmitter {
3680
4091
  }
3681
4092
 
3682
4093
  async _persist() {
4094
+ const rpNow = this.state.resumePoint;
4095
+ if (rpNow && typeof rpNow === 'object' && this._metricsIv) rpNow.interventions = { ...this._metricsIv };
3683
4096
  if (!this.pipeline) return;
3684
4097
  try {
3685
4098
  await writeState(this.pipeline.dir, this.state);
@@ -3718,6 +4131,11 @@ export class RunHarness extends EventEmitter {
3718
4131
  // 'pausing') must replay that setup on resume; a completed setup never leaves a
3719
4132
  // stale stamp behind (resume() re-arms the consumed point, which may carry one).
3720
4133
  const rp = this.state.resumePoint;
4134
+ // ABOVE the `if (rp …)` — a pause counts whether or not the engine produced a resume point.
4135
+ this._metricsIv.pauses += 1;
4136
+ this._metricsIv.lastPauseReason = this.pauseReason || null;
4137
+ // _setPauseReason (run-harness.mjs:820) always stores a string or null.
4138
+ this._metricsIv.lastPauseDetail = this.pauseDetail == null ? null : String(this.pauseDetail).slice(0, 400);
3721
4139
  if (rp && typeof rp === 'object') {
3722
4140
  if (this._setupDone) { delete rp.setupIncomplete; delete rp.titleProvisional; }
3723
4141
  else {
@@ -3727,6 +4145,7 @@ export class RunHarness extends EventEmitter {
3727
4145
  // not a row column.
3728
4146
  rp.titleProvisional = this.state.titleProvisional === true;
3729
4147
  }
4148
+ rp.interventions = { ...this._metricsIv };
3730
4149
  }
3731
4150
  this._setStatus('paused');
3732
4151
  await this._persist();
@@ -3878,6 +4297,13 @@ export class RunHarness extends EventEmitter {
3878
4297
  * workflow -> the run's audit line. */
3879
4298
  async _resolveTopology(_registry) { throw new Error('engine hook not implemented: _resolveTopology'); }
3880
4299
 
4300
+ /** Decide the run's topology AFTER the run row + run root exist (the Auto
4301
+ * workflow). run() calls it with no argument and REPLACES the bootstrap
4302
+ * manifest with a non-null return (the same bag as _resolveTopology); resume()
4303
+ * calls it with `{ resume: rp }` before the setup replay. null keeps what
4304
+ * _resolveTopology produced (every saved workflow). */
4305
+ async _decideTopology(_o = {}) { return null; }
4306
+
3881
4307
  /** Run the pipeline to completion or to a pause.
3882
4308
  * @param {{resume?:object|null, rehydrated?:object|null}} _args resume point + _engineRehydrate's bag
3883
4309
  * @returns {Promise<'done'|'paused'>} */