@pareto4/pp-core 1.9.0 → 1.11.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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-fixer.md +11 -0
  3. package/agents/pp-code-reviewer.md +77 -1
  4. package/agents/pp-executor.md +34 -1
  5. package/agents/pp-planner.md +24 -103
  6. package/commands/pp-ns-manage.md +2 -1
  7. package/commands/pp-vault-promote.md +31 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +5 -3
  10. package/pareto-skills/pp-brand/SKILL.md +1 -1
  11. package/pp-core/bin/lib/capability-registry.cjs +160 -3
  12. package/pp-core/bin/lib/capability-state.cjs +41 -3
  13. package/pp-core/bin/lib/check-command-router.cjs +473 -3
  14. package/pp-core/bin/lib/clusters.cjs +3 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +736 -0
  16. package/pp-core/bin/lib/git-provider.cjs +279 -0
  17. package/pp-core/bin/lib/init-contract.cjs +989 -0
  18. package/pp-core/bin/lib/init-interactive.cjs +183 -0
  19. package/pp-core/bin/lib/io.cjs +9 -0
  20. package/pp-core/bin/lib/org-profile.cjs +696 -0
  21. package/pp-core/bin/lib/platform-bindings.cjs +268 -64
  22. package/pp-core/bin/lib/practices-review-check.cjs +938 -0
  23. package/pp-core/bin/lib/vault-command-router.cjs +239 -2
  24. package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
  25. package/pp-core/bin/lib/vault-practices.cjs +982 -0
  26. package/pp-core/bin/lib/vault-write-back.cjs +842 -0
  27. package/pp-core/bin/lib/vault.cjs +170 -51
  28. package/pp-core/bin/pp-tools.cjs +225 -2
  29. package/pp-core/references/planner-checkpoints.md +72 -0
  30. package/pp-core/references/planner-tdd.md +55 -0
  31. package/pp-core/references/planner-vault-practices.md +67 -0
  32. package/pp-core/templates/practices/README.md +122 -0
  33. package/pp-core/templates/practices/error-handling.md +18 -0
  34. package/pp-core/templates/practices/minimal-comments.md +14 -0
  35. package/pp-core/templates/practices/naming-convention.md +18 -0
  36. package/pp-core/templates/practices/practice-template.md +23 -0
  37. package/pp-core/workflows/code-review.md +74 -18
  38. package/pp-core/workflows/discovery-sprint.md +1 -1
  39. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
  40. package/pp-core/workflows/init.md +137 -76
  41. package/pp-core/workflows/ship.md +1 -0
  42. package/pp-core/workflows/vault-write-back.md +213 -0
  43. package/scripts/gen-capability-registry.cjs +68 -8
  44. package/scripts/gen-platform-bindings-schema.cjs +148 -0
  45. package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
  46. package/scripts/lint-descriptions.cjs +3 -1
  47. package/scripts/lint-legacy-dir-name.cjs +5 -0
  48. package/scripts/lint-test-file-count.allowlist.json +10 -1
@@ -7,7 +7,23 @@ Implements decisions: D-02 (no Obsidian/board scaffolding), D-03 (no discovery s
7
7
  scaffolding here — /pp-discovery self-generates them), D-04 (ASK handoff with smart
8
8
  default), D-06 (generic remote wiring: prompt URL → git remote add → confirmed push,
9
9
  zero provider API), D-07 (minimal identity only — no CLAUDE.md client-context block),
10
- D-13 (gate on .platform-bindings.json via the Phase 7 reader).
10
+ D-13 (gate on .platform-bindings.json via the Phase 7 reader — Step 2 now composes and
11
+ writes this file too, not only reads it, Phase 34 INIT-01).
12
+
13
+ Provisioning is provider-aware (Phase 34 D-11/D-13): `resolveProvider` is the sole
14
+ authority for which provisioning path runs. The gated GitLab `glab` recipe (G-2…G-5) runs
15
+ only when it resolves to `gitlab`; under any other provider (`azure` today) it is
16
+ **skipped, not failed**, and the run falls through to this same generic remote-wiring
17
+ path so the user still leaves with a wired remote (Phase 34 INIT-03).
18
+
19
+ Step 2 is a single instruction to run `pp-tools init` in a real terminal, not a prose
20
+ questionnaire and not a relay this document drives (Phase 34-09 gap closure). Four prior
21
+ UAT walks — plain prose, hardened prose, a tested CLI arm, and a session state machine the
22
+ agent was still handed one step at a time — all failed at the identical point: the agent's
23
+ own model of which steps exist overrode this document and it substituted its own
24
+ `AskUserQuestion` wizard for the question set (`34-VALIDATION.md`). The interaction now
25
+ lives entirely inside `pp-tools init` itself — a human types answers directly into the
26
+ process that acts on them — so this document has nothing left to compress, reorder or drop.
11
27
 
12
28
  Inert of all Pareto/GitLab behavior when no .platform-bindings.json is present.
13
29
  </purpose>
@@ -119,61 +135,51 @@ Step 0). Continue honoring it for every `AskUserQuestion` call in this and later
119
135
 
120
136
  ---
121
137
 
122
- ## 2. Minimal identity capture (D-07)
138
+ ## 2. Identity, provider and binding — run `pp-tools init` (D-05, D-07, INIT-01, INIT-02, INIT-03)
123
139
 
124
- Capture ONLY the five identity fields below. Persist them as a stub in
125
- `.planning/config.json` under a top-level `"project"` key, merged with any existing
126
- config. These fields are used by the gated GitLab provisioning path (Plan 02) for
127
- `group/repo-slug` and `visibility`; they do NOT produce a `CLAUDE.md` client-context
128
- block — that is Phase 9 (CTX-01..03) territory. Do NOT write a coding-standards,
129
- architecture, or DoD block here; executor and Phase 9 must not double-capture these.
130
-
131
- Ask the user with `AskUserQuestion` (or plain-text prompts in TEXT_MODE):
132
-
133
- ```text
134
- 1. Project name (human-readable, e.g. "Acme Dashboard")
135
- 2. Project slug (kebab-case, e.g. "acme-dashboard")
136
- 3. Client name (company / team, e.g. "Acme Corp") — type "internal" if none
137
- 4. Client slug (kebab-case, e.g. "acme") — type "internal" if none
138
- 5. One-line description (what this project does)
139
- ```
140
-
141
- Also ask — used only by the gated GitLab path (D-13, Plan 02):
140
+ Tell the user to run this in their own terminal, then **stop** and wait for them to confirm
141
+ they are done before continuing to Step 3:
142
142
 
143
143
  ```text
144
- 6. Provider (gitlab / github / other / none)
145
- 7. Visibility (private / public / internal)
146
- ```
147
-
148
- Write these to `.planning/config.json`:
149
-
150
- ```json
151
- {
152
- "project": {
153
- "name": "<value>",
154
- "slug": "<value>",
155
- "client_name": "<value>",
156
- "client_slug": "<value>",
157
- "description": "<value>",
158
- "provider": "<value>",
159
- "visibility": "<value>"
160
- }
161
- }
144
+ pp-tools init
162
145
  ```
163
146
 
164
- `mkdir -p .planning` before writing if the directory does not exist.
165
-
166
- **Scope boundary:** this step writes identity only. All coding-standards, architecture
167
- context, client-engagement CLAUDE.md blocks, and discovery-session scaffolding are out of
168
- scope here. Phase 9 owns those; /pp-discovery self-generates its own session files (D-03).
147
+ **Do not ask the identity questions yourself. Do not describe the question set, the
148
+ provider options, or the compose confirmation. Do not attempt to drive the `init-contract`
149
+ session on the user's behalf — not even one step of it, even via `--session-next` /
150
+ `--session-answer`.** Four dated UAT walks (`34-VALIDATION.md` § Manual-Only Verifications)
151
+ found the IDENTICAL failure at exactly this boundary, across four escalating strategies —
152
+ plain prose, hardened prose, a data-supplying CLI arm, and a session state machine the
153
+ agent was still handed one step at a time. Every pure-bash step ran reliably every time;
154
+ the one step whose job is to ask a structured question set was always overridden by the
155
+ agent's own `AskUserQuestion` wizard instead. The only strategy left is to remove the
156
+ interaction from this document entirely: `pp-tools init` prompts on a real terminal, reads
157
+ the answers itself, and performs every write (`.planning/config.json`, `vault.provider`,
158
+ `.platform-bindings.json`) — a human types answers directly into the process that acts on
159
+ them, with nothing in between. It refuses to run when stdin is not a TTY.
160
+
161
+ **If this step is skipped, `/pp-init` has not completed** — no identity config, no
162
+ `vault.provider`, and no binding are produced without it. `pp_run query init-contract
163
+ --verify` reports exactly what is still missing if the operator wants to confirm before
164
+ continuing.
165
+
166
+ **Scope boundary:** this step writes identity, provider and the platform binding only. All
167
+ coding-standards, architecture context, client-engagement CLAUDE.md blocks, and
168
+ discovery-session scaffolding are out of scope here. Phase 9 owns those; /pp-discovery
169
+ self-generates its own session files (D-03).
169
170
 
170
171
  ---
171
172
 
172
173
  ## 3. Remote wiring — provider-agnostic (non-Pareto / no bindings)
173
174
 
174
- **Gate (D-06, D-13):** this step is the active path when
175
- `pp_run query platform-bindings --pick present 2>/dev/null` does NOT equal `"true"`.
176
- When bindings ARE present, see the gated GitLab provisioning section below instead.
175
+ **Gate (D-06, D-13) — bindings presence ONLY, never the provider answer.** Step 3 always
176
+ runs exactly one of its two sub-paths on every `/pp-init` invocation; it is never skipped
177
+ outright. Take THIS generic sub-path when
178
+ `pp_run query platform-bindings --pick present 2>/dev/null` does NOT equal `"true"`. When
179
+ bindings ARE present, take the gated GitLab/Azure sub-path below instead — that section is
180
+ still part of Step 3, not a separate optional step. **The provider answer never decides
181
+ whether Step 3 runs** — inside the gated sub-path it only decides whether the
182
+ GitLab-specific `glab` recipe fires or falls through to this same generic code below.
177
183
 
178
184
  On the generic path:
179
185
 
@@ -213,17 +219,80 @@ gets its own per-action confirmation immediately before it fires — nothing bat
213
219
  step makes **ZERO `gh`/`glab`/provider-API calls**. The push target is the user-supplied
214
220
  URL only.
215
221
 
216
- ### Gated GitLab provisioning (Pareto projects — bindings present)
222
+ ### Gated GitLab/Azure provisioning (Step 3's other sub-path — bindings present)
223
+
224
+ This is Step 3's other sub-path, not a separate step. It runs **instead of** the generic
225
+ remote-wiring path above when
226
+ `pp_run query platform-bindings --pick present 2>/dev/null` equals `"true"`. Within it,
227
+ G-1 always runs first to source coordinates and apply the two guards below; the
228
+ GitLab-specific create recipe (G-2…G-5) runs **only when the resolved provider is
229
+ `gitlab`** — under any other provider (`azure` today) it is skipped, not failed, and the
230
+ run falls through to Step 3's generic remote-wiring path above instead (D-11, D-13).
231
+ Implements D-05 (per-action confirmation), D-09 (existence probe → adopt-if-exists), D-10
232
+ (private by default), D-11 (auth verify-only; gated on provider), D-12 (adopt-if-exists
233
+ reconciliation; coordinates sourced one-per-value, no phantom `gitlab.*` binding reads),
234
+ D-13 (bindings gate; `resolveProvider` is the sole authority for which path runs).
235
+ Covers INIT-02, INIT-03, and the cross-cutting SAFE-01 invariant.
236
+
237
+ #### G-1. Config reads — one source per value (D-12)
238
+
239
+ Per-project values (repo slug, visibility, description) come from the `project.*` block
240
+ Step 2 already writes to `.planning/config.json`; the org-wide group root comes from the
241
+ org profile's init-only seed section, read through the composer's own offline probe; the
242
+ host comes from the composed binding. **No `gitlab` namespace exists in the schema**
243
+ (`additionalProperties: false` at the top level, no such block) or will be declared —
244
+ declaring one would put the same value in both the binding and `.planning/config.json`, a
245
+ fresh instance of the two-sources-of-truth class this repo has already paid for twice.
246
+ One source per value:
247
+
248
+ ```bash
249
+ GROUP=$(pp_run query compose-bindings --offline --pick seed.provisioningGroup 2>/dev/null)
250
+ SLUG=$(pp_run query config-get project.slug --raw 2>/dev/null)
251
+ VIS_RAW=$(pp_run query config-get project.visibility --raw 2>/dev/null)
252
+ HOST=$(pp_run query platform-bindings --pick platform.host 2>/dev/null)
253
+ # D-10: hard-default to private when field is absent or empty
254
+ VIS="${VIS_RAW:-private}"
255
+ DESC=$(pp_run query config-get project.description --raw 2>/dev/null || echo "")
256
+ ```
257
+
258
+ `HOST` still defaults to `gitlab.com` in remote URLs below when empty (the existing
259
+ `${HOST:-gitlab.com}` fallback below, unchanged). `DESC` now carries `--raw`: without it
260
+ `config-get` returns a JSON-quoted string, so the create step's `--description "$DESC"`
261
+ would pass the surrounding quotes through into the created project's description.
262
+
263
+ **Empty-group guard (closes T-34-41).** A bare or half-empty group/slug pair is the
264
+ personal-namespace landmine named in the security invariants below: creating with an
265
+ empty group lands under the user's personal namespace, not the client group. When
266
+ `GROUP` or `SLUG` is empty, print a clear line naming which one is missing and where it
267
+ comes from, skip G-2 through G-5 entirely, and fall through to Step 3's generic remote
268
+ wiring:
269
+
270
+ ```bash
271
+ if [ -z "$GROUP" ] || [ -z "$SLUG" ]; then
272
+ echo "Skipping GitLab provisioning: $( [ -z "$GROUP" ] && echo "GROUP (org profile seed provisioning group)" || echo "SLUG (project.slug)" ) is empty — falling through to Step 3's generic remote wiring."
273
+ fi
274
+ ```
217
275
 
218
- This path runs **instead of** the generic remote-wiring path above when
219
- `pp_run query platform-bindings --pick present 2>/dev/null` equals `"true"`. Implements
220
- D-05 (per-action confirmation), D-09 (existence probe → adopt-if-exists), D-10 (private
221
- by default), D-11 (auth verify-only), D-12 (adopt-if-exists reconciliation), D-13
222
- (bindings gate). Covers INIT-02 and the cross-cutting SAFE-01 invariant.
276
+ **Provider gate (D-11, D-13).** Read the one authority for which provisioning path runs —
277
+ `--offline` so this never touches the network:
278
+
279
+ ```bash
280
+ PROVIDER=$(pp_run query compose-bindings --offline --pick provider 2>/dev/null)
281
+ if [ "$PROVIDER" != "gitlab" ]; then
282
+ echo "Skipping GitLab-specific provisioning for provider \"$PROVIDER\" — falling through to Step 3's generic remote wiring instead."
283
+ fi
284
+ ```
285
+
286
+ Continue to G-2 only when the empty-group guard above did not skip **and** `PROVIDER`
287
+ equals `gitlab`. Otherwise stop here and go to Step 3's generic remote wiring (`:283-312`)
288
+ instead — do not fail, do not error, do not prompt for a missing CLI. "Skipped, not
289
+ failed" is satisfied *and* the user still leaves with a wired remote, which a bare skip
290
+ would not give them; pp-core makes zero provider-API calls it has no client for.
223
291
 
224
292
  **Security invariants — landmine guards:**
225
293
  - Always use the group-qualified `$GROUP/$SLUG` path — never a bare slug (T-08-04, D-09).
226
- A bare slug creates under the user's personal namespace, not the client group.
294
+ A bare slug creates under the user's personal namespace, not the client group. G-1's
295
+ empty-group guard above is what actually enforces this before any create attempt.
227
296
  - Always pass `--private` explicitly — never inherit `glab`'s default `--internal`
228
297
  visibility (T-08-05, D-10). The `--internal` flag is what makes this dangerous; the
229
298
  locked recipe must carry `--private` and must NOT emit `--internal`.
@@ -233,25 +302,6 @@ by default), D-11 (auth verify-only), D-12 (adopt-if-exists reconciliation), D-1
233
302
  - Always reconcile create with a `glab repo view` probe — a non-zero create exit does
234
303
  NOT mean nothing happened (T-08-09, D-09).
235
304
 
236
- #### G-1. Config reads from the bindings reader (Pattern C, D-09, D-13)
237
-
238
- All values come from the Phase 7 bindings reader. **Never hardcoded.**
239
-
240
- ```bash
241
- GROUP=$(pp_run query platform-bindings --pick gitlab.group 2>/dev/null)
242
- SLUG=$(pp_run query platform-bindings --pick gitlab.repo_slug 2>/dev/null)
243
- VIS_RAW=$(pp_run query platform-bindings --pick gitlab.visibility 2>/dev/null)
244
- HOST=$(pp_run query platform-bindings --pick gitlab.host 2>/dev/null)
245
- # D-10: hard-default to private when field is absent or empty
246
- VIS="${VIS_RAW:-private}"
247
- DESC=$(pp_run query config-get project.description 2>/dev/null || echo "")
248
- ```
249
-
250
- The `gitlab.*` namespace is provisional — the bindings reader is lenient on unknown fields
251
- (it only hard-validates `schemaVersion`, `platform.repo`, and `platform.tag`). Treat any
252
- absent `gitlab.*` field gracefully: fall back to safe defaults, never fail hard on a
253
- missing key. `HOST` defaults to `gitlab.com` in remote URLs when empty.
254
-
255
305
  #### G-2. Auth verify-only (D-11)
256
306
 
257
307
  `/pp-init` **never establishes auth and never reads or writes the user's token.**
@@ -518,13 +568,24 @@ chosen command or stop cleanly.
518
568
 
519
569
  ## 5. Commit
520
570
 
521
- Commit the `.planning/config.json` identity stub created in Step 2:
571
+ Commit the `.planning/config.json` identity stub created in Step 2. When the Step 2 session
572
+ composed and wrote a binding (the `compose_confirm` approval), commit `.platform-bindings.json`
573
+ in this same commit rather than a second one (D-09): being committed is what makes the
574
+ binding outrank the profile on every later run, and leaving it untracked would leave the
575
+ precedence story half-built while still tripping every presence gate. The file may
576
+ legitimately not exist — the session declines or composes nothing on the inert path
577
+ (INIT-02) — so branch explicitly rather than word-splitting an unquoted variable into
578
+ `--files`:
522
579
 
523
580
  ```bash
524
- pp_run query commit "docs: initialize project" --files .planning/config.json
581
+ if [ -f .platform-bindings.json ]; then
582
+ pp_run query commit "docs: initialize project" --files .planning/config.json .platform-bindings.json
583
+ else
584
+ pp_run query commit "docs: initialize project" --files .planning/config.json
585
+ fi
525
586
  ```
526
587
 
527
- If `.planning/config.json` is the only new file, stage only that file. If other planning
528
- artifacts were created (e.g. `.planning/` directory structure), include them.
588
+ If other planning artifacts were created (e.g. `.planning/` directory structure), include
589
+ them too.
529
590
 
530
591
  </process>
@@ -133,6 +133,7 @@ Verify the work is ready to ship:
133
133
  ℹ Ship gate advisory ({hook.check.query}): {status}{GATE_RESULT.severity ? ` — ${GATE_RESULT.severity} drift detected` : ''} (non-blocking or below blocking threshold). Continuing.
134
134
  ```
135
135
  - If `status` is `pass` or `inert`: continue silently.
136
+ - Independently of the status arms above, if `GATE_RESULT.message` is a non-empty string, print it verbatim — it is already operator-ready, possibly multi-line text — then continue: a gate that has something to tell the operator says it on every run, not only when its status is a warning.
136
137
  </step>
137
138
 
138
139
  <step name="push_branch">
@@ -0,0 +1,213 @@
1
+ <purpose>
2
+ Take one local practice file from preview to a confirmed, pushed promotion into the client
3
+ vault — and nothing more. This workflow is the single human gate in front of an
4
+ irreversible outward action: it shows the operator the complete preview, asks once whether
5
+ to proceed, and only on explicit approval re-invokes the same guarded CLI arm in confirmed
6
+ mode. It never authors, edits, or reformats the practice file, and it performs no direct git
7
+ operation of its own — every outward action happens inside the promote verb, which this
8
+ workflow only calls.
9
+ </purpose>
10
+
11
+ <required_reading>
12
+ Read all files referenced by the invoking prompt's execution_context before starting.
13
+ </required_reading>
14
+
15
+ <process>
16
+
17
+ <step name="text_mode">
18
+ Resolve `TEXT_MODE` here, before the first question, mirroring the same resolution `init.md`
19
+ does before its own first prompt. Set `TEXT_MODE=true` if `--text` is present in
20
+ `$ARGUMENTS`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call in this
21
+ workflow — including the gate in Step 4 — with a plain-text numbered list, and ask the
22
+ operator to type their choice number. This is required because non-Claude runtimes have no
23
+ interactive question tool, and the confirmation in Step 4 is the entire point of this
24
+ workflow — it must degrade to plain text, never disappear.
25
+ </step>
26
+
27
+ <step name="resolve_launcher">
28
+ Resolve the `pp_run` launcher shim before any other step, so every call below goes through
29
+ the repo-local or installed `pp-tools` binary rather than assuming a global install:
30
+
31
+ ```bash
32
+ _PP_SHIM_NAME="pp-tools.cjs"; _PP_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; PP_TOOLS="${_PP_RUNTIME_ROOT}/pp-core/bin/${_PP_SHIM_NAME}"; if [ -f "$PP_TOOLS" ]; then pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${_PP_RUNTIME_ROOT}/.claude/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${_PP_RUNTIME_ROOT}/.claude/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${_PP_RUNTIME_ROOT}/.codex/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${_PP_RUNTIME_ROOT}/.codex/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif command -v pp-tools >/dev/null 2>&1; then PP_TOOLS="$(command -v pp-tools)"; pp_run() { "$PP_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="$HOME/.claude/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${HERMES_HOME:-$HOME/.hermes}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CODEX_HOME:-$HOME/.codex}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/pp-core/bin/${_PP_SHIM_NAME}" ]; then PP_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/pp-core/bin/${_PP_SHIM_NAME}"; pp_run() { node "$PP_TOOLS" "$@"; }; else echo "ERROR: pp-tools.cjs not found at $PP_TOOLS and pp-tools is not on PATH. Run: npx -y @pareto4/pp-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${PP_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${PP_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
33
+ ```
34
+ </step>
35
+
36
+ <step name="resolve_input">
37
+ Take the practice file path from `$ARGUMENTS` (the first token that is not `--text`). When
38
+ none was given, ask the operator for one with a plain prompt:
39
+
40
+ ```text
41
+ Path to the local practice file to promote (e.g. practices/my-practice.md):
42
+ ```
43
+
44
+ Do not compose, author, rewrite, or reformat the practice file at this or any later step —
45
+ authoring stays outside this guarded path entirely. Whatever bytes are on disk right now at
46
+ that path are exactly the bytes that will be previewed and, if approved, pushed.
47
+
48
+ ```bash
49
+ PRACTICE_FILE="<the resolved path>"
50
+ ```
51
+ </step>
52
+
53
+ <step name="preview">
54
+ Run the promote verb with the file path and `--raw`, capturing stdout, stderr, and the exit
55
+ code. This invocation performs no outward action — it only clones into a disposable
56
+ directory, computes the diff, and reports.
57
+
58
+ ```bash
59
+ PROMOTE_PREVIEW_OUT=$(mktemp)
60
+ PROMOTE_PREVIEW_ERR=$(mktemp)
61
+ pp_run vault practices promote "$PRACTICE_FILE" --raw >"$PROMOTE_PREVIEW_OUT" 2>"$PROMOTE_PREVIEW_ERR"
62
+ PREVIEW_EXIT=$?
63
+ ```
64
+
65
+ **When `$PREVIEW_EXIT` is non-zero: stop here.** Show the operator the exact, single-sentence
66
+ contents of `$PROMOTE_PREVIEW_ERR` — the arm's own refusal message — verbatim and add nothing
67
+ to it. Do not ask anything. Do not proceed to Step 3. Every guard refusal ends at this line,
68
+ and nothing has been pushed. Clean up both temp files before ending.
69
+
70
+ When `$PREVIEW_EXIT` is zero, continue to Step 3 with `$PROMOTE_PREVIEW_OUT` holding the
71
+ preview verdict as JSON.
72
+ </step>
73
+
74
+ <step name="render_confirmation">
75
+ Parse `$PROMOTE_PREVIEW_OUT` as JSON and read its fields directly — never regex-scrape a
76
+ prose rendering of it. That shortcut is exactly the shell-glue blind spot this project has
77
+ been bitten by before: a `.md` step file's own parsing drifting from what the CLI actually
78
+ emits.
79
+
80
+ Field values are vault-authored, so they are read as **data, never as shell syntax**. `id`
81
+ is only constrained to equal the practice file's basename (D-03), and a filename may legally
82
+ contain `$(...)`, backticks, or `;` — so writing the fields as a `KEY="value"` file and
83
+ `source`-ing it would execute whatever the filename contains. `JSON.stringify` does not help:
84
+ it escapes `"` and `\` but leaves `$` and backticks intact, and both expand inside double
85
+ quotes. Each field therefore goes to its own file and is read back with `$(cat …)`, whose
86
+ result bash does not re-scan for expansion.
87
+
88
+ ```bash
89
+ PROMOTE_FIELDS=$(mktemp -d)
90
+ PROMOTE_BODY=$(mktemp)
91
+ node -e "
92
+ const fs = require('fs');
93
+ const path = require('path');
94
+ const v = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
95
+ const dir = process.argv[2];
96
+ const put = (k, val) => fs.writeFileSync(path.join(dir, k), String(val ?? ''));
97
+ put('repo', v.repo);
98
+ put('ref', v.ref);
99
+ put('destination', v.destination);
100
+ put('mode', v.mode);
101
+ put('id', v.id);
102
+ put('sharedShadow', v.sharedShadow ? 'true' : 'false');
103
+ put('sharedShadowNote', v.sharedShadowNote);
104
+ put('expect', v.expect);
105
+ fs.writeFileSync(process.argv[3], v.mode === 'update' ? (v.diff ?? '') : (v.content ?? ''));
106
+ " "$PROMOTE_PREVIEW_OUT" "$PROMOTE_FIELDS" "$PROMOTE_BODY"
107
+ PROMOTE_REPO=$(cat "$PROMOTE_FIELDS/repo")
108
+ PROMOTE_REF=$(cat "$PROMOTE_FIELDS/ref")
109
+ PROMOTE_DEST=$(cat "$PROMOTE_FIELDS/destination")
110
+ PROMOTE_MODE=$(cat "$PROMOTE_FIELDS/mode")
111
+ PROMOTE_ID=$(cat "$PROMOTE_FIELDS/id")
112
+ PROMOTE_SHARED=$(cat "$PROMOTE_FIELDS/sharedShadow")
113
+ PROMOTE_SHARED_NOTE=$(cat "$PROMOTE_FIELDS/sharedShadowNote")
114
+ PROMOTE_EXPECT=$(cat "$PROMOTE_FIELDS/expect")
115
+ ```
116
+
117
+ Show the operator, in this exact order, reading every value from the parsed fields above —
118
+ never truncated, summarized, or collapsed to "the first N lines":
119
+
120
+ 1. The target vault repository (`$PROMOTE_REPO`) and ref (`$PROMOTE_REF`).
121
+ 2. The destination path inside that repository (`$PROMOTE_DEST`).
122
+ 3. Whether this **creates** a new practice or **updates** an existing one
123
+ (`$PROMOTE_MODE`, for id `$PROMOTE_ID`).
124
+ 4. When `$PROMOTE_SHARED` is `true`, the shared-vault disclosure sentence verbatim from
125
+ `$PROMOTE_SHARED_NOTE` — this id also exists in the shared Pareto vault, and promoting it
126
+ here makes the client's version the one that applies for this client.
127
+ 5. One line stating plainly that the secret scan already run inside the promote verb found
128
+ nothing in this file — a file that failed that scan would have refused before a preview
129
+ was ever produced, so reaching this line already proves the scan passed.
130
+ 6. The complete contents of `$PROMOTE_BODY` — the full diff when `$PROMOTE_MODE` is
131
+ `update`, or the full new-file content when it is `create`. Show every byte; the operator
132
+ is about to approve exactly these bytes, so nothing here may be shortened.
133
+ </step>
134
+
135
+ <step name="gate">
136
+ Ask the operator with `AskUserQuestion` (or a numbered plain-text prompt when `TEXT_MODE` is
137
+ active) whether to push this to the client vault. Offer exactly two outcomes — there is no
138
+ default, no timeout, and no auto-advance, because this is an irreversible outward action and
139
+ this confirmation is the whole review step:
140
+
141
+ ```text
142
+ AskUserQuestion([
143
+ {
144
+ header: "Push to client vault",
145
+ question: "Push this practice to the client vault now?",
146
+ multiSelect: false,
147
+ options: [
148
+ { label: "Push it", description: "Push exactly the diff/content shown above" },
149
+ { label: "Stop", description: "Do not push anything — the vault stays unchanged" }
150
+ ]
151
+ }
152
+ ])
153
+ ```
154
+
155
+ **When the operator chooses to stop:** print one sentence saying nothing was pushed and the
156
+ client vault is unchanged, clean up the temp files, and end the workflow here.
157
+
158
+ **When the operator chooses to push:** continue to Step 5.
159
+ </step>
160
+
161
+ <step name="push">
162
+ Only inside this approved branch, run the promote verb again with the same file path, the
163
+ confirmed flag, the expect token carried verbatim from the preview verdict, and `--raw`. This
164
+ workflow never runs an outward git operation itself — the only thing it does here is invoke
165
+ this verb a second time; every push, every commit, and every other outward action happens
166
+ inside the verb, not in this shell block.
167
+
168
+ ```bash
169
+ PROMOTE_PUSH_OUT=$(mktemp)
170
+ PROMOTE_PUSH_ERR=$(mktemp)
171
+ pp_run vault practices promote "$PRACTICE_FILE" --confirmed --expect "$PROMOTE_EXPECT" --raw >"$PROMOTE_PUSH_OUT" 2>"$PROMOTE_PUSH_ERR"
172
+ PUSH_EXIT=$?
173
+ ```
174
+
175
+ **When `$PUSH_EXIT` is non-zero:** show the arm's own single-sentence refusal from
176
+ `$PROMOTE_PUSH_ERR` verbatim and end — the arm has already guaranteed nothing was pushed on a
177
+ refusal. A concurrent-edit refusal here is expected, ordinary behavior: it means someone else
178
+ changed this same practice in the client vault between the preview in Step 2 and this push.
179
+ The remedy is simply to run this workflow again from the top — the new preview will diff
180
+ against their current version. Clean up all temp files before ending.
181
+
182
+ When `$PUSH_EXIT` is zero, continue to Step 6 with `$PROMOTE_PUSH_OUT` holding the push
183
+ verdict as JSON.
184
+ </step>
185
+
186
+ <step name="report">
187
+ Parse `$PROMOTE_PUSH_OUT` the same way Step 3 parsed the preview verdict — direct field
188
+ reads, never a prose scrape:
189
+
190
+ ```bash
191
+ node -e "
192
+ const fs = require('fs');
193
+ const v = JSON.parse(fs.readFileSync(process.argv[1], 'utf8'));
194
+ process.stdout.write(
195
+ 'repo=' + (v.repo ?? '') + '\n' +
196
+ 'destination=' + (v.destination ?? '') + '\n' +
197
+ 'commit=' + (v.commit ?? '') + '\n' +
198
+ 'cacheRefreshed=' + String(!!v.cacheRefreshed) + '\n'
199
+ );
200
+ " "$PROMOTE_PUSH_OUT"
201
+ ```
202
+
203
+ Show the operator: the repository, the destination path, the new commit the push produced,
204
+ and whether the local read cache of the vault was refreshed. When the local cache refresh did
205
+ not succeed, say so plainly and note that the next vault fetch will pick up the change — this
206
+ is a cosmetic staleness in the local cache only, not a sign the push failed.
207
+
208
+ Clean up every temp path created in this workflow (`$PROMOTE_PREVIEW_OUT`,
209
+ `$PROMOTE_PREVIEW_ERR`, `$PROMOTE_BODY`, `$PROMOTE_PUSH_OUT`, `$PROMOTE_PUSH_ERR`, and the
210
+ `$PROMOTE_FIELDS` **directory**, which needs `rm -rf`) before ending.
211
+ </step>
212
+
213
+ </process>
@@ -1232,29 +1232,40 @@ function validateGate(gate, prefix) {
1232
1232
  /**
1233
1233
  * Validate per-capability contract constraints against the Loop Host Contract.
1234
1234
  * This covers:
1235
- * - contribution.into ∈ step's agentRoles
1235
+ * - contribution.into ∈ step's agentRoles, OR contribution.into names an agent
1236
+ * already registered by some capability's own `agents` array (a non-pipeline
1237
+ * consumer of the same loop point, e.g. code-review.md reading execute:post
1238
+ * alongside execute-phase.md — see capabilities/vault-practices's
1239
+ * execute:post -> pp-code-reviewer contribution)
1236
1240
  * - when references a config key in cap.config
1237
1241
  *
1238
1242
  * NOTE: step.consumes satisfiability is NOT checked here — it requires the full
1239
1243
  * set of validated capabilities (cross-capability produces). It runs in
1240
1244
  * validateConsumesGlobal() after loadAndValidate builds capMap.
1241
1245
  *
1242
- * @param {object} cap Validated capability object
1243
- * @param {string} capId Capability id (for error messages)
1246
+ * @param {object} cap Validated capability object
1247
+ * @param {string} capId Capability id (for error messages)
1248
+ * @param {Set<string>} [knownAgentIds] Every agent id declared by any capability's
1249
+ * `agents` array, precomputed once across the whole capabilities/ tree
1250
+ * (order-independent — does not require the target capability to have
1251
+ * already passed validation). Defaults to an empty set so existing callers
1252
+ * that validate a single in-memory fixture are unaffected.
1244
1253
  */
1245
- function validateAgainstContract(cap, capId) {
1254
+ function validateAgainstContract(cap, capId, knownAgentIds) {
1246
1255
  if (cap.role !== 'feature') return [];
1247
1256
  const errors = [];
1248
1257
  const prefix = 'capability "' + capId + '"';
1258
+ const agentIds = knownAgentIds instanceof Set ? knownAgentIds : new Set();
1249
1259
 
1250
- // contribution.into must be in the step's agentRoles
1260
+ // contribution.into must be in the step's agentRoles, or name a registered agent
1251
1261
  for (const contrib of cap.contributions) {
1252
1262
  if (!VALID_LOOP_POINTS.has(contrib.point)) continue; // already reported
1253
1263
  const contract = POINT_TO_CONTRACT.get(contrib.point);
1254
- if (contract && !contract.agentRoles.includes(contrib.into)) {
1264
+ if (contract && !contract.agentRoles.includes(contrib.into) && !agentIds.has(contrib.into)) {
1255
1265
  errors.push(
1256
1266
  prefix + ' contribution.into "' + contrib.into + '" at point "' + contrib.point +
1257
- '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') + ']',
1267
+ '" is not in the step\'s agentRoles [' + contract.agentRoles.join(', ') +
1268
+ '] and is not a registered agent id',
1258
1269
  );
1259
1270
  }
1260
1271
  }
@@ -2055,6 +2066,49 @@ function validateHooksWired(cap, wiredSet) {
2055
2066
 
2056
2067
  // ─── Registry builder ─────────────────────────────────────────────────────────
2057
2068
 
2069
+ /**
2070
+ * Best-effort, order-independent pre-scan of every capability.json's `agents`
2071
+ * array, across the whole capabilities/ tree. Used by validateAgainstContract
2072
+ * to recognize a contribution.into target that names an agent registered by
2073
+ * some OTHER capability (not just the roles of the point's own owning step
2074
+ * workflow) — e.g. code-review.md is a non-pipeline consumer of the same
2075
+ * execute:post loop point execute-phase.md owns, and pp-code-reviewer is
2076
+ * registered as an agent by capabilities/code-review, not by an agent-role in
2077
+ * execute-phase.md's <!-- gsd:loop-host --> marker.
2078
+ *
2079
+ * Deliberately tolerant of malformed/missing files here — real validation
2080
+ * errors for those files are reported by the main loadAndValidate loop; this
2081
+ * pre-scan only needs the `agents` array, never blocks on anything else.
2082
+ *
2083
+ * @param {string} capabilitiesDir
2084
+ * @returns {Set<string>}
2085
+ */
2086
+ function collectDeclaredAgentIds(capabilitiesDir) {
2087
+ const agentIds = new Set();
2088
+ if (!fs.existsSync(capabilitiesDir)) return agentIds;
2089
+
2090
+ const folderEntries = fs.readdirSync(capabilitiesDir, { withFileTypes: true })
2091
+ .filter((e) => e.isDirectory())
2092
+ .map((e) => e.name);
2093
+
2094
+ for (const folderId of folderEntries) {
2095
+ const capPath = path.join(capabilitiesDir, folderId, 'capability.json');
2096
+ if (!fs.existsSync(capPath)) continue;
2097
+ let cap;
2098
+ try {
2099
+ cap = JSON.parse(fs.readFileSync(capPath, 'utf8'));
2100
+ } catch {
2101
+ continue; // malformed JSON reported elsewhere
2102
+ }
2103
+ if (Array.isArray(cap.agents)) {
2104
+ for (const agent of cap.agents) {
2105
+ if (typeof agent === 'string' && agent.length > 0) agentIds.add(agent);
2106
+ }
2107
+ }
2108
+ }
2109
+ return agentIds;
2110
+ }
2111
+
2058
2112
  /**
2059
2113
  * Read + validate all capabilities/<id>/capability.json files.
2060
2114
  * Returns { capMap, errors } where capMap is Map<id, cap>.
@@ -2078,6 +2132,12 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
2078
2132
  // scan is not repeated per-capability. ROOT is the repo root (defined at top of file).
2079
2133
  const wiredSet = getWiredLoopPoints(ROOT);
2080
2134
 
2135
+ // Pre-scan every capability's `agents` array ONCE, order-independent, so
2136
+ // validateAgainstContract can recognize a contribution.into target that
2137
+ // names a registered agent even when that agent's owning capability sorts
2138
+ // after the current one alphabetically.
2139
+ const knownAgentIds = collectDeclaredAgentIds(resolvedCapDir);
2140
+
2081
2141
  const folderEntries = fs.readdirSync(resolvedCapDir, { withFileTypes: true })
2082
2142
  .filter((e) => e.isDirectory())
2083
2143
  .map((e) => e.name)
@@ -2101,7 +2161,7 @@ function loadAndValidate(centralKeys, capabilitiesDir) {
2101
2161
  continue; // skip cross-validation if basic schema fails
2102
2162
  }
2103
2163
 
2104
- const contractErrors = validateAgainstContract(cap, cap.id);
2164
+ const contractErrors = validateAgainstContract(cap, cap.id, knownAgentIds);
2105
2165
  if (contractErrors.length > 0) {
2106
2166
  for (const e of contractErrors) errors.push(folderId + '/capability.json: ' + e);
2107
2167
  // Fix #6: do NOT add contract-invalid caps to capMap — validateCrossCapability should