@sayknow-cli/coding-agent 0.3.7 → 0.3.9

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 (272) hide show
  1. package/dist/types/append-only-mode.d.ts +31 -0
  2. package/dist/types/capability/rule.d.ts +8 -0
  3. package/dist/types/cli/completion-cli.d.ts +41 -0
  4. package/dist/types/cli/config-cli.d.ts +1 -0
  5. package/dist/types/cli/fixture-report.d.ts +20 -0
  6. package/dist/types/cli/fixture-report.test.d.ts +1 -0
  7. package/dist/types/cli/nofile-limit.d.ts +11 -0
  8. package/dist/types/cli/notify-cli.d.ts +7 -0
  9. package/dist/types/cli/update-cli.d.ts +22 -0
  10. package/dist/types/cli.d.ts +124 -1
  11. package/dist/types/commands/completion.d.ts +29 -0
  12. package/dist/types/commands/config.d.ts +3 -0
  13. package/dist/types/commands/deep-interview.d.ts +3 -0
  14. package/dist/types/config/file-lock.d.ts +7 -6
  15. package/dist/types/config/keybindings.d.ts +2 -2
  16. package/dist/types/config/model-profile-activation.d.ts +6 -0
  17. package/dist/types/config/model-profiles.d.ts +1 -0
  18. package/dist/types/config/settings-schema.d.ts +119 -1
  19. package/dist/types/config/settings.d.ts +8 -0
  20. package/dist/types/discovery/index.d.ts +1 -0
  21. package/dist/types/export/html/template.generated.d.ts +1 -1
  22. package/dist/types/export/ttsr.d.ts +14 -3
  23. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  24. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  25. package/dist/types/goals/runtime.d.ts +1 -0
  26. package/dist/types/hooks/native-skill-hook.d.ts +12 -0
  27. package/dist/types/main.d.ts +4 -1
  28. package/dist/types/modes/components/assistant-message.d.ts +5 -1
  29. package/dist/types/modes/components/model-selector.d.ts +4 -0
  30. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  31. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  32. package/dist/types/modes/components/status-line.d.ts +9 -0
  33. package/dist/types/modes/components/thinking-selector.d.ts +2 -2
  34. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  35. package/dist/types/modes/controllers/event-controller.d.ts +5 -0
  36. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  38. package/dist/types/modes/interactive-mode.d.ts +4 -0
  39. package/dist/types/modes/print-mode.d.ts +13 -1
  40. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  41. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -3
  42. package/dist/types/modes/rpc/rpc-socket-security.d.ts +3 -0
  43. package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
  44. package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
  45. package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
  46. package/dist/types/modes/types.d.ts +3 -0
  47. package/dist/types/modes/utils/injected-user-submission.d.ts +52 -0
  48. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  49. package/dist/types/notifications/config-commands.d.ts +9 -0
  50. package/dist/types/notifications/config.d.ts +30 -1
  51. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  52. package/dist/types/notifications/index.d.ts +8 -3
  53. package/dist/types/notifications/lifecycle-commands.d.ts +12 -3
  54. package/dist/types/notifications/lifecycle-control-runtime.d.ts +2 -1
  55. package/dist/types/notifications/reply-sent-store.d.ts +53 -0
  56. package/dist/types/notifications/rich-draft.d.ts +68 -0
  57. package/dist/types/notifications/rich-render.d.ts +90 -0
  58. package/dist/types/notifications/telegram-daemon.d.ts +45 -3
  59. package/dist/types/notifications/telegram-reference.d.ts +7 -5
  60. package/dist/types/notifications/threaded-render.d.ts +14 -0
  61. package/dist/types/runtime-api-key.d.ts +7 -0
  62. package/dist/types/sdk.d.ts +3 -0
  63. package/dist/types/session/agent-session.d.ts +61 -6
  64. package/dist/types/session/agent-storage.d.ts +1 -0
  65. package/dist/types/session/file-mention-pruning.d.ts +8 -0
  66. package/dist/types/session/messages.d.ts +4 -0
  67. package/dist/types/session/session-manager.d.ts +17 -1
  68. package/dist/types/skc-runtime/deep-interview-ambiguity.d.ts +39 -0
  69. package/dist/types/skc-runtime/deep-interview-recorder.d.ts +10 -1
  70. package/dist/types/skc-runtime/deep-interview-state.d.ts +17 -2
  71. package/dist/types/skc-runtime/session-state-sidecar.d.ts +7 -0
  72. package/dist/types/skc-runtime/state-writer.d.ts +3 -0
  73. package/dist/types/skc-runtime/tmux-common.d.ts +1 -0
  74. package/dist/types/skc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
  75. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +120 -13
  76. package/dist/types/skill-state/active-state.d.ts +18 -1
  77. package/dist/types/system-prompt.d.ts +18 -2
  78. package/dist/types/task/executor.d.ts +3 -0
  79. package/dist/types/task/fork-context-budget.d.ts +7 -0
  80. package/dist/types/task/token-log.d.ts +30 -0
  81. package/dist/types/task/token-log.test.d.ts +1 -0
  82. package/dist/types/tools/bisect.d.ts +81 -0
  83. package/dist/types/tools/computer.d.ts +2 -2
  84. package/dist/types/tools/index.d.ts +1 -0
  85. package/dist/types/tools/search.d.ts +2 -2
  86. package/dist/types/tools/ultragoal-ask-guard.d.ts +9 -2
  87. package/dist/types/utils/file-mentions.d.ts +14 -0
  88. package/dist/types/utils/git.d.ts +32 -0
  89. package/dist/types/utils/pasted-image-path.d.ts +30 -0
  90. package/dist/types/web/scrapers/youtube.d.ts +8 -0
  91. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  92. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  93. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  94. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  95. package/package.json +8 -8
  96. package/src/append-only-mode.ts +58 -0
  97. package/src/autoresearch/prompt.md +1 -1
  98. package/src/autoresearch/tools/log-experiment.ts +1 -1
  99. package/src/capability/rule.ts +9 -0
  100. package/src/cli/completion-cli.ts +234 -0
  101. package/src/cli/config-cli.ts +61 -9
  102. package/src/cli/fast-help.ts +1 -0
  103. package/src/cli/fixture-report.test.ts +230 -0
  104. package/src/cli/fixture-report.ts +171 -0
  105. package/src/cli/nofile-limit.ts +48 -0
  106. package/src/cli/notify-cli.ts +103 -17
  107. package/src/cli/telegram-cli.ts +1 -1
  108. package/src/cli/update-cli.ts +148 -33
  109. package/src/cli.ts +29 -1
  110. package/src/commands/completion.ts +84 -0
  111. package/src/commands/config.ts +2 -0
  112. package/src/commands/contribution-prep.ts +1 -1
  113. package/src/commands/deep-interview.ts +2 -1
  114. package/src/config/file-lock.ts +73 -34
  115. package/src/config/keybindings.ts +3 -3
  116. package/src/config/model-profile-activation.ts +65 -0
  117. package/src/config/model-profiles.ts +31 -2
  118. package/src/config/settings-schema.ts +76 -1
  119. package/src/config/settings.ts +22 -0
  120. package/src/defaults/skc/skills/team/SKILL.md +17 -14
  121. package/src/defaults/skc/skills/ultragoal/SKILL.md +59 -4
  122. package/src/defaults/skc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
  123. package/src/defaults/skc-defaults.ts +9 -0
  124. package/src/discovery/helpers.ts +10 -0
  125. package/src/discovery/index.ts +1 -0
  126. package/src/export/html/template.generated.ts +1 -1
  127. package/src/export/html/template.js +60 -2
  128. package/src/export/ttsr.ts +96 -18
  129. package/src/extensibility/extensions/runner.ts +183 -219
  130. package/src/extensibility/extensions/types.ts +9 -0
  131. package/src/extensibility/skc-plugins/runtime-adapters.ts +156 -44
  132. package/src/goals/runtime.ts +10 -2
  133. package/src/hindsight/backend.ts +1 -3
  134. package/src/hooks/native-skill-hook.ts +124 -4
  135. package/src/internal-urls/docs-index.generated.ts +17 -8
  136. package/src/lsp/client.ts +1 -0
  137. package/src/main.ts +95 -43
  138. package/src/memories/index.ts +12 -1
  139. package/src/modes/components/assistant-message.ts +241 -82
  140. package/src/modes/components/custom-editor.ts +1 -0
  141. package/src/modes/components/footer.ts +7 -2
  142. package/src/modes/components/model-selector.ts +54 -11
  143. package/src/modes/components/queued-message-selector.ts +92 -0
  144. package/src/modes/components/settings-selector.ts +4 -0
  145. package/src/modes/components/status-line.ts +167 -54
  146. package/src/modes/components/thinking-selector.ts +10 -8
  147. package/src/modes/components/tool-execution.ts +44 -19
  148. package/src/modes/components/welcome.ts +181 -44
  149. package/src/modes/controllers/command-controller.ts +40 -4
  150. package/src/modes/controllers/event-controller.ts +77 -49
  151. package/src/modes/controllers/extension-ui-controller.ts +12 -11
  152. package/src/modes/controllers/input-controller.ts +192 -35
  153. package/src/modes/controllers/selector-controller.ts +168 -24
  154. package/src/modes/interactive-mode.ts +56 -16
  155. package/src/modes/print-mode.ts +43 -4
  156. package/src/modes/prompt-action-autocomplete.ts +21 -20
  157. package/src/modes/rpc/rpc-mode.ts +32 -12
  158. package/src/modes/rpc/rpc-socket-security.ts +13 -1
  159. package/src/modes/rpc/rpc-types.ts +9 -0
  160. package/src/modes/shared/agent-wire/command-validation.ts +4 -0
  161. package/src/modes/shared/agent-wire/event-contract.ts +22 -0
  162. package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
  163. package/src/modes/shared/agent-wire/scopes.ts +1 -0
  164. package/src/modes/types.ts +3 -0
  165. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  166. package/src/modes/utils/injected-user-submission.ts +94 -0
  167. package/src/modes/utils/ui-helpers.ts +20 -7
  168. package/src/notifications/config-commands.ts +20 -0
  169. package/src/notifications/config.ts +52 -5
  170. package/src/notifications/daemon-paths.ts +2 -0
  171. package/src/notifications/index.ts +144 -18
  172. package/src/notifications/lifecycle-commands.ts +74 -23
  173. package/src/notifications/lifecycle-control-runtime.ts +8 -3
  174. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  175. package/src/notifications/reply-sent-store.ts +134 -0
  176. package/src/notifications/rich-draft.ts +107 -0
  177. package/src/notifications/rich-render.ts +142 -0
  178. package/src/notifications/telegram-daemon-control.ts +5 -3
  179. package/src/notifications/telegram-daemon.ts +606 -98
  180. package/src/notifications/telegram-reference.ts +17 -16
  181. package/src/notifications/threaded-render.ts +45 -2
  182. package/src/prompts/agents/architect.md +19 -10
  183. package/src/prompts/agents/critic.md +16 -18
  184. package/src/prompts/agents/executor.md +14 -11
  185. package/src/prompts/agents/planner.md +18 -13
  186. package/src/prompts/ci-green-request.md +3 -8
  187. package/src/prompts/goals/goal-continuation.md +0 -5
  188. package/src/prompts/goals/goal-mode-active.md +0 -5
  189. package/src/prompts/system/agent-creation-architect.md +2 -2
  190. package/src/prompts/system/plan-mode-active.md +1 -1
  191. package/src/prompts/system/plan-mode-subagent.md +3 -3
  192. package/src/prompts/system/project-prompt.md +0 -13
  193. package/src/prompts/system/subagent-system-prompt.md +3 -2
  194. package/src/prompts/system/system-prompt.md +29 -17
  195. package/src/prompts/system/volatile-project-context.md +10 -0
  196. package/src/prompts/system/web-search.md +4 -7
  197. package/src/prompts/tools/apply-patch.md +6 -0
  198. package/src/prompts/tools/ast-edit.md +1 -0
  199. package/src/prompts/tools/bash.md +1 -1
  200. package/src/prompts/tools/bisect.md +22 -0
  201. package/src/prompts/tools/browser.md +1 -1
  202. package/src/prompts/tools/computer.md +2 -2
  203. package/src/prompts/tools/cron.md +0 -2
  204. package/src/prompts/tools/eval.md +2 -2
  205. package/src/prompts/tools/find.md +1 -1
  206. package/src/prompts/tools/github.md +6 -6
  207. package/src/prompts/tools/goal.md +1 -3
  208. package/src/prompts/tools/image-gen.md +12 -5
  209. package/src/prompts/tools/irc.md +5 -4
  210. package/src/prompts/tools/job.md +2 -2
  211. package/src/prompts/tools/lsp.md +1 -1
  212. package/src/prompts/tools/monitor.md +2 -2
  213. package/src/prompts/tools/patch.md +2 -2
  214. package/src/prompts/tools/replace.md +2 -21
  215. package/src/prompts/tools/rewind.md +1 -1
  216. package/src/prompts/tools/search-tool-bm25.md +6 -11
  217. package/src/prompts/tools/search.md +1 -1
  218. package/src/prompts/tools/skill.md +3 -2
  219. package/src/prompts/tools/ssh.md +2 -1
  220. package/src/prompts/tools/subagent.md +2 -2
  221. package/src/prompts/tools/task.md +4 -5
  222. package/src/prompts/tools/todo-write.md +1 -1
  223. package/src/prompts/tools/vim.md +1 -0
  224. package/src/prompts/tools/web-search.md +2 -0
  225. package/src/prompts/tools/write.md +8 -2
  226. package/src/runtime-api-key.ts +16 -0
  227. package/src/sdk.ts +88 -36
  228. package/src/session/agent-session.ts +1013 -141
  229. package/src/session/agent-storage.ts +4 -0
  230. package/src/session/file-mention-pruning.ts +64 -0
  231. package/src/session/messages.ts +4 -0
  232. package/src/session/session-manager.ts +103 -4
  233. package/src/skc-runtime/deep-interview-ambiguity.ts +206 -0
  234. package/src/skc-runtime/deep-interview-recorder.ts +62 -8
  235. package/src/skc-runtime/deep-interview-state.ts +44 -2
  236. package/src/skc-runtime/session-state-sidecar.ts +263 -20
  237. package/src/skc-runtime/state-runtime.ts +12 -2
  238. package/src/skc-runtime/state-writer.ts +15 -0
  239. package/src/skc-runtime/tmux-common.ts +1 -0
  240. package/src/skc-runtime/ultragoal-guard.ts +55 -66
  241. package/src/skc-runtime/ultragoal-receipt-freshness.ts +315 -0
  242. package/src/skc-runtime/ultragoal-runtime.ts +1672 -159
  243. package/src/skc-runtime/workflow-manifest.generated.json +113 -2
  244. package/src/skc-runtime/workflow-manifest.ts +74 -1
  245. package/src/skill-state/active-state.ts +141 -5
  246. package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
  247. package/src/slash-commands/builtin-registry.ts +296 -72
  248. package/src/system-prompt.ts +57 -16
  249. package/src/task/executor.ts +132 -24
  250. package/src/task/fork-context-advisory.ts +2 -7
  251. package/src/task/fork-context-budget.ts +9 -0
  252. package/src/task/index.ts +13 -6
  253. package/src/task/output-manager.ts +20 -8
  254. package/src/task/receipt.ts +26 -7
  255. package/src/task/token-log.test.ts +188 -0
  256. package/src/task/token-log.ts +179 -0
  257. package/src/tools/bisect.ts +367 -0
  258. package/src/tools/computer.ts +246 -43
  259. package/src/tools/index.ts +3 -0
  260. package/src/tools/output-meta.ts +116 -4
  261. package/src/tools/search.ts +8 -2
  262. package/src/tools/skill.ts +15 -0
  263. package/src/tools/telegram-send.ts +6 -0
  264. package/src/tools/ultragoal-ask-guard.ts +39 -5
  265. package/src/utils/file-mentions.ts +39 -7
  266. package/src/utils/git.ts +73 -0
  267. package/src/utils/pasted-image-path.ts +170 -0
  268. package/src/web/scrapers/youtube.ts +24 -12
  269. package/src/workflow/workflow-intent-diff.ts +29 -6
  270. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  271. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  272. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -6,8 +6,23 @@ import type { WorkflowHudSummary } from "../skill-state/active-state";
6
6
  import { buildUltragoalHudSummary as buildWorkflowUltragoalHudSummary } from "../skill-state/workflow-hud";
7
7
  import { renderCliWriteReceipt } from "./cli-write-receipt";
8
8
  import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
9
+ import {
10
+ computeUltragoalPlanGeneration,
11
+ findFreshBatchCloseReceipt,
12
+ requiredUltragoalGoals,
13
+ validateDeferredMemberReceiptFresh,
14
+ validateReceiptFreshBase,
15
+ } from "./ultragoal-receipt-freshness";
16
+
17
+ export { computeUltragoalPlanGeneration, receiptRelevantGoals } from "./ultragoal-receipt-freshness";
18
+
9
19
  import { sessionUltragoalDir, skcRoot } from "./session-layout";
10
- import { resolveSkcSessionForRead, resolveSkcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
20
+ import {
21
+ resolveSkcSessionForRead,
22
+ resolveSkcSessionForWrite,
23
+ SessionResolutionError,
24
+ writeSessionActivityMarker,
25
+ } from "./session-resolution";
11
26
  import { renderUltragoalStatusMarkdown } from "./state-renderer";
12
27
  import { reconcileWorkflowSkillState } from "./state-runtime";
13
28
  import {
@@ -28,6 +43,96 @@ export type UltragoalGoalStatus =
28
43
  | "review_blocked"
29
44
  | "superseded";
30
45
 
46
+ export type UltragoalPipelineMetadataSource = "original_plan_graph" | "legacy_brief_only" | "steering";
47
+ export type UltragoalPipelineOverlapState =
48
+ | "none"
49
+ | "open"
50
+ | "joined_clean"
51
+ | "blocked_disjoint_continue"
52
+ | "quarantine_required"
53
+ | "rebaseline_complete";
54
+
55
+ export interface UltragoalPipelineTargets extends JsonObject {
56
+ files: string[];
57
+ surfaces: string[];
58
+ }
59
+
60
+ export interface UltragoalPipelineMetadata extends JsonObject {
61
+ schemaVersion: 1;
62
+ goalId: string;
63
+ source: UltragoalPipelineMetadataSource;
64
+ eligible: boolean;
65
+ dependsOn: string[];
66
+ independentOf: string[];
67
+ targets: UltragoalPipelineTargets;
68
+ metadataHash: string;
69
+ overlap: UltragoalPipelineOverlapState;
70
+ overlapId?: string;
71
+ priorGoalId?: string;
72
+ nextGoalId?: string;
73
+ blockerFootprints?: UltragoalPipelineTargets[];
74
+ invalidationReason?: string;
75
+ invalidatedAt?: string;
76
+ }
77
+
78
+ export interface UltragoalGoalMetadataInput {
79
+ schemaVersion: 1;
80
+ goalId: string;
81
+ source: UltragoalPipelineMetadataSource;
82
+ dependsOn?: string[];
83
+ independentOf?: string[];
84
+ targets?: Partial<UltragoalPipelineTargets>;
85
+ }
86
+
87
+ export interface UltragoalValidationBatchMetadata extends JsonObject {
88
+ schemaVersion: 1;
89
+ batchId: string;
90
+ memberIds: string[];
91
+ finalGoalId: string;
92
+ mode: "aggregate-only";
93
+ metadataHash: string;
94
+ }
95
+
96
+ export interface UltragoalValidationBatchInput {
97
+ schemaVersion: 1;
98
+ batchId: string;
99
+ memberIds: string[];
100
+ finalGoalId: string;
101
+ }
102
+
103
+ export interface UltragoalPipelineOverlapHandles extends JsonObject {
104
+ review: JsonObject;
105
+ qa: JsonObject;
106
+ implementation: JsonObject;
107
+ }
108
+
109
+ export interface UltragoalPipelineOverlapReceipt extends JsonObject {
110
+ ok: true;
111
+ event: string;
112
+ overlap_id: string;
113
+ prior_goal_id: string;
114
+ next_goal_id?: string;
115
+ goal_id?: string;
116
+ status?: UltragoalPipelineOverlapState;
117
+ next_goal_status?: UltragoalGoalStatus;
118
+ goals_path: string;
119
+ ledger_path: string;
120
+ }
121
+
122
+ export type UltragoalPipelineLedgerEventName =
123
+ | "pipeline_overlap_started"
124
+ | "pipeline_overlap_joined"
125
+ | "pipeline_overlap_blocked"
126
+ | "pipeline_overlap_quarantined"
127
+ | "pipeline_overlap_rebaselined";
128
+
129
+ export interface UltragoalPipelineLedgerEvent extends UltragoalLedgerEvent {
130
+ event: UltragoalPipelineLedgerEventName;
131
+ schemaVersion: 1;
132
+ overlapId: string;
133
+ priorGoalId: string;
134
+ nextGoalId: string;
135
+ }
31
136
  export interface UltragoalGoal {
32
137
  id: string;
33
138
  title: string;
@@ -40,6 +145,8 @@ export interface UltragoalGoal {
40
145
  evidence?: string;
41
146
  steering?: Record<string, unknown>;
42
147
  completionVerification?: UltragoalCompletionVerification;
148
+ pipelineMetadata?: UltragoalPipelineMetadata;
149
+ validationBatch?: UltragoalValidationBatchMetadata;
43
150
  }
44
151
 
45
152
  export interface UltragoalPlan {
@@ -75,7 +182,36 @@ export interface UltragoalCompletionVerification {
75
182
  requiredGoalSetHashBeforeCheckpoint: string;
76
183
  };
77
184
  checkpointLedgerEventId: string;
78
- }
185
+ validationBatch?:
186
+ | {
187
+ schemaVersion: 1;
188
+ role: "deferred-member";
189
+ batchId: string;
190
+ memberIds: string[];
191
+ finalGoalId: string;
192
+ metadataHash: string;
193
+ changeSetHash: string;
194
+ }
195
+ | {
196
+ schemaVersion: 1;
197
+ role: "batch-close";
198
+ batchId: string;
199
+ memberIds: string[];
200
+ finalGoalId: string;
201
+ memberMetadataHashes: Record<string, string>;
202
+ memberReceiptIds: Record<string, string>;
203
+ memberCheckpointLedgerEventIds: Record<string, string>;
204
+ memberChangeSetHashes: Record<string, string>;
205
+ unionHash: string;
206
+ };
207
+ }
208
+
209
+ type UltragoalDeferredCompletionVerification = UltragoalCompletionVerification & {
210
+ validationBatch: Extract<
211
+ NonNullable<UltragoalCompletionVerification["validationBatch"]>,
212
+ { role: "deferred-member" }
213
+ >;
214
+ };
79
215
 
80
216
  export interface UltragoalLedgerEvent extends JsonObject {
81
217
  eventId?: string;
@@ -142,6 +278,7 @@ export interface UltragoalStatusSummary {
142
278
  nudgeRemaining?: number;
143
279
  nudgeGoalId?: string;
144
280
  nudgeTargetKind?: UltragoalNudgeTargetKind;
281
+ pipelineOverlap?: JsonObject;
145
282
  }
146
283
 
147
284
  export interface UltragoalCommandResult {
@@ -433,119 +570,6 @@ async function writePlan(cwd: string, plan: UltragoalPlan, sessionId?: string |
433
570
  await writeSessionActivityMarker(cwd, resolvedSessionId, { writer: "ultragoal-runtime", path: paths.goalsPath });
434
571
  }
435
572
 
436
- function requiredUltragoalGoals(plan: UltragoalPlan): UltragoalGoal[] {
437
- return plan.goals.filter(goal => goal.status !== "superseded");
438
- }
439
-
440
- function receiptRelevantGoals(
441
- plan: UltragoalPlan,
442
- goal: UltragoalGoal,
443
- receiptKind: UltragoalReceiptKind,
444
- ): UltragoalGoal[] {
445
- return receiptKind === "final-aggregate" ? requiredUltragoalGoals(plan) : [goal];
446
- }
447
-
448
- function ledgerEventId(event: UltragoalLedgerEvent): string | null {
449
- return typeof event.eventId === "string" && event.eventId.trim().length > 0 ? event.eventId : null;
450
- }
451
-
452
- function latestRelevantLedgerEventId(
453
- ledger: readonly UltragoalLedgerEvent[],
454
- relevantGoalIds: readonly string[],
455
- excludeEventId?: string,
456
- ): string | null {
457
- const relevant = new Set(relevantGoalIds);
458
- for (const event of [...ledger].reverse()) {
459
- const eventId = ledgerEventId(event);
460
- if (eventId && eventId === excludeEventId) continue;
461
- const goalId = typeof event.goalId === "string" ? event.goalId : null;
462
- if (!goalId || relevant.has(goalId)) return eventId;
463
- }
464
- return null;
465
- }
466
-
467
- function planSnapshotForReceipt(input: {
468
- plan: UltragoalPlan;
469
- goal: UltragoalGoal;
470
- beforeStatus: UltragoalGoalStatus;
471
- targetGoalUpdatedAt: string;
472
- receiptKind: UltragoalReceiptKind;
473
- }): unknown {
474
- const targetGoalSnapshot = {
475
- ...input.goal,
476
- status: input.beforeStatus,
477
- updatedAt: input.targetGoalUpdatedAt,
478
- evidence: undefined,
479
- completedAt: undefined,
480
- completionVerification: undefined,
481
- };
482
- const goals =
483
- input.receiptKind === "final-aggregate"
484
- ? input.plan.goals.map(goal => ({
485
- ...goal,
486
- status: goal.id === input.goal.id ? input.beforeStatus : goal.status,
487
- updatedAt: goal.id === input.goal.id ? input.targetGoalUpdatedAt : goal.updatedAt,
488
- evidence: goal.id === input.goal.id ? undefined : goal.evidence,
489
- completedAt: goal.id === input.goal.id ? undefined : goal.completedAt,
490
- completionVerification: undefined,
491
- }))
492
- : [targetGoalSnapshot];
493
- return {
494
- version: input.plan.version,
495
- brief: input.plan.brief,
496
- skcGoalMode: input.plan.skcGoalMode,
497
- skcObjective: input.plan.skcObjective,
498
- skcObjectiveAliases: input.plan.skcObjectiveAliases,
499
- createdAt: input.plan.createdAt,
500
- goals,
501
- };
502
- }
503
-
504
- export function computeUltragoalPlanGeneration(input: {
505
- plan: UltragoalPlan;
506
- ledger: readonly UltragoalLedgerEvent[];
507
- goal: UltragoalGoal;
508
- receiptKind: UltragoalReceiptKind;
509
- beforeStatus: UltragoalGoalStatus;
510
- excludeEventId?: string;
511
- targetGoalUpdatedAt?: string;
512
- }): {
513
- planGeneration: string;
514
- basis: UltragoalCompletionVerification["basis"];
515
- } {
516
- const relevantGoals = receiptRelevantGoals(input.plan, input.goal, input.receiptKind);
517
- const relevantGoalIds = relevantGoals.map(goal => goal.id);
518
- const targetGoalUpdatedAt = input.targetGoalUpdatedAt ?? input.goal.updatedAt;
519
- const planHashBeforeCheckpoint = hashStructuredValue(
520
- planSnapshotForReceipt({
521
- plan: input.plan,
522
- goal: input.goal,
523
- beforeStatus: input.beforeStatus,
524
- targetGoalUpdatedAt,
525
- receiptKind: input.receiptKind,
526
- }),
527
- );
528
- const requiredGoalSetHashBeforeCheckpoint = hashStructuredValue(
529
- relevantGoals.map(goal => ({
530
- id: goal.id,
531
- status: goal.id === input.goal.id ? input.beforeStatus : goal.status,
532
- updatedAt: goal.id === input.goal.id ? targetGoalUpdatedAt : goal.updatedAt,
533
- })),
534
- );
535
- const basis: UltragoalCompletionVerification["basis"] = {
536
- planHashBeforeCheckpoint,
537
- latestRelevantLedgerEventIdBeforeCheckpoint: latestRelevantLedgerEventId(
538
- input.ledger,
539
- relevantGoalIds,
540
- input.excludeEventId,
541
- ),
542
- goalUpdatedAtBeforeCheckpoint: targetGoalUpdatedAt,
543
- relevantGoalIdsBeforeCheckpoint: relevantGoalIds,
544
- requiredGoalSetHashBeforeCheckpoint,
545
- };
546
- return { planGeneration: hashStructuredValue(basis), basis };
547
- }
548
-
549
573
  function chooseReceiptKind(
550
574
  plan: UltragoalPlan,
551
575
  goal: UltragoalGoal,
@@ -553,7 +577,12 @@ function chooseReceiptKind(
553
577
  ): UltragoalReceiptKind {
554
578
  if (plan.skcGoalMode === "per-story") return "per-goal";
555
579
  if (status !== "complete") return "per-goal";
556
- const unfinishedRequiredGoals = requiredUltragoalGoals(plan).filter(
580
+ const requiredGoals = requiredUltragoalGoals(plan);
581
+ const existingFinalAggregateGoal = requiredGoals.find(
582
+ item => item.id !== goal.id && item.completionVerification?.receiptKind === "final-aggregate",
583
+ );
584
+ if (existingFinalAggregateGoal) return "per-goal";
585
+ const unfinishedRequiredGoals = requiredGoals.filter(
557
586
  item => item.id !== goal.id && !TERMINAL_OR_SKIPPED_STATUSES.has(item.status),
558
587
  );
559
588
  return unfinishedRequiredGoals.length === 0 ? "final-aggregate" : "per-goal";
@@ -578,6 +607,54 @@ function buildCompletionReceipt(input: {
578
607
  targetGoalUpdatedAt: input.now,
579
608
  excludeEventId: input.checkpointLedgerEventId,
580
609
  });
610
+ let validationBatch: UltragoalCompletionVerification["validationBatch"];
611
+ if (input.goal.validationBatch) {
612
+ if (input.goal.id !== input.goal.validationBatch.finalGoalId) {
613
+ const deferred = qualityGateObject(input.qualityGateJson.deferredToBatch);
614
+ const changeSet = qualityGateObject(deferred?.changeSet);
615
+ validationBatch = {
616
+ schemaVersion: 1,
617
+ role: "deferred-member",
618
+ batchId: input.goal.validationBatch.batchId,
619
+ memberIds: [...input.goal.validationBatch.memberIds],
620
+ finalGoalId: input.goal.validationBatch.finalGoalId,
621
+ metadataHash: input.goal.validationBatch.metadataHash,
622
+ changeSetHash: String(changeSet?.changeSetHash ?? ""),
623
+ };
624
+ } else {
625
+ const close = qualityGateObject(input.qualityGateJson.validationBatchClose);
626
+ const union = qualityGateObject(close?.unionChangeSet);
627
+ const memberReceiptIds: Record<string, string> = {};
628
+ const memberCheckpointLedgerEventIds: Record<string, string> = {};
629
+ const rows = Array.isArray(close?.memberReceipts) ? close.memberReceipts : [];
630
+ for (const row of rows) {
631
+ if (typeof row === "object" && row !== null && !Array.isArray(row)) {
632
+ const record = row as JsonObject;
633
+ const goalId = nonEmptyString(record.goalId);
634
+ if (goalId) {
635
+ memberReceiptIds[goalId] = String(record.receiptId ?? "");
636
+ memberCheckpointLedgerEventIds[goalId] = String(record.checkpointLedgerEventId ?? "");
637
+ }
638
+ }
639
+ }
640
+ validationBatch = {
641
+ schemaVersion: 1,
642
+ role: "batch-close",
643
+ batchId: input.goal.validationBatch.batchId,
644
+ memberIds: [...input.goal.validationBatch.memberIds],
645
+ finalGoalId: input.goal.validationBatch.finalGoalId,
646
+ memberMetadataHashes: {
647
+ ...(qualityGateObject(close?.memberMetadataHashes) as Record<string, string> | undefined),
648
+ },
649
+ memberReceiptIds,
650
+ memberCheckpointLedgerEventIds,
651
+ memberChangeSetHashes: {
652
+ ...(qualityGateObject(union?.memberChangeSetHashes) as Record<string, string> | undefined),
653
+ },
654
+ unionHash: String(union?.unionHash ?? ""),
655
+ };
656
+ }
657
+ }
581
658
  return {
582
659
  schemaVersion: 1,
583
660
  receiptId: crypto.randomUUID(),
@@ -591,12 +668,561 @@ function buildCompletionReceipt(input: {
591
668
  planGeneration: generation.planGeneration,
592
669
  basis: generation.basis,
593
670
  checkpointLedgerEventId: input.checkpointLedgerEventId,
671
+ validationBatch,
594
672
  };
595
673
  }
596
674
 
597
675
  function nonEmptyString(value: unknown): string | null {
598
676
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
599
677
  }
678
+ function stringArray(value: unknown): string[] | null {
679
+ return Array.isArray(value) && value.every(item => typeof item === "string") ? value.map(item => item.trim()) : null;
680
+ }
681
+
682
+ function normalizePipelineStringArray(value: unknown, fieldName: string): string[] {
683
+ const items = stringArray(value) ?? [];
684
+ const filtered = items.filter(item => item.length > 0);
685
+ if (items.length !== filtered.length) throw new Error(`${fieldName} must contain only non-empty strings`);
686
+ return filtered;
687
+ }
688
+
689
+ function normalizePipelinePath(value: string, fieldName: string): string {
690
+ const raw = value.trim();
691
+ if (raw.split(/[\\/]+/).includes("..")) throw new Error(`${fieldName} contains unsafe path ${value}`);
692
+ const normalized = normalizeRepoPath(raw);
693
+ if (
694
+ !normalized ||
695
+ normalized.startsWith("../") ||
696
+ normalized === ".." ||
697
+ path.isAbsolute(normalized) ||
698
+ normalized.includes("\0")
699
+ ) {
700
+ throw new Error(`${fieldName} contains unsafe path ${value}`);
701
+ }
702
+ return normalized;
703
+ }
704
+
705
+ function normalizePipelineTargets(value: unknown, fieldName: string): UltragoalPipelineTargets {
706
+ const record = typeof value === "object" && value !== null && !Array.isArray(value) ? (value as JsonObject) : {};
707
+ const files = normalizePipelineStringArray(record.files, `${fieldName}.files`).map(item =>
708
+ normalizePipelinePath(item, `${fieldName}.files`),
709
+ );
710
+ const surfaces = normalizePipelineStringArray(record.surfaces, `${fieldName}.surfaces`).map(normalizeSurfaceToken);
711
+ if (new Set(files).size !== files.length) throw new Error(`${fieldName}.files contains duplicate normalized paths`);
712
+ if (new Set(surfaces).size !== surfaces.length)
713
+ throw new Error(`${fieldName}.surfaces contains duplicate normalized surfaces`);
714
+ return { files, surfaces };
715
+ }
716
+
717
+ function pipelineMetadataHashBasis(metadata: Omit<UltragoalPipelineMetadata, "metadataHash">): JsonObject {
718
+ return {
719
+ schemaVersion: metadata.schemaVersion,
720
+ goalId: metadata.goalId,
721
+ source: metadata.source,
722
+ dependsOn: metadata.dependsOn,
723
+ independentOf: metadata.independentOf,
724
+ targets: metadata.targets,
725
+ };
726
+ }
727
+
728
+ function hashPipelineMetadata(metadata: Omit<UltragoalPipelineMetadata, "metadataHash">): string {
729
+ return hashStructuredValue(pipelineMetadataHashBasis(metadata));
730
+ }
731
+
732
+ function withPipelineMetadataHash(
733
+ metadata: Omit<UltragoalPipelineMetadata, "metadataHash">,
734
+ ): UltragoalPipelineMetadata {
735
+ return { ...metadata, metadataHash: hashPipelineMetadata(metadata) } as UltragoalPipelineMetadata;
736
+ }
737
+ function validationBatchHashBasis(metadata: Omit<UltragoalValidationBatchMetadata, "metadataHash">): JsonObject {
738
+ return {
739
+ schemaVersion: metadata.schemaVersion,
740
+ batchId: metadata.batchId,
741
+ memberIds: metadata.memberIds,
742
+ finalGoalId: metadata.finalGoalId,
743
+ mode: metadata.mode,
744
+ };
745
+ }
746
+
747
+ function hashValidationBatch(metadata: Omit<UltragoalValidationBatchMetadata, "metadataHash">): string {
748
+ return hashStructuredValue(validationBatchHashBasis(metadata));
749
+ }
750
+
751
+ function withValidationBatchHash(
752
+ metadata: Omit<UltragoalValidationBatchMetadata, "metadataHash">,
753
+ ): UltragoalValidationBatchMetadata {
754
+ return { ...metadata, metadataHash: hashValidationBatch(metadata) } as UltragoalValidationBatchMetadata;
755
+ }
756
+
757
+ function parseValidationBatchInput(
758
+ value: unknown,
759
+ goalIds: ReadonlySet<string>,
760
+ skcGoalMode: UltragoalSkcGoalMode,
761
+ ): UltragoalValidationBatchMetadata[] {
762
+ if (!Array.isArray(value)) throw new Error("validation batch JSON must be an array");
763
+ if (value.length === 0) return [];
764
+ if (skcGoalMode !== "aggregate") throw new Error("validation batches require aggregate ultragoal mode");
765
+ const goalOrder = new Map([...goalIds].map((id, index) => [id, index]));
766
+ const assigned = new Set<string>();
767
+ const batches: UltragoalValidationBatchMetadata[] = [];
768
+ for (const row of value) {
769
+ if (typeof row !== "object" || row === null || Array.isArray(row))
770
+ throw new Error("validation batch rows must be objects");
771
+ const record = row as JsonObject;
772
+ if (record.schemaVersion !== 1) throw new Error("validation batch schemaVersion must be 1");
773
+ const batchId = nonEmptyString(record.batchId);
774
+ if (!batchId) throw new Error("validation batch batchId is required");
775
+ const finalGoalId = nonEmptyString(record.finalGoalId);
776
+ if (!finalGoalId || !goalIds.has(finalGoalId))
777
+ throw new Error(`validation batch ${batchId} references unknown finalGoalId ${finalGoalId ?? ""}`);
778
+ const memberIds = stringArray(record.memberIds);
779
+ if (!memberIds || memberIds.length === 0)
780
+ throw new Error(`validation batch ${batchId} memberIds must be non-empty`);
781
+ if (memberIds.some(id => id.length === 0))
782
+ throw new Error(`validation batch ${batchId} memberIds must contain only non-empty strings`);
783
+ if (new Set(memberIds).size !== memberIds.length)
784
+ throw new Error(`validation batch ${batchId} contains duplicate memberIds`);
785
+ for (const memberId of memberIds) {
786
+ if (!goalIds.has(memberId))
787
+ throw new Error(`validation batch ${batchId} references unknown member ${memberId}`);
788
+ if (assigned.has(memberId)) throw new Error(`Goal ${memberId} belongs to more than one validation batch`);
789
+ }
790
+ if (!memberIds.includes(finalGoalId))
791
+ throw new Error(`validation batch ${batchId} memberIds must contain finalGoalId ${finalGoalId}`);
792
+ for (const memberId of memberIds) assigned.add(memberId);
793
+ const canonicalMemberIds = [...memberIds].sort(
794
+ (left, right) => (goalOrder.get(left) ?? 0) - (goalOrder.get(right) ?? 0),
795
+ );
796
+ batches.push(
797
+ withValidationBatchHash({
798
+ schemaVersion: 1,
799
+ batchId,
800
+ memberIds: canonicalMemberIds,
801
+ finalGoalId,
802
+ mode: "aggregate-only",
803
+ }),
804
+ );
805
+ }
806
+ return batches;
807
+ }
808
+
809
+ function normalizeSavedValidationBatch(record: unknown, id: string): UltragoalValidationBatchMetadata | undefined {
810
+ if (typeof record !== "object" || record === null || Array.isArray(record)) return undefined;
811
+ const value = record as JsonObject;
812
+ if (value.schemaVersion !== 1) throw new Error(`Goal ${id} validation batch schemaVersion must be 1`);
813
+ const batchId = nonEmptyString(value.batchId);
814
+ if (!batchId) throw new Error(`Goal ${id} validation batch batchId is required`);
815
+ const memberIds = stringArray(value.memberIds);
816
+ if (!memberIds || memberIds.length === 0) throw new Error(`Goal ${id} validation batch memberIds must be non-empty`);
817
+ if (new Set(memberIds).size !== memberIds.length || memberIds.some(memberId => memberId.length === 0)) {
818
+ throw new Error(`Goal ${id} validation batch memberIds must be unique non-empty strings`);
819
+ }
820
+ if (!memberIds.includes(id)) throw new Error(`Goal ${id} validation batch must include its goal id`);
821
+ const finalGoalId = nonEmptyString(value.finalGoalId);
822
+ if (!finalGoalId || !memberIds.includes(finalGoalId))
823
+ throw new Error(`Goal ${id} validation batch finalGoalId must be a member`);
824
+ if (value.mode !== "aggregate-only") throw new Error(`Goal ${id} validation batch mode must be aggregate-only`);
825
+ const basis: Omit<UltragoalValidationBatchMetadata, "metadataHash"> = {
826
+ schemaVersion: 1,
827
+ batchId,
828
+ memberIds,
829
+ finalGoalId,
830
+ mode: "aggregate-only",
831
+ };
832
+ const metadataHash = nonEmptyString(value.metadataHash);
833
+ if (!metadataHash) throw new Error(`Goal ${id} validation batch metadataHash is required`);
834
+ const normalized = { ...basis, metadataHash } as UltragoalValidationBatchMetadata;
835
+ if (metadataHash !== hashValidationBatch(basis))
836
+ throw new Error(`Goal ${id} has stale validation batch metadata hash`);
837
+ return normalized;
838
+ }
839
+
840
+ function pipelineMetadataConflictsWithValidationBatch(metadata: UltragoalPipelineMetadata | undefined): boolean {
841
+ return metadata?.eligible === true || metadata?.source === "original_plan_graph" || metadata?.source === "steering";
842
+ }
843
+
844
+ function validateValidationBatchPipelineExclusion(goal: UltragoalGoal): void {
845
+ if (goal.validationBatch && pipelineMetadataConflictsWithValidationBatch(goal.pipelineMetadata)) {
846
+ throw new Error(`Goal ${goal.id} cannot combine validationBatch with eligible pipeline metadata`);
847
+ }
848
+ }
849
+ function requireFreshValidationBatchMetadata(goal: UltragoalGoal): UltragoalValidationBatchMetadata | undefined {
850
+ const metadata = goal.validationBatch;
851
+ if (!metadata) return undefined;
852
+ const { metadataHash, ...basis } = metadata;
853
+ if (metadataHash !== hashValidationBatch(basis))
854
+ throw new Error(`Goal ${goal.id} has stale validation batch metadata hash`);
855
+ validateValidationBatchPipelineExclusion(goal);
856
+ return metadata;
857
+ }
858
+
859
+ function findFreshValidationBatchClose(
860
+ plan: UltragoalPlan,
861
+ metadata: UltragoalValidationBatchMetadata,
862
+ member: UltragoalGoal,
863
+ ledger: readonly UltragoalLedgerEvent[],
864
+ ): UltragoalGoal | undefined {
865
+ const receipt = member.completionVerification;
866
+ if (!receipt) return undefined;
867
+ const finalReceipt = findFreshBatchCloseReceipt({ plan, ledger, deferredGoal: member, deferredReceipt: receipt });
868
+ if (!finalReceipt) return undefined;
869
+ const finalGoal = plan.goals.find(goal => goal.id === metadata.finalGoalId);
870
+ const close = finalReceipt.validationBatch;
871
+ if (!finalGoal || close?.role !== "batch-close") return undefined;
872
+ if (close.batchId !== metadata.batchId || close.finalGoalId !== metadata.finalGoalId) return undefined;
873
+ if (
874
+ close.memberIds.length !== metadata.memberIds.length ||
875
+ close.memberIds.some((id, index) => id !== metadata.memberIds[index])
876
+ )
877
+ return undefined;
878
+ if (close.memberMetadataHashes[member.id] !== metadata.metadataHash) return undefined;
879
+ return finalGoal;
880
+ }
881
+
882
+ function requireDeferredMemberReceiptFresh(
883
+ plan: UltragoalPlan,
884
+ ledger: readonly UltragoalLedgerEvent[],
885
+ member: UltragoalGoal,
886
+ fieldName: string,
887
+ ): UltragoalDeferredCompletionVerification {
888
+ const receipt = member.completionVerification;
889
+ if (!receipt) throw new Error(`${fieldName} requires fresh deferred receipt for ${member.id}`);
890
+ if (receipt.validationBatch?.role !== "deferred-member")
891
+ throw new Error(`${fieldName} requires fresh deferred receipt for ${member.id}`);
892
+ const diagnostic = validateDeferredMemberReceiptFresh({
893
+ plan,
894
+ ledger,
895
+ goal: member,
896
+ receipt,
897
+ receiptKind: "per-goal",
898
+ requireClose: false,
899
+ });
900
+ if (diagnostic.state !== "active_verified_complete")
901
+ throw new Error(`${fieldName}.${member.id} ${diagnostic.message}`);
902
+ return receipt as UltragoalDeferredCompletionVerification;
903
+ }
904
+
905
+ function requireFreshBatchCloseReceiptBasis(
906
+ plan: UltragoalPlan,
907
+ ledger: readonly UltragoalLedgerEvent[],
908
+ goal: UltragoalGoal,
909
+ receipt: UltragoalCompletionVerification,
910
+ event: UltragoalLedgerEvent,
911
+ ): void {
912
+ const batch = receipt.validationBatch;
913
+ if (batch?.role !== "batch-close") return;
914
+ const base = validateReceiptFreshBase({ plan, ledger, goal, receipt, receiptKind: receipt.receiptKind });
915
+ if (base) throw new Error(base.message);
916
+ for (const memberId of batch.memberIds) {
917
+ const member = plan.goals.find(item => item.id === memberId);
918
+ if (
919
+ !member?.validationBatch ||
920
+ member.validationBatch.batchId !== batch.batchId ||
921
+ member.validationBatch.metadataHash !== batch.memberMetadataHashes[memberId]
922
+ ) {
923
+ throw new Error(`Goal ${goal.id} has stale validation batch close receipt for ${batch.batchId}`);
924
+ }
925
+ if (memberId === batch.finalGoalId) continue;
926
+ const memberReceipt = requireDeferredMemberReceiptFresh(
927
+ plan,
928
+ ledger,
929
+ member,
930
+ `Goal ${goal.id} batch-close receipt`,
931
+ );
932
+ if (
933
+ batch.memberReceiptIds[memberId] !== memberReceipt.receiptId ||
934
+ batch.memberCheckpointLedgerEventIds[memberId] !== memberReceipt.checkpointLedgerEventId ||
935
+ batch.memberChangeSetHashes[memberId] !== memberReceipt.validationBatch!.changeSetHash
936
+ ) {
937
+ throw new Error(`Goal ${goal.id} batch-close receipt is stale for deferred member ${memberId}`);
938
+ }
939
+ }
940
+ const close = qualityGateObject(qualityGateObject(event.qualityGateJson)?.validationBatchClose);
941
+ const unionHash = String(qualityGateObject(close?.unionChangeSet)?.unionHash ?? "");
942
+ if (batch.unionHash !== unionHash)
943
+ throw new Error(`Goal ${goal.id} validation batch close receipt union hash is stale`);
944
+ }
945
+
946
+ function clearValidationBatchForBatch(
947
+ plan: UltragoalPlan,
948
+ metadata: UltragoalValidationBatchMetadata | undefined,
949
+ ): void {
950
+ if (!metadata) return;
951
+ for (const member of plan.goals) {
952
+ if (member.validationBatch?.batchId === metadata.batchId) delete member.validationBatch;
953
+ }
954
+ }
955
+
956
+ function freshDeferredValidationBatchBlocker(
957
+ plan: UltragoalPlan,
958
+ metadata: UltragoalValidationBatchMetadata,
959
+ ledger: readonly UltragoalLedgerEvent[],
960
+ ): UltragoalGoal | undefined {
961
+ for (const memberId of metadata.memberIds) {
962
+ const member = plan.goals.find(goal => goal.id === memberId);
963
+ if (!member?.validationBatch || member.status !== "complete") continue;
964
+ try {
965
+ requireDeferredMemberReceiptFresh(plan, ledger, member, "validation batch steering");
966
+ } catch {
967
+ continue;
968
+ }
969
+ if (!findFreshValidationBatchClose(plan, member.validationBatch, member, ledger)) return member;
970
+ }
971
+ return undefined;
972
+ }
973
+
974
+ function requireValidationBatchSteeringAllowed(
975
+ plan: UltragoalPlan,
976
+ goal: UltragoalGoal,
977
+ kind: UltragoalSteeringKind,
978
+ ledger: readonly UltragoalLedgerEvent[],
979
+ ): void {
980
+ const metadata = goal.validationBatch;
981
+ if (!metadata) return;
982
+ const blocker = freshDeferredValidationBatchBlocker(plan, metadata, ledger);
983
+ if (blocker)
984
+ throw new Error(
985
+ `steer ${kind} cannot invalidate validation batch ${metadata.batchId} while member ${blocker.id} has a fresh deferred receipt`,
986
+ );
987
+ }
988
+ function legacyPipelineMetadata(goalId: string): UltragoalPipelineMetadata {
989
+ const basis: Omit<UltragoalPipelineMetadata, "metadataHash"> = {
990
+ schemaVersion: 1,
991
+ goalId,
992
+ source: "legacy_brief_only",
993
+ eligible: false,
994
+ dependsOn: [],
995
+ independentOf: [],
996
+ targets: { files: [], surfaces: [] },
997
+ overlap: "none",
998
+ invalidationReason: "missing_pipeline_metadata",
999
+ };
1000
+ return withPipelineMetadataHash(basis);
1001
+ }
1002
+
1003
+ function normalizePipelineMetadataRecord(value: unknown, goalIds: ReadonlySet<string>): UltragoalPipelineMetadata {
1004
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1005
+ throw new Error("goal metadata rows must be objects");
1006
+ const record = value as JsonObject;
1007
+ if (record.schemaVersion !== 1) throw new Error("goal metadata schemaVersion must be 1");
1008
+ const goalId = nonEmptyString(record.goalId);
1009
+ if (!goalId || !goalIds.has(goalId)) throw new Error(`goal metadata references unknown goal id ${goalId ?? ""}`);
1010
+ const source = record.source;
1011
+ if (source !== "original_plan_graph" && source !== "legacy_brief_only" && source !== "steering") {
1012
+ throw new Error("goal metadata source must be original_plan_graph, legacy_brief_only, or steering");
1013
+ }
1014
+ const dependsOn = normalizePipelineStringArray(record.dependsOn, `metadata ${goalId}.dependsOn`);
1015
+ const independentOf = normalizePipelineStringArray(record.independentOf, `metadata ${goalId}.independentOf`);
1016
+ if (dependsOn.includes(goalId) || independentOf.includes(goalId))
1017
+ throw new Error(`goal metadata ${goalId} cannot reference itself`);
1018
+ for (const id of [...dependsOn, ...independentOf]) {
1019
+ if (!goalIds.has(id)) throw new Error(`goal metadata ${goalId} references unknown goal id ${id}`);
1020
+ }
1021
+ if (dependsOn.some(id => independentOf.includes(id)))
1022
+ throw new Error(`goal metadata ${goalId} has dependency/independence conflict`);
1023
+ const targets = requireNonEmptyPipelineTargets(record.targets, `metadata ${goalId}.targets`);
1024
+ const basis: Omit<UltragoalPipelineMetadata, "metadataHash"> = {
1025
+ schemaVersion: 1,
1026
+ goalId,
1027
+ source,
1028
+ eligible: false,
1029
+ dependsOn,
1030
+ independentOf,
1031
+ targets,
1032
+ overlap: "none",
1033
+ };
1034
+ return withPipelineMetadataHash(basis);
1035
+ }
1036
+
1037
+ function targetsAreDisjoint(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
1038
+ return (
1039
+ left.files.every(file => !right.files.includes(file)) &&
1040
+ left.surfaces.every(surface => !right.surfaces.includes(surface))
1041
+ );
1042
+ }
1043
+
1044
+ function targetsOverlap(left: UltragoalPipelineTargets, right: UltragoalPipelineTargets): boolean {
1045
+ return (
1046
+ left.files.some(file => right.files.includes(file)) ||
1047
+ left.surfaces.some(surface => right.surfaces.includes(surface))
1048
+ );
1049
+ }
1050
+
1051
+ function requireNonEmptyPipelineTargets(value: unknown, fieldName: string): UltragoalPipelineTargets {
1052
+ const targets = normalizePipelineTargets(value, fieldName);
1053
+ if (targets.files.length === 0 && targets.surfaces.length === 0)
1054
+ throw new Error(`${fieldName} requires files or surfaces`);
1055
+ return targets;
1056
+ }
1057
+
1058
+ function collectPipelineBlockerFootprints(result: JsonObject, fieldName: string): UltragoalPipelineTargets[] {
1059
+ const raw = Array.isArray(result.blockers)
1060
+ ? result.blockers
1061
+ : Array.isArray(result.blockerFootprints)
1062
+ ? result.blockerFootprints
1063
+ : [];
1064
+ return raw.map((item, index) => {
1065
+ const record = requireJsonObjectValue(item, `${fieldName}.blockers[${index}]`);
1066
+ const footprint = typeof record.footprint === "object" && record.footprint !== null ? record.footprint : record;
1067
+ return requireNonEmptyPipelineTargets(footprint, `${fieldName}.blockers[${index}].footprint`);
1068
+ });
1069
+ }
1070
+
1071
+ function pipelineTargetsCoverPath(targets: UltragoalPipelineTargets, filePath: string): boolean {
1072
+ const normalized = normalizeRepoPath(filePath);
1073
+ return targets.files.some(target => normalized === target || normalized.startsWith(`${target}/`));
1074
+ }
1075
+
1076
+ function pipelinePeer(plan: UltragoalPlan, metadata: UltragoalPipelineMetadata): UltragoalGoal | undefined {
1077
+ const peerId = metadata.goalId === metadata.priorGoalId ? metadata.nextGoalId : metadata.priorGoalId;
1078
+ return peerId ? plan.goals.find(goal => goal.id === peerId) : undefined;
1079
+ }
1080
+
1081
+ function handleIdsFromValue(value: JsonObject | JsonObject[], fieldName: string): string[] {
1082
+ const records = Array.isArray(value) ? value : [value];
1083
+ const ids = records.map(
1084
+ (record, index) =>
1085
+ nonEmptyString(record.id) ??
1086
+ nonEmptyString(record.handleId) ??
1087
+ nonEmptyString(record.name) ??
1088
+ `${fieldName}-${index}`,
1089
+ );
1090
+ if (ids.some(id => id.length === 0)) throw new Error(`${fieldName} handles require ids`);
1091
+ return ids;
1092
+ }
1093
+
1094
+ function resultHandleIds(value: JsonObject, fieldName: string): string[] {
1095
+ const ids = stringArray(value.handleIds) ?? stringArray(value.handles) ?? [];
1096
+ if (ids.length === 0) throw new Error(`${fieldName} requires handleIds`);
1097
+ return ids;
1098
+ }
1099
+
1100
+ function requireCoveredHandles(expected: readonly string[], actual: readonly string[], fieldName: string): void {
1101
+ const missing = expected.filter(id => !actual.includes(id));
1102
+ if (missing.length > 0) throw new Error(`${fieldName} is missing handle coverage for ${missing.join(", ")}`);
1103
+ }
1104
+
1105
+ function validatePipelineEligibility(metadata: UltragoalPipelineMetadata[]): UltragoalPipelineMetadata[] {
1106
+ const byId = new Map(metadata.map(item => [item.goalId, item]));
1107
+ return metadata.map(item => {
1108
+ const invalidationReasons: string[] = [];
1109
+ if (item.source !== "original_plan_graph") invalidationReasons.push("not_original_plan_graph");
1110
+ if (item.targets.files.length === 0 && item.targets.surfaces.length === 0)
1111
+ invalidationReasons.push("empty_targets");
1112
+ for (const otherId of item.independentOf) {
1113
+ const other = byId.get(otherId);
1114
+ if (!other?.independentOf.includes(item.goalId))
1115
+ invalidationReasons.push(`missing_symmetric_independence:${otherId}`);
1116
+ if (other && !targetsAreDisjoint(item.targets, other.targets))
1117
+ invalidationReasons.push(`shared_targets:${otherId}`);
1118
+ }
1119
+ const eligible = invalidationReasons.length === 0;
1120
+ return {
1121
+ ...item,
1122
+ eligible,
1123
+ ...(eligible ? {} : { invalidationReason: invalidationReasons.join(",") || "ineligible" }),
1124
+ };
1125
+ });
1126
+ }
1127
+
1128
+ function parseGoalMetadataInput(value: unknown, goalIds: ReadonlySet<string>): UltragoalPipelineMetadata[] {
1129
+ if (!Array.isArray(value)) throw new Error("goal metadata JSON must be an array");
1130
+ const seen = new Set<string>();
1131
+ const metadata = value.map(row => {
1132
+ const item = normalizePipelineMetadataRecord(row, goalIds);
1133
+ if (seen.has(item.goalId)) throw new Error(`duplicate goal metadata for ${item.goalId}`);
1134
+ seen.add(item.goalId);
1135
+ return item;
1136
+ });
1137
+ return validatePipelineEligibility(metadata);
1138
+ }
1139
+ function normalizeSavedPipelineMetadata(value: unknown, goalId: string): UltragoalPipelineMetadata | undefined {
1140
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
1141
+ const record = value as JsonObject;
1142
+ const source =
1143
+ record.source === "original_plan_graph" || record.source === "legacy_brief_only" || record.source === "steering"
1144
+ ? record.source
1145
+ : "legacy_brief_only";
1146
+ const overlap =
1147
+ record.overlap === "open" ||
1148
+ record.overlap === "joined_clean" ||
1149
+ record.overlap === "blocked_disjoint_continue" ||
1150
+ record.overlap === "quarantine_required" ||
1151
+ record.overlap === "rebaseline_complete"
1152
+ ? record.overlap
1153
+ : "none";
1154
+ const basis: Omit<UltragoalPipelineMetadata, "metadataHash"> = {
1155
+ schemaVersion: 1,
1156
+ goalId,
1157
+ source,
1158
+ eligible: record.eligible === true,
1159
+ dependsOn: normalizePipelineStringArray(record.dependsOn, `metadata ${goalId}.dependsOn`),
1160
+ independentOf: normalizePipelineStringArray(record.independentOf, `metadata ${goalId}.independentOf`),
1161
+ targets: normalizePipelineTargets(record.targets, `metadata ${goalId}.targets`),
1162
+ overlap,
1163
+ ...(nonEmptyString(record.overlapId) ? { overlapId: nonEmptyString(record.overlapId)! } : {}),
1164
+ ...(nonEmptyString(record.priorGoalId) ? { priorGoalId: nonEmptyString(record.priorGoalId)! } : {}),
1165
+ ...(nonEmptyString(record.nextGoalId) ? { nextGoalId: nonEmptyString(record.nextGoalId)! } : {}),
1166
+ ...(Array.isArray(record.blockerFootprints)
1167
+ ? {
1168
+ blockerFootprints: record.blockerFootprints.map((item, index) =>
1169
+ normalizePipelineTargets(item, `metadata ${goalId}.blockerFootprints[${index}]`),
1170
+ ),
1171
+ }
1172
+ : {}),
1173
+ ...(nonEmptyString(record.invalidationReason)
1174
+ ? { invalidationReason: nonEmptyString(record.invalidationReason)! }
1175
+ : {}),
1176
+ ...(nonEmptyString(record.invalidatedAt) ? { invalidatedAt: nonEmptyString(record.invalidatedAt)! } : {}),
1177
+ };
1178
+ return {
1179
+ ...basis,
1180
+ metadataHash: nonEmptyString(record.metadataHash) ?? hashPipelineMetadata(basis),
1181
+ } as UltragoalPipelineMetadata;
1182
+ }
1183
+
1184
+ function currentPipelineHash(metadata: UltragoalPipelineMetadata): string {
1185
+ return hashPipelineMetadata(metadata);
1186
+ }
1187
+
1188
+ function requireFreshPipelineMetadata(goal: UltragoalGoal): UltragoalPipelineMetadata {
1189
+ const metadata = goal.pipelineMetadata;
1190
+ if (!metadata) throw new Error(`Goal ${goal.id} has no pipeline metadata`);
1191
+ if (metadata.metadataHash !== currentPipelineHash(metadata))
1192
+ throw new Error(`Goal ${goal.id} has stale pipeline metadata hash`);
1193
+ return metadata;
1194
+ }
1195
+
1196
+ function openPipelineOverlap(
1197
+ plan: UltragoalPlan,
1198
+ ): { prior: UltragoalGoal; next: UltragoalGoal; overlapId: string } | null {
1199
+ const openGoals = plan.goals.filter(goal => goal.pipelineMetadata?.overlap === "open");
1200
+ if (openGoals.length === 0) return null;
1201
+ const overlapId = openGoals[0]?.pipelineMetadata?.overlapId;
1202
+ if (!overlapId) return null;
1203
+ const peers = openGoals.filter(goal => goal.pipelineMetadata?.overlapId === overlapId);
1204
+ if (peers.length !== 2) return null;
1205
+ const prior = peers[0];
1206
+ const next = peers[1];
1207
+ if (!prior || !next) return null;
1208
+ return { prior, next, overlapId };
1209
+ }
1210
+
1211
+ function invalidatePipelineMetadata(goal: UltragoalGoal, reason: string, now: string): void {
1212
+ const basis: Omit<UltragoalPipelineMetadata, "metadataHash"> = {
1213
+ schemaVersion: 1,
1214
+ goalId: goal.id,
1215
+ source: goal.pipelineMetadata?.source ?? "steering",
1216
+ eligible: false,
1217
+ dependsOn: goal.pipelineMetadata?.dependsOn ?? [],
1218
+ independentOf: goal.pipelineMetadata?.independentOf ?? [],
1219
+ targets: goal.pipelineMetadata?.targets ?? { files: [], surfaces: [] },
1220
+ overlap: "none",
1221
+ invalidationReason: reason,
1222
+ invalidatedAt: now,
1223
+ };
1224
+ goal.pipelineMetadata = withPipelineMetadataHash(basis);
1225
+ }
600
1226
 
601
1227
  function normalizeGoalStatus(value: unknown): UltragoalGoalStatus {
602
1228
  switch (value) {
@@ -638,6 +1264,8 @@ function normalizePlan(raw: unknown): UltragoalPlan {
638
1264
  const title = nonEmptyString(goalRecord.title) ?? id;
639
1265
  const objective = nonEmptyString(goalRecord.objective) ?? title;
640
1266
  const goalCreatedAt = nonEmptyString(goalRecord.createdAt) ?? createdAt;
1267
+ const pipelineMetadata = normalizeSavedPipelineMetadata(goalRecord.pipelineMetadata, id);
1268
+ const validationBatch = normalizeSavedValidationBatch(goalRecord.validationBatch, id);
641
1269
  return {
642
1270
  ...goalRecord,
643
1271
  id,
@@ -657,6 +1285,8 @@ function normalizePlan(raw: unknown): UltragoalPlan {
657
1285
  typeof goalRecord.completionVerification === "object" && goalRecord.completionVerification !== null
658
1286
  ? (goalRecord.completionVerification as UltragoalCompletionVerification)
659
1287
  : undefined,
1288
+ pipelineMetadata,
1289
+ validationBatch,
660
1290
  };
661
1291
  });
662
1292
  const aliases = Array.isArray(record.skcObjectiveAliases)
@@ -713,6 +1343,7 @@ export async function getUltragoalStatus(cwd: string, sessionId?: string | null)
713
1343
  if (!plan) return { exists: false, status: "missing", paths, counts, goals: [] };
714
1344
  for (const goal of plan.goals) counts[goal.status] += 1;
715
1345
  const currentGoal = plan.goals.find(goal => SCHEDULABLE_STATUSES.has(goal.status));
1346
+ const overlap = openPipelineOverlap(plan);
716
1347
  let status: UltragoalStatusSummary["status"] = "pending";
717
1348
  if (plan.goals.length > 0 && plan.goals.every(goal => TERMINAL_OR_SKIPPED_STATUSES.has(goal.status)))
718
1349
  status = "complete";
@@ -742,6 +1373,16 @@ export async function getUltragoalStatus(cwd: string, sessionId?: string | null)
742
1373
  counts,
743
1374
  goals: plan.goals,
744
1375
  ...nudgeFields,
1376
+ ...(overlap
1377
+ ? {
1378
+ pipelineOverlap: {
1379
+ overlapId: overlap.overlapId,
1380
+ priorGoalId: overlap.prior.id,
1381
+ nextGoalId: overlap.next.id,
1382
+ status: overlap.next.pipelineMetadata?.overlap,
1383
+ },
1384
+ }
1385
+ : {}),
745
1386
  };
746
1387
  }
747
1388
  export function buildUltragoalHudSummary(
@@ -816,6 +1457,10 @@ export async function createUltragoalPlan(input: {
816
1457
  brief: string;
817
1458
  skcGoalMode?: UltragoalSkcGoalMode;
818
1459
  sessionId?: string | null;
1460
+ goalMetadata?: UltragoalGoalMetadataInput[];
1461
+ goalMetadataJson?: string;
1462
+ validationBatches?: UltragoalValidationBatchInput[];
1463
+ validationBatchJson?: string;
819
1464
  }): Promise<UltragoalPlan> {
820
1465
  const brief = input.brief.trim();
821
1466
  if (!brief) throw new Error("ultragoal brief is required");
@@ -831,6 +1476,33 @@ export async function createUltragoalPlan(input: {
831
1476
  createdAt: now,
832
1477
  updatedAt: now,
833
1478
  }));
1479
+ const goalIds = new Set(goals.map(goal => goal.id));
1480
+ const metadataInput = input.goalMetadataJson
1481
+ ? await readStructuredValue(input.cwd, input.goalMetadataJson)
1482
+ : input.goalMetadata;
1483
+ const validationBatchInput = input.validationBatchJson
1484
+ ? await readStructuredValue(input.cwd, input.validationBatchJson)
1485
+ : input.validationBatches;
1486
+ if (metadataInput !== undefined && validationBatchInput !== undefined) {
1487
+ const metadataRows = Array.isArray(metadataInput) ? metadataInput : [];
1488
+ const batchRows = Array.isArray(validationBatchInput) ? validationBatchInput : [];
1489
+ if (metadataRows.length > 0 && batchRows.length > 0)
1490
+ throw new Error("validation-batch-json and goal-metadata-json are mutually exclusive");
1491
+ }
1492
+ const metadata = metadataInput === undefined ? [] : parseGoalMetadataInput(metadataInput, goalIds);
1493
+ const validationBatches =
1494
+ validationBatchInput === undefined
1495
+ ? []
1496
+ : parseValidationBatchInput(validationBatchInput, goalIds, input.skcGoalMode ?? "aggregate");
1497
+ const metadataByGoalId = new Map(metadata.map(item => [item.goalId, item]));
1498
+ const validationBatchByGoalId = new Map<string, UltragoalValidationBatchMetadata>();
1499
+ for (const batch of validationBatches)
1500
+ for (const memberId of batch.memberIds) validationBatchByGoalId.set(memberId, batch);
1501
+ for (const goal of goals) {
1502
+ goal.pipelineMetadata = metadataByGoalId.get(goal.id) ?? legacyPipelineMetadata(goal.id);
1503
+ goal.validationBatch = validationBatchByGoalId.get(goal.id);
1504
+ validateValidationBatchPipelineExclusion(goal);
1505
+ }
834
1506
  const plan: UltragoalPlan = {
835
1507
  version: 1,
836
1508
  brief,
@@ -860,6 +1532,305 @@ export interface UltragoalRunCompletionState {
860
1532
  hasBlockers: boolean;
861
1533
  needsFinalAggregateReceipt: boolean;
862
1534
  }
1535
+ function requireJsonObjectValue(value: unknown, fieldName: string): JsonObject {
1536
+ if (typeof value !== "object" || value === null || Array.isArray(value))
1537
+ throw new Error(`${fieldName} must be an object`);
1538
+ if (Object.keys(value).length === 0) throw new Error(`${fieldName} must be non-empty`);
1539
+ return value as JsonObject;
1540
+ }
1541
+
1542
+ function requireJsonObjectOrArrayValue(value: unknown, fieldName: string): JsonObject | JsonObject[] {
1543
+ if (Array.isArray(value)) {
1544
+ if (value.length === 0) throw new Error(`${fieldName} must be non-empty`);
1545
+ return value.map((item, index) => requireJsonObjectValue(item, `${fieldName}[${index}]`));
1546
+ }
1547
+ return requireJsonObjectValue(value, fieldName);
1548
+ }
1549
+
1550
+ async function readRequiredJsonObject(cwd: string, value: string, fieldName: string): Promise<JsonObject> {
1551
+ return requireJsonObjectValue(await readStructuredValue(cwd, value), fieldName);
1552
+ }
1553
+
1554
+ async function readRequiredJsonObjectOrArray(
1555
+ cwd: string,
1556
+ value: string,
1557
+ fieldName: string,
1558
+ ): Promise<JsonObject | JsonObject[]> {
1559
+ return requireJsonObjectOrArrayValue(await readStructuredValue(cwd, value), fieldName);
1560
+ }
1561
+
1562
+ function requirePipelineStartable(plan: UltragoalPlan, prior: UltragoalGoal, next: UltragoalGoal): void {
1563
+ if (plan.skcGoalMode !== "aggregate")
1564
+ throw new Error("pipeline overlap is supported only for aggregate ultragoal mode");
1565
+ if (openPipelineOverlap(plan))
1566
+ throw new Error("Cannot start pipeline overlap because another overlap is already open");
1567
+ if (prior.status !== "active")
1568
+ throw new Error(`Prior goal ${prior.id} must be active before pipeline overlap starts`);
1569
+ if (next.status !== "pending" && next.status !== "failed")
1570
+ throw new Error(`Next goal ${next.id} must be pending or retryable failed`);
1571
+ validateValidationBatchPipelineExclusion(prior);
1572
+ validateValidationBatchPipelineExclusion(next);
1573
+ if (prior.validationBatch || next.validationBatch)
1574
+ throw new Error("pipeline overlap cannot start for validation batch goals");
1575
+ const priorMetadata = requireFreshPipelineMetadata(prior);
1576
+ const nextMetadata = requireFreshPipelineMetadata(next);
1577
+ if (!priorMetadata.eligible || !nextMetadata.eligible)
1578
+ throw new Error("pipeline overlap requires eligible original-plan metadata on both goals");
1579
+ if (!priorMetadata.independentOf.includes(next.id) || !nextMetadata.independentOf.includes(prior.id)) {
1580
+ throw new Error("pipeline overlap requires symmetric original independence");
1581
+ }
1582
+ if (!targetsAreDisjoint(priorMetadata.targets, nextMetadata.targets))
1583
+ throw new Error("pipeline overlap requires disjoint files and surfaces");
1584
+ }
1585
+
1586
+ function pipelineEventRefs(prior: UltragoalGoal, next: UltragoalGoal): JsonObject {
1587
+ return {
1588
+ priorMetadataHash: prior.pipelineMetadata?.metadataHash,
1589
+ nextMetadataHash: next.pipelineMetadata?.metadataHash,
1590
+ priorTargets: prior.pipelineMetadata?.targets,
1591
+ nextTargets: next.pipelineMetadata?.targets,
1592
+ };
1593
+ }
1594
+
1595
+ function pipelineReceipt(
1596
+ cwd: string,
1597
+ event: string,
1598
+ overlapId: string,
1599
+ prior: UltragoalGoal,
1600
+ next: UltragoalGoal,
1601
+ ): UltragoalPipelineOverlapReceipt {
1602
+ const paths = getUltragoalPaths(cwd, currentUltragoalSessionId(cwd));
1603
+ return {
1604
+ ok: true,
1605
+ event,
1606
+ overlap_id: overlapId,
1607
+ prior_goal_id: prior.id,
1608
+ next_goal_id: next.id,
1609
+ status: next.pipelineMetadata?.overlap,
1610
+ next_goal_status: next.status,
1611
+ goals_path: paths.goalsPath,
1612
+ ledger_path: paths.ledgerPath,
1613
+ };
1614
+ }
1615
+
1616
+ export async function startUltragoalPipelineOverlap(input: {
1617
+ cwd: string;
1618
+ priorGoalId: string;
1619
+ nextGoalId: string;
1620
+ reviewHandles: JsonObject | JsonObject[];
1621
+ qaHandles: JsonObject | JsonObject[];
1622
+ implementationHandle: JsonObject;
1623
+ }): Promise<UltragoalPipelineOverlapReceipt> {
1624
+ const plan = await readUltragoalPlan(input.cwd);
1625
+ if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
1626
+ const prior = plan.goals.find(goal => goal.id === input.priorGoalId);
1627
+ const next = plan.goals.find(goal => goal.id === input.nextGoalId);
1628
+ if (!prior || !next) throw new Error("start-pipeline-overlap requires existing prior and next goal ids");
1629
+ const reviewHandles = requireJsonObjectOrArrayValue(input.reviewHandles, "review handles");
1630
+ const qaHandles = requireJsonObjectOrArrayValue(input.qaHandles, "QA handles");
1631
+ requireJsonObjectValue(input.implementationHandle, "implementation handle");
1632
+ requirePipelineStartable(plan, prior, next);
1633
+ const now = new Date().toISOString();
1634
+ const overlapId = `pipeline-${crypto.randomUUID()}`;
1635
+ const priorMetadata = requireFreshPipelineMetadata(prior);
1636
+ const nextMetadata = requireFreshPipelineMetadata(next);
1637
+ const priorOpenMetadata = {
1638
+ ...priorMetadata,
1639
+ overlap: "open" as const,
1640
+ overlapId,
1641
+ priorGoalId: prior.id,
1642
+ nextGoalId: next.id,
1643
+ };
1644
+ const nextOpenMetadata = {
1645
+ ...nextMetadata,
1646
+ overlap: "open" as const,
1647
+ overlapId,
1648
+ priorGoalId: prior.id,
1649
+ nextGoalId: next.id,
1650
+ };
1651
+ prior.pipelineMetadata = { ...priorOpenMetadata, metadataHash: hashPipelineMetadata(priorOpenMetadata) };
1652
+ next.pipelineMetadata = { ...nextOpenMetadata, metadataHash: hashPipelineMetadata(nextOpenMetadata) };
1653
+ prior.updatedAt = now;
1654
+ next.updatedAt = now;
1655
+ next.status = "active";
1656
+ next.startedAt = next.startedAt ?? now;
1657
+ plan.updatedAt = now;
1658
+ await writePlan(input.cwd, plan);
1659
+ const refs = pipelineEventRefs(prior, next);
1660
+ const expectedReviewHandleIds = handleIdsFromValue(reviewHandles, "review");
1661
+ const expectedQaHandleIds = handleIdsFromValue(qaHandles, "QA");
1662
+ await appendLedger(input.cwd, {
1663
+ event: "pipeline_overlap_started",
1664
+ eventId: crypto.randomUUID(),
1665
+ timestamp: now,
1666
+ schemaVersion: 1,
1667
+ overlapId,
1668
+ priorGoalId: prior.id,
1669
+ nextGoalId: next.id,
1670
+ reviewHandles,
1671
+ reviewHandleIds: expectedReviewHandleIds,
1672
+ qaHandles,
1673
+ qaHandleIds: expectedQaHandleIds,
1674
+ implementationHandle: input.implementationHandle,
1675
+ ...refs,
1676
+ });
1677
+ await appendLedger(input.cwd, { event: "goal_started", goalId: next.id, pipelineOverlapId: overlapId });
1678
+ return pipelineReceipt(input.cwd, "pipeline_overlap_started", overlapId, prior, next);
1679
+ }
1680
+
1681
+ function resultStatus(value: JsonObject, fieldName: string): string {
1682
+ const status = nonEmptyString(value.status) ?? nonEmptyString(value.verdict) ?? nonEmptyString(value.result);
1683
+ if (!status) throw new Error(`${fieldName} requires status, verdict, or result`);
1684
+ return status.toLowerCase();
1685
+ }
1686
+
1687
+ function requireCleanPipelineResult(result: JsonObject, expectedHandleIds: readonly string[], fieldName: string): void {
1688
+ const status = resultStatus(result, fieldName);
1689
+ if (!["passed", "pass", "approved", "clear"].includes(status)) throw new Error(`${fieldName} did not pass`);
1690
+ const evidence = nonEmptyString(result.evidence);
1691
+ if (!evidence || !isSubstantiveEvidence(evidence)) throw new Error(`${fieldName} requires substantive evidence`);
1692
+ requireCoveredHandles(expectedHandleIds, resultHandleIds(result, fieldName), fieldName);
1693
+ if (collectPipelineBlockerFootprints(result, fieldName).length > 0)
1694
+ throw new Error(`${fieldName} cannot clean-join with blockers`);
1695
+ }
1696
+
1697
+ function pipelineStartEventHandleIds(
1698
+ ledger: UltragoalLedgerEvent[],
1699
+ overlapId: string,
1700
+ ): { review: string[]; qa: string[] } {
1701
+ const event = ledger.find(row => row.event === "pipeline_overlap_started" && row.overlapId === overlapId) as
1702
+ | JsonObject
1703
+ | undefined;
1704
+ if (!event) throw new Error(`No pipeline_overlap_started event found for ${overlapId}`);
1705
+ const review =
1706
+ stringArray(event.reviewHandleIds) ??
1707
+ handleIdsFromValue(requireJsonObjectOrArrayValue(event.reviewHandles, "review handles"), "review");
1708
+ const qa =
1709
+ stringArray(event.qaHandleIds) ??
1710
+ handleIdsFromValue(requireJsonObjectOrArrayValue(event.qaHandles, "QA handles"), "QA");
1711
+ return { review, qa };
1712
+ }
1713
+
1714
+ export async function joinUltragoalPipelineOverlap(input: {
1715
+ cwd: string;
1716
+ overlapId: string;
1717
+ reviewResult: JsonObject;
1718
+ qaResult: JsonObject;
1719
+ }): Promise<UltragoalPipelineOverlapReceipt> {
1720
+ const plan = await readUltragoalPlan(input.cwd);
1721
+ if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
1722
+ const overlap = openPipelineOverlap(plan);
1723
+ if (!overlap || overlap.overlapId !== input.overlapId)
1724
+ throw new Error(`No open pipeline overlap found for ${input.overlapId}`);
1725
+ const { prior, next, overlapId } = overlap;
1726
+ const nextMetadata = requireFreshPipelineMetadata(next);
1727
+ const ledger = await readUltragoalLedger(input.cwd);
1728
+ const expectedHandles = pipelineStartEventHandleIds(ledger, overlapId);
1729
+ const reviewBlockers = collectPipelineBlockerFootprints(input.reviewResult, "review result");
1730
+ const qaBlockers = collectPipelineBlockerFootprints(input.qaResult, "QA result");
1731
+ const blockerFootprints = [...reviewBlockers, ...qaBlockers];
1732
+ let state: UltragoalPipelineOverlapState;
1733
+ let event: UltragoalPipelineLedgerEventName;
1734
+ if (blockerFootprints.length === 0) {
1735
+ try {
1736
+ requireCleanPipelineResult(input.reviewResult, expectedHandles.review, "review result");
1737
+ requireCleanPipelineResult(input.qaResult, expectedHandles.qa, "QA result");
1738
+ state = "joined_clean";
1739
+ event = "pipeline_overlap_joined";
1740
+ } catch {
1741
+ state = "quarantine_required";
1742
+ event = "pipeline_overlap_quarantined";
1743
+ }
1744
+ } else if (blockerFootprints.every(footprint => !targetsOverlap(footprint, nextMetadata.targets))) {
1745
+ state = "blocked_disjoint_continue";
1746
+ event = "pipeline_overlap_joined";
1747
+ } else {
1748
+ state = "quarantine_required";
1749
+ event = "pipeline_overlap_quarantined";
1750
+ }
1751
+ const now = new Date().toISOString();
1752
+ for (const goal of [prior, next]) {
1753
+ const metadata = requireFreshPipelineMetadata(goal);
1754
+ const joinedMetadata = { ...metadata, overlap: state, blockerFootprints };
1755
+ goal.pipelineMetadata = { ...joinedMetadata, metadataHash: hashPipelineMetadata(joinedMetadata) };
1756
+ goal.updatedAt = now;
1757
+ }
1758
+ if (state === "quarantine_required") next.status = "blocked";
1759
+ plan.updatedAt = now;
1760
+ await writePlan(input.cwd, plan);
1761
+ await appendLedger(input.cwd, {
1762
+ event,
1763
+ eventId: crypto.randomUUID(),
1764
+ timestamp: now,
1765
+ schemaVersion: 1,
1766
+ overlapId,
1767
+ priorGoalId: prior.id,
1768
+ nextGoalId: next.id,
1769
+ status: state,
1770
+ reviewResult: input.reviewResult,
1771
+ qaResult: input.qaResult,
1772
+ blockerFootprints,
1773
+ ...pipelineEventRefs(prior, next),
1774
+ });
1775
+ return pipelineReceipt(input.cwd, event, overlapId, prior, next);
1776
+ }
1777
+
1778
+ export async function rebaselineUltragoalPipelineOverlap(input: {
1779
+ cwd: string;
1780
+ overlapId: string;
1781
+ goalId: string;
1782
+ evidence: string;
1783
+ targetState: JsonObject;
1784
+ }): Promise<UltragoalPipelineOverlapReceipt> {
1785
+ const plan = await readUltragoalPlan(input.cwd);
1786
+ if (!plan) throw new Error("No ultragoal plan found. Run `skc ultragoal create-goals --brief ...` first.");
1787
+ const goal = plan.goals.find(item => item.id === input.goalId);
1788
+ if (!goal) throw new Error(`No ultragoal goal found for ${input.goalId}.`);
1789
+ const evidence = input.evidence.trim();
1790
+ if (!isSubstantiveEvidence(evidence)) throw new Error("rebaseline-pipeline-overlap requires substantive evidence");
1791
+ const targetState = requireNonEmptyPipelineTargets(input.targetState, "target state");
1792
+ const metadata = requireFreshPipelineMetadata(goal);
1793
+ if (metadata.overlap !== "quarantine_required" || metadata.overlapId !== input.overlapId) {
1794
+ throw new Error(`Goal ${goal.id} is not quarantined for overlap ${input.overlapId}`);
1795
+ }
1796
+ for (const footprint of metadata.blockerFootprints ?? []) {
1797
+ if (targetsOverlap(footprint, targetState))
1798
+ throw new Error("rebaseline-pipeline-overlap target state overlaps unresolved blocker footprints");
1799
+ }
1800
+ const now = new Date().toISOString();
1801
+ const rebaselinedMetadata = { ...metadata, overlap: "rebaseline_complete" as const, targets: targetState };
1802
+ goal.pipelineMetadata = { ...rebaselinedMetadata, metadataHash: hashPipelineMetadata(rebaselinedMetadata) };
1803
+ goal.status = "active";
1804
+ goal.evidence = evidence;
1805
+ goal.updatedAt = now;
1806
+ plan.updatedAt = now;
1807
+ await writePlan(input.cwd, plan);
1808
+ const peer = pipelinePeer(plan, metadata);
1809
+ await appendLedger(input.cwd, {
1810
+ event: "pipeline_overlap_rebaselined",
1811
+ eventId: crypto.randomUUID(),
1812
+ timestamp: now,
1813
+ schemaVersion: 1,
1814
+ overlapId: input.overlapId,
1815
+ priorGoalId: metadata.priorGoalId ?? peer?.id ?? "",
1816
+ nextGoalId: metadata.nextGoalId ?? goal.id,
1817
+ goalId: goal.id,
1818
+ evidence,
1819
+ targetState,
1820
+ metadataHash: goal.pipelineMetadata.metadataHash,
1821
+ });
1822
+ const paths = getUltragoalPaths(input.cwd, currentUltragoalSessionId(input.cwd));
1823
+ return {
1824
+ ok: true,
1825
+ event: "pipeline_overlap_rebaselined",
1826
+ overlap_id: input.overlapId,
1827
+ prior_goal_id: metadata.priorGoalId ?? peer?.id ?? "",
1828
+ goal_id: goal.id,
1829
+ status: goal.pipelineMetadata.overlap,
1830
+ goals_path: paths.goalsPath,
1831
+ ledger_path: paths.ledgerPath,
1832
+ };
1833
+ }
863
1834
 
864
1835
  export function getUltragoalRunCompletionState(
865
1836
  plan: UltragoalPlan,
@@ -1074,6 +2045,7 @@ interface UltragoalChangeSet extends JsonObject {
1074
2045
  mergeBase?: string;
1075
2046
  paths: UltragoalChangeSetPath[];
1076
2047
  rawDiffStat?: string;
2048
+ rawDiff?: string;
1077
2049
  trusted: true;
1078
2050
  }
1079
2051
 
@@ -1086,11 +2058,16 @@ const MANDATORY_COMPUTER_CASE_IDS = [
1086
2058
  "runaway-loop-halt",
1087
2059
  "blast-radius",
1088
2060
  ] as const;
2061
+ const TOOLS_INDEX_PATH = "packages/coding-agent/src/tools/index.ts";
1089
2062
 
1090
2063
  function normalizeRepoPath(value: string): string {
1091
2064
  return value.replaceAll("\\\\", "/").replace(/^\.\//, "");
1092
2065
  }
1093
2066
 
2067
+ function isToolsIndexPath(value: string): boolean {
2068
+ return normalizeRepoPath(value) === TOOLS_INDEX_PATH;
2069
+ }
2070
+
1094
2071
  function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
1095
2072
  const normalized = normalizeRepoPath(value);
1096
2073
  if (normalized.startsWith("crates/pi-natives/src/computer/")) return "code";
@@ -1101,7 +2078,6 @@ function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
1101
2078
  )
1102
2079
  return "tool";
1103
2080
  if (
1104
- normalized === "packages/coding-agent/src/tools/index.ts" ||
1105
2081
  normalized === "packages/coding-agent/src/tools/renderers.ts" ||
1106
2082
  normalized === "packages/coding-agent/src/config/settings-schema.ts"
1107
2083
  )
@@ -1119,18 +2095,76 @@ function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
1119
2095
  function isComputerControlSurfaceCategory(category: UltragoalChangeCategory): boolean {
1120
2096
  // The computer-use red-team suite is conditional, not universal (see the
1121
2097
  // ultragoal SKILL): require it only when the change actually touches
1122
- // computer-control source — the computer tool (`tool`), its settings/registry
1123
- // wiring (`settings-registry`), or computer Rust (`code`). A bare regeneration
1124
- // of the SHARED native binding (`generated-binding`: packages/natives/native/
1125
- // index.{d.ts,js}) is NOT by itself a computer-use change: that file is
1126
- // generated from Rust, so any real computer-use behavior change must also
1127
- // touch one of the categories above and will still trigger the suite. Treating
1128
- // the regenerated aggregate binding as a computer surface forced the suite on
1129
- // unrelated features (e.g. notifications), which the SKILL explicitly warns
1130
- // against, so it is excluded here.
2098
+ // computer-control source — the computer tool (`tool`), its behavior-bearing
2099
+ // settings/renderer wiring (`settings-registry`), or computer Rust (`code`).
2100
+ // A bare regeneration of the SHARED native binding (`generated-binding`:
2101
+ // packages/natives/native/index.{d.ts,js}) is NOT by itself a computer-use
2102
+ // change: that file is generated from Rust, so any real computer-use behavior
2103
+ // change must also touch one of the categories above and will still trigger
2104
+ // the suite. Treating aggregate binding or registration files as a computer
2105
+ // surface forced the suite on unrelated changes, which the SKILL explicitly
2106
+ // warns against, so they are excluded here.
1131
2107
  return category === "code" || category === "tool" || category === "settings-registry";
1132
2108
  }
1133
2109
 
2110
+ function isComputerSpecificToolsIndexDiff(diff: string | undefined, targetPath: string): boolean {
2111
+ if (!diff || !isToolsIndexPath(targetPath)) return false;
2112
+ let inTargetFile = false;
2113
+ for (const line of diff.split("\n")) {
2114
+ if (line.startsWith("diff --git ")) {
2115
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
2116
+ inTargetFile = !!match && (isToolsIndexPath(match[1]!) || isToolsIndexPath(match[2]!));
2117
+ continue;
2118
+ }
2119
+ if (!inTargetFile || line.startsWith("+++") || line.startsWith("---")) continue;
2120
+ if (!line.startsWith("+") && !line.startsWith("-")) continue;
2121
+ const changedLine = line.slice(1);
2122
+ if (
2123
+ /\bComputerTool\b/.test(changedLine) ||
2124
+ /\bisComputerCallable\b/.test(changedLine) ||
2125
+ /\bisComputerLoadablePlatform\b/.test(changedLine) ||
2126
+ /["']computer["']/.test(changedLine) ||
2127
+ /["']\.\/computer["']/.test(changedLine) ||
2128
+ /\bcomputer\s*:/.test(changedLine)
2129
+ ) {
2130
+ return true;
2131
+ }
2132
+ }
2133
+ return false;
2134
+ }
2135
+
2136
+ /** Settings registry file that holds ALL settings, most of them unrelated to computer control. */
2137
+ const SETTINGS_SCHEMA_PATH = "packages/coding-agent/src/config/settings-schema.ts";
2138
+
2139
+ function isSettingsSchemaPath(value: string): boolean {
2140
+ return normalizeRepoPath(value) === SETTINGS_SCHEMA_PATH;
2141
+ }
2142
+
2143
+ /**
2144
+ * The settings registry holds every setting (themes, tool output sizes, retry
2145
+ * knobs, …), so a bare `settings-schema.ts` edit is NOT by itself a computer
2146
+ * change. Mirror {@link isComputerSpecificToolsIndexDiff}: only treat it as a
2147
+ * computer-control surface when the diff actually adds/removes a `computer.*`
2148
+ * setting key. When no diff is available, callers fall back to the conservative
2149
+ * (fail-closed) categorization instead of this narrowing.
2150
+ */
2151
+ function isComputerSpecificSettingsDiff(diff: string | undefined, targetPath: string): boolean {
2152
+ if (!diff || !isSettingsSchemaPath(targetPath)) return false;
2153
+ let inTargetFile = false;
2154
+ for (const line of diff.split("\n")) {
2155
+ if (line.startsWith("diff --git ")) {
2156
+ const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
2157
+ inTargetFile = !!match && (isSettingsSchemaPath(match[1]!) || isSettingsSchemaPath(match[2]!));
2158
+ continue;
2159
+ }
2160
+ if (!inTargetFile || line.startsWith("+++") || line.startsWith("---")) continue;
2161
+ if (!line.startsWith("+") && !line.startsWith("-")) continue;
2162
+ const changedLine = line.slice(1);
2163
+ if (/["']computer\./.test(changedLine)) return true;
2164
+ }
2165
+ return false;
2166
+ }
2167
+
1134
2168
  function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
1135
2169
  const category = row.category ?? categorizeComputerChangePath(row.path);
1136
2170
  const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
@@ -1139,7 +2173,26 @@ function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolea
1139
2173
 
1140
2174
  function trustedChangeSetRequiresComputerSuite(changeSet: UltragoalChangeSet | undefined): boolean {
1141
2175
  if (!changeSet?.trusted) return false;
1142
- return changeSet.paths.some(isComputerControlSurfaceChangePath);
2176
+ return changeSet.paths.some(row => {
2177
+ if (isComputerControlSurfaceChangePath(row)) {
2178
+ // The settings registry mixes computer and non-computer settings. Narrow it
2179
+ // with the diff so unrelated settings edits do not force the computer suite;
2180
+ // fall back to the conservative categorization when no diff is available.
2181
+ const touchesSettingsSchema =
2182
+ isSettingsSchemaPath(row.path) || (row.oldPath ? isSettingsSchemaPath(row.oldPath) : false);
2183
+ if (touchesSettingsSchema && changeSet.rawDiff !== undefined) {
2184
+ return (
2185
+ isComputerSpecificSettingsDiff(changeSet.rawDiff, row.path) ||
2186
+ (row.oldPath ? isComputerSpecificSettingsDiff(changeSet.rawDiff, row.oldPath) : false)
2187
+ );
2188
+ }
2189
+ return true;
2190
+ }
2191
+ return (
2192
+ isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.path) ||
2193
+ (row.oldPath ? isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.oldPath) : false)
2194
+ );
2195
+ });
1143
2196
  }
1144
2197
 
1145
2198
  function requiresComputerRedTeamSuite(executorQa: JsonObject, changeSet: UltragoalChangeSet | undefined): boolean {
@@ -2387,18 +3440,141 @@ export async function validateExecutorQaRedTeamEvidenceForReview(
2387
3440
  await validateExecutorQaRedTeamEvidenceInternal(cwd, executorQa as JsonObject, options);
2388
3441
  }
2389
3442
 
3443
+ function canonicalChangeSetRows(value: unknown, fieldName: string): UltragoalChangeSetPath[] {
3444
+ if (!Array.isArray(value)) throw new Error(`${fieldName} must be an array`);
3445
+ return value.map((row, index) => {
3446
+ if (typeof row !== "object" || row === null || Array.isArray(row))
3447
+ throw new Error(`${fieldName}[${index}] must be an object`);
3448
+ const record = row as JsonObject;
3449
+ const pathValue = nonEmptyString(record.path);
3450
+ if (!pathValue) throw new Error(`${fieldName}[${index}].path is required`);
3451
+ if ("goalId" in record) throw new Error(`${fieldName}[${index}] must not contain goalId attribution`);
3452
+ const status = nonEmptyString(record.status);
3453
+ if (!status) throw new Error(`${fieldName}[${index}].status is required`);
3454
+ return { path: normalizeRepoPath(pathValue), status: status as UltragoalChangeStatus };
3455
+ });
3456
+ }
3457
+
3458
+ function changeSetHashForPaths(paths: readonly UltragoalChangeSetPath[]): string {
3459
+ return hashStructuredValue(paths.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })));
3460
+ }
3461
+
3462
+ function requireChangeSetCoverage(
3463
+ expected: UltragoalChangeSet | undefined,
3464
+ declared: readonly UltragoalChangeSetPath[],
3465
+ fieldName: string,
3466
+ ): void {
3467
+ if (!expected) return;
3468
+ const declaredExactKeys = new Set(declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}\u0000${row.status}`));
3469
+ const declaredPathKeys = new Set(declared.map(row => `${row.oldPath ?? ""}\u0000${row.path}`));
3470
+ for (const row of expected.paths) {
3471
+ const pathKey = `${row.oldPath ?? ""}\u0000${row.path}`;
3472
+ const exactKey = `${pathKey}\u0000${row.status}`;
3473
+ const covered = row.status === "unknown" ? declaredPathKeys.has(pathKey) : declaredExactKeys.has(exactKey);
3474
+ if (!covered) throw new Error(`${fieldName} does not cover computed checkpoint change-set path ${row.path}`);
3475
+ }
3476
+ }
3477
+
3478
+ function requireValidationBatchTuple(
3479
+ metadata: UltragoalValidationBatchMetadata,
3480
+ record: JsonObject,
3481
+ fieldName: string,
3482
+ ): void {
3483
+ if (record.schemaVersion !== 1) throw new Error(`${fieldName}.schemaVersion must be 1`);
3484
+ if (record.batchId !== metadata.batchId) throw new Error(`${fieldName}.batchId must match durable validationBatch`);
3485
+ if (record.finalGoalId !== metadata.finalGoalId)
3486
+ throw new Error(`${fieldName}.finalGoalId must match durable validationBatch`);
3487
+ if (record.metadataHash !== metadata.metadataHash)
3488
+ throw new Error(`${fieldName}.metadataHash must match durable validationBatch`);
3489
+ const memberIds = stringArray(record.memberIds);
3490
+ if (
3491
+ !memberIds ||
3492
+ memberIds.length !== metadata.memberIds.length ||
3493
+ memberIds.some((id, index) => id !== metadata.memberIds[index])
3494
+ ) {
3495
+ throw new Error(`${fieldName}.memberIds must match durable validationBatch order`);
3496
+ }
3497
+ }
3498
+
3499
+ function validateDeferredCompletionQualityGate(
3500
+ gate: JsonObject,
3501
+ goal: UltragoalGoal,
3502
+ metadata: UltragoalValidationBatchMetadata,
3503
+ changeSet?: UltragoalChangeSet,
3504
+ ): void {
3505
+ const allowedKeys = new Set(["deferredToBatch"]);
3506
+ const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key));
3507
+ if (unsupportedKeys.length > 0)
3508
+ throw new Error(`deferred qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
3509
+ const deferred = qualityGateObject(gate.deferredToBatch);
3510
+ if (!deferred) throw new Error("deferred qualityGate requires deferredToBatch object");
3511
+ if (deferred.kind !== "validation-batch-deferred")
3512
+ throw new Error("deferredToBatch.kind must be validation-batch-deferred");
3513
+ requireValidationBatchTuple(metadata, deferred, "deferredToBatch");
3514
+ if (goal.id === metadata.finalGoalId)
3515
+ throw new Error("final validation batch goal cannot use deferredToBatch quality gate");
3516
+ const deferredLanes = stringArray(deferred.deferredLanes)?.filter(Boolean).sort();
3517
+ if (deferredLanes?.join(",") !== "architectReview,executorQa")
3518
+ throw new Error("deferredToBatch.deferredLanes must be architectReview and executorQa");
3519
+ const targeted = qualityGateObject(deferred.targetedVerification);
3520
+ if (!targeted || targeted.status !== PASSED_STATUS || !nonEmptyStringArray(targeted.commands))
3521
+ throw new Error("deferredToBatch.targetedVerification must pass with non-empty commands");
3522
+ requireNonEmptyString(targeted.evidence, "deferredToBatch.targetedVerification.evidence");
3523
+ const cleaner = qualityGateObject(deferred.aiSlopCleaner);
3524
+ if (!cleaner || cleaner.status !== PASSED_STATUS) throw new Error("deferredToBatch.aiSlopCleaner must pass");
3525
+ requireNonEmptyString(cleaner.evidence, "deferredToBatch.aiSlopCleaner.evidence");
3526
+ const iteration = qualityGateObject(deferred.iteration);
3527
+ if (!iteration || iteration.status !== PASSED_STATUS || iteration.fullRerun !== true)
3528
+ throw new Error("deferredToBatch.iteration must pass with fullRerun true");
3529
+ if (!nonEmptyStringArray(iteration.rerunCommands))
3530
+ throw new Error("deferredToBatch.iteration.rerunCommands must be non-empty");
3531
+ requireNonEmptyString(iteration.evidence, "deferredToBatch.iteration.evidence");
3532
+ requireEmptyBlockers(iteration.blockers, "deferredToBatch.iteration.blockers");
3533
+ const declaredChangeSet = qualityGateObject(deferred.changeSet);
3534
+ if (!declaredChangeSet) throw new Error("deferredToBatch.changeSet is required");
3535
+ if (declaredChangeSet.memberGoalId !== goal.id)
3536
+ throw new Error("deferredToBatch.changeSet.memberGoalId must label the checkpointed goal");
3537
+ if (declaredChangeSet.cumulativeFromBase !== true)
3538
+ throw new Error("deferredToBatch.changeSet.cumulativeFromBase must be true");
3539
+ const paths = canonicalChangeSetRows(declaredChangeSet.paths, "deferredToBatch.changeSet.paths");
3540
+ requireChangeSetCoverage(changeSet, paths, "deferredToBatch.changeSet.paths");
3541
+ if (declaredChangeSet.changeSetHash !== changeSetHashForPaths(paths))
3542
+ throw new Error("deferredToBatch.changeSet.changeSetHash does not match declared paths");
3543
+ }
2390
3544
  async function validateCompletionQualityGate(
2391
3545
  cwd: string,
2392
3546
  gate: JsonObject,
2393
- options: { changeSet?: UltragoalChangeSet } = {},
3547
+ options: {
3548
+ changeSet?: UltragoalChangeSet;
3549
+ plan?: UltragoalPlan;
3550
+ goal?: UltragoalGoal;
3551
+ ledger?: readonly UltragoalLedgerEvent[];
3552
+ } = {},
2394
3553
  ): Promise<void> {
3554
+ const batchMode = options.goal?.validationBatch;
3555
+ if (batchMode && options.goal && options.goal.id !== batchMode.finalGoalId) {
3556
+ validateDeferredCompletionQualityGate(gate, options.goal, batchMode, options.changeSet);
3557
+ return;
3558
+ }
3559
+ if (batchMode && options.goal && options.goal.id === batchMode.finalGoalId) {
3560
+ const allowedKeys = new Set(["architectReview", "executorQa", "iteration", "validationBatchClose"]);
3561
+ const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key));
3562
+ if (unsupportedKeys.length > 0)
3563
+ throw new Error(`qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
3564
+ if (!qualityGateObject(gate.validationBatchClose))
3565
+ throw new Error("final validation batch goal requires validationBatchClose");
3566
+ }
2395
3567
  const codeReview = qualityGateObject(gate.codeReview);
2396
3568
  if (codeReview) {
2397
3569
  throw new Error(
2398
3570
  "checkpoint --status complete requires architect review approval through architectReview, executorQa, and iteration quality-gate evidence; legacy codeReview-only gates are not sufficient",
2399
3571
  );
2400
3572
  }
2401
- const allowedKeys = new Set(["architectReview", "executorQa", "iteration"]);
3573
+ const allowedKeys = new Set(
3574
+ batchMode
3575
+ ? ["architectReview", "executorQa", "iteration", "validationBatchClose"]
3576
+ : ["architectReview", "executorQa", "iteration"],
3577
+ );
2402
3578
  const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key));
2403
3579
  if (unsupportedKeys.length > 0) {
2404
3580
  throw new Error(`qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
@@ -2445,12 +3621,105 @@ async function validateCompletionQualityGate(
2445
3621
  }
2446
3622
  requireNonEmptyString(iteration.evidence, "iteration.evidence");
2447
3623
  requireEmptyBlockers(iteration.blockers, "iteration.blockers");
3624
+ if (batchMode && options.goal && options.plan && options.ledger) {
3625
+ validateBatchCloseQualityGate(gate, options.plan, batchMode, options.ledger, options.changeSet);
3626
+ }
2448
3627
  }
2449
3628
 
3629
+ function validateBatchCloseQualityGate(
3630
+ gate: JsonObject,
3631
+ plan: UltragoalPlan,
3632
+ metadata: UltragoalValidationBatchMetadata,
3633
+ ledger: readonly UltragoalLedgerEvent[],
3634
+ changeSet?: UltragoalChangeSet,
3635
+ ): void {
3636
+ const close = qualityGateObject(gate.validationBatchClose);
3637
+ if (!close) throw new Error("validationBatchClose is required");
3638
+ if (close.schemaVersion !== 1 || close.kind !== "validation-batch-close")
3639
+ throw new Error("validationBatchClose.kind must be validation-batch-close");
3640
+ if (close.batchId !== metadata.batchId || close.finalGoalId !== metadata.finalGoalId)
3641
+ throw new Error("validationBatchClose tuple must match durable validationBatch");
3642
+ const memberIds = stringArray(close.memberIds);
3643
+ if (
3644
+ !memberIds ||
3645
+ memberIds.length !== metadata.memberIds.length ||
3646
+ memberIds.some((id, index) => id !== metadata.memberIds[index])
3647
+ )
3648
+ throw new Error("validationBatchClose.memberIds must match durable validationBatch order");
3649
+ const memberMetadataHashes = qualityGateObject(close.memberMetadataHashes);
3650
+ const memberChangeSetHashes = qualityGateObject(qualityGateObject(close.unionChangeSet)?.memberChangeSetHashes);
3651
+ if (!memberMetadataHashes || !memberChangeSetHashes)
3652
+ throw new Error("validationBatchClose member metadata and change-set hashes are required");
3653
+ const seenReceipts = new Set<string>();
3654
+ const receiptRows = Array.isArray(close.memberReceipts) ? close.memberReceipts : [];
3655
+ const nonFinalIds = metadata.memberIds.filter(memberId => memberId !== metadata.finalGoalId);
3656
+ for (const memberId of metadata.memberIds) {
3657
+ const member = plan.goals.find(item => item.id === memberId);
3658
+ if (!member?.validationBatch) throw new Error(`validationBatchClose references missing batch member ${memberId}`);
3659
+ if (member.validationBatch.metadataHash !== memberMetadataHashes[memberId])
3660
+ throw new Error(`validationBatchClose.memberMetadataHashes.${memberId} does not match durable metadata`);
3661
+ if (memberId !== metadata.finalGoalId && member.status !== "complete")
3662
+ throw new Error(`validationBatchClose cannot close before ${memberId} is complete`);
3663
+ }
3664
+ if (receiptRows.length !== nonFinalIds.length)
3665
+ throw new Error("validationBatchClose.memberReceipts must list every non-final member exactly once");
3666
+ for (const row of receiptRows) {
3667
+ if (typeof row !== "object" || row === null || Array.isArray(row))
3668
+ throw new Error("validationBatchClose.memberReceipts rows must be objects");
3669
+ const record = row as JsonObject;
3670
+ const memberId = nonEmptyString(record.goalId);
3671
+ if (!memberId || !nonFinalIds.includes(memberId))
3672
+ throw new Error("validationBatchClose.memberReceipts contains invalid member goalId");
3673
+ if (seenReceipts.has(memberId))
3674
+ throw new Error(`validationBatchClose.memberReceipts contains duplicate member ${memberId}`);
3675
+ seenReceipts.add(memberId);
3676
+ const member = plan.goals.find(item => item.id === memberId)!;
3677
+ const receipt = requireDeferredMemberReceiptFresh(plan, ledger, member, "validationBatchClose.memberReceipts");
3678
+ if (
3679
+ record.role !== "deferred-member" ||
3680
+ record.receiptId !== receipt.receiptId ||
3681
+ record.qualityGateHash !== receipt.qualityGateHash ||
3682
+ record.changeSetHash !== receipt.validationBatch.changeSetHash ||
3683
+ record.checkpointLedgerEventId !== receipt.checkpointLedgerEventId
3684
+ ) {
3685
+ throw new Error(`validationBatchClose.memberReceipts.${memberId} does not match deferred receipt`);
3686
+ }
3687
+ if (memberChangeSetHashes[memberId] !== receipt.validationBatch.changeSetHash)
3688
+ throw new Error(
3689
+ `validationBatchClose.unionChangeSet.memberChangeSetHashes.${memberId} does not match deferred receipt`,
3690
+ );
3691
+ }
3692
+ if (seenReceipts.size !== nonFinalIds.length)
3693
+ throw new Error("validationBatchClose.memberReceipts is missing a non-final member");
3694
+ const union = qualityGateObject(close.unionChangeSet);
3695
+ if (union?.source !== "validation-batch")
3696
+ throw new Error("validationBatchClose.unionChangeSet.source must be validation-batch");
3697
+ const unionPaths = canonicalChangeSetRows(union.paths, "validationBatchClose.unionChangeSet.paths");
3698
+ requireChangeSetCoverage(changeSet, unionPaths, "validationBatchClose.unionChangeSet.paths");
3699
+ const finalHash = changeSetHashForPaths(unionPaths);
3700
+ if (memberChangeSetHashes[metadata.finalGoalId] !== finalHash)
3701
+ throw new Error(
3702
+ "validationBatchClose.unionChangeSet.memberChangeSetHashes final member hash does not match current change set",
3703
+ );
3704
+ if (
3705
+ union.unionHash !==
3706
+ hashStructuredValue({
3707
+ memberChangeSetHashes,
3708
+ paths: unionPaths.map(row => ({ path: row.path, status: row.status, oldPath: row.oldPath })),
3709
+ })
3710
+ )
3711
+ throw new Error("validationBatchClose.unionChangeSet.unionHash does not match declared union");
3712
+ requireNonEmptyString(close.coverageEvidence, "validationBatchClose.coverageEvidence");
3713
+ }
2450
3714
  async function readRequiredCompletionQualityGate(
2451
3715
  cwd: string,
2452
3716
  value: string | undefined,
2453
- options: { changeSet?: UltragoalChangeSet } = {},
3717
+ options: {
3718
+ changeSet?: UltragoalChangeSet;
3719
+ plan?: UltragoalPlan;
3720
+ goal?: UltragoalGoal;
3721
+ ledger?: readonly UltragoalLedgerEvent[];
3722
+ } = {},
2454
3723
  ): Promise<unknown> {
2455
3724
  if (!value?.trim()) {
2456
3725
  throw new Error(
@@ -2460,10 +3729,55 @@ async function readRequiredCompletionQualityGate(
2460
3729
  const gate = await readStructuredValue(cwd, value);
2461
3730
  const gateObject = qualityGateObject(gate);
2462
3731
  if (!gateObject) throw new Error("qualityGate must be a JSON object");
2463
- await validateCompletionQualityGate(cwd, gateObject, { changeSet: options.changeSet });
3732
+ await validateCompletionQualityGate(cwd, gateObject, {
3733
+ changeSet: options.changeSet,
3734
+ plan: options.plan,
3735
+ goal: options.goal,
3736
+ ledger: options.ledger,
3737
+ });
2464
3738
  return gate;
2465
3739
  }
2466
3740
 
3741
+ function validatePipelineCheckpointSafety(
3742
+ plan: UltragoalPlan,
3743
+ goal: UltragoalGoal,
3744
+ changeSet?: UltragoalChangeSet,
3745
+ ): void {
3746
+ const metadata = goal.pipelineMetadata;
3747
+ if (!metadata) return;
3748
+ validateValidationBatchPipelineExclusion(goal);
3749
+ requireFreshPipelineMetadata(goal);
3750
+ if (metadata.overlap === "open") {
3751
+ throw new Error(
3752
+ `Cannot complete ${goal.id} while pipeline overlap ${metadata.overlapId ?? ""} is open; join or quarantine first.`,
3753
+ );
3754
+ }
3755
+ if (metadata.overlap === "quarantine_required") {
3756
+ throw new Error(
3757
+ `Cannot complete ${goal.id} while pipeline overlap ${metadata.overlapId ?? ""} requires rebaseline.`,
3758
+ );
3759
+ }
3760
+ if (metadata.goalId === metadata.priorGoalId && metadata.overlap !== "none" && metadata.overlap !== "joined_clean") {
3761
+ throw new Error(
3762
+ `Cannot complete ${goal.id} without a clean join for pipeline overlap ${metadata.overlapId ?? ""}.`,
3763
+ );
3764
+ }
3765
+ const peer = pipelinePeer(plan, metadata);
3766
+ if (changeSet && metadata.overlap !== "none") {
3767
+ const peerTargets = peer?.pipelineMetadata?.targets;
3768
+ for (const row of changeSet.paths) {
3769
+ const ownedByGoal = pipelineTargetsCoverPath(metadata.targets, row.path);
3770
+ const ownedByPeer = peerTargets ? pipelineTargetsCoverPath(peerTargets, row.path) : false;
3771
+ if (ownedByGoal && ownedByPeer)
3772
+ throw new Error(`Cannot complete ${goal.id} with shared pipeline change-set path ${row.path}.`);
3773
+ if (!ownedByGoal && !ownedByPeer)
3774
+ throw new Error(`Cannot complete ${goal.id} with unattributable pipeline change-set path ${row.path}.`);
3775
+ if (!ownedByGoal && ownedByPeer)
3776
+ throw new Error(`Cannot complete ${goal.id} with next-goal pipeline change-set path ${row.path}.`);
3777
+ }
3778
+ }
3779
+ }
3780
+
2467
3781
  function validateCompleteCheckpointTargetGoal(goal: UltragoalGoal): void {
2468
3782
  if (COMPLETE_CHECKPOINT_ALLOWED_PRE_STATUSES.has(goal.status)) return;
2469
3783
  if (goal.status === "pending") {
@@ -2498,17 +3812,51 @@ export async function checkpointUltragoalGoal(input: {
2498
3812
  const evidence = input.evidence.trim();
2499
3813
  if (!evidence) throw new Error("checkpoint evidence is required");
2500
3814
  const ledgerBefore = await readUltragoalLedger(input.cwd);
2501
- if (
2502
- goal.status === input.status &&
2503
- goal.evidence === evidence &&
2504
- ledgerBefore.some(
2505
- event =>
2506
- event.event === "goal_checkpointed" &&
2507
- event.goalId === goal.id &&
2508
- event.status === input.status &&
2509
- event.evidence === evidence,
2510
- )
2511
- ) {
3815
+ const matchingIdempotentEvent = ledgerBefore.find(
3816
+ event =>
3817
+ event.event === "goal_checkpointed" &&
3818
+ event.goalId === goal.id &&
3819
+ event.status === input.status &&
3820
+ event.evidence === evidence,
3821
+ );
3822
+ const batchMetadata = input.status === "complete" ? requireFreshValidationBatchMetadata(goal) : undefined;
3823
+ if (batchMetadata && goal.completionVerification?.validationBatch) {
3824
+ const receiptBatch = goal.completionVerification.validationBatch;
3825
+ if (receiptBatch.role === "deferred-member" && receiptBatch.metadataHash !== batchMetadata.metadataHash) {
3826
+ throw new Error(`Goal ${goal.id} has stale validation batch completion receipt for ${batchMetadata.batchId}`);
3827
+ }
3828
+ if (
3829
+ receiptBatch.role === "batch-close" &&
3830
+ receiptBatch.memberMetadataHashes[goal.id] !== batchMetadata.metadataHash
3831
+ ) {
3832
+ throw new Error(`Goal ${goal.id} has stale validation batch close receipt for ${batchMetadata.batchId}`);
3833
+ }
3834
+ }
3835
+ if (input.status === "complete" && goal.completionVerification?.validationBatch && !batchMetadata) {
3836
+ throw new Error(`Goal ${goal.id} has stale validation batch completion receipt`);
3837
+ }
3838
+ if (goal.status === input.status && goal.evidence === evidence && matchingIdempotentEvent) {
3839
+ if (batchMetadata) {
3840
+ const receipt = goal.completionVerification;
3841
+ const receiptBatch = receipt?.validationBatch;
3842
+ if (!receipt || !receiptBatch)
3843
+ throw new Error(
3844
+ `Goal ${goal.id} has validation batch ${batchMetadata.batchId} but no matching completion receipt`,
3845
+ );
3846
+ if (receipt.checkpointLedgerEventId !== matchingIdempotentEvent.eventId)
3847
+ throw new Error(`Goal ${goal.id} validation batch receipt does not match prior checkpoint event`);
3848
+ if (hashStructuredValue(matchingIdempotentEvent.qualityGateJson) !== receipt.qualityGateHash)
3849
+ throw new Error(`Goal ${goal.id} validation batch receipt quality gate is stale`);
3850
+ if (receiptBatch.role === "deferred-member" && receiptBatch.metadataHash !== batchMetadata.metadataHash)
3851
+ throw new Error(`Goal ${goal.id} has stale validation batch metadata hash in deferred receipt`);
3852
+ if (receiptBatch.role === "batch-close")
3853
+ requireFreshBatchCloseReceiptBasis(plan, ledgerBefore, goal, receipt, matchingIdempotentEvent);
3854
+ if (
3855
+ receiptBatch.role === "batch-close" &&
3856
+ receiptBatch.memberMetadataHashes[goal.id] !== batchMetadata.metadataHash
3857
+ )
3858
+ throw new Error(`Goal ${goal.id} has stale validation batch metadata hash in close receipt`);
3859
+ }
2512
3860
  // Idempotent re-checkpoint: this goal is already recorded in the target status with the same
2513
3861
  // evidence, so skip the plan rewrite and ledger append to avoid duplicate goal_checkpointed
2514
3862
  // events. The ledger is the dedup source of truth because it is exactly what a duplicate write
@@ -2517,11 +3865,19 @@ export async function checkpointUltragoalGoal(input: {
2517
3865
  // instead of silently dropping it.
2518
3866
  return plan;
2519
3867
  }
2520
- if (input.status === "complete") validateCompleteCheckpointTargetGoal(goal);
2521
3868
  const changeSet = input.status === "complete" ? await computeCheckpointChangeSet(input.cwd) : undefined;
3869
+ if (input.status === "complete") {
3870
+ validatePipelineCheckpointSafety(plan, goal, changeSet);
3871
+ validateCompleteCheckpointTargetGoal(goal);
3872
+ }
2522
3873
  const qualityGateJson =
2523
3874
  input.status === "complete"
2524
- ? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson, { changeSet })
3875
+ ? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson, {
3876
+ changeSet,
3877
+ plan,
3878
+ goal,
3879
+ ledger: ledgerBefore,
3880
+ })
2525
3881
  : input.qualityGateJson
2526
3882
  ? await readStructuredValue(input.cwd, input.qualityGateJson)
2527
3883
  : undefined;
@@ -2769,6 +4125,7 @@ async function addUltragoalSubgoalToPlan(input: {
2769
4125
  createdAt: now,
2770
4126
  updatedAt: now,
2771
4127
  steering: { kind, evidence, rationale },
4128
+ pipelineMetadata: legacyPipelineMetadata(nextId),
2772
4129
  });
2773
4130
  input.plan.updatedAt = now;
2774
4131
  await writePlan(input.cwd, input.plan);
@@ -2810,6 +4167,8 @@ async function splitUltragoalSubgoal(input: {
2810
4167
  });
2811
4168
  const target = findGoalOrThrow(input.plan, input.goalId, kind);
2812
4169
  requireGoalStatus(target, ["pending"], kind);
4170
+ const ledger = await readUltragoalLedger(input.cwd);
4171
+ requireValidationBatchSteeringAllowed(input.plan, target, kind, ledger);
2813
4172
  const replacements = parseReplacementSpecs(input.replacementsJson, kind);
2814
4173
  const now = new Date().toISOString();
2815
4174
  const replacementGoalIds = replacements.map((_, index) => nextUltragoalGoalId(input.plan, index + 1));
@@ -2817,6 +4176,8 @@ async function splitUltragoalSubgoal(input: {
2817
4176
  target.evidence = evidence;
2818
4177
  target.updatedAt = now;
2819
4178
  target.steering = { kind, evidence, rationale, replacementGoalIds };
4179
+ invalidatePipelineMetadata(target, "split_subgoal_superseded", now);
4180
+ clearValidationBatchForBatch(input.plan, target.validationBatch);
2820
4181
  const replacementGoals = replacements.map(
2821
4182
  (replacement, index): UltragoalGoal => ({
2822
4183
  id: replacementGoalIds[index]!,
@@ -2826,6 +4187,7 @@ async function splitUltragoalSubgoal(input: {
2826
4187
  createdAt: now,
2827
4188
  updatedAt: now,
2828
4189
  steering: { kind: "split_replacement", sourceGoalId: target.id, evidence, rationale },
4190
+ pipelineMetadata: legacyPipelineMetadata(replacementGoalIds[index]!),
2829
4191
  }),
2830
4192
  );
2831
4193
  const targetIndex = input.plan.goals.findIndex(goal => goal.id === target.id);
@@ -2903,6 +4265,8 @@ async function revisePendingUltragoalWording(input: {
2903
4265
  });
2904
4266
  const goal = findGoalOrThrow(input.plan, input.goalId, kind);
2905
4267
  requireGoalStatus(goal, ["pending"], kind);
4268
+ const ledger = await readUltragoalLedger(input.cwd);
4269
+ requireValidationBatchSteeringAllowed(input.plan, goal, kind, ledger);
2906
4270
  const title = input.title === undefined ? undefined : input.title.trim();
2907
4271
  const objective = input.objective === undefined ? undefined : input.objective.trim();
2908
4272
  if (input.title !== undefined && !title)
@@ -2922,6 +4286,8 @@ async function revisePendingUltragoalWording(input: {
2922
4286
  const now = new Date().toISOString();
2923
4287
  goal.updatedAt = now;
2924
4288
  goal.steering = { kind, evidence, rationale, changedFields };
4289
+ invalidatePipelineMetadata(goal, "revised_pending_wording", now);
4290
+ clearValidationBatchForBatch(input.plan, goal.validationBatch);
2925
4291
  input.plan.updatedAt = now;
2926
4292
  await writePlan(input.cwd, input.plan);
2927
4293
  await appendLedger(input.cwd, {
@@ -2966,6 +4332,8 @@ async function markBlockedUltragoalSuperseded(input: {
2966
4332
  });
2967
4333
  const goal = findGoalOrThrow(input.plan, input.goalId, kind);
2968
4334
  requireGoalStatus(goal, ["blocked", "review_blocked"], kind);
4335
+ const ledger = await readUltragoalLedger(input.cwd);
4336
+ requireValidationBatchSteeringAllowed(input.plan, goal, kind, ledger);
2969
4337
  const remainingRequiredGoals = requiredUltragoalGoals(input.plan).filter(item => item.id !== goal.id);
2970
4338
  if (remainingRequiredGoals.length === 0) {
2971
4339
  throw new Error(`steer ${kind} cannot supersede ${goal.id} because it is the only remaining required goal`);
@@ -2975,6 +4343,7 @@ async function markBlockedUltragoalSuperseded(input: {
2975
4343
  goal.evidence = evidence;
2976
4344
  goal.updatedAt = now;
2977
4345
  goal.steering = { kind, evidence, rationale, noReplacementRequired: true };
4346
+ clearValidationBatchForBatch(input.plan, goal.validationBatch);
2978
4347
  input.plan.updatedAt = now;
2979
4348
  await writePlan(input.cwd, input.plan);
2980
4349
  await appendLedger(input.cwd, {
@@ -3175,6 +4544,29 @@ function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
3175
4544
  return rows;
3176
4545
  }
3177
4546
 
4547
+ function categorizeCiChangedPath(value: string): UltragoalChangeCategory {
4548
+ // CI_DEV_CHANGED_PATHS intentionally carries path names only. Mixed registries
4549
+ // such as settings-schema.ts require diff-level narrowing; without the diff,
4550
+ // treating the whole registry as computer-control source forces the mandatory
4551
+ // computer red-team suite on unrelated settings changes.
4552
+ if (isSettingsSchemaPath(value)) return "other";
4553
+ return categorizeComputerChangePath(value);
4554
+ }
4555
+
4556
+ function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
4557
+ const raw = process.env.CI_DEV_CHANGED_PATHS;
4558
+ if (!raw) return [];
4559
+ return raw
4560
+ .split(/\r?\n/)
4561
+ .map(row => row.trim())
4562
+ .filter(Boolean)
4563
+ .map(pathValue => ({
4564
+ path: normalizeRepoPath(pathValue),
4565
+ status: "unknown" as UltragoalChangeStatus,
4566
+ category: categorizeCiChangedPath(pathValue),
4567
+ }));
4568
+ }
4569
+
3178
4570
  function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChangeSetPath[] {
3179
4571
  const byKey = new Map<string, UltragoalChangeSetPath>();
3180
4572
  for (const row of groups.flat()) byKey.set(`${row.oldPath ?? ""}\u0000${row.path}`, row);
@@ -3182,30 +4574,39 @@ function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChang
3182
4574
  }
3183
4575
 
3184
4576
  async function computeCheckpointChangeSet(cwd: string): Promise<UltragoalChangeSet | undefined> {
4577
+ const ciChangedPaths = ciDevChangedPathRows();
3185
4578
  const inGit = await spawnText(["git", "rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 3000 });
3186
- if (!inGit.ok || inGit.stdout.trim() !== "true") return undefined;
3187
- if (!(await Bun.file(path.join(cwd, ".git")).exists())) return undefined;
4579
+ if (!inGit.ok || inGit.stdout.trim() !== "true") {
4580
+ if (ciChangedPaths.length === 0) return undefined;
4581
+ return { source: "checkpoint-git", paths: ciChangedPaths, trusted: true };
4582
+ }
3188
4583
  const baseRef = await resolveGitBase(cwd);
3189
4584
  const base = baseRef;
3190
4585
  const mergeBase = await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
3191
- const [committed, unstaged, staged, stat] = await Promise.all([
4586
+ const [committed, unstaged, staged, stat, committedDiff, unstagedDiff, stagedDiff] = await Promise.all([
3192
4587
  spawnText(["git", "diff", "--name-status", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
3193
4588
  spawnText(["git", "diff", "--name-status"], { cwd, timeoutMs: 5000 }),
3194
4589
  spawnText(["git", "diff", "--cached", "--name-status"], { cwd, timeoutMs: 5000 }),
3195
4590
  spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4591
+ spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4592
+ spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
4593
+ spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
4594
+ ]);
4595
+ if (!committed.ok && !unstaged.ok && !staged.ok && ciChangedPaths.length === 0) return undefined;
4596
+ const gitPaths = mergeChangeSetPaths([
4597
+ parseGitNameStatus(committed.stdout),
4598
+ parseGitNameStatus(unstaged.stdout),
4599
+ parseGitNameStatus(staged.stdout),
3196
4600
  ]);
3197
- if (!committed.ok && !unstaged.ok && !staged.ok) return undefined;
4601
+ const paths = gitPaths.length > 0 ? gitPaths : ciChangedPaths;
3198
4602
  return {
3199
4603
  source: "checkpoint-git",
3200
4604
  baseRef,
3201
4605
  mergeBase: mergeBase.ok && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : undefined,
3202
4606
  headRef: "HEAD",
3203
- paths: mergeChangeSetPaths([
3204
- parseGitNameStatus(committed.stdout),
3205
- parseGitNameStatus(unstaged.stdout),
3206
- parseGitNameStatus(staged.stdout),
3207
- ]),
4607
+ paths,
3208
4608
  rawDiffStat: stat.stdout,
4609
+ rawDiff: [committedDiff.stdout, unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n"),
3209
4610
  trusted: true,
3210
4611
  };
3211
4612
  }
@@ -3236,6 +4637,7 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
3236
4637
  source: "review-pr",
3237
4638
  paths: parseUnifiedDiffPaths(source.diff),
3238
4639
  rawDiffStat: source.diff,
4640
+ rawDiff: source.diff,
3239
4641
  trusted: true,
3240
4642
  };
3241
4643
  const local = qualityGateObject(source.local);
@@ -3245,6 +4647,7 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
3245
4647
  source: "review-worktree",
3246
4648
  paths: parseGitNameStatus(String(source.nameStatus ?? source.status ?? "")),
3247
4649
  rawDiffStat: String(source.diffStat ?? ""),
4650
+ rawDiff: String(source.diff ?? ""),
3248
4651
  trusted: true,
3249
4652
  };
3250
4653
  if (kind === "branch" || kind === "pr-fallback")
@@ -3254,6 +4657,7 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
3254
4657
  headRef: "HEAD",
3255
4658
  paths: parseGitNameStatus(String(source.nameStatus ?? "")),
3256
4659
  rawDiffStat: String(source.diffStat ?? ""),
4660
+ rawDiff: String(source.diff ?? ""),
3257
4661
  trusted: true,
3258
4662
  };
3259
4663
  return undefined;
@@ -3261,25 +4665,36 @@ function changeSetFromReviewSource(source: JsonObject): UltragoalChangeSet | und
3261
4665
 
3262
4666
  async function localDiffSource(cwd: string, sourceKind: string, branch?: string): Promise<JsonObject> {
3263
4667
  if (sourceKind === "worktree") {
3264
- const [status, diff, unstaged, staged] = await Promise.all([
4668
+ const [status, diffStat, unstaged, staged, unstagedDiff, stagedDiff] = await Promise.all([
3265
4669
  spawnText(["git", "status", "--short"], { cwd, timeoutMs: 5000 }),
3266
4670
  spawnText(["git", "diff", "--stat"], { cwd, timeoutMs: 5000 }),
3267
4671
  spawnText(["git", "diff", "--name-status"], { cwd, timeoutMs: 5000 }),
3268
4672
  spawnText(["git", "diff", "--cached", "--name-status"], { cwd, timeoutMs: 5000 }),
4673
+ spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
4674
+ spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
3269
4675
  ]);
3270
4676
  return {
3271
4677
  kind: "worktree",
3272
4678
  status: status.stdout,
3273
- diffStat: diff.stdout,
4679
+ diffStat: diffStat.stdout,
4680
+ diff: [unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n"),
3274
4681
  nameStatus: `${unstaged.stdout}\n${staged.stdout}`,
3275
4682
  };
3276
4683
  }
3277
4684
  const base = await resolveGitBase(cwd, branch);
3278
- const [diff, nameStatus] = await Promise.all([
4685
+ const [diffStat, nameStatus, diff] = await Promise.all([
3279
4686
  spawnText(["git", "diff", "--stat", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
3280
4687
  spawnText(["git", "diff", "--name-status", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
4688
+ spawnText(["git", "diff", `${base}...HEAD`], { cwd, timeoutMs: 5000 }),
3281
4689
  ]);
3282
- return { kind: sourceKind, base, branch, diffStat: diff.stdout, nameStatus: nameStatus.stdout };
4690
+ return {
4691
+ kind: sourceKind,
4692
+ base,
4693
+ branch,
4694
+ diffStat: diffStat.stdout,
4695
+ diff: diff.stdout,
4696
+ nameStatus: nameStatus.stdout,
4697
+ };
3283
4698
  }
3284
4699
 
3285
4700
  async function resolveReviewSource(
@@ -3463,6 +4878,17 @@ const FLAGS_WITH_VALUES = new Set([
3463
4878
  "--replacements-json",
3464
4879
  "--order-json",
3465
4880
  "--classification",
4881
+ "--goal-metadata-json",
4882
+ "--validation-batch-json",
4883
+ "--prior-goal-id",
4884
+ "--next-goal-id",
4885
+ "--review-handles-json",
4886
+ "--qa-handles-json",
4887
+ "--implementation-handle-json",
4888
+ "--overlap-id",
4889
+ "--review-result-json",
4890
+ "--qa-result-json",
4891
+ "--target-state-json",
3466
4892
  ]);
3467
4893
 
3468
4894
  function isHelpArg(arg: string): boolean {
@@ -3569,6 +4995,9 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
3569
4995
  " steer",
3570
4996
  " record-review-blockers",
3571
4997
  " classify-blocker",
4998
+ " start-pipeline-overlap",
4999
+ " join-pipeline-overlap",
5000
+ " rebaseline-pipeline-overlap",
3572
5001
  "",
3573
5002
  "Run `skc ultragoal checkpoint --help`, `skc ultragoal review --help`, or `skc ultragoal classify-blocker --help` for command-specific requirements.",
3574
5003
  "",
@@ -3799,9 +5228,18 @@ async function executeUltragoalSteeringCommand(args: readonly string[], cwd: str
3799
5228
  }
3800
5229
 
3801
5230
  async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<UltragoalCommandResult> {
3802
- const sessionId = currentUltragoalSessionId(cwd);
5231
+ // Help must not require a resolvable session; render it before session resolution.
3803
5232
  const help = renderUltragoalHelp(args);
3804
5233
  if (help) return { status: 0, stdout: help };
5234
+ let sessionId: string;
5235
+ try {
5236
+ sessionId = currentUltragoalSessionId(cwd);
5237
+ } catch (error) {
5238
+ // A missing/ambiguous session is an operator input error, not a crash:
5239
+ // surface the guidance on stderr instead of an uncaught-exception dump.
5240
+ if (error instanceof SessionResolutionError) return { status: 1, stderr: `${error.message}\n` };
5241
+ throw error;
5242
+ }
3805
5243
  try {
3806
5244
  const command = commandName(args);
3807
5245
  const json = hasFlag(args, "--json");
@@ -3810,8 +5248,20 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
3810
5248
  return { status: 0, stdout: renderStatus(await getUltragoalStatus(cwd, sessionId), json) };
3811
5249
  case "create":
3812
5250
  case "create-goals": {
5251
+ if (
5252
+ flagValue(args, "--goal-metadata-json") !== undefined &&
5253
+ flagValue(args, "--validation-batch-json") !== undefined
5254
+ ) {
5255
+ throw new Error("--validation-batch-json and --goal-metadata-json are mutually exclusive");
5256
+ }
3813
5257
  const mode = flagValue(args, "--skc-goal-mode") === "per-story" ? "per-story" : "aggregate";
3814
- const plan = await createUltragoalPlan({ cwd, brief: await readBrief(cwd, args), skcGoalMode: mode });
5258
+ const plan = await createUltragoalPlan({
5259
+ cwd,
5260
+ brief: await readBrief(cwd, args),
5261
+ skcGoalMode: mode,
5262
+ goalMetadataJson: flagValue(args, "--goal-metadata-json"),
5263
+ validationBatchJson: flagValue(args, "--validation-batch-json"),
5264
+ });
3815
5265
  return {
3816
5266
  status: 0,
3817
5267
  createdPlan: true,
@@ -3905,6 +5355,65 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
3905
5355
  : `Recorded blocker classification: ${String(event.classification)}.\n`,
3906
5356
  };
3907
5357
  }
5358
+ case "start-pipeline-overlap": {
5359
+ const receipt = await startUltragoalPipelineOverlap({
5360
+ cwd,
5361
+ priorGoalId: flagValue(args, "--prior-goal-id") ?? "",
5362
+ nextGoalId: flagValue(args, "--next-goal-id") ?? "",
5363
+ reviewHandles: await readRequiredJsonObjectOrArray(
5364
+ cwd,
5365
+ flagValue(args, "--review-handles-json") ?? "",
5366
+ "review handles",
5367
+ ),
5368
+ qaHandles: await readRequiredJsonObjectOrArray(
5369
+ cwd,
5370
+ flagValue(args, "--qa-handles-json") ?? "",
5371
+ "QA handles",
5372
+ ),
5373
+ implementationHandle: await readRequiredJsonObject(
5374
+ cwd,
5375
+ flagValue(args, "--implementation-handle-json") ?? "",
5376
+ "implementation handle",
5377
+ ),
5378
+ });
5379
+ return {
5380
+ status: 0,
5381
+ stdout: json ? renderCliWriteReceipt(receipt) : `Started pipeline overlap ${receipt.overlap_id}.\n`,
5382
+ };
5383
+ }
5384
+ case "join-pipeline-overlap": {
5385
+ const receipt = await joinUltragoalPipelineOverlap({
5386
+ cwd,
5387
+ overlapId: flagValue(args, "--overlap-id") ?? "",
5388
+ reviewResult: await readRequiredJsonObject(
5389
+ cwd,
5390
+ flagValue(args, "--review-result-json") ?? "",
5391
+ "review result",
5392
+ ),
5393
+ qaResult: await readRequiredJsonObject(cwd, flagValue(args, "--qa-result-json") ?? "", "QA result"),
5394
+ });
5395
+ return {
5396
+ status: 0,
5397
+ stdout: json ? renderCliWriteReceipt(receipt) : `Joined pipeline overlap ${receipt.overlap_id}.\n`,
5398
+ };
5399
+ }
5400
+ case "rebaseline-pipeline-overlap": {
5401
+ const receipt = await rebaselineUltragoalPipelineOverlap({
5402
+ cwd,
5403
+ overlapId: flagValue(args, "--overlap-id") ?? "",
5404
+ goalId: flagValue(args, "--goal-id") ?? "",
5405
+ evidence: flagValue(args, "--evidence") ?? "",
5406
+ targetState: await readRequiredJsonObject(
5407
+ cwd,
5408
+ flagValue(args, "--target-state-json") ?? "",
5409
+ "target state",
5410
+ ),
5411
+ });
5412
+ return {
5413
+ status: 0,
5414
+ stdout: json ? renderCliWriteReceipt(receipt) : `Rebaselined pipeline overlap ${receipt.overlap_id}.\n`,
5415
+ };
5416
+ }
3908
5417
  default:
3909
5418
  return { status: 1, stderr: `Unknown skc ultragoal command: ${command}\n` };
3910
5419
  }
@@ -3923,6 +5432,9 @@ const RECONCILE_COMMANDS = new Set([
3923
5432
  "record-review-blockers",
3924
5433
  "review",
3925
5434
  "classify-blocker",
5435
+ "start-pipeline-overlap",
5436
+ "join-pipeline-overlap",
5437
+ "rebaseline-pipeline-overlap",
3926
5438
  ]);
3927
5439
 
3928
5440
  /**
@@ -3959,6 +5471,7 @@ async function reconcileUltragoalState(cwd: string): Promise<void> {
3959
5471
  if (summary.nudgeRemaining !== undefined) payload.nudge_remaining = summary.nudgeRemaining;
3960
5472
  if (summary.nudgeGoalId !== undefined) payload.nudge_goal_id = summary.nudgeGoalId;
3961
5473
  if (summary.nudgeTargetKind !== undefined) payload.nudge_target_kind = summary.nudgeTargetKind;
5474
+ if (summary.pipelineOverlap) payload.pipeline_overlap = summary.pipelineOverlap;
3962
5475
  const ledgerText = await Bun.file(summary.paths.ledgerPath)
3963
5476
  .text()
3964
5477
  .catch(() => "");