@pareto4/pp-core 1.12.1 → 1.17.1

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 (196) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +3 -2
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +7 -21
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +274 -0
  9. package/agents/pp-verifier.md +7 -3
  10. package/bin/install.js +10 -3
  11. package/commands/pp-execute-phase.md +1 -3
  12. package/commands/pp-ns-review.md +2 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +35 -0
  15. package/commands/pp-verify-work.md +1 -2
  16. package/gemini-extension.json +1 -1
  17. package/hooks/dist/pp-check-update.js +0 -0
  18. package/hooks/dist/pp-cursor-session-start.js +1 -1
  19. package/hooks/dist/pp-statusline.js +1 -1
  20. package/hooks/dist/pp-update-banner.js +0 -0
  21. package/hooks/pp-check-update.js +0 -0
  22. package/hooks/pp-cursor-session-start.js +1 -1
  23. package/hooks/pp-statusline.js +1 -1
  24. package/hooks/pp-update-banner.js +0 -0
  25. package/package.json +14 -19
  26. package/pareto-skills/_shared/create-project-board.sh +0 -0
  27. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  28. package/pp-core/bin/check-latest-version.cjs +0 -0
  29. package/pp-core/bin/detect-build-commands.sh +2 -2
  30. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  31. package/pp-core/bin/lib/capability-registry.cjs +526 -22
  32. package/pp-core/bin/lib/capability-state.cjs +160 -4
  33. package/pp-core/bin/lib/capability-writer.cjs +19 -5
  34. package/pp-core/bin/lib/check-command-router.cjs +3867 -274
  35. package/pp-core/bin/lib/clusters.cjs +4 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -28
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +253 -76
  39. package/pp-core/bin/lib/config-loader.cjs +7 -103
  40. package/pp-core/bin/lib/config-schema.cjs +1 -1
  41. package/pp-core/bin/lib/config.cjs +154 -62
  42. package/pp-core/bin/lib/decisions.cjs +104 -27
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -1
  45. package/pp-core/bin/lib/git-base-branch.cjs +138 -10
  46. package/pp-core/bin/lib/git-provider.cjs +4 -4
  47. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  48. package/pp-core/bin/lib/init-contract.cjs +653 -629
  49. package/pp-core/bin/lib/init-interactive.cjs +77 -21
  50. package/pp-core/bin/lib/init.cjs +24 -51
  51. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  52. package/pp-core/bin/lib/io.cjs +8 -0
  53. package/pp-core/bin/lib/loop-resolver.cjs +169 -47
  54. package/pp-core/bin/lib/org-profile.cjs +544 -53
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -23
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -203
  59. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  60. package/pp-core/bin/lib/practices-review-check.cjs +15 -9
  61. package/pp-core/bin/lib/profile-output.cjs +1 -1
  62. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  63. package/pp-core/bin/lib/state.cjs +11 -85
  64. package/pp-core/bin/lib/template.cjs +1 -1
  65. package/pp-core/bin/lib/vault-audit.cjs +2594 -0
  66. package/pp-core/bin/lib/vault-command-router.cjs +2756 -60
  67. package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
  68. package/pp-core/bin/lib/vault-index.cjs +699 -0
  69. package/pp-core/bin/lib/vault-mode.cjs +235 -0
  70. package/pp-core/bin/lib/vault-practices.cjs +1194 -89
  71. package/pp-core/bin/lib/vault-review.cjs +2102 -0
  72. package/pp-core/bin/lib/vault-seal.cjs +1936 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1505 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +3184 -0
  75. package/pp-core/bin/lib/vault-update.cjs +242 -0
  76. package/pp-core/bin/lib/vault-write-back.cjs +78 -31
  77. package/pp-core/bin/lib/vault.cjs +2713 -634
  78. package/pp-core/bin/lib/verify.cjs +0 -11
  79. package/pp-core/bin/portable-timeout.sh +1 -1
  80. package/pp-core/bin/pp-tools.cjs +184 -37
  81. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  82. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  83. package/pp-core/bin/shared/model-catalog.json +1 -0
  84. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  85. package/pp-core/org-profile.json +26 -0
  86. package/pp-core/references/autonomous-best-effort.md +8 -7
  87. package/pp-core/references/loop-hook-dispatch.md +0 -14
  88. package/pp-core/references/planner-gap-closure.md +0 -1
  89. package/pp-core/references/planner-tdd.md +0 -1
  90. package/pp-core/references/planner-vault-practices.md +31 -3
  91. package/pp-core/references/planner-vault-standards.md +320 -0
  92. package/pp-core/references/planning-config.md +0 -8
  93. package/pp-core/references/practice-arithmetic-contract.md +121 -0
  94. package/pp-core/references/tdd.md +0 -1
  95. package/pp-core/references/verifier-practice-verdicts.md +263 -0
  96. package/pp-core/templates/README.md +1 -0
  97. package/pp-core/templates/VAULT-REVIEW.md +202 -0
  98. package/pp-core/templates/phase-prompt.md +2 -6
  99. package/pp-core/templates/practices/README.md +137 -11
  100. package/pp-core/templates/project-charter.md +226 -0
  101. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  102. package/pp-core/workflows/add-tests.md +55 -0
  103. package/pp-core/workflows/audit-fix.md +63 -0
  104. package/pp-core/workflows/audit-milestone.md +1 -1
  105. package/pp-core/workflows/autonomous.md +85 -11
  106. package/pp-core/workflows/check-todos.md +13 -2
  107. package/pp-core/workflows/code-review-fix.md +59 -1
  108. package/pp-core/workflows/code-review.md +10 -1
  109. package/pp-core/workflows/complete-milestone.md +1 -1
  110. package/pp-core/workflows/debug.md +56 -2
  111. package/pp-core/workflows/discovery-sprint/prefill.md +21 -0
  112. package/pp-core/workflows/discovery-sprint/prototype.md +46 -48
  113. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  114. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  118. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  123. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  124. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  127. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  128. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  129. package/pp-core/workflows/discovery-sprint.md +7 -3
  130. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  131. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  132. package/pp-core/workflows/discovery-to-build.md +9 -1
  133. package/pp-core/workflows/discuss-phase.md +3 -10
  134. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  135. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  136. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  137. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  138. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  139. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  140. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  141. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -18
  142. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  143. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  144. package/pp-core/workflows/execute-phase.md +154 -110
  145. package/pp-core/workflows/execute-plan.md +58 -4
  146. package/pp-core/workflows/fast.md +53 -0
  147. package/pp-core/workflows/help/modes/full.md +1 -0
  148. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  149. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  150. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  151. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  152. package/pp-core/workflows/init.md +232 -272
  153. package/pp-core/workflows/new-project.md +31 -23
  154. package/pp-core/workflows/plan-phase.md +99 -43
  155. package/pp-core/workflows/quick.md +120 -2
  156. package/pp-core/workflows/secure-phase.md +1 -1
  157. package/pp-core/workflows/ship.md +57 -24
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +60 -1
  160. package/pp-core/workflows/vault-review.md +325 -0
  161. package/pp-core/workflows/verify-work.md +13 -12
  162. package/scripts/changeset/cli.cjs +6 -5
  163. package/scripts/changeset/lint.cjs +0 -0
  164. package/scripts/changeset/new.cjs +33 -50
  165. package/scripts/dev-env.cjs +25 -85
  166. package/scripts/gen-capability-registry.cjs +199 -0
  167. package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
  168. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  169. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  170. package/scripts/lint-docs-required.cjs +0 -0
  171. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  172. package/scripts/lint-test-file-count.allowlist.json +13 -2
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -15
  175. package/scripts/record-vault-mode-golden.cjs +89 -0
  176. package/scripts/release-channel.cjs +0 -0
  177. package/scripts/sync-manifest-versions.cjs +5 -4
  178. package/scripts/sync-rulesets.sh +1 -1
  179. package/scripts/update-size-baseline.cjs +30 -274
  180. package/scripts/vault-calibrate.cjs +996 -0
  181. package/scripts/verify-opengsd-sweep.cjs +3 -5
  182. package/scripts/workflow-size.cjs +9 -61
  183. package/pp-core/bin/lib/dispatch-plan.cjs +0 -472
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/phase-tier.cjs +0 -467
  186. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  189. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  190. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  191. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -72
  192. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -34
  193. package/scripts/lib/size-budget.cjs +0 -311
  194. package/scripts/lint-neutrality-names.cjs +0 -694
  195. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  196. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -11,9 +11,11 @@
11
11
  * profile" — that carries a small set of vault/platform coordinates for a
12
12
  * single operator's projects. The package name lives entirely in the
13
13
  * consuming project's own `.planning/config.json`; pp-core's own source
14
- * never names one (ORG-01, SC1). An install that configures no such
15
- * pointer resolves nothing, warns about nothing, and probes no filesystem
16
- * beyond the one config read every project already pays for (ORG-02).
14
+ * never names one (ORG-01, SC1). Upstream, an install that configures no
15
+ * such pointer resolves nothing (ORG-02). This fork reverses ORG-02 on
16
+ * purpose: with no usable pointer it resolves the embedded profile
17
+ * (`EMBEDDED_ORG_PROFILE_NAME`, PBI 74316), and `org.profile: "none"` is the
18
+ * explicit opt-out.
17
19
  *
18
20
  * It imports ONLY node builtins (`node:fs`, `node:path`) at the top level —
19
21
  * no `./vault.cjs`, `./vault-coordinates.cjs`, `./vault-write-back.cjs`,
@@ -39,6 +41,13 @@
39
41
  * than writing a second regex; the lazy seam is how a zero-import leaf can
40
42
  * still do that.
41
43
  *
44
+ * The second exception (Vault Assurance 13-03, D-23) is a top-level import
45
+ * of `./vault-mode.cjs`, the mode vocabulary leaf. That module imports
46
+ * nothing at all, so it can never require this module back and cannot close
47
+ * a cycle; it is imported so `resolveVaultMode` and the `vault.mode`
48
+ * disposition quote the key and the two literals from their one owner rather
49
+ * than spelling them here.
50
+ *
42
51
  * This file's own prose and code deliberately never name a concrete org
43
52
  * profile package or any client — the pointer is described only as "the
44
53
  * npm package name an operator configures". `tests/org-profile.test.cjs`
@@ -48,12 +57,23 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
48
57
  return (mod && mod.__esModule) ? mod : { "default": mod };
49
58
  };
50
59
  Object.defineProperty(exports, "__esModule", { value: true });
51
- exports.HOST_FORBIDDEN_CHARS = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = exports.ORG_PROFILE_SEED_KEYS = exports.ORG_PROFILE_SEED_SECTION = exports.ORG_PROFILE_ALLOWLIST = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
60
+ exports.HOST_FORBIDDEN_CHARS = exports.PROFILE_REJECTED_CLONE_TARGET_PATTERNS = exports.ORG_PROFILE_SEED_KEYS = exports.ORG_PROFILE_INIT_SECTION = exports.ORG_PROFILE_SEED_SECTION = exports.ORG_PROFILE_ALLOWLIST = exports.VAULT_REQUIRED_CONFIG_KEY = exports.EMBEDDED_ORG_PROFILE_ENV = exports.EMBEDDED_ORG_PROFILE_NAME = exports.ORG_PROFILE_NONE = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
61
+ exports.embeddedOrgProfilePath = embeddedOrgProfilePath;
62
+ exports.effectiveEmbeddedOrgProfilePath = effectiveEmbeddedOrgProfilePath;
63
+ exports.resolveGlobalPackageRootCandidates = resolveGlobalPackageRootCandidates;
64
+ exports.globalRootCandidatesFor = globalRootCandidatesFor;
65
+ exports.probeOrgProfilePackage = probeOrgProfilePackage;
52
66
  exports.resolveOrgProfileDocument = resolveOrgProfileDocument;
53
67
  exports.resolveOrgProfile = resolveOrgProfile;
54
68
  exports.readProfileValue = readProfileValue;
69
+ exports.booleanPolicyValue = booleanPolicyValue;
70
+ exports.resolveVaultRequired = resolveVaultRequired;
71
+ exports.resolveVaultMode = resolveVaultMode;
55
72
  const node_fs_1 = __importDefault(require("node:fs"));
56
73
  const node_path_1 = __importDefault(require("node:path"));
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const vaultMode = require("./vault-mode.cjs");
76
+ const { VAULT_MODE_CONFIG_KEY, DEFAULT_VAULT_MODE, VAULT_MODE_REASON, isVaultMode } = vaultMode;
57
77
  // ─── Constants ───────────────────────────────────────────────────────────────
58
78
  /** The dotted `.planning/config.json` key naming the profile package. Deliberately
59
79
  * top-level, NOT nested under `vault.`, because the profile also supplies
@@ -63,9 +83,112 @@ exports.ORG_PROFILE_CONFIG_KEY = 'org.profile';
63
83
  * package's `main`/`exports` entry, and never loaded via `require` (D-08). */
64
84
  exports.ORG_PROFILE_DATA_FILE = 'org-profile.json';
65
85
  /**
66
- * D-06's fixed allowlist: a profile may contribute exactly these five dotted
67
- * config keys. Entry order is not contractual; membership is. Anything else
86
+ * The `org.profile` value that opts a project out of every org profile, the
87
+ * embedded one included. Compared case-insensitively after trimming — the
88
+ * same spelling `pp-tools init` already accepts as a skip (UAT-13-F3).
89
+ */
90
+ exports.ORG_PROFILE_NONE = 'none';
91
+ /**
92
+ * The name `resolveOrgProfileDocument` reports as `packageName` for this
93
+ * fork's embedded profile. Not a valid npm package name shape for a scoped
94
+ * org profile, so it can never collide with a pointer that resolves.
95
+ *
96
+ * FORK-ONLY (PBI 74316). This fork serves a single organisation, so the
97
+ * profile ships inside pp-core as `pp-core/org-profile.json` instead of as a
98
+ * separate package. It carries coordinates only — never a vault ref: the
99
+ * vault version a project builds on is committed per project in its
100
+ * `.platform-bindings.json` (`client.vaultRef`), so no pp-core release can
101
+ * move it. Never offered upstream — see docs/PORT-BACK-DELTA.md.
102
+ */
103
+ exports.EMBEDDED_ORG_PROFILE_NAME = 'embedded';
104
+ /**
105
+ * Absolute path of the embedded profile document. Resolved against this
106
+ * module's own directory: the compiled file sits at `pp-core/bin/lib/`, and
107
+ * both this repo and every installed tree keep `pp-core/org-profile.json`
108
+ * two levels up.
109
+ */
110
+ function embeddedOrgProfilePath() {
111
+ return node_path_1.default.join(__dirname, '..', '..', exports.ORG_PROFILE_DATA_FILE);
112
+ }
113
+ /**
114
+ * Environment override for the embedded profile, for diagnostics and for test
115
+ * suites that exercise the upstream contract: `none` (or `off`, any case)
116
+ * disables it, any other non-blank value is a path to the document to use
117
+ * instead. Unset or blank leaves the shipped document in place. A project
118
+ * opts out through `org.profile: "none"`, never through this variable.
119
+ */
120
+ exports.EMBEDDED_ORG_PROFILE_ENV = 'PP_EMBEDDED_ORG_PROFILE';
121
+ /**
122
+ * The embedded document path in force for this process: `opts._embeddedPath`
123
+ * (test seam) when given, else the `EMBEDDED_ORG_PROFILE_ENV` override, else
124
+ * `embeddedOrgProfilePath()`. `null` means no embedded profile applies.
125
+ */
126
+ function effectiveEmbeddedOrgProfilePath(opts) {
127
+ if (opts && opts._embeddedPath !== undefined)
128
+ return opts._embeddedPath;
129
+ const override = process.env[exports.EMBEDDED_ORG_PROFILE_ENV];
130
+ if (typeof override === 'string' && override.trim() !== '') {
131
+ const trimmed = override.trim();
132
+ return ['none', 'off'].includes(trimmed.toLowerCase()) ? null : trimmed;
133
+ }
134
+ return embeddedOrgProfilePath();
135
+ }
136
+ /**
137
+ * The version reported for the embedded profile: the pp-core release that
138
+ * ships it. Probes the `VERSION` beside the document (the installer writes
139
+ * `pp-core/VERSION` into every installed tree), then the repo root's. Returns
140
+ * `null` when neither is readable and semver-shaped.
141
+ */
142
+ function readEmbeddedProfileVersion(dataPath) {
143
+ const dir = node_path_1.default.dirname(dataPath);
144
+ for (const candidate of [node_path_1.default.join(dir, 'VERSION'), node_path_1.default.join(dir, '..', 'VERSION')]) {
145
+ try {
146
+ const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
147
+ if (/^\d+\.\d+\.\d+/.test(raw))
148
+ return raw;
149
+ }
150
+ catch {
151
+ // try the next candidate
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ /** The dotted key naming the vault load policy (02-04, GATE-05 / STRICT-01).
157
+ * Declared in `capabilities/vault/capability.json`'s federated config block,
158
+ * boolean, default `false`. Named here so neither the allowlist, the value
159
+ * guard nor `resolveVaultRequired` hand-restates the string. */
160
+ exports.VAULT_REQUIRED_CONFIG_KEY = 'vault.required';
161
+ /**
162
+ * D-06's fixed allowlist: a profile may contribute exactly these seven dotted
163
+ * config keys (five coordinates and two policy keys; the seventh, vault.mode,
164
+ * is documented at its entry). Entry order is not contractual; membership is. Anything else
68
165
  * present in a profile document is dropped with a bounded warning.
166
+ *
167
+ * FIVE COORDINATES AND ONE POLICY (02-04, D-VH-4 / STRICT-01). The first five
168
+ * members answer *where to fetch from* — a provider, three shared-vault
169
+ * coordinates and a platform host. `vault.required` answers something
170
+ * categorically different: *whether the fetch is mandatory*. It is the first
171
+ * POLICY member of this list, and it is here deliberately, so one org-profile
172
+ * package can turn the load gate on across a whole fleet without a per-repo
173
+ * edit.
174
+ *
175
+ * That is also this list's sharpest widening, and it is recorded rather than
176
+ * glossed (threat T-02-17): an `npm update` of the profile package can now
177
+ * move the value of a build gate without a code review in the consuming repo.
178
+ * Three things bound it. First, precedence — project config outranks the
179
+ * profile for every member, so any project can opt out explicitly, and
180
+ * `tests/org-profile.test.cjs` pins that direction with a non-vacuity partner
181
+ * rather than assuming it. Second, direction — the profile can only ever make
182
+ * the gate MORE strict from the built-in default of `false`, and a gate
183
+ * turning on is fail-closed. Third, the value guard below accepts only the two
184
+ * boolean literals for this key, so a profile cannot smuggle an arbitrary
185
+ * string into the policy.
186
+ *
187
+ * The warning in the block beneath this one is NOT retracted: it is still
188
+ * exactly right about the seed section, which stays off this list. A key that
189
+ * layers at runtime is a key an `npm update` can silently move — the
190
+ * difference is that for `vault.required` that consequence was weighed and
191
+ * accepted under a locked decision, and for `bindings.*` it was not.
69
192
  */
70
193
  exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
71
194
  'vault.provider',
@@ -73,6 +196,15 @@ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
73
196
  'vault.fallback_ref',
74
197
  'vault.fallback_host',
75
198
  'platform.host',
199
+ 'vault.required',
200
+ // Vault Assurance 13-03 (D-06): the second POLICY key, after vault.required
201
+ // (02-04). Unlike vault.required, a profile value here can make a gate LESS
202
+ // strict — `standards` turns the blocking vault.audit gate into
203
+ // not_applicable — so the widening is accepted and bounded in 13-CONTEXT
204
+ // D-24 (threat T-13-01): project config outranks the profile, only the two
205
+ // literals pass the disposition below, and the embedded fork profile does
206
+ // not seed it. vault.enabled and init.* stay absent from this list.
207
+ VAULT_MODE_CONFIG_KEY,
76
208
  ]);
77
209
  /**
78
210
  * The single top-level key naming the Phase 34 init-only seed section.
@@ -90,6 +222,15 @@ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
90
222
  * directly from the package.
91
223
  */
92
224
  exports.ORG_PROFILE_SEED_SECTION = 'bindings';
225
+ /**
226
+ * PBI 74299: the top-level key naming the init-answers section — fixed answers
227
+ * for `pp-tools init` questions (`client_name`, `provider`, `visibility`) and
228
+ * `derive_slugs`. Read ONLY by `src/init-contract.cts` through
229
+ * `resolveOrgProfileDocument`; like the seed section it is deliberately absent
230
+ * from `ORG_PROFILE_ALLOWLIST`, because it answers questions once at init and
231
+ * never layers a runtime config key.
232
+ */
233
+ exports.ORG_PROFILE_INIT_SECTION = 'init';
93
234
  /**
94
235
  * The five dotted leaf keys the seed section may carry, each prefixed with
95
236
  * `ORG_PROFILE_SEED_SECTION`. Membership is contractual; entry ORDER is not.
@@ -224,51 +365,125 @@ function describeUnknown(value) {
224
365
  /**
225
366
  * D-03's "global" half of the two-path resolution ladder, as a pure
226
367
  * heuristic with NO subprocess spawn (this repo avoids spawning a
227
- * subprocess for a computation that does not strictly require one):
368
+ * subprocess for a computation that does not strictly require one).
369
+ * Returns an ORDERED list of existing candidate roots (init-friction F1 —
370
+ * the single-root version silently missed Homebrew layouts):
228
371
  *
229
372
  * 1. `NPM_CONFIG_PREFIX` / `npm_config_prefix`, when set to a non-blank
230
373
  * string: `<prefix>/node_modules` on `win32`, `<prefix>/lib/node_modules`
231
374
  * otherwise.
232
- * 2. Otherwise, derived from `process.execPath`: with
233
- * `execDir = path.dirname(process.execPath)`, `path.join(execDir,
234
- * 'node_modules')` on `win32`, `path.resolve(execDir, '..', 'lib',
235
- * 'node_modules')` otherwise.
375
+ * 2. Every PATH directory containing a `node` entry, mapped through the
376
+ * same `<bin>` → `<global tree>` rule — WITHOUT resolving symlinks.
377
+ * This is what fixes Homebrew: `process.execPath` resolves
378
+ * `/opt/homebrew/bin/node` into the Cellar, whose `lib/node_modules`
379
+ * exists but holds only npm itself, while the real global tree lives at
380
+ * `/opt/homebrew/lib/node_modules` beside the PATH symlink.
381
+ * 3. Derived from `process.execPath` (the pre-existing heuristic) — still
382
+ * correct for nvm/Volta layouts.
236
383
  *
237
- * The candidate is returned only when `fs.existsSync(candidate)`;
238
- * otherwise `null`.
384
+ * Candidates are deduped and filtered to `fs.existsSync`; resolution tries
385
+ * each in order (first hit wins), so a wrong-but-existing root earlier in
386
+ * the list cannot mask a later root that actually holds the package.
239
387
  *
240
- * Accepted residual: an nvm/Volta-style layout that honours neither
241
- * `NPM_CONFIG_PREFIX` nor places the global tree beside the node binary
242
- * will simply not resolve a globally-installed profile — this degrades
243
- * per D-09 (falls through to the no-profile behaviour with a warning)
244
- * rather than failing.
388
+ * Accepted residual: a layout that honours none of the three sources will
389
+ * still not resolve a globally-installed profile — this degrades per D-09
390
+ * (falls through to the no-profile behaviour with a warning) rather than
391
+ * failing.
245
392
  *
246
393
  * pp-core's own install directory is deliberately NOT a candidate here:
247
394
  * the AI-runtime install is a bare directory copy with no `package.json`
248
395
  * and no `node_modules` of its own, so resolving relative to it would
249
396
  * never find a sibling package.
397
+ *
398
+ * TWO READERS, ONE BUILDER, ONE FLAG (UAT-13-F2). RESOLUTION — the
399
+ * answer-time probe (`probeOrgProfilePackage`) and profile load
400
+ * (`resolveOrgProfileDocument`) — walks the full list above. DETECTION —
401
+ * `buildInitQuestions`' org-profile hint and `verifyInitInvariants`'
402
+ * INIT_QUESTIONS_ORG_PROFILE_UNASKED check in `src/init-contract.cts` —
403
+ * passes `explicitPrefixOnly: true`: when `NPM_CONFIG_PREFIX` /
404
+ * `npm_config_prefix` is set, only the prefix-derived root is scanned.
405
+ * An explicit prefix is the operator's — or a test harness's — statement
406
+ * of where the global tree is, and a hint or a violation must never be
407
+ * produced from a tree the operator did not point at. Detection roots are
408
+ * therefore a SUBSET of resolution roots: anything detected resolves by
409
+ * name, and a package that sits only on a PATH-derived root while an
410
+ * explicit prefix is set is still selectable by typing its name. Without a
411
+ * prefix both readers see the same list, which is what lets detection find
412
+ * a Homebrew layout at all.
250
413
  */
251
- function resolveGlobalPackageRoot() {
414
+ /** Bounds the PATH scan below — a DoS guard in the same convention as
415
+ * `PROFILE_FLATTEN_LEAF_CAP` (a pathological PATH cannot make resolution
416
+ * scan unboundedly many directories). */
417
+ const GLOBAL_ROOT_PATH_SCAN_CAP = 64;
418
+ /** The `<bin dir>` → `<global node_modules>` mapping npm itself uses:
419
+ * `<bin>/node_modules` on win32, `<bin>/../lib/node_modules` elsewhere. */
420
+ function globalRootFromBinDir(binDir) {
421
+ return process.platform === 'win32'
422
+ ? node_path_1.default.join(binDir, 'node_modules')
423
+ : node_path_1.default.resolve(binDir, '..', 'lib', 'node_modules');
424
+ }
425
+ function resolveGlobalPackageRootCandidates(opts = {}) {
426
+ const candidates = [];
427
+ const push = (c) => {
428
+ if (!candidates.includes(c))
429
+ candidates.push(c);
430
+ };
431
+ const existing = () => candidates.filter((c) => {
432
+ try {
433
+ return node_fs_1.default.existsSync(c);
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ });
252
439
  try {
440
+ // 1. Explicit prefix always wins — same rule as before.
253
441
  const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
254
- let candidate;
255
442
  if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
256
443
  const prefix = prefixEnv.trim();
257
- candidate =
258
- process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
444
+ push(process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules'));
445
+ // Detection mode (see the header): an explicit prefix is authoritative.
446
+ // A prefix that points nowhere yields [] — still the authoritative
447
+ // answer ("no global tree"), never a fall-through to PATH.
448
+ if (opts.explicitPrefixOnly === true)
449
+ return existing();
259
450
  }
260
- else {
261
- const execDir = node_path_1.default.dirname(process.execPath);
262
- candidate =
263
- process.platform === 'win32'
264
- ? node_path_1.default.join(execDir, 'node_modules')
265
- : node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
451
+ // 2. PATH scan for the directory holding the `node` entry AS INVOKED
452
+ // (symlink not resolved). `process.execPath` resolves symlinks, so a
453
+ // Homebrew layout (`/opt/homebrew/bin/node` → Cellar) lands in the
454
+ // Cellar's private `lib/node_modules` (npm only) and misses the real
455
+ // global tree at `/opt/homebrew/lib/node_modules`. The un-resolved
456
+ // PATH entry is what npm's own prefix derivation is anchored to.
457
+ const pathEnv = process.env.PATH ?? '';
458
+ const pathDirs = pathEnv
459
+ .split(node_path_1.default.delimiter)
460
+ .filter((d) => d.trim() !== '')
461
+ .slice(0, GLOBAL_ROOT_PATH_SCAN_CAP);
462
+ const nodeNames = process.platform === 'win32' ? ['node.exe', 'node.cmd', 'node'] : ['node'];
463
+ for (const dir of pathDirs) {
464
+ for (const name of nodeNames) {
465
+ let found = false;
466
+ try {
467
+ found = node_fs_1.default.existsSync(node_path_1.default.join(dir, name));
468
+ }
469
+ catch {
470
+ // unreadable PATH entry — skip it
471
+ }
472
+ if (found) {
473
+ push(globalRootFromBinDir(dir));
474
+ break;
475
+ }
476
+ }
266
477
  }
267
- return node_fs_1.default.existsSync(candidate) ? candidate : null;
478
+ // 3. execPath-derived (the pre-existing heuristic) — still the correct
479
+ // root for nvm/Volta-style layouts where the global tree sits beside
480
+ // the real binary.
481
+ push(globalRootFromBinDir(node_path_1.default.dirname(process.execPath)));
268
482
  }
269
483
  catch {
270
- return null;
484
+ // degrade to whatever was collected before the fault
271
485
  }
486
+ return existing();
272
487
  }
273
488
  // ─── resolveProfilePackage ───────────────────────────────────────────────────
274
489
  /**
@@ -283,23 +498,54 @@ function resolveGlobalPackageRoot() {
283
498
  * 220-228): the same try/catch-returns-null shape, one level removed
284
499
  * (resolving a data file's sibling `package.json`, not a CLI entry point).
285
500
  */
286
- function resolveProfilePackage(pkgName, cwd, globalRoot) {
501
+ function resolveProfilePackage(pkgName, cwd, globalRoots) {
287
502
  try {
288
503
  return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [cwd] }), source: 'project' };
289
504
  }
290
505
  catch {
291
- // fall through to the global attempt below
506
+ // fall through to the global attempts below
292
507
  }
293
- if (globalRoot) {
508
+ for (const globalRoot of globalRoots) {
294
509
  try {
295
510
  return { pkgJsonPath: require.resolve(`${pkgName}/package.json`, { paths: [globalRoot] }), source: 'global' };
296
511
  }
297
512
  catch {
298
- // fall through to null below
513
+ // try the next candidate root
299
514
  }
300
515
  }
301
516
  return null;
302
517
  }
518
+ /** Map the `_globalRoot` test seam (a single root, or `null` for "no global
519
+ * root found") onto the candidate-list shape resolution now uses; when the
520
+ * seam is absent, run the production heuristic. The seam wins over
521
+ * `explicitPrefixOnly`, which only shapes the production heuristic —
522
+ * detection passes it, resolution does not (see the builder's header). */
523
+ function globalRootCandidatesFor(opts) {
524
+ if (opts && opts._globalRoot !== undefined) {
525
+ return opts._globalRoot === null ? [] : [opts._globalRoot];
526
+ }
527
+ return resolveGlobalPackageRootCandidates({ explicitPrefixOnly: opts?.explicitPrefixOnly === true });
528
+ }
529
+ /**
530
+ * Answer-time probe for `/pp-init`'s `org_profile` question (init-friction
531
+ * F2): does `pkgName` resolve from the project or any global root right
532
+ * now? Returns the probed global roots so a rejection message can name
533
+ * them. Never throws (D-09) — any fault reports `resolved: false` with the
534
+ * roots collected so far.
535
+ */
536
+ function probeOrgProfilePackage(pkgName, cwd, opts) {
537
+ let probedGlobalRoots = [];
538
+ try {
539
+ probedGlobalRoots = globalRootCandidatesFor(opts);
540
+ const trimmed = typeof pkgName === 'string' ? pkgName.trim() : '';
541
+ if (trimmed === '')
542
+ return { resolved: false, probedGlobalRoots };
543
+ return { resolved: resolveProfilePackage(trimmed, cwd, probedGlobalRoots) !== null, probedGlobalRoots };
544
+ }
545
+ catch {
546
+ return { resolved: false, probedGlobalRoots };
547
+ }
548
+ }
303
549
  /**
304
550
  * Flatten a parsed profile document to dotted leaf paths, recursing only
305
551
  * through plain objects. Bounded on two axes (T-33-06, denial-of-service):
@@ -345,7 +591,17 @@ function flattenProfileDocument(doc) {
345
591
  * a DISPOSITION over the shared shape predicates, never as a parallel
346
592
  * regex (D-10's "one source of shape truth" requirement).
347
593
  *
348
- * Scalar guard, applied to every key: the value must be a string; its
594
+ * `vault.required` (02-04) is handled BEFORE the scalar guard, because it is
595
+ * the list's only boolean-typed key and the natural JSON an operator writes
596
+ * for it is `true`, not `"true"`. It accepts exactly four inputs — the JSON
597
+ * booleans `true`/`false` and the trimmed strings `'true'`/`'false'` — and
598
+ * carries forward the canonical `'true'`/`'false'` string, so `contributes`
599
+ * stays a `Record<string, string>`. Everything else is rejected, which means
600
+ * a profile cannot smuggle an arbitrary value into a build-gate policy: a
601
+ * typo like `"yes"` is dropped with a bounded warning and the gate stays at
602
+ * its `false` default rather than silently becoming truthy.
603
+ *
604
+ * Scalar guard, applied to every other key: the value must be a string; its
349
605
  * trimmed form must be non-empty; the UNTRIMMED value must carry no ASCII
350
606
  * control character; the TRIMMED value must carry no whitespace (so
351
607
  * leading/trailing padding — e.g. `' azure '` — is tolerated, matching
@@ -388,9 +644,37 @@ function flattenProfileDocument(doc) {
388
644
  */
389
645
  function profileValueDisposition(key, value) {
390
646
  try {
647
+ // 02-04: the one boolean-typed allowlist member, disposed of before the
648
+ // string-only scalar guard below would reject its natural JSON form.
649
+ if (key === exports.VAULT_REQUIRED_CONFIG_KEY) {
650
+ if (value === true || value === false)
651
+ return { ok: true, value: String(value) };
652
+ if (typeof value === 'string') {
653
+ const t = value.trim();
654
+ if (t === 'true' || t === 'false')
655
+ return { ok: true, value: t };
656
+ }
657
+ return { ok: false, reason: 'value must be the boolean true or false' };
658
+ }
659
+ // 13-03 (D-06, D-09): the mode accepts only its two literals, trimmed.
660
+ // Anything else is dropped through the existing warning / rejected[]
661
+ // path, and the mode then resolves to the default. The reason names the
662
+ // allowed values and never echoes the rejected one.
663
+ if (key === VAULT_MODE_CONFIG_KEY) {
664
+ if (isVaultMode(value))
665
+ return { ok: true, value: value.trim() };
666
+ return { ok: false, reason: 'value must be practices or standards' };
667
+ }
391
668
  if (typeof value !== 'string') {
392
669
  return { ok: false, reason: 'value must be a string' };
393
670
  }
671
+ // PBI 74316: exactly "" for the shared repo is an explicit opt-out of the
672
+ // shared tier — the same decision an empty `vault.fallback_repo` in project
673
+ // config already records (see `resolveSharedCoordinates`). Whitespace is
674
+ // not "", so it still falls through to the blank rejection below.
675
+ if (key === 'vault.fallback_repo' && value === '') {
676
+ return { ok: true, value: '' };
677
+ }
394
678
  const trimmed = value.trim();
395
679
  if (trimmed === '') {
396
680
  return { ok: false, reason: 'value must not be blank' };
@@ -462,10 +746,19 @@ function profileValueDisposition(key, value) {
462
746
  * explicitly (including `null`, to simulate "no global root found")
463
747
  * overrides the production heuristic for that one call.
464
748
  *
749
+ * FORK-ONLY (PBI 74316) — the embedded profile. Where upstream resolves
750
+ * nothing, this fork resolves `embeddedOrgProfilePath()`: an absent key, a
751
+ * blank or non-string pointer, and a configured package that cannot be found
752
+ * all yield the embedded document (source `embedded`). Only the explicit
753
+ * `ORG_PROFILE_NONE` pointer yields the fully-absent shape, so a project can
754
+ * still opt out. A configured package that DOES resolve always wins. The
755
+ * `_embeddedPath` option is the test seam for that document: a path replaces
756
+ * it, `null` simulates a tree that ships none; without it the
757
+ * `EMBEDDED_ORG_PROFILE_ENV` override applies (`effectiveEmbeddedOrgProfilePath`).
758
+ *
465
759
  * Never throws (D-09): any fault anywhere in this function degrades to the
466
- * fully-absent shape plus one bounded warning, exactly as if no profile
467
- * were configured. The document is read only via `fs.readFileSync` +
468
- * `JSON.parse`, never `require` (D-08).
760
+ * fully-absent shape plus one bounded warning. The document is read only via
761
+ * `fs.readFileSync` + `JSON.parse`, never `require` (D-08).
469
762
  */
470
763
  function resolveOrgProfileDocument(cwd, opts) {
471
764
  const absentDocShape = (warnings = []) => ({
@@ -478,36 +771,78 @@ function resolveOrgProfileDocument(cwd, opts) {
478
771
  document: {},
479
772
  warnings,
480
773
  });
774
+ const embeddedDocShape = (configured) => {
775
+ const dataPath = effectiveEmbeddedOrgProfilePath(opts);
776
+ if (dataPath === null) {
777
+ return { ...absentDocShape(), configured };
778
+ }
779
+ let parsed;
780
+ try {
781
+ parsed = JSON.parse(node_fs_1.default.readFileSync(dataPath, 'utf-8'));
782
+ }
783
+ catch {
784
+ return {
785
+ ...absentDocShape([bound('the embedded org profile could not be read — no coordinates contributed.')]),
786
+ configured,
787
+ };
788
+ }
789
+ if (!isObject(parsed)) {
790
+ return {
791
+ ...absentDocShape([bound('the embedded org profile is not a JSON object — no coordinates contributed.')]),
792
+ configured,
793
+ };
794
+ }
795
+ return {
796
+ configured,
797
+ resolved: true,
798
+ packageName: exports.EMBEDDED_ORG_PROFILE_NAME,
799
+ version: readEmbeddedProfileVersion(dataPath),
800
+ source: 'embedded',
801
+ dataPath,
802
+ document: parsed,
803
+ warnings: [],
804
+ };
805
+ };
481
806
  try {
482
807
  // Step 1: read the pointer. An absent key (undefined) or a blank string
483
- // is the fully-absent shape with ZERO warnings and no further
484
- // filesystem access — this is the ORG-02 contract. A present
485
- // non-string value is the one exception: same absent shape, plus
486
- // exactly one bounded warning.
808
+ // resolves the embedded profile with ZERO warnings. A present non-string
809
+ // value does the same, plus exactly one bounded warning. The explicit
810
+ // opt-out is the one pointer that resolves nothing.
487
811
  const rawPointer = cfgGet(cwd, exports.ORG_PROFILE_CONFIG_KEY, undefined);
488
812
  if (rawPointer === undefined) {
489
- return absentDocShape();
813
+ return embeddedDocShape(null);
490
814
  }
491
815
  if (typeof rawPointer !== 'string') {
492
816
  const echoed = sanitizeForWarning(describeUnknown(rawPointer));
493
- return absentDocShape([
494
- bound(`${exports.ORG_PROFILE_CONFIG_KEY} value "${echoed}" is not a string — ignoring, no org profile applied.`),
495
- ]);
817
+ const embedded = embeddedDocShape(null);
818
+ const consequence = embedded.resolved ? 'ignoring it, the embedded org profile applies.' : 'ignoring, no org profile applied.';
819
+ return {
820
+ ...embedded,
821
+ warnings: [bound(`${exports.ORG_PROFILE_CONFIG_KEY} value "${echoed}" is not a string — ${consequence}`), ...embedded.warnings],
822
+ };
496
823
  }
497
824
  const pkgName = rawPointer.trim();
498
825
  if (pkgName === '') {
499
- return absentDocShape();
826
+ return embeddedDocShape(null);
500
827
  }
501
- // Step 2: resolve the package (project, then global).
502
- const globalRoot = opts && opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalPackageRoot();
503
- const hit = resolveProfilePackage(pkgName, cwd, globalRoot);
828
+ if (pkgName.toLowerCase() === exports.ORG_PROFILE_NONE) {
829
+ return { ...absentDocShape(), configured: pkgName };
830
+ }
831
+ // Step 2: resolve the package (project, then every global candidate).
832
+ const globalRoots = globalRootCandidatesFor(opts);
833
+ const hit = resolveProfilePackage(pkgName, cwd, globalRoots);
504
834
  if (!hit) {
505
835
  const echoed = sanitizeForWarning(pkgName);
836
+ const probed = globalRoots.length > 0
837
+ ? `the global npm root(s) ${globalRoots.map((r) => sanitizeForWarning(r)).join(', ')}`
838
+ : 'no locatable global npm root';
839
+ const embedded = embeddedDocShape(pkgName);
840
+ const fallback = embedded.resolved ? 'Falling back to the embedded org profile.' : 'Falling back to built-in defaults.';
506
841
  return {
507
- ...absentDocShape(),
508
- configured: pkgName,
842
+ ...embedded,
509
843
  warnings: [
510
- bound(`org profile "${echoed}" was not found — resolution was attempted from the project and the global npm root and found nothing. Falling back to built-in defaults.`),
844
+ bound(`org profile "${echoed}" was not found — resolution was attempted from the project and ${probed} and found nothing. ${fallback}`),
845
+ ...embedded.warnings,
511
846
  ],
512
847
  };
513
848
  }
@@ -612,6 +947,9 @@ function resolveOrgProfile(cwd, opts) {
612
947
  if (leaf.key === exports.ORG_PROFILE_SEED_SECTION || leaf.key.startsWith(`${exports.ORG_PROFILE_SEED_SECTION}.`)) {
613
948
  continue;
614
949
  }
950
+ if (leaf.key === exports.ORG_PROFILE_INIT_SECTION || leaf.key.startsWith(`${exports.ORG_PROFILE_INIT_SECTION}.`)) {
951
+ continue;
952
+ }
615
953
  if (!exports.ORG_PROFILE_ALLOWLIST.includes(leaf.key)) {
616
954
  ignored.push(leaf.key);
617
955
  if (ignoredWarningCount < PROFILE_IGNORED_WARNING_CAP) {
@@ -694,3 +1032,156 @@ function readProfileValue(cwd, key, opts) {
694
1032
  source: resolution.source,
695
1033
  };
696
1034
  }
1035
+ // ─── resolveVaultRequired (02-04, D-VH-4 / STRICT-01) ────────────────────────
1036
+ /**
1037
+ * The four inputs a boolean policy value may take, and nothing else — the SAME
1038
+ * disposition `profileValueDisposition` above already applies to this key.
1039
+ * Returns `undefined` for anything else, which is not the same as `false`: it
1040
+ * means "this value expresses no policy".
1041
+ *
1042
+ * 02-SECURITY UF-01. The project-config read used to be
1043
+ * `value === true || value === 'true'`, which collapsed every other spelling
1044
+ * to `false` — while `config-set vault.required TRUE|True|1|yes|on|enabled`
1045
+ * all answered `{"updated": true}` and wrote exactly those, so the supported
1046
+ * CLI could leave a build gate OFF while reporting success. UF-01 closed the
1047
+ * READ half (here); 07-14 closed the WRITE half, and `config-set` now refuses
1048
+ * every one of those spellings with a usage error and writes nothing, so this
1049
+ * function's `undefined` arm is reachable only from a hand-edited config.json
1050
+ * or an org profile. The asymmetry made it sharper: the LOWER-trust org
1051
+ * profile validated to four literals and warned, while project config — which
1052
+ * outranks it — did not, so a project `"TRUE"` both failed to arm the gate AND
1053
+ * suppressed a profile value that would have, because `found` rather than
1054
+ * validity decided precedence.
1055
+ *
1056
+ * Moved here from `src/vault.cts` together with the gate's ladder
1057
+ * (13-REVIEW CR-02), so the gate and the report share one reading.
1058
+ */
1059
+ function booleanPolicyValue(value) {
1060
+ if (value === true || value === false)
1061
+ return value;
1062
+ if (typeof value === 'string') {
1063
+ const trimmed = value.trim();
1064
+ if (trimmed === 'true')
1065
+ return true;
1066
+ if (trimmed === 'false')
1067
+ return false;
1068
+ }
1069
+ return undefined;
1070
+ }
1071
+ /**
1072
+ * Resolve the `vault.required` POLICY for `cwd`: project config first, the
1073
+ * org profile second, the registered default (`false`) last. This is the ONE
1074
+ * reader of the policy ladder (13-REVIEW CR-02): the load gate
1075
+ * (`resolveVaultRequiredFlag` in `src/vault.cts`) asks it after its own
1076
+ * `vault.enabled` check, and the `vault org-profile` report asks it for the
1077
+ * `effective` row — so the report and the gate cannot disagree about which
1078
+ * level won for the same inputs. Before CR-02 this function read only the
1079
+ * root `.planning/config.json` and counted only `=== true || === 'true'`,
1080
+ * while the gate read the workstream config first and trimmed, so a project
1081
+ * `" true"` or `"yes"` was reported as overriding a profile `true` that the
1082
+ * gate in fact enforced.
1083
+ *
1084
+ * PROJECT CONFIG OUTRANKS THE PROFILE (T-02-17), so the ordering below is
1085
+ * load-bearing. Level 1 reads RAW config through `resolveConfigKey` with
1086
+ * `config: {}` and `registry: {}`: the workstream config.json, then the root
1087
+ * config.json, each in either dotted form (nested first), with no schema
1088
+ * default. A project value of `false` is a real value and wins. A project
1089
+ * value that is PRESENT BUT UNPARSEABLE (`booleanPolicyValue` answers
1090
+ * `undefined`) does not consume the decision: the profile is consulted as if
1091
+ * the project had said nothing, because a value that expresses no policy must
1092
+ * not outrank one that does. It is returned as `ignoredConfigValue` so the
1093
+ * gate can say so on stderr; this function prints nothing.
1094
+ *
1095
+ * This answers the POLICY, not whether a gate exists: on a project with
1096
+ * `vault.enabled` off no gate renders and `resolveVaultRequiredFlag` answers
1097
+ * `false` whatever this returns.
1098
+ *
1099
+ * Never throws — a failed config read falls through to the profile, and the
1100
+ * profile seam already degrades to a miss on any fault.
1101
+ */
1102
+ function resolveVaultRequired(cwd, opts) {
1103
+ // Level 1 — project config, raw (ws then root, either dotted form).
1104
+ let ignoredConfigValue;
1105
+ let ignored = false;
1106
+ try {
1107
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1108
+ const activation = require('./capability-activation.cjs');
1109
+ const fromConfig = activation.resolveConfigKey(exports.VAULT_REQUIRED_CONFIG_KEY, { config: {}, cwd, registry: {} });
1110
+ if (fromConfig.found) {
1111
+ const decided = booleanPolicyValue(fromConfig.value);
1112
+ if (decided !== undefined)
1113
+ return { required: decided, source: 'config', warnings: [] };
1114
+ ignored = true;
1115
+ ignoredConfigValue = fromConfig.value;
1116
+ }
1117
+ }
1118
+ catch { /* fall through to the profile */ }
1119
+ const withIgnored = (r) => (ignored ? { ...r, ignoredConfigValue } : r);
1120
+ // Level 2 — the org profile. Already narrowed to 'true'/'false' by
1121
+ // profileValueDisposition; booleanPolicyValue agrees with it by construction.
1122
+ const profile = readProfileValue(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, opts);
1123
+ if (profile.value !== undefined) {
1124
+ const decided = booleanPolicyValue(profile.value);
1125
+ if (decided !== undefined)
1126
+ return withIgnored({ required: decided, source: 'profile', warnings: profile.warnings });
1127
+ }
1128
+ // Level 3 — the registered default from capabilities/vault/capability.json.
1129
+ return withIgnored({ required: false, source: 'default', warnings: profile.warnings });
1130
+ }
1131
+ /**
1132
+ * Resolve `vault.mode` for `cwd`: project config, then the org profile, then
1133
+ * the registered default `practices` — the same three levels as
1134
+ * `resolveVaultRequired` above.
1135
+ *
1136
+ * Like `resolveVaultRequired` (the one ladder both the load gate and the
1137
+ * report ask, since 13-REVIEW CR-02), this resolver is the ONE reader of the
1138
+ * mode for every mode-aware surface: the `vault
1139
+ * org-profile` report (13-05), the gate, refusals and status field (13-06)
1140
+ * and the parked practices surfaces (13-07). No surface re-reads the key
1141
+ * from config itself; the negative-grep anti-drift test in
1142
+ * `tests/vault-mode.test.cjs` is what keeps a second reader from appearing.
1143
+ *
1144
+ * Level 1 reads RAW config (D-21): `resolveConfigKey` with `config: {}` and
1145
+ * `registry: {}` reads the workstream config.json and then the root
1146
+ * config.json, each in either dotted form (nested first), with NO schema
1147
+ * default and no federated fallback. The merged config from
1148
+ * `config-loader.cts` is deliberately not used:
1149
+ * `federated-config`'s `_typeMatches` substitutes the schema default for an
1150
+ * out-of-enum value, and its warning is suppressed at load
1151
+ * (`config-loader.cts`), so `"whatever"` would silently read as `practices`.
1152
+ * A project value that is present but not one of the two literals returns
1153
+ * `ok: false` with `vault_mode_invalid` and NEVER falls back (D-08): an
1154
+ * unknown mode must be refused, not treated as practices.
1155
+ *
1156
+ * Level 2 is `readProfileValue`; `profileValueDisposition` has already
1157
+ * narrowed the profile value to one of the two literals or dropped it with a
1158
+ * warning (D-09), so an invalid profile value lands at level 3.
1159
+ *
1160
+ * KNOWN GAP (D-25), recorded rather than hidden: `config-get` reads only the
1161
+ * planning dir's config.json, while level 1 here reads the workstream config
1162
+ * and then the root config. In flat mode the two agree (pinned by the parity
1163
+ * test in `tests/vault-mode.test.cjs`); on a partitioned project a root-only
1164
+ * `vault.mode` can read differently. Filed as a todo; the same gap pre-exists
1165
+ * for `vault.required`.
1166
+ *
1167
+ * Never throws: both seams already degrade to a miss on any fault.
1168
+ */
1169
+ function resolveVaultMode(cwd, opts) {
1170
+ // Level 1 — project config, raw (ws then root, either dotted form).
1171
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1172
+ const activation = require('./capability-activation.cjs');
1173
+ const fromConfig = activation.resolveConfigKey(VAULT_MODE_CONFIG_KEY, { config: {}, cwd, registry: {} });
1174
+ if (fromConfig.found) {
1175
+ if (isVaultMode(fromConfig.value)) {
1176
+ return { ok: true, mode: fromConfig.value.trim(), source: 'config', warnings: [] };
1177
+ }
1178
+ return { ok: false, source: 'config', reason: VAULT_MODE_REASON.INVALID, warnings: [] };
1179
+ }
1180
+ // Level 2 — the org profile, already narrowed by profileValueDisposition.
1181
+ const profile = readProfileValue(cwd, VAULT_MODE_CONFIG_KEY, opts);
1182
+ if (isVaultMode(profile.value)) {
1183
+ return { ok: true, mode: profile.value.trim(), source: 'profile', warnings: profile.warnings };
1184
+ }
1185
+ // Level 3 — the registered default from capabilities/vault/capability.json.
1186
+ return { ok: true, mode: DEFAULT_VAULT_MODE, source: 'default', warnings: profile.warnings };
1187
+ }