@orion-agents/orion-code 0.1.0 → 0.1.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 (251) hide show
  1. package/README.md +107 -67
  2. package/README.zh-CN.md +192 -217
  3. package/dist/cli.js +23 -21
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/index.d.ts +1 -1
  6. package/dist/commands/index.d.ts.map +1 -1
  7. package/dist/commands/index.js +397 -212
  8. package/dist/commands/index.js.map +1 -1
  9. package/dist/commands/target-command.d.ts.map +1 -1
  10. package/dist/commands/target-command.js +43 -9
  11. package/dist/commands/target-command.js.map +1 -1
  12. package/dist/commands/types.d.ts +33 -0
  13. package/dist/commands/types.d.ts.map +1 -1
  14. package/dist/commands/types.js +5 -1
  15. package/dist/commands/types.js.map +1 -1
  16. package/dist/core/agent.d.ts.map +1 -1
  17. package/dist/core/agent.js +2 -2
  18. package/dist/core/agent.js.map +1 -1
  19. package/dist/framework/external-assertion.d.ts +65 -0
  20. package/dist/framework/external-assertion.d.ts.map +1 -0
  21. package/dist/framework/external-assertion.js +571 -0
  22. package/dist/framework/external-assertion.js.map +1 -0
  23. package/dist/framework/index.d.ts +6 -4
  24. package/dist/framework/index.d.ts.map +1 -1
  25. package/dist/framework/index.js +4 -1
  26. package/dist/framework/index.js.map +1 -1
  27. package/dist/framework/query.d.ts +18 -1
  28. package/dist/framework/query.d.ts.map +1 -1
  29. package/dist/framework/query.js +65 -28
  30. package/dist/framework/query.js.map +1 -1
  31. package/dist/framework/tool-scheduler.d.ts +2 -0
  32. package/dist/framework/tool-scheduler.d.ts.map +1 -1
  33. package/dist/framework/tool-scheduler.js +23 -9
  34. package/dist/framework/tool-scheduler.js.map +1 -1
  35. package/dist/framework/tool-serializer.d.ts.map +1 -1
  36. package/dist/framework/tool-serializer.js +20 -4
  37. package/dist/framework/tool-serializer.js.map +1 -1
  38. package/dist/framework/tool.d.ts +11 -5
  39. package/dist/framework/tool.d.ts.map +1 -1
  40. package/dist/framework/tool.js.map +1 -1
  41. package/dist/harness/context-harness.d.ts.map +1 -1
  42. package/dist/harness/context-harness.js +4 -3
  43. package/dist/harness/context-harness.js.map +1 -1
  44. package/dist/harness/drift-guard.d.ts.map +1 -1
  45. package/dist/harness/drift-guard.js +14 -5
  46. package/dist/harness/drift-guard.js.map +1 -1
  47. package/dist/harness/safety.d.ts.map +1 -1
  48. package/dist/harness/safety.js +19 -3
  49. package/dist/harness/safety.js.map +1 -1
  50. package/dist/init.d.ts +1 -1
  51. package/dist/init.d.ts.map +1 -1
  52. package/dist/init.js +115 -34
  53. package/dist/init.js.map +1 -1
  54. package/dist/ink-ui/screens/ReplScreen.d.ts.map +1 -1
  55. package/dist/ink-ui/screens/ReplScreen.js +4 -1
  56. package/dist/ink-ui/screens/ReplScreen.js.map +1 -1
  57. package/dist/memory/storage.d.ts.map +1 -1
  58. package/dist/memory/storage.js +1 -0
  59. package/dist/memory/storage.js.map +1 -1
  60. package/dist/memory/store.d.ts.map +1 -1
  61. package/dist/memory/store.js +11 -5
  62. package/dist/memory/store.js.map +1 -1
  63. package/dist/migration/command.d.ts.map +1 -1
  64. package/dist/migration/command.js +6 -1
  65. package/dist/migration/command.js.map +1 -1
  66. package/dist/migration/migrate.d.ts.map +1 -1
  67. package/dist/migration/migrate.js +4 -1
  68. package/dist/migration/migrate.js.map +1 -1
  69. package/dist/print-ui/launch.d.ts +4 -0
  70. package/dist/print-ui/launch.d.ts.map +1 -1
  71. package/dist/print-ui/launch.js +102 -9
  72. package/dist/print-ui/launch.js.map +1 -1
  73. package/dist/product/identity.d.ts +2 -2
  74. package/dist/product/identity.d.ts.map +1 -1
  75. package/dist/product/identity.js +2 -2
  76. package/dist/product/identity.js.map +1 -1
  77. package/dist/product/index.d.ts +2 -1
  78. package/dist/product/index.d.ts.map +1 -1
  79. package/dist/product/index.js +5 -1
  80. package/dist/product/index.js.map +1 -1
  81. package/dist/product/version.d.ts +12 -0
  82. package/dist/product/version.d.ts.map +1 -0
  83. package/dist/product/version.js +31 -0
  84. package/dist/product/version.js.map +1 -0
  85. package/dist/runtime/agent-runtime-controller.d.ts +49 -0
  86. package/dist/runtime/agent-runtime-controller.d.ts.map +1 -1
  87. package/dist/runtime/agent-runtime-controller.js +1048 -81
  88. package/dist/runtime/agent-runtime-controller.js.map +1 -1
  89. package/dist/runtime/agent-runtime-protocol.d.ts +14 -2
  90. package/dist/runtime/agent-runtime-protocol.d.ts.map +1 -1
  91. package/dist/runtime/agent-runtime-protocol.js +18 -0
  92. package/dist/runtime/agent-runtime-protocol.js.map +1 -1
  93. package/dist/runtime/chat-controller.d.ts +6 -0
  94. package/dist/runtime/chat-controller.d.ts.map +1 -1
  95. package/dist/runtime/chat-controller.js +226 -20
  96. package/dist/runtime/chat-controller.js.map +1 -1
  97. package/dist/runtime/goals/accounting.d.ts +28 -0
  98. package/dist/runtime/goals/accounting.d.ts.map +1 -1
  99. package/dist/runtime/goals/accounting.js +69 -0
  100. package/dist/runtime/goals/accounting.js.map +1 -1
  101. package/dist/runtime/goals/completion-audit.d.ts +13 -8
  102. package/dist/runtime/goals/completion-audit.d.ts.map +1 -1
  103. package/dist/runtime/goals/completion-audit.js +598 -16
  104. package/dist/runtime/goals/completion-audit.js.map +1 -1
  105. package/dist/runtime/goals/coordinator.d.ts +44 -5
  106. package/dist/runtime/goals/coordinator.d.ts.map +1 -1
  107. package/dist/runtime/goals/coordinator.js +1124 -58
  108. package/dist/runtime/goals/coordinator.js.map +1 -1
  109. package/dist/runtime/goals/evidence.d.ts +37 -0
  110. package/dist/runtime/goals/evidence.d.ts.map +1 -0
  111. package/dist/runtime/goals/evidence.js +300 -0
  112. package/dist/runtime/goals/evidence.js.map +1 -0
  113. package/dist/runtime/goals/presentation.d.ts +4 -0
  114. package/dist/runtime/goals/presentation.d.ts.map +1 -0
  115. package/dist/runtime/goals/presentation.js +50 -0
  116. package/dist/runtime/goals/presentation.js.map +1 -0
  117. package/dist/runtime/goals/prompt.d.ts.map +1 -1
  118. package/dist/runtime/goals/prompt.js +38 -8
  119. package/dist/runtime/goals/prompt.js.map +1 -1
  120. package/dist/runtime/goals/tools.d.ts +18 -1
  121. package/dist/runtime/goals/tools.d.ts.map +1 -1
  122. package/dist/runtime/goals/tools.js +308 -28
  123. package/dist/runtime/goals/tools.js.map +1 -1
  124. package/dist/runtime/goals/types.d.ts +241 -7
  125. package/dist/runtime/goals/types.d.ts.map +1 -1
  126. package/dist/runtime/goals/types.js +8 -2
  127. package/dist/runtime/goals/types.js.map +1 -1
  128. package/dist/runtime/subagents/child-executor-guard.d.ts +12 -4
  129. package/dist/runtime/subagents/child-executor-guard.d.ts.map +1 -1
  130. package/dist/runtime/subagents/child-executor-guard.js +397 -61
  131. package/dist/runtime/subagents/child-executor-guard.js.map +1 -1
  132. package/dist/runtime/subagents/presets.d.ts +4 -5
  133. package/dist/runtime/subagents/presets.d.ts.map +1 -1
  134. package/dist/runtime/subagents/presets.js +18 -33
  135. package/dist/runtime/subagents/presets.js.map +1 -1
  136. package/dist/runtime/subagents/production.d.ts +3 -1
  137. package/dist/runtime/subagents/production.d.ts.map +1 -1
  138. package/dist/runtime/subagents/production.js +37 -7
  139. package/dist/runtime/subagents/production.js.map +1 -1
  140. package/dist/runtime/subagents/runner.d.ts.map +1 -1
  141. package/dist/runtime/subagents/runner.js +24 -3
  142. package/dist/runtime/subagents/runner.js.map +1 -1
  143. package/dist/runtime/subagents/runtime-integration.d.ts +3 -0
  144. package/dist/runtime/subagents/runtime-integration.d.ts.map +1 -1
  145. package/dist/runtime/subagents/runtime-integration.js +18 -7
  146. package/dist/runtime/subagents/runtime-integration.js.map +1 -1
  147. package/dist/runtime/subagents/supervisor.d.ts +4 -4
  148. package/dist/runtime/subagents/supervisor.d.ts.map +1 -1
  149. package/dist/runtime/subagents/supervisor.js +39 -19
  150. package/dist/runtime/subagents/supervisor.js.map +1 -1
  151. package/dist/runtime/subagents/types.d.ts +7 -0
  152. package/dist/runtime/subagents/types.d.ts.map +1 -1
  153. package/dist/runtime/subagents/types.js +12 -1
  154. package/dist/runtime/subagents/types.js.map +1 -1
  155. package/dist/runtime/ui-events.d.ts +10 -0
  156. package/dist/runtime/ui-events.d.ts.map +1 -1
  157. package/dist/runtime/ui-events.js +5 -5
  158. package/dist/runtime/ui-events.js.map +1 -1
  159. package/dist/runtime/ui-view-model.d.ts +3 -3
  160. package/dist/runtime/ui-view-model.d.ts.map +1 -1
  161. package/dist/runtime/ui-view-model.js +11 -4
  162. package/dist/runtime/ui-view-model.js.map +1 -1
  163. package/dist/services/config.d.ts +18 -7
  164. package/dist/services/config.d.ts.map +1 -1
  165. package/dist/services/config.js +79 -57
  166. package/dist/services/config.js.map +1 -1
  167. package/dist/services/doctor.d.ts.map +1 -1
  168. package/dist/services/doctor.js +164 -18
  169. package/dist/services/doctor.js.map +1 -1
  170. package/dist/services/global-config.d.ts +1 -1
  171. package/dist/services/global-config.d.ts.map +1 -1
  172. package/dist/services/goal-storage.d.ts +9 -4
  173. package/dist/services/goal-storage.d.ts.map +1 -1
  174. package/dist/services/goal-storage.js +1113 -75
  175. package/dist/services/goal-storage.js.map +1 -1
  176. package/dist/services/llm.d.ts +19 -0
  177. package/dist/services/llm.d.ts.map +1 -1
  178. package/dist/services/llm.js +153 -20
  179. package/dist/services/llm.js.map +1 -1
  180. package/dist/services/model-registry.d.ts +1 -1
  181. package/dist/services/model-registry.d.ts.map +1 -1
  182. package/dist/services/model-registry.js +69 -19
  183. package/dist/services/model-registry.js.map +1 -1
  184. package/dist/services/provider-resilience/coordinator.d.ts +2 -1
  185. package/dist/services/provider-resilience/coordinator.d.ts.map +1 -1
  186. package/dist/services/provider-resilience/coordinator.js +7 -6
  187. package/dist/services/provider-resilience/coordinator.js.map +1 -1
  188. package/dist/services/provider-resilience/error-classifier.d.ts.map +1 -1
  189. package/dist/services/provider-resilience/error-classifier.js +67 -17
  190. package/dist/services/provider-resilience/error-classifier.js.map +1 -1
  191. package/dist/services/provider-resilience/types.d.ts +1 -1
  192. package/dist/services/provider-resilience/types.d.ts.map +1 -1
  193. package/dist/services/provider-resilience/types.js.map +1 -1
  194. package/dist/services/redaction.d.ts.map +1 -1
  195. package/dist/services/redaction.js +9 -1
  196. package/dist/services/redaction.js.map +1 -1
  197. package/dist/services/session-storage.d.ts +12 -1
  198. package/dist/services/session-storage.d.ts.map +1 -1
  199. package/dist/services/session-storage.js +23 -7
  200. package/dist/services/session-storage.js.map +1 -1
  201. package/dist/services/storage-maintenance.d.ts +73 -3
  202. package/dist/services/storage-maintenance.d.ts.map +1 -1
  203. package/dist/services/storage-maintenance.js +771 -104
  204. package/dist/services/storage-maintenance.js.map +1 -1
  205. package/dist/services/task-manager.d.ts.map +1 -1
  206. package/dist/services/task-manager.js +2 -2
  207. package/dist/services/task-manager.js.map +1 -1
  208. package/dist/services/web-search-adapters.d.ts.map +1 -1
  209. package/dist/services/web-search-adapters.js +89 -49
  210. package/dist/services/web-search-adapters.js.map +1 -1
  211. package/dist/services/web-search-mcp.d.ts.map +1 -1
  212. package/dist/services/web-search-mcp.js +34 -37
  213. package/dist/services/web-search-mcp.js.map +1 -1
  214. package/dist/services/workspace-state.d.ts +4 -0
  215. package/dist/services/workspace-state.d.ts.map +1 -1
  216. package/dist/services/workspace-state.js +30 -9
  217. package/dist/services/workspace-state.js.map +1 -1
  218. package/dist/terminal-ui/completion.js +1 -1
  219. package/dist/terminal-ui/completion.js.map +1 -1
  220. package/dist/terminal-ui/launch.d.ts +3 -0
  221. package/dist/terminal-ui/launch.d.ts.map +1 -1
  222. package/dist/terminal-ui/launch.js +26 -98
  223. package/dist/terminal-ui/launch.js.map +1 -1
  224. package/dist/tools/git.d.ts.map +1 -1
  225. package/dist/tools/git.js +275 -27
  226. package/dist/tools/git.js.map +1 -1
  227. package/dist/tools/index.d.ts +2 -0
  228. package/dist/tools/index.d.ts.map +1 -1
  229. package/dist/tools/index.js +29 -10
  230. package/dist/tools/index.js.map +1 -1
  231. package/dist/tools/mcp.d.ts.map +1 -1
  232. package/dist/tools/mcp.js +20 -12
  233. package/dist/tools/mcp.js.map +1 -1
  234. package/dist/tools/web.d.ts.map +1 -1
  235. package/dist/tools/web.js +38 -14
  236. package/dist/tools/web.js.map +1 -1
  237. package/dist/tui-ui/layout.d.ts.map +1 -1
  238. package/dist/tui-ui/layout.js +36 -23
  239. package/dist/tui-ui/layout.js.map +1 -1
  240. package/dist/tui-ui/pickers.d.ts.map +1 -1
  241. package/dist/tui-ui/pickers.js +5 -4
  242. package/dist/tui-ui/pickers.js.map +1 -1
  243. package/dist/tui-ui/runner.d.ts +1 -0
  244. package/dist/tui-ui/runner.d.ts.map +1 -1
  245. package/dist/tui-ui/runner.js +15 -17
  246. package/dist/tui-ui/runner.js.map +1 -1
  247. package/dist/tui-ui/state.d.ts +6 -0
  248. package/dist/tui-ui/state.d.ts.map +1 -1
  249. package/dist/tui-ui/state.js +61 -30
  250. package/dist/tui-ui/state.js.map +1 -1
  251. package/package.json +14 -9
@@ -8,31 +8,442 @@
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.GoalCoordinator = void 0;
11
+ exports.goalRequiresBoundaryConfirmation = goalRequiresBoundaryConfirmation;
12
+ const crypto_1 = require("crypto");
13
+ const fs_1 = require("fs");
14
+ const path_1 = require("path");
11
15
  const types_1 = require("./types");
12
16
  const goal_storage_1 = require("../../services/goal-storage");
13
17
  const accounting_1 = require("./accounting");
14
18
  const completion_audit_1 = require("./completion-audit");
19
+ const redaction_1 = require("../../services/redaction");
20
+ const config_dir_1 = require("../../services/config-dir");
21
+ const MAX_EVIDENCE_LEDGER_RECORDS = 500;
22
+ const MAX_EVIDENCE_TRUNCATION_COUNT = 1000000000;
23
+ function addSafeInteger(value, increment, field) {
24
+ if (!Number.isSafeInteger(value) ||
25
+ value < 0 ||
26
+ !Number.isSafeInteger(increment) ||
27
+ increment < 0 ||
28
+ value > Number.MAX_SAFE_INTEGER - increment) {
29
+ throw new RangeError(`${field} would exceed the non-negative safe integer range`);
30
+ }
31
+ return value + increment;
32
+ }
33
+ function boundedTruncationCount(current, increment) {
34
+ return Math.min(MAX_EVIDENCE_TRUNCATION_COUNT, current + increment);
35
+ }
36
+ function appendBoundedEvidence(existing, incoming, objectiveRevision, previousTruncation) {
37
+ const all = [...existing, ...incoming];
38
+ const carried = previousTruncation?.objectiveRevision === objectiveRevision ? previousTruncation : undefined;
39
+ if (all.length <= MAX_EVIDENCE_LEDGER_RECORDS) {
40
+ return { ledger: all, truncation: carried };
41
+ }
42
+ const retained = new Set();
43
+ const retainNewest = (records) => {
44
+ for (let index = records.length - 1; index >= 0; index -= 1) {
45
+ if (retained.size >= MAX_EVIDENCE_LEDGER_RECORDS)
46
+ return;
47
+ retained.add(records[index]);
48
+ }
49
+ };
50
+ // Negative evidence for the current objective epoch is safety-critical. Keep
51
+ // it ahead of passing and old-epoch records; if more than the entire bounded
52
+ // ledger is negative, the truncation summary below makes completion fail
53
+ // closed even after later passing evidence arrives.
54
+ retainNewest(all.filter(record => record.objectiveRevision === objectiveRevision && record.result !== 'passed'));
55
+ retainNewest(all.filter(record => record.objectiveRevision === objectiveRevision));
56
+ retainNewest(all);
57
+ const ledger = all.filter(record => retained.has(record));
58
+ const dropped = all.filter(record => record.objectiveRevision === objectiveRevision && !retained.has(record));
59
+ if (dropped.length === 0)
60
+ return { ledger, truncation: carried };
61
+ const increment = (result) => dropped.filter(record => record.result === result).length;
62
+ return {
63
+ ledger,
64
+ truncation: {
65
+ objectiveRevision,
66
+ droppedPassed: boundedTruncationCount(carried?.droppedPassed ?? 0, increment('passed')),
67
+ droppedFailed: boundedTruncationCount(carried?.droppedFailed ?? 0, increment('failed')),
68
+ droppedInconclusive: boundedTruncationCount(carried?.droppedInconclusive ?? 0, increment('inconclusive')),
69
+ },
70
+ };
71
+ }
72
+ function finalEvidenceReceipt(record) {
73
+ return {
74
+ evidenceId: record.id,
75
+ kind: record.kind,
76
+ provenance: record.kind === 'user'
77
+ ? 'user_acceptance'
78
+ : record.kind === 'external'
79
+ ? 'external'
80
+ : 'runtime_automatic',
81
+ result: record.result,
82
+ subject: record.subject,
83
+ };
84
+ }
85
+ function noProgressTurnSummary(outcome) {
86
+ const evidence = outcome.evidenceRecords ?? [];
87
+ return {
88
+ turnId: (0, redaction_1.redactTraceText)(String(outcome.turnId)).slice(0, 80),
89
+ endedAt: outcome.endedAt,
90
+ finishReason: (0, redaction_1.redactTraceText)(outcome.finishReason).slice(0, 80),
91
+ passedEvidence: evidence.filter(record => record.result === 'passed').length,
92
+ failedEvidence: evidence.filter(record => record.result === 'failed').length,
93
+ inconclusiveEvidence: evidence.filter(record => record.result === 'inconclusive').length,
94
+ planUpdateProposed: Boolean(outcome.pendingPlanUpdate),
95
+ blockerCategory: outcome.blocker?.category,
96
+ };
97
+ }
98
+ function formatNoProgressTurns(turns) {
99
+ return turns
100
+ .map(turn => `${turn.turnId} finish=${turn.finishReason} evidence=${turn.passedEvidence}p/${turn.failedEvidence}f/${turn.inconclusiveEvidence}i plan=${turn.planUpdateProposed ? 'no-change' : 'none'}${turn.blockerCategory ? ` blocker=${turn.blockerCategory}` : ''}`)
101
+ .join('; ');
102
+ }
103
+ /**
104
+ * Build a minimal pending contract from an objective string. Used at creation
105
+ * and at load time to normalize v0.1.1 sidecars (which have no contract).
106
+ *
107
+ * The primary criterion is `source=user` because it is the user's verbatim
108
+ * objective, with a stable id so repeated
109
+ * normalizations of the same goal produce the same id (no churn on reload).
110
+ * User-supplied criteria (`source=user`) are added via a separate path.
111
+ */
112
+ function minimalContract(objective, now) {
113
+ return {
114
+ originalObjective: objective,
115
+ objectiveRevision: 0,
116
+ constraints: [],
117
+ successCriteria: [
118
+ {
119
+ id: 'criterion:primary',
120
+ statement: objective,
121
+ source: 'user',
122
+ status: 'pending',
123
+ // External completion actions cannot be proven by local process output.
124
+ // Other primary criteria accept local runtime evidence; model natural
125
+ // language is never an accepted kind.
126
+ requiredEvidenceKinds: (0, completion_audit_1.criterionRequiresExternalCompletionEvidence)(objective)
127
+ ? ['external', 'user']
128
+ : ['test', 'build', 'file', 'runtime'],
129
+ evidenceRefs: [],
130
+ },
131
+ ],
132
+ planSnapshot: {
133
+ revision: 0,
134
+ phase: 'initial',
135
+ steps: [],
136
+ updatedAt: now,
137
+ },
138
+ };
139
+ }
140
+ const CONTRACT_EVIDENCE_KINDS = new Set([
141
+ 'test',
142
+ 'build',
143
+ 'lint',
144
+ 'file',
145
+ 'runtime',
146
+ 'external',
147
+ 'user',
148
+ ]);
149
+ const BOUNDARY_CONFIRMATION_PATTERN = /\b(publish|deploy|delete|erase|remove|drop|migrate|send|email|message|post|pay|purchase|merge|push|tag|overwrite|destroy|revoke|transfer|upload)\b|发布|部署|删除|清空|迁移|发送|付款|购买|合并|推送|打标签|覆盖|销毁|撤销|转账|上传/i;
150
+ /** High-impact external or destructive goals must not auto-start from model-created text. */
151
+ function goalRequiresBoundaryConfirmation(objective, input = {}) {
152
+ return [
153
+ objective,
154
+ ...(input.constraints ?? []),
155
+ ...(input.successCriteria ?? []).map(criterion => criterion.statement),
156
+ ].some(statement => (0, completion_audit_1.hasUnnegatedActionMatch)(statement, BOUNDARY_CONFIRMATION_PATTERN));
157
+ }
158
+ function boundaryConfirmationState(objectiveRevision, now) {
159
+ return {
160
+ status: 'paused',
161
+ stopReason: {
162
+ kind: 'user',
163
+ message: 'Boundary confirmation required for an external, destructive, or high-impact Goal. Review the objective and constraints, then use /target resume to confirm.',
164
+ at: now,
165
+ },
166
+ boundaryConfirmation: {
167
+ requiredAt: now,
168
+ reason: 'external_destructive_or_high_impact',
169
+ objectiveRevision,
170
+ },
171
+ };
172
+ }
173
+ function pauseForBoundaryConfirmation(goal, now) {
174
+ return {
175
+ ...goal,
176
+ ...boundaryConfirmationState(goal.contract?.objectiveRevision ?? 0, now),
177
+ updatedAt: now,
178
+ };
179
+ }
180
+ function creationContract(objective, now, input) {
181
+ const constraints = input.constraints ?? [];
182
+ const successCriteria = input.successCriteria ?? [];
183
+ if (constraints.length > 50 || successCriteria.length > 50)
184
+ return null;
185
+ if (constraints.some(statement => !statement.trim()) ||
186
+ successCriteria.some(criterion => !criterion.statement.trim() ||
187
+ criterion.requiredEvidenceKinds.length === 0 ||
188
+ criterion.requiredEvidenceKinds.some(kind => !CONTRACT_EVIDENCE_KINDS.has(kind)))) {
189
+ return null;
190
+ }
191
+ const contract = minimalContract(objective, now);
192
+ contract.constraints = constraints.map((statement, index) => ({
193
+ id: `constraint:user:${index + 1}`,
194
+ statement: statement.trim(),
195
+ source: 'user',
196
+ }));
197
+ contract.successCriteria.push(...successCriteria.map((criterion, index) => ({
198
+ id: `criterion:user:${index + 1}`,
199
+ statement: criterion.statement.trim(),
200
+ source: 'user',
201
+ status: 'pending',
202
+ requiredEvidenceKinds: [...new Set(criterion.requiredEvidenceKinds)],
203
+ evidenceRefs: [],
204
+ })));
205
+ return contract;
206
+ }
207
+ function compactPlanValue(value) {
208
+ const compact = value?.replace(/\s+/gu, ' ').trim();
209
+ return compact || undefined;
210
+ }
211
+ function stablePlanStepKey(value) {
212
+ return value
213
+ .normalize('NFKC')
214
+ .toLowerCase()
215
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
216
+ .trim()
217
+ .replace(/\s+/gu, ' ');
218
+ }
219
+ const OBJECTIVE_TRACE_STOP_WORDS = new Set([
220
+ 'add',
221
+ 'build',
222
+ 'check',
223
+ 'code',
224
+ 'complete',
225
+ 'create',
226
+ 'goal',
227
+ 'implement',
228
+ 'pass',
229
+ 'run',
230
+ 'test',
231
+ 'update',
232
+ 'verify',
233
+ ]);
234
+ function objectiveTraceTokens(value) {
235
+ const tokens = new Set();
236
+ for (const raw of value
237
+ .normalize('NFKC')
238
+ .toLowerCase()
239
+ .match(/[\p{L}\p{N}_-]+/gu) ?? []) {
240
+ if (raw.length >= 3 && !OBJECTIVE_TRACE_STOP_WORDS.has(raw))
241
+ tokens.add(raw);
242
+ if (/\p{Script=Han}/u.test(raw)) {
243
+ const characters = Array.from(raw);
244
+ for (let index = 0; index < characters.length - 1; index += 1) {
245
+ tokens.add(`${characters[index]}${characters[index + 1]}`);
246
+ }
247
+ }
248
+ }
249
+ return tokens;
250
+ }
251
+ function criterionTracesToObjective(statement, contract) {
252
+ const objectiveTokens = objectiveTraceTokens(contract.originalObjective);
253
+ if (objectiveTokens.size === 0)
254
+ return false;
255
+ const criterionTokens = objectiveTraceTokens(statement);
256
+ return [...criterionTokens].some(token => objectiveTokens.has(token));
257
+ }
258
+ function substantivePlanProgress(contract, update, derivedCriteria) {
259
+ const current = contract.planSnapshot;
260
+ const completedStableStep = Boolean(current?.steps.some(step => {
261
+ if (step.done)
262
+ return false;
263
+ const key = stablePlanStepKey(step.description);
264
+ return Boolean(key && update.steps.some(next => next.done && stablePlanStepKey(next.description) === key));
265
+ }));
266
+ const traceableCriterionAdded = derivedCriteria.some(criterion => criterionTracesToObjective(criterion.statement, contract));
267
+ return completedStableStep || traceableCriterionAdded;
268
+ }
269
+ function stableEvidenceSource(sourceRef) {
270
+ const tool = /^tool:[^:]+:(.+)$/iu.exec(sourceRef.trim());
271
+ return compactPlanValue(tool ? `tool:${tool[1]}` : sourceRef)?.toLowerCase() ?? 'unknown';
272
+ }
273
+ function normalizedEvidenceSubject(subject) {
274
+ return (0, redaction_1.redactTraceText)(subject)
275
+ .normalize('NFKC')
276
+ .toLowerCase()
277
+ .replace(/[^\p{L}\p{N}_-]+/gu, ' ')
278
+ .trim()
279
+ .replace(/\s+/gu, ' ');
280
+ }
281
+ function evidenceProgressKeys(incoming, contract) {
282
+ const keys = new Set();
283
+ for (const record of incoming) {
284
+ if (record.result !== 'passed')
285
+ continue;
286
+ const stableSource = stableEvidenceSource(record.sourceRef);
287
+ const normalizedSubject = normalizedEvidenceSubject(record.subject);
288
+ const evidenceTokens = objectiveTraceTokens(`${normalizedSubject} ${stableSource}`);
289
+ for (const criterion of contract.successCriteria) {
290
+ if (!criterion.requiredEvidenceKinds.includes(record.kind))
291
+ continue;
292
+ const directCriterionReference = normalizedSubject.includes(criterion.id.toLowerCase()) ||
293
+ stableSource.includes(criterion.id.toLowerCase());
294
+ const criterionTokens = objectiveTraceTokens(criterion.statement);
295
+ const semanticallyRelated = [...criterionTokens].some(token => evidenceTokens.has(token));
296
+ if (!directCriterionReference && !semanticallyRelated)
297
+ continue;
298
+ const materialIdentity = [
299
+ record.objectiveRevision,
300
+ criterion.id,
301
+ record.kind,
302
+ stableSource,
303
+ normalizedSubject,
304
+ record.workspaceFingerprint ?? 'workspace:none',
305
+ ].join('\u0000');
306
+ keys.add((0, crypto_1.createHash)('sha256').update(materialIdentity).digest('hex'));
307
+ }
308
+ }
309
+ return [...keys];
310
+ }
311
+ function criterionStateAdvanced(before, after, evidenceLedger) {
312
+ if (!after)
313
+ return false;
314
+ const passedEvidence = new Set(evidenceLedger.filter(record => record.result === 'passed').map(record => record.id));
315
+ const priorById = new Map(before.successCriteria.map(criterion => [criterion.id, criterion]));
316
+ return after.successCriteria.some(criterion => {
317
+ const prior = priorById.get(criterion.id);
318
+ if (!prior)
319
+ return false;
320
+ if (prior.status !== 'passed' && criterion.status === 'passed')
321
+ return true;
322
+ const priorRefs = new Set(prior.evidenceRefs);
323
+ return criterion.evidenceRefs.some(ref => !priorRefs.has(ref) && passedEvidence.has(ref));
324
+ });
325
+ }
326
+ function planContentChanged(contract, update) {
327
+ const current = contract.planSnapshot;
328
+ if (!current)
329
+ return true;
330
+ if (compactPlanValue(current.phase) !== compactPlanValue(update.phase))
331
+ return true;
332
+ if (compactPlanValue(current.nextAction) !== compactPlanValue(update.nextAction))
333
+ return true;
334
+ if (current.steps.length !== update.steps.length)
335
+ return true;
336
+ return current.steps.some((step, index) => {
337
+ const next = update.steps[index];
338
+ return (compactPlanValue(step.description) !== compactPlanValue(next.description) ||
339
+ step.done !== next.done);
340
+ });
341
+ }
342
+ function newDerivedCriteria(contract, update, planRevision) {
343
+ const existingStatements = new Set(contract.successCriteria.map(criterion => criterion.statement.trim().toLowerCase()));
344
+ return update.derivedCriteria
345
+ .filter(item => !existingStatements.has(item.statement.trim().toLowerCase()))
346
+ .map((item, index) => ({
347
+ id: `criterion:derived:${planRevision}:${index + 1}`,
348
+ statement: item.statement,
349
+ source: 'derived',
350
+ status: 'pending',
351
+ requiredEvidenceKinds: item.requiredEvidenceKinds,
352
+ evidenceRefs: [],
353
+ }));
354
+ }
355
+ /** Ensure a loaded goal carries a contract; normalize v0.1.1 sidecars. */
356
+ function ensureContract(goal) {
357
+ if (goal.contract)
358
+ return goal;
359
+ const contract = minimalContract(goal.objective, goal.updatedAt || goal.createdAt);
360
+ const normalizedLegacy = {
361
+ ...goal,
362
+ contract,
363
+ completedAt: undefined,
364
+ completionAudit: undefined,
365
+ };
366
+ if (goal.status === 'active') {
367
+ return {
368
+ ...normalizedLegacy,
369
+ blocker: undefined,
370
+ stopReason: undefined,
371
+ };
372
+ }
373
+ if (goal.status === 'blocked' && (!goal.blocker || goal.stopReason?.kind !== 'blocked')) {
374
+ return {
375
+ ...normalizedLegacy,
376
+ status: 'paused',
377
+ blocker: undefined,
378
+ stopReason: {
379
+ kind: 'user',
380
+ message: 'Legacy blocked state requires v0.1.2 blocker re-verification.',
381
+ at: Date.now(),
382
+ },
383
+ };
384
+ }
385
+ if (goal.status !== 'complete')
386
+ return normalizedLegacy;
387
+ const now = Date.now();
388
+ return {
389
+ ...normalizedLegacy,
390
+ status: 'paused',
391
+ stopReason: {
392
+ kind: 'user',
393
+ message: 'Legacy completion requires v0.1.2 evidence re-verification.',
394
+ at: now,
395
+ },
396
+ };
397
+ }
15
398
  class GoalCoordinator {
16
399
  constructor(projectPath, sessionId) {
17
400
  this.projectPath = projectPath;
18
401
  this.sessionId = sessionId;
19
402
  this.state = { goal: null, generation: 0, continuationDeferred: false };
403
+ this.loadIssue = null;
404
+ /** Runtime-only dedupe for interrupted stale turns; in-flight turns do not survive restart. */
405
+ this.accountedStaleTurnKeys = new Set();
406
+ /** Last disk-authoritative state, used when both a write and recovery read fail. */
407
+ this.persistedGoalSnapshot = null;
20
408
  }
21
409
  // ==========================================================================
22
410
  // Queries
23
411
  // ==========================================================================
24
- get goal() { return this.state.goal; }
25
- get generation() { return this.state.generation; }
26
- get isActive() { return this.state.goal?.status === 'active'; }
412
+ get goal() {
413
+ return this.state.goal;
414
+ }
415
+ get generation() {
416
+ return this.state.generation;
417
+ }
418
+ get boundSessionId() {
419
+ return this.sessionId;
420
+ }
421
+ get isActive() {
422
+ return this.state.goal?.status === 'active';
423
+ }
27
424
  get canContinue() {
28
- return this.isActive
29
- && !this.state.continuationDeferred
30
- && !(0, accounting_1.isBudgetExceeded)(this.state.goal.tokensUsed, this.state.goal.tokenBudget);
425
+ return (this.isActive &&
426
+ !this.state.continuationDeferred &&
427
+ !(0, accounting_1.isBudgetExceeded)(this.state.goal.tokensUsed, this.state.goal.tokenBudget));
428
+ }
429
+ get lastLoadIssue() {
430
+ return this.loadIssue;
431
+ }
432
+ addGoalInteger(value, increment, field) {
433
+ try {
434
+ return addSafeInteger(value, increment, field);
435
+ }
436
+ catch (error) {
437
+ const message = error instanceof Error ? error.message : String(error);
438
+ this.failClosedAfterPersistenceError(message);
439
+ throw error;
440
+ }
31
441
  }
32
442
  snapshot() {
33
443
  const g = this.state.goal;
34
444
  if (!g)
35
445
  return null;
446
+ const criteria = g.contract?.successCriteria ?? [];
36
447
  return {
37
448
  goalId: g.goalId,
38
449
  revision: g.revision,
@@ -44,37 +455,115 @@ class GoalCoordinator {
44
455
  continuationCount: g.continuationCount,
45
456
  updatedAt: g.updatedAt,
46
457
  stopReason: g.stopReason?.message,
458
+ criteria: {
459
+ passed: criteria.filter(criterion => criterion.status === 'passed').length,
460
+ failed: criteria.filter(criterion => criterion.status === 'failed').length,
461
+ stale: criteria.filter(criterion => criterion.status === 'stale').length,
462
+ total: criteria.length,
463
+ },
464
+ planRevision: g.contract?.planSnapshot?.revision,
465
+ planPhase: g.contract?.planSnapshot?.phase,
466
+ nextAction: g.contract?.planSnapshot?.nextAction,
467
+ auditRemaining: g.completionAudit?.remainingRequirements,
47
468
  };
48
469
  }
49
470
  // ==========================================================================
50
471
  // Lifecycle
51
472
  // ==========================================================================
52
- load() {
473
+ load(recoverActive = false) {
53
474
  const result = (0, goal_storage_1.loadGoal)(this.projectPath, this.sessionId);
54
475
  if (result.ok) {
55
- this.state.goal = result.value;
56
- this.state.generation = 0;
476
+ this.loadIssue = null;
477
+ // v0.1.2: normalize v0.1.1 sidecars (no contract) into a minimal pending
478
+ // contract. This is in-memory only; it does not rewrite the sidecar
479
+ // until the next real state update.
480
+ this.state.goal = ensureContract(result.value);
481
+ this.persistedGoalSnapshot = structuredClone(this.state.goal);
482
+ // Every reload replaces the in-memory authority, even if the disk bytes
483
+ // are unchanged. Advance the runtime epoch so pre-reload outcomes cannot
484
+ // finalize or account against the newly loaded state.
485
+ this.state.generation += 1;
57
486
  this.state.continuationDeferred = false;
487
+ if (recoverActive && this.state.goal.status === 'active') {
488
+ const loadedGoal = this.state.goal;
489
+ const lockPath = (0, path_1.join)((0, config_dir_1.getProjectSessionsDir)(this.projectPath), `${this.sessionId}.goal.json.lock`);
490
+ if ((0, fs_1.existsSync)(lockPath)) {
491
+ const message = `Restart recovery could not persist a paused Goal because storage is temporarily locked at ${lockPath}. No continuation was started; resolve the lock, then use /target resume.`;
492
+ this.loadIssue = { code: 'io_error', message };
493
+ this.failClosedAfterPersistenceError(message);
494
+ return true;
495
+ }
496
+ try {
497
+ this.state.goal = {
498
+ ...loadedGoal,
499
+ status: 'paused',
500
+ revision: addSafeInteger(loadedGoal.revision, 1, 'revision'),
501
+ updatedAt: Date.now(),
502
+ stopReason: {
503
+ kind: 'user',
504
+ message: 'Recovered after restart. Use /target resume to continue.',
505
+ at: Date.now(),
506
+ },
507
+ };
508
+ this.state.continuationDeferred = true;
509
+ this.persist();
510
+ }
511
+ catch (error) {
512
+ const detail = (0, redaction_1.redactTraceText)(error instanceof Error ? error.message : String(error));
513
+ const message = `Restart recovery could not persist a paused Goal. No continuation was started; resolve the storage error, then use /target resume. ${detail}`;
514
+ this.loadIssue = { code: 'io_error', message };
515
+ this.failClosedAfterPersistenceError(message);
516
+ }
517
+ }
58
518
  return true;
59
519
  }
520
+ this.loadIssue = ['corrupt', 'metadata_mismatch', 'incompatible_schema', 'io_error'].includes(result.error)
521
+ ? {
522
+ code: result.error,
523
+ message: result.message,
524
+ }
525
+ : null;
526
+ // A failed repeat load makes the disk state authoritative and the previous
527
+ // in-memory Goal unsafe to continue. Invalidate in-flight work and discard
528
+ // both cached copies without attempting to write the stale Goal back.
529
+ this.state.generation += 1;
530
+ this.state.continuationDeferred = true;
531
+ this.state.goal = null;
532
+ this.persistedGoalSnapshot = null;
60
533
  return false;
61
534
  }
62
- create(objective) {
535
+ create(objective, contractInput = {}) {
63
536
  if (this.state.goal && !types_1.GOAL_TERMINAL_STATES.has(this.state.goal.status)) {
64
- return { ok: false, error: 'An active goal already exists. Use /target replace to change it.' };
537
+ return {
538
+ ok: false,
539
+ error: 'An active goal already exists. Use /target replace to change it.',
540
+ };
65
541
  }
66
542
  if (!objective.trim()) {
67
543
  return { ok: false, error: 'Objective cannot be empty.' };
68
544
  }
69
545
  if (objective.length > types_1.GOAL_INVARIANTS.maxObjectiveChars) {
70
- return { ok: false, error: `Objective too long (max ${types_1.GOAL_INVARIANTS.maxObjectiveChars} chars).` };
546
+ return {
547
+ ok: false,
548
+ error: `Objective too long (max ${types_1.GOAL_INVARIANTS.maxObjectiveChars} chars).`,
549
+ };
550
+ }
551
+ const now = Date.now();
552
+ const contract = creationContract(objective.trim(), now, contractInput);
553
+ if (!contract) {
554
+ return { ok: false, error: 'Goal constraints or success criteria are invalid.' };
71
555
  }
72
- const result = (0, goal_storage_1.createGoal)(this.projectPath, this.sessionId, objective);
556
+ const requiresBoundaryConfirmation = goalRequiresBoundaryConfirmation(objective, contractInput);
557
+ const result = (0, goal_storage_1.createGoal)(this.projectPath, this.sessionId, objective, contract, this.state.goal?.revision, requiresBoundaryConfirmation
558
+ ? boundaryConfirmationState(contract.objectiveRevision, now)
559
+ : undefined);
73
560
  if (!result.ok)
74
561
  return { ok: false, error: result.message };
75
- this.state.goal = result.value;
562
+ const created = result.value;
563
+ this.state.goal = created;
564
+ this.persistedGoalSnapshot = structuredClone(created);
76
565
  this.state.generation += 1;
77
- this.state.continuationDeferred = false;
566
+ this.state.continuationDeferred = created.status !== 'active';
78
567
  return { ok: true };
79
568
  }
80
569
  pause() {
@@ -83,7 +572,7 @@ class GoalCoordinator {
83
572
  this.state.goal = {
84
573
  ...this.state.goal,
85
574
  status: 'paused',
86
- revision: this.state.goal.revision + 1,
575
+ revision: this.addGoalInteger(this.state.goal.revision, 1, 'revision'),
87
576
  updatedAt: Date.now(),
88
577
  stopReason: { kind: 'user', message: 'Paused by user.', at: Date.now() },
89
578
  };
@@ -91,32 +580,169 @@ class GoalCoordinator {
91
580
  this.state.continuationDeferred = true;
92
581
  return true;
93
582
  }
94
- resume() {
583
+ resume(options = {}) {
95
584
  if (!this.state.goal || !types_1.GOAL_USER_RECOVERABLE_STATES.has(this.state.goal.status))
96
585
  return false;
586
+ const pendingBoundary = this.state.goal.boundaryConfirmation &&
587
+ this.state.goal.boundaryConfirmation.confirmedAt === undefined;
588
+ if (pendingBoundary &&
589
+ (!options.confirmBoundary ||
590
+ options.expectedGoalId !== this.state.goal.goalId ||
591
+ options.expectedRevision !== this.state.goal.revision)) {
592
+ return false;
593
+ }
594
+ const resumedAt = Date.now();
97
595
  this.state.goal = {
98
596
  ...this.state.goal,
99
597
  status: 'active',
100
- revision: this.state.goal.revision + 1,
101
- updatedAt: Date.now(),
102
- activeSince: Date.now(),
598
+ revision: this.addGoalInteger(this.state.goal.revision, 1, 'revision'),
599
+ updatedAt: resumedAt,
600
+ activeSince: resumedAt,
103
601
  stopReason: undefined,
104
602
  blocker: undefined,
105
603
  noProgressCount: 0,
604
+ recentNoProgressTurns: [],
605
+ boundaryConfirmation: pendingBoundary
606
+ ? {
607
+ ...this.state.goal.boundaryConfirmation,
608
+ confirmedAt: resumedAt,
609
+ confirmedRevision: this.state.goal.revision,
610
+ }
611
+ : this.state.goal.boundaryConfirmation,
106
612
  };
107
613
  this.persist();
108
614
  this.state.continuationDeferred = false;
109
615
  return true;
110
616
  }
111
- edit(objective) {
112
- if (!this.state.goal)
617
+ edit(objective, reason = 'User invoked /target edit.') {
618
+ if (!this.state.goal || types_1.GOAL_TERMINAL_STATES.has(this.state.goal.status))
113
619
  return false;
114
620
  if (!objective.trim())
115
621
  return false;
622
+ const g = this.state.goal;
623
+ const nextObjective = objective.trim();
624
+ if (nextObjective === g.objective)
625
+ return true;
626
+ const changedAt = Date.now();
627
+ // v0.1.2: preserve originalObjective; record an objective revision on the
628
+ // contract so steering can never silently rewrite the root goal. The
629
+ // top-level `objective` reflects the current wording.
630
+ const currentContract = g.contract ?? minimalContract(g.objective, g.updatedAt);
631
+ const objectiveRevision = this.addGoalInteger(currentContract.objectiveRevision, 1, 'contract.objectiveRevision');
632
+ const contract = {
633
+ ...currentContract,
634
+ originalObjective: currentContract.originalObjective,
635
+ objectiveRevision,
636
+ objectiveHistory: [
637
+ ...(currentContract.objectiveHistory ?? []),
638
+ {
639
+ revision: objectiveRevision,
640
+ previousObjective: g.objective,
641
+ objective: nextObjective,
642
+ reason: reason.trim() || 'User invoked /target edit.',
643
+ changedAt,
644
+ source: 'user',
645
+ },
646
+ ].slice(-50),
647
+ successCriteria: currentContract.successCriteria.map(criterion => criterion.id === 'criterion:primary'
648
+ ? {
649
+ ...criterion,
650
+ statement: nextObjective,
651
+ source: 'user',
652
+ status: 'pending',
653
+ evidenceRefs: [],
654
+ }
655
+ : criterion),
656
+ };
657
+ const edited = {
658
+ ...g,
659
+ objective: nextObjective,
660
+ contract,
661
+ revision: this.addGoalInteger(g.revision, 1, 'revision'),
662
+ updatedAt: changedAt,
663
+ completionAudit: undefined,
664
+ // Truncation is scoped to an objective epoch. A user edit intentionally
665
+ // starts a fresh verification epoch; old evidence remains provenance but
666
+ // cannot permanently block the edited objective.
667
+ evidenceLedgerTruncation: undefined,
668
+ };
669
+ this.state.goal = goalRequiresBoundaryConfirmation(nextObjective)
670
+ ? { ...pauseForBoundaryConfirmation(edited, changedAt), revision: edited.revision }
671
+ : { ...edited, boundaryConfirmation: undefined };
672
+ this.persist();
673
+ this.state.continuationDeferred = this.state.goal.status !== 'active';
674
+ return true;
675
+ }
676
+ /** Record trusted human acceptance for a criterion that requires user evidence. */
677
+ confirmCriterion(criterionId) {
678
+ const goal = this.state.goal;
679
+ const contract = goal?.contract;
680
+ if (!goal || !contract || types_1.GOAL_TERMINAL_STATES.has(goal.status))
681
+ return false;
682
+ const criterion = contract.successCriteria.find(item => item.id === criterionId.trim());
683
+ if (!criterion || !criterion.requiredEvidenceKinds.includes('user'))
684
+ return false;
685
+ const now = Date.now();
686
+ const revision = this.addGoalInteger(goal.revision, 1, 'revision');
687
+ const evidenceId = `evidence:user:${(0, crypto_1.randomUUID)()}`;
688
+ const record = {
689
+ id: evidenceId,
690
+ goalId: goal.goalId,
691
+ goalRevision: revision,
692
+ objectiveRevision: contract.objectiveRevision,
693
+ turnId: `user-confirmation:${revision}`,
694
+ kind: 'user',
695
+ subject: `User confirmed ${criterion.id}: ${criterion.statement}`.slice(0, 240),
696
+ result: 'passed',
697
+ sourceRef: 'user:/target-confirm',
698
+ capturedAt: now,
699
+ redacted: true,
700
+ };
701
+ const boundedEvidence = appendBoundedEvidence(goal.evidenceLedger ?? [], [record], contract.objectiveRevision, goal.evidenceLedgerTruncation);
116
702
  this.state.goal = {
117
- ...this.state.goal,
118
- objective: objective.trim(),
119
- revision: this.state.goal.revision + 1,
703
+ ...goal,
704
+ revision,
705
+ updatedAt: now,
706
+ evidenceLedger: boundedEvidence.ledger,
707
+ evidenceLedgerTruncation: boundedEvidence.truncation,
708
+ contract: {
709
+ ...contract,
710
+ successCriteria: contract.successCriteria.map(item => item.id === criterion.id
711
+ ? {
712
+ ...item,
713
+ status: 'passed',
714
+ evidenceRefs: [...new Set([...item.evidenceRefs, evidenceId])],
715
+ }
716
+ : item),
717
+ },
718
+ completionAudit: undefined,
719
+ };
720
+ this.persist();
721
+ return true;
722
+ }
723
+ addConstraint(statement) {
724
+ const goal = this.state.goal;
725
+ const trimmed = statement.trim();
726
+ if (!goal || goal.status === 'complete' || !trimmed)
727
+ return false;
728
+ const contract = goal.contract ?? minimalContract(goal.objective, goal.updatedAt);
729
+ if (contract.constraints.some(constraint => constraint.statement === trimmed))
730
+ return true;
731
+ const revision = this.addGoalInteger(goal.revision, 1, 'revision');
732
+ this.state.goal = {
733
+ ...goal,
734
+ contract: {
735
+ ...contract,
736
+ constraints: [
737
+ ...contract.constraints,
738
+ {
739
+ id: `constraint:user:${revision}`,
740
+ statement: trimmed,
741
+ source: 'user',
742
+ },
743
+ ],
744
+ },
745
+ revision,
120
746
  updatedAt: Date.now(),
121
747
  };
122
748
  this.persist();
@@ -125,22 +751,32 @@ class GoalCoordinator {
125
751
  replace(objective) {
126
752
  if (!objective.trim())
127
753
  return false;
128
- // Create new goal, old generation invalidated.
129
- const result = (0, goal_storage_1.createGoal)(this.projectPath, this.sessionId, objective);
130
- if (!result.ok)
754
+ // Create new goal with fresh goalId and fresh contract; old generation
755
+ // invalidated. Does not reuse the old goal's completion state.
756
+ const now = Date.now();
757
+ const contract = minimalContract(objective.trim(), now);
758
+ const requiresBoundaryConfirmation = goalRequiresBoundaryConfirmation(objective);
759
+ const result = (0, goal_storage_1.createGoal)(this.projectPath, this.sessionId, objective, contract, this.state.goal?.revision, requiresBoundaryConfirmation
760
+ ? boundaryConfirmationState(contract.objectiveRevision, now)
761
+ : undefined);
762
+ if (!result.ok) {
763
+ this.restoreDiskAuthorityAfterMutationFailure();
131
764
  return false;
132
- this.state.goal = result.value;
765
+ }
766
+ const replacement = result.value;
767
+ this.state.goal = replacement;
768
+ this.persistedGoalSnapshot = structuredClone(replacement);
133
769
  this.state.generation += 1;
134
- this.state.continuationDeferred = false;
770
+ this.state.continuationDeferred = replacement.status !== 'active';
135
771
  return true;
136
772
  }
137
773
  setBudget(tokenBudget) {
138
- if (!this.state.goal)
774
+ if (!this.state.goal || this.state.goal.status === 'complete')
139
775
  return false;
140
776
  this.state.goal = {
141
777
  ...this.state.goal,
142
778
  tokenBudget: tokenBudget ?? undefined,
143
- revision: this.state.goal.revision + 1,
779
+ revision: this.addGoalInteger(this.state.goal.revision, 1, 'revision'),
144
780
  updatedAt: Date.now(),
145
781
  };
146
782
  this.persist();
@@ -149,8 +785,13 @@ class GoalCoordinator {
149
785
  clear() {
150
786
  if (!this.state.goal)
151
787
  return false;
152
- (0, goal_storage_1.deleteGoal)(this.projectPath, this.sessionId);
788
+ const result = (0, goal_storage_1.deleteGoal)(this.projectPath, this.sessionId, this.state.goal.revision);
789
+ if (!result.ok) {
790
+ this.restoreDiskAuthorityAfterMutationFailure();
791
+ throw new Error(`${result.error}: ${result.message}`);
792
+ }
153
793
  this.state.goal = null;
794
+ this.persistedGoalSnapshot = null;
154
795
  this.state.generation += 1;
155
796
  this.state.continuationDeferred = false;
156
797
  return true;
@@ -162,15 +803,23 @@ class GoalCoordinator {
162
803
  if (!this.canContinue)
163
804
  return null;
164
805
  const g = this.state.goal;
806
+ let continuationIndex;
807
+ try {
808
+ continuationIndex = this.addGoalInteger(g.continuationCount, 1, 'continuationCount');
809
+ }
810
+ catch {
811
+ return null;
812
+ }
165
813
  return {
166
814
  inputKind: 'goal_continuation',
167
815
  sessionId: this.sessionId,
168
816
  persistAsUserMessage: false,
169
817
  echoToTranscript: false,
818
+ generation: this.state.generation,
170
819
  goal: {
171
820
  goalId: g.goalId,
172
821
  revision: g.revision,
173
- continuationIndex: g.continuationCount + 1,
822
+ continuationIndex,
174
823
  },
175
824
  };
176
825
  }
@@ -178,17 +827,115 @@ class GoalCoordinator {
178
827
  const g = this.state.goal;
179
828
  if (!g)
180
829
  return;
181
- if (outcome.goalId !== g.goalId || outcome.goalRevision !== g.revision)
830
+ if (outcome.sessionId !== this.sessionId ||
831
+ outcome.goalId !== g.goalId ||
832
+ outcome.goalRevision !== g.revision ||
833
+ (outcome.goalGeneration !== undefined && outcome.goalGeneration !== this.state.generation))
182
834
  return;
183
- // Accumulate usage.
184
- const acc = (0, accounting_1.accumulateTurn)({ tokensUsed: g.tokensUsed, timeUsedMs: g.timeUsedMs, continuationCount: g.continuationCount, noProgressCount: g.noProgressCount }, outcome);
835
+ if (outcome.usageComplete === false) {
836
+ const accounting = (0, accounting_1.accumulateTurn)({
837
+ tokensUsed: g.tokensUsed,
838
+ timeUsedMs: g.timeUsedMs,
839
+ continuationCount: g.continuationCount,
840
+ noProgressCount: g.noProgressCount,
841
+ }, outcome);
842
+ const pausedAt = Date.now();
843
+ const tokensUsed = this.addGoalInteger(accounting.tokensUsed, 0, 'tokensUsed');
844
+ const timeUsedMs = this.addGoalInteger(accounting.timeUsedMs, 0, 'timeUsedMs');
845
+ const continuationCount = this.addGoalInteger(accounting.continuationCount, 0, 'continuationCount');
846
+ const noProgressCount = this.addGoalInteger(accounting.noProgressCount, 0, 'noProgressCount');
847
+ this.state.goal = {
848
+ ...g,
849
+ tokensUsed,
850
+ timeUsedMs,
851
+ continuationCount,
852
+ noProgressCount,
853
+ status: 'paused',
854
+ revision: this.addGoalInteger(g.revision, 1, 'revision'),
855
+ updatedAt: pausedAt,
856
+ lastTurn: {
857
+ turnId: outcome.turnId,
858
+ finishReason: outcome.finishReason,
859
+ endedAt: outcome.endedAt,
860
+ promptTokens: outcome.usage.promptTokens,
861
+ completionTokens: outcome.usage.completionTokens,
862
+ subagentTokens: outcome.usage.subagentTokens,
863
+ totalTokens: outcome.usage.totalTokens,
864
+ madeProgress: outcome.madeProgress,
865
+ },
866
+ stopReason: {
867
+ kind: 'runtime_error',
868
+ message: 'Paused because provider usage was missing or incomplete across retry/fallback attempts; known usage was recorded, and budget accounting is incomplete.',
869
+ at: pausedAt,
870
+ },
871
+ };
872
+ this.state.continuationDeferred = true;
873
+ this.persist();
874
+ return;
875
+ }
876
+ const currentContract = g.contract ?? minimalContract(g.objective, g.updatedAt);
877
+ const pendingPlanRevision = outcome.pendingPlanUpdate
878
+ ? this.addGoalInteger(currentContract.planSnapshot?.revision ?? 0, 1, 'contract.planSnapshot.revision')
879
+ : 0;
880
+ const pendingDerivedCriteria = outcome.pendingPlanUpdate
881
+ ? newDerivedCriteria(currentContract, outcome.pendingPlanUpdate, pendingPlanRevision)
882
+ : [];
883
+ const pendingPlanChangesContent = outcome.pendingPlanUpdate
884
+ ? planContentChanged(currentContract, outcome.pendingPlanUpdate)
885
+ : false;
886
+ const pendingPlanMutatesContract = Boolean(outcome.pendingPlanUpdate && (pendingPlanChangesContent || pendingDerivedCriteria.length > 0));
887
+ const newEvidence = (outcome.evidenceRecords ?? []).filter(record => record.goalId === g.goalId &&
888
+ record.goalRevision === g.revision &&
889
+ record.objectiveRevision === (g.contract?.objectiveRevision ?? 0) &&
890
+ record.turnId === outcome.turnId);
891
+ const planAdvanced = Boolean(outcome.pendingPlanUpdate &&
892
+ substantivePlanProgress(currentContract, outcome.pendingPlanUpdate, pendingDerivedCriteria));
893
+ const evidenceContract = pendingDerivedCriteria.length
894
+ ? {
895
+ ...currentContract,
896
+ successCriteria: [...currentContract.successCriteria, ...pendingDerivedCriteria],
897
+ }
898
+ : currentContract;
899
+ const incomingEvidenceKeys = evidenceProgressKeys(newEvidence, evidenceContract);
900
+ const recordedEvidenceKeys = new Set(g.progressEvidenceKeys ?? []);
901
+ const evidenceAdvanced = incomingEvidenceKeys.some(key => !recordedEvidenceKeys.has(key));
902
+ const hasAuditableProgressInputs = outcome.workspaceChanged !== undefined ||
903
+ outcome.evidenceRecords !== undefined ||
904
+ outcome.pendingPlanUpdate !== undefined;
905
+ // Text-only plan churn is still persisted as a snapshot for observability,
906
+ // but phase/next-action/description rewrites are not Goal progress. Runtime
907
+ // turns must present an auditable signal; legacy direct callers without the
908
+ // new metadata retain their explicit madeProgress value for compatibility.
909
+ const madeAuditableProgress = Boolean(outcome.workspaceChanged) || evidenceAdvanced || planAdvanced;
910
+ const accountingOutcome = {
911
+ ...outcome,
912
+ madeProgress: hasAuditableProgressInputs ? madeAuditableProgress : outcome.madeProgress,
913
+ };
914
+ // Accumulate per-turn usage.
915
+ const acc = (0, accounting_1.accumulateTurn)({
916
+ tokensUsed: g.tokensUsed,
917
+ timeUsedMs: g.timeUsedMs,
918
+ continuationCount: g.continuationCount,
919
+ noProgressCount: g.noProgressCount,
920
+ }, accountingOutcome);
921
+ const tokensUsed = this.addGoalInteger(acc.tokensUsed, 0, 'tokensUsed');
922
+ const timeUsedMs = this.addGoalInteger(acc.timeUsedMs, 0, 'timeUsedMs');
923
+ const continuationCount = this.addGoalInteger(acc.continuationCount, 0, 'continuationCount');
924
+ const noProgressCount = this.addGoalInteger(acc.noProgressCount, 0, 'noProgressCount');
185
925
  const updated = {
186
926
  ...g,
187
- tokensUsed: acc.tokensUsed,
188
- timeUsedMs: acc.timeUsedMs,
189
- continuationCount: acc.continuationCount,
190
- noProgressCount: acc.noProgressCount,
191
- revision: g.revision + 1,
927
+ tokensUsed,
928
+ timeUsedMs,
929
+ continuationCount,
930
+ noProgressCount,
931
+ recentNoProgressTurns: accountingOutcome.madeProgress
932
+ ? []
933
+ : [...(g.recentNoProgressTurns ?? []), noProgressTurnSummary(accountingOutcome)].slice(-3),
934
+ progressEvidenceKeys: [
935
+ ...(g.progressEvidenceKeys ?? []),
936
+ ...incomingEvidenceKeys.filter(key => !recordedEvidenceKeys.has(key)),
937
+ ].slice(-1000),
938
+ revision: this.addGoalInteger(g.revision, 1, 'revision'),
192
939
  updatedAt: Date.now(),
193
940
  lastTurn: {
194
941
  turnId: outcome.turnId,
@@ -198,38 +945,193 @@ class GoalCoordinator {
198
945
  completionTokens: outcome.usage.completionTokens,
199
946
  subagentTokens: outcome.usage.subagentTokens,
200
947
  totalTokens: outcome.usage.totalTokens,
201
- madeProgress: outcome.madeProgress,
948
+ madeProgress: accountingOutcome.madeProgress,
202
949
  },
203
950
  };
204
- // Process pending terminal request.
205
- if (outcome.pendingTerminalRequest) {
206
- if (outcome.pendingTerminalRequest.requestedStatus === 'complete') {
951
+ if (outcome.pendingPlanUpdate && pendingPlanMutatesContract) {
952
+ const contract = updated.contract ?? minimalContract(updated.objective, updated.updatedAt);
953
+ updated.contract = {
954
+ ...contract,
955
+ successCriteria: [...contract.successCriteria, ...pendingDerivedCriteria],
956
+ planSnapshot: {
957
+ revision: pendingPlanRevision,
958
+ phase: outcome.pendingPlanUpdate.phase,
959
+ steps: outcome.pendingPlanUpdate.steps.map((step, index) => ({
960
+ id: `plan:${pendingPlanRevision}:${index + 1}`,
961
+ description: step.description,
962
+ done: step.done,
963
+ })),
964
+ nextAction: outcome.pendingPlanUpdate.nextAction,
965
+ updatedAt: Date.now(),
966
+ },
967
+ };
968
+ }
969
+ const boundedEvidence = appendBoundedEvidence(g.evidenceLedger ?? [], newEvidence, currentContract.objectiveRevision, g.evidenceLedgerTruncation);
970
+ const evidenceLedger = boundedEvidence.ledger;
971
+ updated.evidenceLedger = evidenceLedger;
972
+ updated.evidenceLedgerTruncation = boundedEvidence.truncation;
973
+ // Process a terminal request only when it belongs to this exact turn and
974
+ // goal revision. A stale tool call can never close a newer goal.
975
+ const terminalRequest = outcome.pendingTerminalRequest;
976
+ const currentBlockedSignal = Boolean(terminalRequest?.requestedStatus === 'blocked' &&
977
+ terminalRequest.goalId === g.goalId &&
978
+ terminalRequest.goalRevision === g.revision &&
979
+ terminalRequest.turnId === outcome.turnId &&
980
+ outcome.blocker &&
981
+ updated.blocker?.fingerprint === outcome.blocker.fingerprint);
982
+ // Consecutive means every Goal turn. Any intervening turn without the same
983
+ // current-turn blocker breaks the streak instead of leaving an old counter
984
+ // available for a later terminal request.
985
+ if (updated.blocker && !currentBlockedSignal)
986
+ updated.blocker = undefined;
987
+ if (terminalRequest &&
988
+ terminalRequest.goalId === g.goalId &&
989
+ terminalRequest.goalRevision === g.revision &&
990
+ terminalRequest.turnId === outcome.turnId) {
991
+ if (terminalRequest.requestedStatus === 'complete') {
992
+ if (updated.contract && terminalRequest.criterionEvidence) {
993
+ const ledgerById = new Map(evidenceLedger.map(record => [record.id, record]));
994
+ const criterionCounts = new Map();
995
+ const evidenceCounts = new Map();
996
+ for (const mapping of terminalRequest.criterionEvidence) {
997
+ criterionCounts.set(mapping.criterionId, (criterionCounts.get(mapping.criterionId) ?? 0) + 1);
998
+ for (const id of mapping.evidenceIds) {
999
+ evidenceCounts.set(id, (evidenceCounts.get(id) ?? 0) + 1);
1000
+ }
1001
+ }
1002
+ const mappings = new Map(terminalRequest.criterionEvidence.map(mapping => [
1003
+ mapping.criterionId,
1004
+ mapping.evidenceIds,
1005
+ ]));
1006
+ updated.contract = {
1007
+ ...updated.contract,
1008
+ successCriteria: updated.contract.successCriteria.map(criterion => {
1009
+ if (criterionCounts.get(criterion.id) !== 1)
1010
+ return criterion;
1011
+ const refs = (mappings.get(criterion.id) ?? []).filter(id => {
1012
+ const record = ledgerById.get(id);
1013
+ return Boolean(record &&
1014
+ evidenceCounts.get(id) === 1 &&
1015
+ record.goalId === g.goalId &&
1016
+ record.goalRevision <= g.revision &&
1017
+ record.objectiveRevision === (updated.contract?.objectiveRevision ?? 0) &&
1018
+ criterion.requiredEvidenceKinds.includes(record.kind));
1019
+ });
1020
+ return {
1021
+ ...criterion,
1022
+ evidenceRefs: [...new Set([...criterion.evidenceRefs, ...refs])],
1023
+ };
1024
+ }),
1025
+ };
1026
+ }
207
1027
  const audit = (0, completion_audit_1.auditCompletion)({
208
1028
  objective: updated.objective,
209
- // v0.2.26: use actual evidence from the turn outcome.
210
- evidenceRefs: outcome.evidenceRefs ?? [],
1029
+ contract: updated.contract ?? minimalContract(updated.objective, updated.updatedAt),
1030
+ evidenceLedger,
1031
+ evidenceLedgerTruncation: updated.evidenceLedgerTruncation,
1032
+ goalId: g.goalId,
1033
+ goalRevision: g.revision,
1034
+ requestedAt: terminalRequest.requestedAt,
211
1035
  verificationSummary: outcome.verificationSummary ?? outcome.finishReason,
1036
+ workspaceFingerprint: outcome.workspaceFingerprint,
212
1037
  });
213
1038
  updated.completionAudit = audit;
1039
+ if (updated.contract) {
1040
+ const statusById = new Map(audit.criterionResults?.map(result => [result.criterionId, result.status]) ?? []);
1041
+ updated.contract = {
1042
+ ...updated.contract,
1043
+ successCriteria: updated.contract.successCriteria.map(criterion => ({
1044
+ ...criterion,
1045
+ status: statusById.get(criterion.id) ?? 'pending',
1046
+ })),
1047
+ };
1048
+ }
214
1049
  if (audit.passed) {
1050
+ const completedAt = Date.now();
1051
+ const criterionResults = audit.criterionResults ?? [];
1052
+ const finalLedgerById = new Map(evidenceLedger.map(record => [record.id, record]));
1053
+ updated.completionAudit = {
1054
+ ...audit,
1055
+ finalSummary: {
1056
+ originalObjective: updated.contract?.originalObjective ?? updated.objective,
1057
+ currentObjective: updated.objective,
1058
+ objectiveRevision: updated.contract?.objectiveRevision ?? 0,
1059
+ completedAt,
1060
+ verificationSummary: audit.verificationSummary,
1061
+ criterionResults: criterionResults.map(result => ({
1062
+ criterionId: result.criterionId,
1063
+ status: result.status,
1064
+ evidenceRefs: result.evidenceRefs,
1065
+ evidence: result.evidenceRefs.flatMap(id => {
1066
+ const record = finalLedgerById.get(id);
1067
+ return record ? [finalEvidenceReceipt(record)] : [];
1068
+ }),
1069
+ })),
1070
+ evidenceRefs: audit.evidenceRefs,
1071
+ accounting: {
1072
+ tokensUsed: updated.tokensUsed,
1073
+ timeUsedMs: updated.timeUsedMs,
1074
+ continuationCount: updated.continuationCount,
1075
+ usageComplete: true,
1076
+ },
1077
+ remainingRequirements: [],
1078
+ stopReason: 'completed',
1079
+ },
1080
+ };
215
1081
  updated.status = 'complete';
216
- updated.completedAt = Date.now();
1082
+ updated.completedAt = completedAt;
1083
+ delete updated.activeSince;
1084
+ delete updated.blocker;
1085
+ delete updated.stopReason;
1086
+ if (updated.contract) {
1087
+ const currentPlan = updated.contract.planSnapshot;
1088
+ updated.contract = {
1089
+ ...updated.contract,
1090
+ planSnapshot: {
1091
+ revision: this.addGoalInteger(currentPlan?.revision ?? 0, 1, 'contract.planSnapshot.revision'),
1092
+ phase: 'complete',
1093
+ steps: currentPlan?.steps ?? [],
1094
+ nextAction: undefined,
1095
+ updatedAt: completedAt,
1096
+ },
1097
+ };
1098
+ }
1099
+ }
1100
+ else if (updated.contract) {
1101
+ const currentPlan = updated.contract.planSnapshot;
1102
+ updated.contract = {
1103
+ ...updated.contract,
1104
+ planSnapshot: {
1105
+ revision: this.addGoalInteger(currentPlan?.revision ?? 0, 1, 'contract.planSnapshot.revision'),
1106
+ phase: 'verification',
1107
+ steps: currentPlan?.steps ?? [],
1108
+ nextAction: audit.remainingRequirements[0],
1109
+ updatedAt: Date.now(),
1110
+ },
1111
+ };
217
1112
  }
218
1113
  }
219
- else if (outcome.pendingTerminalRequest.requestedStatus === 'blocked') {
1114
+ else if (terminalRequest.requestedStatus === 'blocked') {
220
1115
  const blocker = outcome.blocker;
221
1116
  if (blocker) {
222
- const fingerprint = (0, completion_audit_1.blockerFingerprint)(blocker.fingerprint, '', '');
1117
+ const fingerprint = blocker.fingerprint;
223
1118
  const same = (0, completion_audit_1.blockersMatch)(updated.blocker, fingerprint);
224
1119
  const newBlocker = {
1120
+ category: blocker.category,
225
1121
  fingerprint,
226
1122
  firstSeenAt: same && updated.blocker ? updated.blocker.firstSeenAt : Date.now(),
227
1123
  lastSeenAt: Date.now(),
228
- consecutiveTurns: same ? (updated.blocker?.consecutiveTurns ?? 0) + 1 : 1,
1124
+ consecutiveTurns: same
1125
+ ? this.addGoalInteger(updated.blocker?.consecutiveTurns ?? 0, 1, 'blocker.consecutiveTurns')
1126
+ : 1,
229
1127
  summary: blocker.summary,
1128
+ retryable: false,
230
1129
  };
231
1130
  updated.blocker = newBlocker;
232
- const blockAudit = (0, completion_audit_1.auditBlocked)({ blocker: newBlocker, noProgressCount: updated.noProgressCount });
1131
+ const blockAudit = (0, completion_audit_1.auditBlocked)({
1132
+ blocker: newBlocker,
1133
+ noProgressCount: updated.noProgressCount,
1134
+ });
233
1135
  if (blockAudit.allowed) {
234
1136
  updated.status = 'blocked';
235
1137
  updated.stopReason = { kind: 'blocked', message: blockAudit.reason, at: Date.now() };
@@ -237,20 +1139,115 @@ class GoalCoordinator {
237
1139
  }
238
1140
  }
239
1141
  }
1142
+ if (updated.lastTurn &&
1143
+ !updated.lastTurn.madeProgress &&
1144
+ criterionStateAdvanced(currentContract, updated.contract, evidenceLedger)) {
1145
+ updated.noProgressCount = 0;
1146
+ updated.recentNoProgressTurns = [];
1147
+ updated.lastTurn = { ...updated.lastTurn, madeProgress: true };
1148
+ }
1149
+ const providerStop = (0, accounting_1.classifyStopReason)(outcome.providerError);
1150
+ if (providerStop && updated.status === 'active') {
1151
+ updated.status = providerStop.kind === 'usage_limit' ? 'usage_limited' : 'paused';
1152
+ updated.stopReason = {
1153
+ kind: providerStop.kind,
1154
+ message: providerStop.message,
1155
+ at: Date.now(),
1156
+ };
1157
+ this.state.continuationDeferred = true;
1158
+ }
240
1159
  // Auto-pause on no-progress threshold.
241
- if (updated.noProgressCount >= types_1.GOAL_INVARIANTS.maxConsecutiveNoProgressTurns && updated.status === 'active') {
1160
+ if (updated.noProgressCount >= types_1.GOAL_INVARIANTS.maxConsecutiveNoProgressTurns &&
1161
+ updated.status === 'active') {
242
1162
  updated.status = 'paused';
243
- updated.stopReason = { kind: 'user', message: 'Auto-paused: no progress for 3 consecutive turns.', at: Date.now() };
1163
+ const recent = updated.recentNoProgressTurns ?? [];
1164
+ updated.stopReason = {
1165
+ kind: 'user',
1166
+ message: `Auto-paused: no progress for 3 consecutive turns. Recent turns: ${formatNoProgressTurns(recent)}.`,
1167
+ at: Date.now(),
1168
+ };
244
1169
  this.state.continuationDeferred = true;
245
1170
  }
246
1171
  // Budget limit.
247
1172
  if ((0, accounting_1.isBudgetExceeded)(updated.tokensUsed, updated.tokenBudget) && updated.status === 'active') {
248
1173
  updated.status = 'budget_limited';
249
- updated.stopReason = { kind: 'budget_limit', message: `Token budget reached: ${updated.tokensUsed}/${updated.tokenBudget}`, at: Date.now() };
1174
+ updated.stopReason = {
1175
+ kind: 'budget_limit',
1176
+ message: `Token budget reached: ${updated.tokensUsed}/${updated.tokenBudget}`,
1177
+ at: Date.now(),
1178
+ };
250
1179
  }
251
1180
  this.state.goal = updated;
252
1181
  this.persist();
253
1182
  }
1183
+ /**
1184
+ * Account an interrupted turn whose request revision was superseded by live steering.
1185
+ *
1186
+ * This deliberately ignores every semantic payload on the stale outcome: evidence,
1187
+ * plan updates, blockers, and terminal requests belong to the old contract revision.
1188
+ * Only already-incurred token/time/turn accounting is retained, without changing the
1189
+ * revised Goal's no-progress streak.
1190
+ */
1191
+ accountStaleTurn(outcome) {
1192
+ const g = this.state.goal;
1193
+ if (!g ||
1194
+ outcome.sessionId !== this.sessionId ||
1195
+ outcome.goalId !== g.goalId ||
1196
+ outcome.goalRevision === undefined ||
1197
+ outcome.goalRevision >= g.revision ||
1198
+ (outcome.goalGeneration !== undefined && outcome.goalGeneration !== this.state.generation)) {
1199
+ return false;
1200
+ }
1201
+ const key = `${outcome.goalId}:${outcome.goalGeneration ?? this.state.generation}:${outcome.turnId}`;
1202
+ if (this.accountedStaleTurnKeys.has(key))
1203
+ return false;
1204
+ const endedAt = Math.max(outcome.startedAt, outcome.endedAt);
1205
+ const elapsedMs = endedAt - outcome.startedAt;
1206
+ const tokensUsed = this.addGoalInteger(g.tokensUsed, Math.max(0, outcome.usage.totalTokens), 'tokensUsed');
1207
+ const timeUsedMs = this.addGoalInteger(g.timeUsedMs, elapsedMs, 'timeUsedMs');
1208
+ const continuationCount = this.addGoalInteger(g.continuationCount, 1, 'continuationCount');
1209
+ const revision = this.addGoalInteger(g.revision, 1, 'revision');
1210
+ this.accountedStaleTurnKeys.add(key);
1211
+ const updated = {
1212
+ ...g,
1213
+ tokensUsed,
1214
+ timeUsedMs,
1215
+ continuationCount,
1216
+ revision,
1217
+ updatedAt: Date.now(),
1218
+ lastTurn: {
1219
+ turnId: outcome.turnId,
1220
+ finishReason: outcome.finishReason,
1221
+ endedAt,
1222
+ promptTokens: Math.max(0, outcome.usage.promptTokens),
1223
+ completionTokens: Math.max(0, outcome.usage.completionTokens),
1224
+ subagentTokens: Math.max(0, outcome.usage.subagentTokens),
1225
+ totalTokens: Math.max(0, outcome.usage.totalTokens),
1226
+ madeProgress: false,
1227
+ },
1228
+ };
1229
+ if ((0, accounting_1.isBudgetExceeded)(updated.tokensUsed, updated.tokenBudget) && updated.status === 'active') {
1230
+ updated.status = 'budget_limited';
1231
+ updated.stopReason = {
1232
+ kind: 'budget_limit',
1233
+ message: `Token budget reached: ${updated.tokensUsed}/${updated.tokenBudget}`,
1234
+ at: Date.now(),
1235
+ };
1236
+ this.state.continuationDeferred = true;
1237
+ }
1238
+ this.state.goal = updated;
1239
+ try {
1240
+ this.persist();
1241
+ }
1242
+ catch (error) {
1243
+ // The key protects an in-flight attempt, but a failed pre-commit save did
1244
+ // not durably account the turn. Release it only after persist() restores
1245
+ // disk authority so the same outcome can be retried exactly once.
1246
+ this.accountedStaleTurnKeys.delete(key);
1247
+ throw error;
1248
+ }
1249
+ return true;
1250
+ }
254
1251
  deferContinuation() {
255
1252
  if (!this.state.goal || this.state.goal.status !== 'active')
256
1253
  return;
@@ -259,19 +1256,88 @@ class GoalCoordinator {
259
1256
  this.state.goal = {
260
1257
  ...g,
261
1258
  status: 'paused',
262
- revision: g.revision + 1,
1259
+ revision: this.addGoalInteger(g.revision, 1, 'revision'),
263
1260
  updatedAt: Date.now(),
264
1261
  stopReason: { kind: 'user', message: 'Paused by interrupt.', at: Date.now() },
265
1262
  };
266
1263
  this.persist();
267
1264
  }
1265
+ limitBudget(reason) {
1266
+ const goal = this.state.goal;
1267
+ if (!goal || goal.status !== 'active')
1268
+ return;
1269
+ this.state.goal = {
1270
+ ...goal,
1271
+ status: 'budget_limited',
1272
+ revision: this.addGoalInteger(goal.revision, 1, 'revision'),
1273
+ updatedAt: Date.now(),
1274
+ stopReason: { kind: 'budget_limit', message: reason, at: Date.now() },
1275
+ };
1276
+ this.state.continuationDeferred = true;
1277
+ this.persist();
1278
+ }
1279
+ /**
1280
+ * Apply a runtime-only safety overlay after a mutation failed to persist.
1281
+ * `persist()` has already restored the disk-authoritative Goal. Keep its
1282
+ * revision intact so `/target resume` can save normally once storage recovers,
1283
+ * while invalidating every in-flight request and queued continuation now.
1284
+ */
1285
+ failClosedAfterPersistenceError(reason) {
1286
+ const goal = this.state.goal;
1287
+ this.state.generation += 1;
1288
+ this.state.continuationDeferred = true;
1289
+ if (!goal || goal.status === 'complete')
1290
+ return;
1291
+ this.state.goal = {
1292
+ ...goal,
1293
+ status: 'paused',
1294
+ updatedAt: Date.now(),
1295
+ stopReason: {
1296
+ kind: 'runtime_error',
1297
+ message: (0, redaction_1.redactTraceText)(reason).slice(0, 600),
1298
+ at: Date.now(),
1299
+ },
1300
+ };
1301
+ }
268
1302
  // ==========================================================================
269
1303
  // Persistence
270
1304
  // ==========================================================================
1305
+ /** Restore disk authority without reviving a deletion-fenced Goal. */
1306
+ restoreDiskAuthorityAfterMutationFailure() {
1307
+ const persisted = (0, goal_storage_1.loadGoal)(this.projectPath, this.sessionId);
1308
+ if (persisted.ok) {
1309
+ this.state.goal = ensureContract(persisted.value);
1310
+ this.persistedGoalSnapshot = structuredClone(this.state.goal);
1311
+ this.state.continuationDeferred = true;
1312
+ return;
1313
+ }
1314
+ if (persisted.error === 'not_found') {
1315
+ this.state.goal = null;
1316
+ this.persistedGoalSnapshot = null;
1317
+ this.state.generation += 1;
1318
+ this.state.continuationDeferred = false;
1319
+ return;
1320
+ }
1321
+ if (this.persistedGoalSnapshot) {
1322
+ this.state.goal = structuredClone(this.persistedGoalSnapshot);
1323
+ }
1324
+ else {
1325
+ this.state.goal = null;
1326
+ }
1327
+ this.state.continuationDeferred = true;
1328
+ }
271
1329
  persist() {
272
1330
  if (!this.state.goal)
273
1331
  return;
274
- (0, goal_storage_1.saveGoal)(this.projectPath, this.sessionId, this.state.goal);
1332
+ const result = (0, goal_storage_1.saveGoal)(this.projectPath, this.sessionId, this.state.goal, Math.max(0, this.state.goal.revision - 1));
1333
+ if (result.ok) {
1334
+ this.persistedGoalSnapshot = structuredClone(this.state.goal);
1335
+ return;
1336
+ }
1337
+ if (!result.ok) {
1338
+ this.restoreDiskAuthorityAfterMutationFailure();
1339
+ throw new Error(`Goal persistence failed (${result.error}): ${result.message}`);
1340
+ }
275
1341
  }
276
1342
  }
277
1343
  exports.GoalCoordinator = GoalCoordinator;