@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
@@ -40,6 +40,14 @@ const prohibition_enforcement_cjs_1 = require("./prohibition-enforcement.cjs");
40
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
41
  const vaultModule = require("./vault.cjs");
42
42
  const { vaultStatus, readManifest } = vaultModule;
43
+ const git_base_branch_cjs_1 = require("./git-base-branch.cjs");
44
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const vaultPracticesModule = require("./vault-practices.cjs");
47
+ const { matchPractices, boundedToken, VALID_SEVERITIES } = vaultPracticesModule;
48
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
49
+ const frontmatterModule = require("./frontmatter.cjs");
50
+ const { extractFrontmatter } = frontmatterModule;
43
51
  // ─── Helpers ──────────────────────────────────────────────────────────────────
44
52
  function normalizePhrase(text) {
45
53
  // eslint-disable-next-line @typescript-eslint/no-base-to-string
@@ -56,10 +64,18 @@ function softPhrase(text) {
56
64
  return '';
57
65
  return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' ');
58
66
  }
67
+ function escapeRegExpLiteral(text) {
68
+ // Decision ids are project-authored (CONTEXT.md), not attacker-controlled, but an
69
+ // unescaped metacharacter (e.g. a stray `.` or `(`) still turns the id into an
70
+ // unintended pattern — crashing `new RegExp(...)` on invalid syntax or silently
71
+ // mis-matching a haystack that only resembles it. This is malformed-input hardening,
72
+ // not a security boundary.
73
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ }
59
75
  function decisionMentioned(haystack, decision) {
60
76
  if (!haystack)
61
77
  return false;
62
- if (new RegExp(`\\b${decision.id}\\b`).test(haystack))
78
+ if (new RegExp(`\\b${escapeRegExpLiteral(decision.id)}\\b`).test(haystack))
63
79
  return true;
64
80
  const phrase = softPhrase(decision.text);
65
81
  return phrase ? normalizePhrase(haystack).includes(phrase) : false;
@@ -967,7 +983,439 @@ function cmdVaultStatus(cwd, raw) {
967
983
  fetchedAt: vs.fetchedAt ?? null,
968
984
  }, raw, undefined);
969
985
  }
970
- function routeCheckCommand({ args, cwd, raw }) {
986
+ // ─── ship.practices query handler (INJ-03, Phase 28) ──────────────────────────
987
+ // Hard caps for the ship-time practices gate (D-05 line cap, T-28-03 DoS bound
988
+ // on top of vault-practices.cts's own PRACTICE_COUNT_CAP/MATCHED_FILES_CAP).
989
+ const SHIP_DIFF_FILE_CAP = 2000;
990
+ const PRACTICE_LINE_CAP = 10;
991
+ /**
992
+ * Read `.planning/config.json` and report whether the vault-practices
993
+ * feature is fully active for the ship gate: `vault.enabled === true` AND
994
+ * `vault.practices_enabled === true` (requires-closure, mirrors Phase 25's
995
+ * activation gate). Read-and-swallow — same shape as `readParetoConfig`.
996
+ * Absent/malformed config or either flag not strictly `true` reports both
997
+ * as inactive, which is the fail-closed default for an opt-in capability.
998
+ */
999
+ function readShipPracticesFlags(root) {
1000
+ const configPath = node_path_1.default.join(root, '.planning', 'config.json');
1001
+ try {
1002
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
1003
+ const vault = parsed['vault'] ?? {};
1004
+ return {
1005
+ enabled: vault['enabled'] === true,
1006
+ practicesEnabled: vault['practices_enabled'] === true,
1007
+ };
1008
+ }
1009
+ catch {
1010
+ return { enabled: false, practicesEnabled: false };
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Map a practice's tier to the plain-English phrase used inline in a ship
1015
+ * summary line (D-05/COMM-01) — `null` (no manifest tier recorded) reads as
1016
+ * "the vault" rather than leaking the internal tier vocabulary.
1017
+ */
1018
+ function shipTierPhrase(tier) {
1019
+ if (tier === 'client')
1020
+ return "the client's own standards";
1021
+ if (tier === 'fallback')
1022
+ return 'the shared standards';
1023
+ return 'the vault';
1024
+ }
1025
+ const SHIP_REVIEW_SOFT_NOTE = 'No practices review recorded for this phase.';
1026
+ /**
1027
+ * Choose which phase REVIEW.md file the ship-summary pointer should read
1028
+ * (T-28-06). Two-tier rule, in this order:
1029
+ *
1030
+ * 1. If any entry is a bare `<digits>-REVIEW.md` (the documented default
1031
+ * name), choose it — the lexicographically first when several exist
1032
+ * (defensive; should not occur in practice).
1033
+ * 2. Otherwise, among round-suffixed `<digits>-REVIEW-<suffix>.md` entries,
1034
+ * choose the one with the GREATEST mtime, breaking ties by the
1035
+ * lexicographically greatest name.
1036
+ *
1037
+ * Both tiers anchor on `^\d+-REVIEW` deliberately: other workflows write
1038
+ * differently-prefixed review artifacts into the SAME phase directory
1039
+ * (`NN-UI-REVIEW.md` from ui-review, `NN-EVAL-REVIEW.md` from eval-review)
1040
+ * whose names also end in `-REVIEW.md`. A suffix-only test would let a UI or
1041
+ * eval review satisfy tier 1 and short-circuit the mtime rule entirely — or,
1042
+ * with both `NN-REVIEW.md` and `NN-EVAL-REVIEW.md` present, pick the
1043
+ * eval file purely because `E` sorts before `R`.
1044
+ *
1045
+ * An alphabetical-only pick (`ls | head -1` style, the pattern
1046
+ * `ship.md`'s SECURITY_FILE resolution uses) would prefer a superseded
1047
+ * pre-gap-closure snapshot over the final numbered round purely because
1048
+ * `p` sorts before `r` — this is the exact Phase-27 shape RESEARCH.md
1049
+ * Pitfall 2 documents (nine round-suffixed files plus a pre-gap-closure
1050
+ * file, no bare file). The mtime rule avoids that failure; do not
1051
+ * replace it with a name-only sort.
1052
+ */
1053
+ function chooseShipReviewFile(dir) {
1054
+ let entries;
1055
+ try {
1056
+ entries = node_fs_1.default.readdirSync(dir);
1057
+ }
1058
+ catch {
1059
+ return null;
1060
+ }
1061
+ const bare = entries.filter((e) => /^\d+-REVIEW\.md$/.test(e)).sort();
1062
+ if (bare.length > 0)
1063
+ return bare[0];
1064
+ const roundSuffixed = entries.filter((e) => /^\d+-REVIEW-[^/]+\.md$/.test(e));
1065
+ if (roundSuffixed.length === 0)
1066
+ return null;
1067
+ let best = null;
1068
+ let bestMtimeMs = -Infinity;
1069
+ for (const name of roundSuffixed) {
1070
+ let mtimeMs;
1071
+ try {
1072
+ mtimeMs = node_fs_1.default.statSync(node_path_1.default.join(dir, name)).mtimeMs;
1073
+ }
1074
+ catch {
1075
+ continue;
1076
+ }
1077
+ if (best === null || mtimeMs > bestMtimeMs || (mtimeMs === bestMtimeMs && name > best)) {
1078
+ best = name;
1079
+ bestMtimeMs = mtimeMs;
1080
+ }
1081
+ }
1082
+ return best;
1083
+ }
1084
+ /**
1085
+ * Build the trailing "what did the code review record" pointer line
1086
+ * (D-08/D-09). Reads `.planning/STATE.md`'s `current_phase`, resolves the
1087
+ * phase directory via `findPhaseInternal` (never a hand-built
1088
+ * `.planning/phases/` path, never a directory glob outside the chosen phase
1089
+ * directory), chooses the right REVIEW.md file via `chooseShipReviewFile`,
1090
+ * and reads its `practices.total` count. Every step degrades to the soft
1091
+ * note on ANY failure — missing/unreadable STATE.md, unset current_phase,
1092
+ * an unresolved phase directory, no REVIEW.md file, a review file lacking
1093
+ * the `practices` key, or a non-numeric/negative total — never throws,
1094
+ * never changes the verdict's `status`.
1095
+ */
1096
+ function buildShipReviewPointerLine(root) {
1097
+ try {
1098
+ const statePath = node_path_1.default.join(root, '.planning', 'STATE.md');
1099
+ const stateContent = node_fs_1.default.readFileSync(statePath, 'utf-8');
1100
+ const stateFm = extractFrontmatter(stateContent);
1101
+ const currentPhase = stateFm['current_phase'];
1102
+ if (currentPhase === undefined || currentPhase === null || currentPhase === '') {
1103
+ return SHIP_REVIEW_SOFT_NOTE;
1104
+ }
1105
+ const phaseResult = findPhaseInternal(root, currentPhase);
1106
+ if (!phaseResult)
1107
+ return SHIP_REVIEW_SOFT_NOTE;
1108
+ const phaseDir = node_path_1.default.join(root, phaseResult.directory);
1109
+ const chosen = chooseShipReviewFile(phaseDir);
1110
+ if (!chosen)
1111
+ return SHIP_REVIEW_SOFT_NOTE;
1112
+ const reviewContent = node_fs_1.default.readFileSync(node_path_1.default.join(phaseDir, chosen), 'utf-8');
1113
+ const reviewFm = extractFrontmatter(reviewContent);
1114
+ const practicesBlock = reviewFm['practices'];
1115
+ if (typeof practicesBlock !== 'object' || practicesBlock === null || Array.isArray(practicesBlock)) {
1116
+ return SHIP_REVIEW_SOFT_NOTE;
1117
+ }
1118
+ const totalRaw = practicesBlock['total'];
1119
+ const total = typeof totalRaw === 'string' ? Number(totalRaw) : totalRaw;
1120
+ if (typeof total !== 'number' || !Number.isFinite(total) || !Number.isInteger(total) || total < 0) {
1121
+ return SHIP_REVIEW_SOFT_NOTE;
1122
+ }
1123
+ return `Code review recorded ${total} practice advisories — see ${chosen}.`;
1124
+ }
1125
+ catch {
1126
+ return SHIP_REVIEW_SOFT_NOTE;
1127
+ }
1128
+ }
1129
+ /**
1130
+ * ship.practices query handler (INJ-03).
1131
+ *
1132
+ * Reproduces the whole ship-time practices check by hand: resolves the
1133
+ * branch diff a pull request would actually merge (never the working
1134
+ * tree), filters out planning-directory paths and deletions (D-02), matches
1135
+ * the filtered file list against the vault's practices using the frozen
1136
+ * Phase 24 matcher as a library, and prints a compact, severity-ordered,
1137
+ * token-bounded summary (D-05/D-06) ending with a pointer to what the code
1138
+ * review recorded (D-08/D-09). Never throws; every fallible step ends in
1139
+ * exactly one `output()` call and a `return`, so the process exits 0 on
1140
+ * every path (T-28-05). `block` is always `false` — this handler never
1141
+ * blocks (D-04: deterministic report only, no judged pass).
1142
+ *
1143
+ * Reuse: `resolveBaseBranch` (git-base-branch.cts) for the diff base,
1144
+ * `execGit` (shell-command-projection.cts) for the diff subprocess,
1145
+ * `matchPractices` + `boundedToken` + `VALID_SEVERITIES` (vault-practices.cts)
1146
+ * for matching and safe rendering.
1147
+ *
1148
+ * Invocable as: pp_run check ship.practices [--raw]
1149
+ * The dot → hyphen normalization in routeCheckCommand maps "ship.practices"
1150
+ * to the "ship-practices" branch — the handler ignores `args` entirely
1151
+ * (D-03: zero new flags), which is what makes the gate hand-reproducible as
1152
+ * a bare `pp_run check ship.practices`.
1153
+ */
1154
+ function cmdShipPractices(cwd, _args, raw) {
1155
+ // Step 1 — root.
1156
+ let root;
1157
+ try {
1158
+ root = findProjectRoot(cwd) || cwd;
1159
+ }
1160
+ catch {
1161
+ root = cwd;
1162
+ }
1163
+ // Step 2 — inert guard (SC3 defence in depth; the registered gate's `when`
1164
+ // condition already prevents this handler being called in this state, so
1165
+ // this guard exists so a manual invocation on a non-vault project also
1166
+ // prints nothing).
1167
+ const { enabled, practicesEnabled } = readShipPracticesFlags(root);
1168
+ if (!enabled || !practicesEnabled) {
1169
+ output({ status: 'inert', block: false, message: '' }, raw, undefined);
1170
+ return;
1171
+ }
1172
+ // Step 3 — base branch. Never re-derive the precedence ladder here; the
1173
+ // generic gate dispatch passes this handler no arguments at all, so it
1174
+ // must own this resolution itself (RESEARCH Pitfall 3).
1175
+ const base = (0, git_base_branch_cjs_1.resolveBaseBranch)(root);
1176
+ // Step 4 — diff: the branch diff a pull request would actually merge,
1177
+ // never the working tree (D-01). `core.quotepath=off` at the subprocess
1178
+ // seam keeps non-ASCII paths literal — with git's default quoting they
1179
+ // arrive C-quoted (`".planning/caf\303\251.md"`), which both bypasses the
1180
+ // Step 5 `.planning/` exclusion (the path starts with `"`) and makes
1181
+ // legitimate non-ASCII files unmatched by every applies-to glob.
1182
+ const diffResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotepath=off', 'diff', '--name-only', '--diff-filter=d', `${base}...HEAD`], { cwd: root });
1183
+ if (diffResult.exitCode !== 0 || diffResult.error) {
1184
+ output({
1185
+ status: 'warn',
1186
+ block: false,
1187
+ message: 'Practices could not be checked: the change set could not be read.',
1188
+ }, raw, undefined);
1189
+ return;
1190
+ }
1191
+ // Step 5 — filter (D-02): drop empties, normalize separators, drop
1192
+ // .planning/** paths. Deletions are already excluded by --diff-filter=d;
1193
+ // a rename contributes only its new path.
1194
+ let files = diffResult.stdout
1195
+ .split('\n')
1196
+ .map((f) => f.trim())
1197
+ .filter((f) => f.length > 0)
1198
+ .map((f) => f.replace(/\\/g, '/'))
1199
+ .filter((f) => !f.startsWith('.planning/'));
1200
+ let filesCapped = false;
1201
+ if (files.length > SHIP_DIFF_FILE_CAP) {
1202
+ files = files.slice(0, SHIP_DIFF_FILE_CAP);
1203
+ filesCapped = true;
1204
+ }
1205
+ // Step 6 — match, using the frozen Phase 24 matcher as a library.
1206
+ let result;
1207
+ try {
1208
+ result = matchPractices({ cwd: root, files });
1209
+ }
1210
+ catch {
1211
+ output({
1212
+ status: 'warn',
1213
+ block: false,
1214
+ message: 'Practices could not be checked: the practices could not be read.',
1215
+ }, raw, undefined);
1216
+ return;
1217
+ }
1218
+ // Step 7 — degraded results (D-07) / Step 8 — compact summary (D-05/D-06).
1219
+ const bodyLines = [];
1220
+ const totalMatches = result.matched.length + result.global.length;
1221
+ const counts = { critical: 0, warning: 0, info: 0 };
1222
+ if (result.practicesProcessed === 0) {
1223
+ bodyLines.push('Practices checked — none found in the vault.');
1224
+ }
1225
+ else if (totalMatches === 0) {
1226
+ bodyLines.push('Practices checked — none apply to this change set.');
1227
+ }
1228
+ else {
1229
+ for (const m of result.matched)
1230
+ counts[m.severity] += 1;
1231
+ for (const g of result.global)
1232
+ counts[g.severity] += 1;
1233
+ bodyLines.push(`Practices checked — ${totalMatches} apply to this change set (${counts.critical} critical, ${counts.warning} warning, ${counts.info} info) across ${files.length} changed files.`);
1234
+ const entries = [
1235
+ ...result.matched.map((m) => ({
1236
+ id: m.id, severity: m.severity, scope: 'scoped', tier: m.tier, path: m.path, matchedCount: m.matchedCount,
1237
+ })),
1238
+ ...result.global.map((g) => ({
1239
+ id: g.id, severity: g.severity, scope: 'global', tier: g.tier, path: g.path,
1240
+ })),
1241
+ ];
1242
+ entries.sort((a, b) => {
1243
+ const sevDiff = VALID_SEVERITIES.indexOf(a.severity) - VALID_SEVERITIES.indexOf(b.severity);
1244
+ if (sevDiff !== 0)
1245
+ return sevDiff;
1246
+ if (a.scope !== b.scope)
1247
+ return a.scope === 'scoped' ? -1 : 1;
1248
+ return a.id.localeCompare(b.id);
1249
+ });
1250
+ // Only `id` and `path` are vault-authored tokens (T-28-02) — bound them
1251
+ // through boundedToken before interpolation. `severity` is a closed enum
1252
+ // and `matchedCount` is a number, so neither needs bounding. Never read
1253
+ // `ask`/`excerpt`/`winningGlobs`/`matchedFiles` here (D-06) and never
1254
+ // call `renderPracticesReport` — it prints the practice prose.
1255
+ const practiceLines = entries.map((e) => {
1256
+ const idTok = boundedToken(e.id);
1257
+ const pathTok = boundedToken(e.path);
1258
+ const phrase = shipTierPhrase(e.tier);
1259
+ return e.scope === 'scoped'
1260
+ ? `- ${idTok} — ${e.severity} — applies to ${e.matchedCount} changed file(s) — from ${phrase} at ${pathTok}`
1261
+ : `- ${idTok} — ${e.severity} — always applies — from ${phrase} at ${pathTok}`;
1262
+ });
1263
+ // Step 9 — cap (D-05): never truncate silently.
1264
+ if (practiceLines.length > PRACTICE_LINE_CAP) {
1265
+ const omitted = practiceLines.length - PRACTICE_LINE_CAP;
1266
+ bodyLines.push(...practiceLines.slice(0, PRACTICE_LINE_CAP));
1267
+ bodyLines.push(`+${omitted} more not shown — ${totalMatches} practices apply in total.`);
1268
+ }
1269
+ else {
1270
+ bodyLines.push(...practiceLines);
1271
+ }
1272
+ }
1273
+ // Step 10 — truncation note: only when Step 5 capped the file list.
1274
+ if (filesCapped) {
1275
+ bodyLines.push(`Only the first ${SHIP_DIFF_FILE_CAP} changed files were checked.`);
1276
+ }
1277
+ // Review pointer (D-08/D-09) — always the final line for every branch that
1278
+ // reaches this point (the inert branch and the two "could not be checked"
1279
+ // branches above already returned and appended nothing).
1280
+ bodyLines.push(buildShipReviewPointerLine(root));
1281
+ // Step 11 — output once. status is 'pass' for every branch that ran the
1282
+ // check (including the two zero-result branches); block is always false.
1283
+ output({
1284
+ status: 'pass',
1285
+ block: false,
1286
+ message: bodyLines.join('\n'),
1287
+ counts: { total: totalMatches, critical: counts.critical, warning: counts.warning, info: counts.info },
1288
+ filesConsidered: files.length,
1289
+ }, raw, undefined);
1290
+ }
1291
+ const _defaultCheckCore = { output };
1292
+ // The four flags `check practices-review` recognizes. Any other `--`-prefixed
1293
+ // token is rejected BY NAME (mirrors vault-command-router.cts's
1294
+ // RECOGNIZED_MATCH_FLAGS guard) — but unlike that guard, rejection here still
1295
+ // exits 0: this command is advisory, and a typo in a workflow step must never
1296
+ // break a review run.
1297
+ const RECOGNIZED_PRACTICES_REVIEW_FLAGS = new Set(['--review', '--corpus-json', '--boundaries', '--quotes']);
1298
+ /**
1299
+ * cmdCheckPracticesReview — `check practices-review` handler (plan 27-29,
1300
+ * VERIF-01/COMM-01). Reads the REVIEW.md at `--review`, dispatches to the
1301
+ * compiled `src/practices-review-check.cts` module's `runReviewCheck`, and
1302
+ * prints its plain-English lines verbatim. Placement rationale: the review
1303
+ * check is advisory and must behave identically whether the vault capability
1304
+ * is active or not, so it lives in the always-available core `check` family
1305
+ * rather than behind the `vault.enabled`-gated `vault` family, even though the
1306
+ * data it inspects originates from the vault.
1307
+ *
1308
+ * Exit status is 0 on EVERY path — missing `--review`, unreadable file,
1309
+ * absent practices section, malformed corpus JSON, the parse cap tripped, an
1310
+ * unrecognized flag, or (round 7, WR-03) a bare positional token, which now
1311
+ * reports the argument that was not understood rather than being silently
1312
+ * discarded — because a review path named without its flag would otherwise
1313
+ * produce the same zero-byte output as a clean review. A boundary or quote
1314
+ * shortfall is a defect in the REVIEW.md, not in the reviewed code; failing
1315
+ * the review would punish the wrong artifact.
1316
+ *
1317
+ * Corpus precedence: `--corpus-json` when present, otherwise
1318
+ * `process.env.VAULT_QUOTE_CORPUS` (what the workflow step sets), otherwise
1319
+ * empty (no comparison, no output from that half).
1320
+ *
1321
+ * Test seams: `_check` injects a mock of the compiled module (default: the
1322
+ * real compiled practices-review checker, lazy-required INSIDE this function
1323
+ * so it only loads when the command is actually dispatched); `_core` injects
1324
+ * a mock output sink. Production callers pass neither.
1325
+ */
1326
+ function cmdCheckPracticesReview({ args }, _check, _core) {
1327
+ const c = _core ?? _defaultCheckCore;
1328
+ let reviewPath;
1329
+ let corpusJsonFlag;
1330
+ let boundariesFlag = false;
1331
+ let quotesFlag = false;
1332
+ const rest = args.slice(2);
1333
+ for (let i = 0; i < rest.length; i++) {
1334
+ const tok = rest[i];
1335
+ if (tok === '--review' || tok === '--corpus-json') {
1336
+ // WR-03: a value-taking flag whose value is missing (trailing token),
1337
+ // empty (T-27-R8-05), or flag-shaped (the next token starts with "--")
1338
+ // must never consume that token as its value — route through the same
1339
+ // usage-message path the unrecognized-flag guard below already uses,
1340
+ // and return before any file read is attempted. The empty-string case
1341
+ // belongs HERE rather than at the read guard below: `--review ''`
1342
+ // currently passes this guard, sets `reviewPath` to the empty string,
1343
+ // then fails the truthiness test at the read guard, which is the
1344
+ // deliberate feature-off path — so the invocation produces zero bytes
1345
+ // at exit 0, byte-identical to a clean review. The one call site that
1346
+ // matters reaches this shape whenever the workflow's `REVIEW_PATH`
1347
+ // variable expands empty.
1348
+ const candidate = rest[i + 1];
1349
+ if (candidate === undefined || candidate === '' || candidate.startsWith('--')) {
1350
+ const msg = `Flag "${tok}" requires a value. Supported: --review <path>, --corpus-json <string>, --boundaries, --quotes.`;
1351
+ c.output(msg, true, msg);
1352
+ return;
1353
+ }
1354
+ if (tok === '--review')
1355
+ reviewPath = candidate;
1356
+ else
1357
+ corpusJsonFlag = candidate;
1358
+ i++;
1359
+ }
1360
+ else if (tok === '--boundaries') {
1361
+ boundariesFlag = true;
1362
+ }
1363
+ else if (tok === '--quotes') {
1364
+ quotesFlag = true;
1365
+ }
1366
+ else if (tok.startsWith('--') && !RECOGNIZED_PRACTICES_REVIEW_FLAGS.has(tok)) {
1367
+ const msg = `Unrecognized flag "${tok}" for check practices-review. Supported: --review <path>, --corpus-json <string>, --boundaries, --quotes.`;
1368
+ c.output(msg, true, msg);
1369
+ return;
1370
+ }
1371
+ else {
1372
+ // WR-03 (round 7): a bare positional token — a review path named
1373
+ // without its flag — must never be silently discarded. Discarding it
1374
+ // exits 0 with zero bytes, byte-identical to a clean review, which is
1375
+ // exactly the false-clean signal this function's whole design exists
1376
+ // to avoid. Route through the same usage-message path the unrecognized-
1377
+ // flag guard above already uses, and return before any file read.
1378
+ const msg = `Unexpected argument "${tok}" for check practices-review. Did you mean --review ${tok}? Supported: --review <path>, --corpus-json <string>, --boundaries, --quotes.`;
1379
+ c.output(msg, true, msg);
1380
+ return;
1381
+ }
1382
+ }
1383
+ // WR-02: track a failed read separately from "no --review given at all" so
1384
+ // the operator-visible line below fires ONLY when a path was actually
1385
+ // supplied and could not be read — never for the feature-off case (no
1386
+ // --review flag), which must keep producing zero bytes (T-27-98).
1387
+ let reviewText = '';
1388
+ let reviewReadFailed = false;
1389
+ if (reviewPath) {
1390
+ try {
1391
+ reviewText = node_fs_1.default.readFileSync(reviewPath, 'utf8');
1392
+ }
1393
+ catch {
1394
+ reviewText = '';
1395
+ reviewReadFailed = true;
1396
+ }
1397
+ }
1398
+ const resolvedCorpusJson = corpusJsonFlag ?? process.env['VAULT_QUOTE_CORPUS'] ?? '';
1399
+ const runBoth = !boundariesFlag && !quotesFlag;
1400
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
1401
+ const mod = _check ?? require('./practices-review-check.cjs');
1402
+ const result = mod.runReviewCheck({
1403
+ reviewText,
1404
+ corpusJson: resolvedCorpusJson,
1405
+ boundaries: runBoth || boundariesFlag,
1406
+ quotes: runBoth || quotesFlag,
1407
+ });
1408
+ const renderedLines = [...result.lines];
1409
+ if (reviewReadFailed && reviewPath) {
1410
+ // Plain English, no exception text / errno / internal identifier (COMM-01).
1411
+ // Emitted on stdout (via c.output's raw path) because the workflow step
1412
+ // that dispatches this arm redirects stderr with `2>/dev/null || true`.
1413
+ renderedLines.unshift(`The review file at ${reviewPath} could not be read — the boundary and client-quote checks did not run.`);
1414
+ }
1415
+ const text = renderedLines.length > 0 ? renderedLines.join('\n') + '\n' : '';
1416
+ c.output(text, true, text);
1417
+ }
1418
+ function routeCheckCommand({ args, cwd, raw, _check, _core }) {
971
1419
  // Normalize dots to hyphens in the subcommand so both forms are accepted.
972
1420
  // This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
973
1421
  // directly runnable as `pp_run check ui.plan-gate` — the dot is normalized to
@@ -1011,6 +1459,12 @@ function routeCheckCommand({ args, cwd, raw }) {
1011
1459
  cmdShipPlatformDrift(cwd, args, raw);
1012
1460
  return;
1013
1461
  }
1462
+ if (subcommand === 'ship-practices') {
1463
+ // ship.practices — deterministic branch-diff practices re-check (INJ-03, Phase 28).
1464
+ // Dot-to-hyphen normalization means query "ship.practices" routes here.
1465
+ cmdShipPractices(cwd, args, raw);
1466
+ return;
1467
+ }
1014
1468
  if (subcommand === 'vault-status') {
1015
1469
  // vault.status — advisory vault cache state query (Phase 13, D-01/D-02).
1016
1470
  // Dot-to-hyphen normalization means query "vault.status" routes here.
@@ -1033,6 +1487,20 @@ function routeCheckCommand({ args, cwd, raw }) {
1033
1487
  cmdVerifyCodebaseDrift(cwd, raw);
1034
1488
  return;
1035
1489
  }
1490
+ if (subcommand === 'practices-review') {
1491
+ // IN-02: this arm deliberately diverges from every sibling `check`
1492
+ // subcommand in two ways. (1) It always emits raw text rather than
1493
+ // honouring the `raw` flag's JSON envelope — the workflow steps and their
1494
+ // tests depend on byte-identical operator lines, not a JSON wrapper.
1495
+ // (2) `--review` paths resolve against the invoking process's working
1496
+ // directory (inside cmdCheckPracticesReview), not the router-resolved
1497
+ // `cwd` parameter — which is what the workflow's `"${REVIEW_PATH}"`
1498
+ // expansion already supplies. Both shapes are pinned by the CLI-seam
1499
+ // tests in tests/practices-review-check.test.cjs, so changing either is a
1500
+ // deliberate contract change, not a refactor.
1501
+ cmdCheckPracticesReview({ args }, _check, _core);
1502
+ return;
1503
+ }
1036
1504
  if (subcommand === 'prohibition-enforcement') {
1037
1505
  // The deterministic test-tier prohibition PRODUCER/gate (#1259, ADR-550 D5d). Locates the
1038
1506
  // wired mechanical check (node-test or lint-rule), confirms fail-first, runs it, builds
@@ -1041,7 +1509,7 @@ function routeCheckCommand({ args, cwd, raw }) {
1041
1509
  (0, prohibition_enforcement_cjs_1.routeProhibitionEnforcement)(args, raw);
1042
1510
  return;
1043
1511
  }
1044
- error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, prohibition-enforcement, ship-platform-drift, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, vault-status, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1512
+ error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, practices-review, prohibition-enforcement, ship-platform-drift, ship-practices, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, vault-status, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
1045
1513
  }
1046
1514
  module.exports = {
1047
1515
  routeCheckCommand,
@@ -1052,5 +1520,7 @@ module.exports = {
1052
1520
  cmdGapAnalysisPlanPost,
1053
1521
  cmdTddReviewCheckpoint,
1054
1522
  cmdShipPlatformDrift,
1523
+ cmdShipPractices,
1055
1524
  cmdVaultStatus,
1525
+ cmdCheckPracticesReview,
1056
1526
  };
@@ -121,6 +121,9 @@ exports.CLUSTERS = Object.freeze({
121
121
  'mempalace-recall',
122
122
  'mempalace-capture',
123
123
  'surface',
124
+ // Operator-driven outward action, grouped with the other push-shaped
125
+ // entrypoints (ship, pr-branch) rather than with the read-only vault loop.
126
+ 'vault-promote',
124
127
  ]),
125
128
  });
126
129
  /**
@@ -143,21 +143,6 @@ function validateShipPrBodySections(value) {
143
143
  function buildNewProjectConfig(userChoices) {
144
144
  const choices = userChoices || {};
145
145
  const homedir = node_os_1.default.homedir();
146
- // Detect API key availability
147
- const braveKeyFile = node_path_1.default.join(homedir, '.gsd', 'brave_api_key');
148
- const hasBraveSearch = !!(process.env['BRAVE_API_KEY'] || node_fs_1.default.existsSync(braveKeyFile));
149
- const firecrawlKeyFile = node_path_1.default.join(homedir, '.gsd', 'firecrawl_api_key');
150
- const hasFirecrawl = !!(process.env['FIRECRAWL_API_KEY'] || node_fs_1.default.existsSync(firecrawlKeyFile));
151
- const exaKeyFile = node_path_1.default.join(homedir, '.gsd', 'exa_api_key');
152
- const hasExaSearch = !!(process.env['EXA_API_KEY'] || node_fs_1.default.existsSync(exaKeyFile));
153
- const tavilyKeyFile = node_path_1.default.join(homedir, '.gsd', 'tavily_api_key');
154
- const hasTavilySearch = !!(process.env['TAVILY_API_KEY'] || node_fs_1.default.existsSync(tavilyKeyFile));
155
- const refKeyFile = node_path_1.default.join(homedir, '.gsd', 'ref_api_key');
156
- const hasRefSearch = !!(process.env['REF_API_KEY'] || node_fs_1.default.existsSync(refKeyFile));
157
- const perplexityKeyFile = node_path_1.default.join(homedir, '.gsd', 'perplexity_api_key');
158
- const hasPerplexity = !!(process.env['PERPLEXITY_API_KEY'] || node_fs_1.default.existsSync(perplexityKeyFile));
159
- const jinaKeyFile = node_path_1.default.join(homedir, '.gsd', 'jina_api_key');
160
- const hasJina = !!(process.env['JINA_API_KEY'] || node_fs_1.default.existsSync(jinaKeyFile));
161
146
  // Load user-level defaults from ~/.gsd/defaults.json if available
162
147
  const globalDefaultsPath = node_path_1.default.join(homedir, '.gsd', 'defaults.json');
163
148
  let userDefaults = {};
@@ -184,13 +169,13 @@ function buildNewProjectConfig(userChoices) {
184
169
  commit_docs: CONFIG_DEFAULTS.commit_docs,
185
170
  parallelization: CONFIG_DEFAULTS.parallelization,
186
171
  search_gitignored: CONFIG_DEFAULTS.search_gitignored,
187
- brave_search: hasBraveSearch,
188
- firecrawl: hasFirecrawl,
189
- exa_search: hasExaSearch,
190
- tavily_search: hasTavilySearch,
191
- ref_search: hasRefSearch,
192
- perplexity: hasPerplexity,
193
- jina: hasJina,
172
+ brave_search: false,
173
+ firecrawl: false,
174
+ exa_search: false,
175
+ tavily_search: false,
176
+ ref_search: false,
177
+ perplexity: false,
178
+ jina: false,
194
179
  git: {
195
180
  branching_strategy: CONFIG_DEFAULTS.branching_strategy,
196
181
  create_tag: true,
@@ -177,6 +177,13 @@ const ERROR_REASON = Object.freeze({
177
177
  SECURITY_SCAN_FAILED: 'security_scan_failed',
178
178
  // platform-bindings
179
179
  PLATFORM_BINDINGS_PARSE_FAILED: 'platform_bindings_parse_failed',
180
+ // vault write-back promote arm (Phase 29 WRB-01/03)
181
+ VAULT_PROMOTE_DISABLED: 'vault_promote_disabled',
182
+ VAULT_PROMOTE_INVALID_PRACTICE: 'vault_promote_invalid_practice',
183
+ VAULT_PROMOTE_SECRET_DETECTED: 'vault_promote_secret_detected',
184
+ VAULT_PROMOTE_TARGET_REFUSED: 'vault_promote_target_refused',
185
+ VAULT_PROMOTE_CONCURRENT_EDIT: 'vault_promote_concurrent_edit',
186
+ VAULT_PROMOTE_TRANSPORT_FAILED: 'vault_promote_transport_failed',
180
187
  // generic
181
188
  USAGE: 'usage',
182
189
  UNKNOWN: 'unknown',
@@ -461,6 +461,12 @@ function cmdLoopRenderHooks(cwd, point, raw, options = {}) {
461
461
  if (state.warnings && state.warnings.length > 0) {
462
462
  envelope.warnings = state.warnings;
463
463
  }
464
+ // No rawValue third argument, so --raw emits the JSON envelope rather than
465
+ // plain text. This is deliberate and load-bearing: 23 workflow call sites do
466
+ // `HOOKS_JSON=$(pp_run loop render-hooks <point> --raw)` and read `activeHooks`
467
+ // off the parsed envelope. Callers who want the human-readable text read the
468
+ // envelope's `rendered` field. Same convention as the deliberate
469
+ // `output(result, raw, undefined)` sites in src/check-command-router.cts.
464
470
  coreOutput(envelope, raw);
465
471
  }
466
472
  module.exports = {