@tea-agent/loop-agent 0.44.0-next.8 → 0.44.0-next.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +97 -0
- package/dist/application/evaluation/budget.js +21 -0
- package/dist/application/evaluation/corpus-hash.js +10 -15
- package/dist/application/evaluation/corpus.js +2 -1
- package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
- package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
- package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
- package/dist/application/evaluation/frontend-pair-registration.js +143 -0
- package/dist/application/evaluation/frontend-paired-summary.js +150 -0
- package/dist/application/evaluation/frontend-run-observation.js +144 -0
- package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
- package/dist/application/evaluation/types.js +45 -12
- package/dist/application/task-lifecycle/observe.js +26 -1
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +11 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/task-advance.js +3 -5
- package/dist/commands/task-source-prepare.js +13 -1
- package/dist/executors/dag-pi/tools/design-terminal-tools.js +17 -7
- package/dist/executors/dag-pi/tools/review-terminal-tools.js +17 -7
- package/dist/executors/dag-pi-executor.js +2048 -125
- package/dist/executors/pi-executor.js +6 -1
- package/dist/executors/pi-sdk-executor.js +76 -11
- package/dist/executors/shell-executor.js +88 -12
- package/dist/infrastructure/console/operation-store.js +2 -0
- package/dist/infrastructure/evaluation/corpus-store.js +37 -12
- package/dist/shared/operator/capabilities.js +10 -5
- package/dist/task/config-types.js +22 -9
- package/dist/task/contract/project.js +13 -0
- package/dist/task/contract/schema.js +2 -8
- package/dist/task/source-prepare/parse-intent.js +124 -5
- package/dist/task/source-prepare/prepare.js +6 -1
- package/dist/task/source-prepare/semantic-intake.js +17 -2
- package/dist/task/source-prepare/source-execution.js +65 -0
- package/dist/task/source-prepare/source-fidelity-pi.js +21 -8
- package/dist/task/source-prepare/source-provider-budget.js +290 -0
- package/dist/worker/console/operator-actions.js +0 -1
- package/dist/worker/console/operator-user-error.js +1 -1
- package/dist/worker/console/prd-intake-bridge.js +5 -15
- package/dist/workflows/dag/budget-enforcement.js +31 -2
- package/dist/workflows/dag/frontend-closeout.js +3 -1
- package/dist/workflows/dag/frontend-contract-facts.js +11 -8
- package/dist/workflows/dag/frontend-design-policy.js +6 -6
- package/dist/workflows/dag/frontend-durable-tools.js +90 -70
- package/dist/workflows/dag/frontend-implementation-contract.js +146 -59
- package/dist/workflows/dag/frontend-plan-canary.js +66 -16
- package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
- package/dist/workflows/dag/frontend-plan-progress.js +92 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +11 -10
- package/dist/workflows/dag/frontend-recovery-run.js +51 -46
- package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
- package/dist/workflows/dag/frontend-review-context.js +29 -1
- package/dist/workflows/dag/frontend-review-scopes.js +92 -2
- package/dist/workflows/dag/frontend-risk.js +8 -3
- package/dist/workflows/dag/frontend-root-observation.js +261 -0
- package/dist/workflows/dag/frontend-session-budget.js +52 -39
- package/dist/workflows/dag/frontend-session-context.js +10 -0
- package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
- package/dist/workflows/dag/frontend-typed-event-store.js +11 -6
- package/dist/workflows/dag/frontend-verification-trace.js +6 -0
- package/dist/workflows/dag/frontend-writer-admission.js +2 -1
- package/dist/workflows/dag/init-hybrid.js +108 -37
- package/dist/workflows/dag/node-execution.js +11 -11
- package/dist/workflows/dag/rerun-feedback.js +35 -4
- package/dist/workflows/dag/rerun-task.js +62 -67
- package/dist/workflows/dag/runner.js +58 -12
- package/dist/workflows/dag/types.js +15 -8
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/templates/backend-test-dag.json +4 -4
- package/docs/templates/frontend-implementation-contract.schema.json +31 -11
- package/package.json +1 -1
- package/skills/frontend-design-review/SKILL.md +8 -11
- package/skills/frontend-design-review/references/review-checklist.md +3 -4
- package/skills/frontend-review/SKILL.md +5 -1
- package/skills/loop-agent/references/command-reference.md +8 -0
|
@@ -1,28 +1,24 @@
|
|
|
1
|
+
import { reviewFindingSchema } from "../workflows/dag/frontend-typed-event-store.js";
|
|
2
|
+
import { authorizeRepairAssertions } from "../workflows/dag/frontend-repair-assertions.js";
|
|
3
|
+
import { createFrontendPlanProgressGuard } from "../workflows/dag/frontend-plan-progress.js";
|
|
1
4
|
import { classifyFrontendPlanRecovery } from "../workflows/dag/frontend-plan-recovery-policy.js";
|
|
2
|
-
import { collectFrontendExecutionGroups } from "../workflows/dag/frontend-execution-groups.js";
|
|
5
|
+
import { collectFrontendExecutionGroups, frontendExecutionSchema } from "../workflows/dag/frontend-execution-groups.js";
|
|
3
6
|
import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
|
|
4
7
|
import { createDurableFrontendTools } from "../workflows/dag/frontend-durable-tools.js";
|
|
5
8
|
import { sha256OfCanonicalJson } from "../task/contract/hash.js";
|
|
6
9
|
import { z } from "zod";
|
|
7
|
-
import { loadFrontendReviewScopes } from "../workflows/dag/frontend-review-scopes.js";
|
|
8
|
-
import { observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
|
|
10
|
+
import { createFrontendReviewScopeProtocol, loadFrontendReviewScopes } from "../workflows/dag/frontend-review-scopes.js";
|
|
11
|
+
import { observeFrontendFinalization, observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
|
|
9
12
|
import path from "node:path";
|
|
10
13
|
import { createHash, randomUUID } from "node:crypto";
|
|
11
14
|
import { readFile, stat } from "node:fs/promises";
|
|
12
15
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
16
|
+
import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
|
|
17
|
+
import { mapContractBlockedOwner } from "../workflows/dag/frontend-human-decision.js";
|
|
13
18
|
import { routeFrontendProviderCapability } from "../workflows/dag/frontend-provider-capability-matrix.js";
|
|
14
19
|
import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
|
|
15
20
|
import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
|
|
16
21
|
import { buildPiWriterToolPolicyContext, createPiReaderCustomTools, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
17
|
-
import { resolveDagPiModelConfig } from "./dag-pi/model-config.js";
|
|
18
|
-
import { isRecordObject } from "./dag-pi/guards.js";
|
|
19
|
-
import { createFrontendReviewTerminalTools } from "./dag-pi/tools/review-terminal-tools.js";
|
|
20
|
-
import { createFrontendDesignTerminalTools } from "./dag-pi/tools/design-terminal-tools.js";
|
|
21
|
-
import { createFrontendScoutEvidenceTools } from "./dag-pi/tools/scout-evidence-tools.js";
|
|
22
|
-
import { createFrontendPlanDecisionTools, translateDecisionPatchFindings } from "./dag-pi/tools/plan-decision-tools.js";
|
|
23
|
-
import { createFrontendContractTools } from "./dag-pi/tools/contract-tools.js";
|
|
24
|
-
import { loadContractRequirementInheritance } from "./dag-pi/plan/ledger-contract.js";
|
|
25
|
-
import { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts, committedFactFromPlanRecord, planFactStringList, planFactScopeIntersects } from "./dag-pi/plan/facts.js";
|
|
26
22
|
import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
|
|
27
23
|
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
28
24
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
@@ -33,7 +29,7 @@ import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelai
|
|
|
33
29
|
import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
|
|
34
30
|
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
35
31
|
import { readTypedEventStoreFromJsonl, typedEventPayloadSha256, } from "../workflows/dag/frontend-typed-event-store.js";
|
|
36
|
-
import { collectCanonicalStateFlowNames, resolveFrontendContractRequirements, } from "../workflows/dag/frontend-contract-facts.js";
|
|
32
|
+
import { collectCanonicalStateFlowNames, frontendEvidenceExpectationSchema, resolveFrontendContractRequirements, validateFrontendRequiredDeliverables, } from "../workflows/dag/frontend-contract-facts.js";
|
|
37
33
|
import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
38
34
|
import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
39
35
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
@@ -355,7 +351,15 @@ export const DAG_PI_WRITE_TOOLS = [
|
|
|
355
351
|
"ls",
|
|
356
352
|
"bash",
|
|
357
353
|
];
|
|
354
|
+
export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
|
|
358
355
|
const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
|
|
356
|
+
export const DAG_PI_MODEL_PROVIDERS = {
|
|
357
|
+
"gpt-5.3-codex-spark": "wizard-local",
|
|
358
|
+
"gpt-5.5": "wizard-local",
|
|
359
|
+
"glm-5.2": "wizard-local",
|
|
360
|
+
"deepseek-v4-flash": "deepseek",
|
|
361
|
+
"deepseek-v4-pro": "deepseek",
|
|
362
|
+
};
|
|
359
363
|
const SAFE_PI_STEPS = new Set([
|
|
360
364
|
"analyze",
|
|
361
365
|
"plan",
|
|
@@ -828,6 +832,366 @@ export function scanDesignTerminalKindsFromSessionEvents(content) {
|
|
|
828
832
|
}
|
|
829
833
|
return kinds;
|
|
830
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* M5: build the two committed typed review terminal tools (approve_review /
|
|
837
|
+
* request_review_changes). Each tool validates its parameters with the review
|
|
838
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
839
|
+
* store, and returns a structured receipt. Terminal conflicts (a second
|
|
840
|
+
* terminal commit) are caught inside execute and returned as an error receipt
|
|
841
|
+
* rather than crashing the node.
|
|
842
|
+
*/
|
|
843
|
+
export async function createFrontendReviewTerminalTools(input) {
|
|
844
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
845
|
+
import("typebox"),
|
|
846
|
+
import("@earendil-works/pi-coding-agent"),
|
|
847
|
+
]);
|
|
848
|
+
const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
849
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
850
|
+
let store = input.store;
|
|
851
|
+
const attemptId = input.attemptId;
|
|
852
|
+
const findingSchema = Type.Object({
|
|
853
|
+
severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
|
|
854
|
+
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
855
|
+
line: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
856
|
+
issue: Type.String({ minLength: 1 }),
|
|
857
|
+
requiredChange: Type.Optional(Type.String({ minLength: 1 })),
|
|
858
|
+
repairAssertions: Type.Optional(Type.Unknown({ description: "Optional mechanical checks: file-absent/state-present {check,token,source}; vt-changed also requires expectedRequirementIds. Invalid items retain diagnostics and do not remove this finding." })),
|
|
859
|
+
}, { additionalProperties: false });
|
|
860
|
+
const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "review", inventory: input.inventory, getStore: () => store, attemptId });
|
|
861
|
+
const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding").map(r => [r.fact.id, r.fact.finding])).values()];
|
|
862
|
+
const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
|
|
863
|
+
const recordFindingTool = defineTool({
|
|
864
|
+
name: "record_review_finding", label: "record_review_finding",
|
|
865
|
+
description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
|
|
866
|
+
parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
867
|
+
async execute(_callId, params) {
|
|
868
|
+
const previous = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding" && r.fact.id === params.id).at(-1);
|
|
869
|
+
const scopeIds = scopeProtocol.findingScopeIds(previous?.fact.scopeIds);
|
|
870
|
+
const fact = { kind: "review-finding", id: params.id, finding: reviewFindingSchema.parse(params.finding), ...(scopeIds ? { scopeIds } : {}) };
|
|
871
|
+
const requestId = `${attemptId}:finding:${randomUUID()}`;
|
|
872
|
+
const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
|
|
873
|
+
const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
|
|
874
|
+
const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
|
|
875
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
876
|
+
},
|
|
877
|
+
});
|
|
878
|
+
const approveParameters = Type.Object({
|
|
879
|
+
findings: Type.Optional(Type.Array(findingSchema, {
|
|
880
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
881
|
+
})),
|
|
882
|
+
}, { additionalProperties: false });
|
|
883
|
+
const requestParameters = Type.Object({
|
|
884
|
+
issueCategory: Type.Enum({
|
|
885
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
886
|
+
"approved-design-defect": "approved-design-defect",
|
|
887
|
+
"target-surface-defect": "target-surface-defect",
|
|
888
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
889
|
+
"unknown": "unknown",
|
|
890
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
891
|
+
evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
|
|
892
|
+
description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
|
|
893
|
+
}),
|
|
894
|
+
findings: Type.Optional(Type.Array(findingSchema, {
|
|
895
|
+
description: "At least one finding", minItems: 1,
|
|
896
|
+
})),
|
|
897
|
+
}, { additionalProperties: false });
|
|
898
|
+
async function adoptReviewFact(kind, fact) {
|
|
899
|
+
const requestId = randomUUID();
|
|
900
|
+
try {
|
|
901
|
+
scopeProtocol.assertComplete();
|
|
902
|
+
const parsed = kind === "approve_review"
|
|
903
|
+
? approveReviewFactSchema.parse(fact)
|
|
904
|
+
: requestReviewChangesFactSchema.parse(fact);
|
|
905
|
+
const staged = stageTypedEventFact({
|
|
906
|
+
store,
|
|
907
|
+
requestId,
|
|
908
|
+
attemptId,
|
|
909
|
+
fact: parsed,
|
|
910
|
+
});
|
|
911
|
+
const adopted = await adoptTypedEventFact({
|
|
912
|
+
store,
|
|
913
|
+
requestId,
|
|
914
|
+
attemptId,
|
|
915
|
+
fact: parsed,
|
|
916
|
+
eventId: staged.eventId,
|
|
917
|
+
expectedRevision: store.revision,
|
|
918
|
+
});
|
|
919
|
+
return {
|
|
920
|
+
content: [
|
|
921
|
+
{
|
|
922
|
+
type: "text",
|
|
923
|
+
text: JSON.stringify({
|
|
924
|
+
ok: true,
|
|
925
|
+
kind,
|
|
926
|
+
eventId: adopted.eventId,
|
|
927
|
+
revision: adopted.revision,
|
|
928
|
+
}),
|
|
929
|
+
},
|
|
930
|
+
],
|
|
931
|
+
details: {
|
|
932
|
+
ok: true,
|
|
933
|
+
kind,
|
|
934
|
+
eventId: adopted.eventId,
|
|
935
|
+
revision: adopted.revision,
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
catch (error) {
|
|
940
|
+
const code = error?.code;
|
|
941
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
942
|
+
return {
|
|
943
|
+
content: [
|
|
944
|
+
{
|
|
945
|
+
type: "text",
|
|
946
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
details: { ok: false, kind, code, error: message },
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const approveReviewTool = defineTool({
|
|
954
|
+
name: "approve_review",
|
|
955
|
+
label: "approve_review",
|
|
956
|
+
description: "Commit the authoritative approve_review terminal fact. Use only when the implementation passes review with no Critical/Important findings.",
|
|
957
|
+
promptSnippet: "Commit the authoritative approve_review terminal verdict (no Critical/Important findings).",
|
|
958
|
+
parameters: approveParameters,
|
|
959
|
+
async execute(_toolCallId, params) {
|
|
960
|
+
return adoptReviewFact("approve_review", {
|
|
961
|
+
kind: "approve_review",
|
|
962
|
+
verdict: "approve_review",
|
|
963
|
+
findings: allFindings(params?.findings),
|
|
964
|
+
});
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
const requestReviewChangesTool = defineTool({
|
|
968
|
+
name: "request_review_changes",
|
|
969
|
+
label: "request_review_changes",
|
|
970
|
+
description: "Commit the authoritative request_review_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
971
|
+
promptSnippet: "Commit the authoritative request_review_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
972
|
+
parameters: requestParameters,
|
|
973
|
+
async execute(_toolCallId, params) {
|
|
974
|
+
return adoptReviewFact("request_review_changes", {
|
|
975
|
+
kind: "request_review_changes",
|
|
976
|
+
verdict: "request_review_changes",
|
|
977
|
+
issueCategory: params?.issueCategory,
|
|
978
|
+
evidenceRefs: params?.evidenceRefs,
|
|
979
|
+
findings: allFindings(params?.findings),
|
|
980
|
+
});
|
|
981
|
+
},
|
|
982
|
+
});
|
|
983
|
+
const durable = await createDurableFrontendTools({
|
|
984
|
+
file: path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), attemptId, store: input.store,
|
|
985
|
+
binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveReviewTool, requestReviewChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
|
|
986
|
+
});
|
|
987
|
+
return { ...durable, scopeProtocol };
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* M8: build the two committed typed design terminal tools (approve_design /
|
|
991
|
+
* request_design_changes). Each tool validates its parameters with the design
|
|
992
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
993
|
+
* store, and returns a structured receipt. Terminal conflicts are caught
|
|
994
|
+
* inside execute and returned as an error receipt rather than crashing the
|
|
995
|
+
* node.
|
|
996
|
+
*/
|
|
997
|
+
export async function createFrontendDesignTerminalTools(input) {
|
|
998
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
999
|
+
import("typebox"),
|
|
1000
|
+
import("@earendil-works/pi-coding-agent"),
|
|
1001
|
+
]);
|
|
1002
|
+
const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1003
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
1004
|
+
let store = input.store;
|
|
1005
|
+
const attemptId = input.attemptId;
|
|
1006
|
+
const findingSchema = Type.Object({
|
|
1007
|
+
severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
|
|
1008
|
+
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
1009
|
+
line: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
1010
|
+
issue: Type.String({ minLength: 1 }),
|
|
1011
|
+
requiredChange: Type.Optional(Type.String({ minLength: 1 })),
|
|
1012
|
+
repairAssertions: Type.Optional(Type.Unknown({ description: "Optional mechanical checks: file-absent/state-present {check,token,source}; vt-changed also requires expectedRequirementIds. Invalid items retain diagnostics and do not remove this finding." })),
|
|
1013
|
+
}, { additionalProperties: false });
|
|
1014
|
+
const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "design", inventory: input.inventory, getStore: () => store, attemptId });
|
|
1015
|
+
const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding").map(r => [r.fact.id, r.fact.finding])).values()];
|
|
1016
|
+
const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
|
|
1017
|
+
const recordFindingTool = defineTool({
|
|
1018
|
+
name: "record_design_finding", label: "record_design_finding",
|
|
1019
|
+
description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
|
|
1020
|
+
parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
1021
|
+
async execute(_callId, params) {
|
|
1022
|
+
const previous = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding" && r.fact.id === params.id).at(-1);
|
|
1023
|
+
const scopeIds = scopeProtocol.findingScopeIds(previous?.fact.scopeIds);
|
|
1024
|
+
const fact = { kind: "design-finding", id: params.id, finding: reviewFindingSchema.parse(params.finding), ...(scopeIds ? { scopeIds } : {}) };
|
|
1025
|
+
const requestId = `${attemptId}:finding:${randomUUID()}`;
|
|
1026
|
+
const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
|
|
1027
|
+
const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
|
|
1028
|
+
const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
|
|
1029
|
+
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
const approveParameters = Type.Object({
|
|
1033
|
+
findings: Type.Optional(Type.Array(findingSchema, {
|
|
1034
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
1035
|
+
})),
|
|
1036
|
+
}, { additionalProperties: false });
|
|
1037
|
+
const requestParameters = Type.Object({
|
|
1038
|
+
issueCategory: Type.Enum({
|
|
1039
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
1040
|
+
"approved-design-defect": "approved-design-defect",
|
|
1041
|
+
"target-surface-defect": "target-surface-defect",
|
|
1042
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
1043
|
+
"unknown": "unknown",
|
|
1044
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
1045
|
+
evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
|
|
1046
|
+
description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
|
|
1047
|
+
}),
|
|
1048
|
+
findings: Type.Optional(Type.Array(findingSchema, {
|
|
1049
|
+
description: "At least one finding", minItems: 1,
|
|
1050
|
+
})),
|
|
1051
|
+
}, { additionalProperties: false });
|
|
1052
|
+
async function adoptDesignFact(kind, fact) {
|
|
1053
|
+
const requestId = randomUUID();
|
|
1054
|
+
try {
|
|
1055
|
+
scopeProtocol.assertComplete();
|
|
1056
|
+
const parsed = kind === "approve_design"
|
|
1057
|
+
? approveDesignFactSchema.parse(fact)
|
|
1058
|
+
: requestDesignChangesFactSchema.parse(fact);
|
|
1059
|
+
const staged = stageTypedEventFact({
|
|
1060
|
+
store,
|
|
1061
|
+
requestId,
|
|
1062
|
+
attemptId,
|
|
1063
|
+
fact: parsed,
|
|
1064
|
+
});
|
|
1065
|
+
const adopted = await adoptTypedEventFact({
|
|
1066
|
+
store,
|
|
1067
|
+
requestId,
|
|
1068
|
+
attemptId,
|
|
1069
|
+
fact: parsed,
|
|
1070
|
+
eventId: staged.eventId,
|
|
1071
|
+
expectedRevision: store.revision,
|
|
1072
|
+
});
|
|
1073
|
+
return {
|
|
1074
|
+
content: [
|
|
1075
|
+
{
|
|
1076
|
+
type: "text",
|
|
1077
|
+
text: JSON.stringify({
|
|
1078
|
+
ok: true,
|
|
1079
|
+
kind,
|
|
1080
|
+
eventId: adopted.eventId,
|
|
1081
|
+
revision: adopted.revision,
|
|
1082
|
+
}),
|
|
1083
|
+
},
|
|
1084
|
+
],
|
|
1085
|
+
details: {
|
|
1086
|
+
ok: true,
|
|
1087
|
+
kind,
|
|
1088
|
+
eventId: adopted.eventId,
|
|
1089
|
+
revision: adopted.revision,
|
|
1090
|
+
},
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
const code = error?.code;
|
|
1095
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1096
|
+
return {
|
|
1097
|
+
content: [
|
|
1098
|
+
{
|
|
1099
|
+
type: "text",
|
|
1100
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
1101
|
+
},
|
|
1102
|
+
],
|
|
1103
|
+
details: { ok: false, kind, code, error: message },
|
|
1104
|
+
};
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
const approveDesignTool = defineTool({
|
|
1108
|
+
name: "approve_design",
|
|
1109
|
+
label: "approve_design",
|
|
1110
|
+
description: "Commit the authoritative approve_design terminal fact. Use only when the plan passes design review with no Critical/Important findings.",
|
|
1111
|
+
promptSnippet: "Commit the authoritative approve_design terminal verdict (no Critical/Important findings).",
|
|
1112
|
+
parameters: approveParameters,
|
|
1113
|
+
async execute(_toolCallId, params) {
|
|
1114
|
+
return adoptDesignFact("approve_design", {
|
|
1115
|
+
kind: "approve_design",
|
|
1116
|
+
verdict: "approve_design",
|
|
1117
|
+
findings: allFindings(params?.findings),
|
|
1118
|
+
});
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
const requestDesignChangesTool = defineTool({
|
|
1122
|
+
name: "request_design_changes",
|
|
1123
|
+
label: "request_design_changes",
|
|
1124
|
+
description: "Commit the authoritative request_design_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
1125
|
+
promptSnippet: "Commit the authoritative request_design_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
1126
|
+
parameters: requestParameters,
|
|
1127
|
+
async execute(_toolCallId, params) {
|
|
1128
|
+
return adoptDesignFact("request_design_changes", {
|
|
1129
|
+
kind: "request_design_changes",
|
|
1130
|
+
verdict: "request_design_changes",
|
|
1131
|
+
issueCategory: params?.issueCategory,
|
|
1132
|
+
evidenceRefs: params?.evidenceRefs,
|
|
1133
|
+
findings: allFindings(params?.findings),
|
|
1134
|
+
});
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
const durable = await createDurableFrontendTools({
|
|
1138
|
+
file: path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), attemptId, store: input.store,
|
|
1139
|
+
binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveDesignTool, requestDesignChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
|
|
1140
|
+
});
|
|
1141
|
+
return { ...durable, scopeProtocol };
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Source fidelity ledger (AC-005/AC-006): load the contract node's committed
|
|
1145
|
+
* requirement facts and build a requirement-id → provenance map. The contract
|
|
1146
|
+
* node declared sourceFragmentIds/sourceRefs from the ledger's
|
|
1147
|
+
* requirement→fragment mapping; the plan inherits them by id so the compiled
|
|
1148
|
+
* canonical contract carries authoritative provenance without the plan
|
|
1149
|
+
* re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
|
|
1150
|
+
* ledger yields an empty map and the plan compiles as before (the design
|
|
1151
|
+
* policy shell will then fail closed on missing provenance).
|
|
1152
|
+
*/
|
|
1153
|
+
async function loadContractRequirementInheritance(runDir) {
|
|
1154
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1155
|
+
let records;
|
|
1156
|
+
try {
|
|
1157
|
+
records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return new Map();
|
|
1161
|
+
}
|
|
1162
|
+
const byId = new Map();
|
|
1163
|
+
for (const record of records) {
|
|
1164
|
+
const fact = record.fact;
|
|
1165
|
+
if (!fact || typeof fact !== "object")
|
|
1166
|
+
continue;
|
|
1167
|
+
const recordFact = fact;
|
|
1168
|
+
if (recordFact.origin !== "contract" ||
|
|
1169
|
+
recordFact.kind !== "requirement") {
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
// Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
|
|
1173
|
+
// the fact itself (origin=contract, kind=requirement, id, text, ...),
|
|
1174
|
+
// not inside an `entry` wrapper.
|
|
1175
|
+
const id = typeof recordFact.id === "string" ? recordFact.id : "";
|
|
1176
|
+
if (!id)
|
|
1177
|
+
continue;
|
|
1178
|
+
const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
|
|
1179
|
+
? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
|
|
1180
|
+
: undefined;
|
|
1181
|
+
const sourceRefs = Array.isArray(recordFact.sourceRefs)
|
|
1182
|
+
? recordFact.sourceRefs.filter((value) => typeof value === "string")
|
|
1183
|
+
: undefined;
|
|
1184
|
+
if (sourceFragmentIds || sourceRefs) {
|
|
1185
|
+
byId.set(id, {
|
|
1186
|
+
...(typeof recordFact.text === "string" ? { text: recordFact.text } : {}),
|
|
1187
|
+
...(recordFact.execution !== undefined ? { execution: frontendExecutionSchema.parse(recordFact.execution) } : {}),
|
|
1188
|
+
...(sourceFragmentIds ? { sourceFragmentIds } : {}),
|
|
1189
|
+
...(sourceRefs ? { sourceRefs } : {}),
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
return byId;
|
|
1194
|
+
}
|
|
831
1195
|
export async function resolveFrontendDecisionAuthority(input) {
|
|
832
1196
|
const inheritance = await loadContractRequirementInheritance(input.runDir);
|
|
833
1197
|
if (inheritance.size === 0)
|
|
@@ -2345,7 +2709,9 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
2345
2709
|
nextOffset: offset + items.length < values.length ? offset + items.length : null });
|
|
2346
2710
|
},
|
|
2347
2711
|
});
|
|
2712
|
+
const planControl = createFrontendPlanProgressGuard();
|
|
2348
2713
|
const durable = await createDurableFrontendTools({
|
|
2714
|
+
planControl,
|
|
2349
2715
|
file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId, store: input.store,
|
|
2350
2716
|
setWorkingStore: next => { store = next; },
|
|
2351
2717
|
binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
|
|
@@ -2371,6 +2737,7 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
2371
2737
|
if (!durableFinalizePlanTool)
|
|
2372
2738
|
throw new Error("frontend plan durable finalize tool unavailable");
|
|
2373
2739
|
return {
|
|
2740
|
+
planControl,
|
|
2374
2741
|
customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
|
|
2375
2742
|
adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
|
|
2376
2743
|
for (const record of records) {
|
|
@@ -2465,9 +2832,58 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
2465
2832
|
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
2466
2833
|
};
|
|
2467
2834
|
}
|
|
2835
|
+
/** Translate derived-patch validation findings into decision-channel
|
|
2836
|
+
* vocabulary. Each zod issue path names a COMPILED patch array, but the model
|
|
2837
|
+
* authored record_* facts — so quote the offending entry's identity and name
|
|
2838
|
+
* the tool that owns it (r14: "uiComponentChoices.0.specReference.section:
|
|
2839
|
+
* Required" is unactionable when the model has never heard of specReference). */
|
|
2840
|
+
export function translateDecisionPatchFindings(message, decision) {
|
|
2841
|
+
const body = message.replace(/^invalid-output:\s*/, "");
|
|
2842
|
+
return body
|
|
2843
|
+
.split(/;\s*/)
|
|
2844
|
+
.map(issue => {
|
|
2845
|
+
const choice = issue.match(/uiComponentChoices\.(\d+)\.(.+)/);
|
|
2846
|
+
if (choice) {
|
|
2847
|
+
const row = decision.reuseDecisions[Number(choice[1])];
|
|
2848
|
+
const base = row
|
|
2849
|
+
? `record_reuse_decision purpose "${row.purpose}" (component ${row.symbol}, decision ${row.decision}): ${issue}`
|
|
2850
|
+
: `record_reuse_decision row ${choice[1]}: ${issue}`;
|
|
2851
|
+
return choice[2]?.startsWith("specReference") === true
|
|
2852
|
+
? `${base} — pass specSection (the AC id / task-source section it implements) on that entry, or use decision "reuse-existing"`
|
|
2853
|
+
: base;
|
|
2854
|
+
}
|
|
2855
|
+
const focus = issue.match(/verificationTargets\.(\d+)\.(.+)/);
|
|
2856
|
+
if (focus) {
|
|
2857
|
+
const row = decision.verificationFocus[Number(focus[1])];
|
|
2858
|
+
if (!row)
|
|
2859
|
+
return `record_verification_focus row ${focus[1]}: ${issue}`;
|
|
2860
|
+
return `record_verification_focus ${row.id}: ${issue} — behavior targets derive uiStates from record_state_ownership rows whose owner equals behaviorGroupId "${row.behaviorGroupId}"; record one for this group if missing`;
|
|
2861
|
+
}
|
|
2862
|
+
const state = issue.match(/uiStates\.(\d+)\.(.+)/);
|
|
2863
|
+
if (state) {
|
|
2864
|
+
const row = decision.stateOwnership[Number(state[1])];
|
|
2865
|
+
return row
|
|
2866
|
+
? `record_state_ownership "${row.state}" (owner ${row.owner}): ${issue}`
|
|
2867
|
+
: `record_state_ownership row ${state[1]}: ${issue}`;
|
|
2868
|
+
}
|
|
2869
|
+
const interaction = issue.match(/interactions\.(\d+)\.(.+)/);
|
|
2870
|
+
if (interaction) {
|
|
2871
|
+
const row = decision.dataFlows[Number(interaction[1])];
|
|
2872
|
+
return row
|
|
2873
|
+
? `record_data_flow "${row.interaction}": ${issue}`
|
|
2874
|
+
: `record_data_flow row ${interaction[1]}: ${issue}`;
|
|
2875
|
+
}
|
|
2876
|
+
const requirement = issue.match(/requirements\.(\d+)\.(.+)/);
|
|
2877
|
+
if (requirement) {
|
|
2878
|
+
return `record_module_placement row ${requirement[1]}: ${issue}`;
|
|
2879
|
+
}
|
|
2880
|
+
return issue;
|
|
2881
|
+
})
|
|
2882
|
+
.join("; ");
|
|
2883
|
+
}
|
|
2468
2884
|
/** Read the recovery child's read-only parent decision snapshot (written by
|
|
2469
2885
|
* the recovery continuation). Absent file → undefined (fresh plan). */
|
|
2470
|
-
export async function readParentDecisionSnapshot(runDir, nodeId) {
|
|
2886
|
+
export async function readParentDecisionSnapshot(runDir, nodeId, context) {
|
|
2471
2887
|
const snapshotPath = path.join(runDir, nodeId, "parent-decision-snapshot.json");
|
|
2472
2888
|
let raw;
|
|
2473
2889
|
try {
|
|
@@ -2479,8 +2895,45 @@ export async function readParentDecisionSnapshot(runDir, nodeId) {
|
|
|
2479
2895
|
throw error;
|
|
2480
2896
|
}
|
|
2481
2897
|
const parsed = JSON.parse(raw);
|
|
2482
|
-
if (!Array.isArray(parsed.facts))
|
|
2898
|
+
if (parsed.schemaVersion !== 1 || parsed.schemaId !== "frontend-plan-parent-decision-snapshot-v1" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(parsed.parentRunId) || !/^[a-f0-9]{64}$/.test(parsed.parentLedgerSha256) || !Array.isArray(parsed.facts) || parsed.facts.some(fact => !fact || typeof fact.kind !== "string" || !fact.entry || typeof fact.entry !== "object" || Array.isArray(fact.entry)))
|
|
2483
2899
|
throw new Error(`parent decision snapshot malformed: ${snapshotPath}`);
|
|
2900
|
+
delete parsed.resolvedParentRunDir;
|
|
2901
|
+
if (context) {
|
|
2902
|
+
const { locateDagRun, readDagRunState, readDagRunSpec } = await import("../workflows/dag/lifecycle.js");
|
|
2903
|
+
const state = await readDagRunState(runDir);
|
|
2904
|
+
if (state.frontendRecoveryState?.parentRunId !== parsed.parentRunId || state.frontendRecoveryState.resetRootNodeId !== "frontend-plan-pi")
|
|
2905
|
+
throw new Error("parent decision snapshot recovery lineage mismatch");
|
|
2906
|
+
const located = await locateDagRun(context.workspaceRoot, parsed.parentRunId);
|
|
2907
|
+
if (!located)
|
|
2908
|
+
throw new Error("parent decision snapshot source run unavailable");
|
|
2909
|
+
const parentSpec = await readDagRunSpec(located.runDir);
|
|
2910
|
+
const sourceHash = sha256OfCanonicalJson(context.sourceBinding ?? null);
|
|
2911
|
+
if (parsed.sourceBindingSha256 !== sourceHash || sha256OfCanonicalJson(parentSpec.sourceBinding ?? null) !== sourceHash)
|
|
2912
|
+
throw new Error("parent decision snapshot source binding mismatch");
|
|
2913
|
+
const ledgerPath = path.join(located.runDir, "frontend-plan-pi", "plan-decision-facts.jsonl");
|
|
2914
|
+
const rawLedger = await readFile(ledgerPath, "utf8");
|
|
2915
|
+
if (createHash("sha256").update(rawLedger).digest("hex") !== parsed.parentLedgerSha256)
|
|
2916
|
+
throw new Error("parent decision snapshot ledger hash mismatch");
|
|
2917
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2918
|
+
const { assembleDecisionContractFromFacts } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
2919
|
+
const records = (await readTypedEventStoreFromJsonl(ledgerPath)).filter(record => record.phase === "committed");
|
|
2920
|
+
if (records.some(record => record.payloadSha256 !== sha256OfCanonicalJson(record.fact)))
|
|
2921
|
+
throw new Error("parent decision snapshot ledger payload hash mismatch");
|
|
2922
|
+
const actual = assembleDecisionContractFromFacts(records.map(record => record.fact));
|
|
2923
|
+
if (sha256OfCanonicalJson(assembleDecisionContractFromFacts(parsed.facts)) !== sha256OfCanonicalJson(actual))
|
|
2924
|
+
throw new Error("parent decision snapshot facts differ from bound ledger");
|
|
2925
|
+
const { readCurrentFrontendRepairFindings } = await import("../workflows/dag/rerun-feedback.js");
|
|
2926
|
+
const feedback = await readCurrentFrontendRepairFindings(located.runDir);
|
|
2927
|
+
const feedbackBinding = feedback ? { sourceNodeId: feedback.sourceNodeId, ledgerSha256: feedback.ledgerSha256, terminalEventId: feedback.terminalEventId } : undefined;
|
|
2928
|
+
if (sha256OfCanonicalJson(parsed.feedbackBinding ?? null) !== sha256OfCanonicalJson(feedbackBinding ?? null) || sha256OfCanonicalJson(parsed.designFindings ?? []) !== sha256OfCanonicalJson(feedback?.verdict === "request" ? feedback.findings : []))
|
|
2929
|
+
throw new Error("parent decision snapshot finding source mismatch");
|
|
2930
|
+
if (parsed.parentCanonicalSha256) {
|
|
2931
|
+
const canonical = await readFile(path.join(located.runDir, "contracts/frontend-implementation-contract.json"), "utf8");
|
|
2932
|
+
if (createHash("sha256").update(canonical).digest("hex") !== parsed.parentCanonicalSha256)
|
|
2933
|
+
throw new Error("parent canonical hash mismatch");
|
|
2934
|
+
}
|
|
2935
|
+
parsed.resolvedParentRunDir = located.runDir;
|
|
2936
|
+
}
|
|
2484
2937
|
return parsed;
|
|
2485
2938
|
}
|
|
2486
2939
|
const decisionKindToToolName = (kind) => `record_${kind.replaceAll("-", "_")}`;
|
|
@@ -2509,6 +2962,480 @@ export async function replayParentDecisionSnapshot(snapshot, tools) {
|
|
|
2509
2962
|
}
|
|
2510
2963
|
}
|
|
2511
2964
|
}
|
|
2965
|
+
export async function createFrontendPlanDecisionTools(input) {
|
|
2966
|
+
const [{ Type }, { defineTool }, { assembleDecisionContractFromFacts, assembleExperimentPlanContract, buildFrontendPlanRelationshipPatch }] = await Promise.all([
|
|
2967
|
+
import("typebox"),
|
|
2968
|
+
import("@earendil-works/pi-coding-agent"),
|
|
2969
|
+
import("../workflows/dag/frontend-plan-decision-contract.js"),
|
|
2970
|
+
]);
|
|
2971
|
+
const { readCommittedEvents } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2972
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
2973
|
+
const { createDurableFrontendTools } = await import("../workflows/dag/frontend-durable-tools.js");
|
|
2974
|
+
let store = input.store;
|
|
2975
|
+
const { attemptId, authority } = input;
|
|
2976
|
+
const optionalStringArray = Type.Optional(Type.Array(Type.String({ minLength: 1 })));
|
|
2977
|
+
const optionalString = Type.Optional(Type.String({ minLength: 1 }));
|
|
2978
|
+
// In-session pre-validation of the derived relationship patch. The
|
|
2979
|
+
// post-session bridge runs the identical analysis; running it first inside
|
|
2980
|
+
// finalize_decision turns findings into a bounded in-session correction
|
|
2981
|
+
// (fix facts, finalize again) instead of an attempt burn. Skipped when the
|
|
2982
|
+
// caller does not supply the runtime skeleton/sourceBinding.
|
|
2983
|
+
let requiredDeliverablesCache;
|
|
2984
|
+
const prevalidateDerivedPatch = async () => {
|
|
2985
|
+
if (!input.skeleton || !input.sourceBinding)
|
|
2986
|
+
return { ok: true, canonicalSha256: "", canonical: {} };
|
|
2987
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
2988
|
+
const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
|
|
2989
|
+
requiredDeliverablesCache ??= (async () => {
|
|
2990
|
+
const map = new Map();
|
|
2991
|
+
try {
|
|
2992
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2993
|
+
const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
2994
|
+
for (const record of [...contractRecords].reverse()) {
|
|
2995
|
+
const fact = record.fact;
|
|
2996
|
+
if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
|
|
2997
|
+
continue;
|
|
2998
|
+
for (const item of Array.isArray(fact.items) ? fact.items : []) {
|
|
2999
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3000
|
+
continue;
|
|
3001
|
+
const requirementId = item.requirementId;
|
|
3002
|
+
const file = item.path;
|
|
3003
|
+
if (typeof requirementId !== "string" || typeof file !== "string")
|
|
3004
|
+
continue;
|
|
3005
|
+
const paths = map.get(requirementId) ?? [];
|
|
3006
|
+
paths.push(file);
|
|
3007
|
+
map.set(requirementId, paths);
|
|
3008
|
+
}
|
|
3009
|
+
break;
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
catch {
|
|
3013
|
+
// Missing/unreadable contract ledger → no deliverable obligations.
|
|
3014
|
+
}
|
|
3015
|
+
return map;
|
|
3016
|
+
})();
|
|
3017
|
+
const concreteWriteSet = [
|
|
3018
|
+
...new Set([
|
|
3019
|
+
...decision.modulePlacements.flatMap(item => item.paths),
|
|
3020
|
+
...decision.verificationFocus.map(item => item.file),
|
|
3021
|
+
]),
|
|
3022
|
+
];
|
|
3023
|
+
const { applyFrontendContractMergePatch, analyzeFrontendPlanPatchCandidate, FrontendContractFailure, PlanPolicyPrecheckFailure, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3024
|
+
try {
|
|
3025
|
+
const patch = buildFrontendPlanRelationshipPatch({
|
|
3026
|
+
decision,
|
|
3027
|
+
authority,
|
|
3028
|
+
concreteWriteSet,
|
|
3029
|
+
requiredDeliverables: await requiredDeliverablesCache,
|
|
3030
|
+
});
|
|
3031
|
+
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
3032
|
+
const analysis = await analyzeFrontendPlanPatchCandidate({
|
|
3033
|
+
runDir: input.runDir,
|
|
3034
|
+
rawContractText: serializeDeterministicJson(merged),
|
|
3035
|
+
sourceBinding: input.sourceBinding,
|
|
3036
|
+
});
|
|
3037
|
+
return {
|
|
3038
|
+
ok: true,
|
|
3039
|
+
canonicalSha256: createHash("sha256")
|
|
3040
|
+
.update(serializeDeterministicJson(analysis.canonical))
|
|
3041
|
+
.digest("hex"),
|
|
3042
|
+
canonical: analysis.canonical,
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
catch (error) {
|
|
3046
|
+
if (error instanceof PlanPolicyPrecheckFailure) {
|
|
3047
|
+
// Template the fix: every uncovered interaction/state maps to a
|
|
3048
|
+
// fill-in record_reuse_decision row (the decision channel's
|
|
3049
|
+
// component-choice tool).
|
|
3050
|
+
const suggestions = error.findings
|
|
3051
|
+
.filter(finding => finding.code === "ui-design-coverage-missing" && finding.path)
|
|
3052
|
+
.map(finding => ({
|
|
3053
|
+
tool: "record_reuse_decision",
|
|
3054
|
+
args: {
|
|
3055
|
+
entry: {
|
|
3056
|
+
symbol: "<name the existing or new component>",
|
|
3057
|
+
decision: "reuse-existing",
|
|
3058
|
+
evidence: ["<existing repo file that proves this reuse>"],
|
|
3059
|
+
purpose: finding.path,
|
|
3060
|
+
},
|
|
3061
|
+
},
|
|
3062
|
+
}));
|
|
3063
|
+
const suggestionBlock = suggestions.length > 0
|
|
3064
|
+
? ` Suggested record_* calls (copy, fill component, submit): ${JSON.stringify(suggestions)}`
|
|
3065
|
+
: "";
|
|
3066
|
+
return {
|
|
3067
|
+
ok: false,
|
|
3068
|
+
error: `finalize_decision pre-validation failed (fix the listed plan facts, then call finalize_decision again): ${error.message}${suggestionBlock}`,
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
// FrontendContractFailure messages carry zod issue paths and are
|
|
3072
|
+
// translated into record_* vocabulary; the bridge compiler throws
|
|
3073
|
+
// decision-state-unbindable / decision-styling-strategy-conflict
|
|
3074
|
+
// diagnostics that are already model-fixable as written.
|
|
3075
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3076
|
+
const translated = error instanceof FrontendContractFailure
|
|
3077
|
+
? translateDecisionPatchFindings(message, decision)
|
|
3078
|
+
: message;
|
|
3079
|
+
return {
|
|
3080
|
+
ok: false,
|
|
3081
|
+
error: `finalize_decision pre-validation failed (fix the listed facts with the named record_* tools — resubmit a corrected row with the same identity and replace:true to replace it — then call finalize_decision again): ${translated}`,
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
};
|
|
3085
|
+
const planToolReceipt = (details) => ({
|
|
3086
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
3087
|
+
details,
|
|
3088
|
+
});
|
|
3089
|
+
async function adoptDecisionFact(kind, requestId, fact) {
|
|
3090
|
+
try {
|
|
3091
|
+
const staged = stageTypedEventFact({
|
|
3092
|
+
store,
|
|
3093
|
+
requestId,
|
|
3094
|
+
attemptId,
|
|
3095
|
+
fact: fact,
|
|
3096
|
+
});
|
|
3097
|
+
const committed = await adoptTypedEventFact({
|
|
3098
|
+
store,
|
|
3099
|
+
requestId,
|
|
3100
|
+
attemptId,
|
|
3101
|
+
fact: fact,
|
|
3102
|
+
eventId: staged.eventId,
|
|
3103
|
+
expectedRevision: store.revision,
|
|
3104
|
+
});
|
|
3105
|
+
return { ok: true, kind, eventId: committed.eventId, revision: committed.revision, error: "" };
|
|
3106
|
+
}
|
|
3107
|
+
catch (error) {
|
|
3108
|
+
return {
|
|
3109
|
+
ok: false,
|
|
3110
|
+
kind,
|
|
3111
|
+
code: error?.code,
|
|
3112
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3113
|
+
};
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
const record = (name, label, description, entrySchema, gate) => defineTool({
|
|
3117
|
+
name,
|
|
3118
|
+
label,
|
|
3119
|
+
description,
|
|
3120
|
+
promptSnippet: `Record ${label}.`,
|
|
3121
|
+
parameters: Type.Object({ entry: entrySchema }, { additionalProperties: false }),
|
|
3122
|
+
async execute(_toolCallId, params) {
|
|
3123
|
+
const entry = params?.entry;
|
|
3124
|
+
const kind = name.replace("record_", "").replace(/_/g, "-");
|
|
3125
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3126
|
+
return planToolReceipt({ ok: false, kind, error: `${name} requires a non-empty entry object` });
|
|
3127
|
+
}
|
|
3128
|
+
if (gate) {
|
|
3129
|
+
const rejected = await gate(entry);
|
|
3130
|
+
if (rejected)
|
|
3131
|
+
return rejected;
|
|
3132
|
+
}
|
|
3133
|
+
const result = await adoptDecisionFact(kind, `${attemptId}:${name}:${randomUUID()}`, { kind, origin: "plan", entry });
|
|
3134
|
+
return planToolReceipt(result);
|
|
3135
|
+
},
|
|
3136
|
+
});
|
|
3137
|
+
const recordModulePlacementTool = record("record_module_placement", "module placement", "Record one module placement: which behavior-group/module id maps to which concrete files. Example: {\"entry\": {\"id\": \"page\", \"paths\": [\"src/page.tsx\"]}}", Type.Object({ id: Type.String({ minLength: 1 }), paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) }, { additionalProperties: false }));
|
|
3138
|
+
const recordReuseDecisionTool = record("record_reuse_decision", "reuse decision", "Record one component decision: which component serves which UI state/interaction (purpose). decision \"reuse-existing\" = an existing repo component (evidence[0] = the repo file that proves it); \"specified\" = the frontend spec mandates it; \"new\" = the task source mandates a bounded addition — specified/new require specSection naming the spec/task-source declaration they implement (e.g. an AC id). Set stylingStrategy once (on the first call) when the task mandates a style contract. Resubmit the same purpose with replace:true to correct a row. Example: {\"entry\": {\"symbol\": \"Spinner\", \"decision\": \"reuse-existing\", \"evidence\": [\"src/ui/Spinner.tsx\"], \"purpose\": \"loading\"}} or {\"entry\": {\"symbol\": \"SmokeCounter\", \"decision\": \"new\", \"evidence\": [\"需求.md\"], \"purpose\": \"increment\", \"specSection\": \"AC-FE-002\", \"stylingStrategy\": \"compact, stable, animation-free single card\"}}", Type.Object({
|
|
3139
|
+
symbol: Type.String({ minLength: 1 }),
|
|
3140
|
+
decision: Type.Union([Type.Literal("specified"), Type.Literal("reuse-existing"), Type.Literal("new")]),
|
|
3141
|
+
evidence: Type.Array(Type.String()),
|
|
3142
|
+
specSection: Type.Optional(Type.String({ minLength: 1 })),
|
|
3143
|
+
purpose: Type.String({ minLength: 1 }),
|
|
3144
|
+
covers: optionalStringArray,
|
|
3145
|
+
rationale: optionalString,
|
|
3146
|
+
stylingStrategy: optionalString,
|
|
3147
|
+
}, { additionalProperties: false }), async (entry) => {
|
|
3148
|
+
// specified/new compile into a canonical specReference whose .section
|
|
3149
|
+
// the contract schema mandates; without the section the row can only
|
|
3150
|
+
// fail at finalize, so demand it here where the model can still act.
|
|
3151
|
+
if (entry.decision === "specified") {
|
|
3152
|
+
const evidenceList = Array.isArray(entry.evidence) ? entry.evidence : [];
|
|
3153
|
+
const evidencePath = typeof evidenceList[0] === "string" ? evidenceList[0] : "";
|
|
3154
|
+
const candidates = input.componentSpecCandidatePaths ?? [];
|
|
3155
|
+
if (candidates.length === 0) {
|
|
3156
|
+
return planToolReceipt({
|
|
3157
|
+
ok: false,
|
|
3158
|
+
kind: "reuse-decision",
|
|
3159
|
+
error: `record_reuse_decision specified-requires-spec-candidates: decision "specified" means a declared component spec mandates this choice, but this task declares no component spec candidates — use decision "new" (task-source-mandated addition, evidence[0] = the task-source file) or "reuse-existing"`,
|
|
3160
|
+
});
|
|
3161
|
+
}
|
|
3162
|
+
if (evidencePath && !candidates.includes(evidencePath)) {
|
|
3163
|
+
return planToolReceipt({
|
|
3164
|
+
ok: false,
|
|
3165
|
+
kind: "reuse-decision",
|
|
3166
|
+
error: `record_reuse_decision specified-spec-reference-outside-candidates: evidence[0] "${evidencePath}" is not a declared component spec candidate; declared candidates: [${candidates.join(", ")}]`,
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
if ((entry.decision === "specified" || entry.decision === "new") &&
|
|
3171
|
+
!(typeof entry.specSection === "string" && entry.specSection.trim())) {
|
|
3172
|
+
return planToolReceipt({
|
|
3173
|
+
ok: false,
|
|
3174
|
+
kind: "reuse-decision",
|
|
3175
|
+
error: `record_reuse_decision spec-section-required: decision "${entry.decision}" must name the spec/task-source declaration it implements via specSection (e.g. an AC id); use decision "reuse-existing" for existing repo conventions. Example: {"entry": {"symbol": "SmokeCounter", "decision": "new", "evidence": ["src/components/SmokeCounter.tsx"], "purpose": "render the counter card", "specSection": "AC-FE-001"}}`,
|
|
3176
|
+
});
|
|
3177
|
+
}
|
|
3178
|
+
return undefined;
|
|
3179
|
+
});
|
|
3180
|
+
const recordStateOwnershipTool = record("record_state_ownership", "state ownership", "Record one UI state and its owner, applicability and expected behavior. owner must equal the behaviorGroupId of the record_verification_focus entries it supports; every behavior group referenced by a verification focus needs at least one state row or finalize fails. Resubmit the same state name with replace:true to correct a row. Example: {\"entry\": {\"state\": \"loading\", \"owner\": \"page\", \"applicable\": true, \"expectedBehavior\": \"render loading\"}}", Type.Object({
|
|
3181
|
+
state: Type.String({ minLength: 1 }),
|
|
3182
|
+
owner: Type.String({ minLength: 1 }),
|
|
3183
|
+
applicable: Type.Boolean(),
|
|
3184
|
+
expectedBehavior: optionalString,
|
|
3185
|
+
notApplicableReason: optionalString,
|
|
3186
|
+
}, { additionalProperties: false }));
|
|
3187
|
+
const frozenInteractionIds = authority.interactionIds ?? [];
|
|
3188
|
+
const recordDataFlowTool = record("record_data_flow", "data flow", "Record one interaction data flow: source behavior group, trigger and expected behavior. One row per interaction name; resubmit the same interaction name to replace it. When a frozen interaction id vocabulary is declared, the interaction name MUST be one of the declared ids — wrong-named records are rejected at submission and stale ones are retracted via retract_data_flow. Example: {\"entry\": {\"interaction\": \"load\", \"source\": \"page\", \"trigger\": \"submit query\", \"expectedBehavior\": \"show loading then results\"}}", Type.Object({
|
|
3189
|
+
interaction: Type.String({ minLength: 1 }),
|
|
3190
|
+
source: Type.String({ minLength: 1 }),
|
|
3191
|
+
trigger: Type.String({ minLength: 1 }),
|
|
3192
|
+
expectedBehavior: Type.String({ minLength: 1 }),
|
|
3193
|
+
}, { additionalProperties: false }), frozenInteractionIds.length > 0
|
|
3194
|
+
? async (entry) => {
|
|
3195
|
+
const name = typeof entry.interaction === "string" ? entry.interaction : "";
|
|
3196
|
+
if (name && !frozenInteractionIds.includes(name)) {
|
|
3197
|
+
return planToolReceipt({
|
|
3198
|
+
ok: false,
|
|
3199
|
+
kind: "data-flow",
|
|
3200
|
+
code: "INTERACTION_ID_NOT_DECLARED",
|
|
3201
|
+
error: `interaction "${name}" is not in the frozen vocabulary; allowed: [${frozenInteractionIds.join(", ")}]. Submit the declared id. To remove an already-recorded wrong interaction, call retract_data_flow with {"interaction": "${name}"}.`,
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
return undefined;
|
|
3205
|
+
}
|
|
3206
|
+
: undefined);
|
|
3207
|
+
const retractDataFlowTool = defineTool({
|
|
3208
|
+
name: "retract_data_flow",
|
|
3209
|
+
label: "retract data flow",
|
|
3210
|
+
description: "Retract a previously recorded interaction data flow (e.g. one recorded under a wrong interaction id). The retracted record and every fact keyed to that interaction name stop participating in the compiled plan. Declared vocabulary ids cannot be retracted. Example: {\"interaction\": \"increment-counter\", \"reason\": \"recorded under a wrong id\"}",
|
|
3211
|
+
promptSnippet: "Retract one interaction data flow by interaction name.",
|
|
3212
|
+
parameters: Type.Object({
|
|
3213
|
+
interaction: Type.String({ minLength: 1 }),
|
|
3214
|
+
reason: Type.Optional(Type.String({ minLength: 1 })),
|
|
3215
|
+
}, { additionalProperties: false }),
|
|
3216
|
+
async execute(_toolCallId, params) {
|
|
3217
|
+
const interaction = typeof params?.interaction === "string" ? params.interaction : "";
|
|
3218
|
+
if (!interaction) {
|
|
3219
|
+
return planToolReceipt({ ok: false, kind: "data-flow", error: "retract_data_flow requires a non-empty interaction" });
|
|
3220
|
+
}
|
|
3221
|
+
if (frozenInteractionIds.includes(interaction)) {
|
|
3222
|
+
return planToolReceipt({ ok: false, kind: "data-flow", error: `interaction "${interaction}" is a declared vocabulary id and cannot be retracted` });
|
|
3223
|
+
}
|
|
3224
|
+
const result = await adoptDecisionFact("data-flow", `${attemptId}:retract_data_flow:${randomUUID()}`, { kind: "data-flow", origin: "plan", entry: { interaction, retracted: true, ...(typeof params?.reason === "string" ? { reason: params.reason } : {}) } });
|
|
3225
|
+
return planToolReceipt(result);
|
|
3226
|
+
},
|
|
3227
|
+
});
|
|
3228
|
+
const recordApiMockBoundaryTool = record("record_api_mock_boundary", "API/Mock boundary", "Record one API boundary and its mode (real/mock/not-needed) with evidence. The boundary is the stable identity: resubmit the same boundary with replace:true to change its mode (e.g. mock→real); the old decision is replaced, not appended. Real/mock endpoints require fixture (the test fixture path) and consumer (the implementation file that calls the API) — design policy rejects endpoints without them. Example: {\"entry\": {\"boundary\": \"GET /items\", \"mode\": \"mock\", \"evidence\": \"fixture only\", \"fixture\": \"test/fixtures/items.ts\", \"consumer\": \"src/page.tsx\"}}", Type.Object({
|
|
3229
|
+
boundary: Type.String({ minLength: 1 }),
|
|
3230
|
+
mode: Type.Union([Type.Literal("real"), Type.Literal("mock"), Type.Literal("not-needed")]),
|
|
3231
|
+
evidence: Type.String({ minLength: 1 }),
|
|
3232
|
+
fixture: optionalString,
|
|
3233
|
+
consumer: optionalString,
|
|
3234
|
+
}, { additionalProperties: false }), async (entry) => {
|
|
3235
|
+
// Design policy hard-requires fixture + consumer on every derived
|
|
3236
|
+
// endpoint (non-not-needed strategies); demand them here where the
|
|
3237
|
+
// model can still act instead of failing the compile post-session.
|
|
3238
|
+
if ((entry.mode === "real" || entry.mode === "mock") &&
|
|
3239
|
+
(!(typeof entry.fixture === "string" && entry.fixture.trim()) ||
|
|
3240
|
+
!(typeof entry.consumer === "string" && entry.consumer.trim()))) {
|
|
3241
|
+
return planToolReceipt({
|
|
3242
|
+
ok: false,
|
|
3243
|
+
kind: "api-mock-boundary",
|
|
3244
|
+
error: `record_api_mock_boundary fixture-and-consumer-required: ${entry.mode} boundary "${entry.boundary}" needs fixture (the test fixture path) and consumer (the implementation file that calls the API). Example: {"entry": {"boundary": "${entry.boundary}", "mode": "${entry.mode}", "evidence": "<why>", "fixture": "test/fixtures/items.ts", "consumer": "src/page.tsx"}}`,
|
|
3245
|
+
});
|
|
3246
|
+
}
|
|
3247
|
+
return undefined;
|
|
3248
|
+
});
|
|
3249
|
+
const recordVerificationFocusTool = record("record_verification_focus", "verification focus", "Record one behavior-group verification focus: which test file/command proves which behavior group at what evidence level. Every behaviorGroupId must have at least one record_state_ownership row whose owner equals it — declare one UI state per behavior group before finalizing. Example: {\"entry\": {\"id\": \"VT-1\", \"behaviorGroupId\": \"page\", \"file\": \"test/page.test.tsx\", \"commandId\": \"test\", \"evidenceLevel\": \"mounted\"}}", Type.Object({
|
|
3250
|
+
id: Type.String({ minLength: 1 }),
|
|
3251
|
+
behaviorGroupId: Type.String({ minLength: 1 }),
|
|
3252
|
+
file: Type.String({ minLength: 1 }),
|
|
3253
|
+
commandId: Type.String({ minLength: 1 }),
|
|
3254
|
+
evidenceLevel: Type.Union([Type.Literal("unit"), Type.Literal("mounted"), Type.Literal("real-integration")]),
|
|
3255
|
+
boundary: optionalString,
|
|
3256
|
+
}, { additionalProperties: false }), async (entry) => {
|
|
3257
|
+
// Mirror the relationship record_plan_verification_target boundary so
|
|
3258
|
+
// protocol errors surface in-node (bounded correction) instead of
|
|
3259
|
+
// burning attempts on an immutable committed fact that finalize rejects.
|
|
3260
|
+
// Duplicate/identity enforcement lives in the durable wrapper (FACT_IDENTITY_CONFLICT
|
|
3261
|
+
// unless replace:true) and the compile collapses same-id records to the
|
|
3262
|
+
// latest replacement, so no duplicate gate is needed here.
|
|
3263
|
+
const verificationCommandId = typeof entry.commandId === "string" ? entry.commandId.trim() : "";
|
|
3264
|
+
if (!verificationCommandId) {
|
|
3265
|
+
return planToolReceipt({
|
|
3266
|
+
ok: false,
|
|
3267
|
+
kind: "verification-focus",
|
|
3268
|
+
error: `record_verification_focus entry.commandId is required (received ${JSON.stringify(entry.commandId ?? null)}); pick one id from the frozen command directory in your prompt`,
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
const { deriveFrontendVerifyCommandDirectoryFromRun, isFrontendTestFilePath } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3272
|
+
const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
|
|
3273
|
+
if (verifyDirectory.length > 0) {
|
|
3274
|
+
const directoryEntry = verifyDirectory.find((candidate) => candidate.commandId === verificationCommandId);
|
|
3275
|
+
if (!directoryEntry) {
|
|
3276
|
+
return planToolReceipt({
|
|
3277
|
+
ok: false,
|
|
3278
|
+
kind: "verification-focus",
|
|
3279
|
+
error: `record_verification_focus verification-target-unknown-command: unknown commandId "${verificationCommandId}"; available frozen commands: [${verifyDirectory.map((candidate) => `${candidate.commandId} (${candidate.mode}: ${candidate.label})`).join(", ")}]`,
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
if (directoryEntry.mode === "behavior" &&
|
|
3283
|
+
typeof entry.file === "string" &&
|
|
3284
|
+
!isFrontendTestFilePath(entry.file)) {
|
|
3285
|
+
return planToolReceipt({
|
|
3286
|
+
ok: false,
|
|
3287
|
+
kind: "verification-focus",
|
|
3288
|
+
error: `record_verification_focus verification-target-phase-mismatch: behavior command "${directoryEntry.label}" (${directoryEntry.commandId}) must bind a test file (__tests__/, tests?/, e2e/, cypress/, *.test.*, *.spec.*, *.cy.*); received file "${entry.file}"`,
|
|
3289
|
+
});
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
// A behavior verification target derives its contract uiStates solely
|
|
3293
|
+
// from the committed state-ownership rows whose owner equals its
|
|
3294
|
+
// behaviorGroupId, and the relationship patch rejects a behavior
|
|
3295
|
+
// target with empty uiStates whenever the plan declares any state or
|
|
3296
|
+
// interaction. That rejection currently surfaces only in the
|
|
3297
|
+
// post-session bridge and burns the whole attempt, so enforce the
|
|
3298
|
+
// pairing here as a bounded in-session correction. A ledger with no
|
|
3299
|
+
// ownership and no data-flow facts stays exempt (pure-logic plan),
|
|
3300
|
+
// mirroring the contract-level exemption.
|
|
3301
|
+
const committedLedger = readCommittedEvents(store, attemptId);
|
|
3302
|
+
const ownershipRows = committedLedger.filter((event) => event.fact.kind === "state-ownership");
|
|
3303
|
+
const hasInteractionFacts = committedLedger.some((event) => event.fact.kind === "data-flow");
|
|
3304
|
+
if ((ownershipRows.length > 0 || hasInteractionFacts) &&
|
|
3305
|
+
!ownershipRows.some((event) => event.fact.entry?.owner ===
|
|
3306
|
+
entry.behaviorGroupId)) {
|
|
3307
|
+
return planToolReceipt({
|
|
3308
|
+
ok: false,
|
|
3309
|
+
kind: "verification-focus",
|
|
3310
|
+
error: `record_verification_focus undeclared-behavior-group-state: behavior group "${entry.behaviorGroupId}" (verification target ${entry.id}) has no record_state_ownership row; finalize derives each behavior target's UI states from ownership rows whose owner equals the behaviorGroupId, so record at least one for this group: {"state": "<name>", "owner": "${entry.behaviorGroupId}", "applicable": true, "expectedBehavior": "<what the state does>"}`,
|
|
3311
|
+
});
|
|
3312
|
+
}
|
|
3313
|
+
return undefined;
|
|
3314
|
+
});
|
|
3315
|
+
const recordDependencyTool = record("record_dependency", "dependency", "Record one dependency name. Example: {\"entry\": {\"name\": \"none\"}}", Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }));
|
|
3316
|
+
const finalizeDecisionTool = defineTool({
|
|
3317
|
+
name: "finalize_decision",
|
|
3318
|
+
label: "finalize_decision",
|
|
3319
|
+
description: "Compile the committed decision facts into a decision contract, pre-validate the derived canonical contract, and commit the terminal. Fails closed on any safety finding or validation finding; correct the reported record_* facts and call finalize_decision again.",
|
|
3320
|
+
promptSnippet: "Compile and finalize the committed decision facts.",
|
|
3321
|
+
parameters: Type.Object({}, { additionalProperties: false }),
|
|
3322
|
+
async execute(_toolCallId) {
|
|
3323
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
3324
|
+
const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
|
|
3325
|
+
const expectedWriteSet = [
|
|
3326
|
+
...decision.modulePlacements.flatMap(item => item.paths),
|
|
3327
|
+
...decision.verificationFocus.map(item => item.file),
|
|
3328
|
+
];
|
|
3329
|
+
const derived = assembleExperimentPlanContract({
|
|
3330
|
+
decision,
|
|
3331
|
+
authority,
|
|
3332
|
+
concreteWriteSet: expectedWriteSet,
|
|
3333
|
+
});
|
|
3334
|
+
if (derived.findings.length > 0) {
|
|
3335
|
+
return planToolReceipt({
|
|
3336
|
+
ok: false,
|
|
3337
|
+
kind: "finalize_decision",
|
|
3338
|
+
code: "DECISION_SAFETY_FINDINGS", validationStage: "safety", completedValidationStages: [],
|
|
3339
|
+
error: `decision safety projection failed: ${derived.findings.join("; ")}`,
|
|
3340
|
+
});
|
|
3341
|
+
}
|
|
3342
|
+
const prevalidation = await prevalidateDerivedPatch();
|
|
3343
|
+
if (!prevalidation.ok) {
|
|
3344
|
+
return planToolReceipt({
|
|
3345
|
+
ok: false,
|
|
3346
|
+
kind: "finalize_decision",
|
|
3347
|
+
code: "DECISION_PREVALIDATION_FAILED", validationStage: "canonical", completedValidationStages: ["safety"],
|
|
3348
|
+
error: prevalidation.error,
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
// Recovery-mode guard: a repair child that finalizes a plan identical
|
|
3352
|
+
// to its parent's has not performed the repair the findings demand —
|
|
3353
|
+
// the identical plan is exactly what admission rejected. Both hashes
|
|
3354
|
+
// use the same deterministic serializer over the canonical contract,
|
|
3355
|
+
// so only a real semantic change passes.
|
|
3356
|
+
// Recovery-mode guard: a repair child must demonstrably close the
|
|
3357
|
+
// parent findings before the run re-enters design review.
|
|
3358
|
+
if (input.parentDecisionSnapshot) {
|
|
3359
|
+
const { readParentCanonical, parentCanonicalContentSha256 } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3360
|
+
const { extractRepairAssertions, evaluateRepairClosure, decisionIdentitySha256 } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
3361
|
+
const parentRunDir = input.parentDecisionSnapshot.resolvedParentRunDir ?? path.join(input.runDir, "..", input.parentDecisionSnapshot.parentRunId);
|
|
3362
|
+
const parentCanonical = await readParentCanonical(parentRunDir);
|
|
3363
|
+
// 1. identical-plan guard. Decisions, not prose: a rationale-only
|
|
3364
|
+
// rewrite is not a repair (smoke r27 cleared a raw byte-hash guard
|
|
3365
|
+
// by editing two rationale strings and changing nothing else). The
|
|
3366
|
+
// byte hash stays as the fallback when the parent canonical cannot
|
|
3367
|
+
// be read.
|
|
3368
|
+
const parentSha = await parentCanonicalContentSha256(parentRunDir);
|
|
3369
|
+
const parentDecisions = parentCanonical
|
|
3370
|
+
? decisionIdentitySha256(parentCanonical)
|
|
3371
|
+
: undefined;
|
|
3372
|
+
const childDecisions = decisionIdentitySha256(prevalidation.canonical);
|
|
3373
|
+
if (parentDecisions
|
|
3374
|
+
? parentDecisions === childDecisions
|
|
3375
|
+
: parentSha !== undefined && parentSha === prevalidation.canonicalSha256) {
|
|
3376
|
+
return planToolReceipt({
|
|
3377
|
+
ok: false,
|
|
3378
|
+
kind: "finalize_decision",
|
|
3379
|
+
code: "DECISION_REPAIR_NO_CHANGE", validationStage: "recovery", completedValidationStages: input.skeleton && input.sourceBinding ? ["safety", "canonical"] : ["safety"],
|
|
3380
|
+
error: "recovery finalize blocked: every structured decision row is identical to the parent run's (rationale-only edits do not count as a repair), but the review findings require changes. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.",
|
|
3381
|
+
});
|
|
3382
|
+
}
|
|
3383
|
+
// 2. per-finding closure: every deterministic assertion extracted
|
|
3384
|
+
// from the parent findings must hold on the child canonical.
|
|
3385
|
+
const findings = input.parentDecisionSnapshot.designFindings ?? [];
|
|
3386
|
+
if (!parentCanonical && findings.some(finding => Array.isArray(finding.repairAssertions) && finding.repairAssertions.length > 0))
|
|
3387
|
+
return planToolReceipt({ ok: false, kind: "finalize_decision", code: "DECISION_REPAIR_PARENT_UNAVAILABLE", error: "Repair assertions require the bound parent canonical contract", validationStage: "recovery", completedValidationStages: input.skeleton && input.sourceBinding ? ["safety", "canonical"] : ["safety"] });
|
|
3388
|
+
if (parentCanonical && findings.length > 0) {
|
|
3389
|
+
const assertions = extractRepairAssertions(findings, parentCanonical);
|
|
3390
|
+
const unauthorized = authorizeRepairAssertions(assertions, { parent: parentCanonical, requirementIds: authority.requirements.map(r => r.id), stateIds: authority.behaviorGroups.flatMap(group => group.requiredStates ?? []), requiredDeliverables: (prevalidation.canonical.targets?.requiredDeliverables ?? []) });
|
|
3391
|
+
if (unauthorized.length)
|
|
3392
|
+
return planToolReceipt({ ok: false, kind: "finalize_decision", code: "DECISION_REPAIR_ASSERTION_UNAUTHORIZED", error: unauthorized.join("; "), validationStage: "recovery", completedValidationStages: input.skeleton && input.sourceBinding ? ["safety", "canonical"] : ["safety"] });
|
|
3393
|
+
const closure = evaluateRepairClosure(assertions, prevalidation.canonical, parentCanonical);
|
|
3394
|
+
if (!closure.closed) {
|
|
3395
|
+
return planToolReceipt({
|
|
3396
|
+
ok: false,
|
|
3397
|
+
kind: "finalize_decision",
|
|
3398
|
+
code: "DECISION_REPAIR_NOT_CLOSED", validationStage: "recovery", completedValidationStages: input.skeleton && input.sourceBinding ? ["safety", "canonical"] : ["safety"],
|
|
3399
|
+
error: `recovery finalize blocked: ${closure.unmet.length} finding(s) are still not closed — ${closure.unmet.join("; ")}. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.`,
|
|
3400
|
+
});
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
const result = await adoptDecisionFact("finalize_decision", `${attemptId}:finalize_decision:${randomUUID()}`, { kind: "finalize_decision", origin: "plan", findings: [] });
|
|
3405
|
+
return planToolReceipt(result);
|
|
3406
|
+
},
|
|
3407
|
+
});
|
|
3408
|
+
const planControl = createFrontendPlanProgressGuard();
|
|
3409
|
+
const durable = await createDurableFrontendTools({
|
|
3410
|
+
planControl,
|
|
3411
|
+
file: path.join(input.runDir, input.nodeId, "plan-decision-facts.jsonl"),
|
|
3412
|
+
attemptId,
|
|
3413
|
+
store: input.store,
|
|
3414
|
+
setWorkingStore: next => { store = next; },
|
|
3415
|
+
binding: { authority },
|
|
3416
|
+
tools: [
|
|
3417
|
+
recordModulePlacementTool,
|
|
3418
|
+
recordReuseDecisionTool,
|
|
3419
|
+
recordStateOwnershipTool,
|
|
3420
|
+
recordDataFlowTool,
|
|
3421
|
+
retractDataFlowTool,
|
|
3422
|
+
recordApiMockBoundaryTool,
|
|
3423
|
+
recordVerificationFocusTool,
|
|
3424
|
+
recordDependencyTool,
|
|
3425
|
+
finalizeDecisionTool,
|
|
3426
|
+
],
|
|
3427
|
+
});
|
|
3428
|
+
const durableFinalizeDecisionTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_decision");
|
|
3429
|
+
if (!durableFinalizeDecisionTool)
|
|
3430
|
+
throw new Error("frontend plan decision durable finalize tool unavailable");
|
|
3431
|
+
return {
|
|
3432
|
+
planControl,
|
|
3433
|
+
customTools: durable.customTools,
|
|
3434
|
+
flush: durable.flush,
|
|
3435
|
+
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
3436
|
+
finalizeDecision: () => durableFinalizeDecisionTool.execute(`${attemptId}:auto-finalize-decision`, {}, undefined, undefined, {}),
|
|
3437
|
+
};
|
|
3438
|
+
}
|
|
2512
3439
|
/**
|
|
2513
3440
|
* Decision → relationship bridge: after a successful decision `finalize`
|
|
2514
3441
|
* (kind finalize_decision committed in plan-decision-facts.jsonl) the plan
|
|
@@ -2610,12 +3537,521 @@ export async function bridgeFrontendPlanDecisionToRelationshipLedger(input) {
|
|
|
2610
3537
|
});
|
|
2611
3538
|
return commitTypedEventRecord(store, eventId, revision);
|
|
2612
3539
|
};
|
|
2613
|
-
adopt("target-surface", patch, 1);
|
|
2614
|
-
adopt("finalize_plan", patch, 2);
|
|
2615
|
-
// The ledger is derived wholesale from the committed decision facts, so a
|
|
2616
|
-
// re-run rewrites it atomically (no incremental append semantics needed).
|
|
2617
|
-
await writeTypedEventStoreJsonl(file, store.records.filter(record => record.phase === "committed"));
|
|
2618
|
-
return { ok: true, patch };
|
|
3540
|
+
adopt("target-surface", patch, 1);
|
|
3541
|
+
adopt("finalize_plan", patch, 2);
|
|
3542
|
+
// The ledger is derived wholesale from the committed decision facts, so a
|
|
3543
|
+
// re-run rewrites it atomically (no incremental append semantics needed).
|
|
3544
|
+
await writeTypedEventStoreJsonl(file, store.records.filter(record => record.phase === "committed"));
|
|
3545
|
+
return { ok: true, patch };
|
|
3546
|
+
}
|
|
3547
|
+
function isRecordObject(value) {
|
|
3548
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3549
|
+
}
|
|
3550
|
+
/** A contract fact is source-mapped when it carries a non-empty `sourceSpan`
|
|
3551
|
+
* (object or string), a non-empty `sourceRefs` array, or a non-empty `source`
|
|
3552
|
+
* string. Anything else is an unmapped source segment (AC-001). */
|
|
3553
|
+
function contractFactSourceSpan(fact) {
|
|
3554
|
+
if (isRecordObject(fact.sourceSpan) || typeof fact.sourceSpan === "string") {
|
|
3555
|
+
return fact.sourceSpan;
|
|
3556
|
+
}
|
|
3557
|
+
if (Array.isArray(fact.sourceRefs) && fact.sourceRefs.length > 0) {
|
|
3558
|
+
return fact.sourceRefs;
|
|
3559
|
+
}
|
|
3560
|
+
if (Array.isArray(fact.sourceFragmentIds) &&
|
|
3561
|
+
fact.sourceFragmentIds.some((value) => typeof value === "string" && value.trim().length > 0)) {
|
|
3562
|
+
return fact.sourceFragmentIds;
|
|
3563
|
+
}
|
|
3564
|
+
if (typeof fact.source === "string" && fact.source.trim().length > 0) {
|
|
3565
|
+
return fact.source;
|
|
3566
|
+
}
|
|
3567
|
+
return undefined;
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* A+B (AC-001): deterministically assemble the read-only `frontend-task-contract-vNext`
|
|
3571
|
+
* audit artifact from committed contract facts. It never rewrites the original
|
|
3572
|
+
* task source: unmapped requirement/constraint segments are listed explicitly
|
|
3573
|
+
* so Plan/Implement/Review keep binding to the raw source, not the model's
|
|
3574
|
+
* summarized contract. The blocked disposition is projected through the frozen
|
|
3575
|
+
* `mapContractBlockedOwner` mapping (AC-002).
|
|
3576
|
+
*/
|
|
3577
|
+
export function buildFrontendTaskContractVNext(records) {
|
|
3578
|
+
const facts = records
|
|
3579
|
+
.filter((record) => record.phase === "committed")
|
|
3580
|
+
.map((record) => record.fact)
|
|
3581
|
+
.filter(isRecordObject);
|
|
3582
|
+
const finalized = facts.find((fact) => fact.kind === "contract-finalized");
|
|
3583
|
+
const disposition = typeof finalized?.disposition === "string" ? finalized.disposition : null;
|
|
3584
|
+
const blockingOwner = typeof finalized?.blockingOwner === "string"
|
|
3585
|
+
? finalized.blockingOwner
|
|
3586
|
+
: null;
|
|
3587
|
+
const projected = mapContractBlockedOwner({
|
|
3588
|
+
disposition: disposition ?? "",
|
|
3589
|
+
blockingOwner: blockingOwner ?? undefined,
|
|
3590
|
+
});
|
|
3591
|
+
const byKind = (kind) => facts.filter((fact) => fact.kind === kind);
|
|
3592
|
+
const requirements = byKind("requirement");
|
|
3593
|
+
const unmappedSourceSegments = requirements
|
|
3594
|
+
.filter((fact) => contractFactSourceSpan(fact) === undefined)
|
|
3595
|
+
.map((fact) => ({
|
|
3596
|
+
kind: "requirement",
|
|
3597
|
+
id: typeof fact.id === "string"
|
|
3598
|
+
? fact.id
|
|
3599
|
+
: typeof fact.text === "string"
|
|
3600
|
+
? fact.text
|
|
3601
|
+
: undefined,
|
|
3602
|
+
}))
|
|
3603
|
+
.filter((segment) => segment.id !== undefined);
|
|
3604
|
+
return {
|
|
3605
|
+
schemaVersion: 1,
|
|
3606
|
+
schemaId: "frontend-task-contract-vNext",
|
|
3607
|
+
disposition,
|
|
3608
|
+
blockingOwner,
|
|
3609
|
+
blockedOwner: projected === "not-blocked" ? null : projected,
|
|
3610
|
+
requirements,
|
|
3611
|
+
constraints: byKind("constraint"),
|
|
3612
|
+
evidenceExpectations: byKind("evidence-expectation"),
|
|
3613
|
+
deliverableDeclarations: byKind("required-deliverables").at(-1)?.items ?? [],
|
|
3614
|
+
handoffIntents: byKind("handoff-intent"),
|
|
3615
|
+
openQuestions: byKind("open-question"),
|
|
3616
|
+
splitProposals: byKind("split-proposal"),
|
|
3617
|
+
unmappedSourceSegments,
|
|
3618
|
+
};
|
|
3619
|
+
}
|
|
3620
|
+
/**
|
|
3621
|
+
* A+B: `frontend-contract-pi` incremental contract tools. The `record_*` tools
|
|
3622
|
+
* commit origin=contract facts and `finalize_contract` commits the terminal
|
|
3623
|
+
* disposition (ready | ready-with-assumptions | blocked + blockingOwner).
|
|
3624
|
+
*/
|
|
3625
|
+
export async function createFrontendContractTools(input) {
|
|
3626
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
3627
|
+
import("typebox"),
|
|
3628
|
+
import("@earendil-works/pi-coding-agent"),
|
|
3629
|
+
]);
|
|
3630
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
3631
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
3632
|
+
let store = input.store;
|
|
3633
|
+
const attemptId = input.attemptId;
|
|
3634
|
+
let activeScope = null;
|
|
3635
|
+
const committedRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement").map(r => String(r.fact.id)));
|
|
3636
|
+
const completedScopeRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "contract-scope-completed").flatMap(r => Array.isArray(r.fact.requirementIds) ? r.fact.requirementIds.filter((id) => typeof id === "string") : []));
|
|
3637
|
+
const receipt = (details) => ({
|
|
3638
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
3639
|
+
details,
|
|
3640
|
+
});
|
|
3641
|
+
async function adoptContractFact(kind, fact) {
|
|
3642
|
+
try {
|
|
3643
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
3644
|
+
const staged = stageTypedEventFact({
|
|
3645
|
+
store,
|
|
3646
|
+
requestId,
|
|
3647
|
+
attemptId,
|
|
3648
|
+
fact: fact,
|
|
3649
|
+
});
|
|
3650
|
+
const committed = await adoptTypedEventFact({
|
|
3651
|
+
store,
|
|
3652
|
+
requestId,
|
|
3653
|
+
attemptId,
|
|
3654
|
+
fact: fact,
|
|
3655
|
+
eventId: staged.eventId,
|
|
3656
|
+
expectedRevision: store.revision,
|
|
3657
|
+
});
|
|
3658
|
+
return {
|
|
3659
|
+
ok: true,
|
|
3660
|
+
kind,
|
|
3661
|
+
eventId: committed.eventId,
|
|
3662
|
+
revision: committed.revision,
|
|
3663
|
+
error: "",
|
|
3664
|
+
};
|
|
3665
|
+
}
|
|
3666
|
+
catch (error) {
|
|
3667
|
+
return {
|
|
3668
|
+
ok: false,
|
|
3669
|
+
kind,
|
|
3670
|
+
code: error?.code,
|
|
3671
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3672
|
+
};
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
const recordKinds = {
|
|
3676
|
+
record_constraint: "constraint",
|
|
3677
|
+
record_handoff_intent: "handoff-intent",
|
|
3678
|
+
record_open_question: "open-question",
|
|
3679
|
+
record_split_proposal: "split-proposal",
|
|
3680
|
+
};
|
|
3681
|
+
const recordTools = Object.entries(recordKinds).map(([name, kind]) => defineTool({
|
|
3682
|
+
name,
|
|
3683
|
+
label: name,
|
|
3684
|
+
description: `Commit an origin=contract ${kind} fact. IMPORTANT: submit incrementally — batch up to 5 record_* calls per message, starting from the FIRST message; never attempt to emit the whole contract in one response (a single large dump will be truncated and rejected). Every message must make progress by committing at least one record_* fact.`,
|
|
3685
|
+
promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
|
|
3686
|
+
parameters: Type.Object({
|
|
3687
|
+
text: Type.String({ minLength: 1 }),
|
|
3688
|
+
requirementIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
3689
|
+
sourceFragmentIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
3690
|
+
...(kind === "constraint" ? { category: Type.Optional(Type.Enum({ constraint: "constraint", "non-goal": "non-goal", risk: "risk" })) } : {}),
|
|
3691
|
+
...(kind === "handoff-intent" ? { taskKind: Type.Literal("frontend-test"), blocking: Type.Optional(Type.Boolean()) } : {}),
|
|
3692
|
+
}, { additionalProperties: false }),
|
|
3693
|
+
async execute(_toolCallId, params) {
|
|
3694
|
+
const data = params;
|
|
3695
|
+
if (!data.text?.trim())
|
|
3696
|
+
return receipt({ ok: false, code: "TOOL_SCHEMA_INVALID", error: `${name}: text must be non-empty` });
|
|
3697
|
+
const knownFragments = new Set([...(input.canonicalRequirements?.values() ?? [])].flatMap(r => r.sourceFragmentIds));
|
|
3698
|
+
if (data.requirementIds?.some(id => !input.canonicalRequirements?.has(id)) || data.sourceFragmentIds?.some(id => !knownFragments.has(id)))
|
|
3699
|
+
return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `${name}: reference is outside the frozen source inventory` });
|
|
3700
|
+
const result = await adoptContractFact(kind, {
|
|
3701
|
+
...(params ?? {}),
|
|
3702
|
+
kind,
|
|
3703
|
+
origin: "contract",
|
|
3704
|
+
});
|
|
3705
|
+
return receipt(result);
|
|
3706
|
+
},
|
|
3707
|
+
}));
|
|
3708
|
+
// record_requirement is runtime-owned by design: the requirement TEXT and
|
|
3709
|
+
// sourceFragmentIds come from the frozen source-fidelity ledger, never from
|
|
3710
|
+
// model-authored prose. The model only names the canonical id it confirms.
|
|
3711
|
+
// This closes the free-shape hole (additionalProperties:true accepted
|
|
3712
|
+
// `statement` rewrites and JSON-stringified `sourceFragmentIds` arrays,
|
|
3713
|
+
// which then reached the planner as empty text + dead fragment bindings).
|
|
3714
|
+
const recordRequirementTool = defineTool({
|
|
3715
|
+
name: "record_requirement",
|
|
3716
|
+
label: "record_requirement",
|
|
3717
|
+
description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass the canonical id and optional execution:{groupId,kind,summary} only. Reuse a group only when its behavior and all permission/threshold/error conditions agree; retain separate groups for differences. Constraints/exclusions do not require invented UI. The canonical id is listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
|
|
3718
|
+
promptSnippet: "Confirm 1-5 canonical requirements by id (up to 5 per message).",
|
|
3719
|
+
parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }), execution: Type.Optional(Type.Object({ groupId: Type.String({ minLength: 1 }), kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]), summary: Type.String({ minLength: 1 }) }, { additionalProperties: false })) }, { additionalProperties: false }),
|
|
3720
|
+
async execute(_toolCallId, params) {
|
|
3721
|
+
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
3722
|
+
if (!id) {
|
|
3723
|
+
return receipt({
|
|
3724
|
+
ok: false,
|
|
3725
|
+
kind: "requirement",
|
|
3726
|
+
error: "record_requirement requires the canonical requirement id",
|
|
3727
|
+
});
|
|
3728
|
+
}
|
|
3729
|
+
if (activeScope && !activeScope.has(id))
|
|
3730
|
+
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `requirement ${id} is outside the complete input scope of this session` });
|
|
3731
|
+
const canonical = input.canonicalRequirements?.get(id);
|
|
3732
|
+
if (!canonical) {
|
|
3733
|
+
const known = [...(input.canonicalRequirements?.keys() ?? [])];
|
|
3734
|
+
return receipt({
|
|
3735
|
+
ok: false,
|
|
3736
|
+
kind: "requirement",
|
|
3737
|
+
error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
if (params.execution) {
|
|
3741
|
+
try {
|
|
3742
|
+
collectFrontendExecutionGroups([...readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement" && r.fact.id !== id).map(r => ({ id: String(r.fact.id), execution: r.fact.execution })), { id, execution: params.execution }]);
|
|
3743
|
+
}
|
|
3744
|
+
catch (error) {
|
|
3745
|
+
return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: String(error) });
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
const result = await adoptContractFact("requirement", {
|
|
3749
|
+
kind: "requirement",
|
|
3750
|
+
origin: "contract",
|
|
3751
|
+
disposition: "explicit",
|
|
3752
|
+
id,
|
|
3753
|
+
text: canonical.text,
|
|
3754
|
+
sourceFragmentIds: canonical.sourceFragmentIds,
|
|
3755
|
+
...(params.execution ? { execution: params.execution } : {}),
|
|
3756
|
+
});
|
|
3757
|
+
return receipt(result);
|
|
3758
|
+
},
|
|
3759
|
+
});
|
|
3760
|
+
// Requirement identity/text/source are runtime-owned and seeded before the
|
|
3761
|
+
// model starts. Execution grouping is still a model decision, so expose it
|
|
3762
|
+
// as a small typed update instead of forcing the model to re-submit the same
|
|
3763
|
+
// requirement just to attach execution metadata.
|
|
3764
|
+
const recordRequirementExecutionTool = defineTool({
|
|
3765
|
+
name: "record_requirement_execution",
|
|
3766
|
+
label: "record_requirement_execution",
|
|
3767
|
+
description: "Attach execution ownership to already-confirmed canonical requirements.",
|
|
3768
|
+
promptSnippet: "Record execution group metadata for confirmed requirements.",
|
|
3769
|
+
parameters: Type.Object({
|
|
3770
|
+
requirementIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
|
|
3771
|
+
execution: Type.Object({
|
|
3772
|
+
groupId: Type.String({ minLength: 1 }),
|
|
3773
|
+
kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]),
|
|
3774
|
+
summary: Type.String({ minLength: 1 }),
|
|
3775
|
+
}, { additionalProperties: false }),
|
|
3776
|
+
}, { additionalProperties: false }),
|
|
3777
|
+
async execute(callId, params) {
|
|
3778
|
+
const ids = params.requirementIds;
|
|
3779
|
+
if (activeScope && ids.some((id) => !activeScope?.has(id)))
|
|
3780
|
+
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "execution metadata references a requirement outside the active contract scope" });
|
|
3781
|
+
const canonical = input.canonicalRequirements;
|
|
3782
|
+
const unknown = ids.filter((id) => !canonical?.has(id));
|
|
3783
|
+
if (unknown.length > 0)
|
|
3784
|
+
return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `record_requirement_execution references unknown canonical requirements: ${unknown.join(", ")}` });
|
|
3785
|
+
const execution = frontendExecutionSchema.parse(params.execution);
|
|
3786
|
+
const existing = readCommittedEvents(store, attemptId)
|
|
3787
|
+
.filter((record) => record.fact.kind === "requirement")
|
|
3788
|
+
.map((record) => ({ id: String(record.fact.id), execution: record.fact.execution }))
|
|
3789
|
+
.filter((unit) => !ids.includes(unit.id));
|
|
3790
|
+
try {
|
|
3791
|
+
collectFrontendExecutionGroups([...existing, ...ids.map((id) => ({ id, execution }))]);
|
|
3792
|
+
}
|
|
3793
|
+
catch (error) {
|
|
3794
|
+
return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: error instanceof Error ? error.message : String(error) });
|
|
3795
|
+
}
|
|
3796
|
+
let result = { ok: true };
|
|
3797
|
+
for (const id of ids) {
|
|
3798
|
+
const requirement = canonical.get(id);
|
|
3799
|
+
result = await adoptContractFact("requirement", { kind: "requirement", origin: "contract", disposition: "explicit", id, text: requirement.text, sourceFragmentIds: requirement.sourceFragmentIds, execution });
|
|
3800
|
+
if (result.ok !== true)
|
|
3801
|
+
return receipt(result);
|
|
3802
|
+
}
|
|
3803
|
+
return receipt({ ...result, requestId: callId });
|
|
3804
|
+
},
|
|
3805
|
+
});
|
|
3806
|
+
// Authoritative UI state declarations: the contract node extracts the
|
|
3807
|
+
// PRD/reference UI-state table into structured facts so the planner binds
|
|
3808
|
+
// uiStates to declared ids instead of inventing names (dogfood
|
|
3809
|
+
// dag-1788504923861-0f7b17a9: 10 invented list-visibility variants, 0 of
|
|
3810
|
+
// the 7 PRD states).
|
|
3811
|
+
const recordUiStateTool = defineTool({
|
|
3812
|
+
name: "record_ui_state",
|
|
3813
|
+
label: "record_ui_state",
|
|
3814
|
+
description: 'Declare ONE authoritative UI state extracted from the task source\'s UI-state table (origin=contract ui-state-declaration fact). Call once per declared state, exactly as the source names it: {"id": "<state id from the source table>", "trigger": "<when this state applies>", "observableOutcome": "<what the user can observe>"}. The planner must bind these ids later; do not rename or invent states.',
|
|
3815
|
+
promptSnippet: "Declare 1-5 authoritative UI states (up to 5 per message).",
|
|
3816
|
+
parameters: Type.Object({
|
|
3817
|
+
id: Type.String({ description: "UI state id exactly as the source table declares it" }),
|
|
3818
|
+
trigger: Type.String({ description: "When this state applies" }),
|
|
3819
|
+
observableOutcome: Type.String({ description: "Observable result for the user" }),
|
|
3820
|
+
}, { additionalProperties: false }),
|
|
3821
|
+
async execute(_toolCallId, params) {
|
|
3822
|
+
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
3823
|
+
const trigger = typeof params?.trigger === "string" ? params.trigger.trim() : "";
|
|
3824
|
+
const observableOutcome = typeof params?.observableOutcome === "string"
|
|
3825
|
+
? params.observableOutcome.trim()
|
|
3826
|
+
: "";
|
|
3827
|
+
if (!id || !trigger || !observableOutcome) {
|
|
3828
|
+
return receipt({
|
|
3829
|
+
ok: false,
|
|
3830
|
+
kind: "ui-state-declaration",
|
|
3831
|
+
error: "record_ui_state requires non-empty id, trigger, and observableOutcome",
|
|
3832
|
+
});
|
|
3833
|
+
}
|
|
3834
|
+
const result = await adoptContractFact("ui-state-declaration", {
|
|
3835
|
+
kind: "ui-state-declaration",
|
|
3836
|
+
origin: "contract",
|
|
3837
|
+
id,
|
|
3838
|
+
trigger,
|
|
3839
|
+
observableOutcome,
|
|
3840
|
+
});
|
|
3841
|
+
return receipt(result);
|
|
3842
|
+
},
|
|
3843
|
+
});
|
|
3844
|
+
const evidenceStatus = Type.Enum({
|
|
3845
|
+
required: "required", optional: "optional", "not-applicable": "not-applicable",
|
|
3846
|
+
});
|
|
3847
|
+
const recordEvidenceExpectationTool = defineTool({
|
|
3848
|
+
name: "record_evidence_expectation",
|
|
3849
|
+
label: "record_evidence_expectation",
|
|
3850
|
+
description: 'Record evidence requirements as {requirementId:"<canonical id>",evidence:{static:"required|optional|not-applicable",behavior:"required|optional|not-applicable",mock:"required|optional|not-applicable","real-integration":"required|optional|not-applicable"}}. Declare at least one lane; later calls replace only the lanes they name.',
|
|
3851
|
+
parameters: Type.Object({
|
|
3852
|
+
requirementId: Type.String(),
|
|
3853
|
+
evidence: Type.Object({
|
|
3854
|
+
static: Type.Optional(evidenceStatus),
|
|
3855
|
+
behavior: Type.Optional(evidenceStatus),
|
|
3856
|
+
mock: Type.Optional(evidenceStatus),
|
|
3857
|
+
"real-integration": Type.Optional(evidenceStatus),
|
|
3858
|
+
}, { additionalProperties: false }),
|
|
3859
|
+
}, { additionalProperties: false }),
|
|
3860
|
+
async execute(_toolCallId, params) {
|
|
3861
|
+
try {
|
|
3862
|
+
const fact = frontendEvidenceExpectationSchema.parse(params);
|
|
3863
|
+
if (!input.canonicalRequirements?.has(fact.requirementId)) {
|
|
3864
|
+
throw new Error(`unknown canonical requirement ${fact.requirementId}`);
|
|
3865
|
+
}
|
|
3866
|
+
return receipt(await adoptContractFact("evidence-expectation", {
|
|
3867
|
+
kind: "evidence-expectation", origin: "contract", ...fact,
|
|
3868
|
+
}));
|
|
3869
|
+
}
|
|
3870
|
+
catch (error) {
|
|
3871
|
+
return receipt({
|
|
3872
|
+
ok: false, kind: "evidence-expectation",
|
|
3873
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3874
|
+
});
|
|
3875
|
+
}
|
|
3876
|
+
},
|
|
3877
|
+
});
|
|
3878
|
+
const recordRequiredDeliverablesTool = defineTool({
|
|
3879
|
+
name: "record_required_deliverables",
|
|
3880
|
+
label: "record_required_deliverables",
|
|
3881
|
+
description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. Each call appends complete source-bound items; replace:true explicitly replaces the inventory. Required before finalize_contract ready.',
|
|
3882
|
+
parameters: Type.Object({ replace: Type.Optional(Type.Boolean()), items: Type.Array(Type.Object({
|
|
3883
|
+
path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
|
|
3884
|
+
}, { additionalProperties: false })) }, { additionalProperties: false }),
|
|
3885
|
+
async execute(_toolCallId, params) {
|
|
3886
|
+
try {
|
|
3887
|
+
const declaration = validateFrontendRequiredDeliverables({ items: params.items }, input.canonicalRequirements ?? new Map());
|
|
3888
|
+
const previous = params.replace ? [] : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "required-deliverables").at(-1)?.fact.items;
|
|
3889
|
+
const items = [...new Map([...(Array.isArray(previous) ? previous : []), ...declaration.items].map(item => [JSON.stringify(item), item])).values()];
|
|
3890
|
+
return receipt(await adoptContractFact("required-deliverables", {
|
|
3891
|
+
kind: "required-deliverables", origin: "contract", items,
|
|
3892
|
+
}));
|
|
3893
|
+
}
|
|
3894
|
+
catch (error) {
|
|
3895
|
+
return receipt({
|
|
3896
|
+
ok: false, kind: "required-deliverables",
|
|
3897
|
+
error: error instanceof Error ? error.message : String(error),
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
},
|
|
3901
|
+
});
|
|
3902
|
+
// OpenSpec selection committed as individual typed facts (one path per
|
|
3903
|
+
// call) so a large candidate set never exceeds a single model output
|
|
3904
|
+
// budget: each tool call carries exactly one {path, disposition,
|
|
3905
|
+
// rationale} row and the ledger accumulates them across calls. Only
|
|
3906
|
+
// positive classifications (required | relevant) are legal; unmentioned
|
|
3907
|
+
// candidates default to irrelevant at the prewrite gate.
|
|
3908
|
+
const recordOpenspecSelectionTool = defineTool({
|
|
3909
|
+
name: "record_openspec_selection",
|
|
3910
|
+
label: "record_openspec_selection",
|
|
3911
|
+
description: "Commit one OpenSpec candidate classification (origin=contract openspec-selection fact). Call once per path you actually use or consult: required (must be read and cited) or relevant (informs planning). Never call it for irrelevant candidates — unmentioned candidates default to irrelevant. You may call it many times; one row per call.",
|
|
3912
|
+
promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
|
|
3913
|
+
parameters: Type.Object({
|
|
3914
|
+
path: Type.String({
|
|
3915
|
+
description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
|
|
3916
|
+
}),
|
|
3917
|
+
disposition: Type.Enum({
|
|
3918
|
+
required: "required",
|
|
3919
|
+
relevant: "relevant",
|
|
3920
|
+
}),
|
|
3921
|
+
rationale: Type.String({}),
|
|
3922
|
+
}, { additionalProperties: false }),
|
|
3923
|
+
async execute(_toolCallId, params) {
|
|
3924
|
+
const path = typeof params?.path === "string" ? params.path : "";
|
|
3925
|
+
const disposition = params?.disposition;
|
|
3926
|
+
const rationale = typeof params?.rationale === "string" ? params.rationale : "";
|
|
3927
|
+
if (!path || !disposition || !rationale.trim()) {
|
|
3928
|
+
return receipt({
|
|
3929
|
+
ok: false,
|
|
3930
|
+
kind: "openspec-selection",
|
|
3931
|
+
error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
|
|
3932
|
+
});
|
|
3933
|
+
}
|
|
3934
|
+
const result = await adoptContractFact("openspec-selection", {
|
|
3935
|
+
kind: "openspec-selection",
|
|
3936
|
+
origin: "contract",
|
|
3937
|
+
path,
|
|
3938
|
+
disposition,
|
|
3939
|
+
rationale,
|
|
3940
|
+
});
|
|
3941
|
+
return receipt(result);
|
|
3942
|
+
},
|
|
3943
|
+
});
|
|
3944
|
+
const finalizeContractTool = defineTool({
|
|
3945
|
+
name: "finalize_contract",
|
|
3946
|
+
label: "finalize_contract",
|
|
3947
|
+
description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
|
|
3948
|
+
promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
|
|
3949
|
+
parameters: Type.Object({
|
|
3950
|
+
disposition: Type.Enum({
|
|
3951
|
+
ready: "ready",
|
|
3952
|
+
"ready-with-assumptions": "ready-with-assumptions",
|
|
3953
|
+
blocked: "blocked",
|
|
3954
|
+
}),
|
|
3955
|
+
blockingOwner: Type.Optional(Type.Enum({
|
|
3956
|
+
"blocked-human": "blocked-human",
|
|
3957
|
+
"blocked-external": "blocked-external",
|
|
3958
|
+
})),
|
|
3959
|
+
assumptions: Type.Optional(Type.Array(Type.String({}))),
|
|
3960
|
+
summary: Type.Optional(Type.String({})),
|
|
3961
|
+
}, { additionalProperties: false }),
|
|
3962
|
+
async execute(_toolCallId, params) {
|
|
3963
|
+
const disposition = params?.disposition;
|
|
3964
|
+
const blockingOwner = params?.blockingOwner;
|
|
3965
|
+
if (disposition === "blocked" && !blockingOwner) {
|
|
3966
|
+
return receipt({
|
|
3967
|
+
ok: false,
|
|
3968
|
+
kind: "finalize_contract",
|
|
3969
|
+
error: "blocked disposition requires blockingOwner (blocked-human | blocked-external)",
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3972
|
+
if (disposition !== "blocked" && input.canonicalRequirements?.size &&
|
|
3973
|
+
!readCommittedEvents(store, attemptId).some((record) => record.fact.kind === "required-deliverables")) {
|
|
3974
|
+
return receipt({
|
|
3975
|
+
ok: false, kind: "finalize_contract",
|
|
3976
|
+
error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
const missing = [...(input.canonicalRequirements?.keys() ?? [])].filter(id => !committedRequirementIds().has(id) || (activeScope !== null && !completedScopeRequirementIds().has(id)));
|
|
3980
|
+
if (disposition !== "blocked" && missing.length)
|
|
3981
|
+
return receipt({ ok: false, code: "CONTRACT_REQUIREMENT_COVERAGE_MISSING", error: `Confirm all complete source obligations before finalizing: ${missing.join(", ")}` });
|
|
3982
|
+
const blockedOwner = mapContractBlockedOwner({
|
|
3983
|
+
disposition: disposition ?? "",
|
|
3984
|
+
blockingOwner,
|
|
3985
|
+
});
|
|
3986
|
+
const result = await adoptContractFact("contract-finalized", {
|
|
3987
|
+
kind: "contract-finalized",
|
|
3988
|
+
origin: "contract",
|
|
3989
|
+
disposition,
|
|
3990
|
+
...(blockingOwner ? { blockingOwner } : {}),
|
|
3991
|
+
...(blockedOwner !== "not-blocked" ? { blockedOwner } : {}),
|
|
3992
|
+
...(Array.isArray(params?.assumptions)
|
|
3993
|
+
? { assumptions: params.assumptions }
|
|
3994
|
+
: {}),
|
|
3995
|
+
...(typeof params?.summary === "string"
|
|
3996
|
+
? { summary: params.summary }
|
|
3997
|
+
: {}),
|
|
3998
|
+
});
|
|
3999
|
+
return receipt(result);
|
|
4000
|
+
},
|
|
4001
|
+
});
|
|
4002
|
+
const completeScopeTool = defineTool({
|
|
4003
|
+
name: "complete_contract_scope", label: "complete_contract_scope",
|
|
4004
|
+
description: "After recording all requirements AND their evidence, constraints, questions and deliverables for this session, mark the scope complete. Confirming an ID alone does not complete its analysis. Do this before finalize_contract.",
|
|
4005
|
+
parameters: Type.Object({ requirementIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) }, { additionalProperties: false }),
|
|
4006
|
+
async execute(_id, params) {
|
|
4007
|
+
const ids = params.requirementIds;
|
|
4008
|
+
if (activeScope === null || ids.length !== activeScope.size || ids.some(id => !activeScope?.has(id) || !committedRequirementIds().has(id)))
|
|
4009
|
+
return receipt({ ok: false, code: "CONTRACT_SCOPE_INCOMPLETE", error: "Complete exactly the active scope after confirming all its obligations" });
|
|
4010
|
+
return receipt(await adoptContractFact("contract-scope-completed", { kind: "contract-scope-completed", origin: "contract", requirementIds: ids }));
|
|
4011
|
+
},
|
|
4012
|
+
});
|
|
4013
|
+
const durable = await createDurableFrontendTools({
|
|
4014
|
+
file: path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), attemptId, store: input.store,
|
|
4015
|
+
setWorkingStore: next => { store = next; }, binding: { canonicalRequirements: input.canonicalRequirements, sourceDigest: input.sourceDigest },
|
|
4016
|
+
tools: [
|
|
4017
|
+
...recordTools,
|
|
4018
|
+
recordRequirementTool,
|
|
4019
|
+
recordRequirementExecutionTool,
|
|
4020
|
+
recordEvidenceExpectationTool,
|
|
4021
|
+
recordUiStateTool,
|
|
4022
|
+
recordRequiredDeliverablesTool,
|
|
4023
|
+
recordOpenspecSelectionTool,
|
|
4024
|
+
completeScopeTool,
|
|
4025
|
+
finalizeContractTool,
|
|
4026
|
+
],
|
|
4027
|
+
});
|
|
4028
|
+
const durableRecordRequirementTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "record_requirement");
|
|
4029
|
+
if (!durableRecordRequirementTool)
|
|
4030
|
+
throw new Error("frontend contract durable requirement tool unavailable");
|
|
4031
|
+
return {
|
|
4032
|
+
customTools: durable.customTools,
|
|
4033
|
+
inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
|
|
4034
|
+
seedCanonicalRequirements: async () => {
|
|
4035
|
+
for (const id of input.canonicalRequirements?.keys() ?? []) {
|
|
4036
|
+
if (committedRequirementIds().has(id))
|
|
4037
|
+
continue;
|
|
4038
|
+
const receipt = await durableRecordRequirementTool.execute(`${attemptId}:seed-requirement:${id}`, { id }, undefined, undefined, {});
|
|
4039
|
+
if (receipt.details?.ok !== true) {
|
|
4040
|
+
throw new Error(`runtime requirement seed rejected for ${id}`);
|
|
4041
|
+
}
|
|
4042
|
+
}
|
|
4043
|
+
await durable.flush();
|
|
4044
|
+
},
|
|
4045
|
+
completedScopeRequirementIds,
|
|
4046
|
+
setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
|
|
4047
|
+
committedRequirementIds,
|
|
4048
|
+
committedFacts: () => readCommittedEvents(input.store, attemptId),
|
|
4049
|
+
flush: async () => {
|
|
4050
|
+
await durable.flush();
|
|
4051
|
+
const committed = readCommittedEvents(input.store, attemptId);
|
|
4052
|
+
await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
|
|
4053
|
+
},
|
|
4054
|
+
};
|
|
2619
4055
|
}
|
|
2620
4056
|
async function resolveFrontendScoutSourceDeclaredPaths(input) {
|
|
2621
4057
|
const binding = input.spec.sourceBinding;
|
|
@@ -2652,6 +4088,270 @@ async function resolveFrontendScoutSourceDeclaredPaths(input) {
|
|
|
2652
4088
|
}
|
|
2653
4089
|
return [...declared].sort();
|
|
2654
4090
|
}
|
|
4091
|
+
/**
|
|
4092
|
+
* A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). Runtime
|
|
4093
|
+
* enriches committed target-surface / design-evidence facts with hash/section/
|
|
4094
|
+
* freshness from real read events; the tool itself never trusts model self-report.
|
|
4095
|
+
*/
|
|
4096
|
+
export async function createFrontendScoutEvidenceTools(input) {
|
|
4097
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
4098
|
+
import("typebox"),
|
|
4099
|
+
import("@earendil-works/pi-coding-agent"),
|
|
4100
|
+
]);
|
|
4101
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4102
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
4103
|
+
let store = input.store;
|
|
4104
|
+
const attemptId = input.attemptId;
|
|
4105
|
+
const stringArray = Type.Array(Type.String({}));
|
|
4106
|
+
const optionalString = Type.Optional(Type.String({}));
|
|
4107
|
+
const scoutCompleteness = Type.Union([
|
|
4108
|
+
Type.Literal("complete"),
|
|
4109
|
+
Type.Literal("blocked"),
|
|
4110
|
+
]);
|
|
4111
|
+
const receipt = (details) => ({
|
|
4112
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
4113
|
+
details,
|
|
4114
|
+
});
|
|
4115
|
+
const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
|
|
4116
|
+
const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
|
|
4117
|
+
let activeScope;
|
|
4118
|
+
const scopeIdentity = (ids) => createHash("sha256").update(JSON.stringify([...ids].sort())).digest("hex");
|
|
4119
|
+
const latestScopes = () => {
|
|
4120
|
+
const byRequirement = new Map();
|
|
4121
|
+
for (const record of readCommittedEvents(store, attemptId))
|
|
4122
|
+
if (record.fact.kind === "scout-scope" && Array.isArray(record.fact.requirementIds)) {
|
|
4123
|
+
for (const id of record.fact.requirementIds)
|
|
4124
|
+
if (typeof id === "string")
|
|
4125
|
+
byRequirement.set(id, record.fact);
|
|
4126
|
+
}
|
|
4127
|
+
return byRequirement;
|
|
4128
|
+
};
|
|
4129
|
+
const isSourceDeclared = (candidate) => {
|
|
4130
|
+
const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
4131
|
+
return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
|
|
4132
|
+
};
|
|
4133
|
+
// A+B (AC-003): runtime enriches declared scout paths with hash/freshness
|
|
4134
|
+
// from the real filesystem. The model's self-reported path list is never
|
|
4135
|
+
// trusted for content identity; a missing file fails closed to fresh=false
|
|
4136
|
+
// with a zero hash instead of inventing content.
|
|
4137
|
+
const enrichScoutPathEvidence = async (paths) => {
|
|
4138
|
+
if (!input.workspaceRoot)
|
|
4139
|
+
return [];
|
|
4140
|
+
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
4141
|
+
const evidence = [];
|
|
4142
|
+
for (const relative of new Set(paths)) {
|
|
4143
|
+
const absolute = path.resolve(workspaceRoot, relative);
|
|
4144
|
+
if (absolute !== workspaceRoot &&
|
|
4145
|
+
!absolute.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
4146
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
4147
|
+
continue;
|
|
4148
|
+
}
|
|
4149
|
+
try {
|
|
4150
|
+
// Directories are legitimate named targets (greenfield smoke: the
|
|
4151
|
+
// page directory exists while the files inside it are to be
|
|
4152
|
+
// created). readFile on a directory throws EISDIR, which used to
|
|
4153
|
+
// mark every directory path fresh=false and structurally fail the
|
|
4154
|
+
// freshness gate for create-new surfaces. stat() first: a directory
|
|
4155
|
+
// counts as fresh existence evidence; its content hash is a stable
|
|
4156
|
+
// directory marker since there is no single file content to hash.
|
|
4157
|
+
const info = await stat(absolute);
|
|
4158
|
+
if (info.isDirectory()) {
|
|
4159
|
+
evidence.push({
|
|
4160
|
+
path: relative,
|
|
4161
|
+
sha256: createHash("sha256").update(`directory:${relative}`).digest("hex"),
|
|
4162
|
+
fresh: true,
|
|
4163
|
+
sourceDeclared: isSourceDeclared(relative),
|
|
4164
|
+
});
|
|
4165
|
+
continue;
|
|
4166
|
+
}
|
|
4167
|
+
const bytes = await readFile(absolute);
|
|
4168
|
+
evidence.push({
|
|
4169
|
+
path: relative,
|
|
4170
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
4171
|
+
fresh: true,
|
|
4172
|
+
sourceDeclared: isSourceDeclared(relative),
|
|
4173
|
+
});
|
|
4174
|
+
}
|
|
4175
|
+
catch {
|
|
4176
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
4179
|
+
return evidence;
|
|
4180
|
+
};
|
|
4181
|
+
async function adoptScoutFact(kind, fact) {
|
|
4182
|
+
try {
|
|
4183
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
4184
|
+
const staged = stageTypedEventFact({
|
|
4185
|
+
store,
|
|
4186
|
+
requestId,
|
|
4187
|
+
attemptId,
|
|
4188
|
+
fact: fact,
|
|
4189
|
+
});
|
|
4190
|
+
const committed = await adoptTypedEventFact({
|
|
4191
|
+
store,
|
|
4192
|
+
requestId,
|
|
4193
|
+
attemptId,
|
|
4194
|
+
fact: fact,
|
|
4195
|
+
eventId: staged.eventId,
|
|
4196
|
+
expectedRevision: store.revision,
|
|
4197
|
+
});
|
|
4198
|
+
return {
|
|
4199
|
+
ok: true,
|
|
4200
|
+
kind,
|
|
4201
|
+
eventId: committed.eventId,
|
|
4202
|
+
revision: committed.revision,
|
|
4203
|
+
error: "",
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
catch (error) {
|
|
4207
|
+
return {
|
|
4208
|
+
ok: false,
|
|
4209
|
+
kind,
|
|
4210
|
+
code: error?.code,
|
|
4211
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4212
|
+
};
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
const recordTargetSurfaceTool = defineTool({
|
|
4216
|
+
name: "record_target_surface",
|
|
4217
|
+
label: "record_target_surface",
|
|
4218
|
+
description: "Commit an origin=scout target-surface fact with complete/blocked discovery status. A complete surface needs a proven target path and no unresolved paths; blocked surfaces name the unresolved paths instead of guessing. Example: {\"completeness\": \"complete\", \"entrypoint\": \"<file>\", \"implementationPaths\": [\"<dir or file>\"], \"testPaths\": [\"<file>\"], \"allowedPathConflicts\": [], \"unresolvedPaths\": []}",
|
|
4219
|
+
promptSnippet: "Commit an origin=scout target-surface fact.",
|
|
4220
|
+
parameters: Type.Object({
|
|
4221
|
+
scopeId: Type.Optional(Type.String({ minLength: 1 })),
|
|
4222
|
+
completeness: scoutCompleteness,
|
|
4223
|
+
entrypoint: optionalString,
|
|
4224
|
+
routeOrMount: optionalString,
|
|
4225
|
+
implementationPaths: stringArray,
|
|
4226
|
+
proposedPaths: Type.Optional(stringArray),
|
|
4227
|
+
testPaths: stringArray,
|
|
4228
|
+
dataSource: optionalString,
|
|
4229
|
+
allowedPathConflicts: stringArray,
|
|
4230
|
+
unresolvedPaths: stringArray,
|
|
4231
|
+
}, { additionalProperties: false }),
|
|
4232
|
+
async execute(_toolCallId, params) {
|
|
4233
|
+
if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
|
|
4234
|
+
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
|
|
4235
|
+
const implementationPaths = params?.implementationPaths ?? [];
|
|
4236
|
+
const proposedPaths = params?.proposedPaths ?? [];
|
|
4237
|
+
const testPaths = params?.testPaths ?? [];
|
|
4238
|
+
const pathEvidence = await enrichScoutPathEvidence([
|
|
4239
|
+
...(params?.entrypoint ? [params.entrypoint] : []),
|
|
4240
|
+
...implementationPaths,
|
|
4241
|
+
...testPaths,
|
|
4242
|
+
]);
|
|
4243
|
+
for (const proposedPath of proposedPaths) {
|
|
4244
|
+
if (!pathEvidence.some((item) => item.path === proposedPath)) {
|
|
4245
|
+
pathEvidence.push({ path: proposedPath, sha256: "0".repeat(64), fresh: false, sourceDeclared: false, proposed: true });
|
|
4246
|
+
}
|
|
4247
|
+
}
|
|
4248
|
+
const surface = {
|
|
4249
|
+
kind: "target-surface",
|
|
4250
|
+
origin: "scout",
|
|
4251
|
+
completeness: params?.completeness ?? "blocked",
|
|
4252
|
+
entrypoint: params?.entrypoint ?? "",
|
|
4253
|
+
routeOrMount: params?.routeOrMount ?? "",
|
|
4254
|
+
implementationPaths,
|
|
4255
|
+
...(proposedPaths.length > 0 ? { proposedPaths } : {}),
|
|
4256
|
+
testPaths,
|
|
4257
|
+
dataSource: params?.dataSource ?? "",
|
|
4258
|
+
allowedPathConflicts: params?.allowedPathConflicts ?? [],
|
|
4259
|
+
unresolvedPaths: params?.unresolvedPaths ?? [],
|
|
4260
|
+
...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
|
|
4261
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
4262
|
+
};
|
|
4263
|
+
if (activeScope) {
|
|
4264
|
+
const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
|
|
4265
|
+
if (surface.completeness === "complete") {
|
|
4266
|
+
const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
|
|
4267
|
+
if (!check.ok)
|
|
4268
|
+
return receipt({ ok: false, code: "SCOUT_SCOPE_INCOMPLETE", error: check.reason });
|
|
4269
|
+
}
|
|
4270
|
+
const saved = await adoptScoutFact("scout-scope", { kind: "scout-scope", origin: "scout", id: params.scopeId, requirementIds: activeScope, surface });
|
|
4271
|
+
if (!saved.ok)
|
|
4272
|
+
return receipt(saved);
|
|
4273
|
+
const current = latestScopes();
|
|
4274
|
+
if (input.requirementIds?.every(id => current.get(id)?.surface?.completeness === "complete")) {
|
|
4275
|
+
const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
|
|
4276
|
+
const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
|
|
4277
|
+
const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
|
|
4278
|
+
return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], proposedPaths: union("proposedPaths"), testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
|
|
4279
|
+
}
|
|
4280
|
+
return receipt(saved);
|
|
4281
|
+
}
|
|
4282
|
+
const result = await adoptScoutFact("target-surface", surface);
|
|
4283
|
+
return receipt(result);
|
|
4284
|
+
},
|
|
4285
|
+
});
|
|
4286
|
+
const recordDesignEvidenceTool = defineTool({
|
|
4287
|
+
name: "record_design_evidence",
|
|
4288
|
+
label: "record_design_evidence",
|
|
4289
|
+
description: "Commit an origin=scout design-evidence fact (source, paths, conflicts). Example: {\"source\": \"<source>\", \"paths\": [\"<file>\"], \"conflicts\": []}",
|
|
4290
|
+
promptSnippet: "Commit an origin=scout design-evidence fact.",
|
|
4291
|
+
parameters: Type.Object({ source: Type.String({}), paths: stringArray, conflicts: stringArray }, { additionalProperties: false }),
|
|
4292
|
+
async execute(_toolCallId, params) {
|
|
4293
|
+
const paths = params?.paths ?? [];
|
|
4294
|
+
const pathEvidence = await enrichScoutPathEvidence(paths);
|
|
4295
|
+
const result = await adoptScoutFact("design-evidence", {
|
|
4296
|
+
kind: "design-evidence",
|
|
4297
|
+
origin: "scout",
|
|
4298
|
+
source: params?.source ?? "",
|
|
4299
|
+
paths,
|
|
4300
|
+
conflicts: params?.conflicts ?? [],
|
|
4301
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
4302
|
+
});
|
|
4303
|
+
return receipt(result);
|
|
4304
|
+
},
|
|
4305
|
+
});
|
|
4306
|
+
const durable = await createDurableFrontendTools({
|
|
4307
|
+
file: path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), attemptId, store: input.store,
|
|
4308
|
+
setWorkingStore: next => { store = next; }, binding: { requirementIds: input.requirementIds, sourceDeclaredPaths: input.sourceDeclaredPaths, sourceDigest: input.sourceDigest, workspaceRoot: input.workspaceRoot },
|
|
4309
|
+
tools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
|
|
4310
|
+
validateRestored: async (records) => {
|
|
4311
|
+
if (!input.workspaceRoot)
|
|
4312
|
+
return;
|
|
4313
|
+
for (const record of records) {
|
|
4314
|
+
const evidence = record.fact.kind === "scout-scope" ? record.fact.surface?.pathEvidence : record.fact.pathEvidence;
|
|
4315
|
+
if (!Array.isArray(evidence))
|
|
4316
|
+
continue;
|
|
4317
|
+
for (const previous of evidence) {
|
|
4318
|
+
if (!isRecordObject(previous) || typeof previous.path !== "string")
|
|
4319
|
+
throw Error("scout path evidence is malformed");
|
|
4320
|
+
const current = (await enrichScoutPathEvidence([previous.path]))[0];
|
|
4321
|
+
if (!current || current.sha256 !== previous.sha256 || current.fresh !== previous.fresh)
|
|
4322
|
+
throw Error(`scout evidence drift: ${previous.path}; refresh Scout before reusing facts`);
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
},
|
|
4326
|
+
});
|
|
4327
|
+
return {
|
|
4328
|
+
...durable,
|
|
4329
|
+
adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
|
|
4330
|
+
for (const record of records) {
|
|
4331
|
+
if (record.phase !== "committed")
|
|
4332
|
+
continue;
|
|
4333
|
+
const fact = record.fact;
|
|
4334
|
+
if (!fact || typeof fact !== "object" || Array.isArray(fact))
|
|
4335
|
+
continue;
|
|
4336
|
+
const result = await adoptScoutFact(typeof fact.kind === "string"
|
|
4337
|
+
? fact.kind
|
|
4338
|
+
: "scout-fact", fact);
|
|
4339
|
+
if (!result.ok) {
|
|
4340
|
+
throw new Error(`frontend scout shard fact merge failed: ${result.error}`);
|
|
4341
|
+
}
|
|
4342
|
+
}
|
|
4343
|
+
}),
|
|
4344
|
+
setActiveScope: ids => {
|
|
4345
|
+
if (!ids.length || ids.some(id => !input.requirementIds?.includes(id)))
|
|
4346
|
+
throw Error("FRONTEND_INPUT_SCOPE_VIOLATION");
|
|
4347
|
+
activeScope = [...new Set(ids)];
|
|
4348
|
+
return scopeIdentity(activeScope);
|
|
4349
|
+
},
|
|
4350
|
+
completedRequirementIds: () => new Set([...latestScopes()].filter(([, fact]) => fact.surface?.completeness === "complete").map(([id]) => id)),
|
|
4351
|
+
completedScopeFacts: () => [...new Set(latestScopes().values())].filter(f => f.surface?.completeness === "complete"),
|
|
4352
|
+
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
4353
|
+
};
|
|
4354
|
+
}
|
|
2655
4355
|
export function buildDagPiUserMessage(task, persona, step) {
|
|
2656
4356
|
const role = task.role ?? "unspecified";
|
|
2657
4357
|
const writePolicy = task.writePolicy ?? "read-only (default)";
|
|
@@ -2711,6 +4411,27 @@ export function buildDagPiUserMessage(task, persona, step) {
|
|
|
2711
4411
|
"Do not wrap the output in code fences and do not add conversational preamble.",
|
|
2712
4412
|
].join(" ");
|
|
2713
4413
|
}
|
|
4414
|
+
export function resolveDagPiModelConfig(modelReference, options) {
|
|
4415
|
+
const separatorIndex = modelReference.indexOf("/");
|
|
4416
|
+
const qualified = separatorIndex >= 0;
|
|
4417
|
+
const provider = qualified
|
|
4418
|
+
? modelReference.slice(0, separatorIndex)
|
|
4419
|
+
: (DAG_PI_MODEL_PROVIDERS[modelReference] ?? DEFAULT_DAG_PI_PROVIDER);
|
|
4420
|
+
const model = qualified
|
|
4421
|
+
? modelReference.slice(separatorIndex + 1)
|
|
4422
|
+
: modelReference;
|
|
4423
|
+
if (!provider || !model) {
|
|
4424
|
+
throw new Error(`invalid DAG Pi model reference "${modelReference}": expected non-empty provider/model`);
|
|
4425
|
+
}
|
|
4426
|
+
const explicitThinking = options?.thinking?.trim();
|
|
4427
|
+
const thinking = explicitThinking ??
|
|
4428
|
+
(provider === "wizard-local" && model === "gpt-5.5" ? "low" : undefined);
|
|
4429
|
+
return {
|
|
4430
|
+
provider,
|
|
4431
|
+
model,
|
|
4432
|
+
...(thinking ? { thinking } : {}),
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
2714
4435
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
2715
4436
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
2716
4437
|
export function buildPiPromptRedactedMarkdown(prompt) {
|
|
@@ -2995,6 +4716,184 @@ const FRONTEND_PLAN_SEGMENTS = [
|
|
|
2995
4716
|
].join(" "),
|
|
2996
4717
|
},
|
|
2997
4718
|
];
|
|
4719
|
+
function committedFactFromPlanRecord(value) {
|
|
4720
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4721
|
+
return undefined;
|
|
4722
|
+
const record = value;
|
|
4723
|
+
if (record.phase !== undefined && record.phase !== "committed")
|
|
4724
|
+
return undefined;
|
|
4725
|
+
const fact = record.fact;
|
|
4726
|
+
return fact && typeof fact === "object" && !Array.isArray(fact)
|
|
4727
|
+
? fact
|
|
4728
|
+
: typeof record.kind === "string"
|
|
4729
|
+
? record
|
|
4730
|
+
: undefined;
|
|
4731
|
+
}
|
|
4732
|
+
function planFactStringList(value) {
|
|
4733
|
+
if (!Array.isArray(value))
|
|
4734
|
+
return [];
|
|
4735
|
+
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
4736
|
+
}
|
|
4737
|
+
function planFactScopeIntersects(fact, requirementIds) {
|
|
4738
|
+
return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
|
|
4739
|
+
}
|
|
4740
|
+
/** Compute the authoritative coverage queue from the committed plan ledger. */
|
|
4741
|
+
export function collectFrontendPlanMissingFacts(input) {
|
|
4742
|
+
const requirements = new Map();
|
|
4743
|
+
const standaloneEvidenceGaps = new Set();
|
|
4744
|
+
const verificationTargetIds = new Set();
|
|
4745
|
+
const verificationTargetRequirements = new Map();
|
|
4746
|
+
for (const value of input.committedFacts) {
|
|
4747
|
+
const fact = committedFactFromPlanRecord(value);
|
|
4748
|
+
if (!fact || fact.origin !== "plan")
|
|
4749
|
+
continue;
|
|
4750
|
+
if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
|
|
4751
|
+
const entry = fact.entry;
|
|
4752
|
+
if (typeof entry.id === "string" && entry.id.trim())
|
|
4753
|
+
requirements.set(entry.id, entry);
|
|
4754
|
+
}
|
|
4755
|
+
if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
|
|
4756
|
+
const entry = fact.entry;
|
|
4757
|
+
const id = entry.id;
|
|
4758
|
+
if (typeof id === "string" && id.trim()) {
|
|
4759
|
+
verificationTargetIds.add(id);
|
|
4760
|
+
verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
|
|
4761
|
+
? entry.requirementIds.filter((value) => typeof value === "string")
|
|
4762
|
+
: []));
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
|
|
4766
|
+
const entry = fact.entry;
|
|
4767
|
+
const requirementId = entry.requirementId;
|
|
4768
|
+
const description = entry.description;
|
|
4769
|
+
if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
|
|
4770
|
+
standaloneEvidenceGaps.add(requirementId);
|
|
4771
|
+
}
|
|
4772
|
+
}
|
|
4773
|
+
}
|
|
4774
|
+
const missing = [];
|
|
4775
|
+
for (const id of input.requirementIds) {
|
|
4776
|
+
const entry = requirements.get(id);
|
|
4777
|
+
if (!entry) {
|
|
4778
|
+
missing.push({
|
|
4779
|
+
kind: "plan-requirement",
|
|
4780
|
+
id,
|
|
4781
|
+
requirementIds: [id],
|
|
4782
|
+
reason: `requirement ${id} has no committed plan-requirement fact`,
|
|
4783
|
+
});
|
|
4784
|
+
continue;
|
|
4785
|
+
}
|
|
4786
|
+
// Verification targets are the single authoritative direction. The
|
|
4787
|
+
// legacy requirement-side list is accepted only as a fallback while
|
|
4788
|
+
// resuming older ledgers; new plans derive it from target.requirementIds.
|
|
4789
|
+
const derivedTargetIds = [...verificationTargetRequirements.entries()]
|
|
4790
|
+
.filter(([, requirementIds]) => requirementIds.has(id))
|
|
4791
|
+
.map(([targetId]) => targetId);
|
|
4792
|
+
const legacyTargetIds = Array.isArray(entry.verificationTargetIds)
|
|
4793
|
+
? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
4794
|
+
: [];
|
|
4795
|
+
const targetIds = derivedTargetIds.length > 0 ? derivedTargetIds : legacyTargetIds;
|
|
4796
|
+
const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
|
|
4797
|
+
? entry.evidenceGap
|
|
4798
|
+
: undefined;
|
|
4799
|
+
const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
|
|
4800
|
+
standaloneEvidenceGaps.has(id);
|
|
4801
|
+
if (targetIds.length === 0 && !hasEvidenceGap) {
|
|
4802
|
+
missing.push({
|
|
4803
|
+
kind: "plan-verification-target",
|
|
4804
|
+
requirementIds: [id],
|
|
4805
|
+
reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
|
|
4806
|
+
});
|
|
4807
|
+
continue;
|
|
4808
|
+
}
|
|
4809
|
+
for (const targetId of targetIds) {
|
|
4810
|
+
if (!verificationTargetIds.has(targetId) ||
|
|
4811
|
+
!verificationTargetRequirements.get(targetId)?.has(id)) {
|
|
4812
|
+
missing.push({
|
|
4813
|
+
kind: "plan-verification-target",
|
|
4814
|
+
id: targetId,
|
|
4815
|
+
requirementIds: [id],
|
|
4816
|
+
reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
|
|
4817
|
+
});
|
|
4818
|
+
}
|
|
4819
|
+
}
|
|
4820
|
+
}
|
|
4821
|
+
return missing;
|
|
4822
|
+
}
|
|
4823
|
+
/** Completeness checks for phases whose facts are committed incrementally. */
|
|
4824
|
+
export function collectFrontendPlanPhaseMissingFacts(input) {
|
|
4825
|
+
const facts = input.committedFacts
|
|
4826
|
+
.map(committedFactFromPlanRecord)
|
|
4827
|
+
.filter((fact) => Boolean(fact && fact.origin === "plan"));
|
|
4828
|
+
if (input.phase === "ux-registry") {
|
|
4829
|
+
return facts.some((fact) => fact.kind === "state-registry")
|
|
4830
|
+
? []
|
|
4831
|
+
: [
|
|
4832
|
+
{
|
|
4833
|
+
kind: "state-registry",
|
|
4834
|
+
requirementIds: [...input.requirementIds],
|
|
4835
|
+
reason: "global UX vocabulary phase has no committed state-registry fact",
|
|
4836
|
+
},
|
|
4837
|
+
];
|
|
4838
|
+
}
|
|
4839
|
+
if (input.phase === "ux-local") {
|
|
4840
|
+
const missing = [];
|
|
4841
|
+
// Evaluate each behaviour requirement independently. A fact scoped to AC-1
|
|
4842
|
+
// must not accidentally satisfy AC-2 merely because both ids share one
|
|
4843
|
+
// UX session; shared facts remain valid when they explicitly list both ids.
|
|
4844
|
+
for (const requirementId of input.requirementIds) {
|
|
4845
|
+
if (!input.behaviorRequiredRequirementIds?.includes(requirementId))
|
|
4846
|
+
continue;
|
|
4847
|
+
const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, new Set([requirementId])));
|
|
4848
|
+
const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
|
|
4849
|
+
Array.isArray(fact.uiComponentChoices) &&
|
|
4850
|
+
fact.uiComponentChoices.length > 0);
|
|
4851
|
+
const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
|
|
4852
|
+
const hasStateFlow = canonicalStateFlow.uiStateNames.size > 0 ||
|
|
4853
|
+
canonicalStateFlow.interactionNames.size > 0;
|
|
4854
|
+
if (!hasChoice) {
|
|
4855
|
+
missing.push({
|
|
4856
|
+
kind: "component-choice",
|
|
4857
|
+
requirementIds: [requirementId],
|
|
4858
|
+
reason: "behaviour-required UX slice has no committed component-choice fact",
|
|
4859
|
+
});
|
|
4860
|
+
}
|
|
4861
|
+
if (!hasStateFlow) {
|
|
4862
|
+
missing.push({
|
|
4863
|
+
kind: "state-flow",
|
|
4864
|
+
requirementIds: [requirementId],
|
|
4865
|
+
reason: "behaviour-required UX slice has no committed state-flow fact",
|
|
4866
|
+
});
|
|
4867
|
+
}
|
|
4868
|
+
}
|
|
4869
|
+
return missing;
|
|
4870
|
+
}
|
|
4871
|
+
const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
|
|
4872
|
+
const allInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
|
|
4873
|
+
const liveInteractions = new Set([...collectCanonicalStateFlowNames(facts.filter(f => !planFactStringList(f.scopeRequirementIds).length || planFactScopeIntersects(f, new Set(input.requirementIds)))).interactionNames].filter(name => allInteractions.has(name)));
|
|
4874
|
+
const coveredInteractions = new Set(facts
|
|
4875
|
+
.filter((fact) => fact.kind === "data-flow")
|
|
4876
|
+
.flatMap((fact) => planFactStringList(fact.interactions)));
|
|
4877
|
+
const missing = [];
|
|
4878
|
+
if (!hasMockApi) {
|
|
4879
|
+
missing.push({
|
|
4880
|
+
kind: "mock-api",
|
|
4881
|
+
requirementIds: [...input.requirementIds],
|
|
4882
|
+
reason: "global Mock/data phase has no committed mock-api fact",
|
|
4883
|
+
});
|
|
4884
|
+
}
|
|
4885
|
+
for (const interaction of liveInteractions) {
|
|
4886
|
+
if (coveredInteractions.has(interaction))
|
|
4887
|
+
continue;
|
|
4888
|
+
missing.push({
|
|
4889
|
+
kind: "data-flow",
|
|
4890
|
+
id: interaction,
|
|
4891
|
+
requirementIds: [...input.requirementIds],
|
|
4892
|
+
reason: `interaction ${interaction} has no committed data-flow fact`,
|
|
4893
|
+
});
|
|
4894
|
+
}
|
|
4895
|
+
return missing;
|
|
4896
|
+
}
|
|
2998
4897
|
/** Estimate calls conservatively: requirement + one VT, with a second VT
|
|
2999
4898
|
* reserved for behaviour-required requirements. Explicit declarations win. */
|
|
3000
4899
|
export function estimateFrontendPlanRequirementRecordCalls(fact) {
|
|
@@ -3767,27 +5666,6 @@ function buildFrontendPlanWorkload(input) {
|
|
|
3767
5666
|
countFrontendPlanTargetSurfaces(input.basePrompt) === 1,
|
|
3768
5667
|
};
|
|
3769
5668
|
}
|
|
3770
|
-
/**
|
|
3771
|
-
* Requirement ids the plan coverage layout must own, in first-commit order.
|
|
3772
|
-
*
|
|
3773
|
-
* The contract ledger is append-only and `record_requirement` is incremental, so
|
|
3774
|
-
* the same id can legitimately be committed more than once. Deduplicating here is
|
|
3775
|
-
* load-bearing: `buildFrontendPlanWorkload` seeds one `unclassified` work group per
|
|
3776
|
-
* list entry without re-checking membership, so a repeated id produced two groups,
|
|
3777
|
-
* `buildWorkBatches` emitted the id twice, and the coverage-layout validator then
|
|
3778
|
-
* rejected the layout the runtime had derived itself (members 8 !== owners 7) as
|
|
3779
|
-
* FRONTEND_PLAN_LAYOUT_INVALID with failureCategory tool-policy - killing the run
|
|
3780
|
-
* at the plan node over a completely ordinary ledger.
|
|
3781
|
-
*/
|
|
3782
|
-
export function collectFrontendPlanRequirementIds(contractFacts) {
|
|
3783
|
-
return [
|
|
3784
|
-
...new Set(contractFacts
|
|
3785
|
-
.filter((record) => record.fact?.kind ===
|
|
3786
|
-
"requirement")
|
|
3787
|
-
.map((record) => record.fact?.id)
|
|
3788
|
-
.filter((id) => typeof id === "string")),
|
|
3789
|
-
];
|
|
3790
|
-
}
|
|
3791
5669
|
const frontendPlanCoverageLayoutSchema = z.object({
|
|
3792
5670
|
schemaVersion: z.literal(1),
|
|
3793
5671
|
bindingSha256: z.string(),
|
|
@@ -4071,9 +5949,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4071
5949
|
}
|
|
4072
5950
|
}
|
|
4073
5951
|
const work = [...domains.values()];
|
|
4074
|
-
|
|
5952
|
+
// Work-unit packing and full provider envelope capacity have separate budgets.
|
|
4075
5953
|
return packFrontendInputUnits(work, {
|
|
4076
|
-
targetBytes:
|
|
5954
|
+
targetBytes: input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES,
|
|
4077
5955
|
maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits ?? 4,
|
|
4078
5956
|
maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
|
|
4079
5957
|
cost: (group) => group.estimatedCalls,
|
|
@@ -4327,7 +6205,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4327
6205
|
};
|
|
4328
6206
|
const customTools = input.segmentCustomTools(atomicFocus ? new Set([atomicTools[atomicFocus.kind]]) : session.toolNames);
|
|
4329
6207
|
const result = await observeFrontendSession({
|
|
4330
|
-
...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
|
|
6208
|
+
...input.observation, phase: `plan/${session.id}`, dispatchReason: (session.retryCount ?? 0) > 0 || !!session.missingFacts?.length || /capacity|recovery|split/.test(session.id) ? "correction" : "initial", scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
|
|
4331
6209
|
prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
|
|
4332
6210
|
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocationCount}.json` : undefined,
|
|
4333
6211
|
}, async (observer) => {
|
|
@@ -4431,7 +6309,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4431
6309
|
});
|
|
4432
6310
|
}
|
|
4433
6311
|
}
|
|
4434
|
-
const promptForMissingPhase = (missing) =>
|
|
6312
|
+
const promptForMissingPhase = (missing) => session.coverageOnly
|
|
4435
6313
|
? buildCoveragePrompt(session.coverageSlice ?? [], missing)
|
|
4436
6314
|
: isCompactLocalSession
|
|
4437
6315
|
? buildCompactLocalPrompt(missing)
|
|
@@ -4439,7 +6317,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4439
6317
|
? buildUxRegistryPrompt(missing)
|
|
4440
6318
|
: isUxLocalSession
|
|
4441
6319
|
? buildUxPrompt(session.requirementSlice ?? [], missing)
|
|
4442
|
-
: buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds)
|
|
6320
|
+
: buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
|
|
4443
6321
|
const recovery = classifyFrontendPlanRecovery({ ...result, stopReason: readWriterThinkingExhaustionEvidence(result).stopReason });
|
|
4444
6322
|
if (recovery === "stop")
|
|
4445
6323
|
return { ...result, ok: false };
|
|
@@ -4687,7 +6565,12 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4687
6565
|
optionalPlanFields.realIntegrationGap = fact.realIntegrationGap;
|
|
4688
6566
|
}
|
|
4689
6567
|
}
|
|
4690
|
-
let
|
|
6568
|
+
let finalizationCount = 0;
|
|
6569
|
+
const finalize = () => observeFrontendFinalization({
|
|
6570
|
+
...input.observation,
|
|
6571
|
+
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-runtime-finalize-${++finalizationCount}.json` : undefined,
|
|
6572
|
+
}, () => input.finalizePlan(optionalPlanFields));
|
|
6573
|
+
let finalizeResult = await finalize();
|
|
4691
6574
|
const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
|
|
4692
6575
|
? (finalizeResult.details ?? finalizeResult)
|
|
4693
6576
|
: finalizeResult;
|
|
@@ -4708,20 +6591,30 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
4708
6591
|
].filter(Boolean).join("\n\n");
|
|
4709
6592
|
input.setActiveRequirementScope?.([]);
|
|
4710
6593
|
const correctionTools = input.segmentCustomTools(null);
|
|
4711
|
-
const correction = await
|
|
4712
|
-
...input.
|
|
4713
|
-
prompt: correctionPrompt,
|
|
4714
|
-
|
|
6594
|
+
const correction = await observeFrontendSession({
|
|
6595
|
+
...input.observation, phase: "plan/finalize-correction", dispatchReason: "correction",
|
|
6596
|
+
scopeIds: input.requirementIds, prompt: correctionPrompt, userMessage: input.sessionOptions.userMessage,
|
|
6597
|
+
customTools: correctionTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
|
|
6598
|
+
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-finalize-correction.json` : undefined,
|
|
6599
|
+
}, async (observer) => {
|
|
6600
|
+
const result = await input.piStepFn({
|
|
6601
|
+
...input.sessionOptions, onAttemptObservation: observer, prompt: correctionPrompt,
|
|
6602
|
+
writerToolPolicy: { requireSdk: true, customTools: correctionTools },
|
|
6603
|
+
});
|
|
6604
|
+
try {
|
|
6605
|
+
await input.flushLedger();
|
|
6606
|
+
lastDurableCount = input.committedFactCount();
|
|
6607
|
+
}
|
|
6608
|
+
catch { /* node-level flush retries below */ }
|
|
6609
|
+
return result;
|
|
4715
6610
|
});
|
|
4716
|
-
try {
|
|
4717
|
-
await input.flushLedger();
|
|
4718
|
-
}
|
|
4719
|
-
catch { /* node-level flush retries below */ }
|
|
4720
6611
|
accumulated = accumulated
|
|
4721
6612
|
? combineSequentialPiResults(accumulated, correction)
|
|
4722
6613
|
: correction;
|
|
6614
|
+
if (!correction.ok && !["invalid-output", "empty-output", "success"].includes(correction.failureCategory))
|
|
6615
|
+
return { ...accumulated, ok: false, failureCategory: correction.failureCategory };
|
|
4723
6616
|
if (correction.ok) {
|
|
4724
|
-
finalizeResult = await
|
|
6617
|
+
finalizeResult = await finalize();
|
|
4725
6618
|
}
|
|
4726
6619
|
const retriedDetails = finalizeResult && typeof finalizeResult === "object"
|
|
4727
6620
|
? (finalizeResult.details ?? finalizeResult)
|
|
@@ -4814,7 +6707,7 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
4814
6707
|
toolName: "record_target_surface",
|
|
4815
6708
|
instruction: [
|
|
4816
6709
|
"PARALLEL SCOUT SHARD — target surface only.",
|
|
4817
|
-
"Inspect routes, entrypoints, implementation ownership, data source, and
|
|
6710
|
+
"Inspect routes, entrypoints, implementation ownership, data source, and existing test entrypoints. Inspect project source/config first; do not recursively explore dependency internals merely to reconfirm standard test-runner behavior. If a concrete required capability cannot be established from project evidence, report the precise gap.",
|
|
4818
6711
|
"Call record_target_surface exactly once with the complete runtime-evidenced surface. Do not call record_design_evidence.",
|
|
4819
6712
|
].join(" "),
|
|
4820
6713
|
},
|
|
@@ -4823,12 +6716,16 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
4823
6716
|
toolName: "record_design_evidence",
|
|
4824
6717
|
instruction: [
|
|
4825
6718
|
"PARALLEL SCOUT SHARD — design evidence only.",
|
|
4826
|
-
"Inspect
|
|
6719
|
+
"Inspect project-owned frontend framework, styling/theme conventions, reusable components, and relevant design/spec files. The surface shard owns test-runner/environment discovery; do not duplicate its Vitest/happy-dom investigation. Stop discovery once the design evidence is sufficient and commit it.",
|
|
4827
6720
|
"Call record_design_evidence for the evidence you actually read. Do not call record_target_surface.",
|
|
4828
6721
|
].join(" "),
|
|
4829
6722
|
},
|
|
4830
6723
|
];
|
|
4831
6724
|
const outcomes = await Promise.all(shards.map(async (shard) => {
|
|
6725
|
+
const cacheKey = JSON.stringify([input.runDir, input.nodeId, shard.id, input.sessionOptions.modelConfig, input.observation?.model, input.observation?.sourceDigest, input.sourceDeclaredPaths]);
|
|
6726
|
+
const cached = input.completedShards?.get(cacheKey);
|
|
6727
|
+
if (cached)
|
|
6728
|
+
return { shard, facts: cached.facts, result: { ...cached.result, durationMs: 0, tokensUsed: 0, parsedEvents: 0, attemptedModels: [], fallbackUsed: false } };
|
|
4832
6729
|
let shardTools;
|
|
4833
6730
|
let shardResult;
|
|
4834
6731
|
try {
|
|
@@ -4871,7 +6768,12 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
4871
6768
|
},
|
|
4872
6769
|
}));
|
|
4873
6770
|
await shardTools.flush();
|
|
4874
|
-
|
|
6771
|
+
const facts = shardTools.committedFacts();
|
|
6772
|
+
const expectedKind = shard.id === "design" ? "design-evidence" : "target-surface";
|
|
6773
|
+
if (shardResult.ok && facts.some(record => { const fact = record.fact; return fact.kind === expectedKind && (shard.id !== "surface" || fact.completeness === "complete"); })) {
|
|
6774
|
+
input.completedShards?.set(cacheKey, { result: structuredClone(shardResult), facts: structuredClone(facts) });
|
|
6775
|
+
}
|
|
6776
|
+
return { shard, result: shardResult, tools: shardTools, facts };
|
|
4875
6777
|
}
|
|
4876
6778
|
catch (error) {
|
|
4877
6779
|
const crashMessage = `frontend scout parallel shard ${shard.id} crashed: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -4911,8 +6813,8 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
4911
6813
|
const ordered = [...outcomes].sort((left, right) => left.shard.id.localeCompare(right.shard.id));
|
|
4912
6814
|
try {
|
|
4913
6815
|
for (const outcome of ordered) {
|
|
4914
|
-
if (outcome.result.ok
|
|
4915
|
-
await input.mainTools.adoptCommittedFacts(outcome.tools
|
|
6816
|
+
if (outcome.result.ok) {
|
|
6817
|
+
await input.mainTools.adoptCommittedFacts(outcome.facts ?? outcome.tools?.committedFacts() ?? []);
|
|
4916
6818
|
}
|
|
4917
6819
|
}
|
|
4918
6820
|
}
|
|
@@ -4939,7 +6841,7 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
4939
6841
|
stderr: `${aggregate.stderr}\nfrontend scout parallel shard failed: ${failed.shard.id}`.trim(),
|
|
4940
6842
|
};
|
|
4941
6843
|
}
|
|
4942
|
-
const committedKinds = new Set(ordered.flatMap((outcome) => (outcome.tools?.committedFacts() ?? [])
|
|
6844
|
+
const committedKinds = new Set(ordered.flatMap((outcome) => (outcome.facts ?? outcome.tools?.committedFacts() ?? [])
|
|
4943
6845
|
.map((record) => record.fact?.kind)
|
|
4944
6846
|
.filter((kind) => typeof kind === "string")));
|
|
4945
6847
|
const missing = ["target-surface", "design-evidence"].filter((kind) => !committedKinds.has(kind));
|
|
@@ -5144,7 +7046,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5144
7046
|
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
5145
7047
|
const store = createTypedEventStore();
|
|
5146
7048
|
reviewTerminalTools = await createFrontendReviewTerminalTools({
|
|
5147
|
-
inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
|
|
7049
|
+
inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES, input.cwd),
|
|
5148
7050
|
inputDigest: reviewInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
|
|
5149
7051
|
attemptId: `${meta.runId}:${input.task.id}`,
|
|
5150
7052
|
store,
|
|
@@ -5301,7 +7203,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5301
7203
|
if (useDecisionPlan) {
|
|
5302
7204
|
// Recovery child: read the parent snapshot before the tools so
|
|
5303
7205
|
// the replay and the identical-plan finalize guard share it.
|
|
5304
|
-
parentDecisionSnapshot = await readParentDecisionSnapshot(meta.runDir, input.task.id);
|
|
7206
|
+
parentDecisionSnapshot = await readParentDecisionSnapshot(meta.runDir, input.task.id, { workspaceRoot: input.cwd, sourceBinding: meta.spec.sourceBinding });
|
|
5305
7207
|
}
|
|
5306
7208
|
decisionPlanTools = await createFrontendPlanDecisionTools({
|
|
5307
7209
|
attemptId: `${meta.runId}:${input.task.id}`,
|
|
@@ -5491,6 +7393,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5491
7393
|
scoutEvidenceTools &&
|
|
5492
7394
|
input.task.complexity !== "LOW") {
|
|
5493
7395
|
result = await runFrontendScoutParallelSessions({
|
|
7396
|
+
completedShards: input.scoutCompletedShards,
|
|
5494
7397
|
piStepFn,
|
|
5495
7398
|
sessionOptions: piSessionOptions,
|
|
5496
7399
|
basePrompt: input.prompt,
|
|
@@ -5569,46 +7472,86 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5569
7472
|
return base;
|
|
5570
7473
|
})()
|
|
5571
7474
|
: input.prompt;
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
|
|
5575
|
-
|
|
7475
|
+
let decisionDurableCount = decisionPlanTools.committedFacts().length;
|
|
7476
|
+
const decisionResult = await observeFrontendSession({
|
|
7477
|
+
artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}-decision.json`),
|
|
7478
|
+
phase: "plan/decision", dispatchReason: "initial",
|
|
7479
|
+
runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId,
|
|
7480
|
+
attempt: input.attempt ?? 1, model: input.model,
|
|
7481
|
+
sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
|
|
7482
|
+
prompt: decisionPrompt, userMessage: piSessionOptions.userMessage, customTools: decisionPlanTools.customTools,
|
|
7483
|
+
committedCount: () => decisionPlanTools.committedFacts().length,
|
|
7484
|
+
durableCommittedCount: () => decisionDurableCount,
|
|
7485
|
+
}, async (observer) => {
|
|
7486
|
+
const result = await piStepFn({
|
|
7487
|
+
...piSessionOptions, frontendPlanControl: decisionPlanTools.planControl, onAttemptObservation: observer, prompt: decisionPrompt,
|
|
7488
|
+
writerToolPolicy: { requireSdk: true, customTools: decisionPlanTools.customTools },
|
|
7489
|
+
});
|
|
7490
|
+
try {
|
|
7491
|
+
await decisionPlanTools.flush();
|
|
7492
|
+
decisionDurableCount = decisionPlanTools.committedFacts().length;
|
|
7493
|
+
}
|
|
7494
|
+
catch { /* node-level flush retries below */ }
|
|
7495
|
+
return result;
|
|
5576
7496
|
});
|
|
5577
|
-
|
|
5578
|
-
|
|
7497
|
+
if ((!decisionResult.ok && !["empty-output", "invalid-output", "unknown"].includes(decisionResult.failureCategory)) || decisionPlanTools.planControl.readStop()) {
|
|
7498
|
+
const stopped = decisionPlanTools.planControl.readStop();
|
|
7499
|
+
result = decisionResult.ok && stopped ? { ...decisionResult, ok: false, failureCategory: "invalid-output", stderr: `${stopped.code}: ${stopped.error}` } : decisionResult;
|
|
5579
7500
|
}
|
|
5580
|
-
|
|
5581
|
-
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
|
|
7501
|
+
else {
|
|
7502
|
+
const finalizeResult = await observeFrontendFinalization({
|
|
7503
|
+
artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}-runtime-finalize.json`),
|
|
7504
|
+
runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1,
|
|
7505
|
+
}, () => decisionPlanTools.finalizeDecision());
|
|
7506
|
+
const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
|
|
7507
|
+
? (finalizeResult.details ?? finalizeResult)
|
|
7508
|
+
: finalizeResult;
|
|
7509
|
+
if (finalizeDetails && typeof finalizeDetails === "object" && finalizeDetails.ok === true) {
|
|
7510
|
+
// Bridge the finalized decisions into the relationship-shaped plan
|
|
7511
|
+
// ledger. The node-level R1 self-check and all downstream shells
|
|
7512
|
+
// compile ONLY plan-typed-facts.jsonl; without this write the whole
|
|
7513
|
+
// decision run fails "frontend plan ledger missing" at R1 on every
|
|
7514
|
+
// attempt. Validation failures surface as invalid-output so the
|
|
7515
|
+
// retry ladder restarts the session with the diagnostics.
|
|
7516
|
+
try {
|
|
7517
|
+
const bridge = await bridgeFrontendPlanDecisionToRelationshipLedger({
|
|
7518
|
+
runDir: meta.runDir,
|
|
7519
|
+
nodeId: input.task.id,
|
|
7520
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
7521
|
+
facts: decisionPlanTools.committedFacts(),
|
|
7522
|
+
authority: decisionPlanAuthority,
|
|
7523
|
+
skeleton: input.task.structuredContractOutput?.skeleton,
|
|
7524
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
7525
|
+
});
|
|
7526
|
+
if (!bridge.ok) {
|
|
7527
|
+
throw new Error(bridge.error);
|
|
7528
|
+
}
|
|
7529
|
+
result = decisionResult;
|
|
7530
|
+
}
|
|
7531
|
+
catch (error) {
|
|
7532
|
+
result = {
|
|
7533
|
+
ok: false,
|
|
7534
|
+
stdout: decisionResult.stdout ?? "",
|
|
7535
|
+
stderr: `${decisionResult.stderr ?? ""}\n${error instanceof Error ? error.message : String(error)}`.trim(),
|
|
7536
|
+
failureCategory: "invalid-output",
|
|
7537
|
+
durationMs: Date.now() - started,
|
|
7538
|
+
modelDisplay: decisionResult.modelDisplay,
|
|
7539
|
+
parsedEvents: decisionResult.parsedEvents,
|
|
7540
|
+
timedOut: decisionResult.timedOut,
|
|
7541
|
+
attemptedModels: decisionResult.attemptedModels,
|
|
7542
|
+
fallbackUsed: decisionResult.fallbackUsed,
|
|
7543
|
+
tokensUsed: decisionResult.tokensUsed,
|
|
7544
|
+
assistantText: decisionResult.assistantText ?? "",
|
|
7545
|
+
command: decisionResult.command ?? [],
|
|
7546
|
+
exitCode: decisionResult.exitCode,
|
|
7547
|
+
};
|
|
5604
7548
|
}
|
|
5605
|
-
result = decisionResult;
|
|
5606
7549
|
}
|
|
5607
|
-
|
|
7550
|
+
else {
|
|
5608
7551
|
result = {
|
|
5609
7552
|
ok: false,
|
|
5610
7553
|
stdout: decisionResult.stdout ?? "",
|
|
5611
|
-
stderr: `${decisionResult.stderr ?? ""}\
|
|
7554
|
+
stderr: `${decisionResult.stderr ?? ""}\nfrontend decision finalize failed: ${String(finalizeDetails?.error ?? "decision finalize rejected")}`.trim(),
|
|
5612
7555
|
failureCategory: "invalid-output",
|
|
5613
7556
|
durationMs: Date.now() - started,
|
|
5614
7557
|
modelDisplay: decisionResult.modelDisplay,
|
|
@@ -5623,24 +7566,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5623
7566
|
};
|
|
5624
7567
|
}
|
|
5625
7568
|
}
|
|
5626
|
-
else {
|
|
5627
|
-
result = {
|
|
5628
|
-
ok: false,
|
|
5629
|
-
stdout: decisionResult.stdout ?? "",
|
|
5630
|
-
stderr: `${decisionResult.stderr ?? ""}\nfrontend decision finalize failed: ${String(finalizeDetails?.error ?? "decision finalize rejected")}`.trim(),
|
|
5631
|
-
failureCategory: "invalid-output",
|
|
5632
|
-
durationMs: Date.now() - started,
|
|
5633
|
-
modelDisplay: decisionResult.modelDisplay,
|
|
5634
|
-
parsedEvents: decisionResult.parsedEvents,
|
|
5635
|
-
timedOut: decisionResult.timedOut,
|
|
5636
|
-
attemptedModels: decisionResult.attemptedModels,
|
|
5637
|
-
fallbackUsed: decisionResult.fallbackUsed,
|
|
5638
|
-
tokensUsed: decisionResult.tokensUsed,
|
|
5639
|
-
assistantText: decisionResult.assistantText ?? "",
|
|
5640
|
-
command: decisionResult.command ?? [],
|
|
5641
|
-
exitCode: decisionResult.exitCode,
|
|
5642
|
-
};
|
|
5643
|
-
}
|
|
5644
7569
|
}
|
|
5645
7570
|
else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
|
|
5646
7571
|
// Frontend-only split: independent coverage map sessions feed a single
|
|
@@ -5653,14 +7578,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5653
7578
|
try {
|
|
5654
7579
|
const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
|
|
5655
7580
|
const contractFacts = await readCommittedOriginFacts(meta.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
|
|
5656
|
-
//
|
|
5657
|
-
//
|
|
5658
|
-
//
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
planRequirementIds = collectFrontendPlanRequirementIds(contractFacts);
|
|
5663
|
-
for (const fact of resolveFrontendContractRequirements(contractFacts.map((record) => record.fact))) {
|
|
7581
|
+
// Read current requirements, not append-only revisions introduced when
|
|
7582
|
+
// Contract attaches execution metadata. Coverage and costs must share
|
|
7583
|
+
// the same identity projection as the rendered Plan input.
|
|
7584
|
+
const requirements = resolveFrontendContractRequirements(contractFacts.map(record => record.fact));
|
|
7585
|
+
planRequirementIds = requirements.map(fact => fact.id);
|
|
7586
|
+
for (const fact of requirements) {
|
|
5664
7587
|
if (fact.evidence.behavior === "required")
|
|
5665
7588
|
behaviorRequiredRequirementIds.push(fact.id);
|
|
5666
7589
|
planRequirementCosts.set(fact.id, estimateFrontendPlanRequirementRecordCalls(fact));
|
|
@@ -5686,7 +7609,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
5686
7609
|
contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
|
|
5687
7610
|
},
|
|
5688
7611
|
piStepFn,
|
|
5689
|
-
sessionOptions: options.sessionOptions,
|
|
7612
|
+
sessionOptions: { ...options.sessionOptions, frontendPlanControl: options.ledgerTools.planControl },
|
|
5690
7613
|
basePrompt: options.basePrompt ?? input.prompt,
|
|
5691
7614
|
attempt: input.attempt ?? 1,
|
|
5692
7615
|
committedFactCount: () => options.ledgerTools.committedFactCount(),
|