diff --git a/docs/architecture.md b/docs/architecture.md index 51230e7..34f11ce 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ or resume, it belongs in Pi/pi-subagents or the provider layer rather than Forge ## Model policy boundary -ForgeFlow registers stable logical roles as Pi virtual models. Their physical model mapping is read from operator policy rather than hard-coded into workflows or subagent definitions. New user/direct requests resolve the current mapping, while continuation/retry requests stay on the physical model already handling the turn to preserve prompt-cache and reasoning-signature continuity. +ForgeFlow registers stable logical roles as Pi virtual models. Their physical model mapping is read from operator policy rather than hard-coded into workflows or subagent definitions. For native Pi children, the parent session registers a required child-only extension that installs only those virtual-model definitions before child model resolution; it does not duplicate ForgeFlow's prompt policy or workflow resources in the child. New user/direct requests resolve the current mapping, while continuation/retry requests stay on the physical model already handling the turn to preserve prompt-cache and reasoning-signature continuity. Project-local `.pi/forgeflow-models.json` is considered only when Pi reports the project trusted. User-level policy under `~/.pi/forgeflow-models.json` remains available for untrusted projects. `FORGEFLOW_MODEL_POLICY` is an explicit operator override. diff --git a/docs/model-policy.md b/docs/model-policy.md index dc8e0b1..dfc9282 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -47,7 +47,7 @@ The pinned `pi-subagents@0.75.0` includes the child-runtime fixes required for P The parent Pi session can select `forgeflow/planner` when the parent is acting as the planning/orchestration role. ForgeFlow's trusted `forgeflow.review` and `forgeflow.accept` workflows explicitly request `forgeflow/reviewer`, so the workflow owns the stable reviewer role while operator policy remains free to change the physical reviewer model without editing workflow code. -`pi-subagents@0.75.0` contains the upstream child-runtime fixes for queued virtual-model registration and logical-selection verification (#2636 and #2638). ForgeFlow therefore does not carry a compatibility shim for virtual child models. +`pi-subagents@0.75.0` contains the upstream child-runtime fixes for queued virtual-model registration and logical-selection verification (#2636 and #2638). ForgeFlow additionally registers a required child-only extension that does nothing except register the five `forgeflow/*` virtual models. Native Pi children therefore receive the logical model registry before model resolution without loading a second copy of ForgeFlow's prompt policy or trusted workflow resources. ForgeFlow does not carry a routing shim for virtual child models. ## Policy file diff --git a/extension/child-model-policy.js b/extension/child-model-policy.js new file mode 100644 index 0000000..fe36572 --- /dev/null +++ b/extension/child-model-policy.js @@ -0,0 +1,5 @@ +import { registerForgeFlowVirtualModels } from "./model-policy.js"; + +export default function registerForgeFlowChildModelPolicy(pi) { + registerForgeFlowVirtualModels(pi); +} diff --git a/extension/index.js b/extension/index.js index 52529f7..ea5e55d 100644 --- a/extension/index.js +++ b/extension/index.js @@ -1,9 +1,16 @@ +import { fileURLToPath } from "node:url"; + +import { registerRequiredChildExtensions } from "pi-subagents/required-child-extensions"; import { registerWorkflowResource } from "pi-subagents/workflow-resources"; import { renderPreflight } from "./invariants.js"; import { createAcceptanceWorkflowDefinition } from "./acceptance-workflow.js"; import { createReviewWorkflowDefinition } from "./review-workflow.js"; import { registerForgeFlowVirtualModels } from "./model-policy.js"; +const CHILD_MODEL_POLICY_EXTENSION = fileURLToPath( + new URL("./child-model-policy.js", import.meta.url) +); + const CORE_POLICY = [ "ForgeFlow is a thin engineering-governance extension for Pi; Pi and pi-subagents own agent execution, sessions, delegation, worktrees, missions, schedules, and resume.", "ForgeFlow may define stable logical model roles, but Pi owns virtual-model dispatch and the provider layer owns channel, credential, quota, and transport routing.", @@ -22,6 +29,7 @@ export default function registerForgeFlow(pi) { let reviewRegistration; let acceptanceRegistration; + let childModelPolicyRegistration; pi.on("before_agent_start", (event) => { event.systemPromptOptions.sections.forgeflow_policy = buildForgeFlowPromptSection(event.prompt); @@ -30,6 +38,7 @@ export default function registerForgeFlow(pi) { pi.on("session_start", (_event, ctx) => { reviewRegistration?.dispose(); acceptanceRegistration?.dispose(); + childModelPolicyRegistration?.dispose(); const sessionId = ctx.sessionManager.getSessionId(); const sessionCwd = ctx.cwd; @@ -41,12 +50,23 @@ export default function registerForgeFlow(pi) { sessionId, definition: createAcceptanceWorkflowDefinition(sessionCwd) }); + childModelPolicyRegistration = registerRequiredChildExtensions({ + sessionId, + extensions: [ + { + id: "forgeflow-model-policy", + path: CHILD_MODEL_POLICY_EXTENSION + } + ] + }); }); pi.on("session_shutdown", () => { reviewRegistration?.dispose(); acceptanceRegistration?.dispose(); + childModelPolicyRegistration?.dispose(); reviewRegistration = undefined; acceptanceRegistration = undefined; + childModelPolicyRegistration = undefined; }); } diff --git a/package.json b/package.json index 3dbdd33..08c70bb 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,6 @@ }, "scripts": { "test": "node --test tests/*.test.js", - "check": "npm test && node --check extension/index.js && node --check extension/model-policy.js && node --check extension/invariants.js && node --check extension/review-workflow.js && node --check extension/review-verdict.js && node --check extension/github-acceptance.js && node --check extension/acceptance-workflow.js && node --check scripts/review-verdict.mjs && node --check scripts/github-acceptance.mjs" + "check": "npm test && node --check extension/index.js && node --check extension/child-model-policy.js && node --check extension/model-policy.js && node --check extension/invariants.js && node --check extension/review-workflow.js && node --check extension/review-verdict.js && node --check extension/github-acceptance.js && node --check extension/acceptance-workflow.js && node --check scripts/review-verdict.mjs && node --check scripts/github-acceptance.mjs" } } diff --git a/tests/extension.test.js b/tests/extension.test.js index a2736fe..5edc023 100644 --- a/tests/extension.test.js +++ b/tests/extension.test.js @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; import test from "node:test"; +import { registerRequiredChildExtensions } from "pi-subagents/required-child-extensions"; +import registerForgeFlowChildModelPolicy from "../extension/child-model-policy.js"; import registerForgeFlow from "../extension/index.js"; test("extension registers Pi lifecycle hooks and injects policy without a model call", () => { @@ -40,14 +43,58 @@ test("extension registers Pi lifecycle hooks and injects policy without a model assert.match(event.systemPromptOptions.sections.forgeflow_policy, /INV-CUTOVER-001/); assert.match(event.systemPromptOptions.sections.forgeflow_policy, /INV-REPLAY-001/); + const sessionId = "forgeflow-pi-native-test"; assert.doesNotThrow(() => { handlers.get("session_start")( {}, { cwd: process.cwd(), - sessionManager: { getSessionId: () => "forgeflow-pi-native-test" } + sessionManager: { getSessionId: () => sessionId } } ); }); + + const childExtensionPath = fileURLToPath( + new URL("../extension/child-model-policy.js", import.meta.url) + ); + assert.throws( + () => registerRequiredChildExtensions({ + sessionId, + extensions: [{ id: "duplicate-probe", path: childExtensionPath }] + }), + /already registered/ + ); + assert.doesNotThrow(() => handlers.get("session_shutdown")()); + + const afterShutdown = registerRequiredChildExtensions({ + sessionId, + extensions: [{ id: "post-shutdown-probe", path: childExtensionPath }] + }); + afterShutdown.dispose(); +}); + +test("child-only model policy extension registers only ForgeFlow virtual models", () => { + const registrations = []; + const handlers = []; + registerForgeFlowChildModelPolicy({ + registerVirtualModel(definition) { + registrations.push(definition); + }, + on(...args) { + handlers.push(args); + } + }); + + assert.deepEqual( + registrations.map(({ provider, id }) => `${provider}/${id}`), + [ + "forgeflow/planner", + "forgeflow/worker", + "forgeflow/reviewer", + "forgeflow/scout", + "forgeflow/oracle" + ] + ); + assert.deepEqual(handlers, []); });