@pareto4/pp-core 1.12.0 → 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.
Files changed (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-reviewer.md +1 -1
  3. package/agents/pp-executor.md +2 -13
  4. package/agents/pp-planner.md +1 -1
  5. package/agents/pp-vault-reviewer.md +51 -0
  6. package/commands/pp-ns-review.md +2 -1
  7. package/commands/pp-vault-review.md +33 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/pp-core/bin/lib/capability-activation.cjs +19 -9
  11. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  12. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  13. package/pp-core/bin/lib/clusters.cjs +1 -0
  14. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  16. package/pp-core/bin/lib/config-loader.cjs +151 -8
  17. package/pp-core/bin/lib/decisions.cjs +104 -27
  18. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  19. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  20. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  21. package/pp-core/bin/lib/init-contract.cjs +180 -66
  22. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  23. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  24. package/pp-core/bin/lib/loop-resolver.cjs +111 -35
  25. package/pp-core/bin/lib/org-profile.cjs +218 -44
  26. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  27. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  28. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  29. package/pp-core/bin/lib/phase.cjs +51 -1
  30. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  31. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  32. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  33. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  34. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  35. package/pp-core/bin/lib/vault-update.cjs +266 -0
  36. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  37. package/pp-core/bin/lib/vault.cjs +313 -11
  38. package/pp-core/bin/pp-tools.cjs +30 -13
  39. package/pp-core/bin/shared/model-catalog.json +2 -1
  40. package/pp-core/references/executor-vault-trace.md +47 -0
  41. package/pp-core/references/loop-hook-dispatch.md +20 -2
  42. package/pp-core/references/planner-vault-practices.md +46 -1
  43. package/pp-core/references/planning-config.md +1 -1
  44. package/pp-core/templates/practices/README.md +140 -11
  45. package/pp-core/templates/project-charter.md +226 -0
  46. package/pp-core/workflows/add-tests.md +4 -0
  47. package/pp-core/workflows/audit-fix.md +2 -0
  48. package/pp-core/workflows/audit-milestone.md +1 -1
  49. package/pp-core/workflows/autonomous.md +3 -3
  50. package/pp-core/workflows/check-todos.md +1 -1
  51. package/pp-core/workflows/code-review-fix.md +3 -1
  52. package/pp-core/workflows/code-review.md +1 -1
  53. package/pp-core/workflows/debug.md +4 -0
  54. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  55. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  56. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  57. package/pp-core/workflows/discovery-sprint.md +1 -1
  58. package/pp-core/workflows/discovery-to-build.md +1 -1
  59. package/pp-core/workflows/discuss-phase.md +2 -2
  60. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  61. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  62. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  63. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  64. package/pp-core/workflows/execute-phase.md +29 -12
  65. package/pp-core/workflows/execute-plan.md +4 -0
  66. package/pp-core/workflows/fast.md +4 -0
  67. package/pp-core/workflows/help/modes/full.md +3 -2
  68. package/pp-core/workflows/init.md +2 -2
  69. package/pp-core/workflows/new-project.md +1 -1
  70. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  71. package/pp-core/workflows/plan-phase.md +13 -10
  72. package/pp-core/workflows/quick.md +5 -1
  73. package/pp-core/workflows/secure-phase.md +1 -1
  74. package/pp-core/workflows/ship.md +7 -3
  75. package/pp-core/workflows/undo.md +1 -1
  76. package/pp-core/workflows/validate-phase.md +5 -1
  77. package/pp-core/workflows/vault-review.md +65 -0
  78. package/pp-core/workflows/verify-work.md +6 -3
  79. package/scripts/gen-capability-registry.cjs +11 -0
  80. package/scripts/lint-test-file-count.allowlist.json +10 -1
  81. package/scripts/prompt-injection-scan.sh +2 -0
@@ -27,10 +27,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
27
27
  return (mod && mod.__esModule) ? mod : { "default": mod };
28
28
  };
29
29
  Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.BASE_BRANCH_REFUSED_REASON = void 0;
31
+ exports.isPlausibleBranchRef = isPlausibleBranchRef;
30
32
  exports.readConfigBaseBranch = readConfigBaseBranch;
31
33
  exports.trySymbolicRef = trySymbolicRef;
32
34
  exports.tryRemoteShow = tryRemoteShow;
33
35
  exports.tryLocalBranch = tryLocalBranch;
36
+ exports.resolveBaseBranchResult = resolveBaseBranchResult;
34
37
  exports.resolveBaseBranch = resolveBaseBranch;
35
38
  exports.gitWorktreeInfoInternal = gitWorktreeInfoInternal;
36
39
  exports.cmdGitBaseBranch = cmdGitBaseBranch;
@@ -38,6 +41,99 @@ const node_fs_1 = __importDefault(require("node:fs"));
38
41
  const node_path_1 = __importDefault(require("node:path"));
39
42
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
40
43
  // ─── Helpers ──────────────────────────────────────────────────────────────────
44
+ /**
45
+ * The one thing that makes a base branch dangerous, and the only rule here that
46
+ * is OURS rather than git's.
47
+ *
48
+ * `execGit` uses argv, so a configured base branch is not shell injection. It is
49
+ * something narrower and worse: a value beginning with `-` is not a revision to
50
+ * git, it is an OPTION. `git log "--output=<path>..HEAD"` exits 0 and creates
51
+ * that file; so does `git diff`. A `.planning/config.json` shipped inside a
52
+ * cloned repository was therefore an arbitrary-file-creation-and-truncation
53
+ * primitive running as the developer or the CI runner (T-6-49).
54
+ *
55
+ * A dash INSIDE a name is an ordinary branch: `feature/-x` is a name git creates
56
+ * and resolves. Only the leading position turns a value into an option, so only
57
+ * the leading position is refused.
58
+ */
59
+ const OPTION_SHAPED = /^-/;
60
+ /**
61
+ * The characters git itself forbids in a ref name: ASCII control characters and
62
+ * space (`\x00`-`\x20`), DEL, and `~ ^ : ? * [ \`.
63
+ *
64
+ * REFUSING MORE THAN THIS IS ITS OWN DEFECT (W16). The first form of this
65
+ * validator was an allowlist of `[A-Za-z0-9._/-]`, which reads as prudent and is
66
+ * not: `feature/a+b`, `feature/a@b` and `café` are all names
67
+ * `git check-ref-format --branch` accepts and `git branch` creates, and all
68
+ * three were silently discarded in favour of whatever the ladder found next — a
69
+ * review computed over a range the project never asked for, which is the exact
70
+ * failure the ladder exists to end. None of `+ @ % = ( ) ; | $ !` can make git
71
+ * do anything, because nothing here reaches a shell. So the rule is git's rule,
72
+ * plus the leading dash, and nothing else.
73
+ */
74
+ const FORBIDDEN_REF_CHARS = /[\x00-\x20\x7f~^:?*[\\]/;
75
+ /** Sequences git forbids anywhere inside a ref name. */
76
+ const FORBIDDEN_REF_SEQUENCES = ['..', '@{', '//'];
77
+ /** A bound, so a pathological value cannot become a pathological command line. */
78
+ const MAX_REF_LENGTH = 255;
79
+ /**
80
+ * Is this a name git could actually resolve as a branch?
81
+ *
82
+ * The predicate is `git check-ref-format --branch` re-implemented, with a
83
+ * leading `-` refused on top. Re-implemented rather than shelled out to: this
84
+ * function is pure, total and called on a path that must never spawn a
85
+ * subprocess to decide whether it is safe to spawn a subprocess. The equivalence
86
+ * is not asserted here in prose — `tests/git-base-branch.test.cjs` drives
87
+ * the admit half from `git check-ref-format --branch` AS THE ORACLE, so git
88
+ * decides what must be admitted and this comment cannot quietly stop being true.
89
+ *
90
+ * Total over any input; never throws. Answering `false` is not an error — it is
91
+ * the resolver's cue to fall THROUGH to the next tier of the ladder, which is
92
+ * the behaviour a project with no override already gets.
93
+ */
94
+ function isPlausibleBranchRef(value) {
95
+ if (typeof value !== 'string')
96
+ return false;
97
+ if (value === '' || value.length > MAX_REF_LENGTH)
98
+ return false;
99
+ if (OPTION_SHAPED.test(value))
100
+ return false;
101
+ if (FORBIDDEN_REF_CHARS.test(value))
102
+ return false;
103
+ for (const bad of FORBIDDEN_REF_SEQUENCES) {
104
+ if (value.includes(bad))
105
+ return false;
106
+ }
107
+ if (value.startsWith('/') || value.endsWith('/') || value.endsWith('.'))
108
+ return false;
109
+ // No path component may be empty, begin with `.`, or end with `.lock`.
110
+ for (const part of value.split('/')) {
111
+ if (part === '' || part.startsWith('.') || part.endsWith('.lock'))
112
+ return false;
113
+ }
114
+ // git's branch shorthand refuses this one by name, so this resolver does too.
115
+ if (value === 'HEAD')
116
+ return false;
117
+ return true;
118
+ }
119
+ /**
120
+ * WHAT A CALLER SAYS WHEN THE OVERRIDE IS REFUSED. One sentence, exported, for
121
+ * every channel — three call sites state it and none of them spells it.
122
+ *
123
+ * A second spelling of this sentence would be a second definition of what the
124
+ * mechanism did. The `check vault.audit` notes and the `check ship.practices`
125
+ * message carry these exact bytes.
126
+ *
127
+ * The reason carries NO copy of the refused value: the value is client-supplied
128
+ * and one of its shapes is an absolute filesystem path, so echoing it back onto
129
+ * an agent-readable surface would trade an injection for a disclosure (CR-03).
130
+ */
131
+ exports.BASE_BRANCH_REFUSED_REASON = 'the configured git.base_branch is not a name git could resolve as a branch, so it was REJECTED ' +
132
+ 'and the base-branch ladder continued without it. A value beginning with "-" is an option to git ' +
133
+ 'rather than a revision, and options such as --output= make git create and truncate the file they ' +
134
+ 'name; every other rule applied here is git\'s own ref-name grammar, so a name git itself would ' +
135
+ 'refuse is refused too. The refused value is deliberately not repeated: it is client-supplied, ' +
136
+ 'and one of its shapes is a filesystem path.';
41
137
  /**
42
138
  * Safely look up `git.base_branch` from the project's config.json.
43
139
  * Returns the configured value (a non-empty, non-null string) or null.
@@ -153,33 +249,63 @@ function tryLocalBranch(cwd, execGit) {
153
249
  }
154
250
  }
155
251
  /**
156
- * Resolve the default/base branch for the repository at `cwd`.
252
+ * Resolve the default/base branch for the repository at `cwd`, and REPORT a
253
+ * refused config override rather than swallowing it.
157
254
  *
158
- * Consults the full precedence ladder and always returns a non-empty string.
255
+ * Consults the full precedence ladder and always returns a non-empty branch.
159
256
  * Never throws.
257
+ *
258
+ * ONLY TIER 1 IS SHAPE-CHECKED, and that is the whole point. Tier 1 is the one
259
+ * value that comes from a file a cloned repository can carry; tiers 2-4 are
260
+ * git's own answers about its own refs, and putting them through the same
261
+ * allowlist would silently demote a legitimate branch whose name this pattern
262
+ * happens not to describe — trading a closed threat for a new silent failure in
263
+ * the one function whose job is to stop guessing.
160
264
  */
161
- function resolveBaseBranch(cwd, deps) {
265
+ function resolveBaseBranchResult(cwd, deps) {
162
266
  const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
163
267
  // Derive .planning dir relative to cwd (mirrors planningDir() in planning-workspace.cjs)
164
268
  const planningDir = node_path_1.default.join(cwd, '.planning');
165
- // 1. Config override
269
+ // 1. Config override — the only untrusted tier, so the only checked one.
166
270
  const configured = readConfigBaseBranch(planningDir, deps);
167
- if (configured)
168
- return configured;
271
+ let configRejected = null;
272
+ if (configured) {
273
+ if (isPlausibleBranchRef(configured))
274
+ return { branch: configured, configRejected: null };
275
+ configRejected = { reason: exports.BASE_BRANCH_REFUSED_REASON };
276
+ }
169
277
  // 2. symbolic-ref (fast, no network)
170
278
  const symref = trySymbolicRef(cwd, execGit);
171
279
  if (symref)
172
- return symref;
280
+ return { branch: symref, configRejected };
173
281
  // 3. git remote show origin (authoritative when origin/HEAD unset)
174
282
  const remoteShow = tryRemoteShow(cwd, execGit);
175
283
  if (remoteShow)
176
- return remoteShow;
284
+ return { branch: remoteShow, configRejected };
177
285
  // 4. Local branch existence
178
286
  const local = tryLocalBranch(cwd, execGit);
179
287
  if (local)
180
- return local;
288
+ return { branch: local, configRejected };
181
289
  // 5. Last-resort default
182
- return 'main';
290
+ return { branch: 'main', configRejected };
291
+ }
292
+ /**
293
+ * Resolve the default/base branch for the repository at `cwd`.
294
+ *
295
+ * Consults the full precedence ladder and always returns a non-empty string.
296
+ * Never throws.
297
+ *
298
+ * The bare-string form. A consumer that computes a range and has a channel to
299
+ * speak on takes `resolveBaseBranchResult` instead and states the refusal
300
+ * (`vault-audit.cts`, the blocking audit gate, and `cmdShipPractices`, the
301
+ * ship:pre check): a gate that ran over a range the project never configured
302
+ * and said nothing would report success on the wrong input. This form stays
303
+ * for callers with nowhere to put a sentence, such as `cmdGitBaseBranch`,
304
+ * whose contract is one branch name on stdout. Either form refuses an
305
+ * option-shaped override.
306
+ */
307
+ function resolveBaseBranch(cwd, deps) {
308
+ return resolveBaseBranchResult(cwd, deps).branch;
183
309
  }
184
310
  /**
185
311
  * Detect whether `cwd` sits inside a git worktree, and if so, return the
@@ -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`)'s `preselected` is resolved via
171
- * `resolveDetectedOrgProfile` — set only when exactly one org-profile-shaped
172
- * package is installed (INIT-PATCH-C); the prompt text is built from that
173
- * detected value rather than kept as two hard-coded strings that can drift.
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 — a single installed org profile was detected: "${detectedOrgProfile}". ` +
208
- 'Press Enter to use it, type "none" to skip it, or type a different package name.)',
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 = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
377
- if (globalRoot && containsOrgProfileShapedPackage(globalRoot)) {
378
- return true;
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 = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
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
- if (!isUsableString(orgProfilePointer) && orgProfilePackageResolvable(cwd, opts)) {
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 skipped rather than answered.',
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 non-blank — the one exception to the "batch write after
894
- * the last answer" rule in `computeCurrentStep`'s own doc comment — because
895
- * the very next question (`provider`) must see it to re-resolve its
896
- * `preselected` (the walk-3 regression Task 1's acceptance names).
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
- session.answers[stepId] = typeof value === 'string' ? value : '';
956
+ const answerValue = typeof value === 'string' ? value : '';
941
957
  if (stepId === 'org_profile') {
942
- const trimmed = session.answers[stepId].trim();
943
- if (trimmed !== '') {
944
- writeConfigMerge(cwd, { org: { profile: trimmed } });
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. With no client vault bound (no or
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
- if (nested || flat) {
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
- armed: false,
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: { enabled: true } });
1203
+ writeConfigMerge(cwd, { vault: patch });
1096
1204
  }
1097
1205
  catch (err) {
1098
- return { ok: false, armed: false, enabled: undefined, client, message: writeFailedMessage(err) };
1206
+ return {
1207
+ ok: false, armed: false, enabled: current, required: currentRequired, practicesEnabled: currentPractices, client,
1208
+ message: writeFailedMessage(err),
1209
+ };
1099
1210
  }
1100
- return { ok: true, armed: true, enabled: true, client, message: 'vault.enabled set to true (it was absent).' };
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
- // INIT-PATCH-C: Enter accepts a carried default, "none" clears it.
172
- // Scoped to an OPTIONAL step carrying a usable `preselected` — today
173
- // that is only `org_profile` when exactly one org-profile-shaped
174
- // package was detected. `provider` also carries a `preselected` but is
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;