gentle-pi 2.6.0 → 2.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +183 -943
  2. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +9 -8
  3. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +3 -3
  4. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +7 -2
  5. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/lens.schema.json +2 -2
  6. package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -1
  7. package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-capabilities.baseline.json +1 -1
  8. package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-roles.baseline.json +2 -2
  9. package/contracts/telemetry/runtime-aggregate-v1.schema.json +8 -6
  10. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  11. package/docs/assets/brand/gentle-pi-banner.svg +33 -0
  12. package/docs/assets/brand/terminal-divider.svg +17 -0
  13. package/docs/assets/diagrams/agent-orchestration.svg +19 -0
  14. package/docs/assets/diagrams/gentleman-workflow.svg +15 -0
  15. package/docs/assets/diagrams/native-review.svg +16 -0
  16. package/docs/assets/diagrams/sdd-cycle.svg +14 -0
  17. package/docs/assets/features/gentle-shell.png +0 -0
  18. package/docs/gentle-shell.md +151 -0
  19. package/docs/readme-reference.md +868 -0
  20. package/extensions/gentle-agents.ts +4 -5
  21. package/extensions/gentle-ai.ts +65 -22
  22. package/extensions/runtime-metrics.ts +10 -19
  23. package/lib/agents-history.ts +7 -1
  24. package/lib/native-review-cli.ts +14 -0
  25. package/lib/runtime-metrics-children.ts +3 -5
  26. package/lib/runtime-metrics-native.ts +17 -9
  27. package/lib/runtime-metrics.ts +95 -42
  28. package/package.json +1 -1
  29. package/runtime/native-review-cli.mjs +14 -0
  30. package/scripts/gentle-ai-installer.mjs +10 -10
  31. package/scripts/types-baseline.json +1 -5
  32. package/scripts/verify-package-files.mjs +3 -3
  33. package/tests/fixtures/runtime-metrics-native-batches.json +1 -1
  34. package/tests/gentle-agents.test.ts +22 -6
  35. package/tests/gentle-ai-binary.test.ts +1 -1
  36. package/tests/gentle-ai-installer.test.ts +47 -47
  37. package/tests/gentle-ai.test.ts +3 -2
  38. package/tests/native-review-capability-contract.test.ts +24 -1
  39. package/tests/package-manifest.test.ts +19 -18
  40. package/tests/review-authority-recovery-docs.test.ts +13 -13
  41. package/tests/review-controller-native-routing.test.ts +49 -0
  42. package/tests/review-ledger-contract.test.ts +7 -5
  43. package/tests/runtime-metrics-children.test.ts +4 -3
  44. package/tests/runtime-metrics-extension.test.ts +40 -31
  45. package/tests/runtime-metrics-model.test.ts +76 -0
  46. package/tests/runtime-metrics-native.test.ts +73 -7
  47. package/tests/runtime-metrics.test.ts +22 -19
  48. package/tests/sdd-managed-runtime-settlement.test.ts +37 -0
  49. package/tests/sdd-selection-transport.test.ts +57 -0
  50. package/tests/skill-collision-prefixes.test.ts +2 -2
  51. package/lib/runtime-metrics-pi-identity.ts +0 -113
  52. package/tests/runtime-metrics-pi-identity.test.ts +0 -113
@@ -101,13 +101,14 @@ function readPackageJson(): PackageJson {
101
101
  }
102
102
  }
103
103
 
104
- test("package declares the tested Pi minimum required for agent_settled", () => {
104
+ test("technical reference declares the tested Pi minimum required for agent_settled", () => {
105
105
  const manifest = readPackageJson();
106
106
  assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.85.1");
107
107
  assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], "0.85.1");
108
- const readme = readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8");
109
- assert.match(readme, /Pi 0\.85\.1 or newer/);
110
- assert.match(readme, /agent_settled/);
108
+ const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
109
+ assert.match(reference, /Pi 0\.85\.1 or newer/);
110
+ assert.match(reference, /agent_settled/);
111
+ assert.match(readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8"), /\]\(docs\/readme-reference\.md(?:#[^)]+)?\)/);
111
112
  });
112
113
 
113
114
  test("package manifest has no obsolete native activation build surface", () => {
@@ -229,12 +230,12 @@ test("generated runtime modules and packed-package checks are deterministic", ()
229
230
  test("package manifest ships and runs the checked-in package-local Gentle AI installer", () => {
230
231
  const packageJson = readPackageJson();
231
232
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
232
- const readme = readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8");
233
+ const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
233
234
 
234
235
  assert.equal(packageJson.scripts?.postinstall, "node scripts/install-gentle-ai.mjs");
235
- assert.match(readme, /run `node scripts\/install-gentle-ai\.mjs`/, "missing-binary recovery documentation must use the package postinstall entrypoint");
236
- assert.match(readme, /installed `gentle-pi` package directory/, "recovery documentation must name the package working directory");
237
- assert.match(readme, /if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before/i, "recovery documentation must prevent the installer skip from repeating");
236
+ assert.match(reference, /run `node scripts\/install-gentle-ai\.mjs`/, "missing-binary recovery documentation must use the package postinstall entrypoint");
237
+ assert.match(reference, /installed `gentle-pi` package directory/, "recovery documentation must name the package working directory");
238
+ assert.match(reference, /if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before/i, "recovery documentation must prevent the installer skip from repeating");
238
239
  assert.ok(packageJson.files?.includes("scripts/"));
239
240
  assert.match(verifier, /"scripts\/install-gentle-ai\.mjs"/);
240
241
  assert.match(verifier, /"scripts\/gentle-ai-installer\.mjs"/);
@@ -277,20 +278,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
277
278
  );
278
279
  });
279
280
 
280
- test("package verification binds the published Gentle AI v2.8.0 runtime pin", () => {
281
+ test("package verification binds the published Gentle AI v2.8.2 runtime pin", () => {
281
282
  const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
282
283
  const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
283
284
  const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
284
285
 
285
- assert.match(installer, /INSTALLER_VERSION = "2\.8\.0"/);
286
+ assert.match(installer, /INSTALLER_VERSION = "2\.8\.2"/);
286
287
  assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
287
- assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:QNdrYDxq\/Bi08o9PCk0jc4r5YObLdFmRCLXjKMqsze8="/);
288
+ assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:54VJ0ruRiPc6MbNw4iFmlcBkQFOyF5D8iHY0A54Ivug="/);
288
289
  assert.match(installer, /GOTOOLCHAIN: "local"/);
289
290
  assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
290
291
  assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
291
292
  assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
292
293
  assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
293
- assert.match(verifier, /v2\.8\.0/);
294
+ assert.match(verifier, /v2\.8\.2/);
294
295
  });
295
296
 
296
297
 
@@ -1508,9 +1509,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
1508
1509
  assert.match(wrapper, /quietToolsEnabled/);
1509
1510
  });
1510
1511
 
1511
- test("v2.6.0 release package and runtime stop before publication", () => {
1512
+ test("v2.6.2 release package and runtime stop before publication", () => {
1512
1513
  const packageJson = readPackageJson();
1513
- assert.equal(packageJson.version, "2.6.0", "the release manifest must remain explicitly pinned to v2.6.0");
1514
+ assert.equal(packageJson.version, "2.6.2", "the release manifest must remain explicitly pinned to v2.6.2");
1514
1515
  assert.equal(
1515
1516
  packageJson.scripts?.test,
1516
1517
  "node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
@@ -1540,8 +1541,8 @@ test("bounded review keeps the Judgment Day skill contract at canon metadata ver
1540
1541
  assert.doesNotMatch(frontmatter, /^ version: "1\.4"$/m);
1541
1542
  });
1542
1543
 
1543
- test("README documents dynamic Gentle AI RDD ownership and the installed permission boundary", () => {
1544
- const readme = readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8");
1544
+ test("technical reference documents dynamic Gentle AI RDD ownership and the installed permission boundary", () => {
1545
+ const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
1545
1546
  for (const clause of [
1546
1547
  "Gentle AI dynamically supplies runtime-specific RDD instructions",
1547
1548
  "does not define an RDD lifecycle",
@@ -1549,9 +1550,9 @@ test("README documents dynamic Gentle AI RDD ownership and the installed permiss
1549
1550
  "package-managed isolated installation",
1550
1551
  "Project and user overrides may shadow a package asset",
1551
1552
  ]) {
1552
- assert.ok(readme.includes(clause), `README missing dynamic RDD clause: ${clause}`);
1553
+ assert.ok(reference.includes(clause), `technical reference missing dynamic RDD clause: ${clause}`);
1553
1554
  }
1554
- assert.doesNotMatch(readme, /New ordinary review uses compact `gentle_review` `start -> finalize -> validate`\./);
1555
+ assert.doesNotMatch(reference, /New ordinary review uses compact `gentle_review` `start -> finalize -> validate`\./);
1555
1556
  });
1556
1557
 
1557
1558
 
@@ -2,21 +2,21 @@ import assert from "node:assert/strict";
2
2
  import { readFileSync } from "node:fs";
3
3
  import test from "node:test";
4
4
 
5
- const README = readFileSync("README.md", "utf8");
5
+ const TECHNICAL_REFERENCE = readFileSync("docs/readme-reference.md", "utf8");
6
6
  const CONTROLLER = readFileSync("extensions/gentle-ai.ts", "utf8");
7
7
 
8
- test("recovery guidance documents the narrow published native maintenance contract", () => {
9
- assert.match(README, /abandon.*quarantine-legacy.*reconcile-authority.*explicit v2\.1\.11 maintenance/i);
10
- assert.match(README, /predecessor lineage and revision.*successor lineage and revision/i);
11
- assert.match(README, /exact seven-line.*anomalies=unchanged_target,malformed_recovery_authorization/i);
12
- assert.match(README, /fresh interactive approval/i);
13
- assert.match(README, /quarantine only the bound invalid compact-v2 recovery successor/i);
14
- assert.match(README, /predecessor stays untouched/i);
15
- assert.match(README, /repair-legacy-alias.*unsupported historical v1 operation alias/i);
16
- assert.match(README, /model supplies only lineage, actor, and reason/i);
17
- assert.match(README, /review dispose-result.*unsupported.*pending.*design/i);
18
- assert.match(README, /RESET.*RECOVER.*destructive/i);
19
- assert.match(README, /typed envelopes/i);
8
+ test("technical reference documents the narrow published native maintenance contract", () => {
9
+ assert.match(TECHNICAL_REFERENCE, /abandon.*quarantine-legacy.*reconcile-authority.*explicit v2\.1\.11 maintenance/i);
10
+ assert.match(TECHNICAL_REFERENCE, /predecessor lineage and revision.*successor lineage and revision/i);
11
+ assert.match(TECHNICAL_REFERENCE, /exact seven-line.*anomalies=unchanged_target,malformed_recovery_authorization/i);
12
+ assert.match(TECHNICAL_REFERENCE, /fresh interactive approval/i);
13
+ assert.match(TECHNICAL_REFERENCE, /quarantine only the bound invalid compact-v2 recovery successor/i);
14
+ assert.match(TECHNICAL_REFERENCE, /predecessor stays untouched/i);
15
+ assert.match(TECHNICAL_REFERENCE, /repair-legacy-alias.*unsupported historical v1 operation alias/i);
16
+ assert.match(TECHNICAL_REFERENCE, /model supplies only lineage, actor, and reason/i);
17
+ assert.match(TECHNICAL_REFERENCE, /review dispose-result.*unsupported.*pending.*design/i);
18
+ assert.match(TECHNICAL_REFERENCE, /RESET.*RECOVER.*destructive/i);
19
+ assert.match(TECHNICAL_REFERENCE, /typed envelopes/i);
20
20
  });
21
21
 
22
22
  test("controller help keeps authorization, blocked outcomes, and recovery boundaries explicit", () => {
@@ -298,6 +298,55 @@ test("approved acknowledgement burn tears down the retained candidate view and k
298
298
  assert.ok(registry.hasProjection(lineageId, contributorRoot), "terminal approved cleanup keeps the lineage projection");
299
299
  });
300
300
 
301
+ // gentle-ai#4003: the native burn is the committed authority outcome. A Pi-side
302
+ // candidate-view teardown failure after it must never be reported as a failed
303
+ // acknowledgement, or the caller cannot tell that authority was consumed.
304
+ test("approved acknowledgement reports the burn truthfully when candidate-view cleanup fails after it", async (t) => {
305
+ const lineageId = "acknowledge-approved-deferred-cleanup";
306
+ const contributorRoot = candidateRepository(t);
307
+ writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
308
+ let failWorktreeRemove = true;
309
+ const registry = new CandidateViewRegistry((file, arguments_, options) => {
310
+ if (failWorktreeRemove && arguments_[0] === "worktree" && arguments_[1] === "remove") {
311
+ failWorktreeRemove = false;
312
+ throw Object.assign(new Error("simulated worktree removal failure"), { status: 128 });
313
+ }
314
+ return execFileSync(file, arguments_, options);
315
+ });
316
+ t.after(() => registry.cleanupAll());
317
+ const view = registry.create({ contributorRoot });
318
+ registry.retain(view.token, lineageId);
319
+ const statusRequests: unknown[] = [];
320
+ let acknowledgements = 0;
321
+ const native = {
322
+ targetStatus: async (request: unknown) => { statusRequests.push(request); return approvedAcknowledgementStatus(lineageId, contributorRoot); },
323
+ acknowledgeApproved: async () => { acknowledgements += 1; },
324
+ } as unknown as NativeReviewCli;
325
+
326
+ const completed = await __testing.executeReviewControllerOperation({ operation: "acknowledge-approved", lineageId }, contributorRoot, native, undefined, registry);
327
+
328
+ assert.equal(acknowledgements, 1, "exactly one native burn");
329
+ assert.equal(statusRequests.length, 1, "no STATUS reconciliation after a cleanup-only failure");
330
+ assert.equal(completed.status, "closed");
331
+ assert.equal(completed.outcome, "native-approved-acknowledgement-completed");
332
+ assert.equal(completed.authority, "burned");
333
+ assert.equal(completed.mutation_performed, true);
334
+ assert.equal(completed.mutation_outcome, "committed");
335
+ assert.deepEqual(completed.candidate_view_cleanup, {
336
+ status: "deferred",
337
+ diagnostics: { code: "candidate-view-git-failure", message: "candidate-view Git command worktree failed; inspect the candidate state before any new START" },
338
+ next_action: "retry-candidate-view-cleanup-or-remove-the-view-out-of-band",
339
+ });
340
+ assert.ok(existsSync(view.root), "a failed teardown preserves the candidate view");
341
+ assert.ok(registry.hasProjection(lineageId, contributorRoot), "the lineage projection survives the deferred cleanup");
342
+
343
+ // The residue stays recoverable: the registry still owns the view, so a
344
+ // later terminal cleanup removes it without replaying the burn.
345
+ registry.cleanupTerminal(lineageId, "approved", contributorRoot);
346
+ assert.equal(existsSync(view.root), false);
347
+ assert.equal(acknowledgements, 1);
348
+ });
349
+
301
350
  test("ambiguous acknowledgement reconciles STATUS once without replaying the provider vector", async () => {
302
351
  const lineageId = "ambiguous-acknowledgement";
303
352
  let statusCalls = 0;
@@ -19,6 +19,7 @@ const JD_SKILL = "skills/judgment-day/SKILL.md";
19
19
  const JD_PROMPTS = "skills/judgment-day/references/prompts-and-formats.md";
20
20
  const GENTLE_SKILL = "skills/gentle-ai/SKILL.md";
21
21
  const README = "README.md";
22
+ const TECHNICAL_REFERENCE = "docs/readme-reference.md";
22
23
  const CHAIN = "assets/chains/4r-review.chain.md";
23
24
  const SDD_WORKFLOW = "assets/sdd-orchestrator-workflow.md";
24
25
  const RELEASE_SKILL = "skills/release/SKILL.md";
@@ -132,7 +133,8 @@ test("canonical contract defines compact risk, causal admission, correction, CAS
132
133
  /untrusted repository content.*malformed inputs.*stale authority.*path drift.*external callers/i,
133
134
  ...JUDGMENT_DAY_DISCOVERY_PATTERNS,
134
135
  ]);
135
- assert.match(read(README), /Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy\./);
136
+ assert.match(read(TECHNICAL_REFERENCE), /Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy\./);
137
+ assert.match(read(README), /\]\(docs\/readme-reference\.md(?:#[^)]+)?\)/);
136
138
  assert.doesNotMatch(read(README), /one one-shot authorization for the exact command/i);
137
139
  assert.doesNotMatch(read(README), /review-publication-gate/i);
138
140
  });
@@ -365,14 +367,14 @@ test("Judgment Day fix routing has one canonical shape and never falls back to g
365
367
  assert.match(read(SDD_WORKFLOW), /\| default\s+\| balanced\s+\| SDD phase fallback; never a Judgment Day role\s+\|/);
366
368
  });
367
369
 
368
- test("orchestrator, injected skill, and README defer RDD lifecycle ownership to Gentle AI", () => {
370
+ test("orchestrator, injected skill, and technical reference defer RDD lifecycle ownership to Gentle AI", () => {
369
371
  const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
370
372
  const orchestrator = union(ORCHESTRATOR);
371
373
  assert.ok(orchestrator.includes(boundary), "orchestrator must carry the sole static ownership boundary");
372
374
 
373
375
  for (const [label, content] of [
374
376
  [GENTLE_SKILL, read(GENTLE_SKILL)],
375
- [README, read(README)],
377
+ [TECHNICAL_REFERENCE, read(TECHNICAL_REFERENCE)],
376
378
  ] as const) {
377
379
  assertMatches(label, content, [
378
380
  /Gentle AI dynamically supplies runtime-specific RDD instructions/i,
@@ -388,8 +390,8 @@ test("orchestrator, injected skill, and README defer RDD lifecycle ownership to
388
390
  }
389
391
  });
390
392
 
391
- test("README documents the dynamic runtime authority boundary without an old package route", () => {
392
- const content = read(README);
393
+ test("technical reference documents the dynamic runtime authority boundary without an old package route", () => {
394
+ const content = read(TECHNICAL_REFERENCE);
393
395
  assert.match(content, /Gentle AI dynamically supplies runtime-specific RDD instructions/i);
394
396
  assert.match(content, /does not define an RDD lifecycle/i);
395
397
  assert.doesNotMatch(content, /New ordinary review uses compact `gentle_review` `start -> finalize -> validate`\./);
@@ -6,11 +6,9 @@ import { runInNewContext } from "node:vm";
6
6
  import { parseAgentClass } from "../lib/runtime-metrics.ts";
7
7
  import { parseAgentDefinition, type AgentDefinition } from "../lib/agents-config.ts";
8
8
  import { normalizeRpcEvent, TASK_EVENT } from "../lib/agents-protocol.ts";
9
- import { lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
10
9
  import { ChildComposition, childEvent, classifyBuiltinAgent, launchSelection } from "../lib/runtime-metrics-children.ts";
11
10
  import { encodeNativeRuntimeEvent } from "../lib/runtime-metrics-native.ts";
12
11
 
13
- await lookupPiCatalogName({ provider: "openai", modelId: "gpt-4o" });
14
12
  const asset = new URL("../assets/agents/gentle-ai-worker.md", import.meta.url);
15
13
  const definition = parseAgentDefinition(readFileSync(asset, "utf8"), asset.pathname, "global");
16
14
  assert.ok("instructions" in definition);
@@ -123,7 +121,10 @@ test("launch distribution and each observed combination remain independent and p
123
121
  assert.equal(view.settled, 1);
124
122
  assert.equal(view.statuses.completed, 1);
125
123
  assert.ok(!JSON.stringify(view).includes("local-"));
126
- const privateLaunch = launchSelection({ ...workerDefinition, instructions: "private" }, { provider: "private", id: "private-model" }, "private-effort");
124
+ // "Private Vendor Co" fails the schema provider pattern (space, uppercase),
125
+ // unlike a genuine lowercase slug such as "private" that would now legitimately
126
+ // pass through as an open-weight provider name.
127
+ const privateLaunch = launchSelection({ ...workerDefinition, instructions: "private" }, { provider: "Private Vendor Co", id: "private-model" }, "private-effort");
127
128
  const filtered = childEvent("local-session", "other", privateLaunch, "failed", {
128
129
  coverage: "final_assistant_messages_only", agentSettled: false, responses: [response("private-model", "private-native")], droppedResponses: 0,
129
130
  });
@@ -2,14 +2,12 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import runtimeMetrics from "../extensions/runtime-metrics.ts";
4
4
  import { parseAgentClass, type RuntimeMetricBucket } from "../lib/runtime-metrics.ts";
5
- import { createPiCatalogNameLookup, type PiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
5
+ import { encodeNativeRuntimeEvent } from "../lib/runtime-metrics-native.ts";
6
6
  import { CHILD_METRICS_EVENT, childEvent } from "../lib/runtime-metrics-children.ts";
7
7
  import { normalizeRpcEvent, TASK_EVENT } from "../lib/agents-protocol.ts";
8
8
 
9
9
  const tick = () => new Promise<void>(resolve => setImmediate(resolve));
10
- type CatalogLookup = NonNullable<NonNullable<Parameters<typeof runtimeMetrics>[2]>["lookup"]>;
11
- function harness(env: NodeJS.ProcessEnv = {}, lookup?: CatalogLookup,
12
- classify?: (input: unknown) => PiCatalogName, mode: "tui" | "print" = "tui", shutdownWaitMs = 1500) {
10
+ function harness(env: NodeJS.ProcessEnv = {}, mode: "tui" | "print" = "tui", shutdownWaitMs = 1500, model: unknown = { provider: "openai", id: "gpt-4o" }) {
13
11
  const handlers = new Map<string, Function>();
14
12
  const listeners = new Map<string, Function>();
15
13
  let session = "first";
@@ -17,13 +15,13 @@ function harness(env: NodeJS.ProcessEnv = {}, lookup?: CatalogLookup,
17
15
  let signal!: AbortSignal;
18
16
  const sent: RuntimeMetricBucket[][] = [];
19
17
  const launches: unknown[] = [];
20
- const ctx: any = { cwd: "/fixture", mode, model: { provider: "openai", id: "gpt-4o" },
18
+ const ctx: any = { cwd: "/fixture", mode, model,
21
19
  sessionManager: { getSessionId: () => session, getEntries: () => assert.fail("no reconstruction") } };
22
20
  const pi: any = { on: (name: string, handler: Function) => handlers.set(name, handler),
23
21
  getThinkingLevel: () => "high", registerCommand() {},
24
22
  appendEntry: () => assert.fail("no metrics persistence"),
25
23
  events: { on: (name: string, handler: Function) => { listeners.set(name, handler); return () => listeners.delete(name); } } };
26
- runtimeMetrics(pi, env, { lookup, classify, now: () => 0, shutdownWaitMs, send: (rows, _cwd, deps) => {
24
+ runtimeMetrics(pi, env, { now: () => 0, shutdownWaitMs, send: (rows, _cwd, deps) => {
27
25
  sent.push(structuredClone(rows)); launches.push(structuredClone(deps?.launches)); signal = deps!.signal!;
28
26
  return new Promise(resolve => { finish = () => resolve("discarded"); });
29
27
  } });
@@ -38,30 +36,41 @@ const final = (input = 7) => ({ role: "assistant", provider: "openai", model: "g
38
36
  providerThinkingLevel: "low", stopReason: "stop", usage: { input, output: 3, cacheRead: 0 },
39
37
  content: [{ text: "private response" }], errorMessage: "private error", path: "/private" });
40
38
 
41
- test("message classification retries a failed catalog load without awaiting it", async () => {
42
- let calls = 0;
43
- const publicName = Object.freeze({ classification: "catalog_public", modelId: "gpt-4o" }) satisfies PiCatalogName;
44
- const catalog = new Map([[JSON.stringify(["openai", "gpt-4o"]), publicName]]);
45
- const catalogLookup = createPiCatalogNameLookup(async () => {
46
- calls++;
47
- if (calls === 1) throw new Error("transient catalog failure");
48
- return catalog;
49
- });
50
- const h = harness({}, catalogLookup.lookup, catalogLookup.classify);
51
- h.emit("session_start");
52
- await tick();
53
- assert.equal(calls, 1);
54
- assert.equal(h.emit("message_end", { message: final() }), undefined);
55
- assert.equal(calls, 2);
39
+ test("a generic open-weight selection reaches the wire by name; no catalog probe or gate", async () => {
40
+ const h = harness({}, "tui", 1500, { provider: "nan", id: "deepseek-v4-flash" });
41
+ await h.emit("session_start");
42
+ h.emit("turn_start"); h.emit("before_provider_request");
43
+ h.emit("message_end", { message: { role: "assistant", provider: "nan", model: "deepseek-v4-flash",
44
+ providerThinkingLevel: "low", stopReason: "stop", usage: { input: 3, output: 2 } } });
56
45
  await tick();
57
46
  assert.equal(h.sent.length, 1);
58
- assert.equal(h.sent[0][0].observedModelId, "unknown", "the racing row remains fail-closed");
59
- await h.finish();
60
- h.emit("message_end", { message: final(8) });
47
+ assert.equal(h.sent[0][0].selectedProvider, "nan");
48
+ assert.equal(h.sent[0][0].selectedModelId, "deepseek-v4-flash");
49
+ const encoded = encodeNativeRuntimeEvent(h.sent[0]);
50
+ assert.ok(encoded);
51
+ const row = JSON.parse(encoded).rows[0];
52
+ assert.deepEqual(row.model, { provider: "nan", id: "deepseek-v4-flash" });
53
+ assert.equal(row.model_evidence, "selected");
54
+ await h.finish(); h.emit("session_shutdown");
55
+ });
56
+
57
+ test("an ambiguous turn with no captured selection still reports the observed model, as selected evidence", async () => {
58
+ const h = harness({}, "tui", 1500, { provider: "nan", id: "deepseek-v4-flash" });
59
+ await h.emit("session_start");
60
+ // No turn_start/before_provider_request: the turn is ambiguous, so no
61
+ // selection was captured for this response.
62
+ h.emit("message_end", { message: { role: "assistant", provider: "nan", model: "glm5.3",
63
+ providerThinkingLevel: "low", stopReason: "stop", usage: { input: 3, output: 2 } } });
61
64
  await tick();
62
- assert.equal(h.sent[1][0].observedModelId, "gpt-4o", "a later row uses the recovered catalog");
63
- await h.finish();
64
- h.emit("session_shutdown");
65
+ assert.equal(h.sent.length, 1);
66
+ assert.equal(h.sent[0][0].selectedModelId, "unknown");
67
+ assert.equal(h.sent[0][0].observedModelId, "glm5.3");
68
+ const encoded = encodeNativeRuntimeEvent(h.sent[0]);
69
+ assert.ok(encoded);
70
+ const row = JSON.parse(encoded).rows[0];
71
+ assert.deepEqual(row.model, { provider: "nan", id: "glm5.3" });
72
+ assert.equal(row.model_evidence, "selected");
73
+ await h.finish(); h.emit("session_shutdown");
65
74
  });
66
75
 
67
76
  test("final callback returns before blocked transport; busy and duplicate messages drop", async () => {
@@ -97,7 +106,7 @@ test("replacement before launch cancels stale work; shutdown does not await a bl
97
106
  });
98
107
 
99
108
  test("print shutdown joins an accepted orchestrator delivery before disposing", async () => {
100
- const h = harness({}, undefined, undefined, "print", 50); await h.emit("session_start");
109
+ const h = harness({}, "print", 50); await h.emit("session_start");
101
110
  h.emit("message_end", { message: final() }); await tick();
102
111
  let stopped = false;
103
112
  const shutdown = h.emit("session_shutdown").then(() => { stopped = true; });
@@ -109,7 +118,7 @@ test("print shutdown joins an accepted orchestrator delivery before disposing",
109
118
  });
110
119
 
111
120
  test("print shutdown joins an accepted child launch and response delivery", async () => {
112
- const h = harness({}, undefined, undefined, "print", 50); await h.emit("session_start");
121
+ const h = harness({}, "print", 50); await h.emit("session_start");
113
122
  const observation = normalizeRpcEvent({ type: "message_end", message: final() }, { observeResponses: true })
114
123
  .find(event => event.type === TASK_EVENT.RESPONSE_OBSERVATION);
115
124
  assert.ok(observation?.type === TASK_EVENT.RESPONSE_OBSERVATION);
@@ -127,7 +136,7 @@ test("print shutdown joins an accepted child launch and response delivery", asyn
127
136
  });
128
137
 
129
138
  test("print shutdown aborts a delivery after the bounded join deadline", async () => {
130
- const h = harness({}, undefined, undefined, "print", 5); await h.emit("session_start");
139
+ const h = harness({}, "print", 5); await h.emit("session_start");
131
140
  h.emit("message_end", { message: final() }); await tick();
132
141
  await h.emit("session_shutdown");
133
142
  assert.equal(h.signal().aborted, true);
@@ -135,7 +144,7 @@ test("print shutdown aborts a delivery after the bounded join deadline", async (
135
144
  });
136
145
 
137
146
  test("interactive shutdown never joins a blocked delivery", async () => {
138
- const h = harness({}, undefined, undefined, "tui", 50); await h.emit("session_start");
147
+ const h = harness({}, "tui", 50); await h.emit("session_start");
139
148
  h.emit("message_end", { message: final() }); await tick();
140
149
  assert.equal(h.emit("session_shutdown"), undefined);
141
150
  assert.equal(h.signal().aborted, true);
@@ -0,0 +1,76 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { normalizeRuntimeModel } from "../lib/runtime-metrics.ts";
4
+
5
+ // Data-driven family-pattern normalizer (gentle-pi#968 / gentle-ai#4536). Rules:
6
+ // - non-string or empty provider or id -> {provider: "unknown", id: "unknown"};
7
+ // - id is trimmed, the LAST "/"-separated segment is kept, then lowercased; it is
8
+ // public only when it matches the schema id pattern within its maxLength,
9
+ // otherwise the id becomes "custom";
10
+ // - provider is trimmed and lowercased; it is kept when it matches the schema
11
+ // provider pattern, otherwise it becomes "custom";
12
+ // - when the id is not public the provider becomes "custom" too, except for the
13
+ // "opencode" provider, which yields "opencode"/"custom";
14
+ // - the literal "unknown"/"unknown" and "custom"/"custom" pairs pass through.
15
+ const cases: Array<[string, unknown, unknown, { provider: string; id: string }]> = [
16
+ ["open-weight family on an arbitrary provider stays public", "nan", "deepseek-v4-flash", { provider: "nan", id: "deepseek-v4-flash" }],
17
+ ["a short version suffix without a separator is still public", "nan", "glm5.3", { provider: "nan", id: "glm5.3" }],
18
+ ["multiple suffix groups without a leading separator are still public", "nan", "glm5.3-flash", { provider: "nan", id: "glm5.3-flash" }],
19
+ ["only the last '/'-separated id segment is kept", "nano-gpt", "TEE/glm-5.3", { provider: "nano-gpt", id: "glm-5.3" }],
20
+ ["a nested provider path in the id is dropped, keeping the outer provider", "openrouter", "deepseek/deepseek-v4-flash", { provider: "openrouter", id: "deepseek-v4-flash" }],
21
+ ["provider and id are lowercased", "OpenAI", "GPT-5.6-Sol", { provider: "openai", id: "gpt-5.6-sol" }],
22
+ ["a long dotted/dashed anthropic id stays public", "anthropic", "claude-sonnet-5-20260101", { provider: "anthropic", id: "claude-sonnet-5-20260101" }],
23
+ ["an id with no recognized family prefix collapses provider and id to custom", "acme", "acme-internal-finetune", { provider: "custom", id: "custom" }],
24
+ ["a public id with a non-conforming provider is emitted as custom/<id>", "Acme Corp", "gpt-5.6", { provider: "custom", id: "gpt-5.6" }],
25
+ ["an id longer than the schema maxLength collapses to custom/custom", "anthropic", `claude-${"x".repeat(64)}`, { provider: "custom", id: "custom" }],
26
+ ["opencode keeps its provider identity even for a private id", "opencode", "private-thing", { provider: "opencode", id: "custom" }],
27
+ ["empty provider and id fail closed to unknown/unknown", "", "", { provider: "unknown", id: "unknown" }],
28
+ ];
29
+
30
+ for (const [name, provider, id, expected] of cases) {
31
+ test(`normalizeRuntimeModel: ${name}`, () => {
32
+ assert.deepEqual(normalizeRuntimeModel(provider, id), expected);
33
+ });
34
+ }
35
+
36
+ test("non-string provider or id fails closed to unknown/unknown", () => {
37
+ for (const [provider, id] of [[undefined, "gpt-5.6"], ["openai", undefined], [null, null], [42, "gpt-5.6"], ["openai", { id: "gpt-5.6" }]] as const) {
38
+ assert.deepEqual(normalizeRuntimeModel(provider, id), { provider: "unknown", id: "unknown" });
39
+ }
40
+ });
41
+
42
+ test("whitespace-only provider or id fails closed to unknown/unknown", () => {
43
+ assert.deepEqual(normalizeRuntimeModel(" ", "gpt-5.6"), { provider: "unknown", id: "unknown" });
44
+ assert.deepEqual(normalizeRuntimeModel("openai", " "), { provider: "unknown", id: "unknown" });
45
+ });
46
+
47
+ test("literal unknown/unknown passes through without pattern matching", () => {
48
+ assert.deepEqual(normalizeRuntimeModel("unknown", "unknown"), { provider: "unknown", id: "unknown" });
49
+ assert.deepEqual(normalizeRuntimeModel("Unknown", "UNKNOWN"), { provider: "unknown", id: "unknown" });
50
+ });
51
+
52
+ test("literal custom/custom passes through", () => {
53
+ assert.deepEqual(normalizeRuntimeModel("custom", "custom"), { provider: "custom", id: "custom" });
54
+ });
55
+
56
+ test("surrounding whitespace is trimmed before matching", () => {
57
+ assert.deepEqual(normalizeRuntimeModel(" nan ", " deepseek-v4-flash "), { provider: "nan", id: "deepseek-v4-flash" });
58
+ });
59
+
60
+ // The mirrored pattern is unambiguous (every suffix group starts with a
61
+ // mandatory separator), so a long alphanumeric run followed by an invalid
62
+ // character cannot trigger catastrophic backtracking in V8. The bound below
63
+ // is generous on purpose: it catches an exponential regression, not jitter.
64
+ test("a pathological 64-character id is rejected in linear time", () => {
65
+ const ids = [`gpt${"a".repeat(60)}!`, `glm${"5".repeat(60)}/`, `deepseek${"x".repeat(55)}-`];
66
+ const started = performance.now();
67
+ for (let round = 0; round < 50; round++) for (const id of ids) {
68
+ assert.deepEqual(normalizeRuntimeModel("nan", id), { provider: "custom", id: "custom" });
69
+ }
70
+ assert.ok(performance.now() - started < 500, "normalizer must not backtrack catastrophically");
71
+ });
72
+
73
+ test("attached run then separated groups stays public", () => {
74
+ assert.deepEqual(normalizeRuntimeModel("nan", "glm5.3-flash:thinking"), { provider: "nan", id: "glm5.3-flash:thinking" });
75
+ assert.deepEqual(normalizeRuntimeModel("nan", "gpt-1-2-3-4-5-6-7-8-9"), { provider: "custom", id: "custom" });
76
+ });
@@ -75,18 +75,84 @@ test("encoder filters custom identities and never promotes SDK model to response
75
75
  row.agentClass = "private-agent" as any; row.effort = "private-effort" as any;
76
76
  row.error = "authentication";
77
77
  const encoded = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
78
- assert.deepEqual(encoded.rows[0].model, { provider: "custom", id: "custom" });
78
+ // "private-model" never normalizes to a public id, so it never claims
79
+ // response evidence; the row falls through to the still-public selection
80
+ // (source()'s default selectedModelId "gpt-5.4") instead of leaking it or
81
+ // collapsing a perfectly good selected identity to custom/custom.
82
+ assert.deepEqual(encoded.rows[0].model, { provider: "openai", id: "gpt-5.4" });
83
+ assert.equal(encoded.rows[0].model_evidence, "selected");
79
84
  assert.equal(encoded.rows[0].agent_class, "unknown");
80
85
  assert.equal(encoded.rows[0].selected_effort, "unavailable");
81
86
  assert.equal(encoded.rows[0].error_category, "auth");
82
87
  assert.ok(!JSON.stringify(encoded).includes("private"));
83
- row.responseModelId = "unknown";
84
- const selected = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
85
- assert.equal(selected.rows[0].model_evidence, "selected");
86
- assert.deepEqual(selected.rows[0].model, { provider: "openai", id: "gpt-5.4" });
87
88
  row.selectedModelId = "unknown";
88
- const unknown = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
89
- assert.equal(unknown.rows[0].model_evidence, "unknown");
89
+ const observedFallback = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
90
+ // With no public selected model either, it falls through once more, but
91
+ // source()'s default observedModelId ("private-alias") is not public either.
92
+ assert.equal(observedFallback.rows[0].model_evidence, "unknown");
93
+ assert.deepEqual(observedFallback.rows[0].model, { provider: "unknown", id: "unknown" });
94
+ });
95
+
96
+ test("a public observed model is a fallback evidence tier, behind response and selected", () => {
97
+ const row = source();
98
+ row.responseModelId = "unknown"; row.selectedModelId = "unknown"; row.observedModelId = "glm5.3-flash"; row.provider = "nan";
99
+ const encoded = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
100
+ assert.deepEqual(encoded.rows[0].model, { provider: "nan", id: "glm5.3-flash" });
101
+ assert.equal(encoded.rows[0].model_evidence, "selected", "the transport has no separate observed evidence category");
102
+ // A public response model still wins over a public observed model.
103
+ const withResponse = source();
104
+ withResponse.selectedModelId = "unknown"; withResponse.observedModelId = "glm5.3-flash"; withResponse.provider = "nan";
105
+ const encodedResponse = JSON.parse(native.encodeNativeRuntimeEvent([withResponse])!);
106
+ assert.equal(encodedResponse.rows[0].model_evidence, "response");
107
+ // A public selected model still wins over a public observed model.
108
+ const withSelected = source();
109
+ withSelected.responseModelId = "unknown"; withSelected.observedModelId = "glm5.3-flash"; withSelected.provider = "nan";
110
+ const encodedSelected = JSON.parse(native.encodeNativeRuntimeEvent([withSelected])!);
111
+ assert.equal(encodedSelected.rows[0].model_evidence, "selected");
112
+ assert.deepEqual(encodedSelected.rows[0].model, { provider: withSelected.selectedProvider, id: withSelected.selectedModelId });
113
+ // A non-public observed model still falls through to unknown/unknown.
114
+ const noEvidence = source();
115
+ noEvidence.responseModelId = "unknown"; noEvidence.selectedModelId = "unknown"; noEvidence.observedModelId = "private-alias";
116
+ const encodedUnknown = JSON.parse(native.encodeNativeRuntimeEvent([noEvidence])!);
117
+ assert.deepEqual(encodedUnknown.rows[0].model, { provider: "unknown", id: "unknown" });
118
+ assert.equal(encodedUnknown.rows[0].model_evidence, "unknown");
119
+ });
120
+
121
+ test("every encoded model satisfies the schema's anyOf, by pattern or by sentinel", () => {
122
+ const idRule = schema.$defs.model.anyOf[0].properties.id as { pattern: string };
123
+ const providerRule = schema.$defs.model.anyOf[0].properties.provider as { pattern: string };
124
+ const idPattern = new RegExp(idRule.pattern);
125
+ const providerPattern = new RegExp(providerRule.pattern);
126
+ const sentinels = schema.$defs.model.anyOf.slice(1) as Array<{ properties: { provider: { const: string }; id: { const: string } } }>;
127
+ function matchesSchema(model: { provider: string; id: string }): boolean {
128
+ if (providerPattern.test(model.provider) && idPattern.test(model.id)) return true;
129
+ return sentinels.some(({ properties }) => properties.provider.const === model.provider && properties.id.const === model.id);
130
+ }
131
+ // Response/selected/observed rows only ever win evidence with an id that is
132
+ // already public, so their encoded model is either the public-pattern
133
+ // branch or the terminal unknown/unknown sentinel. The custom/custom and
134
+ // opencode/custom sentinels are reachable only through a launch row, which
135
+ // (already upstream-classified, always "selected" evidence) is paired
136
+ // through the same normalizer unconditionally.
137
+ const rows: RuntimeMetricBucket[] = [
138
+ source(),
139
+ { ...source(), provider: "nan", selectedProvider: "nan", selectedModelId: "deepseek-v4-flash", responseModelId: "unknown" },
140
+ { ...source(), responseModelId: "unknown", selectedModelId: "unknown", observedModelId: "unknown" },
141
+ ];
142
+ const launches = [
143
+ { evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "opencode", selectedModelId: "custom", selectedEffort: "high" as const, launches: 1 },
144
+ { evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "custom", selectedModelId: "custom", selectedEffort: "high" as const, launches: 1 },
145
+ { evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "unknown", selectedModelId: "unknown", selectedEffort: "high" as const, launches: 1 },
146
+ ];
147
+ const payload = native.encodeNativeRuntimeEvent(rows, launches);
148
+ assert.ok(payload);
149
+ const decoded = JSON.parse(payload).rows as Array<{ model: { provider: string; id: string } }>;
150
+ assert.equal(decoded.length, rows.length + launches.length);
151
+ for (const row of decoded) assert.ok(matchesSchema(row.model), `model ${JSON.stringify(row.model)} must satisfy $defs.model.anyOf`);
152
+ assert.deepEqual(decoded.map(row => row.model), [
153
+ { provider: "openai", id: "gpt-5.4" }, { provider: "nan", id: "deepseek-v4-flash" }, { provider: "unknown", id: "unknown" },
154
+ { provider: "opencode", id: "custom" }, { provider: "custom", id: "custom" }, { provider: "unknown", id: "unknown" },
155
+ ]);
90
156
  });
91
157
 
92
158
  function fixture() {