@akagilnc/pi-workflow-roles 0.1.1751
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/LICENSE +202 -0
- package/README.md +104 -0
- package/README.zh-CN.md +133 -0
- package/THIRD_PARTY_NOTICES.md +60 -0
- package/dist/activation-ledger-git.js +68 -0
- package/dist/activation-ledger-session.js +120 -0
- package/dist/activation-ledger-topology.js +239 -0
- package/dist/activation-reconciliation.js +61 -0
- package/dist/audit-escalation.js +108 -0
- package/dist/auditor-dossier-tool.js +35 -0
- package/dist/canonical-json.js +78 -0
- package/dist/compliance-transport.js +77 -0
- package/dist/doctor-contracts.js +172 -0
- package/dist/dossier-resolution.js +103 -0
- package/dist/evidence-child-executor.js +661 -0
- package/dist/exact-utf8.js +12 -0
- package/dist/git-object-id.js +7 -0
- package/dist/in-process-session.js +50 -0
- package/dist/merger-contracts.js +76 -0
- package/dist/navigator-attendance.js +995 -0
- package/dist/navigator-invocation-identity.js +220 -0
- package/dist/open-tool-schema.js +39 -0
- package/dist/package-contracts/collector-output.js +50 -0
- package/dist/package-contracts/fixer-output.js +72 -0
- package/dist/package-contracts/fixer-packet.js +77 -0
- package/dist/package-contracts/judge-output.js +17 -0
- package/dist/package-contracts/reviewer-output.js +82 -0
- package/dist/package-contracts/terminating-tools.js +173 -0
- package/dist/package-contracts/worker-output.js +13 -0
- package/dist/package-owned-tool-idle.js +104 -0
- package/dist/packaged-role-registry.js +34 -0
- package/dist/public-cli/main.js +23867 -0
- package/dist/public-command-renderer.js +20 -0
- package/dist/reviewer-agent.js +93 -0
- package/dist/reviewer-child-executor.js +23 -0
- package/dist/reviewer-construction.js +95 -0
- package/dist/reviewer-dispatch.js +77 -0
- package/dist/reviewer-execution-ledger.js +160 -0
- package/dist/reviewer-failure-diagnostic.js +17 -0
- package/dist/reviewer-git-snapshot.js +38 -0
- package/dist/reviewer-pinned-git.js +146 -0
- package/dist/reviewer-preflight-error.js +15 -0
- package/dist/reviewer-prompt-identity.js +10 -0
- package/dist/reviewer-scope-prompt.js +21 -0
- package/dist/reviewer-workspace.js +151 -0
- package/dist/sha256.js +5 -0
- package/dist/sitian-record-entry.js +33 -0
- package/dist/stderr-jsonl.js +26 -0
- package/dist/stream-idle-guard.js +75 -0
- package/dist/tool-execution-observation.js +141 -0
- package/dist/uuidv7.js +21 -0
- package/dist/work-subject-identity.js +53 -0
- package/extensions/role-runtime.ts +303 -0
- package/package.json +69 -0
- package/packets/fixer-prerequisites.json +6 -0
- package/packets/fixer-repair.md +5 -0
- package/packets/judge-apply.md +77 -0
- package/packets/judge-authority.md +64 -0
- package/packets/judge-plan.md +55 -0
- package/packets/judge-review.md +49 -0
- package/packets/judge-submission.md +34 -0
- package/resources/methods/code-review/SKILL.md +92 -0
- package/resources/methods/code-review/agents/openai.yaml +3 -0
- package/resources/methods/code-review/provenance.json +26 -0
- package/resources/methods/diagnosing-bugs/SKILL.md +134 -0
- package/resources/methods/diagnosing-bugs/agents/openai.yaml +3 -0
- package/resources/methods/diagnosing-bugs/provenance.json +31 -0
- package/resources/methods/diagnosing-bugs/scripts/hitl-loop.template.sh +41 -0
- package/resources/methods/resolving-merge-conflicts/SKILL.md +14 -0
- package/resources/methods/resolving-merge-conflicts/agents/openai.yaml +3 -0
- package/resources/methods/resolving-merge-conflicts/provenance.json +26 -0
- package/resources/methods/tdd/SKILL.md +38 -0
- package/resources/methods/tdd/agents/openai.yaml +3 -0
- package/resources/methods/tdd/mocking.md +59 -0
- package/resources/methods/tdd/provenance.json +36 -0
- package/resources/methods/tdd/tests.md +77 -0
- package/resources/navigator-route-playbook.md +32 -0
- package/schemas/tool-execution-observation.schema.json +107 -0
- package/scripts/build-package.mjs +65 -0
- package/scripts/generate-tool-execution-observation-schema.ts +7 -0
- package/souls/coder.md +10 -0
- package/souls/collector.md +11 -0
- package/souls/doctor-auditor.md +23 -0
- package/souls/doctor.md +8 -0
- package/souls/fixer-auditor.md +33 -0
- package/souls/fixer.md +13 -0
- package/souls/judge-auditor.md +33 -0
- package/souls/judge.md +74 -0
- package/souls/merger.md +5 -0
- package/souls/navigator.md +5 -0
- package/souls/reviewer-auditor.md +25 -0
- package/souls/reviewer.md +11 -0
- package/src/activation-ledger-git.ts +96 -0
- package/src/activation-ledger-session.ts +188 -0
- package/src/activation-ledger-topology.ts +301 -0
- package/src/activation-ledger.ts +240 -0
- package/src/activation-reconciliation.ts +163 -0
- package/src/activation-trace.ts +38 -0
- package/src/audit-escalation.ts +177 -0
- package/src/auditor-dossier-tool.ts +48 -0
- package/src/auditor-soul.ts +28 -0
- package/src/canonical-json.ts +74 -0
- package/src/canonical-skill-binding.ts +107 -0
- package/src/collector-config.ts +89 -0
- package/src/collector-evidence.ts +461 -0
- package/src/collector-github.ts +656 -0
- package/src/collector-identity.ts +161 -0
- package/src/collector-ledger.ts +827 -0
- package/src/collector-receipt.ts +87 -0
- package/src/collector-role.ts +592 -0
- package/src/collector-tool-schemas.ts +19 -0
- package/src/compliance-transport.ts +130 -0
- package/src/doctor-auditor.ts +53 -0
- package/src/doctor-contracts.ts +166 -0
- package/src/doctor-evidence.ts +47 -0
- package/src/doctor-role.ts +18 -0
- package/src/dossier-resolution.ts +137 -0
- package/src/evidence-child-executor.ts +775 -0
- package/src/exact-utf8.ts +9 -0
- package/src/factory-board.ts +1822 -0
- package/src/git-object-id.ts +11 -0
- package/src/human-format.ts +65 -0
- package/src/in-process-session.ts +78 -0
- package/src/judge-auditor.ts +55 -0
- package/src/judge-recording-anti-forge.ts +53 -0
- package/src/judge-role.ts +160 -0
- package/src/merger-contracts.ts +71 -0
- package/src/merger-git-state.ts +76 -0
- package/src/merger-role.ts +60 -0
- package/src/navigator-attendance.ts +1254 -0
- package/src/navigator-invocation-identity.ts +446 -0
- package/src/open-tool-schema.ts +46 -0
- package/src/package-contracts/collector-output.ts +109 -0
- package/src/package-contracts/fixer-output.ts +81 -0
- package/src/package-contracts/fixer-packet.ts +93 -0
- package/src/package-contracts/judge-output.ts +39 -0
- package/src/package-contracts/reviewer-output.ts +115 -0
- package/src/package-contracts/terminating-tools.ts +259 -0
- package/src/package-contracts/worker-output.ts +36 -0
- package/src/package-owned-tool-idle.ts +134 -0
- package/src/package-resources/method-skill-binding.ts +87 -0
- package/src/package-resources/method-skill.ts +358 -0
- package/src/packaged-role-registry.ts +36 -0
- package/src/public-cli/cli-errors.ts +11 -0
- package/src/public-cli/cli-io.ts +4 -0
- package/src/public-cli/cli.ts +912 -0
- package/src/public-cli/coder-run.ts +575 -0
- package/src/public-cli/collector-run.ts +375 -0
- package/src/public-cli/command-renderer.ts +8 -0
- package/src/public-cli/config.ts +346 -0
- package/src/public-cli/doctor-run.ts +355 -0
- package/src/public-cli/explicit-internal.ts +274 -0
- package/src/public-cli/fixer-run.ts +587 -0
- package/src/public-cli/host-pi-runtime.ts +112 -0
- package/src/public-cli/invocation.ts +1958 -0
- package/src/public-cli/judge-run.ts +507 -0
- package/src/public-cli/main.ts +15 -0
- package/src/public-cli/merger-run.ts +681 -0
- package/src/public-cli/public-run-credentials.ts +71 -0
- package/src/public-cli/registry.ts +153 -0
- package/src/public-cli/reviewer-run.ts +561 -0
- package/src/public-cli/run-lifecycle.ts +884 -0
- package/src/public-cli/settlement.ts +3765 -0
- package/src/public-cli/terminal.ts +325 -0
- package/src/public-command-renderer.ts +43 -0
- package/src/reviewer-agent.ts +94 -0
- package/src/reviewer-auditor.ts +53 -0
- package/src/reviewer-child-executor.ts +31 -0
- package/src/reviewer-construction.ts +137 -0
- package/src/reviewer-dispatch.ts +94 -0
- package/src/reviewer-execution-ledger.ts +206 -0
- package/src/reviewer-failure-diagnostic.ts +18 -0
- package/src/reviewer-git-snapshot.ts +53 -0
- package/src/reviewer-pinned-git.ts +144 -0
- package/src/reviewer-preflight-error.ts +14 -0
- package/src/reviewer-prompt-identity.ts +17 -0
- package/src/reviewer-role.ts +193 -0
- package/src/reviewer-scope-prompt.ts +24 -0
- package/src/reviewer-settlement.ts +63 -0
- package/src/reviewer-workspace.ts +111 -0
- package/src/role-runtime.ts +884 -0
- package/src/sha256.ts +6 -0
- package/src/sitian-record-entry.ts +57 -0
- package/src/stderr-jsonl.ts +28 -0
- package/src/stream-idle-guard.ts +98 -0
- package/src/ticket-snapshot.ts +662 -0
- package/src/ticket-trajectory.ts +1000 -0
- package/src/tool-execution-observation.ts +168 -0
- package/src/uuidv7.ts +1 -0
- package/src/work-subject-identity.ts +94 -0
- package/src/worker-role.ts +434 -0
- package/src/worker-submission-gates.ts +225 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { ModelRuntime, SessionManager, type ExtensionContext, type ExtensionAPI, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
8
|
+
import { Type, type Static } from "typebox";
|
|
9
|
+
import { Value } from "typebox/value";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
NAVIGATOR_INVOCATION_ENTRY,
|
|
13
|
+
mintNavigatorInvocationId,
|
|
14
|
+
} from "./navigator-invocation-identity.ts";
|
|
15
|
+
import { PACKAGED_ROLE_REGISTRY, type PackagedRole, packagedRoleMetadata } from "./packaged-role-registry.ts";
|
|
16
|
+
import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
|
|
17
|
+
import { activationBookDirectory, resolveActivationLedgerHome } from "./activation-ledger-topology.ts";
|
|
18
|
+
import { openInProcessAgentSession } from "./in-process-session.ts";
|
|
19
|
+
import { renderPublicAkRoleCommand } from "./public-command-renderer.ts";
|
|
20
|
+
import { issueRoot, subjectPath } from "./work-subject-identity.ts";
|
|
21
|
+
import { wrapPackageOwnedToolDefinition } from "./package-owned-tool-idle.ts";
|
|
22
|
+
|
|
23
|
+
export const NAVIGATOR_EVENT_TYPE = "ak-navigator-attendance" as const;
|
|
24
|
+
export const NAVIGATOR_PREPARE_TOOL_NAME = "ak_navigator_prepare" as const;
|
|
25
|
+
export const NAVIGATOR_DEFAULT_MODEL = "openai-codex/gpt-5.6-luna:max" as const;
|
|
26
|
+
|
|
27
|
+
export const NAVIGATOR_TARGETS = PACKAGED_ROLE_REGISTRY.map(({ role, phases }) => ({ role, phases }));
|
|
28
|
+
|
|
29
|
+
export type NavigatorTargetRole = PackagedRole;
|
|
30
|
+
export type NavigatorPhase = "plan" | "apply" | null;
|
|
31
|
+
export type NavigatorUnavailableKey = "context" | "session" | "model" | "thinking" | "auth" | "quota" | "transport" | "unknown";
|
|
32
|
+
|
|
33
|
+
export class NavigatorUnavailableError extends Error {
|
|
34
|
+
readonly unavailableSource: NavigatorUnavailableKey;
|
|
35
|
+
readonly unavailableCause: NavigatorUnavailableKey;
|
|
36
|
+
readonly originalCause: unknown;
|
|
37
|
+
|
|
38
|
+
constructor(source: NavigatorUnavailableKey, message: string, cause: NavigatorUnavailableKey = source, originalCause?: unknown) {
|
|
39
|
+
super(message);
|
|
40
|
+
this.name = "NavigatorUnavailableError";
|
|
41
|
+
this.unavailableSource = source;
|
|
42
|
+
this.unavailableCause = cause;
|
|
43
|
+
this.originalCause = originalCause;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type NavigatorProviderFailureFact = {
|
|
48
|
+
source: NavigatorUnavailableKey;
|
|
49
|
+
cause: NavigatorUnavailableKey;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type NavigatorProviderErrorShape = {
|
|
53
|
+
statusCode?: number;
|
|
54
|
+
status?: number;
|
|
55
|
+
code?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function navigatorProviderFailureFromStatus(status: number | undefined): NavigatorProviderFailureFact | undefined {
|
|
59
|
+
if (status === 401 || status === 403) return { source: "auth", cause: "auth" };
|
|
60
|
+
if (status === 429) return { source: "quota", cause: "quota" };
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function navigatorProviderFailureFromCode(code: unknown): NavigatorProviderFailureFact | undefined {
|
|
65
|
+
if (typeof code === "number") {
|
|
66
|
+
return navigatorProviderFailureFromStatus(code);
|
|
67
|
+
}
|
|
68
|
+
if (typeof code !== "string") return undefined;
|
|
69
|
+
if (code === "unauthorized" || code === "authentication_failed") return { source: "auth", cause: "auth" };
|
|
70
|
+
if (code === "insufficient_quota" || code === "quota_exhausted") return { source: "quota", cause: "quota" };
|
|
71
|
+
if (code === "transport_error") return { source: "transport", cause: "transport" };
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function navigatorProviderFailureFromError(error: unknown): NavigatorProviderFailureFact | undefined {
|
|
76
|
+
if (!exactRecord(error)) return undefined;
|
|
77
|
+
const statusCode = typeof error.statusCode === "number"
|
|
78
|
+
? error.statusCode
|
|
79
|
+
: typeof error.status === "number"
|
|
80
|
+
? error.status
|
|
81
|
+
: undefined;
|
|
82
|
+
return navigatorProviderFailureFromStatus(statusCode) ?? navigatorProviderFailureFromCode(error.code);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function navigatorProviderFailureFromDiagnostics(diagnostics: unknown): NavigatorProviderFailureFact | undefined {
|
|
86
|
+
if (!Array.isArray(diagnostics)) return undefined;
|
|
87
|
+
for (const diagnostic of diagnostics) {
|
|
88
|
+
if (!exactRecord(diagnostic)) continue;
|
|
89
|
+
if (diagnostic.type === "provider_transport_failure") return { source: "transport", cause: "transport" };
|
|
90
|
+
if (exactRecord(diagnostic.error)) {
|
|
91
|
+
const fromCode = navigatorProviderFailureFromCode(diagnostic.error.code);
|
|
92
|
+
if (fromCode !== undefined) return fromCode;
|
|
93
|
+
}
|
|
94
|
+
if (exactRecord(diagnostic.details)) {
|
|
95
|
+
const status = typeof diagnostic.details.status === "number"
|
|
96
|
+
? diagnostic.details.status
|
|
97
|
+
: typeof diagnostic.details.statusCode === "number"
|
|
98
|
+
? diagnostic.details.statusCode
|
|
99
|
+
: undefined;
|
|
100
|
+
const fromStatus = navigatorProviderFailureFromStatus(status);
|
|
101
|
+
if (fromStatus !== undefined) return fromStatus;
|
|
102
|
+
const fromCode = navigatorProviderFailureFromCode(diagnostic.details.code);
|
|
103
|
+
if (fromCode !== undefined) return fromCode;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const navigatorProviderFailureSchema = Type.Object({
|
|
110
|
+
source: Type.Union([Type.Literal("context"), Type.Literal("session"), Type.Literal("model"), Type.Literal("thinking"), Type.Literal("auth"), Type.Literal("quota"), Type.Literal("transport"), Type.Literal("unknown")]),
|
|
111
|
+
cause: Type.Union([Type.Literal("context"), Type.Literal("session"), Type.Literal("model"), Type.Literal("thinking"), Type.Literal("auth"), Type.Literal("quota"), Type.Literal("transport"), Type.Literal("unknown")]),
|
|
112
|
+
}, { additionalProperties: false });
|
|
113
|
+
|
|
114
|
+
export function navigatorProviderFailure<T extends object>(message: T, source: NavigatorUnavailableKey, cause: NavigatorUnavailableKey = source): T & { navigatorFailure: NavigatorProviderFailureFact } {
|
|
115
|
+
const fact = { source, cause } satisfies NavigatorProviderFailureFact;
|
|
116
|
+
if (!Value.Check(navigatorProviderFailureSchema, fact)) throw new TypeError("Navigator provider failure fact is not typed");
|
|
117
|
+
return Object.assign(message, { navigatorFailure: fact });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function navigatorUnavailableError(source: NavigatorUnavailableKey, error: unknown, cause: NavigatorUnavailableKey = source): NavigatorUnavailableError {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
return error instanceof NavigatorUnavailableError ? error : new NavigatorUnavailableError(source, message, cause, error);
|
|
123
|
+
}
|
|
124
|
+
export type NavigatorSettlement =
|
|
125
|
+
| { kind: "accepted"; role: string; phase: NavigatorPhase; status?: string }
|
|
126
|
+
| { kind: "human_decision"; role: string; phase: NavigatorPhase; status: string }
|
|
127
|
+
| { kind: "role_infrastructure_failure"; role: string; phase: NavigatorPhase }
|
|
128
|
+
| { kind: "arrival"; role: "lander"; phase: null; message?: string };
|
|
129
|
+
|
|
130
|
+
export type NavigatorSubjectProvenance = "placeholder" | "role_input" | "user_prompt";
|
|
131
|
+
|
|
132
|
+
export type NavigatorWorkContext = {
|
|
133
|
+
subjectKey: string;
|
|
134
|
+
subject: string;
|
|
135
|
+
authority: string;
|
|
136
|
+
subjectProvenance: NavigatorSubjectProvenance;
|
|
137
|
+
contextError?: unknown;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export type NavigatorRouteTarget = { role: NavigatorTargetRole; phase: NavigatorPhase };
|
|
141
|
+
/** Normalized preparation advice. v1 success needs machine-usable next only. */
|
|
142
|
+
export type NavigatorCandidate = {
|
|
143
|
+
id?: string;
|
|
144
|
+
matches?: { role: string; phase: NavigatorPhase; kind: "accepted"; statuses?: string[] };
|
|
145
|
+
route?: NavigatorRouteTarget[];
|
|
146
|
+
next?: NavigatorRouteTarget;
|
|
147
|
+
reason?: string;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type NavigatorReport = {
|
|
151
|
+
/** Affirmative attendance only. Lawful no-advice is typed, never inferred from absence. */
|
|
152
|
+
disposition: "recommendation" | "no-advice" | "unavailable" | "arrival";
|
|
153
|
+
route?: NavigatorRouteTarget[];
|
|
154
|
+
next?: NavigatorRouteTarget;
|
|
155
|
+
reason?: string;
|
|
156
|
+
command?: string;
|
|
157
|
+
unavailableReason?: string;
|
|
158
|
+
unavailableSource?: NavigatorUnavailableKey;
|
|
159
|
+
unavailableCause?: NavigatorUnavailableKey;
|
|
160
|
+
routePlaybookReadFailure?: string;
|
|
161
|
+
arrivalMessage?: string;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export type NavigatorEvent = {
|
|
165
|
+
version: 1;
|
|
166
|
+
disposition: NavigatorReport["disposition"];
|
|
167
|
+
invocationId: string;
|
|
168
|
+
role: string;
|
|
169
|
+
phase: NavigatorPhase;
|
|
170
|
+
subjectKey: string;
|
|
171
|
+
route?: NavigatorRouteTarget[];
|
|
172
|
+
next?: NavigatorRouteTarget;
|
|
173
|
+
reason?: string;
|
|
174
|
+
command?: string;
|
|
175
|
+
unavailableReason?: string;
|
|
176
|
+
unavailableSource?: NavigatorUnavailableKey;
|
|
177
|
+
unavailableCause?: NavigatorUnavailableKey;
|
|
178
|
+
routePlaybookReadFailure?: string;
|
|
179
|
+
arrivalMessage?: string;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export type NavigatorSettlementFact = NavigatorSettlement & { invocationId: string; subjectKey: string };
|
|
183
|
+
export type NavigatorContextProjection = {
|
|
184
|
+
subjectKey: string;
|
|
185
|
+
subject: string;
|
|
186
|
+
authority: string;
|
|
187
|
+
currentRole: { role: string; phase: NavigatorPhase };
|
|
188
|
+
/** Present only on settlement-bound prepare; speculative prepare omits it. */
|
|
189
|
+
currentSettlement?: NavigatorSettlement;
|
|
190
|
+
priorRoute: NavigatorRouteTarget[] | null;
|
|
191
|
+
publicSettlementHistory: NavigatorSettlementFact[];
|
|
192
|
+
liveRoleHelp: Array<{ role: NavigatorTargetRole; help: string }>;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Shared terminal-internal consistency for Navigator advice vs the just-accepted
|
|
197
|
+
* settlement (family #224/#226/#227). One table for all seats — not per-role guards.
|
|
198
|
+
*
|
|
199
|
+
* Merger closes delivery. It is only consistent immediately after judge converged.
|
|
200
|
+
* Fresh accepted work from any other seat must not skip to merger.
|
|
201
|
+
*
|
|
202
|
+
* Unfinished is an open handoff (ADR 0050): recommending judge would send half-done
|
|
203
|
+
* work to audit. Machine criterion anchors only status=unfinished — refused and
|
|
204
|
+
* partially_completed remain settled terminals whose judge path stays lawful.
|
|
205
|
+
* Positive continuation is free-form via rebind + status-matched candidates
|
|
206
|
+
* (ADR 0010/0061); this only suppresses self-contradictory typed emission.
|
|
207
|
+
*/
|
|
208
|
+
export function navigatorAdviceConsistentWithSettlement(
|
|
209
|
+
next: NavigatorRouteTarget,
|
|
210
|
+
settlement: NavigatorSettlement,
|
|
211
|
+
): boolean {
|
|
212
|
+
if (settlement.kind !== "accepted") return true;
|
|
213
|
+
// #227: open unfinished handoff must not be typed as next=judge.
|
|
214
|
+
if (settlement.status === "unfinished" && next.role === "judge") return false;
|
|
215
|
+
// #265: completed Fixer apply must not be sent back to repeat the same work.
|
|
216
|
+
if (settlement.role === "fixer" && settlement.phase === "apply" && settlement.status === "completed"
|
|
217
|
+
&& next.role === "fixer" && next.phase === "apply") return false;
|
|
218
|
+
if (next.role !== "merger") return true;
|
|
219
|
+
return settlement.role === "judge" && settlement.status === "converged";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Provider admission is ADR 0060 object root only. Nested advisory shape
|
|
223
|
+
// (candidates/next/route/matches/reason/command) is never a gate — every object
|
|
224
|
+
// root reaches the unique execute/normalize path exactly once.
|
|
225
|
+
const prepareSchema = Type.Object({}, { additionalProperties: true });
|
|
226
|
+
type PrepareOutput = Static<typeof prepareSchema>;
|
|
227
|
+
|
|
228
|
+
export type NavigatorPreparationSession = {
|
|
229
|
+
prompt(text: string): Promise<void>;
|
|
230
|
+
appendEntry(customType: string, data: unknown): void;
|
|
231
|
+
entries(): readonly unknown[];
|
|
232
|
+
providerFailure?(): NavigatorProviderFailureFact | undefined;
|
|
233
|
+
setModel?(model: string, thinkingLevel: "off" | "max"): Promise<void>;
|
|
234
|
+
getThinkingLevel?(): string;
|
|
235
|
+
dispose(): void;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export type NavigatorSessionFactory = (options: {
|
|
239
|
+
context: ExtensionContext;
|
|
240
|
+
sessionDir: string;
|
|
241
|
+
modelSettingPath?: string;
|
|
242
|
+
tool: ToolDefinition;
|
|
243
|
+
}) => Promise<NavigatorPreparationSession>;
|
|
244
|
+
|
|
245
|
+
export type NavigatorAttendanceOptions = {
|
|
246
|
+
context: ExtensionContext;
|
|
247
|
+
role: string;
|
|
248
|
+
phase: NavigatorPhase;
|
|
249
|
+
subjectKey: string;
|
|
250
|
+
sessionDir: string;
|
|
251
|
+
loadSoul: () => Promise<string>;
|
|
252
|
+
loadRoutePlaybook?: () => Promise<string>;
|
|
253
|
+
loadRoleHelp: (role: NavigatorTargetRole) => Promise<string>;
|
|
254
|
+
createSession: NavigatorSessionFactory;
|
|
255
|
+
modelSettingPath?: string;
|
|
256
|
+
subject: string;
|
|
257
|
+
authority: string;
|
|
258
|
+
contextError?: unknown;
|
|
259
|
+
sessionDirectory?: (subjectKey: string) => string;
|
|
260
|
+
/** Exact principal owned by shared role lifecycle; attendance never overrides it. */
|
|
261
|
+
invocationId?: string;
|
|
262
|
+
onEvent: (event: NavigatorEvent, report: NavigatorReport) => void | Promise<void>;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const ROUTE_ENTRY = "ak-navigator-route";
|
|
266
|
+
const CONTEXT_ENTRY = "ak-navigator-context";
|
|
267
|
+
const INVOCATION_ENTRY = NAVIGATOR_INVOCATION_ENTRY;
|
|
268
|
+
const SETTLEMENT_ENTRY = "ak-navigator-settlement";
|
|
269
|
+
const targetRoles = new Set<string>(NAVIGATOR_TARGETS.map(({ role }) => role));
|
|
270
|
+
const unavailableKeys = new Set<NavigatorUnavailableKey>(["context", "session", "model", "thinking", "auth", "quota", "transport", "unknown"]);
|
|
271
|
+
|
|
272
|
+
function unavailableKey(value: unknown): NavigatorUnavailableKey | undefined {
|
|
273
|
+
return typeof value === "string" && unavailableKeys.has(value as NavigatorUnavailableKey)
|
|
274
|
+
? value as NavigatorUnavailableKey
|
|
275
|
+
: undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function exactRecord(value: unknown): value is Record<string, unknown> {
|
|
279
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
280
|
+
}
|
|
281
|
+
function targetIsValid(value: unknown): value is NavigatorRouteTarget {
|
|
282
|
+
if (!exactRecord(value) || !targetRoles.has(String(value.role))) return false;
|
|
283
|
+
const metadata = packagedRoleMetadata(String(value.role));
|
|
284
|
+
return metadata !== undefined && metadata.phases.includes(value.phase as never);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Normalize one advice target. Phase is kept only when present and meaningful; bare role stays usable. */
|
|
288
|
+
function normalizeTarget(value: unknown): NavigatorRouteTarget | undefined {
|
|
289
|
+
if (!exactRecord(value)) return undefined;
|
|
290
|
+
const role = typeof value.role === "string" ? value.role.trim() : "";
|
|
291
|
+
if (!targetRoles.has(role)) return undefined;
|
|
292
|
+
const metadata = packagedRoleMetadata(role);
|
|
293
|
+
if (metadata === undefined) return undefined;
|
|
294
|
+
if (value.phase === undefined || value.phase === null) {
|
|
295
|
+
return { role: role as NavigatorTargetRole, phase: null };
|
|
296
|
+
}
|
|
297
|
+
if (value.phase === "plan" || value.phase === "apply") {
|
|
298
|
+
if (metadata.phases.includes(value.phase as never)) {
|
|
299
|
+
return { role: role as NavigatorTargetRole, phase: value.phase };
|
|
300
|
+
}
|
|
301
|
+
// Present but not meaningful for this role → drop to bare role direction.
|
|
302
|
+
return { role: role as NavigatorTargetRole, phase: null };
|
|
303
|
+
}
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function normalizeMatches(value: unknown): NavigatorCandidate["matches"] | undefined {
|
|
308
|
+
if (!exactRecord(value)) return undefined;
|
|
309
|
+
if (typeof value.role !== "string" || value.role.trim() === "") return undefined;
|
|
310
|
+
if (value.kind !== "accepted") return undefined;
|
|
311
|
+
let phase: NavigatorPhase;
|
|
312
|
+
if (value.phase === undefined || value.phase === null) phase = null;
|
|
313
|
+
else if (value.phase === "plan" || value.phase === "apply") phase = value.phase;
|
|
314
|
+
else return undefined;
|
|
315
|
+
if (value.statuses !== undefined) {
|
|
316
|
+
if (!Array.isArray(value.statuses) || value.statuses.some((status) => typeof status !== "string" || status.trim() === "")) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
role: value.role,
|
|
321
|
+
phase,
|
|
322
|
+
kind: "accepted",
|
|
323
|
+
statuses: [...value.statuses],
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
return { role: value.role, phase, kind: "accepted" };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Normalize one submitted candidate. Broken ancillary fields are dropped, never a rejection. */
|
|
330
|
+
function normalizeCandidate(value: unknown): NavigatorCandidate | undefined {
|
|
331
|
+
if (!exactRecord(value)) return undefined;
|
|
332
|
+
const next = normalizeTarget(value.next);
|
|
333
|
+
const route = Array.isArray(value.route)
|
|
334
|
+
? value.route.map(normalizeTarget).filter((target): target is NavigatorRouteTarget => target !== undefined)
|
|
335
|
+
: undefined;
|
|
336
|
+
const matches = normalizeMatches(value.matches);
|
|
337
|
+
const id = typeof value.id === "string" && value.id.trim() !== "" ? value.id : undefined;
|
|
338
|
+
const reason = typeof value.reason === "string" && value.reason.trim() !== "" ? value.reason : undefined;
|
|
339
|
+
// Model command prose is never execution authority; omit from normalized advice.
|
|
340
|
+
return {
|
|
341
|
+
...(id === undefined ? {} : { id }),
|
|
342
|
+
...(matches === undefined ? {} : { matches }),
|
|
343
|
+
...(route === undefined || route.length === 0 ? {} : { route }),
|
|
344
|
+
...(next === undefined ? {} : { next }),
|
|
345
|
+
...(reason === undefined ? {} : { reason }),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Accept any prepare submission shape; missing/malformed candidates become empty advice. */
|
|
350
|
+
function normalizePrepareOutput(value: unknown): NavigatorCandidate[] {
|
|
351
|
+
if (!exactRecord(value) || !Array.isArray(value.candidates)) return [];
|
|
352
|
+
return value.candidates
|
|
353
|
+
.map(normalizeCandidate)
|
|
354
|
+
.filter((candidate): candidate is NavigatorCandidate => candidate !== undefined);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function routeEqual(a: readonly NavigatorRouteTarget[] | undefined, b: readonly NavigatorRouteTarget[]): boolean {
|
|
358
|
+
return a !== undefined && a.length === b.length && a.every((target, index) => target.role === b[index]!.role && target.phase === b[index]!.phase);
|
|
359
|
+
}
|
|
360
|
+
function routeText(route: readonly NavigatorRouteTarget[]): string {
|
|
361
|
+
return route.map((target) => target.phase === null ? target.role : `${target.role} ${target.phase}`).join(" → ");
|
|
362
|
+
}
|
|
363
|
+
function targetText(target: NavigatorRouteTarget): string {
|
|
364
|
+
return target.phase === null ? target.role : `${target.role} ${target.phase}`;
|
|
365
|
+
}
|
|
366
|
+
function oneLine(value: string): string {
|
|
367
|
+
return value.split(/\r?\n/, 1)[0]!.trim();
|
|
368
|
+
}
|
|
369
|
+
export function navigatorSubjectKey(
|
|
370
|
+
subjectRoot: string,
|
|
371
|
+
subject: string,
|
|
372
|
+
provenance: NavigatorSubjectProvenance = "role_input",
|
|
373
|
+
): string {
|
|
374
|
+
if (issueRoot(subjectRoot) !== undefined || !subjectRoot.includes("/.ak/work/")) return subjectRoot;
|
|
375
|
+
if (provenance === "placeholder") return subjectRoot;
|
|
376
|
+
const normalized = subject.trim().replace(/\s+/g, " ");
|
|
377
|
+
if (normalized === "") return subjectRoot;
|
|
378
|
+
return `${subjectRoot}#${createHash("sha256").update(normalized).digest("hex").slice(0, 32)}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Role inputs for one ad-hoc work item live below role-specific run folders.
|
|
383
|
+
* The folder and filename are transport, not identity: the shared work root
|
|
384
|
+
* keeps task.md, fix-packet.json, and other natural inputs on one subject.
|
|
385
|
+
*/
|
|
386
|
+
export function navigatorSubjectKeyForInput(subjectRoot: string, reference: string, cwd = process.cwd()): string {
|
|
387
|
+
if (issueRoot(subjectRoot) !== undefined || !subjectRoot.includes("/.ak/work/")) return subjectRoot;
|
|
388
|
+
const resolvedReference = resolve(cwd, reference);
|
|
389
|
+
const marker = "/runs/";
|
|
390
|
+
if (resolvedReference.includes(marker)) {
|
|
391
|
+
// The work root, not task.md/fix-packet.json/etc., is the stable subject.
|
|
392
|
+
// Different roots remain isolated without inventing a filename convention.
|
|
393
|
+
return subjectRoot;
|
|
394
|
+
}
|
|
395
|
+
return navigatorSubjectKey(subjectRoot, resolvedReference);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function subjectDirectory(cwd: string, subjectKey: string): string {
|
|
399
|
+
const book = activationBookDirectory(
|
|
400
|
+
resolveActivationLedgerHome(),
|
|
401
|
+
resolveBookKeyFromGit(cwd),
|
|
402
|
+
);
|
|
403
|
+
const digest = createHash("sha256").update(subjectKey).digest("hex").slice(0, 32);
|
|
404
|
+
return join(book, "navigator", digest);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function navigatorModelSettingPath(): string {
|
|
408
|
+
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "navigator-model.json");
|
|
409
|
+
}
|
|
410
|
+
export async function readNavigatorModelSetting(path = navigatorModelSettingPath()): Promise<string> {
|
|
411
|
+
try {
|
|
412
|
+
const raw = JSON.parse(await readFile(path, "utf8")) as unknown;
|
|
413
|
+
if (!exactRecord(raw) || typeof raw.model !== "string" || raw.model.trim() === "") throw new Error("Navigator model setting is malformed");
|
|
414
|
+
return raw.model;
|
|
415
|
+
} catch (error) {
|
|
416
|
+
// Contract: README.md#Navigator-attendance — the absent optional persistent setting uses the documented default; all other causes propagate.
|
|
417
|
+
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") return NAVIGATOR_DEFAULT_MODEL;
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
export async function writeNavigatorModelSetting(model: string, path = navigatorModelSettingPath()): Promise<void> {
|
|
422
|
+
const normalized = model.trim();
|
|
423
|
+
parseNavigatorModelSetting(normalized);
|
|
424
|
+
await mkdir(dirname(path), { recursive: true });
|
|
425
|
+
await writeFile(path, JSON.stringify({ model: normalized }) + "\n", "utf8");
|
|
426
|
+
}
|
|
427
|
+
export function parseNavigatorModelSetting(value: string): { provider: string; model: string; thinkingLevel: "off" | "max" } {
|
|
428
|
+
const slash = value.indexOf("/");
|
|
429
|
+
if (slash <= 0 || slash === value.length - 1) throw new Error("Navigator model setting must be provider/model[:max]");
|
|
430
|
+
const provider = value.slice(0, slash);
|
|
431
|
+
const modelWithThinking = value.slice(slash + 1);
|
|
432
|
+
const colon = modelWithThinking.lastIndexOf(":");
|
|
433
|
+
const suffix = colon < 0 ? undefined : modelWithThinking.slice(colon + 1);
|
|
434
|
+
if (suffix !== undefined && suffix !== "max") throw new Error("Navigator model setting must use :max or omit the thinking suffix");
|
|
435
|
+
const model = colon < 0 ? modelWithThinking : modelWithThinking.slice(0, colon);
|
|
436
|
+
if (model === "") throw new Error("Navigator model setting must include a model");
|
|
437
|
+
return { provider, model, thinkingLevel: suffix === "max" ? "max" : "off" };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function createNavigatorPrepareTool(onOutput: (value: PrepareOutput) => void): ToolDefinition {
|
|
441
|
+
return wrapPackageOwnedToolDefinition({
|
|
442
|
+
name: NAVIGATOR_PREPARE_TOOL_NAME,
|
|
443
|
+
label: "Navigator preparation",
|
|
444
|
+
description: "Submit Navigator direction advice. Provide candidates with next.role (phase when meaningful). route/matches/reason/command are optional context, not acceptance gates.",
|
|
445
|
+
parameters: prepareSchema,
|
|
446
|
+
async execute(_id, value) {
|
|
447
|
+
// Rule 0: the unique prepare submission is accepted once. Ancillary shape is
|
|
448
|
+
// normalized later; never open a format-correction retry loop here.
|
|
449
|
+
onOutput(value as PrepareOutput);
|
|
450
|
+
return { content: [{ type: "text" as const, text: "Navigator preparation accepted" }], details: value, terminate: true as const };
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function selectNavigatorCandidate(candidates: readonly NavigatorCandidate[], settlement: NavigatorSettlement): NavigatorCandidate | undefined {
|
|
456
|
+
if (settlement.kind !== "accepted") return undefined;
|
|
457
|
+
const usable = candidates.filter((candidate) => candidate.next !== undefined);
|
|
458
|
+
if (usable.length === 0) return undefined;
|
|
459
|
+
const matched = usable.filter((candidate) =>
|
|
460
|
+
candidate.matches !== undefined
|
|
461
|
+
&& candidate.matches.role === settlement.role
|
|
462
|
+
&& candidate.matches.phase === settlement.phase,
|
|
463
|
+
);
|
|
464
|
+
// Status-specific candidates outrank role/phase generics regardless of declaration order.
|
|
465
|
+
if (matched.length > 0) {
|
|
466
|
+
if (settlement.status !== undefined) {
|
|
467
|
+
const statusSpecific = matched.find((candidate) => candidate.matches?.statuses?.includes(settlement.status!) === true);
|
|
468
|
+
if (statusSpecific !== undefined) return statusSpecific;
|
|
469
|
+
}
|
|
470
|
+
return matched.find((candidate) => candidate.matches?.statuses === undefined);
|
|
471
|
+
}
|
|
472
|
+
// v1 direction-only / broken matches: absent match metadata must not drop a usable next.
|
|
473
|
+
return usable.find((candidate) => candidate.matches === undefined);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function formatNavigatorReport(report: NavigatorReport): string {
|
|
477
|
+
const playbookFailure = report.routePlaybookReadFailure === undefined
|
|
478
|
+
? []
|
|
479
|
+
: [`路书读取失败:${oneLine(report.routePlaybookReadFailure)}`];
|
|
480
|
+
if (report.disposition === "no-advice") return playbookFailure.join("\n");
|
|
481
|
+
if (report.disposition === "unavailable") return [...playbookFailure, `导航不可用:${oneLine(report.unavailableReason ?? "未能完成导航准备")}`].join("\n");
|
|
482
|
+
if (report.disposition === "arrival") return [...playbookFailure, oneLine(report.arrivalMessage ?? "已到达目的地")].join("\n");
|
|
483
|
+
return [
|
|
484
|
+
...playbookFailure,
|
|
485
|
+
...(report.route === undefined ? [] : [`路线:${routeText(report.route)}`]),
|
|
486
|
+
`下一步:${targetText(report.next!)}`,
|
|
487
|
+
...(report.reason === undefined || report.reason.trim() === "" ? [] : [`理由:${oneLine(report.reason)}`]),
|
|
488
|
+
...(report.command === undefined || report.command.trim() === "" ? [] : [`命令:${oneLine(report.command)}`]),
|
|
489
|
+
].join("\n");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type SettlementNavigation = {
|
|
493
|
+
disposition: "recommendation";
|
|
494
|
+
route?: NavigatorRouteTarget[];
|
|
495
|
+
next: NavigatorRouteTarget;
|
|
496
|
+
reason?: string;
|
|
497
|
+
command?: string;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
/** Recommendation essentials for the one mandatory last-ak_*_output extraction. */
|
|
501
|
+
export function settlementNavigationFromEvent(event: NavigatorEvent): SettlementNavigation | undefined {
|
|
502
|
+
if (event.disposition !== "recommendation") return undefined;
|
|
503
|
+
if (event.next === undefined) return undefined;
|
|
504
|
+
return {
|
|
505
|
+
disposition: "recommendation",
|
|
506
|
+
...(event.route === undefined ? {} : { route: event.route }),
|
|
507
|
+
next: event.next,
|
|
508
|
+
...(event.reason === undefined ? {} : { reason: event.reason }),
|
|
509
|
+
...(event.command === undefined ? {} : { command: event.command }),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
type SettlementTextPart = { type: "text"; text: string };
|
|
514
|
+
|
|
515
|
+
function appendNavigatorReportToContent<T extends { type: string }>(
|
|
516
|
+
content: readonly T[],
|
|
517
|
+
reportText: string,
|
|
518
|
+
): Array<T | SettlementTextPart> {
|
|
519
|
+
if (reportText === "") return content.slice();
|
|
520
|
+
const parts: Array<T | SettlementTextPart> = content.slice();
|
|
521
|
+
for (let index = parts.length - 1; index >= 0; index -= 1) {
|
|
522
|
+
const part = parts[index];
|
|
523
|
+
if (part !== undefined && part.type === "text" && typeof (part as SettlementTextPart).text === "string") {
|
|
524
|
+
parts[index] = { ...(part as object), type: "text", text: `${(part as SettlementTextPart).text}\n${reportText}` } as SettlementTextPart;
|
|
525
|
+
return parts;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return [...parts, { type: "text", text: reportText }];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Decorate an accepted role-output tool result so the one mandatory settlement
|
|
533
|
+
* extraction (last ak_*_output toolResult) carries recommendation essentials in
|
|
534
|
+
* content text. Receipt details stay byte-identical to the terminating-tool
|
|
535
|
+
* contract — unavailable and affirmative no-advice leave the settlement untouched.
|
|
536
|
+
*/
|
|
537
|
+
export function decorateSettlementWithNavigation<T extends { type: string }>(
|
|
538
|
+
event: { content: readonly T[]; details: unknown },
|
|
539
|
+
presentation: { event: NavigatorEvent; report: NavigatorReport } | undefined,
|
|
540
|
+
): { content: Array<T | SettlementTextPart>; details: unknown } | undefined {
|
|
541
|
+
if (presentation === undefined) return undefined;
|
|
542
|
+
if (settlementNavigationFromEvent(presentation.event) === undefined) return undefined;
|
|
543
|
+
const { routePlaybookReadFailure: _advisoryFailure, ...receiptReport } = presentation.report;
|
|
544
|
+
const reportText = formatNavigatorReport(receiptReport);
|
|
545
|
+
if (reportText === "") return undefined;
|
|
546
|
+
return {
|
|
547
|
+
content: appendNavigatorReportToContent(event.content, reportText),
|
|
548
|
+
details: event.details,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function createNavigatorAttendance(options: NavigatorAttendanceOptions) {
|
|
553
|
+
let preparation: Promise<NavigatorCandidate[]> | undefined;
|
|
554
|
+
let sessionReady: Promise<NavigatorPreparationSession> | undefined;
|
|
555
|
+
let session: NavigatorPreparationSession | undefined;
|
|
556
|
+
let subjectKey = options.subjectKey;
|
|
557
|
+
let subject = options.subject;
|
|
558
|
+
let authority = options.authority;
|
|
559
|
+
let contextError = options.contextError;
|
|
560
|
+
let sessionDir = options.sessionDir;
|
|
561
|
+
let candidates: NavigatorCandidate[] | undefined;
|
|
562
|
+
// Shared lifecycle owns the principal when supplied; otherwise mint once per attendance.
|
|
563
|
+
const invocationPrincipal = options.invocationId ?? mintNavigatorInvocationId();
|
|
564
|
+
let activeInvocationId: string | undefined = invocationPrincipal;
|
|
565
|
+
let previousRoute: NavigatorRouteTarget[] | undefined;
|
|
566
|
+
let outputSink: ((value: PrepareOutput) => void) | undefined;
|
|
567
|
+
let settlementTail: Promise<void> = Promise.resolve();
|
|
568
|
+
let settlementFailure: unknown;
|
|
569
|
+
let preparationFailure: unknown;
|
|
570
|
+
let routePlaybookReadFailure: string | undefined;
|
|
571
|
+
let disposed = false;
|
|
572
|
+
/** One-shot live-help warm; consumed by the next prepare so later prepares reread live help. */
|
|
573
|
+
let warmedHelp: Promise<Array<{ role: NavigatorTargetRole; help: string }>> | undefined;
|
|
574
|
+
|
|
575
|
+
const loadLiveHelp = async (): Promise<Array<{ role: NavigatorTargetRole; help: string }>> => {
|
|
576
|
+
try {
|
|
577
|
+
return await Promise.all(
|
|
578
|
+
NAVIGATOR_TARGETS.map(async ({ role }) => ({
|
|
579
|
+
role,
|
|
580
|
+
help: await options.loadRoleHelp(role),
|
|
581
|
+
})),
|
|
582
|
+
);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
throw navigatorUnavailableError("transport", error);
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const unavailable = (invocationId: string, reason: unknown): NavigatorReport => {
|
|
589
|
+
const failure = reason instanceof NavigatorUnavailableError
|
|
590
|
+
? reason
|
|
591
|
+
: navigatorUnavailableError("unknown", reason);
|
|
592
|
+
return {
|
|
593
|
+
disposition: "unavailable",
|
|
594
|
+
unavailableReason: failure.message,
|
|
595
|
+
unavailableSource: failure.unavailableSource,
|
|
596
|
+
unavailableCause: failure.unavailableCause,
|
|
597
|
+
};
|
|
598
|
+
};
|
|
599
|
+
let routePlaybookSettlement: Promise<void> | undefined;
|
|
600
|
+
/** Settlement-bound prepare only; cleared at the start of each prepare body. */
|
|
601
|
+
let prepareBoundSettlement: NavigatorSettlement | undefined;
|
|
602
|
+
const prepare = async (): Promise<NavigatorCandidate[]> => {
|
|
603
|
+
// Exact principal is owned by shared lifecycle (or one mint per attendance).
|
|
604
|
+
// Model/tool/advice paths cannot override it; role-session persistence is
|
|
605
|
+
// pi.appendEntry at lifecycle start — not optional sessionManager probing.
|
|
606
|
+
const boundSettlement = prepareBoundSettlement;
|
|
607
|
+
prepareBoundSettlement = undefined;
|
|
608
|
+
const invocationId = invocationPrincipal;
|
|
609
|
+
activeInvocationId = invocationId;
|
|
610
|
+
if (contextError !== undefined) throw navigatorUnavailableError("context", contextError);
|
|
611
|
+
if (typeof authority !== "string" || authority.trim() === "") {
|
|
612
|
+
throw navigatorUnavailableError(
|
|
613
|
+
"context",
|
|
614
|
+
new Error("controlling authority content was not supplied as typed work context"),
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// Load soul / model setting / live help in parallel. Live help is N pi --help
|
|
619
|
+
// subprocesses; serializing it behind session create made the post-role 10s
|
|
620
|
+
// grace cover help-boot under load (ENOENT / unavailable flake). Session
|
|
621
|
+
// create still follows context load so tool registration and prompt stay
|
|
622
|
+
// one readiness step for callers.
|
|
623
|
+
let soul: string;
|
|
624
|
+
let modelSetting: string;
|
|
625
|
+
let help: Array<{ role: NavigatorTargetRole; help: string }>;
|
|
626
|
+
let routePlaybook = "";
|
|
627
|
+
routePlaybookReadFailure = undefined;
|
|
628
|
+
const soulPromise = (async () => {
|
|
629
|
+
try {
|
|
630
|
+
const text = (await options.loadSoul()).trim();
|
|
631
|
+
if (!text) throw new Error("Navigator soul is empty");
|
|
632
|
+
return text;
|
|
633
|
+
} catch (error) {
|
|
634
|
+
// Contract: README.md#Navigator-attendance — context-loading failures become typed unavailable reports while retaining the original cause.
|
|
635
|
+
throw navigatorUnavailableError("context", error);
|
|
636
|
+
}
|
|
637
|
+
})();
|
|
638
|
+
const routePlaybookPromise = (async () => {
|
|
639
|
+
if (options.loadRoutePlaybook === undefined) return "";
|
|
640
|
+
try {
|
|
641
|
+
return await options.loadRoutePlaybook();
|
|
642
|
+
} catch (error) {
|
|
643
|
+
routePlaybookReadFailure = error instanceof Error ? error.message : String(error);
|
|
644
|
+
return "";
|
|
645
|
+
}
|
|
646
|
+
})();
|
|
647
|
+
// Preparation is fail-fast for its primary dependencies, but settlement
|
|
648
|
+
// independently drains this optional diagnostic before emitting attendance.
|
|
649
|
+
routePlaybookSettlement = routePlaybookPromise.then(() => undefined);
|
|
650
|
+
const modelPromise = (async () => {
|
|
651
|
+
try {
|
|
652
|
+
return await readNavigatorModelSetting(options.modelSettingPath);
|
|
653
|
+
} catch (error) {
|
|
654
|
+
throw navigatorUnavailableError("model", error);
|
|
655
|
+
}
|
|
656
|
+
})();
|
|
657
|
+
// Prefer one-shot warm from session_start; clear so the next prepare reloads live.
|
|
658
|
+
const helpPromise = warmedHelp ?? loadLiveHelp();
|
|
659
|
+
warmedHelp = undefined;
|
|
660
|
+
[soul, routePlaybook, modelSetting, help] = await Promise.all([
|
|
661
|
+
soulPromise,
|
|
662
|
+
routePlaybookPromise,
|
|
663
|
+
modelPromise,
|
|
664
|
+
helpPromise,
|
|
665
|
+
]);
|
|
666
|
+
|
|
667
|
+
let model: ReturnType<typeof parseNavigatorModelSetting>;
|
|
668
|
+
try {
|
|
669
|
+
model = parseNavigatorModelSetting(modelSetting);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
throw navigatorUnavailableError("model", error);
|
|
672
|
+
}
|
|
673
|
+
const helpContext = help.map(({ role, help: text }) => `<role_help role="${role}">\n${text}\n</role_help>`).join("\n");
|
|
674
|
+
let output: PrepareOutput | undefined;
|
|
675
|
+
outputSink = (value) => {
|
|
676
|
+
if (output !== undefined) throw new Error("Navigator preparation must submit exactly one typed candidate batch");
|
|
677
|
+
output = value;
|
|
678
|
+
};
|
|
679
|
+
const tool = createNavigatorPrepareTool((value) => { outputSink?.(value); });
|
|
680
|
+
if (session === undefined) {
|
|
681
|
+
sessionReady = (async () => {
|
|
682
|
+
let created: NavigatorPreparationSession;
|
|
683
|
+
try {
|
|
684
|
+
created = await options.createSession({ context: options.context, sessionDir, ...(options.modelSettingPath === undefined ? {} : { modelSettingPath: options.modelSettingPath }), tool });
|
|
685
|
+
} catch (error) {
|
|
686
|
+
throw navigatorUnavailableError("session", error);
|
|
687
|
+
}
|
|
688
|
+
if (disposed) {
|
|
689
|
+
created.dispose();
|
|
690
|
+
throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
691
|
+
}
|
|
692
|
+
try {
|
|
693
|
+
await created.setModel?.(modelSetting, model.thinkingLevel);
|
|
694
|
+
if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
695
|
+
if (created.getThinkingLevel?.() !== undefined && created.getThinkingLevel() !== model.thinkingLevel) {
|
|
696
|
+
throw new NavigatorUnavailableError("thinking", `Navigator thinking level ${model.thinkingLevel} is unavailable for ${modelSetting}`);
|
|
697
|
+
}
|
|
698
|
+
created.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
|
|
699
|
+
if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
700
|
+
session = created;
|
|
701
|
+
return created;
|
|
702
|
+
} catch (error) {
|
|
703
|
+
if (session !== created) created.dispose();
|
|
704
|
+
throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
|
|
705
|
+
}
|
|
706
|
+
})();
|
|
707
|
+
await sessionReady;
|
|
708
|
+
sessionReady = undefined;
|
|
709
|
+
} else {
|
|
710
|
+
try {
|
|
711
|
+
await session.setModel?.(modelSetting, model.thinkingLevel);
|
|
712
|
+
if (session.getThinkingLevel?.() !== undefined && session.getThinkingLevel() !== model.thinkingLevel) {
|
|
713
|
+
throw new NavigatorUnavailableError("thinking", `Navigator thinking level ${model.thinkingLevel} is unavailable for ${modelSetting}`);
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
// Contract: README.md#Navigator-attendance — resumed-session configuration failures remain typed unavailable and retain the original cause.
|
|
717
|
+
throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("session", error);
|
|
718
|
+
}
|
|
719
|
+
session.appendEntry(INVOCATION_ENTRY, { invocationId, role: options.role, phase: options.phase, subjectKey });
|
|
720
|
+
}
|
|
721
|
+
if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
722
|
+
const activeSession = session;
|
|
723
|
+
if (activeSession === undefined) throw new Error("Navigator session was not created");
|
|
724
|
+
const prior = activeSession.entries().filter((entry): entry is { type: "custom"; customType: string; data?: unknown } => exactRecord(entry) && entry.type === "custom" && entry.customType === ROUTE_ENTRY && exactRecord(entry.data) && entry.data.subjectKey === subjectKey).at(-1)?.data;
|
|
725
|
+
if (exactRecord(prior) && Array.isArray(prior.route) && prior.route.every((target) => targetIsValid(target))) {
|
|
726
|
+
previousRoute = prior.route.map((target) => ({ role: target.role as NavigatorTargetRole, phase: target.phase as NavigatorPhase }));
|
|
727
|
+
}
|
|
728
|
+
const publicSettlementHistory = activeSession.entries()
|
|
729
|
+
.filter((entry): entry is { type: "custom"; customType: string; data?: unknown } => exactRecord(entry) && entry.type === "custom" && entry.customType === SETTLEMENT_ENTRY && exactRecord(entry.data))
|
|
730
|
+
.slice(-8)
|
|
731
|
+
.map((entry) => entry.data as NavigatorSettlementFact);
|
|
732
|
+
const projection: NavigatorContextProjection = {
|
|
733
|
+
subjectKey,
|
|
734
|
+
subject,
|
|
735
|
+
authority,
|
|
736
|
+
currentRole: { role: options.role, phase: options.phase },
|
|
737
|
+
...(boundSettlement === undefined ? {} : { currentSettlement: boundSettlement }),
|
|
738
|
+
priorRoute: exactRecord(prior) && Array.isArray(prior.route) && prior.route.every((target) => targetIsValid(target))
|
|
739
|
+
? prior.route.map((target) => ({ role: target.role as NavigatorTargetRole, phase: target.phase as NavigatorPhase }))
|
|
740
|
+
: null,
|
|
741
|
+
publicSettlementHistory,
|
|
742
|
+
liveRoleHelp: help,
|
|
743
|
+
};
|
|
744
|
+
activeSession.appendEntry(CONTEXT_ENTRY, projection);
|
|
745
|
+
const request = [
|
|
746
|
+
"Act as the Navigator direction advisor. Submit one next-step advice batch; do not execute or invoke any role.",
|
|
747
|
+
`<navigator_soul>\n${soul}\n</navigator_soul>`,
|
|
748
|
+
...(routePlaybookReadFailure === undefined ? [
|
|
749
|
+
`<route_playbook>\n${routePlaybook}\n</route_playbook>`,
|
|
750
|
+
"The route playbook is advisory material only. Exercise independent judgment: adopt, alter, or ignore it; the caller may also deviate.",
|
|
751
|
+
] : ["The optional route playbook could not be read. Continue independent judgment from the other supplied materials."]),
|
|
752
|
+
`<work_subject>\n${subject}\n</work_subject>`,
|
|
753
|
+
`<controlling_authority>\n${authority}\n</controlling_authority>`,
|
|
754
|
+
`<current_role>\n${JSON.stringify({ role: options.role, phase: options.phase })}\n</current_role>`,
|
|
755
|
+
...(boundSettlement === undefined ? [] : [
|
|
756
|
+
`<current_settlement>\n${JSON.stringify(boundSettlement)}\n</current_settlement>`,
|
|
757
|
+
"The current role has just reached this typed settlement. Recommend the next packaged role AFTER this settlement.",
|
|
758
|
+
"public_settlement_history is prior background only — a prior terminal does not consume or replace the work this settlement just produced.",
|
|
759
|
+
]),
|
|
760
|
+
`<prior_route>\n${JSON.stringify(prior ?? null)}\n</prior_route>`,
|
|
761
|
+
`<public_settlement_history>\n${JSON.stringify(projection.publicSettlementHistory)}\n</public_settlement_history>`,
|
|
762
|
+
...(boundSettlement === undefined ? [
|
|
763
|
+
"Preparation is speculative while the current role still runs. Prefer candidates[].matches keyed to plausible accepted outcomes of the current role; prior history must not substitute for the current role's work.",
|
|
764
|
+
] : []),
|
|
765
|
+
`<live_role_help>\n${helpContext}\n</live_role_help>`,
|
|
766
|
+
`Use model setting ${JSON.stringify(modelSetting)} for this call. Return exactly one ${NAVIGATOR_PREPARE_TOOL_NAME} call.`,
|
|
767
|
+
"v1 requires a usable next direction: candidates[].next.role, with phase only when present and meaningful. route, matches, id, reason, and command are optional context — never retry to satisfy optional shape.",
|
|
768
|
+
"Do not put task-specific paths, prompts, packets, or Skill bindings in any field. Command display is rendered by the host from next, not from model prose.",
|
|
769
|
+
].join("\n\n");
|
|
770
|
+
try {
|
|
771
|
+
try {
|
|
772
|
+
if (disposed) throw navigatorUnavailableError("session", new Error("Navigator attendance was disposed"));
|
|
773
|
+
await activeSession.prompt(request);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
throw error instanceof NavigatorUnavailableError ? error : navigatorUnavailableError("transport", error);
|
|
776
|
+
}
|
|
777
|
+
if (output === undefined) {
|
|
778
|
+
const nativeFailure = [...activeSession.entries()].reverse().find((entry: unknown) => {
|
|
779
|
+
if (!exactRecord(entry) || entry.type !== "message" || !exactRecord(entry.message)) return false;
|
|
780
|
+
return entry.message.role === "assistant" && typeof entry.message.errorMessage === "string" && entry.message.errorMessage.trim() !== "";
|
|
781
|
+
});
|
|
782
|
+
const nativeMessage = exactRecord(nativeFailure) && exactRecord(nativeFailure.message) ? nativeFailure.message : undefined;
|
|
783
|
+
const errorMessage = nativeMessage !== undefined && typeof nativeMessage.errorMessage === "string"
|
|
784
|
+
? nativeMessage.errorMessage
|
|
785
|
+
: "Navigator did not submit direction advice";
|
|
786
|
+
// Classification originates only at the native provider stream seam.
|
|
787
|
+
// AssistantMessage metadata is a human diagnostic surface, not an acceptance oracle.
|
|
788
|
+
const providerFailure = activeSession.providerFailure?.();
|
|
789
|
+
const source = providerFailure?.source ?? "unknown";
|
|
790
|
+
const cause = providerFailure?.cause ?? source;
|
|
791
|
+
throw navigatorUnavailableError(source, errorMessage, cause);
|
|
792
|
+
}
|
|
793
|
+
candidates = normalizePrepareOutput(output);
|
|
794
|
+
return candidates;
|
|
795
|
+
} finally {
|
|
796
|
+
outputSink = undefined;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
setWorkContext(next: NavigatorWorkContext): void {
|
|
802
|
+
if (next.subjectKey !== subjectKey && session !== undefined) {
|
|
803
|
+
session.dispose();
|
|
804
|
+
session = undefined;
|
|
805
|
+
previousRoute = undefined;
|
|
806
|
+
}
|
|
807
|
+
subjectKey = next.subjectKey;
|
|
808
|
+
subject = next.subject;
|
|
809
|
+
authority = next.authority;
|
|
810
|
+
contextError = next.contextError;
|
|
811
|
+
sessionDir = options.sessionDirectory?.(next.subjectKey) ?? options.sessionDir;
|
|
812
|
+
},
|
|
813
|
+
/**
|
|
814
|
+
* Start live-help subprocesses during activation without beginning full
|
|
815
|
+
* preparation. Next prepare() consumes the warm result; a later prepare
|
|
816
|
+
* reloads so live help edits remain visible.
|
|
817
|
+
*/
|
|
818
|
+
warmHelp(): void {
|
|
819
|
+
if (disposed || warmedHelp !== undefined || preparation !== undefined) return;
|
|
820
|
+
warmedHelp = loadLiveHelp();
|
|
821
|
+
// Prevent unhandled rejection if attendance is disposed before prepare.
|
|
822
|
+
void warmedHelp.catch(() => undefined);
|
|
823
|
+
},
|
|
824
|
+
prepare(): void {
|
|
825
|
+
if (disposed || preparation !== undefined) return;
|
|
826
|
+
preparationFailure = undefined;
|
|
827
|
+
preparation = prepare();
|
|
828
|
+
// Contract: README.md#Navigator-attendance — background preparation rejection is drained so the later typed settlement can report unavailable; retain the exact cause until settlement.
|
|
829
|
+
void preparation.catch((error) => { preparationFailure = error; });
|
|
830
|
+
},
|
|
831
|
+
isPreparing(): boolean {
|
|
832
|
+
return preparation !== undefined || sessionReady !== undefined;
|
|
833
|
+
},
|
|
834
|
+
knownRoutePlaybookReadFailure(): string | undefined {
|
|
835
|
+
return routePlaybookReadFailure;
|
|
836
|
+
},
|
|
837
|
+
settle(settlement: NavigatorSettlement): Promise<void> {
|
|
838
|
+
const next = settlementTail.then(() => settleOnce(settlement));
|
|
839
|
+
// Contract: README.md#Navigator-attendance — rejected attendance settlements are drained only to serialize later attendance; retain the exact rejection for the caller/audit path.
|
|
840
|
+
settlementTail = next.catch((error) => { settlementFailure = error; });
|
|
841
|
+
return next;
|
|
842
|
+
},
|
|
843
|
+
dispose(): void {
|
|
844
|
+
disposed = true;
|
|
845
|
+
// Leave sessionReady so an in-flight createSession observes disposed and drains exactly once.
|
|
846
|
+
// Late settleOnce completion observes disposed and skips onEvent.
|
|
847
|
+
session?.dispose();
|
|
848
|
+
session = undefined;
|
|
849
|
+
activeInvocationId = undefined;
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
async function settleOnce(settlement: NavigatorSettlement): Promise<void> {
|
|
854
|
+
const invocationId = activeInvocationId ?? invocationPrincipal;
|
|
855
|
+
let report: NavigatorReport;
|
|
856
|
+
if (settlement.kind === "human_decision" || settlement.kind === "role_infrastructure_failure") {
|
|
857
|
+
// Contract: lawful human/role outcomes emit affirmative typed no-advice when
|
|
858
|
+
// preparation completed; rejected preparation remains typed unavailable.
|
|
859
|
+
// Drain the in-flight work so the next driver input cannot start a second prompt on the same native session.
|
|
860
|
+
if (sessionReady !== undefined) {
|
|
861
|
+
try { await sessionReady; } catch (error) { preparationFailure ??= error; }
|
|
862
|
+
}
|
|
863
|
+
if (preparation !== undefined) {
|
|
864
|
+
try { await preparation; } catch (error) { preparationFailure ??= error; }
|
|
865
|
+
}
|
|
866
|
+
session?.appendEntry(SETTLEMENT_ENTRY, { invocationId, subjectKey, role: settlement.role, phase: settlement.phase, kind: settlement.kind, ...(settlement.kind === "human_decision" ? { status: settlement.status } : {}) });
|
|
867
|
+
if (preparationFailure !== undefined) {
|
|
868
|
+
report = unavailable(invocationId, preparationFailure);
|
|
869
|
+
} else {
|
|
870
|
+
// Affirmative no-advice attendance — never inferred later from absence.
|
|
871
|
+
report = { disposition: "no-advice" };
|
|
872
|
+
}
|
|
873
|
+
} else if (settlement.kind === "arrival") {
|
|
874
|
+
// Contract: README.md#Navigator-attendance — failed attendance is reported as typed unavailable rather than silently discarded.
|
|
875
|
+
if (sessionReady !== undefined) {
|
|
876
|
+
try { await sessionReady; } catch (error) { preparationFailure ??= error; }
|
|
877
|
+
}
|
|
878
|
+
if (preparation !== undefined) {
|
|
879
|
+
try { await preparation; } catch (error) { preparationFailure ??= error; }
|
|
880
|
+
}
|
|
881
|
+
report = preparationFailure === undefined
|
|
882
|
+
? { disposition: "arrival", arrivalMessage: settlement.message ?? "已到达目的地" }
|
|
883
|
+
: unavailable(invocationId, preparationFailure);
|
|
884
|
+
} else if (preparation === undefined) {
|
|
885
|
+
report = unavailable(invocationId, "Navigator preparation did not start");
|
|
886
|
+
} else {
|
|
887
|
+
try {
|
|
888
|
+
if (sessionReady !== undefined) await sessionReady;
|
|
889
|
+
let prepared = await preparation;
|
|
890
|
+
session?.appendEntry(SETTLEMENT_ENTRY, { invocationId, subjectKey, role: settlement.role, phase: settlement.phase, kind: settlement.kind, ...(settlement.status === undefined ? {} : { status: settlement.status }) });
|
|
891
|
+
let selected = selectNavigatorCandidate(prepared, settlement);
|
|
892
|
+
// Family #224/#226/#227 shared seam: speculative prepare runs before the
|
|
893
|
+
// current terminal exists and may treat prior history as decisive. When
|
|
894
|
+
// selected next contradicts this accepted settlement, discard it and
|
|
895
|
+
// re-prepare once bound to the settlement (single shared mechanism).
|
|
896
|
+
if (
|
|
897
|
+
selected?.next !== undefined
|
|
898
|
+
&& !navigatorAdviceConsistentWithSettlement(selected.next, settlement)
|
|
899
|
+
) {
|
|
900
|
+
prepareBoundSettlement = settlement;
|
|
901
|
+
prepared = await prepare();
|
|
902
|
+
selected = selectNavigatorCandidate(prepared, settlement);
|
|
903
|
+
if (
|
|
904
|
+
selected?.next !== undefined
|
|
905
|
+
&& !navigatorAdviceConsistentWithSettlement(selected.next, settlement)
|
|
906
|
+
) {
|
|
907
|
+
throw new Error("Navigator advice contradicts the accepted settlement");
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
// Usable model/authority next only — never invent from settlement role/status, prior absence, or prose.
|
|
911
|
+
if (selected?.next === undefined) {
|
|
912
|
+
throw new Error("Navigator prepared no machine-usable next direction");
|
|
913
|
+
}
|
|
914
|
+
const selectedRoute = selected.route;
|
|
915
|
+
const routeChanged = selectedRoute !== undefined && !routeEqual(previousRoute, selectedRoute);
|
|
916
|
+
// Single owner: public registry renderer (ADR 0052). Model command prose is never authority.
|
|
917
|
+
const command = renderPublicAkRoleCommand(selected.next);
|
|
918
|
+
report = {
|
|
919
|
+
disposition: "recommendation",
|
|
920
|
+
...(routeChanged ? { route: selectedRoute } : {}),
|
|
921
|
+
next: selected.next,
|
|
922
|
+
...(selected.reason === undefined ? {} : { reason: oneLine(selected.reason) }),
|
|
923
|
+
...(command === undefined ? {} : { command }),
|
|
924
|
+
};
|
|
925
|
+
if (selectedRoute !== undefined) {
|
|
926
|
+
previousRoute = selectedRoute;
|
|
927
|
+
session?.appendEntry(ROUTE_ENTRY, { invocationId, subjectKey, route: selectedRoute });
|
|
928
|
+
}
|
|
929
|
+
// Contract: README.md#Navigator-attendance — Navigator failures become typed unavailable without invalidating the role Receipt; retain the original cause in the unavailable report.
|
|
930
|
+
} catch (error) {
|
|
931
|
+
report = unavailable(invocationId, error);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// A primary preparation failure may reject Promise.all before the optional
|
|
935
|
+
// routebook read finishes. Preserve that primary unavailable cause while
|
|
936
|
+
// waiting for, and independently attaching, the routebook diagnostic.
|
|
937
|
+
await routePlaybookSettlement;
|
|
938
|
+
if (routePlaybookReadFailure !== undefined) {
|
|
939
|
+
report = { ...report, routePlaybookReadFailure };
|
|
940
|
+
}
|
|
941
|
+
const event: NavigatorEvent = {
|
|
942
|
+
version: 1,
|
|
943
|
+
disposition: report.disposition,
|
|
944
|
+
invocationId,
|
|
945
|
+
role: options.role,
|
|
946
|
+
phase: options.phase,
|
|
947
|
+
subjectKey,
|
|
948
|
+
...(report.route === undefined ? {} : { route: report.route }),
|
|
949
|
+
...(report.next === undefined ? {} : { next: report.next }),
|
|
950
|
+
...(report.reason === undefined ? {} : { reason: report.reason }),
|
|
951
|
+
...(report.command === undefined ? {} : { command: report.command }),
|
|
952
|
+
...(report.unavailableReason === undefined ? {} : { unavailableReason: report.unavailableReason }),
|
|
953
|
+
...(report.unavailableSource === undefined ? {} : { unavailableSource: report.unavailableSource }),
|
|
954
|
+
...(report.unavailableCause === undefined ? {} : { unavailableCause: report.unavailableCause }),
|
|
955
|
+
...(report.routePlaybookReadFailure === undefined ? {} : { routePlaybookReadFailure: report.routePlaybookReadFailure }),
|
|
956
|
+
...(report.arrivalMessage === undefined ? {} : { arrivalMessage: report.arrivalMessage }),
|
|
957
|
+
};
|
|
958
|
+
// Dispose during post-role grace must ignore late completion (ADR 0052 / #106).
|
|
959
|
+
// Every settled disposition (recommendation | no-advice | unavailable | arrival) is affirmative.
|
|
960
|
+
if (!disposed) {
|
|
961
|
+
await options.onEvent(event, report);
|
|
962
|
+
}
|
|
963
|
+
preparation = undefined;
|
|
964
|
+
sessionReady = undefined;
|
|
965
|
+
candidates = undefined;
|
|
966
|
+
preparationFailure = undefined;
|
|
967
|
+
routePlaybookSettlement = undefined;
|
|
968
|
+
routePlaybookReadFailure = undefined;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
export type NavigatorAttendance = ReturnType<typeof createNavigatorAttendance>;
|
|
973
|
+
|
|
974
|
+
export function createNativeNavigatorSessionFactory(defaultModelSettingPath = navigatorModelSettingPath()): NavigatorSessionFactory {
|
|
975
|
+
// Pre-warm once per factory (per process). Concurrent prepares share the same
|
|
976
|
+
// runtime construction rather than each paying ModelRuntime.create under load.
|
|
977
|
+
const sharedModelRuntime = ModelRuntime.create({ allowModelNetwork: false });
|
|
978
|
+
return async ({ context, sessionDir, modelSettingPath, tool }) => {
|
|
979
|
+
let configured: string;
|
|
980
|
+
try {
|
|
981
|
+
configured = await readNavigatorModelSetting(modelSettingPath ?? defaultModelSettingPath);
|
|
982
|
+
} catch (error) {
|
|
983
|
+
throw navigatorUnavailableError("model", error);
|
|
984
|
+
}
|
|
985
|
+
let parsed: ReturnType<typeof parseNavigatorModelSetting>;
|
|
986
|
+
try {
|
|
987
|
+
parsed = parseNavigatorModelSetting(configured);
|
|
988
|
+
} catch (error) {
|
|
989
|
+
throw navigatorUnavailableError("model", error);
|
|
990
|
+
}
|
|
991
|
+
const model = context.modelRegistry.find(parsed.provider, parsed.model);
|
|
992
|
+
const provider = context.modelRegistry.getProvider(parsed.provider);
|
|
993
|
+
if (model === undefined || provider === undefined) throw new NavigatorUnavailableError("model", `Navigator model is unavailable: ${configured}`);
|
|
994
|
+
let auth: Awaited<ReturnType<typeof context.modelRegistry.getApiKeyAndHeaders>>;
|
|
995
|
+
try {
|
|
996
|
+
auth = await context.modelRegistry.getApiKeyAndHeaders(model);
|
|
997
|
+
} catch (error) {
|
|
998
|
+
throw navigatorUnavailableError("auth", error);
|
|
999
|
+
}
|
|
1000
|
+
if (!auth.ok) throw new NavigatorUnavailableError("auth", auth.error);
|
|
1001
|
+
try {
|
|
1002
|
+
const sessionInfo = await stat(sessionDir);
|
|
1003
|
+
if (!sessionInfo.isDirectory()) throw new NavigatorUnavailableError("session", `Navigator session path is not a directory: ${sessionDir}`);
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
if (error instanceof NavigatorUnavailableError) throw error;
|
|
1006
|
+
// Contract: README.md#Navigator-attendance — the resumable session directory may be created by session setup; retain and classify every other filesystem cause.
|
|
1007
|
+
if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) {
|
|
1008
|
+
throw navigatorUnavailableError("session", error);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
let providerFailure: NavigatorProviderFailureFact | undefined;
|
|
1012
|
+
const assignProviderFailure = (fact: NavigatorProviderFailureFact | undefined): void => {
|
|
1013
|
+
if (fact !== undefined) providerFailure = fact;
|
|
1014
|
+
};
|
|
1015
|
+
const classifyProviderStreamError = (error: unknown): void => {
|
|
1016
|
+
if (!exactRecord(error)) {
|
|
1017
|
+
assignProviderFailure(navigatorProviderFailureFromError(error));
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
// Contract-shaped AssistantMessage facts only: diagnostics carry typed transport/auth/quota codes.
|
|
1021
|
+
// Non-contract message metadata (statusCode/code/navigatorFailure) is never an acceptance oracle.
|
|
1022
|
+
assignProviderFailure(navigatorProviderFailureFromDiagnostics(error.diagnostics));
|
|
1023
|
+
if (providerFailure !== undefined) return;
|
|
1024
|
+
// Thrown Error-shaped provider failures may still carry SDK status/code fields.
|
|
1025
|
+
if (error.role !== "assistant") assignProviderFailure(navigatorProviderFailureFromError(error));
|
|
1026
|
+
};
|
|
1027
|
+
const classifyProviderResponseStatus = (status: number): void => {
|
|
1028
|
+
if (status >= 200 && status < 300) {
|
|
1029
|
+
// A later successful attempt clears auth/quota facts recorded from earlier retry responses.
|
|
1030
|
+
if (providerFailure?.source === "auth" || providerFailure?.source === "quota") {
|
|
1031
|
+
providerFailure = undefined;
|
|
1032
|
+
}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
assignProviderFailure(navigatorProviderFailureFromStatus(status));
|
|
1036
|
+
};
|
|
1037
|
+
const humanProviderError = <T extends Record<string, unknown>>(error: T): T => {
|
|
1038
|
+
const human = { ...error };
|
|
1039
|
+
delete human.statusCode;
|
|
1040
|
+
delete human.code;
|
|
1041
|
+
delete human.navigatorFailure;
|
|
1042
|
+
return human;
|
|
1043
|
+
};
|
|
1044
|
+
let providerFailureEvidenceNumber = 0;
|
|
1045
|
+
let providerFailureEvidence: { id: string; error: unknown } | undefined;
|
|
1046
|
+
const retainProviderFailure = (error: unknown): string => {
|
|
1047
|
+
const id = `navigator-provider-failure-${++providerFailureEvidenceNumber}`;
|
|
1048
|
+
providerFailureEvidence = { id, error };
|
|
1049
|
+
return id;
|
|
1050
|
+
};
|
|
1051
|
+
const setupFailureMessage = (error: unknown) => ({
|
|
1052
|
+
role: "assistant" as const,
|
|
1053
|
+
content: [] as [],
|
|
1054
|
+
api: "unknown" as const,
|
|
1055
|
+
provider: "unknown",
|
|
1056
|
+
model: "unknown",
|
|
1057
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
1058
|
+
stopReason: "error" as const,
|
|
1059
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
1060
|
+
navigatorFailureEvidenceId: retainProviderFailure(error),
|
|
1061
|
+
timestamp: Date.now(),
|
|
1062
|
+
});
|
|
1063
|
+
const instrumentProvider = <TProvider extends NonNullable<typeof provider>>(sourceProvider: TProvider): TProvider => {
|
|
1064
|
+
type StreamFn = TProvider["stream"];
|
|
1065
|
+
type StreamSimpleFn = TProvider["streamSimple"];
|
|
1066
|
+
type StreamOptionsArg = Parameters<StreamFn>[2];
|
|
1067
|
+
type StreamSimpleOptionsArg = Parameters<StreamSimpleFn>[2];
|
|
1068
|
+
const instrumentStreamOptions = <TOptions>(options: TOptions): TOptions => {
|
|
1069
|
+
const record = (exactRecord(options) ? options : {}) as Record<string, unknown>;
|
|
1070
|
+
const previous = typeof record.onResponse === "function"
|
|
1071
|
+
? record.onResponse as (response: { status: number; headers: Record<string, string> }, model: unknown) => void | Promise<void>
|
|
1072
|
+
: undefined;
|
|
1073
|
+
return {
|
|
1074
|
+
...record,
|
|
1075
|
+
onResponse: async (response: { status: number; headers: Record<string, string> }, model: unknown) => {
|
|
1076
|
+
classifyProviderResponseStatus(response.status);
|
|
1077
|
+
await previous?.(response, model);
|
|
1078
|
+
},
|
|
1079
|
+
} as TOptions;
|
|
1080
|
+
};
|
|
1081
|
+
const wrapProviderStream = (source: ReturnType<StreamFn>): ReturnType<StreamFn> => {
|
|
1082
|
+
const wrapped = createAssistantMessageEventStream();
|
|
1083
|
+
void (async () => {
|
|
1084
|
+
let result: Awaited<ReturnType<typeof source.result>> | undefined;
|
|
1085
|
+
let sawTerminal = false;
|
|
1086
|
+
try {
|
|
1087
|
+
for await (const event of source) {
|
|
1088
|
+
if (event.type === "done" || event.type === "error") {
|
|
1089
|
+
sawTerminal = true;
|
|
1090
|
+
if (event.type === "done" && exactRecord(event.message)) {
|
|
1091
|
+
assignProviderFailure(navigatorProviderFailureFromDiagnostics(event.message.diagnostics));
|
|
1092
|
+
result = humanProviderError(event.message) as typeof event.message;
|
|
1093
|
+
wrapped.push({ ...event, message: result });
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (event.type === "error" && exactRecord(event.error)) {
|
|
1097
|
+
classifyProviderStreamError(event.error);
|
|
1098
|
+
result = humanProviderError(event.error) as typeof event.error;
|
|
1099
|
+
wrapped.push({ ...event, error: result });
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
wrapped.push(event);
|
|
1104
|
+
}
|
|
1105
|
+
// Only await result after a terminal done/error event. end(undefined) leaves result() unresolved forever.
|
|
1106
|
+
if (sawTerminal) {
|
|
1107
|
+
const terminal = await source.result();
|
|
1108
|
+
if (result === undefined && exactRecord(terminal)) result = humanProviderError(terminal) as typeof terminal;
|
|
1109
|
+
else if (result === undefined) result = terminal;
|
|
1110
|
+
}
|
|
1111
|
+
} catch (error) {
|
|
1112
|
+
// Contract: README.md#Navigator-attendance — synthetic provider errors retain a stable pointer to the raw rejection while exposing only human-safe text.
|
|
1113
|
+
classifyProviderStreamError(error);
|
|
1114
|
+
if (providerFailure === undefined) providerFailure = { source: "transport", cause: "transport" };
|
|
1115
|
+
if (!sawTerminal) {
|
|
1116
|
+
const message = setupFailureMessage(error);
|
|
1117
|
+
wrapped.push({ type: "error", reason: "error", error: message });
|
|
1118
|
+
result = message;
|
|
1119
|
+
sawTerminal = true;
|
|
1120
|
+
}
|
|
1121
|
+
} finally {
|
|
1122
|
+
// No terminal stream event means the provider produced no response.
|
|
1123
|
+
// Emit a synthetic error so callers do not hang waiting for done/error, and never await an unresolved source.result().
|
|
1124
|
+
if (!sawTerminal) {
|
|
1125
|
+
if (providerFailure === undefined) providerFailure = { source: "transport", cause: "transport" };
|
|
1126
|
+
const message = setupFailureMessage(new Error("Navigator provider produced no response"));
|
|
1127
|
+
wrapped.push({ type: "error", reason: "error", error: message });
|
|
1128
|
+
result = message;
|
|
1129
|
+
sawTerminal = true;
|
|
1130
|
+
}
|
|
1131
|
+
wrapped.end(result);
|
|
1132
|
+
}
|
|
1133
|
+
})();
|
|
1134
|
+
return wrapped as ReturnType<StreamFn>;
|
|
1135
|
+
};
|
|
1136
|
+
const invokeInstrumentedStream = (invoke: () => ReturnType<StreamFn>): ReturnType<StreamFn> => {
|
|
1137
|
+
// Reset before invoking the selected provider so setup throws cannot leave a prior call's fact.
|
|
1138
|
+
providerFailure = undefined;
|
|
1139
|
+
try {
|
|
1140
|
+
return wrapProviderStream(invoke());
|
|
1141
|
+
} catch (error) {
|
|
1142
|
+
// Contract: README.md#Navigator-attendance — setup failures become terminal synthetic errors, with the raw rejection retained by stable evidence pointer.
|
|
1143
|
+
classifyProviderStreamError(error);
|
|
1144
|
+
if (providerFailure === undefined) providerFailure = { source: "transport", cause: "transport" };
|
|
1145
|
+
const wrapped = createAssistantMessageEventStream();
|
|
1146
|
+
const message = setupFailureMessage(error);
|
|
1147
|
+
queueMicrotask(() => {
|
|
1148
|
+
wrapped.push({ type: "error", reason: "error", error: message });
|
|
1149
|
+
wrapped.end(message);
|
|
1150
|
+
});
|
|
1151
|
+
return wrapped as ReturnType<StreamFn>;
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
return {
|
|
1155
|
+
...sourceProvider,
|
|
1156
|
+
stream(model: Parameters<StreamFn>[0], streamContext: Parameters<StreamFn>[1], options: StreamOptionsArg) {
|
|
1157
|
+
const instrumented = instrumentStreamOptions(options);
|
|
1158
|
+
return invokeInstrumentedStream(() => sourceProvider.stream(model, streamContext, instrumented) as ReturnType<StreamFn>) as ReturnType<StreamFn>;
|
|
1159
|
+
},
|
|
1160
|
+
streamSimple(model: Parameters<StreamSimpleFn>[0], streamContext: Parameters<StreamSimpleFn>[1], options: StreamSimpleOptionsArg) {
|
|
1161
|
+
const instrumented = instrumentStreamOptions(options);
|
|
1162
|
+
return invokeInstrumentedStream(() => sourceProvider.streamSimple(model, streamContext, instrumented) as ReturnType<StreamFn>) as ReturnType<StreamSimpleFn>;
|
|
1163
|
+
},
|
|
1164
|
+
} as TProvider;
|
|
1165
|
+
};
|
|
1166
|
+
let modelRuntime: Awaited<ReturnType<typeof ModelRuntime.create>>;
|
|
1167
|
+
try {
|
|
1168
|
+
modelRuntime = await sharedModelRuntime;
|
|
1169
|
+
modelRuntime.registerNativeProvider(instrumentProvider(provider));
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
throw navigatorUnavailableError("session", error);
|
|
1172
|
+
}
|
|
1173
|
+
let opened: Awaited<ReturnType<typeof openInProcessAgentSession>>;
|
|
1174
|
+
try {
|
|
1175
|
+
// Shared in-process session open (#233) — same ModelRuntime module instance.
|
|
1176
|
+
opened = await openInProcessAgentSession({
|
|
1177
|
+
cwd: context.cwd,
|
|
1178
|
+
model,
|
|
1179
|
+
modelRuntime,
|
|
1180
|
+
thinkingLevel: parsed.thinkingLevel,
|
|
1181
|
+
sessionManager: SessionManager.continueRecent(context.cwd, sessionDir),
|
|
1182
|
+
noTools: "all",
|
|
1183
|
+
tools: [NAVIGATOR_PREPARE_TOOL_NAME],
|
|
1184
|
+
customTools: [tool],
|
|
1185
|
+
});
|
|
1186
|
+
} catch (error) {
|
|
1187
|
+
throw navigatorUnavailableError("session", error);
|
|
1188
|
+
}
|
|
1189
|
+
if (opened.session.thinkingLevel !== parsed.thinkingLevel) {
|
|
1190
|
+
opened.dispose();
|
|
1191
|
+
throw new NavigatorUnavailableError("thinking", `Navigator thinking level ${parsed.thinkingLevel} is unavailable for ${configured}`);
|
|
1192
|
+
}
|
|
1193
|
+
return {
|
|
1194
|
+
prompt: async (text) => {
|
|
1195
|
+
try {
|
|
1196
|
+
await opened.session.prompt(text);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
throw navigatorUnavailableError("transport", error);
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
providerFailure: () => providerFailure,
|
|
1202
|
+
appendEntry: (customType, data) => { opened.session.sessionManager.appendCustomEntry(customType, data); },
|
|
1203
|
+
entries: () => opened.session.sessionManager.getEntries(),
|
|
1204
|
+
setModel: async (next, thinkingLevel) => {
|
|
1205
|
+
let nextParsed: ReturnType<typeof parseNavigatorModelSetting>;
|
|
1206
|
+
try {
|
|
1207
|
+
nextParsed = parseNavigatorModelSetting(next);
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
throw navigatorUnavailableError("model", error);
|
|
1210
|
+
}
|
|
1211
|
+
const nextModel = context.modelRegistry.find(nextParsed.provider, nextParsed.model);
|
|
1212
|
+
const nextProvider = context.modelRegistry.getProvider(nextParsed.provider);
|
|
1213
|
+
if (nextModel === undefined || nextProvider === undefined) throw new NavigatorUnavailableError("model", `Navigator model is unavailable: ${next}`);
|
|
1214
|
+
let nextAuth: Awaited<ReturnType<typeof context.modelRegistry.getApiKeyAndHeaders>>;
|
|
1215
|
+
try {
|
|
1216
|
+
nextAuth = await context.modelRegistry.getApiKeyAndHeaders(nextModel);
|
|
1217
|
+
} catch (error) {
|
|
1218
|
+
throw navigatorUnavailableError("auth", error);
|
|
1219
|
+
}
|
|
1220
|
+
if (!nextAuth.ok) throw new NavigatorUnavailableError("auth", nextAuth.error);
|
|
1221
|
+
try {
|
|
1222
|
+
// setModel replaces the registered provider id; keep the stream seam instrumented.
|
|
1223
|
+
modelRuntime.registerNativeProvider(instrumentProvider(nextProvider));
|
|
1224
|
+
await opened.session.setModel(nextModel);
|
|
1225
|
+
opened.session.setThinkingLevel(thinkingLevel);
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
throw navigatorUnavailableError("session", error);
|
|
1228
|
+
}
|
|
1229
|
+
if (opened.session.thinkingLevel !== nextParsed.thinkingLevel || opened.session.thinkingLevel !== thinkingLevel) {
|
|
1230
|
+
throw new NavigatorUnavailableError("thinking", `Navigator thinking level ${thinkingLevel} is unavailable for ${next}`);
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
getThinkingLevel: () => opened.session.thinkingLevel,
|
|
1234
|
+
dispose: () => opened.dispose(),
|
|
1235
|
+
};
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
export function registerNavigatorModelCommand(pi: ExtensionAPI, path = navigatorModelSettingPath()): void {
|
|
1240
|
+
pi.registerCommand("navigator-model", {
|
|
1241
|
+
description: "Set the persistent Navigator model (provider/model[:max]).",
|
|
1242
|
+
handler: async (args) => {
|
|
1243
|
+
await writeNavigatorModelSetting(args.trim(), path);
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
export function navigatorSessionDirectory(context: ExtensionContext, subjectKey?: string): string {
|
|
1249
|
+
const current = context.sessionManager.getSessionDir();
|
|
1250
|
+
const key = subjectKey ?? subjectPath(current, context.cwd);
|
|
1251
|
+
return subjectDirectory(context.cwd, key);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
export { subjectPath };
|