pi-daddy 0.18.1 → 0.20.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 (218) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/README.md +136 -23
  3. package/contracts/ledger/v2/README.md +59 -0
  4. package/contracts/ledger/v2/fixtures/capability-decision.json +95 -0
  5. package/contracts/ledger/v2/fixtures/check-receipt.json +40 -0
  6. package/contracts/ledger/v2/fixtures/child-lifecycle.json +42 -0
  7. package/contracts/ledger/v2/fixtures/workspace-lease.json +41 -0
  8. package/contracts/ledger/v2/ledger-event.schema.json +633 -0
  9. package/contracts/ledger/v3/README.md +36 -0
  10. package/contracts/ledger/v3/fixtures/capability-decision.json +98 -0
  11. package/contracts/ledger/v3/fixtures/check-receipt.json +42 -0
  12. package/contracts/ledger/v3/fixtures/child-lifecycle.json +45 -0
  13. package/contracts/ledger/v3/fixtures/workflow-fact.json +41 -0
  14. package/contracts/ledger/v3/fixtures/workspace-lease.json +43 -0
  15. package/contracts/ledger/v3/ledger-event.schema.json +930 -0
  16. package/dist/approval-prompt.d.ts +2 -1
  17. package/dist/approval-prompt.d.ts.map +1 -1
  18. package/dist/approval-prompt.js +9 -0
  19. package/dist/approval-prompt.js.map +1 -1
  20. package/dist/approval.d.ts +4 -2
  21. package/dist/approval.d.ts.map +1 -1
  22. package/dist/approval.js +4 -0
  23. package/dist/approval.js.map +1 -1
  24. package/dist/capabilities.d.ts +90 -0
  25. package/dist/capabilities.d.ts.map +1 -1
  26. package/dist/capabilities.js +114 -3
  27. package/dist/capabilities.js.map +1 -1
  28. package/dist/catalog.d.ts +15 -1
  29. package/dist/catalog.d.ts.map +1 -1
  30. package/dist/catalog.js +54 -3
  31. package/dist/catalog.js.map +1 -1
  32. package/dist/check-runner.d.ts.map +1 -1
  33. package/dist/check-runner.js +19 -7
  34. package/dist/check-runner.js.map +1 -1
  35. package/dist/cli.d.ts.map +1 -1
  36. package/dist/cli.js +21 -1
  37. package/dist/cli.js.map +1 -1
  38. package/dist/correlation.d.ts.map +1 -1
  39. package/dist/correlation.js +10 -0
  40. package/dist/correlation.js.map +1 -1
  41. package/dist/dashboard-cli.d.ts +18 -0
  42. package/dist/dashboard-cli.d.ts.map +1 -0
  43. package/dist/dashboard-cli.js +154 -0
  44. package/dist/dashboard-cli.js.map +1 -0
  45. package/dist/dashboard-handshake.d.ts +37 -0
  46. package/dist/dashboard-handshake.d.ts.map +1 -0
  47. package/dist/dashboard-handshake.js +127 -0
  48. package/dist/dashboard-handshake.js.map +1 -0
  49. package/dist/dashboard-herdr.d.ts +54 -0
  50. package/dist/dashboard-herdr.d.ts.map +1 -0
  51. package/dist/dashboard-herdr.js +286 -0
  52. package/dist/dashboard-herdr.js.map +1 -0
  53. package/dist/dashboard-projection.d.ts +73 -0
  54. package/dist/dashboard-projection.d.ts.map +1 -0
  55. package/dist/dashboard-projection.js +294 -0
  56. package/dist/dashboard-projection.js.map +1 -0
  57. package/dist/dashboard-render.d.ts +10 -0
  58. package/dist/dashboard-render.d.ts.map +1 -0
  59. package/dist/dashboard-render.js +208 -0
  60. package/dist/dashboard-render.js.map +1 -0
  61. package/dist/definitions.d.ts.map +1 -1
  62. package/dist/definitions.js +7 -1
  63. package/dist/definitions.js.map +1 -1
  64. package/dist/delegate-types.d.ts +6 -2
  65. package/dist/delegate-types.d.ts.map +1 -1
  66. package/dist/delegate-types.js.map +1 -1
  67. package/dist/delegate.d.ts.map +1 -1
  68. package/dist/delegate.js +34 -4
  69. package/dist/delegate.js.map +1 -1
  70. package/dist/delegation-approval.d.ts.map +1 -1
  71. package/dist/delegation-approval.js +37 -12
  72. package/dist/delegation-approval.js.map +1 -1
  73. package/dist/execution-id.d.ts +6 -0
  74. package/dist/execution-id.d.ts.map +1 -0
  75. package/dist/execution-id.js +13 -0
  76. package/dist/execution-id.js.map +1 -0
  77. package/dist/executor.d.ts +2 -1
  78. package/dist/executor.d.ts.map +1 -1
  79. package/dist/executor.js +1 -0
  80. package/dist/executor.js.map +1 -1
  81. package/dist/grant-env.d.ts +2 -0
  82. package/dist/grant-env.d.ts.map +1 -1
  83. package/dist/grant-env.js +26 -3
  84. package/dist/grant-env.js.map +1 -1
  85. package/dist/index.d.ts +4 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +4 -1
  88. package/dist/index.js.map +1 -1
  89. package/dist/init.d.ts +13 -1
  90. package/dist/init.d.ts.map +1 -1
  91. package/dist/init.js +35 -2
  92. package/dist/init.js.map +1 -1
  93. package/dist/lease-helper.d.ts +58 -0
  94. package/dist/lease-helper.d.ts.map +1 -0
  95. package/dist/lease-helper.js +94 -0
  96. package/dist/lease-helper.js.map +1 -0
  97. package/dist/lease-record.d.ts +15 -3
  98. package/dist/lease-record.d.ts.map +1 -1
  99. package/dist/lease-record.js.map +1 -1
  100. package/dist/ledger-events.d.ts +59 -15
  101. package/dist/ledger-events.d.ts.map +1 -1
  102. package/dist/ledger-events.js +67 -5
  103. package/dist/ledger-events.js.map +1 -1
  104. package/dist/ledger-identifiers.d.ts +7 -0
  105. package/dist/ledger-identifiers.d.ts.map +1 -0
  106. package/dist/ledger-identifiers.js +20 -0
  107. package/dist/ledger-identifiers.js.map +1 -0
  108. package/dist/ledger-report.d.ts +5 -2
  109. package/dist/ledger-report.d.ts.map +1 -1
  110. package/dist/ledger-report.js +38 -14
  111. package/dist/ledger-report.js.map +1 -1
  112. package/dist/ledger-v3-validation.d.ts +11 -0
  113. package/dist/ledger-v3-validation.d.ts.map +1 -0
  114. package/dist/ledger-v3-validation.js +265 -0
  115. package/dist/ledger-v3-validation.js.map +1 -0
  116. package/dist/ledger.d.ts +27 -9
  117. package/dist/ledger.d.ts.map +1 -1
  118. package/dist/ledger.js +38 -6
  119. package/dist/ledger.js.map +1 -1
  120. package/dist/propagation.d.ts +19 -1
  121. package/dist/propagation.d.ts.map +1 -1
  122. package/dist/propagation.js +23 -1
  123. package/dist/propagation.js.map +1 -1
  124. package/dist/refusals.d.ts +1 -1
  125. package/dist/refusals.d.ts.map +1 -1
  126. package/dist/refusals.js +1 -0
  127. package/dist/refusals.js.map +1 -1
  128. package/dist/resolve.d.ts +10 -0
  129. package/dist/resolve.d.ts.map +1 -1
  130. package/dist/resolve.js +27 -3
  131. package/dist/resolve.js.map +1 -1
  132. package/dist/routing-authority.d.ts +71 -0
  133. package/dist/routing-authority.d.ts.map +1 -0
  134. package/dist/routing-authority.js +100 -0
  135. package/dist/routing-authority.js.map +1 -0
  136. package/dist/run-child.d.ts +3 -1
  137. package/dist/run-child.d.ts.map +1 -1
  138. package/dist/run-child.js +30 -2
  139. package/dist/run-child.js.map +1 -1
  140. package/dist/run-herdr.d.ts +2 -0
  141. package/dist/run-herdr.d.ts.map +1 -1
  142. package/dist/run-herdr.js +8 -0
  143. package/dist/run-herdr.js.map +1 -1
  144. package/dist/skill-packages.d.ts +11 -5
  145. package/dist/skill-packages.d.ts.map +1 -1
  146. package/dist/skill-packages.js +20 -11
  147. package/dist/skill-packages.js.map +1 -1
  148. package/dist/workflow-fact-id.d.ts +4 -0
  149. package/dist/workflow-fact-id.d.ts.map +1 -0
  150. package/dist/workflow-fact-id.js +14 -0
  151. package/dist/workflow-fact-id.js.map +1 -0
  152. package/dist/workflow-facts.d.ts +34 -0
  153. package/dist/workflow-facts.d.ts.map +1 -0
  154. package/dist/workflow-facts.js +43 -0
  155. package/dist/workflow-facts.js.map +1 -0
  156. package/dist/workspace-lease.d.ts +15 -3
  157. package/dist/workspace-lease.d.ts.map +1 -1
  158. package/dist/workspace-lease.js +81 -24
  159. package/dist/workspace-lease.js.map +1 -1
  160. package/dist/workspace.d.ts +25 -0
  161. package/dist/workspace.d.ts.map +1 -1
  162. package/dist/workspace.js +142 -5
  163. package/dist/workspace.js.map +1 -1
  164. package/extensions/chain-ledger.ts +4 -0
  165. package/extensions/chain-plan.ts +98 -0
  166. package/extensions/delegate-chain.ts +63 -122
  167. package/extensions/delegation-ledger.ts +60 -0
  168. package/extensions/delegation.ts +20 -14
  169. package/extensions/execute-child.ts +85 -21
  170. package/extensions/execution-occurrence.ts +20 -0
  171. package/extensions/grants-command.ts +33 -4
  172. package/extensions/grants.ts +50 -1
  173. package/extensions/init-command.ts +33 -2
  174. package/extensions/run-delegation.ts +36 -56
  175. package/extensions/session-report.ts +13 -20
  176. package/extensions/session.ts +16 -3
  177. package/extensions/workspace-runtime.ts +44 -4
  178. package/herdr-plugin/herdr-plugin.toml +18 -0
  179. package/package.json +22 -5
  180. package/src/approval-prompt.ts +2 -1
  181. package/src/approval.ts +4 -2
  182. package/src/capabilities.ts +120 -4
  183. package/src/catalog.ts +62 -4
  184. package/src/check-runner.ts +24 -8
  185. package/src/cli.ts +24 -1
  186. package/src/correlation.ts +11 -0
  187. package/src/dashboard-cli.ts +171 -0
  188. package/src/dashboard-handshake.ts +185 -0
  189. package/src/dashboard-herdr.ts +355 -0
  190. package/src/dashboard-projection.ts +390 -0
  191. package/src/dashboard-render.ts +254 -0
  192. package/src/definitions.ts +7 -1
  193. package/src/delegate-types.ts +6 -2
  194. package/src/delegate.ts +43 -4
  195. package/src/delegation-approval.ts +37 -12
  196. package/src/execution-id.ts +18 -0
  197. package/src/executor.ts +2 -1
  198. package/src/grant-env.ts +39 -7
  199. package/src/index.ts +26 -0
  200. package/src/init.ts +40 -2
  201. package/src/lease-helper.ts +97 -0
  202. package/src/lease-record.ts +15 -3
  203. package/src/ledger-events.ts +116 -28
  204. package/src/ledger-identifiers.ts +21 -0
  205. package/src/ledger-report.ts +40 -19
  206. package/src/ledger-v3-validation.ts +266 -0
  207. package/src/ledger.ts +75 -12
  208. package/src/propagation.ts +24 -1
  209. package/src/refusals.ts +1 -0
  210. package/src/resolve.ts +29 -3
  211. package/src/routing-authority.ts +121 -0
  212. package/src/run-child.ts +35 -3
  213. package/src/run-herdr.ts +9 -0
  214. package/src/skill-packages.ts +20 -13
  215. package/src/workflow-fact-id.ts +16 -0
  216. package/src/workflow-facts.ts +65 -0
  217. package/src/workspace-lease.ts +84 -24
  218. package/src/workspace.ts +179 -6
@@ -0,0 +1,18 @@
1
+ id = "pi-daddy.dashboard"
2
+ name = "pi-daddy dashboard"
3
+ version = "1.0.0"
4
+ min_herdr_version = "0.8.0"
5
+ description = "Read-only live projection of a pi-daddy governance ledger"
6
+ platforms = ["linux", "macos", "windows"]
7
+
8
+ [[panes]]
9
+ id = "dashboard"
10
+ title = "PI-DADDY"
11
+ description = "Live governed execution and refusal tree"
12
+ placement = "split"
13
+ command = [
14
+ "node",
15
+ "--input-type=module",
16
+ "-e",
17
+ "import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(process.env.HERDR_PLUGIN_ROOT + '/../dist/dashboard-cli.js').href)).then(m => m.runDashboard()).catch(e => { console.error(e); process.exitCode = 1; })"
18
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.18.1",
3
+ "version": "0.20.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -128,26 +128,43 @@
128
128
  "./check-runner": {
129
129
  "types": "./dist/check-runner.d.ts",
130
130
  "default": "./dist/check-runner.js"
131
- }
131
+ },
132
+ "./dashboard-projection": {
133
+ "types": "./dist/dashboard-projection.d.ts",
134
+ "default": "./dist/dashboard-projection.js"
135
+ },
136
+ "./dashboard-render": {
137
+ "types": "./dist/dashboard-render.d.ts",
138
+ "default": "./dist/dashboard-render.js"
139
+ },
140
+ "./contracts/ledger/v3/ledger-event.schema.json": "./contracts/ledger/v3/ledger-event.schema.json",
141
+ "./contracts/ledger/v3/fixtures/*.json": "./contracts/ledger/v3/fixtures/*.json",
142
+ "./contracts/ledger/v2/ledger-event.schema.json": "./contracts/ledger/v2/ledger-event.schema.json",
143
+ "./contracts/ledger/v2/fixtures/*.json": "./contracts/ledger/v2/fixtures/*.json"
132
144
  },
133
145
  "bin": {
134
- "pi-daddy": "./dist/cli.js"
146
+ "pi-daddy": "./dist/cli.js",
147
+ "pi-daddy-dashboard": "./dist/dashboard-cli.js"
135
148
  },
136
149
  "files": [
137
150
  "dist",
138
151
  "extensions",
139
152
  "src",
153
+ "contracts",
154
+ "herdr-plugin",
140
155
  "README.md",
141
156
  "CHANGELOG.md",
142
157
  "LICENSE"
143
158
  ],
144
159
  "scripts": {
145
- "build": "tsc -p tsconfig.json",
160
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
146
161
  "typecheck": "tsc -p tsconfig.check.json",
147
162
  "test": "node --test test/*.test.ts",
148
163
  "test:integration": "node --test test-integration/*.it.ts",
164
+ "contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
149
165
  "prepack": "npm run build",
150
- "test:smoke": "node scripts/smoke-installed.mjs"
166
+ "test:smoke": "node scripts/smoke-installed.mjs",
167
+ "test:mutation": "node scripts/mutation-audit.mjs"
151
168
  },
152
169
  "main": "./dist/index.js",
153
170
  "types": "./dist/index.d.ts",
@@ -51,7 +51,8 @@ export interface PromptRequest {
51
51
  * breaking) cannot recover that from `scope` or from parsing `reason`. Only "declined" means a human said
52
52
  * no; the other three are absence-of-signal, not a signal.
53
53
  */
54
- export type PromptOutcomeKind = "granted" | "no-ui" | "declined" | "dismissed" | "error";
54
+ export const PROMPT_OUTCOME_KINDS = ["granted", "declined", "dismissed", "no-ui", "error"] as const;
55
+ export type PromptOutcomeKind = typeof PROMPT_OUTCOME_KINDS[number];
55
56
 
56
57
  export interface PromptOutcome {
57
58
  /** The scope the human chose, or null for any form of no. */
package/src/approval.ts CHANGED
@@ -14,10 +14,12 @@ import type { Capability, ResolveResult } from "./resolve.ts";
14
14
  import { approvalBindingsEqual, type ApprovalBinding } from "./correlation.ts";
15
15
 
16
16
  /** How far a single yes reaches in time. */
17
- export type ApprovalScope = "once" | "session" | "always";
17
+ export const APPROVAL_SCOPES = ["once", "session", "always"] as const;
18
+ export type ApprovalScope = typeof APPROVAL_SCOPES[number];
18
19
 
19
20
  /** Where a yes came from, for the ledger. These call for different follow-ups, so they stay distinct. */
20
- export type ApprovalSource = "prompt" | "session" | "persisted" | "inherited";
21
+ export const APPROVAL_SOURCES = ["prompt", "session", "persisted", "inherited"] as const;
22
+ export type ApprovalSource = typeof APPROVAL_SOURCES[number];
21
23
 
22
24
  /** Which call site is asking. Determines the scopes offered — see `offeredScopes`. */
23
25
  export type ApprovalPath = "definition" | "delegate";
@@ -12,7 +12,7 @@
12
12
  * *what one delegation does*.
13
13
  */
14
14
 
15
- import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
15
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
16
16
  import { WILDCARD } from "./pi-tools.ts";
17
17
  import { GovernanceRefusal, refusal } from "./refusals.ts";
18
18
 
@@ -42,15 +42,61 @@ export function maySpawnDefinition(ownGrant: Capability[], name: string): boolea
42
42
  /** The tool name that confers the ability to delegate further. */
43
43
  export const DELEGATE_CAPABILITY: Capability = "tool:delegate";
44
44
 
45
+ /**
46
+ * Every namespace a capability id may carry, as its literal prefix.
47
+ *
48
+ * **One list, because there were three and adding a namespace only updated some of them.** ADR-0035 taught
49
+ * `normaliseCapability` about `workspace:` and left `ceilingForDefinition` (which mangled it into
50
+ * `tool:workspace:<id>`) and `isSafeCapability` (which called it malformed) behind. The two places that parse
51
+ * an id's namespace read this, so a fifth namespace is one entry rather than a third divergence.
52
+ *
53
+ * **Not every prefix decision in the package.** Inline `startsWith("` tests on a capability namespace are
54
+ * scattered across `src/` and `extensions/`; this list is read by two of them. **No number is written here,
55
+ * and the history is why:** SPEC first claimed the list was "what every site reads"; a correction said "six"
56
+ * while its own enumeration listed seven; this docstring then said "nine" in the sentence claiming it gave no
57
+ * number; and the correction to *that* said "fourteen", which counted every file containing any
58
+ * `startsWith("` — CLI flags, `git worktree list` parsing, an assurance sentinel and node_modules entries
59
+ * among them. Four numbers, four wrong. The command, so nobody has to quote a predecessor:
60
+ *
61
+ * grep -rl 'startsWith("\(tool:\|ext:\|agent:\|skill:\|workspace:\)' src extensions
62
+ *
63
+ * Consolidating the sites is a separate change, and a count nobody re-derives is the defect this list exists
64
+ * to prevent.
65
+ *
66
+ * `docs/SPEC.md`'s grammar section is the prose statement of the same list and is kept in step with it.
67
+ */
68
+ export const CAPABILITY_NAMESPACE_PREFIXES = ["tool:", "ext:", "skill:", "agent:", "workspace:"] as const;
69
+
45
70
  /** Accept `read` or `tool:read` or `ext:pkg/tool` and normalise to a capability id. */
46
71
  export function normaliseCapability(raw: string): Capability {
47
72
  const value = raw.trim();
48
- if (value.startsWith("tool:") || value.startsWith("ext:") || value.startsWith("skill:") || value.startsWith("agent:")) {
49
- return value;
50
- }
73
+ if (CAPABILITY_NAMESPACE_PREFIXES.some((prefix) => value.startsWith(prefix))) return value;
51
74
  return `tool:${value}`;
52
75
  }
53
76
 
77
+ /** The capability that authorises routing a child to one registered workspace (ADR-0035). */
78
+ export function workspaceCapability(workspaceId: string): Capability {
79
+ return `workspace:${workspaceId}`;
80
+ }
81
+
82
+ /**
83
+ * May this session route a child to workspace `id`?
84
+ *
85
+ * Mirrors `maySpawnDefinition` deliberately — same shape, same wildcard handling, same reason. Routing was
86
+ * the one governance dimension that did not attenuate (R-131, measured in
87
+ * `docs/probes/g36-workspace-attenuation`): the registry inherited into every child and nothing checked the
88
+ * caller's authority, so a child routed to `staging` could route its grandchild to `prod`.
89
+ *
90
+ * `tool:*` satisfies it because governance is opt-in — an ungoverned session holds the wildcard and must
91
+ * keep working exactly as before.
92
+ */
93
+ export function mayRouteToWorkspace(ownGrant: readonly Capability[], workspaceId: string): boolean {
94
+ const held = new Set(ownGrant);
95
+ return held.has(WILDCARD)
96
+ || held.has(WORKSPACE_WILDCARD)
97
+ || held.has(workspaceCapability(workspaceId));
98
+ }
99
+
54
100
  /**
55
101
  * Characters that make a capability id mean something OTHER than one capability.
56
102
  *
@@ -78,6 +124,76 @@ export function isWellFormedCapability(id: string): boolean {
78
124
  return id.length > 0 && !CAPABILITY_ID_SEPARATORS.test(id) && id.trim() === id;
79
125
  }
80
126
 
127
+ /**
128
+ * The STRICT grammar: a whitelist of what a capability id may look like, not a blocklist of what it may not.
129
+ *
130
+ * **Two consumers, and they are two different reasons for the same rule.** It lives here rather than in
131
+ * either of them because it shipped in one and was needed by both:
132
+ *
133
+ * 1. `skill-packages.ts` — the boundary that GENERATES a grant. R-77/R-78: a package declaring
134
+ * `allowed-tools: Read,ext:x";touch /tmp/pwned;PI_GRANTS_GRANT="` produced a `.pi/grants.env` that ran
135
+ * arbitrary code the moment an operator sourced the line `init` prints.
136
+ * 2. `workspace.ts` — the operator registry, since ADR-0035 made a registry id the tail of a capability id
137
+ * (`workspace:<id>`). That made the registry an input to this grammar, and it got the LOOSE
138
+ * `isWellFormedCapability` blocklist instead, which review showed costs three things:
139
+ * an id of literally `*` loaded and minted `WORKSPACE_WILDCARD`, so an operator naming ONE worktree held
140
+ * routing authority over every id in the registry including ones added later;
141
+ * an id containing a space became TWO capabilities, because `ceilingForDefinition` splits `allowed-tools`
142
+ * on `[\s,]+` — `allowed-tools: read, workspace:prod bash` measured as
143
+ * `['tool:bash','tool:read','workspace:prod']`, i.e. routing over production plus a shell, neither typed
144
+ * by anyone (0.18.1's comma, one namespace over);
145
+ * and quote/`$()`/backtick ids reached the `ROUTABLE WORKSPACES` block of the generated file, whose own
146
+ * instructions tell the operator to paste them into `PI_GRANTS_GRANT`.
147
+ *
148
+ * **A blocklist was the wrong shape for this and the comment on `isWellFormedCapability` says why it is the
149
+ * right shape THERE**: that one guards the enforcement path, which must keep accepting whatever ids operators
150
+ * already have. A registry is a file the operator writes and can rename, so refusing a hostile id costs
151
+ * nothing — and `grant-env.ts`'s standing warning applies to it exactly: *"the third channel — whatever it
152
+ * turns out to be — should cost a refusal rather than an injection."* The registry was the third channel.
153
+ */
154
+ /**
155
+ * A workspace registry id, which is NOT a tool name and needed its own rule.
156
+ *
157
+ * `isSafeCapability` was reused here first, and that was the error: it is the grammar for a *tool* name, and
158
+ * it refused ids that published 0.18.1 accepted — `feature/x` above all. Git worktrees are routinely named
159
+ * after their branch, so a slash is the ordinary case, and a slash splits nothing: not the comma-separated
160
+ * grant, not `allowed-tools`' `[\s,]+`. Refusing it bought no safety and broke the common setup.
161
+ *
162
+ * What is refused, and why each one earns it:
163
+ * - **whitespace** — `ceilingForDefinition` splits `allowed-tools` on `[\s,]+`, so `workspace:prod bash`
164
+ * measured as `['tool:bash','tool:read','workspace:prod']`: routing over production plus a shell, neither
165
+ * typed by anyone. 0.18.1's comma, one namespace over.
166
+ * - **comma, CR, LF, NUL** — split a grant.
167
+ * - **`*`** — collides with `WORKSPACE_WILDCARD`, so registering a worktree as `*` and granting
168
+ * `workspace:*` believing it named that one root minted routing authority over the whole registry.
169
+ * - **quotes, `$`, backticks, `;`, `&`, `|`, `<`, `>`, `(`, `)`, `\`, `#`** — reach the ROUTABLE WORKSPACES
170
+ * block of a generated `.pi/grants.env`, whose own instructions tell the operator to paste the id into
171
+ * `PI_GRANTS_GRANT`. Sourcing the file was safe; following its instructions executed. R-77/R-78.
172
+ * - **control characters and non-ASCII** — the generated file is reviewed in an editor and `/grants` prints
173
+ * these; backspace and ANSI escapes let one id render as another. This is the one refusal that costs a
174
+ * legitimate user something (a non-English worktree name), and it is a deliberate trade recorded as
175
+ * breaking rather than asserted to cost nothing.
176
+ */
177
+ export function isSafeWorkspaceId(id: string): boolean {
178
+ return /^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(id);
179
+ }
180
+
181
+ export function isSafeCapability(id: Capability): boolean {
182
+ const segment = "[A-Za-z0-9][A-Za-z0-9._-]*";
183
+ // `workspace:` delegates to the WORKSPACE grammar rather than reusing `segment`, and that is the fifth
184
+ // site of the same defect. `isSafeWorkspaceId` allows a slash because a git worktree is routinely named
185
+ // after its branch; `segment` does not, so this function refused `workspace:feature/x` — dropping the whole
186
+ // definition, exiting 1, and telling the operator "a capability id is tool:/skill:/agent:<name> or
187
+ // ext:<pkg>/<tool>", which does not even mention the namespace. Meanwhile the CHANGELOG told them slashes
188
+ // are fine. **The boundary that GENERATES grants could not emit the id shape the release advertises**, so
189
+ // the migration path for a breaking change was closed by the commit that documented it as open.
190
+ if (id.startsWith("workspace:")) return isSafeWorkspaceId(id.slice("workspace:".length));
191
+ return (
192
+ new RegExp(`^(tool|skill|agent):${segment}$`).test(id) ||
193
+ new RegExp(`^ext:(@${segment}/)?${segment}/${segment}$`).test(id)
194
+ );
195
+ }
196
+
81
197
  /**
82
198
  * The write-side backstop, for every channel that joins capabilities into one string.
83
199
  *
package/src/catalog.ts CHANGED
@@ -23,9 +23,11 @@ import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { loadDefinitions, type SkillDefinition } from "./definitions.ts";
25
25
  import { PI_BUILTIN_TOOLS, WILDCARD } from "./pi-tools.ts";
26
- import { AGENT_WILDCARD, type Capability } from "./resolve.ts";
26
+ import { AGENT_WILDCARD, WORKSPACE_WILDCARD, type Capability } from "./resolve.ts";
27
+ import { loadWorkspaceRegistry, type WorkspaceRegistryFile } from "./workspace.ts";
28
+ import { isSafeWorkspaceId } from "./capabilities.ts";
27
29
 
28
- export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType";
30
+ export type CapabilityKind = "builtin" | "extension" | "skill" | "agentType" | "workspace";
29
31
 
30
32
  export interface CatalogEntry {
31
33
  capability: Capability;
@@ -112,6 +114,22 @@ export function definitionEntries(definitions: Map<string, SkillDefinition>): Ca
112
114
  }));
113
115
  }
114
116
 
117
+ /**
118
+ * Registered workspaces, as `workspace:<id>` capabilities (ADR-0035).
119
+ *
120
+ * For DISPLAY and SCAFFOLDING only — `/grants` listing what this session may route to, and `init` offering
121
+ * the ids without choosing among them (ADR-0028). It is deliberately **not** what `unknownCapabilities`
122
+ * checks against; see the comment there.
123
+ *
124
+ * The operator registry is the authority on which ids exist, exactly as it is at `resolveWorkspace`. This
125
+ * enumerates it; it does not decide anything.
126
+ */
127
+ export function workspaceEntries(registry: WorkspaceRegistryFile, source?: string): CatalogEntry[] {
128
+ return Object.keys(registry.workspaces)
129
+ .sort()
130
+ .map((id) => ({ capability: `workspace:${id}` as Capability, kind: "workspace" as const, ...(source ? { source } : {}) }));
131
+ }
132
+
115
133
  /** Assemble a catalog from parts. Pure, so it is testable without a filesystem. */
116
134
  export function makeCatalog(entries: CatalogEntry[]): Catalog {
117
135
  const deduped = new Map<Capability, CatalogEntry>();
@@ -131,8 +149,23 @@ export function makeCatalog(entries: CatalogEntry[]): Catalog {
131
149
  export async function buildCatalog(input: {
132
150
  cwd: string;
133
151
  observedTools: string[] | null;
152
+ /** Operator workspace registry (`PI_GRANTS_WORKSPACE_REGISTRY`). Absent or unreadable yields no entries. */
153
+ registryPath?: string;
134
154
  }): Promise<Catalog> {
135
- const [skills, definitions] = await Promise.all([loadSkills(input.cwd), loadDefinitions(input.cwd)]);
155
+ const [skills, definitions, workspaces] = await Promise.all([
156
+ loadSkills(input.cwd),
157
+ loadDefinitions(input.cwd),
158
+ // Fails SOFT, and only because nothing here is an authority. A malformed registry must not stop a
159
+ // session from starting — `loadWorkspaceRegistry` throws a GovernanceRefusal naming the file, and that
160
+ // refusal is the operator's signal at the point of USE, where routing actually depends on it. Swallowing
161
+ // it there would be unsafe; swallowing it here costs a display list.
162
+ input.registryPath
163
+ ? loadWorkspaceRegistry(input.registryPath).then(
164
+ (r) => workspaceEntries(r, input.registryPath),
165
+ () => [] as CatalogEntry[],
166
+ )
167
+ : Promise.resolve([] as CatalogEntry[]),
168
+ ]);
136
169
  return makeCatalog([
137
170
  // pi's built-ins are seeded unconditionally, because they are known statically and the catalog is
138
171
  // consulted BEFORE any provider request has happened — `/grants` runs at that point. Without this,
@@ -148,6 +181,7 @@ export async function buildCatalog(input: {
148
181
  ...(input.observedTools ? classifyToolNames(input.observedTools) : []),
149
182
  ...skills,
150
183
  ...definitionEntries(definitions),
184
+ ...workspaces,
151
185
  ]);
152
186
  }
153
187
 
@@ -165,7 +199,31 @@ export function unknownCapabilities(requested: Capability[], catalog: Catalog):
165
199
  // refused it BEFORE `resolve` could apply ADR-0023's rule. That made the ADR's "a parent holding
166
200
  // `agent:*` may hand down `agent:*`" false, and made a definition declaring `allowed-tools: agent:*`
167
201
  // unspawnable from any grant. The wildcard is live only at the root without this.
168
- return requested.filter((c) => c !== WILDCARD && c !== AGENT_WILDCARD && !catalog.has(c)).sort();
202
+ //
203
+ // `workspace:` is exempt as a NAMESPACE, not merely at its wildcard, and that asymmetry is deliberate.
204
+ // ADR-0035 minted the namespace and taught `normaliseCapability`, `resolve` and `childEnv` about it, but
205
+ // not this line — so with a catalog present (and `delegationContext` always supplies one) every requested
206
+ // `workspace:<id>` was refused UNKNOWN_TOOL as *"a typo, or an uninstalled package"*. A child could
207
+ // therefore never be granted a workspace capability at all, which made routing stop dead below the root
208
+ // instead of attenuating, and made the ADR's own "two authorities, not one" unreachable in production.
209
+ //
210
+ // Exempt rather than catalogued-and-checked because the operator registry is the authority and it is
211
+ // consulted where it matters: `resolveWorkspace` refuses an unregistered id with WORKSPACE_NOT_REGISTERED,
212
+ // naming the registry. A second, weaker check here can only turn that precise refusal into a misleading
213
+ // one — and it would do so for reasons that have nothing to do with the id, like a registry this session
214
+ // cannot read. `buildCatalog` still ENUMERATES workspaces, for `/grants` and for `init`'s scaffold; that
215
+ // is display, and display is not authority. Same trade-off the built-ins comment above states and accepts.
216
+ // The workspace exemption requires a WELL-FORMED id, not merely the prefix. A bare `workspace:` names
217
+ // nothing, and exempting it let it reach a child's grant and the ledger as authority over no workspace at
218
+ // all — an exemption for ids the registry is authoritative about should not also cover ids no registry
219
+ // could contain.
220
+ // `WORKSPACE_WILDCARD` is listed with the other two because it is GRAMMAR, and `isSafeCapability` refuses
221
+ // wildcards by design — so folding it into the namespace test below un-exempts it. Caught by the tests for
222
+ // the previous two fixes, which is the checklist paying for itself.
223
+ const exempt = (c: Capability) =>
224
+ c === WILDCARD || c === AGENT_WILDCARD || c === WORKSPACE_WILDCARD
225
+ || (c.startsWith("workspace:") && isSafeWorkspaceId(c.slice("workspace:".length)));
226
+ return requested.filter((c) => !exempt(c) && !catalog.has(c)).sort();
169
227
  }
170
228
 
171
229
  /**
@@ -5,9 +5,8 @@ import { basename, isAbsolute, join } from "node:path";
5
5
  import { normaliseCorrelation, type CorrelationMetadata } from "./correlation.ts";
6
6
  import {
7
7
  appendLedgerEvent,
8
+ buildCheckReceiptLedgerEvent,
8
9
  buildWorkspaceLeaseEvent,
9
- type CheckReceiptLedgerEvent,
10
- LEDGER_VERSION,
11
10
  } from "./ledger.ts";
12
11
  import { computeGitCandidateIdentity } from "./git-identity.ts";
13
12
  import { runChild } from "./run-child.ts";
@@ -20,6 +19,8 @@ import {
20
19
  leaseAcquisitionOutcome,
21
20
  leaseReleaseLedgerOutcome,
22
21
  } from "./workspace.ts";
22
+ import { newExecutionId } from "./execution-id.ts";
23
+ import { isLedgerDisplayIdentifier } from "./ledger-identifiers.ts";
23
24
 
24
25
  export interface CheckDefinition {
25
26
  /** Absolute operator-owned executable. PATH lookup is deliberately not part of the contract. */
@@ -86,6 +87,12 @@ export function buildCheckEnvironment(
86
87
  }
87
88
 
88
89
  function validateDefinition(checkId: string, value: CheckDefinition | undefined): CheckDefinition {
90
+ // `check:<id>:<uuid>` is also the v3 childId, whose closed display-identifier bound is 512 characters.
91
+ if (!isLedgerDisplayIdentifier(checkId) || checkId.length > 460) {
92
+ throw new GovernanceRefusal(refusal(
93
+ "CHECK_CONFIGURATION_INVALID", `check ${JSON.stringify(checkId)} must be an ASCII identifier`, { check_id: checkId },
94
+ ));
95
+ }
89
96
  if (!value) {
90
97
  throw new GovernanceRefusal(refusal(
91
98
  "CHECK_NOT_CONFIGURED", `check ${JSON.stringify(checkId)} is not present in operator-owned configuration`, { check_id: checkId },
@@ -148,6 +155,8 @@ export async function runNamedCheck(input: {
148
155
  // governed writer. `access` still records what the configured executable is intended to do.
149
156
  const leaseAccess: WorkspaceAccess = "write";
150
157
  const ownerId = `check:${input.checkId}:${randomUUID()}`;
158
+ const executionId = newExecutionId();
159
+ const parentExecutionId = null;
151
160
  const correlation = normaliseCorrelation(input.correlation);
152
161
  let lease: Awaited<ReturnType<typeof acquireWorkspaceLease>> | undefined;
153
162
  let stagedDir: string | undefined;
@@ -164,6 +173,7 @@ export async function runNamedCheck(input: {
164
173
  if (input.ledgerPath) await appendLedgerEvent(
165
174
  { path: input.ledgerPath, strict: true },
166
175
  buildWorkspaceLeaseEvent({
176
+ executionId, parentExecutionId,
167
177
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
168
178
  outcome: "refused",
169
179
  correlation, now: new Date(),
@@ -179,6 +189,7 @@ export async function runNamedCheck(input: {
179
189
  await appendLedgerEvent(
180
190
  { path: input.ledgerPath, strict: true },
181
191
  buildWorkspaceLeaseEvent({
192
+ executionId, parentExecutionId,
182
193
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
183
194
  outcome: leaseAcquisitionOutcome(leaseAccess, lease.recovered), recovered: lease.recovered,
184
195
  correlation, now: new Date(),
@@ -257,6 +268,7 @@ export async function runNamedCheck(input: {
257
268
  if (input.ledgerPath) await appendLedgerEvent(
258
269
  { path: input.ledgerPath, strict: true },
259
270
  buildWorkspaceLeaseEvent({
271
+ executionId, parentExecutionId,
260
272
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
261
273
  outcome: leaseReleaseLedgerOutcome(releaseOutcome, releaseReason), releaseReason, correlation, now: new Date(),
262
274
  }),
@@ -272,12 +284,14 @@ export async function runNamedCheck(input: {
272
284
  };
273
285
  const receipt: CheckReceipt = { receipt_id: receiptId(body), ...body };
274
286
  if (input.ledgerPath) {
275
- const event: CheckReceiptLedgerEvent = {
276
- ledgerVersion: LEDGER_VERSION, event: "check_receipt", ts: ended.toISOString(), childId: ownerId,
277
- receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId, checkId: input.checkId,
278
- treeSha: receipt.tree_sha, ...(correlation ? { correlation } : {}),
279
- };
280
- await appendLedgerEvent({ path: input.ledgerPath, strict: true }, event);
287
+ await appendLedgerEvent(
288
+ { path: input.ledgerPath, strict: true },
289
+ buildCheckReceiptLedgerEvent({
290
+ executionId, parentExecutionId,
291
+ childId: ownerId, receiptId: receipt.receipt_id, workspaceId: input.workspace.workspaceId,
292
+ checkId: input.checkId, treeSha: receipt.tree_sha, correlation, now: ended,
293
+ }),
294
+ );
281
295
  }
282
296
  return { output: result.text, exitCode: result.code, signal: result.signal ?? null, receipt };
283
297
  } catch (error) {
@@ -304,6 +318,7 @@ export async function runNamedCheck(input: {
304
318
  onFailure: (cause) => { notes.push(`check refusal record failed: ${String(cause)}`); },
305
319
  },
306
320
  buildWorkspaceLeaseEvent({
321
+ executionId, parentExecutionId,
307
322
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
308
323
  outcome: "refused",
309
324
  refusal: error instanceof GovernanceRefusal
@@ -327,6 +342,7 @@ export async function runNamedCheck(input: {
327
342
  onFailure: (cause) => { notes.push(`check lease release record failed: ${String(cause)}`); },
328
343
  },
329
344
  buildWorkspaceLeaseEvent({
345
+ executionId, parentExecutionId,
330
346
  childId: ownerId, workspaceId: input.workspace.workspaceId, root: input.workspace.root, access: leaseAccess,
331
347
  outcome: outcome === "lost"
332
348
  ? "lost"
package/src/cli.ts CHANGED
@@ -19,6 +19,7 @@ import { relative, resolve as resolvePath } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
20
  import { UnsafeGrantError } from "./grant-env.ts";
21
21
  import { applyInit, countDeclaring, planInit, type InitPlan } from "./init.ts";
22
+ import { registeredWorkspaceIds } from "./workspace.ts";
22
23
  import { discoverSkillPackages, skillPackageRoots, type RefusedSkill, type SkillPackage } from "./skill-packages.ts";
23
24
 
24
25
  const USAGE = `pi-daddy — capability governance for pi sub-agents
@@ -109,7 +110,7 @@ async function init(cwd: string, force: boolean): Promise<number> {
109
110
 
110
111
  let plan: InitPlan;
111
112
  try {
112
- plan = planInit(packages, cwd);
113
+ plan = planInit(packages, cwd, await registeredWorkspaceIds());
113
114
  } catch (error) {
114
115
  // R-78's backstop reaching the surface. Nothing is written: a grant that could mean something to a
115
116
  // shell is not a grant, and half-scaffolding a project would be worse than scaffolding none of it.
@@ -210,6 +211,28 @@ function report(plan: InitPlan): void {
210
211
  );
211
212
  }
212
213
 
214
+ // ROUTING, which had no line here at all — and `report()` is the output of the command the docs tell an
215
+ // operator to run. Keeping `workspace:` ids out of `withheldCapabilities` (so the `/grants init` dialog
216
+ // could not grant them off a package declaration) removed the ONLY thing this path said about a definition
217
+ // that cannot be spawned: `needs-withheld` is not one of the cases handled above, so a routing package
218
+ // produced a copied definition, an unusable grant, and total silence.
219
+ //
220
+ // The fix that caused it argued that "a breaking change whose migration is only discoverable by opening a
221
+ // file is not much of a migration" — and then applied that to the in-session notify and not to the CLI.
222
+ if (plan.routableWorkspaces.length > 0) {
223
+ const blocked = plan.skills.filter((s) => s.withheld === "needs-withheld").map((s) => s.name);
224
+ console.log(
225
+ `\nROUTABLE WORKSPACES: ${plan.routableWorkspaces.join(", ")}.\n` +
226
+ `Routing a child to one needs its id in PI_GRANTS_GRANT (ADR-0035); without it the delegation is\n` +
227
+ `refused WORKSPACE_NOT_AUTHORIZED. They are listed COMMENTED in .pi/grants.env and never granted for\n` +
228
+ `you — which worktree a child starts in is not something a package can declare.` +
229
+ (blocked.length > 0
230
+ ? `\nUntil you grant one, these cannot be spawned: ${blocked.join(", ")} — add the capability, then\n` +
231
+ `their \`agent:\` ids.`
232
+ : ""),
233
+ );
234
+ }
235
+
213
236
  console.log(
214
237
  `\nLive grant (${plan.grant.length} capabilities): ${plan.grant.join(", ")}\n\n` +
215
238
  ` $EDITOR .pi/grants.env # review it, then commit it\n` +
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { DefinitionDigest } from "./definitions.ts";
3
3
  import type { Capability } from "./resolve.ts";
4
4
  import { GovernanceRefusal, refusal } from "./refusals.ts";
5
+ import { isLedgerCorrelationIdentifier } from "./ledger-identifiers.ts";
5
6
 
6
7
  export type JsonPrimitive = string | number | boolean | null;
7
8
  export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
@@ -71,6 +72,13 @@ const CORRELATION_NUMERIC = new Set<keyof CorrelationMetadata>([
71
72
  "event_seq", "last_change_seq", "last_authority_seq",
72
73
  ]);
73
74
 
75
+ // These fields are rendered as labels/identities. Treating arbitrary prose as an "id" let a model copy
76
+ // task or output text into the ledger and dashboard through correlation while staying schema-valid.
77
+ const CORRELATION_IDENTIFIERS = new Set<keyof CorrelationMetadata>([
78
+ "schema_version", "run_id", "task_id", "workspace_id", "context_id", "phase", "assurance",
79
+ "assurance_effective", "policy_label", "assurance_source",
80
+ ]);
81
+
74
82
  function correlationRefusal(message: string, details?: Record<string, string | number>): GovernanceRefusal {
75
83
  return new GovernanceRefusal(refusal("CORRELATION_INVALID", `correlation metadata: ${message}`, details));
76
84
  }
@@ -147,6 +155,9 @@ export function normaliseCorrelation(input: CorrelationMetadata | undefined): Co
147
155
  { field: key, limit: MAX_CORRELATION_FIELD_CHARS, actual: value.length },
148
156
  );
149
157
  }
158
+ if (CORRELATION_IDENTIFIERS.has(key as keyof CorrelationMetadata) && !isLedgerCorrelationIdentifier(value)) {
159
+ throw correlationRefusal(`${key} must be an ASCII identifier, not free-form text`, { field: key });
160
+ }
150
161
  output[key] = value;
151
162
  }
152
163
  return output as CorrelationMetadata;