@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -0
- package/agents/pp-code-fixer.md +11 -0
- package/agents/pp-code-reviewer.md +77 -1
- package/agents/pp-executor.md +34 -1
- package/agents/pp-planner.md +24 -103
- package/bin/install.js +9 -1
- package/commands/pp-ns-manage.md +2 -1
- package/commands/pp-vault-promote.md +31 -0
- package/gemini-extension.json +1 -1
- package/package.json +6 -3
- package/pareto-skills/pp-project-setup/SKILL.md +32 -440
- package/pp-core/bin/lib/capability-registry.cjs +111 -2
- package/pp-core/bin/lib/capability-state.cjs +54 -10
- package/pp-core/bin/lib/check-command-router.cjs +473 -3
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/config.cjs +7 -22
- package/pp-core/bin/lib/io.cjs +7 -0
- package/pp-core/bin/lib/loop-resolver.cjs +6 -0
- package/pp-core/bin/lib/practices-review-check.cjs +938 -0
- package/pp-core/bin/lib/profile-output.cjs +4 -2
- package/pp-core/bin/lib/vault-command-router.cjs +108 -2
- package/pp-core/bin/lib/vault-practices.cjs +982 -0
- package/pp-core/bin/lib/vault-write-back.cjs +756 -0
- package/pp-core/bin/lib/vault.cjs +45 -3
- package/pp-core/bin/pp-tools.cjs +26 -0
- package/pp-core/bin/shared/config-schema.manifest.json +4 -0
- package/pp-core/references/planner-checkpoints.md +72 -0
- package/pp-core/references/planner-tdd.md +55 -0
- package/pp-core/references/planner-vault-practices.md +67 -0
- package/pp-core/templates/practices/README.md +122 -0
- package/pp-core/templates/practices/error-handling.md +18 -0
- package/pp-core/templates/practices/minimal-comments.md +14 -0
- package/pp-core/templates/practices/naming-convention.md +18 -0
- package/pp-core/templates/practices/practice-template.md +23 -0
- package/pp-core/workflows/code-review.md +74 -18
- package/pp-core/workflows/discovery-sprint.md +409 -16
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +250 -0
- package/pp-core/workflows/discovery-to-build.md +218 -48
- package/pp-core/workflows/discuss-phase.md +1 -1
- package/pp-core/workflows/execute-phase.md +1 -1
- package/pp-core/workflows/plan-phase.md +1 -1
- package/pp-core/workflows/ship.md +1 -0
- package/pp-core/workflows/vault-write-back.md +213 -0
- package/scripts/changeset/lint.cjs +20 -5
- package/scripts/gen-capability-registry.cjs +68 -8
- package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
- package/scripts/lint-descriptions.cjs +3 -1
- package/scripts/lint-docs-required.cjs +10 -3
- package/scripts/lint-legacy-dir-name.cjs +5 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- package/scripts/release-channel.cjs +58 -0
- package/scripts/release-tarball-smoke.cjs +4 -0
- package/scripts/verify-opengsd-sweep.cjs +12 -0
- 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
|
-
|
|
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:
|
|
188
|
-
firecrawl:
|
|
189
|
-
exa_search:
|
|
190
|
-
tavily_search:
|
|
191
|
-
ref_search:
|
|
192
|
-
perplexity:
|
|
193
|
-
jina:
|
|
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,
|
package/pp-core/bin/lib/io.cjs
CHANGED
|
@@ -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 = {
|