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
@@ -30,7 +30,6 @@ import { openInExternalEditor } from "./gentle-shell.ts";
30
30
  import { resolveGentlePiAgentHome } from "../lib/agent-home.ts";
31
31
  import { assertResearchCheckpoint, parseResearchPersistence, RESEARCH_PERSISTENCE_ENTRY, canonicalArtifactPath, researchAgent, renderResearchCapabilities, RESEARCH_CHILD_TOOLS_ENV, RESEARCH_SELECTION_ENV, RESEARCH_ARTIFACT_ENV, parseResearchArtifactIntent, researchArtifactCall, researchArtifactReadback, type ResearchArtifactIntent, type ResearchWriteIdentity } from "../lib/sdd-research-capabilities.ts";
32
32
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, childEvent, launchSelection, type LaunchSelection } from "../lib/runtime-metrics-children.ts";
33
- import { lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
34
33
  import { runtimeMetricsEnvAllows, type RuntimeMetricsPolicyDeps } from "../lib/runtime-metrics-policy.ts";
35
34
 
36
35
  // Gentle Agents: subagents as isolated `pi --mode rpc` children, a task
@@ -198,9 +197,12 @@ export async function admitManagedRemediation(request: TaskRequest, input: unkno
198
197
  task.error = "Managed remediation lacks complete passing planned-command evidence";
199
198
  }
200
199
  if (payload.outcome === "passed" && state.settlement && state.settlement.state !== "blocked") task.status = TASK_STATUS.COMPLETED;
201
- if (!state.settlement || state.settlement.state === "blocked") {
200
+ if (!state.settlement) {
202
201
  task.status = TASK_STATUS.FAILED;
203
202
  task.error = "Native remediation settlement unresolved; retain exact history for reconciliation";
203
+ } else if (state.settlement.state === "blocked") {
204
+ task.status = TASK_STATUS.FAILED;
205
+ task.error = `Native remediation settlement blocked(${state.settlement.reason ?? "unspecified"}); current native admission decides any later attempt`;
204
206
  }
205
207
  await persist(task);
206
208
  },
@@ -244,7 +246,6 @@ export interface AgentsDeps extends RunnerDeps {
244
246
  runtimeMetricsPolicy?: RuntimeMetricsPolicyDeps;
245
247
  metricsNow?: () => number;
246
248
  metricsSchedule?: RunnerDeps["schedule"];
247
- lookupPiCatalogName?: typeof lookupPiCatalogName;
248
249
  }
249
250
 
250
251
  export function agentRuntimePaths(home: string, agentHome = join(home, ".pi", "agent")): { sessions: string; transcripts: string } {
@@ -633,7 +634,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
633
634
  const stoppingTaskIds = new Set<string>();
634
635
  const yieldedTaskIds = new Set<string>();
635
636
  const metricsNow = deps.metricsNow ?? (() => performance.now());
636
- const catalogLookup = deps.lookupPiCatalogName ?? lookupPiCatalogName;
637
637
  let metricsOwner = {};
638
638
  const metricTasks = new Map<string, { selection?: LaunchSelection; started: number; launched: boolean; finished: boolean; current(): boolean; valid(): boolean }>();
639
639
  const unsubscribeMetrics = pi.events.on(CHILD_METRICS_REVOKED, id => {
@@ -1121,7 +1121,6 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
1121
1121
  onLaunch: () => { metrics.launched = true; request.onLaunch?.(); },
1122
1122
  ...(observe ? { canCollectResponseObservations: metrics.valid, prepareResponseObservations: async () => {
1123
1123
  if (metrics.finished || owner !== metricsOwner || request.parentSessionId !== activeSessionId() || !runtimeMetricsEnvAllows(deps.env)) return false;
1124
- void catalogLookup({ provider: "openai", modelId: "gpt-4o" }).catch(() => {});
1125
1124
  if (!metrics.valid()) return false;
1126
1125
  metrics.selection = launchSelection(request.agent, request.model, request.thinking);
1127
1126
  metrics.started = metricsNow();
@@ -1839,7 +1839,15 @@ async function resolveSelectedNativeSddChangeStartup(
1839
1839
  }
1840
1840
  if (selection.phase === "remediate") {
1841
1841
  if (status.nextRecommended !== "remediate" || status.remediationState?.failedEvidenceRevision !== selection.failedEvidenceRevision) throw new Error("Stale remediation selection");
1842
- } else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || status.blockedReasons.length > 0) {
1842
+ } else if (status.nextRecommended !== selection.phase || status.dependencies[selection.phase] !== "ready" || (status.blockedReasons.length > 0 && selection.phase !== "verify")) {
1843
+ // Native's contract gates terminal, archive, and apply work on a
1844
+ // non-empty `blockedReasons`, and it deliberately keeps the `verify`
1845
+ // route runnable, because the blocker can name the evidence refresh
1846
+ // that is its own remedy ("failed verification evidence is incomplete;
1847
+ // rerun SDD verification", gentle-ai#3538). Vetoing that route made the
1848
+ // native-recommended phase unreachable (gentle-pi#972). The other
1849
+ // phases still fail closed, and every blocker stays in the injected
1850
+ // status for reporting.
1843
1851
  throw new Error(`SDD selection native status blocks phase ${selection.phase}; it cannot execute.`);
1844
1852
  }
1845
1853
  return { selection, status };
@@ -5601,6 +5609,33 @@ function completeNativeStart(
5601
5609
  };
5602
5610
  }
5603
5611
 
5612
+ // gentle-ai#4003: every Pi-side teardown step that fails after the native
5613
+ // burn is deferred cleanup, not a failed acknowledgement. Only the
5614
+ // already-sanitized CandidateViewError surface is relayed; anything else is
5615
+ // reduced to the step's fixed code so no path or command text reaches the
5616
+ // caller. The candidate-view hint is out-of-band on purpose: no controller
5617
+ // operation exposes a cleanup-only retry, and replaying acknowledge-approved
5618
+ // would hit the already-burned lineage.
5619
+ const POST_BURN_CLEANUP = {
5620
+ candidateView: { code: "candidate-view-cleanup-failed", nextAction: "retry-candidate-view-cleanup-or-remove-the-view-out-of-band" },
5621
+ retainedSelection: { code: "retained-selection-cleanup-failed", nextAction: "retained-selection-clears-on-the-next-terminal-status" },
5622
+ } as const;
5623
+
5624
+ function deferredPostBurnCleanup(step: (typeof POST_BURN_CLEANUP)[keyof typeof POST_BURN_CLEANUP], cleanup: () => void): Record<string, unknown> | undefined {
5625
+ try {
5626
+ cleanup();
5627
+ return undefined;
5628
+ } catch (error) {
5629
+ return {
5630
+ status: "deferred",
5631
+ diagnostics: error instanceof CandidateViewError
5632
+ ? { code: error.reason, message: error.message }
5633
+ : { code: step.code },
5634
+ next_action: step.nextAction,
5635
+ };
5636
+ }
5637
+ }
5638
+
5604
5639
  function nativeOperationFailure(operation: ReviewControllerOperation | "gentle_review_capture", error: unknown): Record<string, unknown> {
5605
5640
  const value = error as { mutationOutcome?: unknown; nextAction?: unknown; diagnostics?: unknown; auditRecord?: unknown; launchAttempted?: unknown; candidateViewPreNative?: unknown; failureEnvelope?: { raw?: unknown; mutationOutcome?: unknown; replayability?: unknown; nextAction?: unknown; code?: unknown; continuation?: { command?: unknown } } };
5606
5641
  if (isRecord(value.failureEnvelope) && isRecord(value.failureEnvelope.raw)) {
@@ -7335,42 +7370,50 @@ async function executeReviewControllerOperation(
7335
7370
  } catch (error) {
7336
7371
  return nativeOperationFailure(parameters.operation, error);
7337
7372
  }
7373
+ let acknowledged: NativeReviewAcknowledgeApprovedOutcome | void;
7338
7374
  try {
7339
7375
  // gentle-ai #3947: the burn answers with one review-acknowledged/v1
7340
7376
  // envelope bound to exactly this lineage, target, and revision, and
7341
7377
  // the burn is reported from that envelope, never from a later
7342
7378
  // STATUS. Every published release up to v2.5.0-rc.3 still burns in
7343
7379
  // silence, and that result stays byte-identical.
7344
- const acknowledged = await acknowledgementCli.acknowledgeApproved({
7380
+ acknowledged = await acknowledgementCli.acknowledgeApproved({
7345
7381
  argumentTokens,
7346
7382
  cwd: defaultCwd,
7347
7383
  binding: { lineageId: parameters.lineageId, targetIdentity: status.targetIdentity, revision: status.authority.revision },
7348
7384
  ...(signal === undefined ? {} : { signal }),
7349
7385
  });
7350
- clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId);
7351
- // The registry owns restoring writability of its 0555 views before
7352
- // removal; a terminal approved cleanup keeps the lineage projection.
7353
- candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd);
7354
- // gentle-pi#668: `closed` is never auto-derived or recorded here --
7355
- // a parent that wants the on-path passes nativeReviewOutcome:
7356
- // "closed" explicitly on its next assess call for this candidate.
7357
- return {
7358
- operation: parameters.operation,
7359
- status: "closed",
7360
- outcome: "native-approved-acknowledgement-completed",
7361
- lineage_id: parameters.lineageId,
7362
- target_identity: status.targetIdentity,
7363
- ...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }),
7364
- authority: "burned",
7365
- ...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }),
7366
- delivery: "ordinary-repository-policy",
7367
- mutation_performed: true,
7368
- mutation_outcome: "committed",
7369
- };
7370
7386
  } catch (error) {
7371
7387
  if (!nativeMutationRequiresStatus(error)) return nativeOperationFailure(parameters.operation, error);
7372
7388
  return await reconcileNativeMutationFailure(parameters.operation, error, acknowledgementCli, target, retainedUntrackedSelections);
7373
7389
  }
7390
+ // gentle-ai#4003: from here the native burn is the committed authority
7391
+ // outcome. Both Pi-side teardown steps run outside the mutation-result
7392
+ // try/catch and each one is guarded on its own, so a cleanup failure is
7393
+ // reported as deferred cleanup and never as a failed acknowledgement
7394
+ // that would invite a replay of a burned operation.
7395
+ const retainedSelectionCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.retainedSelection, () => clearRetainedNativeUntrackedSelection(retainedUntrackedSelections, defaultCwd, parameters.lineageId));
7396
+ // The registry owns restoring writability of its 0555 views before
7397
+ // removal; a terminal approved cleanup keeps the lineage projection.
7398
+ const candidateViewCleanup = deferredPostBurnCleanup(POST_BURN_CLEANUP.candidateView, () => candidateViews?.cleanupTerminal(parameters.lineageId, "approved", defaultCwd));
7399
+ // gentle-pi#668: `closed` is never auto-derived or recorded here --
7400
+ // a parent that wants the on-path passes nativeReviewOutcome:
7401
+ // "closed" explicitly on its next assess call for this candidate.
7402
+ return {
7403
+ operation: parameters.operation,
7404
+ status: "closed",
7405
+ outcome: "native-approved-acknowledgement-completed",
7406
+ lineage_id: parameters.lineageId,
7407
+ target_identity: status.targetIdentity,
7408
+ ...(acknowledged === undefined ? {} : { consumed_revision: acknowledged.consumedRevision }),
7409
+ authority: "burned",
7410
+ ...(acknowledged === undefined ? {} : { burn_evidence: acknowledged.schema }),
7411
+ delivery: "ordinary-repository-policy",
7412
+ mutation_performed: true,
7413
+ mutation_outcome: "committed",
7414
+ ...(retainedSelectionCleanup === undefined ? {} : { retained_selection_cleanup: retainedSelectionCleanup }),
7415
+ ...(candidateViewCleanup === undefined ? {} : { candidate_view_cleanup: candidateViewCleanup }),
7416
+ };
7374
7417
  }
7375
7418
  if (parameters.operation === REVIEW_CONTROLLER_OPERATION.ANSWER_CONSENT) {
7376
7419
  const input = parseControllerJson(requiredControllerString(parameters, "input"), parameters.operation);
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { EFFORTS, ORCHESTRATOR_AGENT_CLASS, RuntimeMetrics, UNKNOWN_AGENT_CLASS, type FinalResponse, type TokenMeasurement } from "../lib/runtime-metrics.ts";
3
- import { classifyPiCatalogName, lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
4
3
  import { CHILD_METRICS_EVENT, CHILD_METRICS_REVOKED, snapshotChildEvent, type ChildLaunchBucket } from "../lib/runtime-metrics-children.ts";
5
4
  import { RuntimeMetricsAttempt } from "../lib/runtime-metrics-delivery.ts";
6
5
  import { sendNativeRuntimeEvent, type NativeRuntimeTransportDeps } from "../lib/runtime-metrics-native.ts";
@@ -12,10 +11,8 @@ import { runtimeMetricsEnvAllows } from "../lib/runtime-metrics-policy.ts";
12
11
  * Pi hooks lack request correlation: latency and SDK-zero presence are unknown.
13
12
  */
14
13
  export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
15
- { lookup = lookupPiCatalogName, classify = classifyPiCatalogName, native, send = sendNativeRuntimeEvent,
16
- now = () => performance.now(), shutdownWaitMs = 1500 }:
17
- { lookup?: typeof lookupPiCatalogName; classify?: typeof classifyPiCatalogName; native?: NativeRuntimeTransportDeps;
18
- send?: typeof sendNativeRuntimeEvent; now?: () => number; shutdownWaitMs?: number } = {}): void {
14
+ { native, send = sendNativeRuntimeEvent, now = () => performance.now(), shutdownWaitMs = 1500 }:
15
+ { native?: NativeRuntimeTransportDeps; send?: typeof sendNativeRuntimeEvent; now?: () => number; shutdownWaitMs?: number } = {}): void {
19
16
  const allows = () => env.GENTLE_PI_AGENTS_CHILD !== "1" && runtimeMetricsEnvAllows(env);
20
17
  if (!allows()) return;
21
18
  type Selection = Pick<FinalResponse, "selectedModelId" | "selectedProvider" | "effort">;
@@ -24,7 +21,7 @@ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
24
21
  let requestSeen = false;
25
22
  let ambiguous = false;
26
23
  let live: { id: string; started: number; ctx: ExtensionContext; attempt: RuntimeMetricsAttempt;
27
- seen: WeakSet<object>; children: Set<string>; catalog: boolean } | undefined;
24
+ seen: WeakSet<object>; children: Set<string> } | undefined;
28
25
  const missing = { state: "unavailable" } as const;
29
26
  function invalidate() { selection = undefined; ambiguous = true; }
30
27
  function dispose() { live?.attempt.dispose(); live = undefined; active = false; invalidate(); }
@@ -32,15 +29,10 @@ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
32
29
  if (!allows() || live?.id !== ctx.sessionManager.getSessionId()) dispose();
33
30
  return live;
34
31
  }
35
- function refreshCatalog(owner: NonNullable<typeof live>, input: { provider: unknown; modelId: unknown }) {
36
- void lookup(input).then(result => {
37
- if (live === owner && result.classification === "catalog_public") owner.catalog = true;
38
- }, () => { /* A later classification callback may retry the bounded load. */ });
39
- }
40
32
  function submit(owner: NonNullable<typeof live>, responses: FinalResponse[], launches?: ChildLaunchBucket[]) {
41
33
  if (!responses.length || live !== owner || !current(owner.ctx)) return;
42
34
  // Ephemeral event-local accounting only; source IDs never enter these rows.
43
- const metrics = new RuntimeMetrics({ classifyModel: classify });
35
+ const metrics = new RuntimeMetrics();
44
36
  for (const [index, row] of responses.entries()) metrics.record({ ...row, responseId: String(index) });
45
37
  const rows = metrics.snapshot();
46
38
  if (!rows.length) return;
@@ -71,10 +63,8 @@ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
71
63
  pi.on("session_start", (_event, ctx) => {
72
64
  dispose();
73
65
  if (!allows()) return;
74
- const owner = { id: ctx.sessionManager.getSessionId(), started: now(), ctx,
75
- attempt: new RuntimeMetricsAttempt(), seen: new WeakSet<object>(), children: new Set<string>(), catalog: false };
76
- live = owner;
77
- refreshCatalog(owner, { provider: "openai", modelId: "gpt-4o" });
66
+ live = { id: ctx.sessionManager.getSessionId(), started: now(), ctx,
67
+ attempt: new RuntimeMetricsAttempt(), seen: new WeakSet<object>(), children: new Set<string>() };
78
68
  });
79
69
  pi.on("session_shutdown", (_event, ctx) => {
80
70
  const teardown = () => { dispose(); offChild(); offRevoke(); };
@@ -95,8 +85,10 @@ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
95
85
  if (!active || ambiguous) return;
96
86
  let effort: FinalResponse["effort"] = "unavailable";
97
87
  try { const value = pi.getThinkingLevel(); if (EFFORTS.includes(value)) effort = value; } catch { /* No evidence. */ }
98
- selection = { selectedProvider: owner.catalog ? ctx.model?.provider as FinalResponse["provider"] ?? "unknown" : "unknown",
99
- selectedModelId: owner.catalog && typeof ctx.model?.id === "string" && ctx.model.id.length <= 128 ? ctx.model.id : undefined, effort };
88
+ // No catalog gate: the selection is taken as-is (bounded to 128 chars) and
89
+ // the schema-driven normalizer decides what actually leaves the machine.
90
+ selection = { selectedProvider: typeof ctx.model?.provider === "string" && ctx.model.provider.length <= 128 ? ctx.model.provider : undefined,
91
+ selectedModelId: typeof ctx.model?.id === "string" && ctx.model.id.length <= 128 ? ctx.model.id : undefined, effort };
100
92
  });
101
93
  function token(value: unknown): TokenMeasurement {
102
94
  return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= 1_000_000_000
@@ -109,7 +101,6 @@ export default function runtimeMetrics(pi: ExtensionAPI, env = process.env,
109
101
  if (!owner || message.role !== "assistant" || message.stopReason === "pending" || message.stopReason === "deferred"
110
102
  || owner.seen.has(message)) return;
111
103
  owner.seen.add(message);
112
- refreshCatalog(owner, { provider: "openai", modelId: "gpt-4o" });
113
104
  const selected = active && !ambiguous ? selection : undefined;
114
105
  submit(owner, [{ kind: "final_assistant_response", responseId: "0",
115
106
  selectedProvider: selected?.selectedProvider ?? "unknown", selectedModelId: selected?.selectedModelId,
@@ -16,7 +16,13 @@ export function remediationUnresolved(task: TaskRecord): boolean {
16
16
  const state = task.sddRemediation;
17
17
  if (!state) return false;
18
18
  if (state.acquireUncertain || state.settlementUncertain) return true;
19
- if (state.settlement) return state.settlement.state === "blocked";
19
+ // A received settlement is a definite native outcome, whatever its state
20
+ // (including "blocked"): it is terminal task history, never local
21
+ // ambiguity. Native admission is the sole authority over any later
22
+ // attempt for the same cwd/change; only genuinely uncertain outcomes, or
23
+ // no settlement at all with a still-retained token/claimed actor, are
24
+ // unresolved.
25
+ if (state.settlement) return false;
20
26
  return !!state.token || !!state.actorClaimed || !["blocked", "complete"].includes(state.acquireResult?.state ?? "");
21
27
  }
22
28
 
@@ -966,6 +966,20 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
966
966
  // so this row repeats 2.7.0. riskEvidence and hint remain dark because
967
967
  // neither is proven to reach the negotiated START path Pi consumes.
968
968
  "2.8.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
969
+ // v2.8.1 only changed runtime telemetry model attribution (gentle-ai#4536);
970
+ // the closed fields Pi consumes did not change between 2.8.0 and 2.8.1, so
971
+ // this row repeats 2.8.0 exactly. riskEvidence and hint remain dark
972
+ // because neither is proven to reach the negotiated START path Pi consumes.
973
+ "2.8.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
974
+ // v2.8.2 shipped OpenCode SDD preflight plugin fixes, community-tools RTK
975
+ // acquisition, and Claude Code Stop telemetry. The provider contract semver
976
+ // stays 1.2.0; the same pin re-mirrors bundle bytes that had drifted under
977
+ // that semver (lens inspection.status "unavailable", targeted-validator
978
+ // regressions/inspection members, seven Pi stop reason codes). None of
979
+ // those touch the closed START/STATUS fields this row negotiates, so it
980
+ // repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
981
+ // is proven to reach the negotiated START path Pi consumes.
982
+ "2.8.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
969
983
  });
970
984
 
971
985
  export interface NativeReviewProcessDiagnostics {
@@ -2,14 +2,12 @@ import { readFileSync, readdirSync } from "node:fs";
2
2
  import { parseAgentDefinition, type AgentDefinition, type ModelRef } from "./agents-config.ts";
3
3
  import type { ChildObservationSnapshot } from "./agents-runner.ts";
4
4
  import { FINISHED_STATUSES, type TaskStatus } from "./agents-protocol.ts";
5
- import { classifyRuntimeModelId, EFFORTS, parseAgentClass, RuntimeMetrics, UNKNOWN_AGENT_CLASS, validRuntimeResponse, type AgentClass, type FinalResponse, type RuntimeMetricBucket } from "./runtime-metrics.ts";
6
- import { classifyPiCatalogName } from "./runtime-metrics-pi-identity.ts";
5
+ import { classifyRuntimeModelId, EFFORTS, normalizeRuntimeProvider, parseAgentClass, RuntimeMetrics, UNKNOWN_AGENT_CLASS, validRuntimeResponse, type AgentClass, type FinalResponse, type RuntimeMetricBucket } from "./runtime-metrics.ts";
7
6
  export const CHILD_METRICS_EVENT = "gentle:runtime-metrics:child/v1";
8
7
  // Local revocation notification invalidates active observations. Contains only
9
8
  // the local session join, never policy output.
10
9
  export const CHILD_METRICS_REVOKED = "gentle:runtime-metrics:revoked/v1";
11
10
  const missing = { state: "unavailable" } as const;
12
- const providers = ["anthropic", "openai", "openai-codex", "google", "google-vertex", "amazon-bedrock", "openrouter", "custom", "unknown"];
13
11
  const tokenFields = ["input", "output", "cacheRead", "cacheWrite", "reasoning", "totalTokens"] as const;
14
12
  /** Recognize only names from this package's fixed assets and the transport's
15
13
  * closed agent_class enum. Customized packaged agents retain their schema name;
@@ -44,11 +42,11 @@ export function classifyBuiltinAgent(agent: AgentDefinition): AgentClass {
44
42
  } catch { return UNKNOWN_AGENT_CLASS; }
45
43
  }
46
44
  function provider(value: unknown): FinalResponse["provider"] {
47
- return providers.includes(value as string) ? value as FinalResponse["provider"] : value ? "custom" : "unknown";
45
+ return normalizeRuntimeProvider(value);
48
46
  }
49
47
  function modelId(namespace: unknown, value: unknown): string {
50
48
  if (value === "unknown" || value === undefined) return "unknown";
51
- return classifyRuntimeModelId(namespace, value, classifyPiCatalogName);
49
+ return classifyRuntimeModelId(namespace, value);
52
50
  }
53
51
  function effort(value: unknown): FinalResponse["effort"] {
54
52
  return EFFORTS.includes(value as FinalResponse["effort"]) ? value as FinalResponse["effort"] : "unavailable";
@@ -1,7 +1,7 @@
1
1
  import { spawn, type ChildProcessWithoutNullStreams, type SpawnOptionsWithoutStdio } from "node:child_process";
2
2
  import { resolveGentleAiBinary } from "./gentle-ai-binary.ts";
3
3
  import { runtimeMetricsEnvAllows } from "./runtime-metrics-policy.ts";
4
- import type { RuntimeMetricBucket } from "./runtime-metrics.ts";
4
+ import { isPublicRuntimeModelId, normalizeRuntimeModel, type RuntimeMetricBucket } from "./runtime-metrics.ts";
5
5
  import type { ChildLaunchBucket } from "./runtime-metrics-children.ts";
6
6
 
7
7
  import schema from "../contracts/telemetry/runtime-aggregate-v1.schema.json" with { type: "json" };
@@ -10,11 +10,11 @@ export type NativeRuntimeResult = "stored" | "duplicate" | "discarded" | "disabl
10
10
  export const NATIVE_SEND_ACK_SCHEMA = "gentle-ai.telemetry-runtime-send/v1";
11
11
  const MAX_METRIC = schema.$defs.count.maximum;
12
12
  const count = (value: number) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_METRIC;
13
+ // Re-derives through the same schema-driven normalizer used at record time so
14
+ // an already-classified (provider, id) pair is re-validated against the
15
+ // transport's anyOf shape before it ever leaves the machine.
13
16
  function publicModel(provider: string, id: string) {
14
- const matches = (rule: { const?: string; enum?: string[] }, value: string) => rule.const === value || rule.enum?.includes(value);
15
- if (schema.$defs.model.oneOf.some(({ properties }) => matches(properties.provider, provider) && matches(properties.id, id))) return { provider, id };
16
- const kind = !id || id === "unknown" ? "unknown" : "custom";
17
- return { provider: kind, id: kind };
17
+ return normalizeRuntimeModel(provider, id);
18
18
  }
19
19
 
20
20
  /** Closed field projection of one event, not a session snapshot. Oversized or
@@ -34,14 +34,22 @@ export function encodeNativeRuntimeEvent(source: readonly RuntimeMetricBucket[],
34
34
  };
35
35
  const agentClass = schema.$defs.row.properties.agent_class.enum.includes(row.agentClass) ? row.agentClass : "unknown";
36
36
  const effort = (value: string) => schema.$defs.effort.enum.includes(value) ? value : "unavailable";
37
- const response = typeof row.responseModelId === "string" && row.responseModelId !== "unknown" && row.responseModelId !== "";
38
- const selected = typeof row.selectedModelId === "string" && row.selectedModelId !== "unknown" && row.selectedModelId !== "";
37
+ // Evidence order: an actually-dispatched response model wins; otherwise
38
+ // the caller's own selection; otherwise the SDK-observed model the
39
+ // selection never captured (still labeled "selected" evidence, since
40
+ // the transport has no separate "observed" category). Never response
41
+ // evidence unless the response model itself normalizes to a public id.
42
+ const response = isPublicRuntimeModelId(row.responseModelId);
43
+ const selected = !response && isPublicRuntimeModelId(row.selectedModelId);
44
+ const observed = !response && !selected && isPublicRuntimeModelId(row.observedModelId);
39
45
  const model = response ? publicModel(row.provider, row.responseModelId)
40
- : selected ? publicModel(row.selectedProvider, row.selectedModelId) : { provider: "unknown", id: "unknown" };
46
+ : selected ? publicModel(row.selectedProvider, row.selectedModelId)
47
+ : observed ? publicModel(row.provider, row.observedModelId)
48
+ : { provider: "unknown", id: "unknown" };
41
49
  const time = row.fullResponseMs;
42
50
  if (!count(time.measured) || !Number.isFinite(time.sum) || time.sum < 0 || time.sum > MAX_METRIC
43
51
  || (!time.measured && time.sum !== 0)) throw new Error("Invalid duration");
44
- return { model, model_evidence: response ? "response" : selected ? "selected" : "unknown",
52
+ return { model, model_evidence: response ? "response" : (selected || observed) ? "selected" : "unknown",
45
53
  agent_kind: agentClass === "orchestrator" || agentClass === "unknown" ? agentClass : "built_in", agent_class: agentClass,
46
54
  selected_effort: effort(row.effort), effective_effort: effort(row.providerThinkingLevel),
47
55
  launches: null, responses: row.responses,
@@ -1,5 +1,4 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { classifyPiCatalogName } from "./runtime-metrics-pi-identity.ts";
3
2
 
4
3
  const runtimeSchema = JSON.parse(readFileSync(new URL("../contracts/telemetry/runtime-aggregate-v1.schema.json", import.meta.url), "utf8"));
5
4
 
@@ -7,7 +6,6 @@ const runtimeSchema = JSON.parse(readFileSync(new URL("../contracts/telemetry/ru
7
6
  // Callers supply finalized assistant responses and authoritative classifications.
8
7
  // Never infer executor, usage availability, or measured timings from SDK defaults.
9
8
  const EXECUTORS = ["orchestrator", "worker", "reviewer", "unknown"] as const;
10
- const PROVIDERS = ["anthropic", "openai", "openai-codex", "google", "google-vertex", "amazon-bedrock", "openrouter", "custom", "unknown"] as const;
11
9
  // Stable families, not model IDs/versions: new models need no catalog update.
12
10
  // Callers map known native metadata to families; private aliases stay custom.
13
11
  const FAMILIES = ["claude", "gpt", "o-series", "gemini", "llama", "qwen", "deepseek", "kimi", "custom", "unknown"] as const;
@@ -42,38 +40,93 @@ function requiredAgentClass(value: string): AgentClass {
42
40
  export const UNKNOWN_AGENT_CLASS = requiredAgentClass("unknown");
43
41
  export const ORCHESTRATOR_AGENT_CLASS = requiredAgentClass("orchestrator");
44
42
 
45
- function registeredModels(): ReadonlySet<string> {
46
- const rules: unknown = runtimeSchema?.$defs?.model?.oneOf;
47
- if (!Array.isArray(rules) || !rules.length) throw new Error("Invalid runtime telemetry model schema");
48
- const values = (rule: unknown, field: "provider" | "id"): string[] => {
49
- if (!object(rule)) throw new Error("Invalid runtime telemetry model schema");
50
- const properties = rule.properties;
51
- if (!object(properties) || !object(properties[field])) throw new Error("Invalid runtime telemetry model schema");
52
- const property = properties[field];
53
- const candidates = typeof property.const === "string" ? [property.const] : property.enum;
54
- if (!Array.isArray(candidates) || !candidates.length || candidates.some(value => typeof value !== "string")) {
55
- throw new Error("Invalid runtime telemetry model schema");
56
- }
57
- return candidates as string[];
58
- };
59
- const models = new Set<string>();
60
- for (const rule of rules) for (const provider of values(rule, "provider")) {
61
- for (const id of values(rule, "id")) models.add(JSON.stringify([provider, id]));
43
+ interface ModelFieldRule { pattern: RegExp; maxLength: number }
44
+
45
+ function modelFieldRule(field: "provider" | "id"): ModelFieldRule {
46
+ // The length cap lives on $defs.model.properties.<field>; the shape pattern
47
+ // lives only on the first (public-pattern) branch of $defs.model.anyOf, next
48
+ // to the unknown/custom/opencode sentinel branches. Both are mirrored,
49
+ // byte-for-byte, from the Gentle AI transport schema.
50
+ const property: unknown = runtimeSchema?.$defs?.model?.properties?.[field];
51
+ if (!object(property) || typeof property.maxLength !== "number") {
52
+ throw new Error(`Invalid runtime telemetry model ${field} schema`);
62
53
  }
63
- return models;
54
+ const branches: unknown = runtimeSchema?.$defs?.model?.anyOf;
55
+ const patternProperty = Array.isArray(branches) && object(branches[0]) && object(branches[0].properties)
56
+ ? branches[0].properties[field] : undefined;
57
+ if (!object(patternProperty) || typeof patternProperty.pattern !== "string") {
58
+ throw new Error(`Invalid runtime telemetry model ${field} schema`);
59
+ }
60
+ return { pattern: new RegExp(patternProperty.pattern), maxLength: property.maxLength };
64
61
  }
65
62
 
66
- const REGISTERED_MODELS = registeredModels();
63
+ // Schema-driven, not a hardcoded TypeScript regex: the mirrored transport
64
+ // contract owns the open family-pattern rules for provider/id shape. A
65
+ // companion Gentle AI change keeps the Go side on the same patterns.
66
+ const MODEL_PROVIDER_RULE = modelFieldRule("provider");
67
+ const MODEL_ID_RULE = modelFieldRule("id");
67
68
 
68
- /** The mirrored transport registry is authoritative before the optional Pi
69
- * catalog. Unknown registry pairs still pass through the existing privacy
70
- * classifier, so private IDs can only become custom/unknown.
69
+ /** Provider-only normalization, independent of any specific model id: trims,
70
+ * lowercases, and keeps the slug only when it matches the schema provider
71
+ * pattern within its maxLength. Non-string/empty input is "unknown"; a
72
+ * non-conforming non-empty string is "custom". Shared by the standalone
73
+ * provider dimension and by normalizeRuntimeModel's own provider handling.
71
74
  */
72
- export function classifyRuntimeModelId(provider: unknown, modelId: unknown,
73
- classifyModel: typeof classifyPiCatalogName = classifyPiCatalogName): string {
74
- if (typeof provider === "string" && typeof modelId === "string"
75
- && REGISTERED_MODELS.has(JSON.stringify([provider, modelId]))) return modelId;
76
- return classifyModel({ provider, modelId }).modelId;
75
+ export function normalizeRuntimeProvider(value: unknown): string {
76
+ if (typeof value !== "string") return "unknown";
77
+ const trimmed = value.trim();
78
+ if (!trimmed) return "unknown";
79
+ const lower = trimmed.toLowerCase();
80
+ return lower.length <= MODEL_PROVIDER_RULE.maxLength && MODEL_PROVIDER_RULE.pattern.test(lower) ? lower : "custom";
81
+ }
82
+
83
+ /** Generic, schema-driven family-pattern normalizer for a (provider, id)
84
+ * selection or response pair (gentle-pi#968 / gentle-ai#4536). Open-weight
85
+ * models on arbitrary providers are reported by name; private aliases and
86
+ * fine-tunes stay custom. Rules, identical to the mirrored Go/schema side:
87
+ * - a non-string or empty provider or id fails closed to unknown/unknown;
88
+ * - id: trim, keep the LAST "/"-separated segment, lowercase; public only
89
+ * when it matches the schema id pattern within its maxLength, otherwise
90
+ * "custom";
91
+ * - provider: trim, lowercase; kept when it matches the schema provider
92
+ * pattern, otherwise "custom";
93
+ * - when the id is not public the provider becomes "custom" too, except
94
+ * "opencode", which stays opencode/custom;
95
+ * - the literal unknown/unknown and custom/custom pairs pass through.
96
+ */
97
+ export function normalizeRuntimeModel(provider: unknown, id: unknown): { provider: string; id: string } {
98
+ if (typeof provider !== "string" || typeof id !== "string") return { provider: "unknown", id: "unknown" };
99
+ const providerTrimmed = provider.trim();
100
+ const idTrimmed = id.trim();
101
+ if (!providerTrimmed || !idTrimmed) return { provider: "unknown", id: "unknown" };
102
+ const providerLower = providerTrimmed.toLowerCase();
103
+ const idSegment = idTrimmed.split("/").pop() ?? "";
104
+ const idLower = idSegment.toLowerCase();
105
+ if (providerLower === "unknown" && idLower === "unknown") return { provider: "unknown", id: "unknown" };
106
+ if (providerLower === "custom" && idLower === "custom") return { provider: "custom", id: "custom" };
107
+ const idPublic = idLower.length <= MODEL_ID_RULE.maxLength && MODEL_ID_RULE.pattern.test(idLower);
108
+ const id_ = idPublic ? idLower : "custom";
109
+ let providerResult = normalizeRuntimeProvider(providerTrimmed);
110
+ if (!idPublic) providerResult = providerResult === "opencode" ? "opencode" : "custom";
111
+ return { provider: providerResult, id: id_ };
112
+ }
113
+
114
+ /** Thin wrapper over normalizeRuntimeModel for callers that only track the
115
+ * id dimension (the provider is used only to decide the closed unknown/empty
116
+ * fast path; a valid non-empty provider string never changes the id result).
117
+ */
118
+ export function classifyRuntimeModelId(provider: unknown, modelId: unknown): string {
119
+ return normalizeRuntimeModel(provider, modelId).id;
120
+ }
121
+
122
+ /** True only for an id already in its normalized public form: it matches the
123
+ * schema id pattern within its maxLength (which, by construction, also
124
+ * excludes the "unknown"/"custom" sentinels, since neither matches any
125
+ * recognized family prefix). Used to pick the strongest available evidence
126
+ * tier among already-classified id dimensions (response/selected/observed).
127
+ */
128
+ export function isPublicRuntimeModelId(value: unknown): value is string {
129
+ return typeof value === "string" && value.length > 0 && value.length <= MODEL_ID_RULE.maxLength && MODEL_ID_RULE.pattern.test(value);
77
130
  }
78
131
 
79
132
  type Missing = { state: "unavailable" | "unsupported" };
@@ -84,20 +137,23 @@ export interface FinalResponse {
84
137
  /** Local dedupe only: 1..128 UTF-16 code units; never exported. */
85
138
  responseId: string;
86
139
  /** Caller-observed selected SDK model ID, never dispatched/response identity.
87
- * Catalog must be loaded before accounting; only public catalog names survive.
140
+ * Normalized through normalizeRuntimeModel at record time; only an id
141
+ * matching the schema family pattern survives as a public name.
88
142
  */
89
143
  selectedModelId?: string;
90
144
  /** Selection namespace, independent from observed response provider.
91
145
  * Omission preserves legacy same-provider callers; adapters must pass it explicitly.
92
146
  */
93
- selectedProvider?: typeof PROVIDERS[number];
147
+ selectedProvider?: string;
94
148
  agentClass?: AgentClass;
95
- /** Public catalog names from SDK response metadata, never endpoint proof. */
149
+ /** SDK-observed model id from response metadata, never endpoint proof.
150
+ * Normalized like selectedModelId; only a schema family match survives.
151
+ */
96
152
  observedModelId?: string;
97
153
  responseModelId?: string;
98
154
  providerThinkingLevel?: typeof EFFORTS[number];
99
155
  executor: typeof EXECUTORS[number];
100
- provider: typeof PROVIDERS[number];
156
+ provider: string;
101
157
  modelFamily: typeof FAMILIES[number];
102
158
  effort: typeof EFFORTS[number];
103
159
  error: typeof ERRORS[number];
@@ -195,17 +251,14 @@ export class RuntimeMetrics {
195
251
  #buckets = new Map<string, RuntimeMetricBucket>();
196
252
  #maxResponses: number;
197
253
  #maxBuckets: number;
198
- #classifyModel: typeof classifyPiCatalogName;
199
254
 
200
- constructor({ maxResponses = 1024, maxBuckets = 64, classifyModel = classifyPiCatalogName }:
201
- { maxResponses?: number; maxBuckets?: number; classifyModel?: typeof classifyPiCatalogName } = {}) {
255
+ constructor({ maxResponses = 1024, maxBuckets = 64 }: { maxResponses?: number; maxBuckets?: number } = {}) {
202
256
  if (!Number.isInteger(maxResponses) || maxResponses < 1 || maxResponses > 1024
203
257
  || !Number.isInteger(maxBuckets) || maxBuckets < 1 || maxBuckets > 64) {
204
258
  throw new RangeError("Invalid runtime metrics capacity");
205
259
  }
206
260
  this.#maxResponses = maxResponses;
207
261
  this.#maxBuckets = maxBuckets;
208
- this.#classifyModel = classifyModel;
209
262
  }
210
263
 
211
264
  record(response: FinalResponse): "recorded" | "duplicate" | "invalid" | "capacity" {
@@ -215,13 +268,13 @@ export class RuntimeMetrics {
215
268
  const dimensions = {
216
269
  hostAgent: "pi" as const,
217
270
  agentClass: category(AGENT_CLASSES, response.agentClass, UNKNOWN_AGENT_CLASS),
218
- observedModelId: classifyRuntimeModelId(response.provider, response.observedModelId, this.#classifyModel),
219
- responseModelId: classifyRuntimeModelId(response.provider, response.responseModelId, this.#classifyModel),
271
+ observedModelId: classifyRuntimeModelId(response.provider, response.observedModelId),
272
+ responseModelId: classifyRuntimeModelId(response.provider, response.responseModelId),
220
273
  providerThinkingLevel: category(EFFORTS, response.providerThinkingLevel, "unavailable"),
221
- selectedModelId: classifyRuntimeModelId(selectedProvider, response.selectedModelId, this.#classifyModel),
222
- selectedProvider: category(PROVIDERS, selectedProvider, typeof selectedProvider === "string" && selectedProvider ? "custom" : "unknown"),
274
+ selectedModelId: classifyRuntimeModelId(selectedProvider, response.selectedModelId),
275
+ selectedProvider: normalizeRuntimeProvider(selectedProvider),
223
276
  executor: category(EXECUTORS, response.executor, "unknown"),
224
- provider: category(PROVIDERS, response.provider, typeof response.provider === "string" && response.provider ? "custom" : "unknown"),
277
+ provider: normalizeRuntimeProvider(response.provider),
225
278
  modelFamily: category(FAMILIES, response.modelFamily, typeof response.modelFamily === "string" && response.modelFamily ? "custom" : "unknown"),
226
279
  effort: response.effort,
227
280
  error: response.error,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -967,6 +967,20 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
967
967
  // so this row repeats 2.7.0. riskEvidence and hint remain dark because
968
968
  // neither is proven to reach the negotiated START path Pi consumes.
969
969
  "2.8.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
970
+ // v2.8.1 only changed runtime telemetry model attribution (gentle-ai#4536);
971
+ // the closed fields Pi consumes did not change between 2.8.0 and 2.8.1, so
972
+ // this row repeats 2.8.0 exactly. riskEvidence and hint remain dark
973
+ // because neither is proven to reach the negotiated START path Pi consumes.
974
+ "2.8.1": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
975
+ // v2.8.2 shipped OpenCode SDD preflight plugin fixes, community-tools RTK
976
+ // acquisition, and Claude Code Stop telemetry. The provider contract semver
977
+ // stays 1.2.0; the same pin re-mirrors bundle bytes that had drifted under
978
+ // that semver (lens inspection.status "unavailable", targeted-validator
979
+ // regressions/inspection members, seven Pi stop reason codes). None of
980
+ // those touch the closed START/STATUS fields this row negotiates, so it
981
+ // repeats 2.8.1 exactly. riskEvidence and hint remain dark because neither
982
+ // is proven to reach the negotiated START path Pi consumes.
983
+ "2.8.2": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
970
984
  });
971
985
 
972
986