@pareto4/pp-core 1.12.1 → 1.13.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/agents/pp-code-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +107 -45
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +277 -12
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +13 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -0
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +22 -5
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +7 -4
- package/pp-core/workflows/quick.md +4 -0
- package/pp-core/workflows/ship.md +5 -1
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +4 -0
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +3 -0
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- package/scripts/prompt-injection-scan.sh +2 -0
|
@@ -44,7 +44,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
44
44
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
45
45
|
};
|
|
46
46
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
47
|
-
exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
|
|
47
|
+
exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_ORG_PROFILE_DECLINED_KEY = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
|
|
48
48
|
exports.buildInitQuestions = buildInitQuestions;
|
|
49
49
|
exports.verifyInitInvariants = verifyInitInvariants;
|
|
50
50
|
exports.initSessionNext = initSessionNext;
|
|
@@ -74,6 +74,21 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
74
74
|
* a bare unregistered top-level namespace would trip.
|
|
75
75
|
*/
|
|
76
76
|
exports.INIT_COMPOSE_DECLINED_KEY = 'init.compose_declined';
|
|
77
|
+
/**
|
|
78
|
+
* The dotted `.planning/config.json` key `pp-tools init` sets when the
|
|
79
|
+
* operator is offered a detected org profile and SKIPS it — a blank answer or
|
|
80
|
+
* `none` in any letter case. It is the only signal that tells "offered a
|
|
81
|
+
* detected profile and skipped it" from "the question never ran": both leave
|
|
82
|
+
* `org.profile` unset, and without it `verifyInitInvariants` reported every
|
|
83
|
+
* deliberate skip as `INIT_QUESTIONS_ORG_PROFILE_UNASKED`. Written by
|
|
84
|
+
* `initSessionAnswer` only when a profile was detectable (the predicate the
|
|
85
|
+
* violation reads), read back with strict boolean identity, and written back
|
|
86
|
+
* to `false` when a later run selects a profile. Registered under the
|
|
87
|
+
* `vault` capability so `config-set` accepts it. Kept OFF
|
|
88
|
+
* `ORG_PROFILE_ALLOWLIST`: a profile package must never answer the question
|
|
89
|
+
* about itself.
|
|
90
|
+
*/
|
|
91
|
+
exports.INIT_ORG_PROFILE_DECLINED_KEY = 'init.org_profile_declined';
|
|
77
92
|
/** The identity-only choices appended after the provider seam's own
|
|
78
93
|
* `PROVIDERS` list — never retyped ahead of `PROVIDERS` itself (T-34-50). */
|
|
79
94
|
const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['github', 'other', 'none']);
|
|
@@ -167,10 +182,12 @@ function getDotted(obj, dottedPath) {
|
|
|
167
182
|
* `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
|
|
168
183
|
* is a complete, valid response), never whether the question is asked.
|
|
169
184
|
*
|
|
170
|
-
* Question 6 (`org_profile`)
|
|
171
|
-
* `resolveDetectedOrgProfile`
|
|
172
|
-
*
|
|
173
|
-
*
|
|
185
|
+
* Question 6 (`org_profile`) names a HINT, never a default: when
|
|
186
|
+
* `resolveDetectedOrgProfile` finds exactly one org-profile-shaped package
|
|
187
|
+
* installed (INIT-PATCH-C), the prompt names it, Enter skips, and typing its
|
|
188
|
+
* name (or any other) selects it — the question carries no `preselected`.
|
|
189
|
+
* The prompt text is built from that detected value rather than kept as two
|
|
190
|
+
* hard-coded strings that can drift.
|
|
174
191
|
* `opts._globalRoot` is forwarded to it unchanged, the same test-seam
|
|
175
192
|
* convention `verifyInitInvariants`'s own `_globalRoot` already follows —
|
|
176
193
|
* production callers omit it.
|
|
@@ -201,14 +218,16 @@ function buildInitQuestions(cwd, opts = {}) {
|
|
|
201
218
|
// stays maximally strict, no allowlist entry was added for it. The detected
|
|
202
219
|
// value itself is never a literal in this file — it is a runtime string
|
|
203
220
|
// read from an installed package, interpolated into the prompt below.
|
|
221
|
+
// A detected profile is a HINT, never a default: no `preselected`, so a
|
|
222
|
+
// profile installed on the operator's machine (perhaps another client's)
|
|
223
|
+
// never becomes a project's binding by pressing Enter. Enter skips (and
|
|
224
|
+
// `initSessionAnswer` records the skip); typing the name selects it.
|
|
204
225
|
const orgProfileQuestion = isUsableString(detectedOrgProfile)
|
|
205
226
|
? {
|
|
206
227
|
id: 'org_profile',
|
|
207
|
-
prompt: `Org profile package (optional —
|
|
208
|
-
'
|
|
228
|
+
prompt: `Org profile package (optional — "${detectedOrgProfile}" is installed: type its name to use it, ` +
|
|
229
|
+
'or press Enter to skip)',
|
|
209
230
|
optionalAnswer: true,
|
|
210
|
-
preselected: detectedOrgProfile,
|
|
211
|
-
preselectedLabel: `${detectedOrgProfile} (detected installed profile)`,
|
|
212
231
|
}
|
|
213
232
|
: {
|
|
214
233
|
id: 'org_profile',
|
|
@@ -242,36 +261,6 @@ function buildInitQuestions(cwd, opts = {}) {
|
|
|
242
261
|
];
|
|
243
262
|
}
|
|
244
263
|
// ─── verifyInitInvariants ───────────────────────────────────────────────────
|
|
245
|
-
/**
|
|
246
|
-
* Byte-for-byte the same heuristic `src/org-profile.cts`'s own (private,
|
|
247
|
-
* unexported) `resolveGlobalPackageRoot` uses. Duplicated here because it
|
|
248
|
-
* is not part of that module's public surface — the same per-module
|
|
249
|
-
* duplication convention every sibling seam module in this repo follows
|
|
250
|
-
* for helpers it cannot import without either widening an unrelated
|
|
251
|
-
* module's exports or risking a require cycle.
|
|
252
|
-
*/
|
|
253
|
-
function resolveGlobalNodeModulesRoot() {
|
|
254
|
-
try {
|
|
255
|
-
const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
|
|
256
|
-
let candidate;
|
|
257
|
-
if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
|
|
258
|
-
const prefix = prefixEnv.trim();
|
|
259
|
-
candidate =
|
|
260
|
-
process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
|
|
261
|
-
}
|
|
262
|
-
else {
|
|
263
|
-
const execDir = node_path_1.default.dirname(process.execPath);
|
|
264
|
-
candidate =
|
|
265
|
-
process.platform === 'win32'
|
|
266
|
-
? node_path_1.default.join(execDir, 'node_modules')
|
|
267
|
-
: node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
|
|
268
|
-
}
|
|
269
|
-
return node_fs_1.default.existsSync(candidate) ? candidate : null;
|
|
270
|
-
}
|
|
271
|
-
catch {
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
264
|
/** Conservative npm-package-name shape check (bare `name` or scoped
|
|
276
265
|
* `@scope/name`; lowercase, digits, `.`/`_`/`-`, never leading `.`/`_` in
|
|
277
266
|
* either segment). A directory name that does not match is never offered as
|
|
@@ -367,15 +356,27 @@ function collectOrgProfileShapedPackages(nodeModulesDir, opts = {}) {
|
|
|
367
356
|
function containsOrgProfileShapedPackage(nodeModulesDir) {
|
|
368
357
|
return collectOrgProfileShapedPackages(nodeModulesDir, { limit: 1 }).names.length > 0;
|
|
369
358
|
}
|
|
359
|
+
/**
|
|
360
|
+
* The global roots DETECTION scans — the org-profile hint and the UNASKED
|
|
361
|
+
* check — from the one candidate builder in `src/org-profile.cts`, never a
|
|
362
|
+
* private copy (the single-root duplicate that lived here missed every
|
|
363
|
+
* Homebrew layout). `explicitPrefixOnly`: an explicit npm prefix is
|
|
364
|
+
* authoritative, so a hint or a violation never comes from a tree the
|
|
365
|
+
* operator did not point at. The `_globalRoot` seam still wins.
|
|
366
|
+
*/
|
|
367
|
+
function detectionGlobalRoots(opts) {
|
|
368
|
+
return (0, org_profile_cjs_1.globalRootCandidatesFor)({ _globalRoot: opts._globalRoot, explicitPrefixOnly: true });
|
|
369
|
+
}
|
|
370
370
|
function orgProfilePackageResolvable(cwd, opts) {
|
|
371
371
|
try {
|
|
372
372
|
const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
|
|
373
373
|
if (node_fs_1.default.existsSync(projectNodeModules) && containsOrgProfileShapedPackage(projectNodeModules)) {
|
|
374
374
|
return true;
|
|
375
375
|
}
|
|
376
|
-
const globalRoot
|
|
377
|
-
|
|
378
|
-
|
|
376
|
+
for (const globalRoot of detectionGlobalRoots(opts)) {
|
|
377
|
+
if (containsOrgProfileShapedPackage(globalRoot)) {
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
379
380
|
}
|
|
380
381
|
return false;
|
|
381
382
|
}
|
|
@@ -405,8 +406,7 @@ function resolveDetectedOrgProfile(cwd, opts) {
|
|
|
405
406
|
for (const name of projectResult.names)
|
|
406
407
|
names.add(name);
|
|
407
408
|
}
|
|
408
|
-
const globalRoot
|
|
409
|
-
if (globalRoot) {
|
|
409
|
+
for (const globalRoot of detectionGlobalRoots(opts)) {
|
|
410
410
|
const globalResult = collectOrgProfileShapedPackages(globalRoot);
|
|
411
411
|
if (globalResult.capped)
|
|
412
412
|
anyCapped = true;
|
|
@@ -421,6 +421,14 @@ function resolveDetectedOrgProfile(cwd, opts) {
|
|
|
421
421
|
return null;
|
|
422
422
|
}
|
|
423
423
|
}
|
|
424
|
+
/** The one owner of the org-profile decline rule: a blank answer, or `none`
|
|
425
|
+
* in any letter case with surrounding whitespace, skips the question. Enter
|
|
426
|
+
* in the TTY driver and `--session-answer org_profile=none` / `org_profile=`
|
|
427
|
+
* all end here. */
|
|
428
|
+
function isOrgProfileDecline(value) {
|
|
429
|
+
const trimmed = value.trim();
|
|
430
|
+
return trimmed === '' || trimmed.toLowerCase() === 'none';
|
|
431
|
+
}
|
|
424
432
|
/** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
|
|
425
433
|
* "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
|
|
426
434
|
* on the latter (see `verifyInitInvariants`'s own doc comment for why an
|
|
@@ -504,12 +512,17 @@ function verifyInitInvariants(cwd, opts = {}) {
|
|
|
504
512
|
// installed (project node_modules or the machine's global root) but
|
|
505
513
|
// org.profile was never set, i.e. Question 6 appears to have been
|
|
506
514
|
// skipped rather than answered blank.
|
|
515
|
+
// A recorded skip (init.org_profile_declined, the JSON boolean true and
|
|
516
|
+
// nothing else) is an answer. A set pointer is checked first; the marker is
|
|
517
|
+
// checked before the node_modules scan.
|
|
507
518
|
try {
|
|
508
519
|
const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
|
|
509
|
-
|
|
520
|
+
const orgProfileDeclined = getDotted(parsed, exports.INIT_ORG_PROFILE_DECLINED_KEY) === true;
|
|
521
|
+
if (!isUsableString(orgProfilePointer) && !orgProfileDeclined && orgProfilePackageResolvable(cwd, opts)) {
|
|
510
522
|
violations.push({
|
|
511
523
|
code: INVARIANT_CODE.ORG_PROFILE_UNASKED,
|
|
512
|
-
message: 'An org-profile-shaped package is installed but org.profile is unset — the pointer question (Question 6) appears to have been
|
|
524
|
+
message: 'An org-profile-shaped package is installed but org.profile is unset and no skip was recorded — the pointer question (Question 6) appears not to have been answered. ' +
|
|
525
|
+
'Re-run "pp-tools init --reset" and either type the package name to use it, or press Enter (or type "none") to record a skip.',
|
|
513
526
|
});
|
|
514
527
|
}
|
|
515
528
|
}
|
|
@@ -890,10 +903,13 @@ function initSessionNext(cwd, opts = {}) {
|
|
|
890
903
|
* any answer once the session is already `done`.
|
|
891
904
|
*
|
|
892
905
|
* The `org_profile` answer is written to `.planning/config.json` (`org.profile`)
|
|
893
|
-
* IMMEDIATELY when
|
|
894
|
-
* the last answer" rule in `computeCurrentStep`'s own doc
|
|
895
|
-
* the very next question (`provider`) must see it to
|
|
896
|
-
* `preselected` (the walk-3 regression Task 1's acceptance
|
|
906
|
+
* IMMEDIATELY when it selects a package — the one exception to the "batch
|
|
907
|
+
* write after the last answer" rule in `computeCurrentStep`'s own doc
|
|
908
|
+
* comment — because the very next question (`provider`) must see it to
|
|
909
|
+
* re-resolve its `preselected` (the walk-3 regression Task 1's acceptance
|
|
910
|
+
* names). A decline (`isOrgProfileDecline`: blank, or `none` in any case) is
|
|
911
|
+
* recorded instead as `init.org_profile_declined: true` when a profile was
|
|
912
|
+
* detectable.
|
|
897
913
|
*/
|
|
898
914
|
function initSessionAnswer(cwd, stepId, value, opts = {}) {
|
|
899
915
|
const loaded = loadSession(cwd);
|
|
@@ -937,13 +953,34 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
|
|
|
937
953
|
if (!step.question.optionalAnswer && (typeof value !== 'string' || value.trim() === '')) {
|
|
938
954
|
return { ok: false, reason: 'answer_required', message: `Question "${stepId}" requires a non-blank answer.` };
|
|
939
955
|
}
|
|
940
|
-
|
|
956
|
+
const answerValue = typeof value === 'string' ? value : '';
|
|
941
957
|
if (stepId === 'org_profile') {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
958
|
+
if (isOrgProfileDecline(answerValue)) {
|
|
959
|
+
// Enter and `none` leave the same session record. The marker is
|
|
960
|
+
// written exactly when the UNASKED violation would otherwise fire
|
|
961
|
+
// (the same predicate); with no profile detectable nothing was
|
|
962
|
+
// offered, so nothing is recorded.
|
|
963
|
+
session.answers[stepId] = '';
|
|
964
|
+
if (orgProfilePackageResolvable(cwd, { _globalRoot: opts._globalRoot })) {
|
|
965
|
+
writeConfigMerge(cwd, { init: { org_profile_declined: true } });
|
|
966
|
+
}
|
|
967
|
+
persistSession(cwd, session);
|
|
968
|
+
return { ok: true };
|
|
969
|
+
}
|
|
970
|
+
const trimmed = answerValue.trim();
|
|
971
|
+
session.answers[stepId] = answerValue;
|
|
972
|
+
const orgPatch = { org: { profile: trimmed } };
|
|
973
|
+
// A selection after an earlier recorded skip writes the marker back to
|
|
974
|
+
// false in the same merge, so a stale decline never sits beside a set
|
|
975
|
+
// pointer. No write when the marker is absent.
|
|
976
|
+
if (getDotted(readConfigFile(cwd).parsed, exports.INIT_ORG_PROFILE_DECLINED_KEY) === true) {
|
|
977
|
+
orgPatch.init = { org_profile_declined: false };
|
|
945
978
|
}
|
|
979
|
+
writeConfigMerge(cwd, orgPatch);
|
|
980
|
+
persistSession(cwd, session);
|
|
981
|
+
return { ok: true };
|
|
946
982
|
}
|
|
983
|
+
session.answers[stepId] = answerValue;
|
|
947
984
|
persistSession(cwd, session);
|
|
948
985
|
return { ok: true };
|
|
949
986
|
}
|
|
@@ -1028,7 +1065,16 @@ function initSessionReset(cwd) {
|
|
|
1028
1065
|
* `init-contract --arm-vault`: turn the client vault on for a project whose
|
|
1029
1066
|
* client vault is bound. Writes `vault.enabled: true` ONLY when the key is
|
|
1030
1067
|
* absent — an explicit `false` (or any other explicit value) is the
|
|
1031
|
-
* operator's decision and is kept.
|
|
1068
|
+
* operator's decision and is kept. When the vault ends up enabled it also
|
|
1069
|
+
* writes `vault.required: true` where that key is absent: a project with a
|
|
1070
|
+
* bound client vault should stop when that vault stops loading, instead of
|
|
1071
|
+
* building without it after a confirmation. An explicit `vault.required`
|
|
1072
|
+
* (including `false`) is kept, and so is an org profile's value: when the
|
|
1073
|
+
* profile sets `vault.required` nothing is written, because the gate already
|
|
1074
|
+
* reads it (project > profile > default). It also writes
|
|
1075
|
+
* `vault.practices_enabled: true` where that key is absent, so the project
|
|
1076
|
+
* records which client practices each phase applied from its first phase on;
|
|
1077
|
+
* an explicit value is kept. All keys go in one write. With no client vault bound (no or
|
|
1032
1078
|
* malformed `.platform-bindings.json`, or `client.vaultRepo` /
|
|
1033
1079
|
* `client.vaultRef` missing — the same "bound" the gate's verdict uses) it
|
|
1034
1080
|
* writes nothing and returns `ok: false` naming /pp-init. A present but
|
|
@@ -1063,6 +1109,8 @@ function armVault(cwd) {
|
|
|
1063
1109
|
ok: false,
|
|
1064
1110
|
armed: false,
|
|
1065
1111
|
enabled: undefined,
|
|
1112
|
+
required: undefined,
|
|
1113
|
+
practicesEnabled: undefined,
|
|
1066
1114
|
client,
|
|
1067
1115
|
message: '.planning/config.json is not valid JSON — fix it (or re-run /pp-init), then arm the vault again. Nothing was written.',
|
|
1068
1116
|
};
|
|
@@ -1071,33 +1119,99 @@ function armVault(cwd) {
|
|
|
1071
1119
|
const nested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'enabled');
|
|
1072
1120
|
const flat = Object.prototype.hasOwnProperty.call(cfg, 'vault.enabled');
|
|
1073
1121
|
const current = nested && vaultSection ? vaultSection.enabled : flat ? cfg['vault.enabled'] : undefined;
|
|
1122
|
+
const requiredNested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'required');
|
|
1123
|
+
const requiredFlat = Object.prototype.hasOwnProperty.call(cfg, 'vault.required');
|
|
1124
|
+
const currentRequired = requiredNested && vaultSection ? vaultSection.required : requiredFlat ? cfg['vault.required'] : undefined;
|
|
1125
|
+
const practicesNested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'practices_enabled');
|
|
1126
|
+
const practicesFlat = Object.prototype.hasOwnProperty.call(cfg, 'vault.practices_enabled');
|
|
1127
|
+
const currentPractices = practicesNested && vaultSection
|
|
1128
|
+
? vaultSection.practices_enabled
|
|
1129
|
+
: practicesFlat
|
|
1130
|
+
? cfg['vault.practices_enabled']
|
|
1131
|
+
: undefined;
|
|
1132
|
+
const practicesAbsent = !(practicesNested || practicesFlat);
|
|
1074
1133
|
if (!binding.bound) {
|
|
1075
1134
|
return {
|
|
1076
1135
|
ok: false,
|
|
1077
1136
|
armed: false,
|
|
1078
1137
|
enabled: current,
|
|
1138
|
+
required: currentRequired,
|
|
1139
|
+
practicesEnabled: currentPractices,
|
|
1079
1140
|
client,
|
|
1080
1141
|
message: 'No client vault is bound — .platform-bindings.json needs client.vaultRepo and client.vaultRef. Run /pp-init to bind it; vault.enabled was not changed.',
|
|
1081
1142
|
};
|
|
1082
1143
|
}
|
|
1083
|
-
|
|
1144
|
+
const enabledAbsent = !(nested || flat);
|
|
1145
|
+
const enabledAfter = enabledAbsent ? true : current;
|
|
1146
|
+
const requiredAbsent = !(requiredNested || requiredFlat);
|
|
1147
|
+
// An org profile that sets vault.required already decides it for this project (the gate
|
|
1148
|
+
// reads project > profile > default); arming must not override it with a project value.
|
|
1149
|
+
let profileRequired = null;
|
|
1150
|
+
if (requiredAbsent) {
|
|
1151
|
+
try {
|
|
1152
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1153
|
+
const orgProfile = require('./org-profile.cjs');
|
|
1154
|
+
const r = orgProfile.resolveVaultRequired(cwd);
|
|
1155
|
+
if (r.source === 'profile')
|
|
1156
|
+
profileRequired = r.required;
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
/* no profile: arm as before */
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
const patch = {};
|
|
1163
|
+
if (enabledAbsent)
|
|
1164
|
+
patch.enabled = true;
|
|
1165
|
+
if (enabledAfter === true && requiredAbsent && profileRequired === null)
|
|
1166
|
+
patch.required = true;
|
|
1167
|
+
// A bound client vault also records which of its practices each phase applied,
|
|
1168
|
+
// from the first phase on. An explicit value is the operator's choice and is kept.
|
|
1169
|
+
if (enabledAfter === true && practicesAbsent)
|
|
1170
|
+
patch.practices_enabled = true;
|
|
1171
|
+
const clauses = [
|
|
1172
|
+
enabledAbsent
|
|
1173
|
+
? 'vault.enabled set to true (it was absent)'
|
|
1174
|
+
: current === true
|
|
1175
|
+
? 'vault.enabled is already true'
|
|
1176
|
+
: `vault.enabled is explicitly set to ${JSON.stringify(current)} — kept as the operator's choice`,
|
|
1177
|
+
];
|
|
1178
|
+
if (enabledAfter === true) {
|
|
1179
|
+
clauses.push(!requiredAbsent
|
|
1180
|
+
? `vault.required is set to ${JSON.stringify(currentRequired)} — kept as the operator's choice`
|
|
1181
|
+
: profileRequired !== null
|
|
1182
|
+
? `vault.required comes from the org profile (${profileRequired}) — not written to the project`
|
|
1183
|
+
: 'vault.required set to true (it was absent): planning and the build now stop when the client vault does not load');
|
|
1184
|
+
clauses.push(practicesAbsent
|
|
1185
|
+
? "vault.practices_enabled set to true (it was absent): each plan now carries the client vault's practices and a record of which applied"
|
|
1186
|
+
: `vault.practices_enabled is set to ${JSON.stringify(currentPractices)} — kept as the operator's choice`);
|
|
1187
|
+
}
|
|
1188
|
+
const requiredAfter = Object.prototype.hasOwnProperty.call(patch, 'required')
|
|
1189
|
+
? true
|
|
1190
|
+
: profileRequired !== null
|
|
1191
|
+
? profileRequired
|
|
1192
|
+
: currentRequired;
|
|
1193
|
+
const practicesAfter = Object.prototype.hasOwnProperty.call(patch, 'practices_enabled')
|
|
1194
|
+
? true
|
|
1195
|
+
: currentPractices;
|
|
1196
|
+
if (Object.keys(patch).length === 0) {
|
|
1084
1197
|
return {
|
|
1085
|
-
ok: true,
|
|
1086
|
-
|
|
1087
|
-
enabled: current,
|
|
1088
|
-
client,
|
|
1089
|
-
message: current === true
|
|
1090
|
-
? 'vault.enabled is already true — nothing to do.'
|
|
1091
|
-
: `vault.enabled is explicitly set to ${JSON.stringify(current)} — kept as the operator's choice.`,
|
|
1198
|
+
ok: true, armed: false, enabled: current, required: requiredAfter, practicesEnabled: practicesAfter, client,
|
|
1199
|
+
message: `${clauses.join('; ')} — nothing to do.`,
|
|
1092
1200
|
};
|
|
1093
1201
|
}
|
|
1094
1202
|
try {
|
|
1095
|
-
writeConfigMerge(cwd, { vault:
|
|
1203
|
+
writeConfigMerge(cwd, { vault: patch });
|
|
1096
1204
|
}
|
|
1097
1205
|
catch (err) {
|
|
1098
|
-
return {
|
|
1206
|
+
return {
|
|
1207
|
+
ok: false, armed: false, enabled: current, required: currentRequired, practicesEnabled: currentPractices, client,
|
|
1208
|
+
message: writeFailedMessage(err),
|
|
1209
|
+
};
|
|
1099
1210
|
}
|
|
1100
|
-
return {
|
|
1211
|
+
return {
|
|
1212
|
+
ok: true, armed: true, enabled: enabledAfter, required: requiredAfter, practicesEnabled: practicesAfter, client,
|
|
1213
|
+
message: `${clauses.join('; ')}.`,
|
|
1214
|
+
};
|
|
1101
1215
|
}
|
|
1102
1216
|
// ─── cmdInitContract ────────────────────────────────────────────────────────
|
|
1103
1217
|
/**
|
|
@@ -168,26 +168,10 @@ async function runInitInteractive(cwd, prompter, opts = {}) {
|
|
|
168
168
|
preselectedLabel: state.preselectedLabel,
|
|
169
169
|
optionalAnswer: state.optionalAnswer,
|
|
170
170
|
});
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
// NOT `optionalAnswer`, so the `state.optionalAnswer === true` guard
|
|
176
|
-
// below leaves it untouched: a blank answer there still falls through
|
|
177
|
-
// to `initSessionAnswer` verbatim, which still rejects it and re-asks
|
|
178
|
-
// exactly as today.
|
|
179
|
-
const hasUsableDefault = state.optionalAnswer === true && typeof state.preselected === 'string' && state.preselected.trim() !== '';
|
|
180
|
-
let submittedAnswer = answer;
|
|
181
|
-
if (hasUsableDefault) {
|
|
182
|
-
const trimmedLower = answer.trim().toLowerCase();
|
|
183
|
-
if (answer.trim() === '') {
|
|
184
|
-
submittedAnswer = state.preselected;
|
|
185
|
-
}
|
|
186
|
-
else if (trimmedLower === 'none') {
|
|
187
|
-
submittedAnswer = '';
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const result = (0, init_contract_cjs_1.initSessionAnswer)(cwd, stepId, submittedAnswer, sessionOpts);
|
|
171
|
+
// A detected org profile is a hint, never a default: the driver has no
|
|
172
|
+
// default-acceptance path, and the answer goes to initSessionAnswer
|
|
173
|
+
// verbatim, which owns the none/blank decline.
|
|
174
|
+
const result = (0, init_contract_cjs_1.initSessionAnswer)(cwd, stepId, answer, sessionOpts);
|
|
191
175
|
if (result.ok) {
|
|
192
176
|
if (stepId === 'compose_confirm') {
|
|
193
177
|
wroteBinding = readBindingsContent(cwd) !== preBindings;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Loop gate runner — the one owner of the generic `check.query` gate contract.
|
|
4
|
+
*
|
|
5
|
+
* Command surface: pp-tools loop run-gates <point> [--phase <N>] [--tier light|standard|heavy] [--query <q>] [--raw]
|
|
6
|
+
*
|
|
7
|
+
* Resolves the active hooks at <point> through the loop resolver (the same
|
|
8
|
+
* owner `loop render-hooks` uses, so an unreadable config refuses here too),
|
|
9
|
+
* then runs every active gate in registry order and applies the two-step gate
|
|
10
|
+
* contract execute-phase states for `execute:wave:post` / `execute:post`:
|
|
11
|
+
*
|
|
12
|
+
* Step 1 — did the check COMMAND succeed? (`pp-tools check <query> [<phase>]
|
|
13
|
+
* [<check.args>…] --raw` exits 0 with a JSON object). If not: `onError: halt` halts,
|
|
14
|
+
* `onError: skip` (or absent) warns and moves on; `block` is never read.
|
|
15
|
+
* Step 2 — read `block`. A blocking gate (`blocking: true`) halts on
|
|
16
|
+
* `block: true`; that halt is never bypassed by `onError`. An advisory gate
|
|
17
|
+
* (`blocking` not true) reports `block: true` or a non-empty `message` as an
|
|
18
|
+
* advisory and never halts. `block: false` with no message passes silently;
|
|
19
|
+
* a blocking gate that passes WITH a message (a note) passes with an `ℹ` line.
|
|
20
|
+
*
|
|
21
|
+
* `check.args` (optional) are literal tokens the gate declaration appends after
|
|
22
|
+
* the phase — the gate's own policy, stated where the gate is declared (e.g.
|
|
23
|
+
* `--stage plan`). `--query <q>` runs only the gates whose `check.query` is `q`,
|
|
24
|
+
* so a host can run one gate in code beside a loop that handles the others.
|
|
25
|
+
*
|
|
26
|
+
* A gate this runner cannot run (no `check.query`, or a query that is not a
|
|
27
|
+
* check name) halts when it is blocking and is reported when it is advisory:
|
|
28
|
+
* a blocking gate is never passed having checked nothing.
|
|
29
|
+
*
|
|
30
|
+
* Output: the verdict JSON on stdout (`{ point, phase, halt, gates[], lines[] }`),
|
|
31
|
+
* every line of `lines` also on stderr so a host capturing `$(…)` still shows
|
|
32
|
+
* them, and exit 1 when `halt` is true. A host therefore writes
|
|
33
|
+
* `X=$(pp_run loop run-gates <point> …) || exit 1` and the halt stops it.
|
|
34
|
+
*/
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
39
|
+
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const ioMod = require("./io.cjs");
|
|
42
|
+
const { output: coreOutput, error: coreError, ERROR_REASON } = ioMod;
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
44
|
+
const loopResolverModule = require("./loop-resolver.cjs");
|
|
45
|
+
const { resolveProjectLoopHooks } = loopResolverModule;
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
const configLoaderModule = require("./config-loader.cjs");
|
|
48
|
+
const { unreadableConfigFiles } = configLoaderModule;
|
|
49
|
+
const phase_tier_cjs_1 = require("./phase-tier.cjs");
|
|
50
|
+
/** A check name as `routeCheckCommand` accepts it (dotted or hyphenated); never an option or a path. */
|
|
51
|
+
const CHECK_QUERY_RE = /^[a-z0-9][a-z0-9.-]*$/i;
|
|
52
|
+
/** A declared `check.args` token: a flag or a plain word — never a path, a quote or a shell metacharacter. */
|
|
53
|
+
const CHECK_ARG_RE = /^(?:--[a-z][a-z0-9-]*|[a-z0-9][a-z0-9._-]*)$/i;
|
|
54
|
+
const CHECK_ARGS_MAX = 8;
|
|
55
|
+
const CHECK_TIMEOUT_MS = 300_000;
|
|
56
|
+
/** A check's operator message is relayed whole (line breaks kept), up to this many characters. */
|
|
57
|
+
const MESSAGE_CAP = 16_000;
|
|
58
|
+
function boundMessage(v) {
|
|
59
|
+
if (typeof v !== 'string')
|
|
60
|
+
return null;
|
|
61
|
+
const flat = v.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]+/g, ' ').trim();
|
|
62
|
+
if (flat === '')
|
|
63
|
+
return null;
|
|
64
|
+
return flat.length > MESSAGE_CAP ? `${flat.slice(0, MESSAGE_CAP)}…` : flat;
|
|
65
|
+
}
|
|
66
|
+
/** The declared `check.args`, or null when the declaration carries an argument this runner will not pass. */
|
|
67
|
+
function declaredCheckArgs(check) {
|
|
68
|
+
const args = check ? check['args'] : undefined;
|
|
69
|
+
if (args === undefined)
|
|
70
|
+
return [];
|
|
71
|
+
if (!Array.isArray(args) || args.length > CHECK_ARGS_MAX)
|
|
72
|
+
return null;
|
|
73
|
+
return args.every((a) => typeof a === 'string' && CHECK_ARG_RE.test(a)) ? args : null;
|
|
74
|
+
}
|
|
75
|
+
/** Run `pp-tools check <query> [<phase>] [<args>…] --raw` in a child process (check handlers exit the process). */
|
|
76
|
+
const defaultCheckRunner = (query, phase, cwd, checkArgs) => {
|
|
77
|
+
const tools = node_path_1.default.join(__dirname, '..', 'pp-tools.cjs');
|
|
78
|
+
const args = [tools, 'check', query, ...(phase ? [phase] : []), ...checkArgs, '--raw'];
|
|
79
|
+
const r = (0, shell_command_projection_cjs_1.execTool)(process.execPath, args, { cwd, timeout: CHECK_TIMEOUT_MS });
|
|
80
|
+
if (r.exitCode !== 0 || r.signal) {
|
|
81
|
+
const why = r.signal ? `killed by ${r.signal}` : `exit ${r.exitCode}`;
|
|
82
|
+
return { ok: false, result: null, error: `${why}${r.stderr ? `: ${r.stderr.split('\n')[0]}` : ''}` };
|
|
83
|
+
}
|
|
84
|
+
if (r.stdout === '')
|
|
85
|
+
return { ok: false, result: null, error: 'empty output' };
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(r.stdout);
|
|
88
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
89
|
+
return { ok: false, result: null, error: 'output is not a JSON object' };
|
|
90
|
+
return { ok: true, result: parsed, error: null };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { ok: false, result: null, error: 'output is not JSON' };
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* Run every active gate at `point` (already resolved) under the two-step
|
|
98
|
+
* contract. Pure apart from `runCheck`.
|
|
99
|
+
*/
|
|
100
|
+
function runGates(point, phase, activeHooks, cwd, runCheck = defaultCheckRunner) {
|
|
101
|
+
const gates = [];
|
|
102
|
+
const lines = [];
|
|
103
|
+
let halt = false;
|
|
104
|
+
for (const h of activeHooks) {
|
|
105
|
+
if (h.kind !== 'gate')
|
|
106
|
+
continue;
|
|
107
|
+
const blocking = h.blocking === true;
|
|
108
|
+
const onError = typeof h.onError === 'string' ? h.onError : 'skip';
|
|
109
|
+
const check = h.check && typeof h.check === 'object' ? h.check : null;
|
|
110
|
+
const query = check && typeof check['query'] === 'string' ? check['query'] : null;
|
|
111
|
+
const checkArgs = declaredCheckArgs(check);
|
|
112
|
+
const run = { capId: h.capId, query, blocking, onError, outcome: 'pass', block: null, message: null };
|
|
113
|
+
if (query === null || !CHECK_QUERY_RE.test(query) || checkArgs === null) {
|
|
114
|
+
run.outcome = 'unrunnable';
|
|
115
|
+
run.message = query === null
|
|
116
|
+
? 'the gate declares no check.query'
|
|
117
|
+
: checkArgs === null ? 'check.args is not a list of plain flags and words' : 'check.query is not a check name';
|
|
118
|
+
if (blocking) {
|
|
119
|
+
halt = true;
|
|
120
|
+
lines.push(`⛔ ${point} blocked: capability gate (${h.capId}) cannot be run (${run.message}). Fix the capability declaration before continuing.`);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
lines.push(`⚠ ${h.capId} advisory (${point}): gate not run — ${run.message}.`);
|
|
124
|
+
}
|
|
125
|
+
gates.push(run);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// Step 1 — did the check command itself succeed?
|
|
129
|
+
const r = runCheck(query, phase, cwd, checkArgs);
|
|
130
|
+
if (!r.ok || r.result === null) {
|
|
131
|
+
run.message = boundMessage(r.error) ?? 'command error';
|
|
132
|
+
if (onError === 'halt') {
|
|
133
|
+
run.outcome = 'error-halt';
|
|
134
|
+
halt = true;
|
|
135
|
+
lines.push(`⛔ ${point} blocked: gate check command failed (${h.capId}: check ${query}): ${run.message}. Resolve before continuing.`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
run.outcome = 'error-skip';
|
|
139
|
+
lines.push(`⚠ ${point} gate check command failed (${h.capId}: check ${query}): ${run.message} — skipped (onError: ${onError}).`);
|
|
140
|
+
}
|
|
141
|
+
gates.push(run);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// Step 2 — read block (only reached when the command succeeded).
|
|
145
|
+
const block = r.result['block'] === true;
|
|
146
|
+
run.block = block;
|
|
147
|
+
run.message = boundMessage(r.result['message']) ?? boundMessage(r.result['reason']);
|
|
148
|
+
if (blocking && block) {
|
|
149
|
+
run.outcome = 'blocked';
|
|
150
|
+
halt = true;
|
|
151
|
+
lines.push(`⛔ ${point} blocked by capability gate (${h.capId}): ${run.message ?? `check ${query} reported block`}. Resolve before continuing.`);
|
|
152
|
+
}
|
|
153
|
+
else if (!blocking && (block || boundMessage(r.result['message']) !== null)) {
|
|
154
|
+
run.outcome = 'advisory';
|
|
155
|
+
lines.push(`⚠ ${h.capId} advisory (${point}): ${run.message ?? `check ${query} reported block`}`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
run.outcome = 'pass';
|
|
159
|
+
const note = blocking ? boundMessage(r.result['message']) : null;
|
|
160
|
+
if (note !== null)
|
|
161
|
+
lines.push(`ℹ ${h.capId} (${point}): ${note}`);
|
|
162
|
+
}
|
|
163
|
+
gates.push(run);
|
|
164
|
+
}
|
|
165
|
+
return { point, phase, halt, gates, lines };
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `loop run-gates <point> [--phase <N>] [--tier <t>]`. Exit 1 on a halt or a
|
|
169
|
+
* refusal; the verdict JSON is on stdout either way except on a refusal.
|
|
170
|
+
*/
|
|
171
|
+
function cmdLoopRunGates(cwd, point, raw, options = {}) {
|
|
172
|
+
if (!point) {
|
|
173
|
+
coreError('loop run-gates requires a <point> argument (e.g. verify:post)', ERROR_REASON.USAGE);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const phase = typeof options['phase'] === 'string' && options['phase'] !== '' ? options['phase'] : null;
|
|
177
|
+
const tierCli = typeof options['tier'] === 'string' && options['tier'] !== '' ? options['tier'] : null;
|
|
178
|
+
if (tierCli !== null && !['light', 'standard', 'heavy'].includes(tierCli)) {
|
|
179
|
+
coreError('--tier needs one of light, standard, heavy', ERROR_REASON.USAGE);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const phaseTier = (phase !== null || tierCli !== null) && unreadableConfigFiles(cwd).length === 0
|
|
183
|
+
? (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, phase, tierCli)
|
|
184
|
+
: null;
|
|
185
|
+
const outcome = resolveProjectLoopHooks(cwd, point, { phaseTier, what: `loop run-gates ${point}` });
|
|
186
|
+
if (!outcome.ok) {
|
|
187
|
+
coreError(outcome.message, outcome.reason === 'config_unreadable' ? ERROR_REASON.CONFIG_PARSE_FAILED : undefined);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const onlyQuery = typeof options['query'] === 'string' && options['query'] !== '' ? options['query'] : null;
|
|
191
|
+
const hooks = onlyQuery === null
|
|
192
|
+
? outcome.resolved.activeHooks
|
|
193
|
+
: outcome.resolved.activeHooks.filter((h) => {
|
|
194
|
+
const c = h.check && typeof h.check === 'object' ? h.check : null;
|
|
195
|
+
return c !== null && c['query'] === onlyQuery;
|
|
196
|
+
});
|
|
197
|
+
const verdict = runGates(point, phase, hooks, cwd);
|
|
198
|
+
if (verdict.lines.length > 0) {
|
|
199
|
+
try {
|
|
200
|
+
process.stderr.write(verdict.lines.join('\n') + '\n');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* stderr closed */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
coreOutput(verdict, raw);
|
|
207
|
+
if (verdict.halt)
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
}
|
|
210
|
+
module.exports = {
|
|
211
|
+
runGates,
|
|
212
|
+
cmdLoopRunGates,
|
|
213
|
+
};
|