@pareto4/pp-core 1.13.0 → 1.18.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 (204) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +31 -12
  3. package/agents/pp-code-fixer.md +0 -11
  4. package/agents/pp-code-reviewer.md +1 -77
  5. package/agents/pp-debug-session-manager.md +9 -6
  6. package/agents/pp-executor.md +12 -29
  7. package/agents/pp-plan-checker.md +4 -4
  8. package/agents/pp-planner.md +34 -20
  9. package/agents/pp-vault-reviewer.md +100 -23
  10. package/agents/pp-verifier.md +1 -3
  11. package/bin/install.js +10 -3
  12. package/commands/pp-execute-phase.md +1 -3
  13. package/commands/pp-ns-manage.md +1 -1
  14. package/commands/pp-ns-review.md +1 -1
  15. package/commands/pp-plan-phase.md +1 -2
  16. package/commands/pp-vault-promote.md +7 -8
  17. package/commands/pp-vault-review.md +6 -5
  18. package/commands/pp-verify-work.md +1 -2
  19. package/gemini-extension.json +1 -1
  20. package/hooks/dist/pp-check-update.js +0 -0
  21. package/hooks/dist/pp-cursor-session-start.js +1 -1
  22. package/hooks/dist/pp-statusline.js +1 -1
  23. package/hooks/dist/pp-update-banner.js +0 -0
  24. package/hooks/pp-check-update.js +0 -0
  25. package/hooks/pp-cursor-session-start.js +1 -1
  26. package/hooks/pp-statusline.js +1 -1
  27. package/hooks/pp-update-banner.js +0 -0
  28. package/package.json +10 -18
  29. package/pareto-skills/_shared/create-project-board.sh +0 -0
  30. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  31. package/pp-core/bin/check-latest-version.cjs +0 -0
  32. package/pp-core/bin/detect-build-commands.sh +2 -2
  33. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  34. package/pp-core/bin/lib/capability-registry.cjs +201 -229
  35. package/pp-core/bin/lib/capability-state.cjs +93 -1
  36. package/pp-core/bin/lib/capability-writer.cjs +3 -3
  37. package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
  38. package/pp-core/bin/lib/clusters.cjs +3 -0
  39. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  40. package/pp-core/bin/lib/commands.cjs +9 -4
  41. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  42. package/pp-core/bin/lib/config-loader.cjs +7 -103
  43. package/pp-core/bin/lib/config-schema.cjs +1 -1
  44. package/pp-core/bin/lib/config.cjs +151 -62
  45. package/pp-core/bin/lib/decisions.cjs +4 -4
  46. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  47. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  48. package/pp-core/bin/lib/git-base-branch.cjs +11 -12
  49. package/pp-core/bin/lib/git-provider.cjs +9 -10
  50. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  51. package/pp-core/bin/lib/init-contract.cjs +620 -738
  52. package/pp-core/bin/lib/init-interactive.cjs +62 -4
  53. package/pp-core/bin/lib/init.cjs +24 -51
  54. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  55. package/pp-core/bin/lib/io.cjs +4 -2
  56. package/pp-core/bin/lib/loop-resolver.cjs +165 -129
  57. package/pp-core/bin/lib/org-profile.cjs +402 -161
  58. package/pp-core/bin/lib/package-identity.cjs +3 -3
  59. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  60. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  61. package/pp-core/bin/lib/phase.cjs +11 -253
  62. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  63. package/pp-core/bin/lib/profile-output.cjs +1 -1
  64. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  65. package/pp-core/bin/lib/state.cjs +11 -85
  66. package/pp-core/bin/lib/template.cjs +1 -1
  67. package/pp-core/bin/lib/vault-command-router.cjs +1682 -477
  68. package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
  69. package/pp-core/bin/lib/vault-index.cjs +656 -0
  70. package/pp-core/bin/lib/vault-seal.cjs +1851 -0
  71. package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
  72. package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1504 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +155 -2778
  75. package/pp-core/bin/lib/vault-update.cjs +10 -59
  76. package/pp-core/bin/lib/vault.cjs +2570 -950
  77. package/pp-core/bin/lib/verify.cjs +0 -11
  78. package/pp-core/bin/portable-timeout.sh +1 -1
  79. package/pp-core/bin/pp-tools.cjs +157 -54
  80. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  81. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  82. package/pp-core/bin/shared/model-catalog.json +2 -2
  83. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  84. package/pp-core/org-profile.json +26 -0
  85. package/pp-core/references/autonomous-best-effort.md +8 -7
  86. package/pp-core/references/loop-hook-dispatch.md +0 -25
  87. package/pp-core/references/planner-gap-closure.md +0 -1
  88. package/pp-core/references/planner-tdd.md +0 -1
  89. package/pp-core/references/planner-vault-standards.md +311 -0
  90. package/pp-core/references/planning-config.md +0 -8
  91. package/pp-core/references/tdd.md +0 -1
  92. package/pp-core/templates/README.md +1 -0
  93. package/pp-core/templates/VAULT-REVIEW.md +56 -0
  94. package/pp-core/templates/phase-prompt.md +2 -6
  95. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  96. package/pp-core/workflows/add-tests.md +55 -4
  97. package/pp-core/workflows/audit-fix.md +63 -2
  98. package/pp-core/workflows/audit-milestone.md +1 -1
  99. package/pp-core/workflows/autonomous.md +85 -11
  100. package/pp-core/workflows/check-todos.md +13 -2
  101. package/pp-core/workflows/code-review-fix.md +59 -3
  102. package/pp-core/workflows/code-review.md +10 -57
  103. package/pp-core/workflows/complete-milestone.md +1 -1
  104. package/pp-core/workflows/debug.md +54 -4
  105. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  106. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  107. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  108. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  109. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  110. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  111. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  112. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  113. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  118. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  122. package/pp-core/workflows/discovery-sprint/vault-context.md +207 -0
  123. package/pp-core/workflows/discovery-sprint.md +7 -3
  124. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  125. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  126. package/pp-core/workflows/discovery-to-build.md +10 -2
  127. package/pp-core/workflows/discuss-phase.md +3 -10
  128. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +327 -0
  129. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  130. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  131. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +78 -0
  132. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  133. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  134. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  135. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +219 -20
  136. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
  137. package/pp-core/workflows/execute-phase.md +152 -128
  138. package/pp-core/workflows/execute-plan.md +54 -6
  139. package/pp-core/workflows/fast.md +53 -4
  140. package/pp-core/workflows/help/modes/full.md +3 -3
  141. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  142. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  143. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  144. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  145. package/pp-core/workflows/init.md +232 -272
  146. package/pp-core/workflows/new-project.md +31 -23
  147. package/pp-core/workflows/plan-phase.md +98 -45
  148. package/pp-core/workflows/quick.md +117 -3
  149. package/pp-core/workflows/secure-phase.md +1 -1
  150. package/pp-core/workflows/ship.md +57 -28
  151. package/pp-core/workflows/undo.md +1 -1
  152. package/pp-core/workflows/update.md +3 -3
  153. package/pp-core/workflows/validate-phase.md +58 -3
  154. package/pp-core/workflows/vault-review.md +161 -23
  155. package/pp-core/workflows/vault-write-back.md +73 -110
  156. package/pp-core/workflows/verify-work.md +12 -14
  157. package/scripts/changeset/cli.cjs +6 -5
  158. package/scripts/changeset/lint.cjs +0 -0
  159. package/scripts/changeset/new.cjs +33 -50
  160. package/scripts/dev-env.cjs +25 -85
  161. package/scripts/gen-capability-registry.cjs +120 -13
  162. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  163. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  164. package/scripts/lint-docs-required.cjs +0 -0
  165. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  166. package/scripts/lint-test-file-count.allowlist.json +3 -19
  167. package/scripts/lint-test-file-count.cjs +5 -6
  168. package/scripts/prompt-injection-scan.sh +19 -17
  169. package/scripts/release-channel.cjs +0 -0
  170. package/scripts/sync-manifest-versions.cjs +5 -4
  171. package/scripts/sync-rulesets.sh +1 -1
  172. package/scripts/update-size-baseline.cjs +30 -274
  173. package/scripts/verify-opengsd-sweep.cjs +3 -5
  174. package/scripts/workflow-size.cjs +9 -61
  175. package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
  176. package/pp-core/bin/lib/install-run.cjs +0 -536
  177. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  178. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  179. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  180. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  181. package/pp-core/bin/lib/practices-review-check.cjs +0 -938
  182. package/pp-core/bin/lib/vault-audit.cjs +0 -2070
  183. package/pp-core/bin/lib/vault-practices.cjs +0 -1990
  184. package/pp-core/bin/lib/vault-review.cjs +0 -2359
  185. package/pp-core/bin/lib/vault-write-back.cjs +0 -877
  186. package/pp-core/references/executor-vault-trace.md +0 -47
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/references/planner-vault-practices.md +0 -112
  189. package/pp-core/templates/practices/README.md +0 -251
  190. package/pp-core/templates/practices/error-handling.md +0 -18
  191. package/pp-core/templates/practices/minimal-comments.md +0 -14
  192. package/pp-core/templates/practices/naming-convention.md +0 -18
  193. package/pp-core/templates/practices/practice-template.md +0 -23
  194. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  195. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  196. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  197. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  198. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  199. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  200. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  201. package/scripts/lib/size-budget.cjs +0 -311
  202. package/scripts/lint-neutrality-names.cjs +0 -694
  203. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  204. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -11,12 +11,14 @@
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
- * no `./vault.cjs`, `./vault-coordinates.cjs`, `./vault-write-back.cjs`,
21
+ * no `./vault.cjs`, `./vault-coordinates.cjs`,
20
22
  * `./io.cjs`, `./shell-command-projection.cjs`. This is load-bearing: in
21
23
  * 33-03 BOTH `src/git-provider.cts` and `src/vault-coordinates.cts` import
22
24
  * THIS module at their own top level. If this module imported either of
@@ -31,7 +33,7 @@
31
33
  * inside `profileValueDisposition`, executed only when a profile value is
32
34
  * actually being validated, never at module load. This is the same
33
35
  * lazy-require seam `src/vault-command-router.cts` already uses for
34
- * `./vault.cjs` and `./vault-write-back.cjs`, and it is safe here precisely
36
+ * `./vault.cjs`, and it is safe here precisely
35
37
  * because `git-provider.cjs` has no top-level dependency on this module —
36
38
  * by the time any value is being validated, that module is either already
37
39
  * in the require cache or loads cleanly on its own. D-10 requires reusing
@@ -48,14 +50,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
48
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
49
51
  };
50
52
  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.TRACE_ACCEPTED_PREDICATE_MAX = exports.TRACE_ACCEPTED_PREDICATES_CAP = exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY = exports.VAULT_REQUIRED_CONFIG_KEY = exports.ORG_PROFILE_DATA_FILE = exports.ORG_PROFILE_CONFIG_KEY = void 0;
53
+ 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;
54
+ exports.embeddedOrgProfilePath = embeddedOrgProfilePath;
55
+ exports.effectiveEmbeddedOrgProfilePath = effectiveEmbeddedOrgProfilePath;
52
56
  exports.resolveGlobalPackageRootCandidates = resolveGlobalPackageRootCandidates;
53
57
  exports.globalRootCandidatesFor = globalRootCandidatesFor;
58
+ exports.probeOrgProfilePackage = probeOrgProfilePackage;
54
59
  exports.resolveOrgProfileDocument = resolveOrgProfileDocument;
55
60
  exports.resolveOrgProfile = resolveOrgProfile;
56
61
  exports.readProfileValue = readProfileValue;
62
+ exports.booleanPolicyValue = booleanPolicyValue;
57
63
  exports.resolveVaultRequired = resolveVaultRequired;
58
- exports.resolveAcceptedTracePredicates = resolveAcceptedTracePredicates;
59
64
  const node_fs_1 = __importDefault(require("node:fs"));
60
65
  const node_path_1 = __importDefault(require("node:path"));
61
66
  // ─── Constants ───────────────────────────────────────────────────────────────
@@ -66,41 +71,113 @@ exports.ORG_PROFILE_CONFIG_KEY = 'org.profile';
66
71
  /** The fixed filename read from the resolved package's own directory — never the
67
72
  * package's `main`/`exports` entry, and never loaded via `require` (D-08). */
68
73
  exports.ORG_PROFILE_DATA_FILE = 'org-profile.json';
69
- /** The dotted key naming the vault load policy, declared boolean (default
70
- * false) by `capabilities/vault/capability.json`. Named once here so neither
71
- * the allowlist, the value guard nor `resolveVaultRequired` restates it. */
72
- exports.VAULT_REQUIRED_CONFIG_KEY = 'vault.required';
73
74
  /**
74
- * The dotted key naming the practice-trace predicate types a READER accepts
75
- * besides pp-core's own (`TRACE_PREDICATE_TYPE` in src/vault-trace.cts). It
76
- * exists so a project that switches engine mid-engagement can keep reading
77
- * the traces its earlier engine wrote: the org profile of that engagement
78
- * lists the earlier engine's predicate URI here. Writers never use it — pp-core
79
- * always writes its own URI — and pp-core itself names no such URI.
75
+ * The `org.profile` value that opts a project out of every org profile, the
76
+ * embedded one included. Compared case-insensitively after trimming — the
77
+ * same spelling `pp-tools init` already accepts as a skip (UAT-13-F3).
78
+ */
79
+ exports.ORG_PROFILE_NONE = 'none';
80
+ /**
81
+ * The name `resolveOrgProfileDocument` reports as `packageName` for this
82
+ * fork's embedded profile. Not a valid npm package name shape for a scoped
83
+ * org profile, so it can never collide with a pointer that resolves.
84
+ *
85
+ * FORK-ONLY (PBI 74316). This fork serves a single organisation, so the
86
+ * profile ships inside pp-core as `pp-core/org-profile.json` instead of as a
87
+ * separate package. It carries coordinates only — never a vault ref: the
88
+ * vault version a project builds on is committed per project in its
89
+ * `.platform-bindings.json` (`client.vaultRef`), so no pp-core release can
90
+ * move it.
80
91
  */
81
- exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY = 'vault.trace.accepted_predicates';
82
- /** At most this many accepted predicate URIs, each at most this long. */
83
- exports.TRACE_ACCEPTED_PREDICATES_CAP = 8;
84
- exports.TRACE_ACCEPTED_PREDICATE_MAX = 200;
92
+ exports.EMBEDDED_ORG_PROFILE_NAME = 'embedded';
93
+ /**
94
+ * Absolute path of the embedded profile document. Resolved against this
95
+ * module's own directory: the compiled file sits at `pp-core/bin/lib/`, and
96
+ * both this repo and every installed tree keep `pp-core/org-profile.json`
97
+ * two levels up.
98
+ */
99
+ function embeddedOrgProfilePath() {
100
+ return node_path_1.default.join(__dirname, '..', '..', exports.ORG_PROFILE_DATA_FILE);
101
+ }
102
+ /**
103
+ * Environment override for the embedded profile, for diagnostics and for test
104
+ * suites that exercise the upstream contract: `none` (or `off`, any case)
105
+ * disables it, any other non-blank value is a path to the document to use
106
+ * instead. Unset or blank leaves the shipped document in place. A project
107
+ * opts out through `org.profile: "none"`, never through this variable.
108
+ */
109
+ exports.EMBEDDED_ORG_PROFILE_ENV = 'PP_EMBEDDED_ORG_PROFILE';
110
+ /**
111
+ * The embedded document path in force for this process: `opts._embeddedPath`
112
+ * (test seam) when given, else the `EMBEDDED_ORG_PROFILE_ENV` override, else
113
+ * `embeddedOrgProfilePath()`. `null` means no embedded profile applies.
114
+ */
115
+ function effectiveEmbeddedOrgProfilePath(opts) {
116
+ if (opts && opts._embeddedPath !== undefined)
117
+ return opts._embeddedPath;
118
+ const override = process.env[exports.EMBEDDED_ORG_PROFILE_ENV];
119
+ if (typeof override === 'string' && override.trim() !== '') {
120
+ const trimmed = override.trim();
121
+ return ['none', 'off'].includes(trimmed.toLowerCase()) ? null : trimmed;
122
+ }
123
+ return embeddedOrgProfilePath();
124
+ }
85
125
  /**
86
- * D-06's fixed allowlist: a profile may contribute exactly these seven dotted
87
- * config keys. Entry order is not contractual; membership is. Anything else
88
- * present in a profile document is dropped with a bounded warning.
89
- *
90
- * FIVE COORDINATES AND ONE POLICY. The first five answer where to fetch from.
91
- * `vault.required` answers whether the vault load is mandatory, so one
92
- * org-profile package can turn the load gate on across a fleet without a
93
- * per-repository edit. That is this list's sharpest widening: an `npm update`
94
- * of the profile package can move a build gate without a review in the
95
- * consuming repository. It is bounded three ways: project config outranks the
96
- * profile, so any project can opt out explicitly; the profile can only raise
97
- * the gate from the built-in default `false` (a gate turning on fails
98
- * closed); and the value guard accepts only the two boolean literals.
99
- * `vault.enabled` (arming the capability) stays off the list.
100
- *
101
- * `vault.trace.accepted_predicates` is READ-ONLY widening: it lets the trace
102
- * readers accept another engine's predicate URI (a list of https URIs). It
103
- * changes no writer and no gate polarity.
126
+ * The version reported for the embedded profile: the pp-core release that
127
+ * ships it. Probes the `VERSION` beside the document (the installer writes
128
+ * `pp-core/VERSION` into every installed tree), then the repo root's. Returns
129
+ * `null` when neither is readable and semver-shaped.
130
+ */
131
+ function readEmbeddedProfileVersion(dataPath) {
132
+ const dir = node_path_1.default.dirname(dataPath);
133
+ for (const candidate of [node_path_1.default.join(dir, 'VERSION'), node_path_1.default.join(dir, '..', 'VERSION')]) {
134
+ try {
135
+ const raw = node_fs_1.default.readFileSync(candidate, 'utf-8').trim();
136
+ if (/^\d+\.\d+\.\d+/.test(raw))
137
+ return raw;
138
+ }
139
+ catch {
140
+ // try the next candidate
141
+ }
142
+ }
143
+ return null;
144
+ }
145
+ /** The dotted key naming the vault load policy (02-04, GATE-05 / STRICT-01).
146
+ * Declared in `capabilities/vault/capability.json`'s federated config block,
147
+ * boolean, default `false`. Named here so neither the allowlist, the value
148
+ * guard nor `resolveVaultRequired` hand-restates the string. */
149
+ exports.VAULT_REQUIRED_CONFIG_KEY = 'vault.required';
150
+ /**
151
+ * D-06's fixed allowlist: a profile may contribute exactly these six dotted
152
+ * config keys (five coordinates and one policy key). Entry order is not
153
+ * contractual; membership is. Anything else present in a profile document is
154
+ * dropped with a bounded warning, except the retired keys below.
155
+ *
156
+ * FIVE COORDINATES AND ONE POLICY (02-04, D-VH-4 / STRICT-01). The first five
157
+ * members answer *where to fetch from* — a provider, three shared-vault
158
+ * coordinates and a platform host. `vault.required` answers something
159
+ * categorically different: *whether the fetch is mandatory*. It is the first
160
+ * POLICY member of this list, and it is here deliberately, so one org-profile
161
+ * package can turn the load gate on across a whole fleet without a per-repo
162
+ * edit.
163
+ *
164
+ * That is also this list's sharpest widening, and it is recorded rather than
165
+ * glossed (threat T-02-17): an `npm update` of the profile package can now
166
+ * move the value of a build gate without a code review in the consuming repo.
167
+ * Three things bound it. First, precedence — project config outranks the
168
+ * profile for every member, so any project can opt out explicitly, and
169
+ * `tests/org-profile.test.cjs` pins that direction with a non-vacuity partner
170
+ * rather than assuming it. Second, direction — the profile can only ever make
171
+ * the gate MORE strict from the built-in default of `false`, and a gate
172
+ * turning on is fail-closed. Third, the value guard below accepts only the two
173
+ * boolean literals for this key, so a profile cannot smuggle an arbitrary
174
+ * string into the policy.
175
+ *
176
+ * The warning in the block beneath this one is NOT retracted: it is still
177
+ * exactly right about the seed section, which stays off this list. A key that
178
+ * layers at runtime is a key an `npm update` can silently move — the
179
+ * difference is that for `vault.required` that consequence was weighed and
180
+ * accepted under a locked decision, and for `bindings.*` it was not.
104
181
  */
105
182
  exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
106
183
  'vault.provider',
@@ -108,9 +185,15 @@ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
108
185
  'vault.fallback_ref',
109
186
  'vault.fallback_host',
110
187
  'platform.host',
111
- exports.VAULT_REQUIRED_CONFIG_KEY,
112
- exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY,
188
+ 'vault.required',
113
189
  ]);
190
+ /**
191
+ * Keys a profile written for an older pp-core may still carry, which this
192
+ * engine no longer reads (FORK-ONLY: the vault once had a second, practices
193
+ * mode). They are dropped without a warning: a profile that still says
194
+ * `vault.mode` needs no attention, because the standards vault is the only vault.
195
+ */
196
+ const RETIRED_PROFILE_KEYS = new Set(['vault.mode', 'vault.practices_enabled']);
114
197
  /**
115
198
  * The single top-level key naming the Phase 34 init-only seed section.
116
199
  *
@@ -127,6 +210,15 @@ exports.ORG_PROFILE_ALLOWLIST = Object.freeze([
127
210
  * directly from the package.
128
211
  */
129
212
  exports.ORG_PROFILE_SEED_SECTION = 'bindings';
213
+ /**
214
+ * PBI 74299: the top-level key naming the init-answers section — fixed answers
215
+ * for `pp-tools init` questions (`client_name`, `provider`, `visibility`) and
216
+ * `derive_slugs`. Read ONLY by `src/init-contract.cts` through
217
+ * `resolveOrgProfileDocument`; like the seed section it is deliberately absent
218
+ * from `ORG_PROFILE_ALLOWLIST`, because it answers questions once at init and
219
+ * never layers a runtime config key.
220
+ */
221
+ exports.ORG_PROFILE_INIT_SECTION = 'init';
130
222
  /**
131
223
  * The five dotted leaf keys the seed section may carry, each prefixed with
132
224
  * `ORG_PROFILE_SEED_SECTION`. Membership is contractual; entry ORDER is not.
@@ -257,39 +349,66 @@ function describeUnknown(value) {
257
349
  return Object.prototype.toString.call(value);
258
350
  }
259
351
  }
260
- // ─── Global package roots ────────────────────────────────────────────────────
352
+ // ─── resolveGlobalPackageRoot ────────────────────────────────────────────────
261
353
  /**
262
- * D-03's "global" half of the two-path resolution ladder, as a pure heuristic
263
- * with NO subprocess spawn. Returns an ORDERED list of existing candidate
264
- * roots (a single root silently missed Homebrew layouts):
354
+ * D-03's "global" half of the two-path resolution ladder, as a pure
355
+ * heuristic with NO subprocess spawn (this repo avoids spawning a
356
+ * subprocess for a computation that does not strictly require one).
357
+ * Returns an ORDERED list of existing candidate roots (init-friction F1 —
358
+ * the single-root version silently missed Homebrew layouts):
265
359
  *
266
360
  * 1. `NPM_CONFIG_PREFIX` / `npm_config_prefix`, when set to a non-blank
267
361
  * string: `<prefix>/node_modules` on `win32`, `<prefix>/lib/node_modules`
268
362
  * otherwise.
269
- * 2. Every PATH directory containing a `node` entry, mapped through the same
270
- * `<bin>` → `<global tree>` rule — WITHOUT resolving symlinks. This is what
271
- * finds Homebrew: `process.execPath` resolves `/opt/homebrew/bin/node` into
272
- * the Cellar, whose `lib/node_modules` exists but holds only npm, while the
273
- * real global tree lives at `/opt/homebrew/lib/node_modules` beside the
274
- * PATH symlink.
275
- * 3. Derived from `process.execPath` — still correct for nvm/Volta layouts.
363
+ * 2. Every PATH directory containing a `node` entry, mapped through the
364
+ * same `<bin>` → `<global tree>` rule — WITHOUT resolving symlinks.
365
+ * This is what fixes Homebrew: `process.execPath` resolves
366
+ * `/opt/homebrew/bin/node` into the Cellar, whose `lib/node_modules`
367
+ * exists but holds only npm itself, while the real global tree lives at
368
+ * `/opt/homebrew/lib/node_modules` beside the PATH symlink.
369
+ * 3. Derived from `process.execPath` (the pre-existing heuristic) — still
370
+ * correct for nvm/Volta layouts.
276
371
  *
277
372
  * Candidates are deduped and filtered to `fs.existsSync`; resolution tries
278
- * each in order (first hit wins), so an existing-but-empty earlier root cannot
279
- * mask a later root that holds the package.
373
+ * each in order (first hit wins), so a wrong-but-existing root earlier in
374
+ * the list cannot mask a later root that actually holds the package.
375
+ *
376
+ * Accepted residual: a layout that honours none of the three sources will
377
+ * still not resolve a globally-installed profile — this degrades per D-09
378
+ * (falls through to the no-profile behaviour with a warning) rather than
379
+ * failing.
280
380
  *
281
- * Detection (the `/pp-init` org-profile hint and its invariant check) passes
282
- * `explicitPrefixOnly: true`: with `NPM_CONFIG_PREFIX` set, only the prefix
283
- * root is scanned, because a hint must never come from a tree the operator did
284
- * not point at. Detection roots are therefore a subset of resolution roots.
381
+ * pp-core's own install directory is deliberately NOT a candidate here:
382
+ * the AI-runtime install is a bare directory copy with no `package.json`
383
+ * and no `node_modules` of its own, so resolving relative to it would
384
+ * never find a sibling package.
285
385
  *
286
- * pp-core's own install directory is deliberately NOT a candidate: the
287
- * runtime install is a bare directory copy with no `node_modules` of its own.
386
+ * TWO READERS, ONE BUILDER, ONE FLAG (UAT-13-F2). RESOLUTION — the
387
+ * answer-time probe (`probeOrgProfilePackage`) and profile load
388
+ * (`resolveOrgProfileDocument`) — walks the full list above. DETECTION —
389
+ * `buildInitQuestions`' org-profile hint and `verifyInitInvariants`'
390
+ * INIT_QUESTIONS_ORG_PROFILE_UNASKED check in `src/init-contract.cts` —
391
+ * passes `explicitPrefixOnly: true`: when `NPM_CONFIG_PREFIX` /
392
+ * `npm_config_prefix` is set, only the prefix-derived root is scanned.
393
+ * An explicit prefix is the operator's — or a test harness's — statement
394
+ * of where the global tree is, and a hint or a violation must never be
395
+ * produced from a tree the operator did not point at. Detection roots are
396
+ * therefore a SUBSET of resolution roots: anything detected resolves by
397
+ * name, and a package that sits only on a PATH-derived root while an
398
+ * explicit prefix is set is still selectable by typing its name. Without a
399
+ * prefix both readers see the same list, which is what lets detection find
400
+ * a Homebrew layout at all.
288
401
  */
402
+ /** Bounds the PATH scan below — a DoS guard in the same convention as
403
+ * `PROFILE_FLATTEN_LEAF_CAP` (a pathological PATH cannot make resolution
404
+ * scan unboundedly many directories). */
289
405
  const GLOBAL_ROOT_PATH_SCAN_CAP = 64;
290
- /** npm's own `<bin dir>` → `<global node_modules>` mapping. */
406
+ /** The `<bin dir>` → `<global node_modules>` mapping npm itself uses:
407
+ * `<bin>/node_modules` on win32, `<bin>/../lib/node_modules` elsewhere. */
291
408
  function globalRootFromBinDir(binDir) {
292
- return process.platform === 'win32' ? node_path_1.default.join(binDir, 'node_modules') : node_path_1.default.resolve(binDir, '..', 'lib', 'node_modules');
409
+ return process.platform === 'win32'
410
+ ? node_path_1.default.join(binDir, 'node_modules')
411
+ : node_path_1.default.resolve(binDir, '..', 'lib', 'node_modules');
293
412
  }
294
413
  function resolveGlobalPackageRootCandidates(opts = {}) {
295
414
  const candidates = [];
@@ -306,16 +425,25 @@ function resolveGlobalPackageRootCandidates(opts = {}) {
306
425
  }
307
426
  });
308
427
  try {
428
+ // 1. Explicit prefix always wins — same rule as before.
309
429
  const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
310
430
  if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
311
431
  const prefix = prefixEnv.trim();
312
432
  push(process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules'));
313
- // An explicit prefix is authoritative for detection; one that points
314
- // nowhere yields [] — never a fall-through to PATH.
433
+ // Detection mode (see the header): an explicit prefix is authoritative.
434
+ // A prefix that points nowhere yields [] — still the authoritative
435
+ // answer ("no global tree"), never a fall-through to PATH.
315
436
  if (opts.explicitPrefixOnly === true)
316
437
  return existing();
317
438
  }
318
- const pathDirs = (process.env.PATH ?? '')
439
+ // 2. PATH scan for the directory holding the `node` entry AS INVOKED
440
+ // (symlink not resolved). `process.execPath` resolves symlinks, so a
441
+ // Homebrew layout (`/opt/homebrew/bin/node` → Cellar) lands in the
442
+ // Cellar's private `lib/node_modules` (npm only) and misses the real
443
+ // global tree at `/opt/homebrew/lib/node_modules`. The un-resolved
444
+ // PATH entry is what npm's own prefix derivation is anchored to.
445
+ const pathEnv = process.env.PATH ?? '';
446
+ const pathDirs = pathEnv
319
447
  .split(node_path_1.default.delimiter)
320
448
  .filter((d) => d.trim() !== '')
321
449
  .slice(0, GLOBAL_ROOT_PATH_SCAN_CAP);
@@ -335,6 +463,9 @@ function resolveGlobalPackageRootCandidates(opts = {}) {
335
463
  }
336
464
  }
337
465
  }
466
+ // 3. execPath-derived (the pre-existing heuristic) — still the correct
467
+ // root for nvm/Volta-style layouts where the global tree sits beside
468
+ // the real binary.
338
469
  push(globalRootFromBinDir(node_path_1.default.dirname(process.execPath)));
339
470
  }
340
471
  catch {
@@ -342,23 +473,12 @@ function resolveGlobalPackageRootCandidates(opts = {}) {
342
473
  }
343
474
  return existing();
344
475
  }
345
- /**
346
- * Map the `_globalRoot` test seam (a single root, or `null` for "no global
347
- * root found") onto the candidate-list shape; when the seam is absent, run the
348
- * production heuristic. The seam wins over `explicitPrefixOnly`.
349
- */
350
- function globalRootCandidatesFor(opts) {
351
- if (opts && opts._globalRoot !== undefined) {
352
- return opts._globalRoot === null ? [] : [opts._globalRoot];
353
- }
354
- return resolveGlobalPackageRootCandidates({ explicitPrefixOnly: opts?.explicitPrefixOnly === true });
355
- }
356
476
  // ─── resolveProfilePackage ───────────────────────────────────────────────────
357
477
  /**
358
478
  * D-03's two-path resolution ladder. Attempt `require.resolve(pkgName +
359
479
  * '/package.json', { paths: [cwd] })` first and report `source: 'project'`;
360
- * on failure attempt the same with `paths: [root]` for each global candidate
361
- * root in order and report `source: 'global'`; otherwise `null`. The
480
+ * on failure attempt the same with `paths: [globalRoot]` when a global root
481
+ * was found and report `source: 'global'`; otherwise `null`. The
362
482
  * project-local hit always wins when both would resolve — the global
363
483
  * attempt is only ever reached when the project attempt threw.
364
484
  *
@@ -383,6 +503,37 @@ function resolveProfilePackage(pkgName, cwd, globalRoots) {
383
503
  }
384
504
  return null;
385
505
  }
506
+ /** Map the `_globalRoot` test seam (a single root, or `null` for "no global
507
+ * root found") onto the candidate-list shape resolution now uses; when the
508
+ * seam is absent, run the production heuristic. The seam wins over
509
+ * `explicitPrefixOnly`, which only shapes the production heuristic —
510
+ * detection passes it, resolution does not (see the builder's header). */
511
+ function globalRootCandidatesFor(opts) {
512
+ if (opts && opts._globalRoot !== undefined) {
513
+ return opts._globalRoot === null ? [] : [opts._globalRoot];
514
+ }
515
+ return resolveGlobalPackageRootCandidates({ explicitPrefixOnly: opts?.explicitPrefixOnly === true });
516
+ }
517
+ /**
518
+ * Answer-time probe for `/pp-init`'s `org_profile` question (init-friction
519
+ * F2): does `pkgName` resolve from the project or any global root right
520
+ * now? Returns the probed global roots so a rejection message can name
521
+ * them. Never throws (D-09) — any fault reports `resolved: false` with the
522
+ * roots collected so far.
523
+ */
524
+ function probeOrgProfilePackage(pkgName, cwd, opts) {
525
+ let probedGlobalRoots = [];
526
+ try {
527
+ probedGlobalRoots = globalRootCandidatesFor(opts);
528
+ const trimmed = typeof pkgName === 'string' ? pkgName.trim() : '';
529
+ if (trimmed === '')
530
+ return { resolved: false, probedGlobalRoots };
531
+ return { resolved: resolveProfilePackage(trimmed, cwd, probedGlobalRoots) !== null, probedGlobalRoots };
532
+ }
533
+ catch {
534
+ return { resolved: false, probedGlobalRoots };
535
+ }
536
+ }
386
537
  /**
387
538
  * Flatten a parsed profile document to dotted leaf paths, recursing only
388
539
  * through plain objects. Bounded on two axes (T-33-06, denial-of-service):
@@ -428,7 +579,17 @@ function flattenProfileDocument(doc) {
428
579
  * a DISPOSITION over the shared shape predicates, never as a parallel
429
580
  * regex (D-10's "one source of shape truth" requirement).
430
581
  *
431
- * Scalar guard, applied to every key: the value must be a string; its
582
+ * `vault.required` (02-04) is handled BEFORE the scalar guard, because it is
583
+ * the list's only boolean-typed key and the natural JSON an operator writes
584
+ * for it is `true`, not `"true"`. It accepts exactly four inputs — the JSON
585
+ * booleans `true`/`false` and the trimmed strings `'true'`/`'false'` — and
586
+ * carries forward the canonical `'true'`/`'false'` string, so `contributes`
587
+ * stays a `Record<string, string>`. Everything else is rejected, which means
588
+ * a profile cannot smuggle an arbitrary value into a build-gate policy: a
589
+ * typo like `"yes"` is dropped with a bounded warning and the gate stays at
590
+ * its `false` default rather than silently becoming truthy.
591
+ *
592
+ * Scalar guard, applied to every other key: the value must be a string; its
432
593
  * trimmed form must be non-empty; the UNTRIMMED value must carry no ASCII
433
594
  * control character; the TRIMMED value must carry no whitespace (so
434
595
  * leading/trailing padding — e.g. `' azure '` — is tolerated, matching
@@ -471,9 +632,8 @@ function flattenProfileDocument(doc) {
471
632
  */
472
633
  function profileValueDisposition(key, value) {
473
634
  try {
474
- // The one boolean-typed member, disposed of before the string-only scalar
475
- // guard below would reject its natural JSON form. Carried as the canonical
476
- // string "true" / "false".
635
+ // 02-04: the one boolean-typed allowlist member, disposed of before the
636
+ // string-only scalar guard below would reject its natural JSON form.
477
637
  if (key === exports.VAULT_REQUIRED_CONFIG_KEY) {
478
638
  if (value === true || value === false)
479
639
  return { ok: true, value: String(value) };
@@ -484,35 +644,16 @@ function profileValueDisposition(key, value) {
484
644
  }
485
645
  return { ok: false, reason: 'value must be the boolean true or false' };
486
646
  }
487
- // The one list-typed member. Carried forward as a canonical JSON array
488
- // string, so `contributes` stays a string map.
489
- if (key === exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY) {
490
- const list = Array.isArray(value) ? value : null;
491
- if (list === null || list.length === 0) {
492
- return { ok: false, reason: 'value must be a non-empty list of https URIs' };
493
- }
494
- if (list.length > exports.TRACE_ACCEPTED_PREDICATES_CAP) {
495
- return { ok: false, reason: `value lists more than ${exports.TRACE_ACCEPTED_PREDICATES_CAP} URIs` };
496
- }
497
- const kept = [];
498
- for (const item of list) {
499
- if (typeof item !== 'string')
500
- return { ok: false, reason: 'every entry must be a string' };
501
- const t = item.trim();
502
- if (t.length > exports.TRACE_ACCEPTED_PREDICATE_MAX) {
503
- return { ok: false, reason: `an entry is longer than ${exports.TRACE_ACCEPTED_PREDICATE_MAX} characters` };
504
- }
505
- if (!/^https:\/\/[^\s\x00-\x1f\x7f"'<>`\\]+$/.test(t)) {
506
- return { ok: false, reason: 'every entry must be an https URI with no whitespace or quotes' };
507
- }
508
- if (!kept.includes(t))
509
- kept.push(t);
510
- }
511
- return { ok: true, value: JSON.stringify(kept) };
512
- }
513
647
  if (typeof value !== 'string') {
514
648
  return { ok: false, reason: 'value must be a string' };
515
649
  }
650
+ // PBI 74316: exactly "" for the shared repo is an explicit opt-out of the
651
+ // shared tier — the same decision an empty `vault.fallback_repo` in project
652
+ // config already records (see `resolveSharedCoordinates`). Whitespace is
653
+ // not "", so it still falls through to the blank rejection below.
654
+ if (key === 'vault.fallback_repo' && value === '') {
655
+ return { ok: true, value: '' };
656
+ }
516
657
  const trimmed = value.trim();
517
658
  if (trimmed === '') {
518
659
  return { ok: false, reason: 'value must not be blank' };
@@ -580,14 +721,23 @@ function profileValueDisposition(key, value) {
580
721
  * The `_globalRoot` option is the repo's `_`-prefixed test seam convention
581
722
  * (as used by `_vault` / `_exec` / `_writeBack` in
582
723
  * `src/vault-command-router.cts`); production callers omit it and the
583
- * module walks `resolveGlobalPackageRootCandidates()`. Passing `_globalRoot`
724
+ * module calls `resolveGlobalPackageRoot()`. Passing `_globalRoot`
584
725
  * explicitly (including `null`, to simulate "no global root found")
585
726
  * overrides the production heuristic for that one call.
586
727
  *
728
+ * FORK-ONLY (PBI 74316) — the embedded profile. Where upstream resolves
729
+ * nothing, this fork resolves `embeddedOrgProfilePath()`: an absent key, a
730
+ * blank or non-string pointer, and a configured package that cannot be found
731
+ * all yield the embedded document (source `embedded`). Only the explicit
732
+ * `ORG_PROFILE_NONE` pointer yields the fully-absent shape, so a project can
733
+ * still opt out. A configured package that DOES resolve always wins. The
734
+ * `_embeddedPath` option is the test seam for that document: a path replaces
735
+ * it, `null` simulates a tree that ships none; without it the
736
+ * `EMBEDDED_ORG_PROFILE_ENV` override applies (`effectiveEmbeddedOrgProfilePath`).
737
+ *
587
738
  * Never throws (D-09): any fault anywhere in this function degrades to the
588
- * fully-absent shape plus one bounded warning, exactly as if no profile
589
- * were configured. The document is read only via `fs.readFileSync` +
590
- * `JSON.parse`, never `require` (D-08).
739
+ * fully-absent shape plus one bounded warning. The document is read only via
740
+ * `fs.readFileSync` + `JSON.parse`, never `require` (D-08).
591
741
  */
592
742
  function resolveOrgProfileDocument(cwd, opts) {
593
743
  const absentDocShape = (warnings = []) => ({
@@ -600,25 +750,62 @@ function resolveOrgProfileDocument(cwd, opts) {
600
750
  document: {},
601
751
  warnings,
602
752
  });
753
+ const embeddedDocShape = (configured) => {
754
+ const dataPath = effectiveEmbeddedOrgProfilePath(opts);
755
+ if (dataPath === null) {
756
+ return { ...absentDocShape(), configured };
757
+ }
758
+ let parsed;
759
+ try {
760
+ parsed = JSON.parse(node_fs_1.default.readFileSync(dataPath, 'utf-8'));
761
+ }
762
+ catch {
763
+ return {
764
+ ...absentDocShape([bound('the embedded org profile could not be read — no coordinates contributed.')]),
765
+ configured,
766
+ };
767
+ }
768
+ if (!isObject(parsed)) {
769
+ return {
770
+ ...absentDocShape([bound('the embedded org profile is not a JSON object — no coordinates contributed.')]),
771
+ configured,
772
+ };
773
+ }
774
+ return {
775
+ configured,
776
+ resolved: true,
777
+ packageName: exports.EMBEDDED_ORG_PROFILE_NAME,
778
+ version: readEmbeddedProfileVersion(dataPath),
779
+ source: 'embedded',
780
+ dataPath,
781
+ document: parsed,
782
+ warnings: [],
783
+ };
784
+ };
603
785
  try {
604
786
  // Step 1: read the pointer. An absent key (undefined) or a blank string
605
- // is the fully-absent shape with ZERO warnings and no further
606
- // filesystem access — this is the ORG-02 contract. A present
607
- // non-string value is the one exception: same absent shape, plus
608
- // exactly one bounded warning.
787
+ // resolves the embedded profile with ZERO warnings. A present non-string
788
+ // value does the same, plus exactly one bounded warning. The explicit
789
+ // opt-out is the one pointer that resolves nothing.
609
790
  const rawPointer = cfgGet(cwd, exports.ORG_PROFILE_CONFIG_KEY, undefined);
610
791
  if (rawPointer === undefined) {
611
- return absentDocShape();
792
+ return embeddedDocShape(null);
612
793
  }
613
794
  if (typeof rawPointer !== 'string') {
614
795
  const echoed = sanitizeForWarning(describeUnknown(rawPointer));
615
- return absentDocShape([
616
- bound(`${exports.ORG_PROFILE_CONFIG_KEY} value "${echoed}" is not a string — ignoring, no org profile applied.`),
617
- ]);
796
+ const embedded = embeddedDocShape(null);
797
+ const consequence = embedded.resolved ? 'ignoring it, the embedded org profile applies.' : 'ignoring, no org profile applied.';
798
+ return {
799
+ ...embedded,
800
+ warnings: [bound(`${exports.ORG_PROFILE_CONFIG_KEY} value "${echoed}" is not a string — ${consequence}`), ...embedded.warnings],
801
+ };
618
802
  }
619
803
  const pkgName = rawPointer.trim();
620
804
  if (pkgName === '') {
621
- return absentDocShape();
805
+ return embeddedDocShape(null);
806
+ }
807
+ if (pkgName.toLowerCase() === exports.ORG_PROFILE_NONE) {
808
+ return { ...absentDocShape(), configured: pkgName };
622
809
  }
623
810
  // Step 2: resolve the package (project, then every global candidate).
624
811
  const globalRoots = globalRootCandidatesFor(opts);
@@ -628,11 +815,13 @@ function resolveOrgProfileDocument(cwd, opts) {
628
815
  const probed = globalRoots.length > 0
629
816
  ? `the global npm root(s) ${globalRoots.map((r) => sanitizeForWarning(r)).join(', ')}`
630
817
  : 'no locatable global npm root';
818
+ const embedded = embeddedDocShape(pkgName);
819
+ const fallback = embedded.resolved ? 'Falling back to the embedded org profile.' : 'Falling back to built-in defaults.';
631
820
  return {
632
- ...absentDocShape(),
633
- configured: pkgName,
821
+ ...embedded,
634
822
  warnings: [
635
- bound(`org profile "${echoed}" was not found — resolution was attempted from the project and ${probed} and found nothing. Falling back to built-in defaults.`),
823
+ bound(`org profile "${echoed}" was not found — resolution was attempted from the project and ${probed} and found nothing. ${fallback}`),
824
+ ...embedded.warnings,
636
825
  ],
637
826
  };
638
827
  }
@@ -737,6 +926,11 @@ function resolveOrgProfile(cwd, opts) {
737
926
  if (leaf.key === exports.ORG_PROFILE_SEED_SECTION || leaf.key.startsWith(`${exports.ORG_PROFILE_SEED_SECTION}.`)) {
738
927
  continue;
739
928
  }
929
+ if (leaf.key === exports.ORG_PROFILE_INIT_SECTION || leaf.key.startsWith(`${exports.ORG_PROFILE_INIT_SECTION}.`)) {
930
+ continue;
931
+ }
932
+ if (RETIRED_PROFILE_KEYS.has(leaf.key))
933
+ continue;
740
934
  if (!exports.ORG_PROFILE_ALLOWLIST.includes(leaf.key)) {
741
935
  ignored.push(leaf.key);
742
936
  if (ignoredWarningCount < PROFILE_IGNORED_WARNING_CAP) {
@@ -819,52 +1013,99 @@ function readProfileValue(cwd, key, opts) {
819
1013
  source: resolution.source,
820
1014
  };
821
1015
  }
1016
+ // ─── resolveVaultRequired (02-04, D-VH-4 / STRICT-01) ────────────────────────
822
1017
  /**
823
- * The ONE ladder for `vault.required`, which `computeVaultVerdict` (the
824
- * verdict owner) and `vault org-profile` both read:
1018
+ * The four inputs a boolean policy value may take, and nothing else — the SAME
1019
+ * disposition `profileValueDisposition` above already applies to this key.
1020
+ * Returns `undefined` for anything else, which is not the same as `false`: it
1021
+ * means "this value expresses no policy".
825
1022
  *
826
- * 1. The project's `.planning/config.json`. A present value wins, whatever
827
- * it is: a JSON boolean is used as is; anything else is treated as false
828
- * and reported as invalid (the gate card names it), never passed on to the
829
- * profile — an explicit project value is the project's decision.
830
- * 2. The org profile, already narrowed to "true" / "false" by the value guard.
831
- * 3. The registered default, false.
1023
+ * 02-SECURITY UF-01. The project-config read used to be
1024
+ * `value === true || value === 'true'`, which collapsed every other spelling
1025
+ * to `false` — while `config-set vault.required TRUE|True|1|yes|on|enabled`
1026
+ * all answered `{"updated": true}` and wrote exactly those, so the supported
1027
+ * CLI could leave a build gate OFF while reporting success. UF-01 closed the
1028
+ * READ half (here); 07-14 closed the WRITE half, and `config-set` now refuses
1029
+ * every one of those spellings with a usage error and writes nothing, so this
1030
+ * function's `undefined` arm is reachable only from a hand-edited config.json
1031
+ * or an org profile. The asymmetry made it sharper: the LOWER-trust org
1032
+ * profile validated to four literals and warned, while project config — which
1033
+ * outranks it — did not, so a project `"TRUE"` both failed to arm the gate AND
1034
+ * suppressed a profile value that would have, because `found` rather than
1035
+ * validity decided precedence.
832
1036
  *
833
- * Never throws.
1037
+ * Moved here from `src/vault.cts` together with the gate's ladder
1038
+ * (13-REVIEW CR-02), so the gate and the report share one reading.
834
1039
  */
835
- function resolveVaultRequired(cwd, opts) {
836
- try {
837
- const raw = cfgGet(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, undefined);
838
- if (raw !== undefined) {
839
- return { required: raw === true, source: 'config', valid: typeof raw === 'boolean', raw, warnings: [] };
840
- }
841
- const profile = readProfileValue(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, opts);
842
- if (profile.value === 'true' || profile.value === 'false') {
843
- return { required: profile.value === 'true', source: 'profile', valid: true, raw: undefined, warnings: profile.warnings };
844
- }
845
- return { required: false, source: 'default', valid: true, raw: undefined, warnings: profile.warnings };
846
- }
847
- catch {
848
- return { required: false, source: 'default', valid: true, raw: undefined, warnings: [] };
1040
+ function booleanPolicyValue(value) {
1041
+ if (value === true || value === false)
1042
+ return value;
1043
+ if (typeof value === 'string') {
1044
+ const trimmed = value.trim();
1045
+ if (trimmed === 'true')
1046
+ return true;
1047
+ if (trimmed === 'false')
1048
+ return false;
849
1049
  }
1050
+ return undefined;
850
1051
  }
851
- // ─── resolveAcceptedTracePredicates ──────────────────────────────────────────
852
1052
  /**
853
- * The extra practice-trace predicate types the org profile lists under
854
- * `vault.trace.accepted_predicates` — `[]` when there is no profile, the key is
855
- * absent, or its value was refused by the value guard. Org profile only: a
856
- * project config value is not read (the key is not a project setting). Never
857
- * throws.
1053
+ * Resolve the `vault.required` POLICY for `cwd`: project config first, the
1054
+ * org profile second, the registered default (`false`) last. This is the ONE
1055
+ * reader of the policy ladder (13-REVIEW CR-02): the load gate
1056
+ * (`resolveVaultRequiredFlag` in `src/vault.cts`) asks it after its own
1057
+ * `vault.enabled` check, and the `vault org-profile` report asks it for the
1058
+ * `effective` row — so the report and the gate cannot disagree about which
1059
+ * level won for the same inputs. Before CR-02 this function read only the
1060
+ * root `.planning/config.json` and counted only `=== true || === 'true'`,
1061
+ * while the gate read the workstream config first and trimmed, so a project
1062
+ * `" true"` or `"yes"` was reported as overriding a profile `true` that the
1063
+ * gate in fact enforced.
1064
+ *
1065
+ * PROJECT CONFIG OUTRANKS THE PROFILE (T-02-17), so the ordering below is
1066
+ * load-bearing. Level 1 reads RAW config through `resolveConfigKey` with
1067
+ * `config: {}` and `registry: {}`: the workstream config.json, then the root
1068
+ * config.json, each in either dotted form (nested first), with no schema
1069
+ * default. A project value of `false` is a real value and wins. A project
1070
+ * value that is PRESENT BUT UNPARSEABLE (`booleanPolicyValue` answers
1071
+ * `undefined`) does not consume the decision: the profile is consulted as if
1072
+ * the project had said nothing, because a value that expresses no policy must
1073
+ * not outrank one that does. It is returned as `ignoredConfigValue` so the
1074
+ * gate can say so on stderr; this function prints nothing.
1075
+ *
1076
+ * This answers the POLICY, not whether a gate exists: on a project with
1077
+ * `vault.enabled` off no gate renders and `resolveVaultRequiredFlag` answers
1078
+ * `false` whatever this returns.
1079
+ *
1080
+ * Never throws — a failed config read falls through to the profile, and the
1081
+ * profile seam already degrades to a miss on any fault.
858
1082
  */
859
- function resolveAcceptedTracePredicates(cwd, opts) {
1083
+ function resolveVaultRequired(cwd, opts) {
1084
+ // Level 1 — project config, raw (ws then root, either dotted form).
1085
+ let ignoredConfigValue;
1086
+ let ignored = false;
860
1087
  try {
861
- const profile = readProfileValue(cwd, exports.VAULT_TRACE_ACCEPTED_PREDICATES_KEY, opts);
862
- if (typeof profile.value !== 'string')
863
- return [];
864
- const parsed = JSON.parse(profile.value);
865
- return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
1088
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1089
+ const activation = require('./capability-activation.cjs');
1090
+ const fromConfig = activation.resolveConfigKey(exports.VAULT_REQUIRED_CONFIG_KEY, { config: {}, cwd, registry: {} });
1091
+ if (fromConfig.found) {
1092
+ const decided = booleanPolicyValue(fromConfig.value);
1093
+ if (decided !== undefined)
1094
+ return { required: decided, source: 'config', warnings: [] };
1095
+ ignored = true;
1096
+ ignoredConfigValue = fromConfig.value;
1097
+ }
866
1098
  }
867
- catch {
868
- return [];
1099
+ catch { /* fall through to the profile */ }
1100
+ const withIgnored = (r) => (ignored ? { ...r, ignoredConfigValue } : r);
1101
+ // Level 2 — the org profile. Already narrowed to 'true'/'false' by
1102
+ // profileValueDisposition; booleanPolicyValue agrees with it by construction.
1103
+ const profile = readProfileValue(cwd, exports.VAULT_REQUIRED_CONFIG_KEY, opts);
1104
+ if (profile.value !== undefined) {
1105
+ const decided = booleanPolicyValue(profile.value);
1106
+ if (decided !== undefined)
1107
+ return withIgnored({ required: decided, source: 'profile', warnings: profile.warnings });
869
1108
  }
1109
+ // Level 3 — the registered default from capabilities/vault/capability.json.
1110
+ return withIgnored({ required: false, source: 'default', warnings: profile.warnings });
870
1111
  }