@worca/app 1.3.0 → 1.4.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 (187) hide show
  1. package/README.md +85 -6
  2. package/agents/clarify.meta.json +1 -0
  3. package/agents/memoryDefragmenter.meta.json +2 -1
  4. package/agents/reviewer.meta.json +60 -0
  5. package/agents/worca-cc-code-reviewer.md +33 -0
  6. package/agents/worca-cc-memory-defragmenter.md +5 -3
  7. package/agents/workspaceScanner.meta.json +1 -0
  8. package/package.json +14 -10
  9. package/scripts/git-diff.mjs +25 -0
  10. package/scripts/gitDiff.meta.json +18 -0
  11. package/scripts/js-inline.mjs +11 -0
  12. package/scripts/js.meta.json +22 -0
  13. package/scripts/py-inline.py +27 -0
  14. package/scripts/py.meta.json +22 -0
  15. package/scripts/shell.meta.json +24 -0
  16. package/skills/worca/SKILL.md +3 -2
  17. package/src/cli/models.mjs +247 -0
  18. package/src/cli/render.mjs +72 -4
  19. package/src/cli/schedule.mjs +494 -0
  20. package/src/cli/worca-cc.mjs +1001 -22
  21. package/src/core/agent-registry.mjs +75 -23
  22. package/src/core/agent-store.mjs +51 -2
  23. package/src/core/artifacts.mjs +73 -10
  24. package/src/core/ask/events.mjs +119 -1
  25. package/src/core/ask/limits.mjs +32 -4
  26. package/src/core/ask/mcp-stdio.mjs +12 -0
  27. package/src/core/ask/model-deps.mjs +126 -0
  28. package/src/core/ask/model-proposal.mjs +370 -0
  29. package/src/core/ask/models.mjs +12 -0
  30. package/src/core/ask/policy-deps.mjs +124 -0
  31. package/src/core/ask/policy-proposal.mjs +363 -0
  32. package/src/core/ask/prompt.mjs +74 -9
  33. package/src/core/ask/proposal.mjs +54 -5
  34. package/src/core/ask/schedule-deps.mjs +83 -0
  35. package/src/core/ask/schedule-spec.mjs +310 -0
  36. package/src/core/ask/script-deps.mjs +357 -0
  37. package/src/core/ask/source-deps.mjs +52 -0
  38. package/src/core/ask/source-spec.mjs +157 -0
  39. package/src/core/ask/spawn.mjs +1 -0
  40. package/src/core/ask/store.mjs +6 -3
  41. package/src/core/ask/tool-deps.mjs +4 -0
  42. package/src/core/ask/tools.mjs +657 -37
  43. package/src/core/ask/turn.mjs +109 -2
  44. package/src/core/ask-files.mjs +406 -0
  45. package/src/core/ask-forms.mjs +195 -0
  46. package/src/core/ask-projection.mjs +72 -0
  47. package/src/core/bridge/errors.mjs +84 -0
  48. package/src/core/bridge/provider-ops.mjs +281 -0
  49. package/src/core/bridge/providers/copilot.mjs +269 -0
  50. package/src/core/bridge/providers/endpoint.mjs +257 -0
  51. package/src/core/bridge/registry.mjs +88 -0
  52. package/src/core/bridge/semaphore.mjs +73 -0
  53. package/src/core/bridge/server.mjs +184 -0
  54. package/src/core/bridge/telemetry.mjs +53 -0
  55. package/src/core/bridge/translate/request.mjs +252 -0
  56. package/src/core/bridge/translate/response.mjs +82 -0
  57. package/src/core/bridge/translate/stream.mjs +242 -0
  58. package/src/core/bridge/upstream.mjs +209 -0
  59. package/src/core/chat/command-router.mjs +58 -4
  60. package/src/core/chat/notifier.mjs +14 -1
  61. package/src/core/chat/renderers.mjs +35 -0
  62. package/src/core/claude-runner.mjs +126 -19
  63. package/src/core/config.mjs +212 -31
  64. package/src/core/cost-budget.mjs +3 -2
  65. package/src/core/db.mjs +169 -15
  66. package/src/core/failure-policy.mjs +10 -0
  67. package/src/core/fs-browse.mjs +16 -4
  68. package/src/core/git-info.mjs +22 -0
  69. package/src/core/graph/builtin-workflows.mjs +3 -1
  70. package/src/core/graph/exec-io.mjs +71 -0
  71. package/src/core/graph/executor.mjs +139 -70
  72. package/src/core/graph/human-evidence.mjs +131 -0
  73. package/src/core/graph/python-probe.mjs +172 -0
  74. package/src/core/graph/registry-ports.mjs +10 -6
  75. package/src/core/graph/scheduler.mjs +39 -24
  76. package/src/core/graph/script-child.mjs +81 -0
  77. package/src/core/graph/script-runner.mjs +597 -0
  78. package/src/core/graph/worca_script.py +207 -0
  79. package/src/core/guardrail-store.mjs +16 -0
  80. package/src/core/human-backfill.mjs +108 -0
  81. package/src/core/human-rate.mjs +17 -0
  82. package/src/core/index-html.mjs +6 -2
  83. package/src/core/memory-defrag-model.mjs +112 -0
  84. package/src/core/memory-store.mjs +70 -18
  85. package/src/core/memory-sync.mjs +22 -11
  86. package/src/core/metrics/read.mjs +4 -1
  87. package/src/core/metrics/record.mjs +50 -2
  88. package/src/core/metrics/sync.mjs +6 -4
  89. package/src/core/model-env.mjs +149 -0
  90. package/src/core/model-test.mjs +14 -1
  91. package/src/core/notifications.mjs +128 -0
  92. package/src/core/onboarding.mjs +8 -2
  93. package/src/core/orchestrator.mjs +357 -26
  94. package/src/core/phases.mjs +95 -6
  95. package/src/core/plugin-api.mjs +24 -7
  96. package/src/core/plugin-manifest.mjs +184 -18
  97. package/src/core/plugin-models.mjs +1 -0
  98. package/src/core/plugin-script-cases.mjs +118 -0
  99. package/src/core/plugin-store.mjs +163 -17
  100. package/src/core/plugin-workflows.mjs +71 -17
  101. package/src/core/policy/cache.mjs +116 -0
  102. package/src/core/policy/effective.mjs +175 -0
  103. package/src/core/policy/gate.mjs +91 -0
  104. package/src/core/policy/local.mjs +145 -0
  105. package/src/core/policy/registry.mjs +330 -0
  106. package/src/core/policy/scope.mjs +61 -0
  107. package/src/core/policy/state.mjs +79 -0
  108. package/src/core/policy/sync.mjs +513 -0
  109. package/src/core/protocol.mjs +43 -0
  110. package/src/core/run-harness.mjs +421 -72
  111. package/src/core/scheduler.mjs +980 -0
  112. package/src/core/script-bench.mjs +628 -0
  113. package/src/core/script-registry.mjs +116 -0
  114. package/src/core/script-store.mjs +563 -0
  115. package/src/core/settings.mjs +489 -14
  116. package/src/core/stats.mjs +33 -2
  117. package/src/core/workflow-export.mjs +94 -3
  118. package/src/core/workflow-share.mjs +67 -18
  119. package/src/core/workflows.mjs +47 -11
  120. package/src/core/workspaces.mjs +18 -12
  121. package/src/shared/forms/answer.mjs +164 -0
  122. package/src/shared/forms/catalog.mjs +91 -0
  123. package/src/shared/forms/form-def.mjs +290 -0
  124. package/src/shared/forms/layout.mjs +67 -0
  125. package/src/shared/forms/paths.mjs +47 -0
  126. package/src/shared/forms/project.mjs +309 -0
  127. package/src/shared/forms/schema.mjs +205 -0
  128. package/src/shared/graph/agent-meta.mjs +55 -5
  129. package/src/shared/graph/constants.mjs +14 -2
  130. package/src/shared/graph/flow-layout.mjs +2 -1
  131. package/src/shared/graph/isomorphic.mjs +5 -3
  132. package/src/shared/graph/manifest.mjs +22 -13
  133. package/src/shared/graph/ports.mjs +45 -19
  134. package/src/shared/graph/script-cases.mjs +257 -0
  135. package/src/shared/graph/script-icons.mjs +46 -0
  136. package/src/shared/graph/script-infer.mjs +259 -0
  137. package/src/shared/graph/script-meta.mjs +408 -0
  138. package/src/shared/graph/script-templates.mjs +201 -0
  139. package/src/shared/graph/template.mjs +4 -4
  140. package/src/shared/graph/validate.mjs +89 -16
  141. package/src/shared/human-estimate.mjs +100 -0
  142. package/src/shared/schedule/recurrence.mjs +353 -0
  143. package/src/shared/team-metrics/aggregate.mjs +51 -11
  144. package/{scripts → tools}/install.mjs +3 -3
  145. package/ui/public/app.js +4390 -683
  146. package/ui/public/artifact-picker.mjs +189 -0
  147. package/ui/public/ask/dom.mjs +121 -0
  148. package/ui/public/ask/form-preview.mjs +55 -0
  149. package/ui/public/ask/form-renderer.mjs +250 -0
  150. package/ui/public/ask/registry.mjs +53 -0
  151. package/ui/public/ask/widgets-display.mjs +370 -0
  152. package/ui/public/ask/widgets-input.mjs +624 -0
  153. package/ui/public/ask/widgets-layout.mjs +90 -0
  154. package/ui/public/ask-panel.mjs +401 -27
  155. package/ui/public/ask-run-card.mjs +1 -1
  156. package/ui/public/bridge-view.mjs +694 -0
  157. package/ui/public/chat-settings-view.mjs +24 -0
  158. package/ui/public/code-editor.mjs +181 -0
  159. package/ui/public/getting-started.mjs +34 -7
  160. package/ui/public/graph/composer.mjs +138 -10
  161. package/ui/public/graph/inspector.mjs +61 -58
  162. package/ui/public/graph/palette.mjs +27 -9
  163. package/ui/public/graph/run-decor.mjs +34 -17
  164. package/ui/public/graph/run-hosts.mjs +7 -1
  165. package/ui/public/graph/save-dialog.mjs +3 -0
  166. package/ui/public/graph/view.mjs +23 -7
  167. package/ui/public/guardrails-view.mjs +15 -3
  168. package/ui/public/guide-spot.mjs +87 -9
  169. package/ui/public/index.html +480 -141
  170. package/ui/public/memory-view.mjs +22 -4
  171. package/ui/public/models-view.mjs +162 -17
  172. package/ui/public/node-tunables.mjs +33 -4
  173. package/ui/public/plugins-view.mjs +23 -1
  174. package/ui/public/results-view.mjs +4 -2
  175. package/ui/public/schedule-sheet.mjs +430 -0
  176. package/ui/public/schedules-view.mjs +432 -0
  177. package/ui/public/script-bench-view.mjs +1154 -0
  178. package/ui/public/script-forms.mjs +282 -0
  179. package/ui/public/script-wizard.mjs +529 -0
  180. package/ui/public/scripts-view.mjs +868 -0
  181. package/ui/public/stats-view.mjs +159 -52
  182. package/ui/public/style.css +1461 -44
  183. package/ui/public/team-metrics-surfaces.mjs +77 -16
  184. package/ui/public/team-metrics-view.mjs +68 -5
  185. package/ui/public/team-policy-view.mjs +1402 -0
  186. package/ui/public/ui-level.mjs +237 -0
  187. package/ui/server.mjs +1827 -73
@@ -17,7 +17,7 @@ import { EventEmitter } from 'node:events';
17
17
  import { spawn } from 'node:child_process';
18
18
  import { homedir } from 'node:os';
19
19
  import { fileURLToPath } from 'node:url';
20
- import { join, basename, resolve, sep, relative } from 'node:path';
20
+ import { join, basename, dirname, resolve, sep, relative } from 'node:path';
21
21
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
22
22
  import { readFile, writeFile, readdir, mkdir, realpath, rename } from 'node:fs/promises';
23
23
 
@@ -40,8 +40,8 @@ import {
40
40
  pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
41
41
  memoryCaps,
42
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';
43
+ import { mountDirs, mountMemory, refreshMount, syncBack, memoryTotals, validateMemoryScope, withStoreLock, memoryRulesPath, memoryWorkPath, MEMORY_RULES_REL, MEMORY_INJECTED_ENTRY } from './memory-sync.mjs';
44
+ import { memoryRoot, renderMemoryBlock, bumpScopeState, readScopeState, memoryScopeReport, renderDefragBrief } from './memory-store.mjs';
45
45
  import { readCostCapOverride, totalWindowSpendUsd, costWindowStart, recordCostDelta } from './cost-budget.mjs';
46
46
  import {
47
47
  writeRunManifest, readRunManifest, updateRunManifest, rmGuarded, rescueModifiedMounts,
@@ -55,7 +55,8 @@ import {
55
55
  probeClaudeCapabilities, explainUnspawnableClaude,
56
56
  } from './preflight.mjs';
57
57
  import { fanoutCap, mapWithCap } from './fanout.mjs';
58
- import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig } from './config.mjs';
58
+ import { resolveStepModels, observeModelCost, resolveModelCost, modelCostConfig, readTeamMetricsPrefs } from './config.mjs';
59
+ import { bridgeCallsFor, forgetBridgeTag } from './bridge/telemetry.mjs';
59
60
  import { readGuardrailSet } from './guardrail-store.mjs';
60
61
  import { unionGuardrails, guardrailsToPermissionRules, mergePermissionRules } from './guardrails.mjs';
61
62
  import { collectRequiredSkills, validateSkills, injectSkills, pluginSkillDirs } from './skills.mjs';
@@ -71,27 +72,27 @@ import {
71
72
  REASON, pauseConsequences, describePauseReason,
72
73
  } from './failure-policy.mjs';
73
74
  import { recordRunMetrics } from './metrics/record.mjs';
75
+ // Team policy (team-policy design §6–§7): the document a run's cost gates fold in, its
76
+ // per-run state, and the off-policy findings the run log names at start.
77
+ import { resolveProjectPolicy, resolveWorkspacePolicy } from './policy/sync.mjs';
78
+ import { fieldsForRun, effectiveCap, deviationsFor } from './policy/effective.mjs';
79
+ import { writePolicyState, hasPipelineOverride, readTotalAck } from './policy/state.mjs';
80
+ import { installedPluginsMap, WORCA_VERSION as POLICY_WORCA_VERSION } from './policy/local.mjs';
81
+ import { readSettings as readRawSettings } from './settings.mjs';
74
82
 
75
83
  // worca-cc repo root; holds skills/. fileURLToPath, never URL.pathname: the
76
84
  // latter is `/C:/…` on Windows and %-encoded everywhere (see DEFAULT_AGENTS_DIR
77
85
  // in agent-registry.mjs, which is the single source for the built-in agents dir).
78
86
  const REPO_ROOT = fileURLToPath(new URL('../../', import.meta.url));
79
87
 
80
- /**
81
- * §9.4 message enrichment: does a DISABLED plugin ship this agent key? Scans
82
- * lock entries with enabled === false, reading key fields from each plugin's
83
- * current/agents/*.meta.json. Returns the plugin name or null. try/catch
84
- * throughout: no resolvable home / no lock / broken current => null (callers
85
- * fall back to the generic "not installed" message).
86
- * @param {string} key
87
- * @returns {string|null}
88
- */
89
- function findDisabledPluginFor(key) {
88
+ /** The disabled plugin that ships `<subdir>/<key>.meta.json`, or null. Shared by the
89
+ * agent preflight (`agents`) and the orchestrator's script preflight (`scripts`). */
90
+ export function findDisabledPluginFor(key, subdir = 'agents') {
90
91
  try {
91
92
  const lock = readPluginsLock();
92
93
  for (const name of Object.keys(lock).sort()) {
93
94
  if (!lock[name] || lock[name].enabled !== false) continue;
94
- const dir = join(pluginCurrentDir(name), 'agents');
95
+ const dir = join(pluginCurrentDir(name), subdir);
95
96
  let files;
96
97
  try { files = readdirSync(dir); } catch { continue; }
97
98
  for (const f of files) {
@@ -318,6 +319,18 @@ function describeToolResults(raw) {
318
319
  return lines;
319
320
  }
320
321
 
322
+ /** The tools whose `file_path` can be a memory write. */
323
+ const MEMORY_WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
324
+
325
+ /** The human text of a tool_result block: a string, or the first text block; `<tool_use_error>`
326
+ * tags stripped (the CLI wraps some errors in them). '' when there is none. */
327
+ function toolResultText(block) {
328
+ const c = block?.content;
329
+ const raw = typeof c === 'string' ? c
330
+ : Array.isArray(c) ? (c.find((x) => x?.type === 'text' && typeof x.text === 'string')?.text || '') : '';
331
+ return raw.replace(/<\/?tool_use_error>/g, '').trim();
332
+ }
333
+
321
334
  /** A short, human-readable target for a tool call (file, command, pattern…). */
322
335
  function toolTarget(name, input, projectDir) {
323
336
  if (!input || typeof input !== 'object') return '';
@@ -544,6 +557,9 @@ export function scrubErrorRows(snapshot) {
544
557
  * questions with their first option so downstream never sees gaps.
545
558
  */
546
559
  export function normalizeClarifyAnswer(payload, questions) {
560
+ // A form answer is `{form, version, values}` and is NEVER flattened here: the
561
+ // "fill with the first option" fallback below is legacy-kind only (spec §5).
562
+ if (payload && typeof payload === 'object' && typeof payload.form === 'string' && payload.values) return [];
547
563
  const arr = Array.isArray(payload?.answers)
548
564
  ? payload.answers
549
565
  : Array.isArray(payload)
@@ -677,7 +693,9 @@ export class RunHarness extends EventEmitter {
677
693
  this.pauseDetail = null; // the human detail behind pauseReason ('error': the clipped message)
678
694
  // Team metrics (§4.4 interventions). Resume runs on a NEW instance, so the counters are
679
695
  // 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 };
696
+ // pausedMs: time the run spent parked (paused, or dead between a crash and its resume);
697
+ // pausedAt: the pause stamp resume() measures from (null while running).
698
+ this._metricsIv = { questions: 0, pauses: 0, resumes: 0, pausedMs: 0, pausedAt: null, lastPauseReason: null, lastPauseDetail: null };
681
699
  this._metricsRecorded = false;
682
700
  this._setupDone = false; // run()/resume() flip this right before _engineRun (setup replay)
683
701
  this._rehydrated = true; // resume() clears this until the paused run is rehydrated (the 'resume' site)
@@ -699,12 +717,17 @@ export class RunHarness extends EventEmitter {
699
717
  this._askTail = null; // serializes _ask: ONE prompt open at a time (recovery + step questions)
700
718
  this._recoverySeq = 0; // monotonic id source for recovery prompts (determinism-safe)
701
719
  this.agentPrompts = null;
702
- this.memory = null; // { root, mount, dirs, baseline } after _mountMemory
720
+ this.memory = null; // { root, mount, rules, dirs, baseline } after _mountMemory — mount = the WRITABLE copy (<pipeline.dir>/memory), rules = the read-only copy (<runCwd>/.claude/rules/worca)
703
721
  this.memoryBlock = ''; // the ## Worca memory pointer block, rendered once per mount (files load natively — no per-spawn re-render)
704
722
  this.memoryChanges = []; // Change[] — the durable ledger's `changes`
705
723
  this._memoryWarned = new Set();
706
724
  this._memoryTail = null; // per-run sync chain: one syncBack at a time (F1)
725
+ // Failed-write bookkeeping (memory-write-split design §4): executionId -> { calls: Map<toolUseId, key>,
726
+ // last: Map<key.id, { ...key, ok, reason }> }. Filled by _trackMemoryWrites from every stream frame,
727
+ // drained by _takeFailedMemoryWrites at sync time.
728
+ this._memoryWrites = new Map();
707
729
  this._ledgerSeq = 0; // monotonic: two ledger writes must never share a temp name
730
+ this._pendingAudits = []; // audit lines an engine hook queued before the pipeline dir existed (_resolveTopology runs first); run() appends them right after "Pipeline created"
708
731
  this.toolInstruction = '';
709
732
  // Cap for the in-worktree graphify build (macOS has no timeout(1)).
710
733
  // Resolution order: constructor option → WORCA_GRAPH_TIMEOUT_MS env → 120s.
@@ -749,7 +772,8 @@ export class RunHarness extends EventEmitter {
749
772
  // detached run throws TypeError on the first this.state.branches[key] = … .
750
773
  branches: {},
751
774
  checkpointRefs: {},
752
- memoryMount: null, // <runCwd>/.claude/rules/worca after _mountMemory (native rules: inside every spawn's cwd)
775
+ memoryMount: null, // <pipeline.dir>/memory after _mountMemory — the WRITABLE copy agents edit (--add-dir on every spawn)
776
+ memoryRules: null, // <runCwd>/.claude/rules/worca — the read-only copy the CLI loads natively
753
777
  pauseReason: null, // mirrors this.pauseReason so getState() (a deep clone of state) carries it live
754
778
  pauseDetail: null, // mirrors this.pauseDetail
755
779
  // Sub-agent lifecycle records (rides the existing `state` snapshot; mirrored to
@@ -777,15 +801,29 @@ export class RunHarness extends EventEmitter {
777
801
  return false;
778
802
  }
779
803
  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) {
804
+ const out = pq.validate(payload);
805
+ // Two validator flavours, deliberately: the Auto proposal's returns the
806
+ // CLEAN value or null (§5.4 — the question stays open, silently), while a
807
+ // form's gate 3 returns a RESULT OBJECT carrying the field errors that
808
+ // POST /api/answer owes the client as 422. Only the latter throws.
809
+ if (out && typeof out === 'object' && typeof out.ok === 'boolean') {
810
+ if (!out.ok) {
811
+ this._log('orchestrator', 'warn', `answer() rejected: invalid answer for ${id} — the question stays open`);
812
+ const err = new Error('invalid answer');
813
+ err.code = 'INVALID_ANSWER';
814
+ err.errors = Array.isArray(out.errors) ? out.errors : [];
815
+ throw err;
816
+ }
817
+ this.pendingQuestion = null;
818
+ pq.resolve(out.payload);
819
+ return true;
820
+ }
821
+ if (out == null) {
784
822
  this._log('orchestrator', 'warn', `answer() ignored: malformed payload for ${id} — the question stays open`);
785
823
  return false;
786
824
  }
787
825
  this.pendingQuestion = null;
788
- pq.resolve(clean);
826
+ pq.resolve(out);
789
827
  return true;
790
828
  }
791
829
  this.pendingQuestion = null;
@@ -957,6 +995,7 @@ export class RunHarness extends EventEmitter {
957
995
  this.state.tools = tools;
958
996
  this.stepModels = stepModels;
959
997
  await this._resolveGuardrails();
998
+ await this._resolvePolicy();
960
999
  this._log(
961
1000
  'preflight',
962
1001
  'info',
@@ -1063,6 +1102,7 @@ export class RunHarness extends EventEmitter {
1063
1102
  `Preflight: using **${tools.tool}**${tools.kind ? ` (${tools.kind})` : ''}.`,
1064
1103
  );
1065
1104
  }
1105
+ for (const line of this._pendingAudits.splice(0)) await appendAudit(this.pipeline.dir, line);
1066
1106
 
1067
1107
  // 3) Ensure a git repo + checkpoint commit (per member on a workspace run).
1068
1108
  if (this.isWorkspace) await this._ensureGitCheckpointAll();
@@ -1141,8 +1181,10 @@ export class RunHarness extends EventEmitter {
1141
1181
  await this._assembleContext(resolvedSkills);
1142
1182
  }
1143
1183
  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,
1184
+ // 3f) Agent memory: mount the store twice — the read-only rules copy into
1185
+ // <runCwd>/.claude/rules/worca (the CLI loads it natively) and the writable copy into
1186
+ // <pipeline.dir>/memory (every spawn's --add-dir; the sync-back reads it) — and render the
1187
+ // pointer block every spawn carries, naming the writable copy. Pure fs work, both modes,
1146
1188
  // mock included. AFTER 3e: the assembly rewrites injectedPaths and the mount registers
1147
1189
  // itself into that map.
1148
1190
  await this._mountMemory();
@@ -1315,7 +1357,21 @@ export class RunHarness extends EventEmitter {
1315
1357
  this.state.stepper = safeParse(row.stepper);
1316
1358
  this.state.tools = safeParse(row.tools);
1317
1359
  this.state.branch = safeParse(row.branch);
1318
- this.state.steps = (steps || []).map((s) => ({ ...s, runningSince: null }));
1360
+ // Parked time (autonomy = active ÷ (wall − paused)). A paused row is measured from the
1361
+ // stamp _completePaused wrote into the point; an interrupted row from the last heartbeat
1362
+ // (the last time the dead process was seen alive — reconcileStaleRunning keeps it for
1363
+ // this). A point written before the stamp existed falls back to the row's updated_at.
1364
+ // The same anchor closes every step clock a crash left running: the tail up to the
1365
+ // anchor is real work that the crash never folded, the rest of the gap is parked.
1366
+ const iv = rp.interventions && typeof rp.interventions === 'object' ? rp.interventions : {};
1367
+ const anchor = Date.parse(row.status === 'interrupted' ? (row.heartbeat_at || row.updated_at) : (iv.pausedAt || row.updated_at));
1368
+ const now = Date.now();
1369
+ const parkedMs = Number.isFinite(anchor) ? Math.max(0, now - anchor) : 0;
1370
+ this.state.steps = (steps || []).map((s) => {
1371
+ if (s.runningSince == null || !Number.isFinite(anchor)) return { ...s, runningSince: null };
1372
+ return { ...s, activeMs: (s.activeMs || 0) + Math.max(0, Math.min(anchor, now) - s.runningSince), runningSince: null };
1373
+ });
1374
+ this.state.totalActiveMs = sumStepActive(this.state.steps);
1319
1375
  this.baseName = row.base_name;
1320
1376
  this.planDatePrefix = row.date_prefix;
1321
1377
  this.pipeline = { id: row.id, dir: rp.pipelineDir, promptText: row.prompt || '' };
@@ -1325,10 +1381,10 @@ export class RunHarness extends EventEmitter {
1325
1381
  this.stepModels = rp.stepModels || null;
1326
1382
  this.workflowId = rp.workflowId || this.workflowId;
1327
1383
  // 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 : {};
1384
+ // run adds a resume but no pause (§4.4 decision 6). The stamp is consumed here.
1330
1385
  this._metricsIv = {
1331
1386
  questions: iv.questions | 0, pauses: iv.pauses | 0, resumes: (iv.resumes | 0) + 1,
1387
+ pausedMs: (Number.isFinite(iv.pausedMs) ? iv.pausedMs : 0) + parkedMs, pausedAt: null,
1332
1388
  lastPauseReason: iv.lastPauseReason ?? null, lastPauseDetail: iv.lastPauseDetail ?? null,
1333
1389
  };
1334
1390
  // The saved point carries the pause that produced it; a resumed run is running.
@@ -1340,6 +1396,7 @@ export class RunHarness extends EventEmitter {
1340
1396
  this.guardrailsId = rp.guardrailsId || this.guardrailsId;
1341
1397
  this.state.guardrailsId = this.guardrailsId;
1342
1398
  await this._resolveGuardrails();
1399
+ await this._resolvePolicy();
1343
1400
  // Restore the EFFECTIVE instruction from the resume point — by dispatch time
1344
1401
  // run() has replaced the detect-time tools.instruction with the in-worktree
1345
1402
  // graph-build outcome (worktreeGraphInstruction() or ''). Falling back to
@@ -1738,6 +1795,74 @@ export class RunHarness extends EventEmitter {
1738
1795
  * LATEST definition. A missing/deleted set fails OPEN to the Permissive
1739
1796
  * (empty) policy with a loud warn — never an abort.
1740
1797
  */
1798
+ /**
1799
+ * Team policy (design §6, §9): the document that governs this run, folded for its kind
1800
+ * (workspaceRuns for a workspace target), plus the off-policy findings the run log names
1801
+ * once. Cache-first: only a project with no cache at all pays one bounded fetch. A missing,
1802
+ * unreadable or unsupported policy means local settings apply — loudly, never an abort.
1803
+ * Sets `this.policyRun` = { home, sha, fields, deviations, unattended } or null.
1804
+ */
1805
+ async _resolvePolicy() {
1806
+ this.policyRun = null;
1807
+ this._policyPersisted = false;
1808
+ this._policyWarned = new Set();
1809
+ let r;
1810
+ try {
1811
+ r = this.isWorkspace
1812
+ ? await resolveWorkspacePolicy(this.workspace?.id, { discover: 'if-missing' })
1813
+ : await resolveProjectPolicy(this.projectDir, { discover: 'if-missing' });
1814
+ } catch (err) {
1815
+ this._log('policy', 'warn', `team policy could not be read (${err?.message || err}); your local settings apply`);
1816
+ return;
1817
+ }
1818
+ if (!r.ok) {
1819
+ if (r.reason === 'delegate-invalid' || r.reason === 'home-stale' || r.reason === 'unsupported' || r.code === 'DOC_UNKNOWN') {
1820
+ this._log('policy', 'warn', `team policy not applied — ${r.detail || r.reason}; your local settings apply`);
1821
+ }
1822
+ return;
1823
+ }
1824
+ const fields = fieldsForRun(r.doc, { workspaceRun: this.isWorkspace });
1825
+ let installed = {};
1826
+ try { installed = installedPluginsMap(); } catch { /* no plugins root yet */ }
1827
+ let metricsRecord = null;
1828
+ try { const tm = readTeamMetricsPrefs(projectKey(this.projectDir)); metricsRecord = tm ? tm.record !== false : null; } catch { /* optional */ }
1829
+ const stepModels = Object.entries(this.stepModels || {}).map(([role, sel]) => ({ role, model: sel?.model }));
1830
+ const deviations = deviationsFor(fields, {
1831
+ guardrailsId: this.guardrailsId, guardrailSet: this.guardrails ? { settings: this.guardrails } : null,
1832
+ stepModels, installed, worcaVersion: POLICY_WORCA_VERSION, metricsRecord,
1833
+ });
1834
+ this.policyRun = { home: r.home, homeDir: r.homeDir, sha: r.sha, fields, deviations: deviations.map((d) => d.code), unattended: !!this.auto };
1835
+ for (const w of r.warnings || []) this._log('policy', 'warn', `team policy ${r.home}: ${w}`);
1836
+ const cap = fields['cost.pipelineLimitUsd'];
1837
+ const tot = fields['cost.totalLimitUsd'];
1838
+ const caps = [cap ? `pipeline cap $${Number(cap.value).toFixed(2)} (${cap.kind}${cap.kind === 'soft' ? `, ${cap.onBreach || 'pause'}` : ''})` : null,
1839
+ tot ? `total cap $${Number(tot.value).toFixed(2)} (${tot.kind})` : null].filter(Boolean).join(' · ');
1840
+ this._log('policy', 'info', `team policy ${r.home}${r.sha ? ` @ ${String(r.sha).slice(0, 7)}` : ''}${r.delegated ? ` (followed by ${r.from})` : ''}${this.isWorkspace && Object.keys(r.doc.workspaceRuns || {}).length ? ' · workspace-run values' : ''}${caps ? ` · ${caps}` : ''}`);
1841
+ for (const d of deviations) this._log('policy', d.level === 'warn' ? 'warn' : 'info', `off-policy: ${d.text}`);
1842
+ if (this.auto && ((cap?.kind === 'soft' && (cap.onBreach || 'pause') === 'pause') || (tot?.kind === 'soft' && (tot.onBreach || 'pause') === 'pause'))) {
1843
+ this._log('policy', 'info', 'unattended run: a team soft cap warns instead of pausing (nobody can click "continue past")');
1844
+ }
1845
+ // Workspace runs never union member policies (design §6): say so once when a member is tighter.
1846
+ if (this.isWorkspace && cap) {
1847
+ for (const m of this.members || []) {
1848
+ try {
1849
+ const mr = await resolveProjectPolicy(m.projectDir, { discover: false });
1850
+ if (!mr.ok || mr.home === r.home) continue;
1851
+ const mc = fieldsForRun(mr.doc)['cost.pipelineLimitUsd'];
1852
+ if (mc && mc.value < cap.value) this._log('policy', 'info', `member ${mr.from} carries a tighter pipeline cap ($${Number(mc.value).toFixed(2)}, ${mr.home}); the workspace policy applies to this run`);
1853
+ } catch { /* informational only */ }
1854
+ }
1855
+ }
1856
+ }
1857
+
1858
+ /** First persist of the run's policy state (needs the pipeline row); later writes merge. */
1859
+ _persistPolicyState(patch = {}) {
1860
+ if (!this.pipeline?.id || !this.policyRun) return;
1861
+ const base = this._policyPersisted ? {} : { home: this.policyRun.home, sha: this.policyRun.sha, deviations: this.policyRun.deviations, unattended: this.policyRun.unattended };
1862
+ this._policyPersisted = true;
1863
+ try { writePolicyState(this.pipeline.id, { ...base, ...patch }); } catch (err) { this._log('policy', 'warn', `could not record policy state: ${err?.message || err}`); }
1864
+ }
1865
+
1741
1866
  async _resolveGuardrails() {
1742
1867
  let set = await readGuardrailSet(this.guardrailsId || 'permissive');
1743
1868
  if (!set) {
@@ -1858,7 +1983,7 @@ export class RunHarness extends EventEmitter {
1858
1983
  if (!this.pipeline?.dir) return;
1859
1984
  try { await this._mountMemoryUnguarded({ resume }); }
1860
1985
  catch (err) {
1861
- this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null;
1986
+ this.memory = null; this.memoryBlock = ''; this.state.memoryMount = null; this.state.memoryRules = null;
1862
1987
  if (existsSync(this._memoryLedgerPath())) await this._writeMemoryLedger({ neutralised: true });
1863
1988
  const why = String(err?.message || err).split('\n')[0];
1864
1989
  // A defragment run IS its mount (B8): rethrow, and run()'s setup failure policy parks the run
@@ -1874,9 +1999,11 @@ export class RunHarness extends EventEmitter {
1874
1999
  }
1875
2000
 
1876
2001
  /**
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.
2002
+ * Mount the memory store into this run twice: the read-only rules copy at
2003
+ * `<runCwd>/.claude/rules/worca` (where Claude Code discovers rules natively — run root on a
2004
+ * detached workspace run, the primary worktree otherwise) and the writable copy at
2005
+ * `<pipeline.dir>/memory` (the sync-back mount, outside every checkout). Always recomputed,
2006
+ * never the ledger's absolute paths.
1880
2007
  * On resume, the previous segment's ledger is read first and its mount is synced back BEFORE
1881
2008
  * anything else (§5 "resume of a paused run"): the sync is pure fs and needs neither git nor
1882
2009
  * the tracked guard, so a guard that fails only NOW (git broken, the previous segment's agent
@@ -1884,10 +2011,15 @@ export class RunHarness extends EventEmitter {
1884
2011
  */
1885
2012
  async _mountMemoryUnguarded({ resume }) {
1886
2013
  const root = memoryRoot();
1887
- // Pre-setup there is no run cwd, and the LIVE checkout must never take a mount.
2014
+ // Pre-setup there is no run cwd, and the LIVE checkout must never take a rules copy.
1888
2015
  const cwd = this.runCwd || null;
1889
2016
  if (!cwd || cwd === this.projectDir) throw new Error('no run cwd to mount into');
1890
- const mount = memoryMountPath(cwd);
2017
+ if (!this.pipeline?.dir) throw new Error('no pipeline dir for the writable memory copy');
2018
+ // Two copies (memory-write-split design D1): the READ-ONLY rules copy inside the cwd, where Claude
2019
+ // Code loads it and refuses every write (`.claude` is a protected path); the WRITABLE copy — the
2020
+ // sync-back mount — under the pipeline dir, outside every checkout, reached by --add-dir.
2021
+ const rules = memoryRulesPath(cwd);
2022
+ const mount = memoryWorkPath(this.pipeline.dir);
1891
2023
  // §8.8 scope of the record: 'runRoot' when the cwd IS the run root, else the member whose
1892
2024
  // checkout is the cwd (the primary member on single and legacy-workspace runs).
1893
2025
  const scope = (this.runRoot && cwd === this.runRoot) ? 'runRoot'
@@ -1900,9 +2032,9 @@ export class RunHarness extends EventEmitter {
1900
2032
  try { ledger = JSON.parse(await readFile(this._memoryLedgerPath(), 'utf8')); } catch { ledger = null; }
1901
2033
  if (ledger && ledger.baseline && Array.isArray(ledger.dirs)) {
1902
2034
  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.
2035
+ // The interrupted segment's writes live at the LEDGER's mount: this dir since the write
2036
+ // split, the in-checkout `.claude/rules/worca` for a run paused before it (a pause keeps the
2037
+ // checkout, so that dir is still there). Sync whichever exists; the recomputed paths are used from here on.
1906
2038
  const prev = (typeof ledger.mount === 'string' && ledger.mount !== mount && existsSync(ledger.mount)) ? ledger.mount : mount;
1907
2039
  try {
1908
2040
  await this._syncMemoryWith({ mount: prev, dirs: ledger.dirs, baseline: ledger.baseline, nodeId: 'resume', executionId: null, agentKey: null, label: 'the interrupted execution' });
@@ -1911,12 +2043,11 @@ export class RunHarness extends EventEmitter {
1911
2043
  // through onError). If it ever does: do NOT remount over unsynced writes; keep the
1912
2044
  // PREVIOUS mount + baseline so the next execution's sync retries them.
1913
2045
  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);
2046
+ this.memory = { root, mount: prev, rules: null, dirs: ledger.dirs, baseline: ledger.baseline };
2047
+ this.state.memoryMount = prev; this.state.memoryRules = null;
2048
+ // Register in every case: the rules copy of the interrupted segment (if any) is still inside
2049
+ // the checkout and must stay excluded from the commit and removed at teardown. Idempotent.
2050
+ await this._registerMemoryMount(scope);
1920
2051
  this._refreshMemoryBlock();
1921
2052
  return;
1922
2053
  }
@@ -1938,16 +2069,20 @@ export class RunHarness extends EventEmitter {
1938
2069
  // EBUSY past the retries) leaves files under the cwd, and only the §8.8 entry keeps them out
1939
2070
  // of the commit and gets them removed at teardown. Idempotent, harmless on failure.
1940
2071
  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 };
2072
+ // The WRITABLE copy: a full copy of the mounted scopes (agents edit existing files in place;
2073
+ // syncBack diffs it against this baseline). Outside git — no sentinel.
2074
+ const m = await mountMemory({ root, mount, dirs, onError });
2075
+ // The READ-ONLY rules copy: the same files where the CLI discovers them. Its baseline is
2076
+ // irrelevant (nothing syncs back from it). `<rules>/.gitignore` = `*` keeps it out of an agent's
2077
+ // own `git add -A`, a staging pre-commit hook, snapshotWorktreePatch's bare `git add -A` and the
2078
+ // reviewer's `git status`; the §8.8 `:(exclude)` pathspec stays as defence in depth.
2079
+ await mountMemory({ root, mount: rules, dirs, onError, gitIgnore: true });
2080
+ this.memory = { root, mount, rules, dirs, baseline: m.baseline };
1947
2081
  this.state.memoryMount = mount;
2082
+ this.state.memoryRules = rules;
1948
2083
  this._refreshMemoryBlock();
1949
2084
  await this._writeMemoryLedger();
1950
- this._log('memory', 'info', `Memory mounted at ${mount}: ${m.files} file(s) across ${dirs.length} scope(s)`);
2085
+ this._log('memory', 'info', `Memory mounted: ${m.files} file(s) across ${dirs.length} scope(s) — loaded from ${rules}, written to ${mount}`);
1951
2086
  }
1952
2087
 
1953
2088
  /**
@@ -1996,8 +2131,11 @@ export class RunHarness extends EventEmitter {
1996
2131
  if (!this.memory) return Promise.resolve(null);
1997
2132
  const job = async () => {
1998
2133
  if (!this.memory) return null;
2134
+ // Every frame of the execution has arrived (the runner resolved before _afterExecution); a
2135
+ // null executionId is the run-end sync and drains what unfinished executions left behind.
2136
+ const failed = this._takeFailedMemoryWrites(ctx.executionId ?? null);
1999
2137
  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 });
2138
+ return await this._syncMemoryWith({ ...this.memory, nodeId: ctx.nodeId, executionId: ctx.executionId, agentKey: nc?.key ?? null, label: nc?.key || ctx.label || ctx.nodeId, failed });
2001
2139
  } catch (err) {
2002
2140
  this._log('memory', 'warn', `memory sync failed after ${ctx.executionId}: ${err?.message || err}`);
2003
2141
  return null;
@@ -2007,7 +2145,7 @@ export class RunHarness extends EventEmitter {
2007
2145
  return this._memoryTail;
2008
2146
  }
2009
2147
 
2010
- async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label }) {
2148
+ async _syncMemoryWith({ mount, dirs, baseline, nodeId, executionId, agentKey, label, failed = [] }) {
2011
2149
  const now = new Date().toISOString();
2012
2150
  const res = await syncBack({
2013
2151
  root: memoryRoot(), mount, dirs, baseline, source: `${this.memoryScope ? 'defrag' : 'run'}:${this.pipeline.id}`, now,
@@ -2018,26 +2156,119 @@ export class RunHarness extends EventEmitter {
2018
2156
  // an OLD instance can never race a resumed one because the scheduler drains in-flight
2019
2157
  // executions before the run reports 'paused' (scheduler.mjs, the pause drain).
2020
2158
  if (this.memory && this.memory.mount === mount) this.memory.baseline = res.baseline;
2021
- if (res.total || res.rejected.length) {
2159
+ if (res.total || res.rejected.length || failed.length) {
2022
2160
  this.memoryChanges.push({
2023
2161
  executionId, nodeId, agentKey, at: now,
2024
- added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected,
2162
+ added: res.added, modified: res.modified, deleted: res.deleted, rejected: res.rejected, failed,
2025
2163
  });
2026
2164
  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`;
2165
+ `${res.rejected.length ? ` (${res.rejected.length} rejected)` : ''}${failed.length ? ` (${failed.length} failed)` : ''} by ${label}`;
2166
+ const name = (r) => `${r.scope ? `${r.scope}/` : ''}${r.name}.md`;
2029
2167
  const details = [
2030
2168
  ...res.added.map((r) => `added ${name(r)}`), ...res.modified.map((r) => `updated ${name(r)}`),
2031
2169
  ...res.deleted.map((r) => `deleted ${name(r)}`), ...res.rejected.map((r) => `rejected ${name(r)} — ${r.reason}`),
2170
+ ...failed.map((r) => `failed ${name(r)} — ${r.reason}`),
2032
2171
  ];
2033
2172
  this._log('memory', 'info', `${head}: ${details.join('; ')}`, { nodeId, executionId });
2173
+ // A failed write is the one memory outcome nobody asked for: warn, per file, so it is visible in
2174
+ // the run log without opening the 300 KB transcript.
2175
+ for (const r of failed) this._log('memory', 'warn', `memory: ${name(r)} written by ${label} never reached the store — ${r.reason}`, { nodeId, executionId });
2034
2176
  await appendAudit(this.pipeline.dir, `${head}: ${details.join('; ')}`).catch(() => {});
2177
+ await this._recordFailedWrites(failed, now);
2178
+ }
2179
+ // The rules copy the NEXT execution loads must carry what this sync stored (the store is the
2180
+ // authority: it also holds Ask/UI writes made mid-run). Non-destructive and per-file atomic, so
2181
+ // a Task sub-agent spawning right now never reads a torn file; never touches the sentinel.
2182
+ if (res.total && this.memory && this.memory.mount === mount && this.memory.rules) {
2183
+ try {
2184
+ const { failed: stale } = await withStoreLock(memoryRoot(), () => refreshMount({ root: memoryRoot(), mount: this.memory.rules, dirs, onError: (p, err) => this._memoryReadWarn(p, err) }));
2185
+ if (stale.length) this._log('memory', 'warn', `memory: the rules copy could not be refreshed for ${stale.join(', ')} — the next agent loads the previous text`);
2186
+ } catch (err) {
2187
+ this._log('memory', 'warn', `memory: the rules copy could not be refreshed: ${err?.message || err}`);
2188
+ }
2035
2189
  }
2036
2190
  if (this.memory && this.memory.mount === mount) await this._writeMemoryLedger();
2037
2191
  return res;
2038
2192
  }
2039
2193
 
2040
- /** `{ mount, dirs, baseline, changes }` — best-effort, atomic via temp + rename. */
2194
+ /** Bump the `.state` counters of every scope a failed write named (memory-write-split design D9).
2195
+ * A write beside the scope dirs (`scope: ''`, or a rel that is not mounted) is reported but counted
2196
+ * against no scope. Best-effort: a counter that cannot be written is a warn line, never a throw. */
2197
+ async _recordFailedWrites(failed, now) {
2198
+ if (!failed.length || !this.memory) return;
2199
+ const byRel = new Map();
2200
+ for (const f of failed) if (f.scope) byRel.set(f.scope, (byRel.get(f.scope) || 0) + 1);
2201
+ for (const [rel, n] of byRel) {
2202
+ const d = this.memory.dirs.find((x) => x.rel === rel);
2203
+ if (!d) continue;
2204
+ try {
2205
+ await withStoreLock(memoryRoot(), async () => {
2206
+ const st = await readScopeState(memoryRoot(), d.scope);
2207
+ await bumpScopeState(memoryRoot(), d.scope, { failedWrites: (Number(st.failedWrites) || 0) + n, lastFailedAt: now, lastFailedRunId: this.pipeline.id });
2208
+ });
2209
+ } catch (err) {
2210
+ this._log('memory', 'warn', `memory: failed-write counter not updated for ${rel}: ${err?.message || err}`);
2211
+ }
2212
+ }
2213
+ }
2214
+
2215
+ /** Classify a tool call's target: `{ id, where, scope, name }` when it sits under the writable copy
2216
+ * (`where: 'memory'`) or the read-only rules copy (`where: 'rules'`), else null. `scope` is the
2217
+ * mounted rel the path starts with, else its dirname inside the copy ('' at the copy's root) — a
2218
+ * write beside the scope dirs is still reported. Relative paths resolve against the run cwd, which
2219
+ * is every spawn's cwd. */
2220
+ _memoryWriteKey(p) {
2221
+ if (typeof p !== 'string' || !p || !this.memory) return null;
2222
+ const abs = resolve(this.runCwd || this.workDir || this.projectDir, p);
2223
+ for (const [where, base] of [['memory', this.memory.mount], ['rules', this.memory.rules]]) {
2224
+ if (!base || !(abs === base || abs.startsWith(base + sep))) continue;
2225
+ const rel = relative(base, abs).split(sep).join('/');
2226
+ const d = this.memory.dirs.find((x) => rel === x.rel || rel.startsWith(`${x.rel}/`));
2227
+ const dn = dirname(rel);
2228
+ return { id: `${where}:${rel}`, where, scope: d ? d.rel : (dn === '.' ? '' : dn), name: basename(rel).replace(/\.md$/i, '') };
2229
+ }
2230
+ return null;
2231
+ }
2232
+
2233
+ _trackMemoryWrites(raw, attr) {
2234
+ if (!this.memory) return;
2235
+ const content = raw?.message?.content;
2236
+ if (!Array.isArray(content)) return;
2237
+ const exec = attr?.executionId ?? '(no execution)';
2238
+ for (const b of content) {
2239
+ if (b?.type === 'tool_use' && MEMORY_WRITE_TOOLS.has(b.name) && typeof b.id === 'string') {
2240
+ const key = this._memoryWriteKey(b.input?.file_path || b.input?.path || b.input?.notebook_path);
2241
+ if (!key) continue;
2242
+ const rec = this._memoryWrites.get(exec) || { calls: new Map(), last: new Map() };
2243
+ rec.calls.set(b.id, key);
2244
+ this._memoryWrites.set(exec, rec);
2245
+ } else if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string') {
2246
+ const rec = this._memoryWrites.get(exec);
2247
+ const key = rec?.calls.get(b.tool_use_id);
2248
+ if (!key) continue;
2249
+ // 400: the CLI's refusal quotes the absolute path and ends with the cause ("… which is a
2250
+ // sensitive file.") — a deep run-root path must not clip the cause away.
2251
+ rec.last.set(key.id, { ...key, ok: !b.is_error, reason: b.is_error ? clip(toolResultText(b), 400) : '' });
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ /** The keys whose LAST write outcome in `executionId` was an error, as `[{ scope, name, reason }]`
2257
+ * (a retry that succeeded clears the failure); that execution's bookkeeping is dropped. `null`
2258
+ * drains EVERY pending execution — the run-end sync, so an execution the run never finished
2259
+ * (stop, error) still reports (design D13). A rules-copy write names its own cause first. */
2260
+ _takeFailedMemoryWrites(executionId) {
2261
+ const recs = executionId == null ? [...this._memoryWrites.values()] : [this._memoryWrites.get(executionId)].filter(Boolean);
2262
+ if (executionId == null) this._memoryWrites.clear(); else this._memoryWrites.delete(executionId);
2263
+ const out = [];
2264
+ for (const rec of recs) for (const v of rec.last.values()) {
2265
+ if (v.ok) continue;
2266
+ out.push({ scope: v.scope, name: v.name, reason: v.where === 'rules' ? `written into the read-only rules copy — ${v.reason}` : v.reason });
2267
+ }
2268
+ return out;
2269
+ }
2270
+
2271
+ /** `{ mount, rules, dirs, baseline, changes }` — best-effort, atomic via temp + rename. */
2041
2272
  async _writeMemoryLedger({ neutralised = false } = {}) {
2042
2273
  if (!this.pipeline?.dir || (!this.memory && !neutralised)) return;
2043
2274
  const file = this._memoryLedgerPath();
@@ -2045,13 +2276,29 @@ export class RunHarness extends EventEmitter {
2045
2276
  // no baseline, so a later resume has nothing stale to diff against (a missing mount dir
2046
2277
  // must never read as "the run deleted every file").
2047
2278
  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 };
2279
+ ? { mount: null, rules: null, dirs: [], baseline: {}, changes: this.memoryChanges }
2280
+ : { mount: this.memory.mount, rules: this.memory.rules, dirs: this.memory.dirs, baseline: this.memory.baseline, changes: this.memoryChanges };
2050
2281
  const tmp = `${file}.tmp-${process.pid}-${++this._ledgerSeq}`;
2051
2282
  try { await writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); await rename(tmp, file); }
2052
2283
  catch (err) { this._log('memory', 'warn', `memory ledger not written: ${err?.message || err}`); }
2053
2284
  }
2054
2285
 
2286
+ /** The `## Memory health` section a defragment run appends to its task document: the reasons the
2287
+ * scope is flagged and the budgets a finished defragment must meet — read from the STORE (what
2288
+ * Settings → Memory shows), which the mount mirrors at this point. '' on every other run.
2289
+ * Best-effort: a store read failure costs the agent its brief, never the run. */
2290
+ async _defragBrief() {
2291
+ if (!this.memoryScope || !this.memory?.dirs?.length) return '';
2292
+ try {
2293
+ const caps = memoryCaps();
2294
+ const { health } = await memoryScopeReport(memoryRoot(), this.memory.dirs[0].scope, caps, { onError: (p, err) => this._memoryReadWarn(p, err) });
2295
+ return renderDefragBrief(health, caps);
2296
+ } catch (err) {
2297
+ this._log('memory', 'warn', `memory: the defragment brief could not be built: ${err?.message || err}`);
2298
+ return '';
2299
+ }
2300
+ }
2301
+
2055
2302
  /** A finished defragment run resets the scope's counters (spec §5, §7): called on the `done`
2056
2303
  * arms only, after _buildResults' final sync. `this.memory.dirs[0]` is the one mounted scope.
2057
2304
  * Amendment B31: a run whose ledger holds ANY rejected write did not produce the scope the
@@ -2069,7 +2316,7 @@ export class RunHarness extends EventEmitter {
2069
2316
  }
2070
2317
  const now = new Date().toISOString();
2071
2318
  try {
2072
- await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0 }));
2319
+ await withStoreLock(memoryRoot(), () => bumpScopeState(memoryRoot(), d.scope, { lastDefragAt: now, lastDefragRunId: this.pipeline.id, writesSinceDefrag: 0, failedWrites: 0, lastFailedAt: null, lastFailedRunId: null }));
2073
2320
  this._log('memory', 'info', `Memory: ${d.label} defragmented — write counter reset`);
2074
2321
  await appendAudit(this.pipeline.dir, `Memory: ${d.label} defragmented by this run.`).catch(() => {});
2075
2322
  } catch (err) {
@@ -2748,27 +2995,80 @@ export class RunHarness extends EventEmitter {
2748
2995
  * raised limit or a window reset takes effect at the next step (F9). */
2749
2996
  _checkCostLimits() {
2750
2997
  if (!this.pipeline?.id) return; // pre-createPipeline: nothing to meter
2751
- const pipeLimit = pipelineCostLimitUsd();
2998
+ this._persistPolicyState(); // first boundary with a row: home/sha/deviations land
2999
+ const teamFields = this.policyRun?.fields || {};
3000
+ const home = this.policyRun?.home || null;
2752
3001
  // resume() rehydrates state.steps but not state.totalCostUsd, so the row
2753
3002
  // total reads $0 until the first cost event of the resumed run. Take the
2754
3003
  // larger of the two so a resumed over-cap pipeline cannot run one free step.
2755
3004
  const spentHere = Math.max(this.state.totalCostUsd || 0, sumStepCosts(this.state.steps));
2756
- if (pipeLimit != null && spentHere >= pipeLimit
2757
- && !readCostCapOverride(this.pipeline.id)) {
3005
+ // Team policy (design §7): the tighter of the developer's cap and a soft team cap applies;
3006
+ // a team default only starts the developer off. `binding` says whose number tripped.
3007
+ const teamPipe = teamFields['cost.pipelineLimitUsd'] || null;
3008
+ const pipe = effectiveCap({ local: pipelineCostLimitUsd(), team: teamPipe });
3009
+ // The developer's own cap (or a team DEFAULT, which is the same thing): the existing
3010
+ // pause + the existing per-pipeline override. A team-bound fold has no "own" cap here.
3011
+ const ownCap = pipe.binding === 'team' ? null : pipe.cap;
3012
+ if (ownCap != null && spentHere >= ownCap && !readCostCapOverride(this.pipeline.id)) {
2758
3013
  this._capReached(REASON.COST_PIPELINE,
2759
- `pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${pipeLimit.toFixed(2)})`);
3014
+ `pipeline cost limit reached ($${spentHere.toFixed(2)} >= $${ownCap.toFixed(2)})`);
3015
+ }
3016
+ // The team SOFT cap, whether or not it is the tighter number: the local override never
3017
+ // bypasses it — only the team override ("continue past team cap") does.
3018
+ if (teamPipe && teamPipe.kind === 'soft' && spentHere >= teamPipe.value && !hasPipelineOverride(this.pipeline.id)) {
3019
+ const detail = `team cost cap reached ($${spentHere.toFixed(2)} >= $${Number(teamPipe.value).toFixed(2)}, ${home})`;
3020
+ this._teamCapBreach('pipeline', teamPipe, detail, REASON.COST_PIPELINE_POLICY);
2760
3021
  }
2761
- const totalLimit = totalCostLimitUsd();
2762
- if (totalLimit != null) {
2763
- const period = costLimitResetPeriod();
2764
- const spent = totalWindowSpendUsd(costWindowStart(new Date(), period).getTime());
2765
- if (spent >= totalLimit) {
2766
- this._capReached(REASON.COST_TOTAL,
2767
- `total cost limit reached ($${spent.toFixed(2)} >= $${totalLimit.toFixed(2)} this ${period === 'weekly' ? 'week' : 'month'})`);
3022
+ const period = this._effectiveResetPeriod();
3023
+ const tot = effectiveCap({ local: totalCostLimitUsd(), team: teamFields['cost.totalLimitUsd'] || null });
3024
+ if (tot.cap != null) {
3025
+ const windowStartMs = costWindowStart(new Date(), period).getTime();
3026
+ const spent = totalWindowSpendUsd(windowStartMs);
3027
+ if (spent >= tot.cap) {
3028
+ const w = period === 'weekly' ? 'week' : 'month';
3029
+ if (tot.binding === 'team') {
3030
+ const ack = readTotalAck(projectKey(this.policyRun.homeDir || this.projectDir), home, windowStartMs);
3031
+ if (ack) {
3032
+ // Acknowledged once for this window (design §7): the run proceeds and the record says so.
3033
+ if (!this._policyWarned.has('total-ack')) { this._policyWarned.add('total-ack'); this._persistPolicyState({ overrides: ['total'], ...(ack.reason ? { reason: ack.reason } : {}) }); }
3034
+ } else {
3035
+ const detail = `team total cap reached ($${spent.toFixed(2)} >= $${tot.cap.toFixed(2)} this ${w}, ${home})`;
3036
+ this._teamCapBreach('total', tot.team, detail, REASON.COST_TOTAL_POLICY);
3037
+ }
3038
+ } else {
3039
+ this._capReached(REASON.COST_TOTAL,
3040
+ `total cost limit reached ($${spent.toFixed(2)} >= $${tot.cap.toFixed(2)} this ${w})`);
3041
+ }
2768
3042
  }
2769
3043
  }
2770
3044
  }
2771
3045
 
3046
+ /** The reset period: the developer's when stored, else a team default, else monthly. */
3047
+ _effectiveResetPeriod() {
3048
+ const stored = readRawSettings().costLimitResetPeriod;
3049
+ if (stored === 'weekly' || stored === 'monthly') return stored;
3050
+ const team = this.policyRun?.fields?.['cost.resetPeriod'];
3051
+ return team && (team.value === 'weekly' || team.value === 'monthly') ? team.value : costLimitResetPeriod();
3052
+ }
3053
+
3054
+ /**
3055
+ * A soft team cap was hit and nobody has continued past it. `onBreach: warn`, and any
3056
+ * unattended (--yes) run, log ONE line and go on with `exceeded` recorded; otherwise the
3057
+ * run pauses on the policy reason so the resume flow can offer "continue past".
3058
+ */
3059
+ _teamCapBreach(which, team, detail, reason) {
3060
+ const breach = team?.onBreach || 'pause';
3061
+ if (breach === 'warn' || this.auto) {
3062
+ if (this._policyWarned.has(which)) return;
3063
+ this._policyWarned.add(which);
3064
+ const why = breach === 'warn' ? 'the policy says warn' : 'unattended run, nobody can continue past a pause';
3065
+ this._log('policy', 'warn', `${detail} — continuing: ${why}`);
3066
+ this._persistPolicyState({ exceeded: [which] });
3067
+ return;
3068
+ }
3069
+ this._capReached(reason, detail);
3070
+ }
3071
+
2772
3072
  /** The BUDGET site (failure-policy.mjs): a cost cap was reached at a step
2773
3073
  * boundary. Unlike the catch-block sites this throws itself — its caller is
2774
3074
  * the boundary gate. The audit line is required: _completePaused suppresses
@@ -2908,7 +3208,8 @@ export class RunHarness extends EventEmitter {
2908
3208
  * Freezes the active-time clock while blocked on the user (active-time-only).
2909
3209
  * @returns {Promise<any>} the answer payload
2910
3210
  */
2911
- async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow, validate }) {
3211
+ async _ask({ id, kind, questions, issues, recovery, agent, nodeId, wireId, executionId, deliveryNo, holdNo, workflow,
3212
+ askId, form, version, title, surface, data, layout, answerSchema, fileRefs, files, autoValues, validate }) {
2912
3213
  this._checkAbort();
2913
3214
  // No interactive prompt may OPEN on a pausing run. pause() rejects only the
2914
3215
  // prompt that is currently open; a queued ask (a parallel sibling's questions
@@ -2940,6 +3241,15 @@ export class RunHarness extends EventEmitter {
2940
3241
  ...(deliveryNo != null ? { deliveryNo } : {}),
2941
3242
  ...(holdNo != null ? { holdNo } : {}),
2942
3243
  ...(workflow !== undefined ? { workflow } : {}),
3244
+ // The ask-form envelope (spec §4, ruling X1) rides the EXISTING 'question'
3245
+ // frame — no new transport, no new slot. `id` (already emitted above) is the
3246
+ // ANSWER token; `askId` is the route-safe file token and they are never
3247
+ // interchangeable. `validate` and `autoValues` are arguments only and must
3248
+ // never reach a socket.
3249
+ ...(kind === 'form'
3250
+ ? { askId, form, version, title, surface: surface || 'any', data, layout, answerSchema,
3251
+ fileRefs: fileRefs || [], files: files || [] }
3252
+ : {}),
2943
3253
  });
2944
3254
  this._metricsIv.questions += 1;
2945
3255
 
@@ -2956,6 +3266,13 @@ export class RunHarness extends EventEmitter {
2956
3266
  this._log('orchestrator', 'info', `auto-accepting workflow proposal ${id}`);
2957
3267
  return { decision: 'accept' };
2958
3268
  }
3269
+ if (kind === 'form') {
3270
+ // D10: a form ask is auto-answered with the form's AUTO ANSWER, which
3271
+ // gate 1 proved passes gate 3 — so an unattended run neither hangs nor
3272
+ // produces an invalid answer. No pending question is installed.
3273
+ this._log('orchestrator', 'info', `auto-answering form ${id} (${form})`);
3274
+ return { form, version, values: autoValues && typeof autoValues === 'object' ? autoValues : {} };
3275
+ }
2959
3276
  if (kind === 'clarify' || kind === 'questions') {
2960
3277
  this._log('orchestrator', 'info', `auto-answering ${kind} ${id}`);
2961
3278
  return {
@@ -3604,6 +3921,7 @@ export class RunHarness extends EventEmitter {
3604
3921
  const cost = costCfg
3605
3922
  ? resolveModelCost(attr.model, rawCost, e.raw.usage, costCfg)
3606
3923
  : rawCost;
3924
+ if (isResult) this._recordBridgeCalls(attr?.stepKey, attr?.executionId);
3607
3925
  if (Number.isFinite(cost)) this._recordCost(cost, attr?.stepKey);
3608
3926
  else if (isResult && !this.claude.mock) {
3609
3927
  // A {perMtok} model prices from tokens alone, so a result with no usage is
@@ -3629,6 +3947,12 @@ export class RunHarness extends EventEmitter {
3629
3947
  } catch { /* derived state — never fail the run over it */ }
3630
3948
  }
3631
3949
 
3950
+ // Agent memory (memory-write-split design §4): pair every Write/Edit aimed at a memory directory
3951
+ // with its tool_result, main stream and sub-agent frames alike (same cwd, same dirs), so a write
3952
+ // the CLI refused — or that failed for any other reason — is reported at sync time instead of
3953
+ // vanishing. Never throws; never mutates run state.
3954
+ this._trackMemoryWrites(e.raw, attr);
3955
+
3632
3956
  // Sub-agent attribution. A child (Task/Agent) event carries parent_tool_use_id
3633
3957
  // = the id of the parent's Task tool_use block; main-agent events carry null/
3634
3958
  // absent. parent_tool_use_id is a TOP-LEVEL stream-json field; the message-
@@ -4004,6 +4328,30 @@ export class RunHarness extends EventEmitter {
4004
4328
  * carries the figure.
4005
4329
  * @param {number} costUsd
4006
4330
  */
4331
+ /**
4332
+ * Model bridge (model-bridge-design.md §7.2/§8.6): the premium-request-
4333
+ * initiating calls a node made through the bridge, read off the bridge's
4334
+ * per-execution counter when the node's terminal `result` arrives and
4335
+ * stamped on the step (`bridgeCalls`; `bridgeContinued` the tool-loop
4336
+ * continuations). Nothing for a non-bridged node, so the step shape is
4337
+ * unchanged there. Persisted through exec_meta (artifacts.mjs).
4338
+ */
4339
+ _recordBridgeCalls(stepKey, executionId) {
4340
+ if (!executionId) return;
4341
+ const calls = bridgeCallsFor(executionId);
4342
+ if (!calls.initiated && !calls.continued) return;
4343
+ forgetBridgeTag(executionId);
4344
+ const key = stepKey
4345
+ || (this.state.cycle ? `${this.state.phase}#${this.state.cycle}` : this.state.phase);
4346
+ const step = this.state.steps.find((s) => s.key === key);
4347
+ if (!step) return;
4348
+ step.bridgeCalls = (step.bridgeCalls || 0) + calls.initiated;
4349
+ step.bridgeContinued = (step.bridgeContinued || 0) + calls.continued;
4350
+ this.state.updatedAt = new Date().toISOString();
4351
+ this._emit('state', this.getState());
4352
+ this._persist().catch(() => {});
4353
+ }
4354
+
4007
4355
  _recordCost(costUsd, stepKey = null) {
4008
4356
  if (!Number.isFinite(costUsd) || costUsd < 0) return;
4009
4357
  const key = stepKey
@@ -4133,6 +4481,7 @@ export class RunHarness extends EventEmitter {
4133
4481
  const rp = this.state.resumePoint;
4134
4482
  // ABOVE the `if (rp …)` — a pause counts whether or not the engine produced a resume point.
4135
4483
  this._metricsIv.pauses += 1;
4484
+ this._metricsIv.pausedAt = new Date().toISOString(); // resume() measures the parked time from here
4136
4485
  this._metricsIv.lastPauseReason = this.pauseReason || null;
4137
4486
  // _setPauseReason (run-harness.mjs:820) always stores a string or null.
4138
4487
  this._metricsIv.lastPauseDetail = this.pauseDetail == null ? null : String(this.pauseDetail).slice(0, 400);