@pareto4/pp-core 1.8.2 → 1.10.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 (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -0
  3. package/agents/pp-code-fixer.md +11 -0
  4. package/agents/pp-code-reviewer.md +77 -1
  5. package/agents/pp-executor.md +34 -1
  6. package/agents/pp-planner.md +24 -103
  7. package/bin/install.js +9 -1
  8. package/commands/pp-ns-manage.md +2 -1
  9. package/commands/pp-vault-promote.md +31 -0
  10. package/gemini-extension.json +1 -1
  11. package/package.json +6 -3
  12. package/pareto-skills/pp-project-setup/SKILL.md +32 -440
  13. package/pp-core/bin/lib/capability-registry.cjs +111 -2
  14. package/pp-core/bin/lib/capability-state.cjs +54 -10
  15. package/pp-core/bin/lib/check-command-router.cjs +473 -3
  16. package/pp-core/bin/lib/clusters.cjs +3 -0
  17. package/pp-core/bin/lib/config.cjs +7 -22
  18. package/pp-core/bin/lib/io.cjs +7 -0
  19. package/pp-core/bin/lib/loop-resolver.cjs +6 -0
  20. package/pp-core/bin/lib/practices-review-check.cjs +938 -0
  21. package/pp-core/bin/lib/profile-output.cjs +4 -2
  22. package/pp-core/bin/lib/vault-command-router.cjs +108 -2
  23. package/pp-core/bin/lib/vault-practices.cjs +982 -0
  24. package/pp-core/bin/lib/vault-write-back.cjs +756 -0
  25. package/pp-core/bin/lib/vault.cjs +45 -3
  26. package/pp-core/bin/pp-tools.cjs +26 -0
  27. package/pp-core/bin/shared/config-schema.manifest.json +4 -0
  28. package/pp-core/references/planner-checkpoints.md +72 -0
  29. package/pp-core/references/planner-tdd.md +55 -0
  30. package/pp-core/references/planner-vault-practices.md +67 -0
  31. package/pp-core/templates/practices/README.md +122 -0
  32. package/pp-core/templates/practices/error-handling.md +18 -0
  33. package/pp-core/templates/practices/minimal-comments.md +14 -0
  34. package/pp-core/templates/practices/naming-convention.md +18 -0
  35. package/pp-core/templates/practices/practice-template.md +23 -0
  36. package/pp-core/workflows/code-review.md +74 -18
  37. package/pp-core/workflows/discovery-sprint.md +409 -16
  38. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +250 -0
  39. package/pp-core/workflows/discovery-to-build.md +218 -48
  40. package/pp-core/workflows/discuss-phase.md +1 -1
  41. package/pp-core/workflows/execute-phase.md +1 -1
  42. package/pp-core/workflows/plan-phase.md +1 -1
  43. package/pp-core/workflows/ship.md +1 -0
  44. package/pp-core/workflows/vault-write-back.md +213 -0
  45. package/scripts/changeset/lint.cjs +20 -5
  46. package/scripts/gen-capability-registry.cjs +68 -8
  47. package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
  48. package/scripts/lint-descriptions.cjs +3 -1
  49. package/scripts/lint-docs-required.cjs +10 -3
  50. package/scripts/lint-legacy-dir-name.cjs +5 -0
  51. package/scripts/lint-test-file-count.allowlist.json +10 -1
  52. package/scripts/release-channel.cjs +58 -0
  53. package/scripts/release-tarball-smoke.cjs +4 -0
  54. package/scripts/verify-opengsd-sweep.cjs +12 -0
  55. package/scripts/verify-rename-guard.cjs +333 -0
@@ -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>
@@ -24,20 +24,35 @@ const OPT_OUT_LABEL = 'no-changelog';
24
24
 
25
25
  // Files counted as "user-facing" — touching any of these requires either a
26
26
  // fragment or an explicit opt-out label. Test/CI/docs/lock files do not.
27
+ // This list mirrors package.json's npm-shipped `files` array so that
28
+ // everything reaching an end user's install is gate-covered. Two `files`
29
+ // entries are deliberately EXCLUDED here: `LICENSE` and `NOTICE` — a
30
+ // licence-text edit carries no changelog-worthy behavior change, so it is
31
+ // not a gate trigger even though it ships in the tarball.
32
+ //
33
+ // `src/` is the load-bearing addition (BUS-02, 21-10): the compiled
34
+ // `pp-core/bin/lib/*.cjs` output is gitignored, so a CLI behavior change
35
+ // reaches users through `src/*.cts` alone. Before this widening such a
36
+ // diff matched zero prefixes here (see historical bypass commit 3320c0a4).
27
37
  const USER_FACING_PREFIXES = [
28
38
  'bin/',
29
- 'gsd-core/',
39
+ 'pp-core/',
30
40
  'agents/',
31
41
  'commands/',
32
42
  'hooks/',
33
- 'sdk/src/',
34
- 'sdk/prompts/',
43
+ 'src/',
44
+ 'pareto-skills/',
45
+ 'assets/',
46
+ '.claude-plugin/',
47
+ 'scripts/',
35
48
  ];
36
49
 
37
50
  // Exact-match user-facing files. Any direct edit to one of these without a
38
51
  // fragment also fails the lint — closes the bypass where a contributor edits
39
- // CHANGELOG.md directly to sneak past the new workflow.
40
- const USER_FACING_FILES = new Set(['CHANGELOG.md']);
52
+ // CHANGELOG.md directly to sneak past the new workflow. `gemini-extension.json`
53
+ // and `GEMINI.md` are the other two exact-match npm-shipped files (package.json
54
+ // `files`) that are not already covered by a directory prefix above.
55
+ const USER_FACING_FILES = new Set(['CHANGELOG.md', 'gemini-extension.json', 'GEMINI.md']);
41
56
 
42
57
  function isUserFacing(file) {
43
58
  if (USER_FACING_FILES.has(file)) return true;
@@ -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
@@ -326,5 +326,12 @@
326
326
  "tests/workflow-size-budget.test.cjs :: source-text-is-the-product",
327
327
  "tests/workspace.test.cjs :: source-text-is-the-product",
328
328
  "tests/worktree-cleanup.test.cjs :: source-text-is-the-product",
329
- "tests/worktree.test.cjs :: source-text-is-the-product"
329
+ "tests/worktree.test.cjs :: source-text-is-the-product",
330
+ "tests/comp-01-provider-off-by-default.test.cjs :: validates runtime CLI stdout/stderr/on-disk-config behavior, not source grep",
331
+ "tests/discovery-annotations.test.cjs :: source-text-is-the-product",
332
+ "tests/discovery-architecture-deliverables.test.cjs :: source-text-is-the-product",
333
+ "tests/discovery-to-build-canonical-refs.test.cjs :: source-text-is-the-product",
334
+ "tests/discovery-to-build-fixture.test.cjs :: source-text-is-the-product",
335
+ "tests/discovery-to-build-path-contract.test.cjs :: source-text-is-the-product",
336
+ "tests/release-smoke-discuss-plan-execute.install.test.cjs :: integration-test-input"
330
337
  ]
@@ -18,7 +18,9 @@ const path = require('path');
18
18
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
19
19
 
20
20
  const MAX_LENGTH = 100;
21
- const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
21
+ // D-12 flat-hyphen rename (05-04): command files live flat at commands/pp-<cmd>.md,
22
+ // no longer under a legacy nested subdirectory (mirrors lint-command-contract.cjs's COMMANDS_DIR).
23
+ const COMMANDS_DIR = path.join(__dirname, '..', 'commands');
22
24
 
23
25
  /**
24
26
  * Parse the description field from frontmatter in a .md file.
@@ -38,8 +38,10 @@ function isFragmentPath(file) {
38
38
  return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md');
39
39
  }
40
40
 
41
+ // Phase 21 BUS-02/D-03: CONTRIBUTING.md is a gated deliverable in addition to
42
+ // anything under docs/, so engine MRs that touch it are recognized by the gate.
41
43
  function isDocsFile(file) {
42
- return file.startsWith(DOCS_PREFIX);
44
+ return file === 'CONTRIBUTING.md' || file.startsWith(DOCS_PREFIX);
43
45
  }
44
46
 
45
47
  // Per-fragment escape hatch: parse.cjs extracts `<!-- docs-exempt: <reason> -->`
@@ -193,9 +195,14 @@ function main() {
193
195
  for (const f of fragments.filter((f) => TRIGGERING_TYPES.has(f.type))) {
194
196
  process.stderr.write(` ${f.path} (type: ${f.type})\n`);
195
197
  }
196
- process.stderr.write(`\nNo files under docs/ were modified in this PR.\n\n`);
197
198
  process.stderr.write(
198
- `Update the relevant docs/ file(s), or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
199
+ `\nNo gate-satisfying documentation file was modified in this PR — neither anything\n`,
200
+ );
201
+ process.stderr.write(
202
+ `under \`${DOCS_PREFIX}\` nor \`CONTRIBUTING.md\`.\n\n`,
203
+ );
204
+ process.stderr.write(
205
+ `Update a file under \`${DOCS_PREFIX}\` or \`CONTRIBUTING.md\`, or add the \`${OPT_OUT_LABEL}\` label if this change\n`,
199
206
  );
200
207
  process.stderr.write(
201
208
  `is genuinely internal-only (infrastructure, refactor, test-only). Per-fragment\n`,
@@ -53,6 +53,11 @@ const ALLOWLIST_FILES = new Set([
53
53
  'README.ko-KR.md',
54
54
  'README.pt-BR.md',
55
55
  'README.zh-CN.md',
56
+ // Generated snapshot of every grandfathered legacy-token reference in the tree,
57
+ // so it quotes those lines by construction — including this file's own comment
58
+ // above. Scanning it here is circular: the baseline cannot carry per-line allow
59
+ // markers without breaking its byte-for-byte match against the real sources.
60
+ 'tests/rename-guard-baseline.json',
56
61
  ]);
57
62
 
58
63
  const ALLOWLIST_DIR_PREFIXES = [
@@ -179,12 +179,21 @@
179
179
  },
180
180
  "vault": {
181
181
  "files": [
182
+ "vault-practices.property.test.cjs",
182
183
  "vault-read-path.test.cjs",
183
184
  "vault-security.test.cjs",
184
185
  "vault-status-check.test.cjs",
185
186
  "vault.test.cjs"
186
187
  ],
187
- "issue": "v1.7.0 Client Vault Load — four distinct concerns: vault.test (fetch/status/summary unit), vault-security (VSEC-02/03 write-guard + secret/fence), vault-read-path (VGATE-03 SUMMARY pointer), vault-status-check (VGATE-02 gate-query handler)"
188
+ "issue": "v1.7.0 Client Vault Load — four distinct concerns: vault.test (fetch/status/summary unit), vault-security (VSEC-02/03 write-guard + secret/fence), vault-read-path (VGATE-03 SUMMARY pointer), vault-status-check (VGATE-02 gate-query handler). Phase 24-02 added vault-practices.property.test.cjs (fast-check fuzz of the practices glob-fallback compiler + a practices-frontmatter round-trip, PRAC-02) — it is grouped under this bucket rather than \"vault-practices\" because the test-file-count lint's hyphen-prefix matcher treats \"vault-practices.property\" (period before \"property\") as starting with \"vault-\" before it can match the more specific \"vault-practices-\" prefix; the module it actually tests is pp-core/bin/lib/vault-practices.cjs, not vault.cjs."
189
+ },
190
+ "vault-practices": {
191
+ "files": [
192
+ "vault-practices-examples.test.cjs",
193
+ "vault-practices-matcher.test.cjs",
194
+ "vault-practices.test.cjs"
195
+ ],
196
+ "issue": "Phase 24 (24-01/24-02/24-04) — three distinct concerns for pp-core/bin/lib/vault-practices.cjs: vault-practices.test (24-01 parser/validation/discovery unit tests), vault-practices-matcher.test (24-02 glob matcher + two-bucket result + report unit tests), vault-practices-examples.test (24-04 shipped-example drift guard + minimal-comments global-bucket smoke case, PRAC-03 Success Criterion 3). All three test the same production module and are each independently load-bearing; consolidating would blur distinct plan-level verification gates."
188
197
  }
189
198
  }
190
199
  }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * release-channel.cjs
6
+ *
7
+ * Pure decision logic mapping a git release tag to an npm dist-tag publish
8
+ * channel. Given a tag like `v1.9.0-rc.1` returns `'next'`; given a clean
9
+ * `v1.8.1` returns `'latest'`. Fails closed (returns `null` / CLI exits 1) on
10
+ * any unrecognized or malformed input instead of defaulting to `'latest'` —
11
+ * the production-facing channel (ASVS V5 tampering concern: `CI_COMMIT_TAG`
12
+ * is attacker-influenceable if a bad tag is pushed). Matching is exhaustive
13
+ * and full-string anchored: anything that isn't an exact `X.Y.Z` release or
14
+ * an exact `X.Y.Z-(rc|beta|alpha).N` prerelease returns null — no partial or
15
+ * heuristic match ever resolves to `'latest'`.
16
+ *
17
+ * Usage:
18
+ * node scripts/release-channel.cjs "$CI_COMMIT_TAG" # prints channel, exit 0
19
+ * node scripts/release-channel.cjs # reads CI_COMMIT_TAG env
20
+ * argv (process.argv[2]) is preferred over the CI_COMMIT_TAG env var so the
21
+ * module is deterministic under its own tests even when CI_COMMIT_TAG is
22
+ * set in the ambient environment (as it always is on a real GitLab tag
23
+ * pipeline).
24
+ * (unrecognized/empty tag) -> stderr message, exit 1, no stdout
25
+ */
26
+
27
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
28
+
29
+ // Exact release version: no prerelease suffix, no build metadata, no trailing
30
+ // garbage.
31
+ const RELEASE_RE = /^\d+\.\d+\.\d+$/;
32
+
33
+ // Exact prerelease version: matches upstream gsd-core's own prerelease convention: -rc.N / -beta.N / -alpha.N — gsd-allow-legacy-name
34
+ const PRERELEASE_RE = /^\d+\.\d+\.\d+-(?:rc|beta|alpha)\.\d+$/;
35
+
36
+ function channelForTag(tag) {
37
+ if (typeof tag !== 'string' || tag.length === 0) return null;
38
+ const version = tag.replace(/^v/, '');
39
+ if (RELEASE_RE.test(version)) return 'latest';
40
+ if (PRERELEASE_RE.test(version)) return 'next';
41
+ return null;
42
+ }
43
+
44
+ module.exports = { channelForTag, PRERELEASE_RE };
45
+
46
+ function cliMain() {
47
+ const tag = process.argv[2] || process.env.CI_COMMIT_TAG || '';
48
+ const channel = channelForTag(tag);
49
+ if (!channel) {
50
+ throw new ExitError(1, `release-channel: unrecognized tag "${tag}"`);
51
+ }
52
+ process.stdout.write(channel + '\n');
53
+ return 0;
54
+ }
55
+
56
+ if (require.main === module) {
57
+ runMain(cliMain);
58
+ }
@@ -756,6 +756,10 @@ module.exports = {
756
756
  locateLegacyGsdProgress,
757
757
  driveInstaller,
758
758
  findInstallerBin,
759
+ // 20-02 Task 1: expose the installed pp-tools bin resolver so the
760
+ // REL-02 discuss->plan->execute smoke test can resolve it without
761
+ // re-implementing bin resolution (RESEARCH.md Don't-Hand-Roll).
762
+ findGsdToolsBin, // gsd-allow-legacy-name
759
763
  };
760
764
 
761
765
  if (require.main === module) {
@@ -41,6 +41,16 @@ const ROOT = path.resolve(__dirname, '..');
41
41
  /** Matches any recognised OpenGSD spelling in a line of text. */
42
42
  const OPENGSD_RE = /opengsd|open-gsd/i;
43
43
 
44
+ /**
45
+ * MIT-attribution carve-out (line-level): the README's License & Attribution
46
+ * section must name the upstream project — the same D-07 class as
47
+ * LICENSE/NOTICE, but scoped to attribution lines only so the rest of the
48
+ * README stays on the scanned surface.
49
+ */
50
+ const ATTRIBUTION_LINE_RE =
51
+ /hard fork of \[gsd-core\]\(https:\/\/github\.com\/open-gsd\/gsd-core\)/;
52
+ const README_FILE = path.join(__dirname, '..', 'README.md');
53
+
44
54
  /**
45
55
  * Broader brand pattern for npm-facing manifest metadata (package.json
46
56
  * `description` / `author`). Catches the upstream product name ("GSD Core")
@@ -185,6 +195,8 @@ function scanFiles(files) {
185
195
  const lines = content.split('\n');
186
196
  for (let i = 0; i < lines.length; i++) {
187
197
  if (OPENGSD_RE.test(lines[i])) {
198
+ // README attribution line: MIT-required upstream credit (D-07 class).
199
+ if (abs === README_FILE && ATTRIBUTION_LINE_RE.test(lines[i])) continue;
188
200
  hits.push({
189
201
  file: path.relative(ROOT, abs),
190
202
  line: i + 1,