@gajae-code/coding-agent 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/dist/types/append-only-mode.d.ts +31 -0
- package/dist/types/capability/rule.d.ts +8 -0
- package/dist/types/cli/fixture-report.d.ts +20 -0
- package/dist/types/cli/fixture-report.test.d.ts +1 -0
- package/dist/types/cli/nofile-limit.d.ts +11 -0
- package/dist/types/cli/update-cli.d.ts +22 -0
- package/dist/types/config/settings-schema.d.ts +105 -1
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/export/ttsr.d.ts +14 -3
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +5 -2
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +7 -0
- package/dist/types/gjc-runtime/state-writer.d.ts +3 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +26 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +42 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +119 -13
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/hooks/native-skill-hook.d.ts +12 -0
- package/dist/types/modes/components/assistant-message.d.ts +5 -1
- package/dist/types/modes/components/status-line.d.ts +9 -0
- package/dist/types/modes/controllers/command-controller.d.ts +1 -0
- package/dist/types/modes/controllers/event-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +14 -0
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +19 -0
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +23 -1
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
- package/dist/types/notifications/config.d.ts +6 -0
- package/dist/types/notifications/telegram-daemon.d.ts +8 -0
- package/dist/types/notifications/threaded-render.d.ts +6 -0
- package/dist/types/sdk.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +43 -1
- package/dist/types/session/file-mention-pruning.d.ts +8 -0
- package/dist/types/session/messages.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +17 -1
- package/dist/types/skill-state/active-state.d.ts +18 -1
- package/dist/types/system-prompt.d.ts +18 -2
- package/dist/types/task/executor.d.ts +1 -0
- package/dist/types/task/fork-context-budget.d.ts +7 -0
- package/dist/types/task/token-log.d.ts +30 -0
- package/dist/types/task/token-log.test.d.ts +1 -0
- package/dist/types/tools/bisect.d.ts +81 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/search.d.ts +2 -2
- package/dist/types/utils/file-mentions.d.ts +14 -0
- package/dist/types/utils/git.d.ts +32 -0
- package/dist/types/web/scrapers/youtube.d.ts +8 -0
- package/package.json +7 -7
- package/src/append-only-mode.ts +58 -0
- package/src/autoresearch/prompt.md +1 -1
- package/src/autoresearch/tools/log-experiment.ts +1 -1
- package/src/capability/rule.ts +9 -0
- package/src/cli/config-cli.ts +27 -3
- package/src/cli/fixture-report.test.ts +230 -0
- package/src/cli/fixture-report.ts +171 -0
- package/src/cli/nofile-limit.ts +48 -0
- package/src/cli/update-cli.ts +85 -9
- package/src/cli.ts +26 -0
- package/src/commands/contribution-prep.ts +1 -1
- package/src/config/settings-schema.ts +68 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +12 -13
- package/src/defaults/gjc/skills/ralplan/SKILL.md +30 -93
- package/src/defaults/gjc/skills/team/SKILL.md +16 -13
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +59 -4
- package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +30 -0
- package/src/defaults/gjc-defaults.ts +9 -0
- package/src/discovery/helpers.ts +10 -0
- package/src/discovery/index.ts +1 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +60 -2
- package/src/export/ttsr.ts +96 -18
- package/src/extensibility/extensions/runner.ts +181 -219
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +156 -44
- package/src/gjc-runtime/deep-interview-state.ts +31 -2
- package/src/gjc-runtime/session-state-sidecar.ts +208 -13
- package/src/gjc-runtime/state-writer.ts +15 -0
- package/src/gjc-runtime/team-runtime.ts +164 -15
- package/src/gjc-runtime/ultragoal-guard.ts +55 -66
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +315 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +1593 -143
- package/src/gjc-runtime/workflow-manifest.generated.json +113 -2
- package/src/gjc-runtime/workflow-manifest.ts +74 -1
- package/src/goals/runtime.ts +10 -2
- package/src/hindsight/backend.ts +1 -3
- package/src/hooks/native-skill-hook.ts +95 -4
- package/src/internal-urls/docs-index.generated.ts +14 -5
- package/src/main.ts +43 -2
- package/src/memories/index.ts +12 -1
- package/src/modes/components/assistant-message.ts +241 -82
- package/src/modes/components/custom-editor.ts +1 -0
- package/src/modes/components/status-line.ts +167 -54
- package/src/modes/components/tool-execution.ts +44 -19
- package/src/modes/controllers/command-controller.ts +39 -2
- package/src/modes/controllers/event-controller.ts +66 -47
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/interactive-mode.ts +9 -1
- package/src/modes/rpc/rpc-mode.ts +29 -3
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/modes/shared/agent-wire/command-validation.ts +4 -0
- package/src/modes/shared/agent-wire/event-contract.ts +22 -0
- package/src/modes/shared/agent-wire/event-envelope.ts +82 -0
- package/src/modes/shared/agent-wire/scopes.ts +1 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +17 -4
- package/src/notifications/config.ts +16 -4
- package/src/notifications/index.ts +78 -5
- package/src/notifications/telegram-daemon-cli.ts +2 -2
- package/src/notifications/telegram-daemon-control.ts +2 -2
- package/src/notifications/telegram-daemon.ts +85 -8
- package/src/notifications/threaded-render.ts +17 -1
- package/src/prompts/agents/architect.md +19 -10
- package/src/prompts/agents/critic.md +16 -18
- package/src/prompts/agents/executor.md +14 -11
- package/src/prompts/agents/planner.md +18 -13
- package/src/prompts/ci-green-request.md +3 -8
- package/src/prompts/goals/goal-continuation.md +0 -5
- package/src/prompts/goals/goal-mode-active.md +0 -5
- package/src/prompts/system/agent-creation-architect.md +2 -2
- package/src/prompts/system/plan-mode-active.md +1 -1
- package/src/prompts/system/plan-mode-subagent.md +3 -3
- package/src/prompts/system/project-prompt.md +0 -13
- package/src/prompts/system/subagent-system-prompt.md +3 -2
- package/src/prompts/system/system-prompt.md +26 -19
- package/src/prompts/system/volatile-project-context.md +10 -0
- package/src/prompts/system/web-search.md +4 -7
- package/src/prompts/tools/apply-patch.md +6 -0
- package/src/prompts/tools/ast-edit.md +1 -0
- package/src/prompts/tools/bash.md +1 -1
- package/src/prompts/tools/bisect.md +22 -0
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +0 -2
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/find.md +1 -1
- package/src/prompts/tools/github.md +6 -6
- package/src/prompts/tools/goal.md +1 -3
- package/src/prompts/tools/image-gen.md +12 -5
- package/src/prompts/tools/irc.md +5 -4
- package/src/prompts/tools/job.md +2 -2
- package/src/prompts/tools/lsp.md +1 -1
- package/src/prompts/tools/monitor.md +2 -2
- package/src/prompts/tools/patch.md +2 -2
- package/src/prompts/tools/replace.md +2 -21
- package/src/prompts/tools/rewind.md +1 -1
- package/src/prompts/tools/search-tool-bm25.md +1 -11
- package/src/prompts/tools/search.md +1 -1
- package/src/prompts/tools/skill.md +3 -2
- package/src/prompts/tools/ssh.md +2 -1
- package/src/prompts/tools/subagent.md +2 -2
- package/src/prompts/tools/task.md +4 -5
- package/src/prompts/tools/todo-write.md +1 -1
- package/src/prompts/tools/vim.md +1 -0
- package/src/prompts/tools/web-search.md +2 -0
- package/src/prompts/tools/write.md +8 -2
- package/src/sdk.ts +32 -22
- package/src/session/agent-session.ts +732 -107
- package/src/session/file-mention-pruning.ts +64 -0
- package/src/session/messages.ts +4 -0
- package/src/session/session-manager.ts +59 -2
- package/src/skill-state/active-state.ts +141 -5
- package/src/skill-state/deep-interview-mutation-guard.ts +67 -3
- package/src/slash-commands/builtin-registry.ts +65 -0
- package/src/system-prompt.ts +57 -16
- package/src/task/executor.ts +125 -20
- package/src/task/fork-context-advisory.ts +2 -7
- package/src/task/fork-context-budget.ts +9 -0
- package/src/task/index.ts +4 -3
- package/src/task/output-manager.ts +4 -2
- package/src/task/token-log.test.ts +188 -0
- package/src/task/token-log.ts +179 -0
- package/src/tools/bisect.ts +367 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/output-meta.ts +116 -4
- package/src/tools/search.ts +8 -2
- package/src/tools/skill.ts +15 -0
- package/src/utils/file-mentions.ts +39 -7
- package/src/utils/git.ts +73 -0
- package/src/web/scrapers/youtube.ts +24 -12
|
@@ -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 { gjcRoot, sessionUltragoalDir } from "./session-layout";
|
|
10
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
resolveGjcSessionForRead,
|
|
22
|
+
resolveGjcSessionForWrite,
|
|
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.trim() : "";
|
|
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
|
-
gjcGoalMode: input.plan.gjcGoalMode,
|
|
497
|
-
gjcObjective: input.plan.gjcObjective,
|
|
498
|
-
gjcObjectiveAliases: input.plan.gjcObjectiveAliases,
|
|
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.gjcGoalMode === "per-story") return "per-goal";
|
|
555
579
|
if (status !== "complete") return "per-goal";
|
|
556
|
-
const
|
|
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
|
+
gjcGoalMode: UltragoalGjcGoalMode,
|
|
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 (gjcGoalMode !== "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.gjcObjectiveAliases)
|
|
@@ -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
|
gjcGoalMode?: UltragoalGjcGoalMode;
|
|
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.gjcGoalMode ?? "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.gjcGoalMode !== "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 `gjc 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 `gjc 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 `gjc 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,
|
|
@@ -1162,6 +2133,38 @@ function isComputerSpecificToolsIndexDiff(diff: string | undefined, targetPath:
|
|
|
1162
2133
|
return false;
|
|
1163
2134
|
}
|
|
1164
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
|
+
|
|
1165
2168
|
function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
|
|
1166
2169
|
const category = row.category ?? categorizeComputerChangePath(row.path);
|
|
1167
2170
|
const oldCategory = row.oldPath ? categorizeComputerChangePath(row.oldPath) : category;
|
|
@@ -1171,7 +2174,20 @@ function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolea
|
|
|
1171
2174
|
function trustedChangeSetRequiresComputerSuite(changeSet: UltragoalChangeSet | undefined): boolean {
|
|
1172
2175
|
if (!changeSet?.trusted) return false;
|
|
1173
2176
|
return changeSet.paths.some(row => {
|
|
1174
|
-
if (isComputerControlSurfaceChangePath(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
|
+
}
|
|
1175
2191
|
return (
|
|
1176
2192
|
isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.path) ||
|
|
1177
2193
|
(row.oldPath ? isComputerSpecificToolsIndexDiff(changeSet.rawDiff, row.oldPath) : false)
|
|
@@ -2424,18 +3440,141 @@ export async function validateExecutorQaRedTeamEvidenceForReview(
|
|
|
2424
3440
|
await validateExecutorQaRedTeamEvidenceInternal(cwd, executorQa as JsonObject, options);
|
|
2425
3441
|
}
|
|
2426
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
|
+
}
|
|
2427
3544
|
async function validateCompletionQualityGate(
|
|
2428
3545
|
cwd: string,
|
|
2429
3546
|
gate: JsonObject,
|
|
2430
|
-
options: {
|
|
3547
|
+
options: {
|
|
3548
|
+
changeSet?: UltragoalChangeSet;
|
|
3549
|
+
plan?: UltragoalPlan;
|
|
3550
|
+
goal?: UltragoalGoal;
|
|
3551
|
+
ledger?: readonly UltragoalLedgerEvent[];
|
|
3552
|
+
} = {},
|
|
2431
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
|
+
}
|
|
2432
3567
|
const codeReview = qualityGateObject(gate.codeReview);
|
|
2433
3568
|
if (codeReview) {
|
|
2434
3569
|
throw new Error(
|
|
2435
3570
|
"checkpoint --status complete requires architect review approval through architectReview, executorQa, and iteration quality-gate evidence; legacy codeReview-only gates are not sufficient",
|
|
2436
3571
|
);
|
|
2437
3572
|
}
|
|
2438
|
-
const allowedKeys = new Set(
|
|
3573
|
+
const allowedKeys = new Set(
|
|
3574
|
+
batchMode
|
|
3575
|
+
? ["architectReview", "executorQa", "iteration", "validationBatchClose"]
|
|
3576
|
+
: ["architectReview", "executorQa", "iteration"],
|
|
3577
|
+
);
|
|
2439
3578
|
const unsupportedKeys = Object.keys(gate).filter(key => !allowedKeys.has(key));
|
|
2440
3579
|
if (unsupportedKeys.length > 0) {
|
|
2441
3580
|
throw new Error(`qualityGate contains unsupported keys: ${unsupportedKeys.join(", ")}`);
|
|
@@ -2482,12 +3621,105 @@ async function validateCompletionQualityGate(
|
|
|
2482
3621
|
}
|
|
2483
3622
|
requireNonEmptyString(iteration.evidence, "iteration.evidence");
|
|
2484
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
|
+
}
|
|
2485
3627
|
}
|
|
2486
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
|
+
}
|
|
2487
3714
|
async function readRequiredCompletionQualityGate(
|
|
2488
3715
|
cwd: string,
|
|
2489
3716
|
value: string | undefined,
|
|
2490
|
-
options: {
|
|
3717
|
+
options: {
|
|
3718
|
+
changeSet?: UltragoalChangeSet;
|
|
3719
|
+
plan?: UltragoalPlan;
|
|
3720
|
+
goal?: UltragoalGoal;
|
|
3721
|
+
ledger?: readonly UltragoalLedgerEvent[];
|
|
3722
|
+
} = {},
|
|
2491
3723
|
): Promise<unknown> {
|
|
2492
3724
|
if (!value?.trim()) {
|
|
2493
3725
|
throw new Error(
|
|
@@ -2497,10 +3729,55 @@ async function readRequiredCompletionQualityGate(
|
|
|
2497
3729
|
const gate = await readStructuredValue(cwd, value);
|
|
2498
3730
|
const gateObject = qualityGateObject(gate);
|
|
2499
3731
|
if (!gateObject) throw new Error("qualityGate must be a JSON object");
|
|
2500
|
-
await validateCompletionQualityGate(cwd, gateObject, {
|
|
3732
|
+
await validateCompletionQualityGate(cwd, gateObject, {
|
|
3733
|
+
changeSet: options.changeSet,
|
|
3734
|
+
plan: options.plan,
|
|
3735
|
+
goal: options.goal,
|
|
3736
|
+
ledger: options.ledger,
|
|
3737
|
+
});
|
|
2501
3738
|
return gate;
|
|
2502
3739
|
}
|
|
2503
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
|
+
|
|
2504
3781
|
function validateCompleteCheckpointTargetGoal(goal: UltragoalGoal): void {
|
|
2505
3782
|
if (COMPLETE_CHECKPOINT_ALLOWED_PRE_STATUSES.has(goal.status)) return;
|
|
2506
3783
|
if (goal.status === "pending") {
|
|
@@ -2535,17 +3812,51 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
2535
3812
|
const evidence = input.evidence.trim();
|
|
2536
3813
|
if (!evidence) throw new Error("checkpoint evidence is required");
|
|
2537
3814
|
const ledgerBefore = await readUltragoalLedger(input.cwd);
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
event
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
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
|
+
}
|
|
2549
3860
|
// Idempotent re-checkpoint: this goal is already recorded in the target status with the same
|
|
2550
3861
|
// evidence, so skip the plan rewrite and ledger append to avoid duplicate goal_checkpointed
|
|
2551
3862
|
// events. The ledger is the dedup source of truth because it is exactly what a duplicate write
|
|
@@ -2554,11 +3865,19 @@ export async function checkpointUltragoalGoal(input: {
|
|
|
2554
3865
|
// instead of silently dropping it.
|
|
2555
3866
|
return plan;
|
|
2556
3867
|
}
|
|
2557
|
-
if (input.status === "complete") validateCompleteCheckpointTargetGoal(goal);
|
|
2558
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
|
+
}
|
|
2559
3873
|
const qualityGateJson =
|
|
2560
3874
|
input.status === "complete"
|
|
2561
|
-
? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson, {
|
|
3875
|
+
? await readRequiredCompletionQualityGate(input.cwd, input.qualityGateJson, {
|
|
3876
|
+
changeSet,
|
|
3877
|
+
plan,
|
|
3878
|
+
goal,
|
|
3879
|
+
ledger: ledgerBefore,
|
|
3880
|
+
})
|
|
2562
3881
|
: input.qualityGateJson
|
|
2563
3882
|
? await readStructuredValue(input.cwd, input.qualityGateJson)
|
|
2564
3883
|
: undefined;
|
|
@@ -2806,6 +4125,7 @@ async function addUltragoalSubgoalToPlan(input: {
|
|
|
2806
4125
|
createdAt: now,
|
|
2807
4126
|
updatedAt: now,
|
|
2808
4127
|
steering: { kind, evidence, rationale },
|
|
4128
|
+
pipelineMetadata: legacyPipelineMetadata(nextId),
|
|
2809
4129
|
});
|
|
2810
4130
|
input.plan.updatedAt = now;
|
|
2811
4131
|
await writePlan(input.cwd, input.plan);
|
|
@@ -2847,6 +4167,8 @@ async function splitUltragoalSubgoal(input: {
|
|
|
2847
4167
|
});
|
|
2848
4168
|
const target = findGoalOrThrow(input.plan, input.goalId, kind);
|
|
2849
4169
|
requireGoalStatus(target, ["pending"], kind);
|
|
4170
|
+
const ledger = await readUltragoalLedger(input.cwd);
|
|
4171
|
+
requireValidationBatchSteeringAllowed(input.plan, target, kind, ledger);
|
|
2850
4172
|
const replacements = parseReplacementSpecs(input.replacementsJson, kind);
|
|
2851
4173
|
const now = new Date().toISOString();
|
|
2852
4174
|
const replacementGoalIds = replacements.map((_, index) => nextUltragoalGoalId(input.plan, index + 1));
|
|
@@ -2854,6 +4176,8 @@ async function splitUltragoalSubgoal(input: {
|
|
|
2854
4176
|
target.evidence = evidence;
|
|
2855
4177
|
target.updatedAt = now;
|
|
2856
4178
|
target.steering = { kind, evidence, rationale, replacementGoalIds };
|
|
4179
|
+
invalidatePipelineMetadata(target, "split_subgoal_superseded", now);
|
|
4180
|
+
clearValidationBatchForBatch(input.plan, target.validationBatch);
|
|
2857
4181
|
const replacementGoals = replacements.map(
|
|
2858
4182
|
(replacement, index): UltragoalGoal => ({
|
|
2859
4183
|
id: replacementGoalIds[index]!,
|
|
@@ -2863,6 +4187,7 @@ async function splitUltragoalSubgoal(input: {
|
|
|
2863
4187
|
createdAt: now,
|
|
2864
4188
|
updatedAt: now,
|
|
2865
4189
|
steering: { kind: "split_replacement", sourceGoalId: target.id, evidence, rationale },
|
|
4190
|
+
pipelineMetadata: legacyPipelineMetadata(replacementGoalIds[index]!),
|
|
2866
4191
|
}),
|
|
2867
4192
|
);
|
|
2868
4193
|
const targetIndex = input.plan.goals.findIndex(goal => goal.id === target.id);
|
|
@@ -2940,6 +4265,8 @@ async function revisePendingUltragoalWording(input: {
|
|
|
2940
4265
|
});
|
|
2941
4266
|
const goal = findGoalOrThrow(input.plan, input.goalId, kind);
|
|
2942
4267
|
requireGoalStatus(goal, ["pending"], kind);
|
|
4268
|
+
const ledger = await readUltragoalLedger(input.cwd);
|
|
4269
|
+
requireValidationBatchSteeringAllowed(input.plan, goal, kind, ledger);
|
|
2943
4270
|
const title = input.title === undefined ? undefined : input.title.trim();
|
|
2944
4271
|
const objective = input.objective === undefined ? undefined : input.objective.trim();
|
|
2945
4272
|
if (input.title !== undefined && !title)
|
|
@@ -2959,6 +4286,8 @@ async function revisePendingUltragoalWording(input: {
|
|
|
2959
4286
|
const now = new Date().toISOString();
|
|
2960
4287
|
goal.updatedAt = now;
|
|
2961
4288
|
goal.steering = { kind, evidence, rationale, changedFields };
|
|
4289
|
+
invalidatePipelineMetadata(goal, "revised_pending_wording", now);
|
|
4290
|
+
clearValidationBatchForBatch(input.plan, goal.validationBatch);
|
|
2962
4291
|
input.plan.updatedAt = now;
|
|
2963
4292
|
await writePlan(input.cwd, input.plan);
|
|
2964
4293
|
await appendLedger(input.cwd, {
|
|
@@ -3003,6 +4332,8 @@ async function markBlockedUltragoalSuperseded(input: {
|
|
|
3003
4332
|
});
|
|
3004
4333
|
const goal = findGoalOrThrow(input.plan, input.goalId, kind);
|
|
3005
4334
|
requireGoalStatus(goal, ["blocked", "review_blocked"], kind);
|
|
4335
|
+
const ledger = await readUltragoalLedger(input.cwd);
|
|
4336
|
+
requireValidationBatchSteeringAllowed(input.plan, goal, kind, ledger);
|
|
3006
4337
|
const remainingRequiredGoals = requiredUltragoalGoals(input.plan).filter(item => item.id !== goal.id);
|
|
3007
4338
|
if (remainingRequiredGoals.length === 0) {
|
|
3008
4339
|
throw new Error(`steer ${kind} cannot supersede ${goal.id} because it is the only remaining required goal`);
|
|
@@ -3012,6 +4343,7 @@ async function markBlockedUltragoalSuperseded(input: {
|
|
|
3012
4343
|
goal.evidence = evidence;
|
|
3013
4344
|
goal.updatedAt = now;
|
|
3014
4345
|
goal.steering = { kind, evidence, rationale, noReplacementRequired: true };
|
|
4346
|
+
clearValidationBatchForBatch(input.plan, goal.validationBatch);
|
|
3015
4347
|
input.plan.updatedAt = now;
|
|
3016
4348
|
await writePlan(input.cwd, input.plan);
|
|
3017
4349
|
await appendLedger(input.cwd, {
|
|
@@ -3212,6 +4544,20 @@ function parseGitNameStatus(output: string): UltragoalChangeSetPath[] {
|
|
|
3212
4544
|
return rows;
|
|
3213
4545
|
}
|
|
3214
4546
|
|
|
4547
|
+
function ciDevChangedPathRows(): UltragoalChangeSetPath[] {
|
|
4548
|
+
const raw = process.env.CI_DEV_CHANGED_PATHS;
|
|
4549
|
+
if (!raw) return [];
|
|
4550
|
+
return raw
|
|
4551
|
+
.split(/\r?\n/)
|
|
4552
|
+
.map(row => row.trim())
|
|
4553
|
+
.filter(Boolean)
|
|
4554
|
+
.map(pathValue => ({
|
|
4555
|
+
path: normalizeRepoPath(pathValue),
|
|
4556
|
+
status: "unknown" as UltragoalChangeStatus,
|
|
4557
|
+
category: categorizeComputerChangePath(pathValue),
|
|
4558
|
+
}));
|
|
4559
|
+
}
|
|
4560
|
+
|
|
3215
4561
|
function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChangeSetPath[] {
|
|
3216
4562
|
const byKey = new Map<string, UltragoalChangeSetPath>();
|
|
3217
4563
|
for (const row of groups.flat()) byKey.set(`${row.oldPath ?? ""}\u0000${row.path}`, row);
|
|
@@ -3219,8 +4565,12 @@ function mergeChangeSetPaths(groups: UltragoalChangeSetPath[][]): UltragoalChang
|
|
|
3219
4565
|
}
|
|
3220
4566
|
|
|
3221
4567
|
async function computeCheckpointChangeSet(cwd: string): Promise<UltragoalChangeSet | undefined> {
|
|
4568
|
+
const ciChangedPaths = ciDevChangedPathRows();
|
|
3222
4569
|
const inGit = await spawnText(["git", "rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: 3000 });
|
|
3223
|
-
if (!inGit.ok || inGit.stdout.trim() !== "true")
|
|
4570
|
+
if (!inGit.ok || inGit.stdout.trim() !== "true") {
|
|
4571
|
+
if (ciChangedPaths.length === 0) return undefined;
|
|
4572
|
+
return { source: "checkpoint-git", paths: ciChangedPaths, trusted: true };
|
|
4573
|
+
}
|
|
3224
4574
|
const baseRef = await resolveGitBase(cwd);
|
|
3225
4575
|
const base = baseRef;
|
|
3226
4576
|
const mergeBase = await spawnText(["git", "merge-base", "HEAD", baseRef], { cwd, timeoutMs: 3000 });
|
|
@@ -3233,17 +4583,19 @@ async function computeCheckpointChangeSet(cwd: string): Promise<UltragoalChangeS
|
|
|
3233
4583
|
spawnText(["git", "diff"], { cwd, timeoutMs: 5000 }),
|
|
3234
4584
|
spawnText(["git", "diff", "--cached"], { cwd, timeoutMs: 5000 }),
|
|
3235
4585
|
]);
|
|
3236
|
-
if (!committed.ok && !unstaged.ok && !staged.ok) return undefined;
|
|
4586
|
+
if (!committed.ok && !unstaged.ok && !staged.ok && ciChangedPaths.length === 0) return undefined;
|
|
4587
|
+
const gitPaths = mergeChangeSetPaths([
|
|
4588
|
+
parseGitNameStatus(committed.stdout),
|
|
4589
|
+
parseGitNameStatus(unstaged.stdout),
|
|
4590
|
+
parseGitNameStatus(staged.stdout),
|
|
4591
|
+
]);
|
|
4592
|
+
const paths = gitPaths.length > 0 ? gitPaths : ciChangedPaths;
|
|
3237
4593
|
return {
|
|
3238
4594
|
source: "checkpoint-git",
|
|
3239
4595
|
baseRef,
|
|
3240
4596
|
mergeBase: mergeBase.ok && mergeBase.stdout.trim() ? mergeBase.stdout.trim() : undefined,
|
|
3241
4597
|
headRef: "HEAD",
|
|
3242
|
-
paths
|
|
3243
|
-
parseGitNameStatus(committed.stdout),
|
|
3244
|
-
parseGitNameStatus(unstaged.stdout),
|
|
3245
|
-
parseGitNameStatus(staged.stdout),
|
|
3246
|
-
]),
|
|
4598
|
+
paths,
|
|
3247
4599
|
rawDiffStat: stat.stdout,
|
|
3248
4600
|
rawDiff: [committedDiff.stdout, unstagedDiff.stdout, stagedDiff.stdout].filter(Boolean).join("\n"),
|
|
3249
4601
|
trusted: true,
|
|
@@ -3517,6 +4869,17 @@ const FLAGS_WITH_VALUES = new Set([
|
|
|
3517
4869
|
"--replacements-json",
|
|
3518
4870
|
"--order-json",
|
|
3519
4871
|
"--classification",
|
|
4872
|
+
"--goal-metadata-json",
|
|
4873
|
+
"--validation-batch-json",
|
|
4874
|
+
"--prior-goal-id",
|
|
4875
|
+
"--next-goal-id",
|
|
4876
|
+
"--review-handles-json",
|
|
4877
|
+
"--qa-handles-json",
|
|
4878
|
+
"--implementation-handle-json",
|
|
4879
|
+
"--overlap-id",
|
|
4880
|
+
"--review-result-json",
|
|
4881
|
+
"--qa-result-json",
|
|
4882
|
+
"--target-state-json",
|
|
3520
4883
|
]);
|
|
3521
4884
|
|
|
3522
4885
|
function isHelpArg(arg: string): boolean {
|
|
@@ -3623,6 +4986,9 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
|
|
|
3623
4986
|
" steer",
|
|
3624
4987
|
" record-review-blockers",
|
|
3625
4988
|
" classify-blocker",
|
|
4989
|
+
" start-pipeline-overlap",
|
|
4990
|
+
" join-pipeline-overlap",
|
|
4991
|
+
" rebaseline-pipeline-overlap",
|
|
3626
4992
|
"",
|
|
3627
4993
|
"Run `gjc ultragoal checkpoint --help`, `gjc ultragoal review --help`, or `gjc ultragoal classify-blocker --help` for command-specific requirements.",
|
|
3628
4994
|
"",
|
|
@@ -3853,9 +5219,18 @@ async function executeUltragoalSteeringCommand(args: readonly string[], cwd: str
|
|
|
3853
5219
|
}
|
|
3854
5220
|
|
|
3855
5221
|
async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<UltragoalCommandResult> {
|
|
3856
|
-
|
|
5222
|
+
// Help must not require a resolvable session; render it before session resolution.
|
|
3857
5223
|
const help = renderUltragoalHelp(args);
|
|
3858
5224
|
if (help) return { status: 0, stdout: help };
|
|
5225
|
+
let sessionId: string;
|
|
5226
|
+
try {
|
|
5227
|
+
sessionId = currentUltragoalSessionId(cwd);
|
|
5228
|
+
} catch (error) {
|
|
5229
|
+
// A missing/ambiguous session is an operator input error, not a crash:
|
|
5230
|
+
// surface the guidance on stderr instead of an uncaught-exception dump.
|
|
5231
|
+
if (error instanceof SessionResolutionError) return { status: 1, stderr: `${error.message}\n` };
|
|
5232
|
+
throw error;
|
|
5233
|
+
}
|
|
3859
5234
|
try {
|
|
3860
5235
|
const command = commandName(args);
|
|
3861
5236
|
const json = hasFlag(args, "--json");
|
|
@@ -3864,8 +5239,20 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
|
|
|
3864
5239
|
return { status: 0, stdout: renderStatus(await getUltragoalStatus(cwd, sessionId), json) };
|
|
3865
5240
|
case "create":
|
|
3866
5241
|
case "create-goals": {
|
|
5242
|
+
if (
|
|
5243
|
+
flagValue(args, "--goal-metadata-json") !== undefined &&
|
|
5244
|
+
flagValue(args, "--validation-batch-json") !== undefined
|
|
5245
|
+
) {
|
|
5246
|
+
throw new Error("--validation-batch-json and --goal-metadata-json are mutually exclusive");
|
|
5247
|
+
}
|
|
3867
5248
|
const mode = flagValue(args, "--gjc-goal-mode") === "per-story" ? "per-story" : "aggregate";
|
|
3868
|
-
const plan = await createUltragoalPlan({
|
|
5249
|
+
const plan = await createUltragoalPlan({
|
|
5250
|
+
cwd,
|
|
5251
|
+
brief: await readBrief(cwd, args),
|
|
5252
|
+
gjcGoalMode: mode,
|
|
5253
|
+
goalMetadataJson: flagValue(args, "--goal-metadata-json"),
|
|
5254
|
+
validationBatchJson: flagValue(args, "--validation-batch-json"),
|
|
5255
|
+
});
|
|
3869
5256
|
return {
|
|
3870
5257
|
status: 0,
|
|
3871
5258
|
createdPlan: true,
|
|
@@ -3959,6 +5346,65 @@ async function dispatchUltragoalCommand(args: string[], cwd: string): Promise<Ul
|
|
|
3959
5346
|
: `Recorded blocker classification: ${String(event.classification)}.\n`,
|
|
3960
5347
|
};
|
|
3961
5348
|
}
|
|
5349
|
+
case "start-pipeline-overlap": {
|
|
5350
|
+
const receipt = await startUltragoalPipelineOverlap({
|
|
5351
|
+
cwd,
|
|
5352
|
+
priorGoalId: flagValue(args, "--prior-goal-id") ?? "",
|
|
5353
|
+
nextGoalId: flagValue(args, "--next-goal-id") ?? "",
|
|
5354
|
+
reviewHandles: await readRequiredJsonObjectOrArray(
|
|
5355
|
+
cwd,
|
|
5356
|
+
flagValue(args, "--review-handles-json") ?? "",
|
|
5357
|
+
"review handles",
|
|
5358
|
+
),
|
|
5359
|
+
qaHandles: await readRequiredJsonObjectOrArray(
|
|
5360
|
+
cwd,
|
|
5361
|
+
flagValue(args, "--qa-handles-json") ?? "",
|
|
5362
|
+
"QA handles",
|
|
5363
|
+
),
|
|
5364
|
+
implementationHandle: await readRequiredJsonObject(
|
|
5365
|
+
cwd,
|
|
5366
|
+
flagValue(args, "--implementation-handle-json") ?? "",
|
|
5367
|
+
"implementation handle",
|
|
5368
|
+
),
|
|
5369
|
+
});
|
|
5370
|
+
return {
|
|
5371
|
+
status: 0,
|
|
5372
|
+
stdout: json ? renderCliWriteReceipt(receipt) : `Started pipeline overlap ${receipt.overlap_id}.\n`,
|
|
5373
|
+
};
|
|
5374
|
+
}
|
|
5375
|
+
case "join-pipeline-overlap": {
|
|
5376
|
+
const receipt = await joinUltragoalPipelineOverlap({
|
|
5377
|
+
cwd,
|
|
5378
|
+
overlapId: flagValue(args, "--overlap-id") ?? "",
|
|
5379
|
+
reviewResult: await readRequiredJsonObject(
|
|
5380
|
+
cwd,
|
|
5381
|
+
flagValue(args, "--review-result-json") ?? "",
|
|
5382
|
+
"review result",
|
|
5383
|
+
),
|
|
5384
|
+
qaResult: await readRequiredJsonObject(cwd, flagValue(args, "--qa-result-json") ?? "", "QA result"),
|
|
5385
|
+
});
|
|
5386
|
+
return {
|
|
5387
|
+
status: 0,
|
|
5388
|
+
stdout: json ? renderCliWriteReceipt(receipt) : `Joined pipeline overlap ${receipt.overlap_id}.\n`,
|
|
5389
|
+
};
|
|
5390
|
+
}
|
|
5391
|
+
case "rebaseline-pipeline-overlap": {
|
|
5392
|
+
const receipt = await rebaselineUltragoalPipelineOverlap({
|
|
5393
|
+
cwd,
|
|
5394
|
+
overlapId: flagValue(args, "--overlap-id") ?? "",
|
|
5395
|
+
goalId: flagValue(args, "--goal-id") ?? "",
|
|
5396
|
+
evidence: flagValue(args, "--evidence") ?? "",
|
|
5397
|
+
targetState: await readRequiredJsonObject(
|
|
5398
|
+
cwd,
|
|
5399
|
+
flagValue(args, "--target-state-json") ?? "",
|
|
5400
|
+
"target state",
|
|
5401
|
+
),
|
|
5402
|
+
});
|
|
5403
|
+
return {
|
|
5404
|
+
status: 0,
|
|
5405
|
+
stdout: json ? renderCliWriteReceipt(receipt) : `Rebaselined pipeline overlap ${receipt.overlap_id}.\n`,
|
|
5406
|
+
};
|
|
5407
|
+
}
|
|
3962
5408
|
default:
|
|
3963
5409
|
return { status: 1, stderr: `Unknown gjc ultragoal command: ${command}\n` };
|
|
3964
5410
|
}
|
|
@@ -3977,6 +5423,9 @@ const RECONCILE_COMMANDS = new Set([
|
|
|
3977
5423
|
"record-review-blockers",
|
|
3978
5424
|
"review",
|
|
3979
5425
|
"classify-blocker",
|
|
5426
|
+
"start-pipeline-overlap",
|
|
5427
|
+
"join-pipeline-overlap",
|
|
5428
|
+
"rebaseline-pipeline-overlap",
|
|
3980
5429
|
]);
|
|
3981
5430
|
|
|
3982
5431
|
/**
|
|
@@ -4013,6 +5462,7 @@ async function reconcileUltragoalState(cwd: string): Promise<void> {
|
|
|
4013
5462
|
if (summary.nudgeRemaining !== undefined) payload.nudge_remaining = summary.nudgeRemaining;
|
|
4014
5463
|
if (summary.nudgeGoalId !== undefined) payload.nudge_goal_id = summary.nudgeGoalId;
|
|
4015
5464
|
if (summary.nudgeTargetKind !== undefined) payload.nudge_target_kind = summary.nudgeTargetKind;
|
|
5465
|
+
if (summary.pipelineOverlap) payload.pipeline_overlap = summary.pipelineOverlap;
|
|
4016
5466
|
const ledgerText = await Bun.file(summary.paths.ledgerPath)
|
|
4017
5467
|
.text()
|
|
4018
5468
|
.catch(() => "");
|