zob-harness 0.13.0 → 0.15.0

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 (31) hide show
  1. package/.pi/capabilities/zob-public-runtime-capabilities.json +22 -2
  2. package/.pi/extensions/zob-harness/index.ts +73 -0
  3. package/.pi/extensions/zob-harness/src/core/constants.ts +3 -3
  4. package/.pi/extensions/zob-harness/src/domains/capability/AGENTS.md +72 -0
  5. package/.pi/extensions/zob-harness/src/domains/capability/capability-contract.ts +121 -0
  6. package/.pi/extensions/zob-harness/src/domains/capability/launch-gate.ts +185 -0
  7. package/.pi/extensions/zob-harness/src/domains/capability/nudge-policy.ts +397 -0
  8. package/.pi/extensions/zob-harness/src/domains/capability/primitives.ts +192 -0
  9. package/.pi/extensions/zob-harness/src/domains/capability/types.ts +94 -0
  10. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/envelope.ts +45 -1
  11. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/local-transport.ts +23 -4
  12. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/local-transport.ts.bak.1781340854 +147 -0
  13. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/pending-replies.ts +33 -5
  14. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts +127 -2
  15. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/response-capture.ts +2 -0
  16. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +133 -24
  17. package/.pi/extensions/zob-harness/src/runtime/commands/zlive.ts +152 -16
  18. package/.pi/extensions/zob-harness/src/runtime/events.ts +306 -49
  19. package/.pi/extensions/zob-harness/src/runtime/schemas.ts +13 -2
  20. package/.pi/extensions/zob-harness/src/runtime/state.ts +27 -3
  21. package/.pi/extensions/zob-harness/src/runtime/tools-coms.ts +98 -19
  22. package/.pi/prompts/implement.md +1 -0
  23. package/.pi/prompts/orchestrator.md +2 -1
  24. package/.pi/skills/zob-coms-safety/SKILL.md +5 -0
  25. package/.pi/skills/zob-coms-v2-live/SKILL.md +5 -1
  26. package/.pi/skills/zob-goal-todo-tree/SKILL.md +1 -1
  27. package/.pi/skills/zob-harness/SKILL.md +1 -1
  28. package/package.json +1 -1
  29. package/scripts/context-discovery/query.mjs +9 -2
  30. package/scripts/zpeer-local-e2e-smoke.mjs +104 -2
  31. package/scripts/zpeer-static-smoke.mjs +36 -9
@@ -719,7 +719,27 @@
719
719
  "docRefs": [
720
720
  ".pi/extensions/zob-harness/src/AGENTS.md"
721
721
  ],
722
- "noShipNotes": "Agent-initiated visible ZPeer ask; local_socket only, room-scoped with optional explicit roomId, mode defaults async, rate/loop guarded, raw bodies transient and durable records hash-only."
722
+ "noShipNotes": "Agent-initiated visible ZPeer ask; local_socket only, room-scoped with optional explicit roomId, mode defaults async, opt-in requireResponse tracks msgId-correlated replies with bounded timeout/reinjection metadata, normal/urgent/force are rate/loop guarded, force requires a hashed reason and role/topology guards, raw bodies transient and durable records hash-only."
723
+ },
724
+ {
725
+ "name": "zpeer_reply",
726
+ "family": "coms-v2-live",
727
+ "modes": [
728
+ "explore",
729
+ "plan",
730
+ "implement",
731
+ "oracle",
732
+ "factory",
733
+ "orchestrator"
734
+ ],
735
+ "skillRefs": [
736
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
737
+ ".pi/skills/zob-coms-safety/SKILL.md"
738
+ ],
739
+ "docRefs": [
740
+ ".pi/extensions/zob-harness/src/AGENTS.md"
741
+ ],
742
+ "noShipNotes": "Explicit msgId-bound ZPeer reply path for active inbound messages; local_socket only, requires exact active msgId, wrong/expired/already-answered msgIds are blocked, raw reply body is transient and durable metadata stores outputHash/status only."
723
743
  },
724
744
  {
725
745
  "name": "zob_goal_room_send",
@@ -1671,7 +1691,7 @@
1671
1691
  "AGENTS.md",
1672
1692
  ".pi/extensions/zob-harness/src/AGENTS.md"
1673
1693
  ],
1674
- "noShipNotes": "Local-only multi-room-scoped peer UX with active-room compatibility; transient prompt/response over local_socket, persisted command/mission metadata is hash/body-free."
1694
+ "noShipNotes": "Local-only multi-room-scoped peer UX with active-room compatibility; supports /zpeer reply <msgId> <response> and --require-response; transient prompt/response over local_socket, persisted command/mission metadata is hash/body-free."
1675
1695
  },
1676
1696
  {
1677
1697
  "name": "zagent",
@@ -352,6 +352,79 @@ export type {
352
352
  AutoResolveVerdict,
353
353
  ResolutionStrategyFn,
354
354
  } from "./src/domains/environment/auto-resolve.js";
355
+ // WS-CH1 (capability-validation PART II keystone): the typed CapabilityContract
356
+ // pillar (the 5th pillar alongside computeWorklist/EvidenceContract/EnvironmentContract).
357
+ // The contract shape + registry + PURE primitives (no node:fs/node:child_process); the
358
+ // body + readManifest IO + the role->required-tools map are project-registered (WS-CH3).
359
+ // Metadata-only / body-free / network-disabled: AgentManifest/RoleRequirement/
360
+ // CapabilityVerdict carry agent ids, tool names, mode names, manifest paths, fixCommand
361
+ // strings only; FORBIDDEN_PLAINTEXT_KEYS applies (reused from the worklist domain).
362
+ // CRITICAL SAFETY: NO auto-resolve on the contract (manifest edit = security-sensitive =
363
+ // operator-gated, unlike Round 4 env auto-resolve).
364
+ export {
365
+ buildFixPacket,
366
+ compareCapability,
367
+ manifestHasTool,
368
+ modePermitsWrite,
369
+ requiredToolsForRole,
370
+ } from "./src/domains/capability/primitives.js";
371
+ export type { CapabilityFixPacketEntry } from "./src/domains/capability/primitives.js";
372
+ export {
373
+ capabilityBodyFreeViolations,
374
+ listCapabilityContractIds,
375
+ registerCapabilityContract,
376
+ resolveCapabilityContract,
377
+ } from "./src/domains/capability/capability-contract.js";
378
+ export type { CapabilityContract } from "./src/domains/capability/capability-contract.js";
379
+ export type {
380
+ AgentManifest,
381
+ CapabilityVerdict,
382
+ RoleName,
383
+ RoleRequirement,
384
+ } from "./src/domains/capability/types.js";
385
+ // WS-CH2 (capability-validation PART II): the launch-time gate primitive + the
386
+ // runtime nudge-backoff/gap primitives. runCapabilityGate reads each manifest once
387
+ // (via the contract's readManifest), evaluates every agent against its role's
388
+ // requirement, returns { ok, verdicts, fix_packet, shouldStart } with
389
+ // shouldStart === ok BY CONSTRUCTION (fail-closed, no opt-out). Pure over
390
+ // (contract, agentIds); never starts anything itself (mechanism in harness, action
391
+ // in app). Idempotent + re-runnable (re-reads manifests on re-call). The nudge-
392
+ // policy primitives compose the supervisor anti-spam policy: planBackoffNudge
393
+ // (60s→2m→5m→15m cap + the structural capability_gap_stop), detectCapabilityGap
394
+ // (gates escalation — slow-but-capable stays gap===false), capabilityGapFixPacket
395
+ // (the metadata-only alert_no_ship fix packet), transitionOnCapabilityGap (forces
396
+ // the terminal capability_gap:true stop once gap===true). Metadata-only /
397
+ // body-free / network-disabled. No node:fs/node:child_process (the purity grep
398
+ // returns nothing). CRITICAL SAFETY: NO auto-resolve (manifest edit = security-
399
+ // sensitive = operator-gated, intentional divergence from Round 4 env auto-resolve).
400
+ export { runCapabilityGate } from "./src/domains/capability/launch-gate.js";
401
+ // NOTE: aliased as CapabilityLaunchGateOptions / CapabilityLaunchGateResult to
402
+ // avoid a name collision with the WS-PH2 environment pillar's identically-named
403
+ // LaunchGateOptions / LaunchGateResult exports above (index.ts ~L330). Both
404
+ // shapes exist; consumers pick the capability gate via the `Capability*` prefix.
405
+ export type {
406
+ LaunchGateOptions as CapabilityLaunchGateOptions,
407
+ LaunchGateResult as CapabilityLaunchGateResult,
408
+ } from "./src/domains/capability/launch-gate.js";
409
+ export {
410
+ DEFAULT_NUDGE_SCHEDULE,
411
+ NUDGE_BACKOFF_CAP_MS,
412
+ capabilityGapFixPacket,
413
+ detectCapabilityGap,
414
+ planBackoffNudge,
415
+ transitionOnCapabilityGap,
416
+ } from "./src/domains/capability/nudge-policy.js";
417
+ export type {
418
+ CapabilityGapAction,
419
+ CapabilityGapFixPacket,
420
+ CapabilityGapTransition,
421
+ DetectCapabilityGapInput,
422
+ DriverRecord,
423
+ GapResult,
424
+ NudgePlan,
425
+ NudgeSchedule,
426
+ PlanBackoffNudgeInput,
427
+ } from "./src/domains/capability/nudge-policy.js";
355
428
  export { DEFAULT_PROMOTION_GATES, advancePromotionCandidate, appendPromotionLedger, createPromotionCandidate, promotionCandidateDir, promotionCandidateRef, promotionReportsDir, summarizePromotionCandidates, transitionAllowed, validatePromotionCandidate, writePromotionCandidate } from "./src/domains/promotion/candidate.js";
356
429
  export { addPromotionComsMessageRef, buildPromotionComsMessageRef, buildPromotionComsThread, validatePromotionComsMessageRef, validatePromotionComsReadiness, validatePromotionComsThread, writePromotionComsThread } from "./src/domains/promotion/coms.js";
357
430
  export { applyDocumentationPromotionInQuarantine, prepareDocumentationPromotion, validateDocumentationPromotion, validateDocumentationPromotionCandidate } from "./src/domains/promotion/documentation.js";
@@ -46,7 +46,7 @@ export const SUPERVISED_READONLY_CHILD_TOOLS = ["read", "grep", "find", "ls"] as
46
46
  export const READ_ONLY_CHAIN_TOOLS = ["read", "grep", "find", "ls"] as const;
47
47
  export const BLOCKED_CHAIN_TOOLS = ["bash", "edit", "write", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run"] as const;
48
48
 
49
- export const ZOB_COMS_TOOLS = ["zob_coms_send", "zob_coms_ack", "zob_coms_status", "zob_coms_reply", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask"] as const;
49
+ export const ZOB_COMS_TOOLS = ["zob_coms_send", "zob_coms_ack", "zob_coms_status", "zob_coms_reply", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply"] as const;
50
50
  export const ZOB_GOAL_ROOM_TOOLS = ["zob_goal_room_send", "zob_goal_room_list"] as const;
51
51
  export const ZOB_GOVERNED_REQUEST_TOOLS = ["zob_governed_request_extract"] as const;
52
52
  export const ZOB_WORKSPACE_CLAIM_TOOLS = ["zob_workspace_claim", "zob_workspace_release", "zob_workspace_claims_list"] as const;
@@ -70,10 +70,10 @@ export const ZOB_AUTONOMOUS_READ_TOOLS = ["zob_autonomous_validate_run", "zob_au
70
70
  export const ZOB_AUTONOMOUS_FACTORY_TOOLS = ["zob_autonomous_dry_run", "zob_autonomous_readonly_smoke"] as const;
71
71
 
72
72
  export const MODE_TOOLS: Record<ModeName, string[]> = {
73
- explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
73
+ explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
74
74
  plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
75
75
  implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
76
- oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
76
+ oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
77
77
  orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS],
78
78
  factory: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_AUTONOMOUS_FACTORY_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_WORKLIST_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
79
79
  // Vanilla is handled specially by applyMode: all currently available Pi tools are enabled.
@@ -0,0 +1,72 @@
1
+ # Capability domain (`src/domains/capability/`)
2
+
3
+ WS-CH1 keystone of the capability-validation extraction (PART II). The FIFTH
4
+ harness pillar alongside `computeWorklist` (Round 2), `EvidenceContract`
5
+ (Round 3), and `EnvironmentContract` (Round 4): a typed `CapabilityContract` +
6
+ registry + PURE check primitives that shift agent-capability validation (an
7
+ agent's `allowedTools`/`defaultMode` vs the protocol's per-role required tools)
8
+ from late runtime discovery (a stall + 59 nudge-spams when a structurally
9
+ incapable agent is launched) to an early fail-closed launch gate every consumer
10
+ gets for free.
11
+
12
+ ## Shape provenance
13
+
14
+ - `AgentManifest` / `RoleRequirement` / `CapabilityVerdict` / `CapabilityContract`
15
+ mirror the WS-CH1 plan signatures (adapted JSDoc typedefs -> TS interfaces;
16
+ camelCase harness convention) verbatim. `registerCapabilityContract` /
17
+ `resolveCapabilityContract` / `listCapabilityContractIds` Map registry mirrors
18
+ `environment-contract.ts` (Round 4) EXACTLY.
19
+ - The registry is keyed by `reducerId` (projects register under their
20
+ `reducer_id`; the transposer registers `reducerId='project-transposer'` in
21
+ WS-CH3). A missing contract resolves to `undefined` (typed-missing, NOT a
22
+ silent default) — same plan-specified divergence as `resolveEnvironmentContract`.
23
+
24
+ ## Purity contract (headline acceptance)
25
+
26
+ - `primitives.ts` imports NO `node:fs`, NO `node:child_process`, NO
27
+ `spawnSync`/`readdirSync`/`readFileSync`/`exec`. It is PURE over the
28
+ `AgentManifest` / `RoleRequirement` / `CapabilityVerdict` data structures the
29
+ caller passes (the ONLY IO seam is the project-registered `readManifest`, which
30
+ the harness never calls — it is a signature only in CH1).
31
+ - `types.ts` + `capability-contract.ts` import ONLY `src/core/**` + siblings
32
+ (`./types.js`, `../worklist/types.js` for `FORBIDDEN_PLAINTEXT_KEYS`). No
33
+ runtime; no `@earendil-works/pi-coding-agent`. This keeps the domain reusable by
34
+ transposer/pacman-style projections.
35
+
36
+ ## MUST DO
37
+
38
+ - Keep the contract SHAPE-ONLY; the body (`evaluateCapability` dispatch +
39
+ `readManifest` IO + the role->required-tools map) is project-registered.
40
+ `readManifest` is the ONLY IO and is project-supplied (the harness defines the
41
+ signature only). Primitives stay pure over the inputs. Metadata-only /
42
+ body-free / network-disabled: agent ids, tool names, mode names, manifest paths,
43
+ fixCommand strings only.
44
+
45
+ ## MUST NOT
46
+
47
+ - No `node:fs` / `node:child_process` (or `spawnSync`/`readdirSync`/`exec`) in
48
+ `primitives.ts`. No raw bodies/prompts/diffs/secrets in any manifest,
49
+ requirement, verdict, or fix packet. No network. No change to the worklist or
50
+ environment domains (read-only import of `FORBIDDEN_PLAINTEXT_KEYS`).
51
+ - **CRITICAL SAFETY DIVERGENCE from Round 4**: NO auto-resolve method on the
52
+ contract (no `applyAutoResolve`, no auto-fix). Editing a ZAgent manifest changes
53
+ an agent's authority (allowedTools/defaultMode) = SECURITY-SENSITIVE = always
54
+ operator-gated, unlike Round 4's reversible environment auto-resolve. The
55
+ contract evaluates + surfaces a fix packet; applying the edit is app/operator-side.
56
+
57
+ ## Files
58
+
59
+ - `types.ts` — `RoleName`, `AgentManifest`, `RoleRequirement`, `CapabilityVerdict`
60
+ (all readonly, metadata-only).
61
+ - `capability-contract.ts` — `CapabilityContract`, `registerCapabilityContract` /
62
+ `resolveCapabilityContract` / `listCapabilityContractIds` Map registry,
63
+ `capabilityBodyFreeViolations`.
64
+ - `primitives.ts` — `manifestHasTool`, `modePermitsWrite`, `requiredToolsForRole`,
65
+ `compareCapability`, `buildFixPacket` (PURE over the inputs; result interface
66
+ `CapabilityFixPacketEntry` exported).
67
+
68
+ ## Validation
69
+
70
+ - `npm run check -- --pretty false` clean; `node --import tsx --test "test/capability/*"` green.
71
+ - `grep primitives.ts for direct fs / process-spawn / exec tokens` returns
72
+ NOTHING (the purity proof).
@@ -0,0 +1,121 @@
1
+ // ZOB Harness — Agent capability validation contract + registry (WS-CH1 keystone,
2
+ // capability-validation PART II).
3
+ //
4
+ // The FIFTH PILLAR alongside computeWorklist (Round 2), EvidenceContract
5
+ // (Round 3), and EnvironmentContract (Round 4): a typed CapabilityContract that
6
+ // every multi-agent consumer registers under its `reducer_id`, mirroring
7
+ // registerEnvironmentContract's Map registry EXACTLY. The harness ships the SHAPE
8
+ // (the contract interface + the pure primitives) + the registry; the BODY
9
+ // (which roles need which tools) + the manifest READER (`readManifest` IO) are
10
+ // project-registered (WS-CH3, deferred). CH1-style: shape-only, deferred body.
11
+ //
12
+ // CRITICAL SAFETY DIVERGENCE from Round-4 EnvironmentContract (documented here so
13
+ // it is never accidentally re-introduced): there is NO auto-resolve method on
14
+ // this contract. Round 4's EnvironmentContract had `snapshotEnvironment` + a
15
+ // WS-PH3 `applyAutoResolve` framework (allowlisted + REVERSIBLE env fixes). A
16
+ // capability gap is NOT auto-resolvable: editing a ZAgent manifest changes an
17
+ // agent's authority (its allowedTools/defaultMode), which is a SECURITY-SENSITIVE
18
+ // operation that must stay operator-gated. This contract therefore exposes ONLY
19
+ // `evaluateCapability` (pure verdict) + `readManifest` (project-supplied IO) +
20
+ // `requirements` (project-supplied role→required-tools body). The fix packet the
21
+ // verdict surfaces is the END of what the harness does; applying the manifest edit
22
+ // is app/operator-side (WS-CH2 launch-gate will turn a failing verdict into a
23
+ // fail-closed block + a printed fix_command, never an auto-edit). This is the
24
+ // same split as Round 4's `runLaunchGate` (mechanism in harness, action in app)
25
+ // EXCEPT there is intentionally no `applyAutoResolve` sibling — the action is
26
+ // always a human edit.
27
+ //
28
+ // Purity contract: imports ONLY from src/core/** + siblings (../worklist/types.js
29
+ // for FORBIDDEN_PLAINTEXT_KEYS, ./types.js for the typed shapes — type-only,
30
+ // erased at runtime). No IO; no runtime; no @earendil-works/pi-coding-agent
31
+ // types. Metadata-only / body-free / network-disabled: CapabilityContract
32
+ // consumers carry agent ids, tool names, mode names, manifest paths, and
33
+ // fixCommand strings only; FORBIDDEN_PLAINTEXT_KEYS is enforced on every value
34
+ // that enters the contract via capabilityBodyFreeViolations.
35
+ import { FORBIDDEN_PLAINTEXT_KEYS } from "../worklist/types.js";
36
+ import type {
37
+ AgentManifest,
38
+ CapabilityVerdict,
39
+ RoleName,
40
+ RoleRequirement,
41
+ } from "./types.js";
42
+
43
+ // --- The capability contract ------------------------------------------------
44
+ // Project-registered under its `reducer_id` (the same keying as
45
+ // EnvironmentContract / EvidenceContract / WorklistReducer). `evaluateCapability`
46
+ // is the SINGLE SITE of capability satisfaction: pure over
47
+ // (manifest, requirement); no IO. `readManifest` is the ONLY IO entry point —
48
+ // the project supplies it (reads .pi/zagents/<id>.json -> AgentManifest); CH1
49
+ // defines the signature only and never does IO in the harness. `requirements`
50
+ // returns the project's role→required-tools body (from the protocol's
51
+ // machine-readable `Required tools per role` section / .pi/capabilities/<id>.json).
52
+ export interface CapabilityContract {
53
+ /** Canonical verdict, pure over (manifest, requirement). */
54
+ evaluateCapability(manifest: AgentManifest, requirement: RoleRequirement): CapabilityVerdict;
55
+ /** Project-supplied manifest reader (.pi/zagents/<id>.json -> AgentManifest). The ONLY IO. */
56
+ readManifest(agentId: string): AgentManifest;
57
+ /** Project-supplied role->required-tools body (from protocol / .pi/capabilities). */
58
+ requirements(): Record<RoleName, RoleRequirement>;
59
+ }
60
+
61
+ // --- Body-free enforcement ---------------------------------------------------
62
+ // Mirrors environmentBodyFreeViolations / evidenceBodyFreeViolations /
63
+ // worklistBodyFreeViolations: recursively scan an AgentManifest / RoleRequirement
64
+ // / CapabilityVerdict / fix-packet value and report any FORBIDDEN_PLAINTEXT_KEYS
65
+ // found (body/task/prompt/output/content/message/text/rationale/diff/patch).
66
+ // Returns [] for a clean value. The launch gate (WS-CH2) will call this before
67
+ // threading a manifest set into the contract; CH1 exposes it now so the contract
68
+ // is body-free from the first use.
69
+ export function capabilityBodyFreeViolations(value: unknown): string[] {
70
+ const violations: string[] = [];
71
+ const visit = (item: unknown, path: string): void => {
72
+ if (!item || typeof item !== "object") return;
73
+ if (Array.isArray(item)) {
74
+ for (let index = 0; index < item.length; index += 1) visit(item[index], `${path}[${index}]`);
75
+ return;
76
+ }
77
+ for (const [key, child] of Object.entries(item as Record<string, unknown>)) {
78
+ if (FORBIDDEN_PLAINTEXT_KEYS.has(key)) violations.push(`${path}.${key}`);
79
+ visit(child, `${path}.${key}`);
80
+ }
81
+ };
82
+ visit(value, "root");
83
+ return violations;
84
+ }
85
+
86
+ // --- Capability contract registry -------------------------------------------
87
+ // Mirrors the EnvironmentContract registry (environment-contract.ts
88
+ // registerEnvironmentContract / resolveEnvironmentContract /
89
+ // listEnvironmentContractIds) and the EvidenceContract / WorklistReducer
90
+ // registries: keyed by reducerId (projects register under their reducer_id; the
91
+ // transposer will register reducerId='project-transposer' in WS-CH3). A MISSING
92
+ // contract resolves to `undefined` — a typed-missing signal, NOT a silent
93
+ // default — so the caller can fail-closed explicitly. (Same deliberate,
94
+ // plan-specified divergence as resolveEnvironmentContract: the WS-CH1 plan
95
+ // signature is `CapabilityContract | undefined`; the launch gate WS-CH2 will turn
96
+ // undefined into an explicit fail-closed verdict.)
97
+ const CAPABILITY_CONTRACTS = new Map<string, CapabilityContract>();
98
+
99
+ export function registerCapabilityContract(
100
+ reducerId: string,
101
+ contract: CapabilityContract,
102
+ ): void {
103
+ if (!reducerId) throw new Error("registerCapabilityContract: reducerId is required");
104
+ if (!contract || typeof contract !== "object")
105
+ throw new Error(`registerCapabilityContract('${reducerId}'): contract is required`);
106
+ if (typeof contract.evaluateCapability !== "function")
107
+ throw new Error(`CapabilityContract '${reducerId}' is missing evaluateCapability(manifest, requirement)`);
108
+ if (typeof contract.readManifest !== "function")
109
+ throw new Error(`CapabilityContract '${reducerId}' is missing readManifest(agentId)`);
110
+ if (typeof contract.requirements !== "function")
111
+ throw new Error(`CapabilityContract '${reducerId}' is missing requirements()`);
112
+ CAPABILITY_CONTRACTS.set(reducerId, contract);
113
+ }
114
+
115
+ export function resolveCapabilityContract(reducerId: string): CapabilityContract | undefined {
116
+ return CAPABILITY_CONTRACTS.get(reducerId);
117
+ }
118
+
119
+ export function listCapabilityContractIds(): string[] {
120
+ return [...CAPABILITY_CONTRACTS.keys()];
121
+ }
@@ -0,0 +1,185 @@
1
+ // ZOB Harness — Agent capability launch-time gate primitive (WS-CH2 keystone,
2
+ // capability-validation PART II).
3
+ //
4
+ // The launch-time GATE on top of the WS-CH1 CapabilityContract: a pure primitive
5
+ // `runCapabilityGate(contract, agentIds, options)` that reads each manifest ONCE
6
+ // (via the contract's project-registered readManifest), evaluates every agent
7
+ // against its role's requirement (via the contract's evaluateCapability), and
8
+ // returns { ok, verdicts, fix_packet, shouldStart } — a pure verdict the app
9
+ // gates ZTEAM_LAUNCHER start on. It NEVER starts anything itself (mechanism in
10
+ // harness, action in app), preserving the shift-left split: the gate says
11
+ // "should this run start?" and the app acts on the answer. Mirrors the Round-4
12
+ // runLaunchGate invariant verbatim.
13
+ //
14
+ // THE HEADLINE DESIGN is fail-closed BY CONSTRUCTION — shouldStart === ok always,
15
+ // with NO parameter to opt out (the whole point of shifting agent-capability
16
+ // discovery from late runtime inside one agent — where it stalls ~2h and is
17
+ // nudge-spammed 59 times — to early launch when the operator who edits manifests
18
+ // is present). This is the temporal mirror of Round 4 applied to agent
19
+ // CONFIGURATION instead of environmental preconditions.
20
+ //
21
+ // IDEMPOTENT + RE-RUNNABLE: runCapabilityGate re-reads manifests (fresh
22
+ // contract.readManifest per agent) and re-evaluates on every call — it performs
23
+ // NO memoization. This is the property that makes "edit manifest → re-launch"
24
+ // work without a special path: after a manifest edit fixes a failing agent, a
25
+ // second runCapabilityGate call re-reads the now-fixed manifest and re-derives
26
+ // shouldStart === true.
27
+ //
28
+ // CRITICAL SAFETY DIVERGENCE from Round-4 EnvironmentContract (documented so it
29
+ // is never accidentally re-introduced): there is NO auto-resolve here. The gate
30
+ // EVALUATES + SURFACES a fix packet (buildFixPacket); APPLYING the manifest edit
31
+ // is operator-gated (a manifest edit changes an agent's authority =
32
+ // security-sensitive). grep this file for 'applyAutoResolve|autoFix|auto-resolve'
33
+ // returns ONLY this comment block — there is no such method. The Round-4
34
+ // environment launch-gate had an applyAutoResolve sibling (WS-PH3, allowlisted +
35
+ // REVERSIBLE env fixes); this capability gate intentionally does NOT, because
36
+ // editing a ZAgent manifest is a security-sensitive authority change that must
37
+ // stay operator-gated.
38
+ //
39
+ // Purity contract (the headline acceptance): this module performs NO direct
40
+ // filesystem access and NO process spawning. It imports type-only (erased at
41
+ // runtime) from the siblings ./types.js + ./capability-contract.js + a single
42
+ // runtime import of buildFixPacket (PURE) from ./primitives.js. The real IO
43
+ // (reading .pi/zagents/<id>.json into an AgentManifest) lives ENTIRELY in the
44
+ // project-registered readManifest (WS-CH3), never here. (The purity grep over
45
+ // this file — scanning for node:fs / node:child_process / spawnSync /
46
+ // readdirSync / readFileSync / exec( — returns NOTHING.)
47
+ //
48
+ // Metadata-only / body-free / network-disabled: LaunchGateResult /
49
+ // CapabilityFixPacketEntry carry agent ids, tool names, mode names, manifest
50
+ // paths, and fix_command strings only — no prompt bodies, no secrets, no
51
+ // allowedPaths contents.
52
+ import type { CapabilityContract } from "./capability-contract.js";
53
+ import type { AgentManifest, CapabilityVerdict } from "./types.js";
54
+ import { buildFixPacket, type CapabilityFixPacketEntry } from "./primitives.js";
55
+
56
+ // Re-export the fix-packet entry shape so consumers import it from the gate's
57
+ // surface (mirrors the WS-PH2 launch-gate re-exporting FixPacketEntry). The
58
+ // shape is defined in primitives.ts (buildFixPacket); the gate re-exports it so
59
+ // a downstream reader has one import site for the launch-gate result type.
60
+ export type { CapabilityFixPacketEntry } from "./primitives.js";
61
+
62
+ // --- Options ----------------------------------------------------------------
63
+ export interface LaunchGateOptions {
64
+ /**
65
+ * Pluggable clock (deterministic in tests). The verdict itself is pure over
66
+ * (contract, manifests) and time-independent; this hook is reserved for an app
67
+ * to stamp emitted fix_packet entries. Reserved — not consumed by the core
68
+ * flow. (Mirrors the WS-PH2 runLaunchGate `now?` reserved hook.)
69
+ *
70
+ * NOTE: there is NO `manifests?` / `allowStart?` / `force?` option here. The
71
+ * manifests are read via the contract's readManifest (the only IO seam); and
72
+ * shouldStart === ok BY CONSTRUCTION with no opt-out — so no such option can
73
+ * exist. The only option is the reserved clock.
74
+ */
75
+ readonly now?: () => Date;
76
+ }
77
+
78
+ // --- Result -----------------------------------------------------------------
79
+ export interface LaunchGateResult {
80
+ /** True iff EVERY evaluated agent's verdict.ok === true (fail-closed). */
81
+ readonly ok: boolean;
82
+ /** One verdict per evaluated agent (agents with no role requirement are skipped). */
83
+ readonly verdicts: readonly CapabilityVerdict[];
84
+ /** One entry per FAILING agent, in evaluation order. Empty iff ok === true. */
85
+ readonly fix_packet: readonly CapabilityFixPacketEntry[];
86
+ /**
87
+ * === ok BY CONSTRUCTION (fail-closed). There is NO option to opt out of this:
88
+ * the gate refuses to start whenever any agent fails its capability contract.
89
+ * This is the shift-left guarantee — agent capability validated at launch, not
90
+ * ~2h into a run via nudge-spam.
91
+ */
92
+ readonly shouldStart: boolean;
93
+ }
94
+
95
+ // --- The primitive ----------------------------------------------------------
96
+ /**
97
+ * Pure over (contract, agentIds). For each agentId: (1) read the manifest ONCE
98
+ * via contract.readManifest; (2) look up the manifest's role requirement via
99
+ * contract.requirements()[manifest.role] — agents with NO requirement are SKIPPED
100
+ * (permissive for unknown roles, not blocking; documented); (3) evaluate via
101
+ * contract.evaluateCapability(manifest, requirement) (the contract method, so the
102
+ * project can override — it delegates to compareCapability by default);
103
+ * (4) ok = every verdict.ok === true; (5) fix_packet from the FAILING verdicts via
104
+ * buildFixPacket (one entry per failure); (6) shouldStart === ok BY CONSTRUCTION
105
+ * (no opt-out — the headline fail-closed shift-left guarantee).
106
+ *
107
+ * SYNC when readManifest returns synchronously; returns a
108
+ * Promise<LaunchGateResult> ONLY when readManifest returns a Promise (each
109
+ * manifest is read once, then the set is shared across the evaluation). The union
110
+ * return type accommodates both paths.
111
+ *
112
+ * IDEMPOTENT + RE-RUNNABLE: no memoization. A second call after a manifest edit
113
+ * re-reads and re-evaluates, so "edit manifest → re-launch" works without a
114
+ * special path. NEVER starts anything itself — it is a pure verdict the app acts
115
+ * on (same split as Round 4's runLaunchGate).
116
+ *
117
+ * NO AUTO-RESOLVE: this function evaluates + surfaces a fix packet; it does NOT
118
+ * apply any manifest edit. Applying the edit is operator-gated (security-
119
+ * sensitive authority change).
120
+ */
121
+ export function runCapabilityGate(
122
+ contract: CapabilityContract,
123
+ agentIds: readonly string[],
124
+ // `options` is reserved (the `now?` clock hook); the core flow is pure over
125
+ // (contract, agentIds) and does not consume it. Kept on the signature so the
126
+ // reserved hook is available to an app without a breaking change later.
127
+ options: LaunchGateOptions = {},
128
+ ): LaunchGateResult | Promise<LaunchGateResult> {
129
+ // (1) Read each manifest ONCE via the contract's project-registered readManifest.
130
+ // The IO lives in the contract (app-side), NEVER here. readManifest may be
131
+ // sync OR async (a project may register an async reader); the declared
132
+ // WS-CH1 signature is sync, but we tolerate a Promise return at runtime so a
133
+ // project whose reader is async composes without a wrapper. Each manifest is
134
+ // read exactly once (no repeated IO per agent).
135
+ const manifestReads = agentIds.map(
136
+ (agentId) => contract.readManifest(agentId) as AgentManifest | Promise<AgentManifest>,
137
+ );
138
+
139
+ // Build the verdict from resolved manifests. Pure over (contract, manifests).
140
+ const buildResult = (manifests: readonly AgentManifest[]): LaunchGateResult => {
141
+ const requirements = contract.requirements();
142
+ const verdicts: CapabilityVerdict[] = [];
143
+ const fix_packet: CapabilityFixPacketEntry[] = [];
144
+
145
+ for (const manifest of manifests) {
146
+ // (2) Look up the manifest's role requirement. Agents with no declared
147
+ // requirement are SKIPPED (permissive for unknown roles, not blocking).
148
+ // The launch gate evaluates only agents whose role has a protocol-declared
149
+ // tool contract; an unknown role is treated as "no requirement" rather
150
+ // than a blocker (the operator can add a requirement to enforce it).
151
+ const requirement = requirements[manifest.role];
152
+ if (!requirement) continue;
153
+
154
+ // (3) Evaluate via the contract method (delegates to compareCapability by
155
+ // default; the project can override). Single site of capability
156
+ // satisfaction per the WS-CH1 contract.
157
+ const verdict = contract.evaluateCapability(manifest, requirement);
158
+ verdicts.push(verdict);
159
+
160
+ // (5) fix_packet: one entry per FAILING agent, in evaluation order. Built
161
+ // from the verdict + the manifest + the requirement via buildFixPacket
162
+ // (the metadata-only remediation shape from primitives.ts).
163
+ if (!verdict.ok) {
164
+ fix_packet.push(buildFixPacket(verdict, manifest, requirement));
165
+ }
166
+ }
167
+
168
+ // (4) ok iff EVERY verdict.ok === true (fail-closed — any single failure
169
+ // blocks start). An empty verdict set (no agents had requirements) is ok.
170
+ const ok = verdicts.every((v) => v.ok === true);
171
+
172
+ // (6) shouldStart === ok BY CONSTRUCTION. No option opts out of fail-closed.
173
+ // This is the headline shift-left guarantee — derived from ok, never set
174
+ // independently.
175
+ return { ok, verdicts, fix_packet, shouldStart: ok };
176
+ };
177
+
178
+ // SYNC when every readManifest returns synchronously; ASYNC only when at least
179
+ // one readManifest returns a Promise (await them all via Promise.all, then
180
+ // build the result once over the resolved set).
181
+ if (manifestReads.some((read) => read instanceof Promise)) {
182
+ return Promise.all(manifestReads).then(buildResult);
183
+ }
184
+ return buildResult(manifestReads as readonly AgentManifest[]);
185
+ }