@pareto4/pp-core 1.13.0 → 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 (201) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +2 -1
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +10 -13
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +244 -21
  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 +1 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +5 -3
  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 +396 -82
  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 +3334 -946
  35. package/pp-core/bin/lib/clusters.cjs +3 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -35
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +251 -88
  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 +4 -4
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -38
  45. package/pp-core/bin/lib/git-base-branch.cjs +13 -11
  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 +648 -738
  49. package/pp-core/bin/lib/init-interactive.cjs +76 -4
  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 +189 -129
  54. package/pp-core/bin/lib/org-profile.cjs +469 -152
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -29
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -253
  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 +650 -126
  66. package/pp-core/bin/lib/vault-command-router.cjs +2604 -367
  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 +139 -72
  71. package/pp-core/bin/lib/vault-review.cjs +194 -451
  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 +607 -254
  75. package/pp-core/bin/lib/vault-update.cjs +9 -33
  76. package/pp-core/bin/lib/vault-write-back.cjs +70 -42
  77. package/pp-core/bin/lib/vault.cjs +2734 -920
  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 -54
  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 +2 -2
  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 -25
  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 +17 -34
  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 +20 -23
  100. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  101. package/pp-core/workflows/add-tests.md +55 -4
  102. package/pp-core/workflows/audit-fix.md +63 -2
  103. package/pp-core/workflows/audit-milestone.md +1 -1
  104. package/pp-core/workflows/autonomous.md +85 -11
  105. package/pp-core/workflows/check-todos.md +13 -2
  106. package/pp-core/workflows/code-review-fix.md +59 -3
  107. package/pp-core/workflows/code-review.md +10 -1
  108. package/pp-core/workflows/complete-milestone.md +1 -1
  109. package/pp-core/workflows/debug.md +54 -4
  110. package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
  111. package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
  112. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  113. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  114. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  117. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  118. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  123. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  124. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  127. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  128. package/pp-core/workflows/discovery-sprint.md +7 -3
  129. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  130. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  131. package/pp-core/workflows/discovery-to-build.md +10 -2
  132. package/pp-core/workflows/discuss-phase.md +3 -10
  133. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  134. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  135. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  136. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  137. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  138. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  139. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  140. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -20
  141. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  142. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  143. package/pp-core/workflows/execute-phase.md +155 -128
  144. package/pp-core/workflows/execute-plan.md +56 -6
  145. package/pp-core/workflows/fast.md +53 -4
  146. package/pp-core/workflows/help/modes/full.md +3 -3
  147. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  148. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  149. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  150. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  151. package/pp-core/workflows/init.md +232 -272
  152. package/pp-core/workflows/new-project.md +31 -23
  153. package/pp-core/workflows/plan-phase.md +98 -45
  154. package/pp-core/workflows/quick.md +117 -3
  155. package/pp-core/workflows/secure-phase.md +1 -1
  156. package/pp-core/workflows/ship.md +57 -28
  157. package/pp-core/workflows/undo.md +1 -1
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +58 -3
  160. package/pp-core/workflows/vault-review.md +282 -22
  161. package/pp-core/workflows/verify-work.md +12 -14
  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 -11
  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 +12 -10
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -17
  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 -483
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/loop-gates.cjs +0 -213
  186. package/pp-core/bin/lib/phase-manifest.cjs +0 -527
  187. package/pp-core/bin/lib/phase-tier.cjs +0 -480
  188. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  189. package/pp-core/references/executor-vault-trace.md +0 -47
  190. package/pp-core/references/planner-plan-graph.md +0 -66
  191. package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
  192. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  193. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  194. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  195. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
  196. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
  197. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
  198. package/scripts/lib/size-budget.cjs +0 -311
  199. package/scripts/lint-neutrality-names.cjs +0 -694
  200. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  201. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -1,116 +1,78 @@
1
1
  "use strict";
2
- /**
3
- * src/init-contract.cts — the `/pp-init` question set and end-state
4
- * invariant check, as code (milestone v1.11.0, Phase 34-07, gap closure).
5
- *
6
- * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
7
- * compiles to the git-ignored `pp-core/bin/lib/init-contract.cjs`; do NOT
8
- * hand-edit the compiled output. Behaviour is locked by
9
- * `tests/init-contract.test.cjs`.
10
- *
11
- * Born from a documented failure, not a speculative hardening. Two human
12
- * UAT walks of `/pp-init` (`34-VALIDATION.md` § Manual-Only Verifications,
13
- * `34-05-SUMMARY.md`) found that prose-driven load-bearing behaviour — a
14
- * question set that must not shrink, a step that must not be skippable, a
15
- * config key that must be set — is not reliably enforced by an executing
16
- * agent re-interpreting Markdown, even after two escalating rounds of
17
- * explicit in-file hardening. `buildInitQuestions` moves the canonical
18
- * question set (and the provider option list, derived from `PROVIDERS`
19
- * rather than retyped) out of prose into tested data; `verifyInitInvariants`
20
- * inspects the RESULTING project state after init runs, rather than
21
- * trusting that any one step announced itself — enforcement placed inside
22
- * the step it protects does not constrain an agent that skips the step
23
- * entirely (see `34-VALIDATION.md`'s diagnosis).
24
- *
25
- * Both functions are read-only and never throw. `verifyInitInvariants`
26
- * calls `composeBindings` in offline mode to answer "was a composable
27
- * binding ever produced" — this module therefore sits ABOVE
28
- * `src/compose-bindings.cts` in the CommonJS graph. Nothing in
29
- * `compose-bindings.cts`, `git-provider.cts` or `org-profile.cts` imports
30
- * this module, at load time or lazily, so this introduces no require cycle.
31
- *
32
- * This module's own prose deliberately never names a concrete org profile
33
- * package or client, matching the convention `src/org-profile.cts` and
34
- * `src/compose-bindings.cts` both state in their own headers.
35
- *
36
- * Residual risk, stated plainly (see the plan's threat register): nothing
37
- * here prevents an agent from declining to call this arm at all.
38
- * `buildInitQuestions` makes the rendered content correct when called;
39
- * `verifyInitInvariants` makes the omission provable afterwards, by
40
- * inspecting state `init.md` cannot avoid leaving behind. It does not make
41
- * step-skipping impossible.
42
- */
2
+ /** The `/pp-init` question set, session state machine and end-state invariant check, as tested code.
3
+ * Compiled to the git-ignored `pp-core/bin/lib/init-contract.cjs` — never hand-edit the output. */
43
4
  var __importDefault = (this && this.__importDefault) || function (mod) {
44
5
  return (mod && mod.__esModule) ? mod : { "default": mod };
45
6
  };
46
7
  Object.defineProperty(exports, "__esModule", { value: true });
47
- exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_ORG_PROFILE_DECLINED_KEY = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
8
+ exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_PROBE_STATUS = exports.INIT_PROBE_NOTE_KEY = exports.INIT_PROBE_WHO_KEY = exports.INIT_PROBE_WHEN_KEY = exports.INIT_PROBE_STATUS_KEY = exports.INIT_ORG_PROFILE_DECLINED_KEY = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
9
+ exports.resolveVaultEnabledState = resolveVaultEnabledState;
10
+ exports.resolveVaultPracticesEnabledState = resolveVaultPracticesEnabledState;
11
+ exports.resolveVaultRequiredState = resolveVaultRequiredState;
12
+ exports.readVaultEnabledState = readVaultEnabledState;
13
+ exports.readVaultPracticesEnabledState = readVaultPracticesEnabledState;
14
+ exports.readVaultStandardsModeActive = readVaultStandardsModeActive;
15
+ exports.readVaultRequiredState = readVaultRequiredState;
48
16
  exports.buildInitQuestions = buildInitQuestions;
17
+ exports.deriveSlug = deriveSlug;
18
+ exports.readProfileInitAnswers = readProfileInitAnswers;
49
19
  exports.verifyInitInvariants = verifyInitInvariants;
20
+ exports.armVaultKeysWhereAbsent = armVaultKeysWhereAbsent;
21
+ exports.recordProbeOutcome = recordProbeOutcome;
22
+ exports.recordProbeDeferral = recordProbeDeferral;
50
23
  exports.initSessionNext = initSessionNext;
51
24
  exports.initSessionAnswer = initSessionAnswer;
52
25
  exports.initSessionReset = initSessionReset;
53
- exports.armVault = armVault;
26
+ exports.armVaultFromProfile = armVaultFromProfile;
54
27
  exports.cmdInitContract = cmdInitContract;
55
28
  const node_fs_1 = __importDefault(require("node:fs"));
56
29
  const node_path_1 = __importDefault(require("node:path"));
57
30
  // eslint-disable-next-line @typescript-eslint/no-require-imports
58
31
  const io = require("./io.cjs");
59
32
  const { output } = io;
33
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
34
+ const capabilityActivation = require("./capability-activation.cjs");
60
35
  const git_provider_cjs_1 = require("./git-provider.cjs");
61
36
  const org_profile_cjs_1 = require("./org-profile.cjs");
37
+ const platform_bindings_cjs_1 = require("./platform-bindings.cjs");
62
38
  const compose_bindings_cjs_1 = require("./compose-bindings.cjs");
63
39
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
64
40
  // ─── Constants ───────────────────────────────────────────────────────────────
65
- /**
66
- * The dotted `.planning/config.json` key `/pp-init` Step 2.5 sets when the
67
- * operator explicitly declines the compose-and-write confirmation. This is
68
- * the ONLY signal that distinguishes "the operator was asked and said no"
69
- * from "the step never ran at all" — both leave `.platform-bindings.json`
70
- * absent, and `verifyInitInvariants` cannot tell them apart any other way
71
- * (see `INVARIANT_CODE.COMPOSITION_NOT_RUN` below). Registered as a
72
- * `vault` capability config key (`capabilities/vault/capability.json`) so
73
- * `pp-tools config-set` accepts it without the "unknown config key" error
74
- * a bare unregistered top-level namespace would trip.
75
- */
41
+ /** Set when the operator declines `compose_confirm` — the only way to tell "declined" from "never ran". */
76
42
  exports.INIT_COMPOSE_DECLINED_KEY = 'init.compose_declined';
77
- /**
78
- * The dotted `.planning/config.json` key `pp-tools init` sets when the
79
- * operator is offered a detected org profile and SKIPS it — a blank answer or
80
- * `none` in any letter case. It is the only signal that tells "offered a
81
- * detected profile and skipped it" from "the question never ran": both leave
82
- * `org.profile` unset, and without it `verifyInitInvariants` reported every
83
- * deliberate skip as `INIT_QUESTIONS_ORG_PROFILE_UNASKED`. Written by
84
- * `initSessionAnswer` only when a profile was detectable (the predicate the
85
- * violation reads), read back with strict boolean identity, and written back
86
- * to `false` when a later run selects a profile. Registered under the
87
- * `vault` capability so `config-set` accepts it. Kept OFF
88
- * `ORG_PROFILE_ALLOWLIST`: a profile package must never answer the question
89
- * about itself.
90
- */
43
+ /** Legacy upstream marker for a skipped org profile; nothing writes it any more, kept registered so old configs load. */
91
44
  exports.INIT_ORG_PROFILE_DECLINED_KEY = 'init.org_profile_declined';
92
- /** The identity-only choices appended after the provider seam's own
93
- * `PROVIDERS` list — never retyped ahead of `PROVIDERS` itself (T-34-50). */
94
- const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['github', 'other', 'none']);
95
- /** Bounds the `node_modules` scan `verifyInitInvariants` runs to answer
96
- * "could an org profile package have been pointed to" — a DoS guard, the
97
- * same convention `src/org-profile.cts`'s `PROFILE_FLATTEN_LEAF_CAP` and
98
- * `PROFILE_FLATTEN_DEPTH_CAP` use for the analogous profile-document walk. */
99
- const NODE_MODULES_SCAN_CAP = 2000;
45
+ /** The /pp-init Step 2.6 reachability-proof record (status, when, who, note), read back by `--verify`.
46
+ * Registered vault config keys; never profile-settable and not for operator editing. */
47
+ exports.INIT_PROBE_STATUS_KEY = 'init.probe_status';
48
+ /** ISO-8601 UTC instant the record was written. See `INIT_PROBE_STATUS_KEY`. */
49
+ exports.INIT_PROBE_WHEN_KEY = 'init.probe_when';
50
+ /** Who recorded the proof; never empty (falls back to the literal `unknown`). */
51
+ exports.INIT_PROBE_WHO_KEY = 'init.probe_who';
52
+ /** The bounded human note: the probe's own summary on `passed`/`failed`, the
53
+ * operator's stated reason on `deferred`. See `INIT_PROBE_STATUS_KEY`. */
54
+ exports.INIT_PROBE_NOTE_KEY = 'init.probe_note';
55
+ /** Probe status vocabulary; `deferred` is only recordable through `recordProbeDeferral`, which requires a reason. */
56
+ exports.INIT_PROBE_STATUS = Object.freeze({
57
+ PASSED: 'passed',
58
+ FAILED: 'failed',
59
+ DEFERRED: 'deferred',
60
+ });
61
+ /** Provider choices that only record identity, appended after `PROVIDERS` (never a retyped copy of it). */
62
+ const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['other', 'none']);
100
63
  const INVARIANT_CODE = Object.freeze({
101
- ORG_PROFILE_UNASKED: 'INIT_QUESTIONS_ORG_PROFILE_UNASKED',
102
64
  COMPOSITION_NOT_RUN: 'INIT_COMPOSITION_NOT_RUN',
103
65
  VAULT_PROVIDER_UNSET: 'INIT_VAULT_PROVIDER_UNSET',
104
66
  IDENTITY_MISSING: 'INIT_IDENTITY_MISSING',
67
+ /** Reachability proof never run, failed, or consciously deferred — three states that need three different responses. */
68
+ PROBE_NOT_RUN: 'INIT_PROBE_NOT_RUN',
69
+ PROBE_FAILED: 'INIT_PROBE_FAILED',
70
+ PROBE_DEFERRED: 'INIT_PROBE_DEFERRED',
71
+ /** `vault.enabled` is present but not a JSON boolean, so the activation resolver and the proof check disagree. */
72
+ VAULT_ENABLED_MALFORMED: 'INIT_VAULT_ENABLED_MALFORMED',
105
73
  });
106
- /** The recognized flag set for the `init-contract` CLI arm (the
107
- * `pp-tools.cjs` fail-fast unrecognized-flag guard reads this, mirroring
108
- * `COMPOSE_BINDINGS_FLAGS`). Extended by Task 2 (34-08 gap closure) with the
109
- * session relay flags — `--session-answer` additionally consumes the next
110
- * argv token as its `<stepId>=<value>` payload (parsed in `pp-tools.cjs`,
111
- * mirroring the `--cwd`/`--pick` convention), and `--offline` forwards to
112
- * every `composeBindings` call the session makes so a CLI-driven loop can
113
- * run with no network access — parity with `compose-bindings --offline`. */
74
+ /** Flags the `init-contract` CLI arm accepts; `--session-answer`, `--record-probe` and `--defer-probe` take the next token as payload.
75
+ * `--arm-vault` (quick 261003-kmd) is the non-interactive compose-and-arm `/pp-new-project` calls. */
114
76
  exports.INIT_CONTRACT_FLAGS = Object.freeze([
115
77
  '--questions',
116
78
  '--verify',
@@ -118,39 +80,21 @@ exports.INIT_CONTRACT_FLAGS = Object.freeze([
118
80
  '--session-answer',
119
81
  '--session-reset',
120
82
  '--offline',
83
+ '--record-probe',
84
+ '--defer-probe',
121
85
  '--arm-vault',
122
86
  ]);
123
- /** `.planning/.init-session.json` — the CLI-owned session state file
124
- * (Artifacts table, 34-08). Never hand-edited; malformed content is
125
- * refused, not silently repaired (T-34-63). */
87
+ /** CLI-owned session state file; malformed content is refused, never repaired. */
126
88
  const INIT_SESSION_FILE_NAME = '.init-session.json';
127
- /** The `error` code `initSessionNext`/`initSessionAnswer` report when the
128
- * session file exists but does not parse as valid session state — the ONLY
129
- * recovery is `--session-reset` (T-34-63); this module never resumes a
130
- * malformed file into a guessed-at state. */
89
+ /** Reported when the session file is malformed; the only recovery is `--session-reset`. */
131
90
  exports.INIT_SESSION_MALFORMED = 'INIT_SESSION_MALFORMED';
132
- /** CR-01: the `error`/`reason` code `initSessionNext`/`initSessionAnswer`
133
- * report when a filesystem write this module performs (`persistSession`,
134
- * `writeConfigMerge`, the write-mode `composeBindings` call) fails — e.g. an
135
- * unwritable `.planning` directory (permissions, a read-only mount, a full
136
- * disk). Every sibling module in this seam (`composeBindings`,
137
- * `resolveOrgProfileDocument`, `resolveOrgProfile`, `verifyInitInvariants`)
138
- * never throws and degrades to a bounded data verdict instead (D-09); this
139
- * module previously did not follow that convention on its write paths and
140
- * let the exception escape uncaught. Recovery is to fix the underlying
141
- * filesystem issue and retry the same step — nothing is left half-recorded,
142
- * since the throw is caught before `session` is persisted. */
91
+ /** Reported when a session write fails (e.g. an unwritable `.planning`); fix the cause and retry the same step. */
143
92
  exports.INIT_SESSION_WRITE_FAILED = 'INIT_SESSION_WRITE_FAILED';
144
- /** compose_confirm's answer vocabulary — deliberately permissive so a human
145
- * relaying through `AskUserQuestion`/TEXT_MODE can answer naturally; every
146
- * other string is rejected (never silently treated as a decline). */
93
+ /** `compose_confirm` answers; anything else is rejected, never treated as a decline. */
147
94
  const YES_VALUES = Object.freeze(['yes', 'y', 'approve', 'approved', 'true']);
148
95
  const NO_VALUES = Object.freeze(['no', 'n', 'decline', 'declined', 'false']);
149
- /** Maps each of the 7 identity/provider/visibility question ids (every
150
- * question but `org_profile`, which is written immediately on answer — see
151
- * `initSessionAnswer` — so the very next question, `provider`, can
152
- * re-resolve its `preselected` against it) to the short key it is written
153
- * under inside `.planning/config.json`'s `project` block. */
96
+ /** Maps each of the 7 identity/provider/visibility question ids to the short
97
+ * key it is written under inside `.planning/config.json`'s `project` block. */
154
98
  const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
155
99
  project_name: 'name',
156
100
  project_slug: 'slug',
@@ -160,9 +104,7 @@ const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
160
104
  provider: 'provider',
161
105
  visibility: 'visibility',
162
106
  });
163
- // ─── Small local helpers (deliberate per-module duplication — this repo's
164
- // established convention for small, cycle-risk-free helpers; see
165
- // `src/git-provider.cts`'s `cfgGet` for the canonical example) ────────────────
107
+ // ─── Small local helpers (duplicated per module to avoid require cycles) ──────────
166
108
  function isObject(v) {
167
109
  return typeof v === 'object' && v !== null && !Array.isArray(v);
168
110
  }
@@ -172,35 +114,143 @@ function isUsableString(v) {
172
114
  function getDotted(obj, dottedPath) {
173
115
  return dottedPath.split('.').reduce((cur, key) => (isObject(cur) ? cur[key] : undefined), obj);
174
116
  }
175
- // ─── buildInitQuestions ────────────────────────────────────────────────────
117
+ /** Arms the vault capability; written by the session machine, never by an org profile. */
118
+ const VAULT_ENABLED_CONFIG_KEY = 'vault.enabled';
119
+ /** Arms vault-practices; set beside `vault.enabled` under the same absent-only rule, inert without it. */
120
+ const VAULT_PRACTICES_ENABLED_CONFIG_KEY = 'vault.practices_enabled';
121
+ /** The single reading of `vault.enabled` in this module, in the form the gate honours
122
+ * (`resolveGateHonouredKeyState`, Phase 15 D-32). `value` is the observed value, never a
123
+ * coercion, so reports name what is really in the file. */
124
+ function resolveVaultEnabledState(parsed) {
125
+ return resolveGateHonouredKeyState(parsed, VAULT_ENABLED_CONFIG_KEY);
126
+ }
127
+ /** Four-state reading of `vault.practices_enabled`, like `resolveVaultEnabledState`. */
128
+ function resolveVaultPracticesEnabledState(parsed) {
129
+ return resolveGateHonouredKeyState(parsed, VAULT_PRACTICES_ENABLED_CONFIG_KEY);
130
+ }
131
+ /** Four-state reading of the project's OWN `vault.required` (not the profile-layered gate value),
132
+ * so the arming guard respects an explicit per-project `false`. */
133
+ function resolveVaultRequiredState(parsed) {
134
+ return resolveBooleanKeyState(parsed, org_profile_cjs_1.VAULT_REQUIRED_CONFIG_KEY);
135
+ }
136
+ /** Classify a PRESENT value into the three present states. */
137
+ function classifyPresentValue(value) {
138
+ if (value === true)
139
+ return { state: 'armed', value };
140
+ if (value === false)
141
+ return { state: 'off', value };
142
+ return { state: 'malformed', value };
143
+ }
144
+ /** Either-form four-state reader. Used for `vault.required`, whose gate-side ladder
145
+ * (`resolveVaultRequired`, `src/org-profile.cts`) reads both key forms. */
146
+ function resolveBooleanKeyState(parsed, key) {
147
+ const hit = capabilityActivation.getConfigValueEitherForm(parsed, key);
148
+ if (hit.found !== true)
149
+ return { state: 'absent', value: undefined };
150
+ return classifyPresentValue(hit.value);
151
+ }
176
152
  /**
177
- * The canonical `/pp-init` question set, as data. `init.md` renders exactly
178
- * what this returns, in the order returned, none merged, reordered or
179
- * dropped — see the module header for why this moved out of prose.
180
- *
181
- * Question 6 (`org_profile`) is the org profile pointer, marked
182
- * `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
183
- * is a complete, valid response), never whether the question is asked.
153
+ * Four-state reading of a vault switch whose gates read ONLY the nested form (Phase 15
154
+ * D-32): `vault.enabled` and `vault.practices_enabled`. `loadConfig` materialises their
155
+ * nested defaults, so the gates' evaluator answers at its first level and never reaches a
156
+ * hand-written FLAT key (it warns that the key is ignored). Reading the flat form as
157
+ * `armed` therefore told /pp-init that a switch was on while no gate rendered. The
158
+ * absent-only arming then skipped it, and the done line promised governance nobody
159
+ * enforced (todo init-practices-reader-reads-flat-form-as-armed).
184
160
  *
185
- * Question 6 (`org_profile`) names a HINT, never a default: when
186
- * `resolveDetectedOrgProfile` finds exactly one org-profile-shaped package
187
- * installed (INIT-PATCH-C), the prompt names it, Enter skips, and typing its
188
- * name (or any other) selects it — the question carries no `preselected`.
189
- * The prompt text is built from that detected value rather than kept as two
190
- * hard-coded strings that can drift.
191
- * `opts._globalRoot` is forwarded to it unchanged, the same test-seam
192
- * convention `verifyInitInvariants`'s own `_globalRoot` already follows —
193
- * production callers omit it.
194
- *
195
- * Question 7 (`provider`) carries the fixed, complete option list: every
196
- * value in `PROVIDERS` (imported, never retyped — this is what keeps
197
- * `azure` from being silently dropped again, T-34-50) plus the three
198
- * identity-only choices. `preselected` is resolved via `resolveProvider`,
199
- * the same authority the `compose-bindings --offline --pick provider`
200
- * probe reads (pure config + org-profile consultation, no network) — so
201
- * calling it directly here is equivalent, not a second implementation.
161
+ * - nested present: its value decides, exactly as before;
162
+ * - flat-only `true`: `absent`, because the gate sees nothing. The arming writes the
163
+ * canonical nested key, which is what the operator asked for;
164
+ * - flat-only `false`: `off`. The gate reads off too, and an explicit false is never
165
+ * overwritten (07-09 WR-07);
166
+ * - flat-only anything else: `malformed`, kept and reported, never repaired.
167
+ */
168
+ function resolveGateHonouredKeyState(parsed, key) {
169
+ const nested = capabilityActivation._getNestedConfigValue(parsed, key);
170
+ if (nested.found === true)
171
+ return classifyPresentValue(nested.value);
172
+ const flat = capabilityActivation._getFlatConfigValue(parsed, key);
173
+ if (flat.found !== true || flat.value === true)
174
+ return { state: 'absent', value: undefined };
175
+ return classifyPresentValue(flat.value);
176
+ }
177
+ /** The vault adapter's gate-side readers, required lazily: pp-tools loads this module on
178
+ * every command and the vault adapter is large. vault.cjs never requires this module, so
179
+ * there is no cycle. */
180
+ function vaultGateReaders() {
181
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
182
+ const vault = require('./vault.cjs');
183
+ return vault;
184
+ }
185
+ /** Ask one gate-side reader; any throw (including the require) reads as OFF. */
186
+ function gateReadsOn(read) {
187
+ try {
188
+ return read(vaultGateReaders()) === true;
189
+ }
190
+ catch {
191
+ return false;
192
+ }
193
+ }
194
+ /**
195
+ * The project-at-`cwd` reading the done line prints from (Phase 15 D-32): `armed` exactly
196
+ * when the gate-side reader is ON, so the line can never name a switch the gates do not
197
+ * honour. The root file's own four-state reading supplies everything else.
198
+ * - gate ON, root armed → the root reading;
199
+ * - gate ON, root not armed → `armed` (another config layer, a workstream config, arms
200
+ * it; `value` is the gate's reading, `true`);
201
+ * - gate OFF, root armed → `off` (a workstream config overrides it, or the active
202
+ * workstream has no config); `value` stays the root's;
203
+ * - gate OFF otherwise → the root reading (absent, off or malformed).
202
204
  */
203
- function buildInitQuestions(cwd, opts = {}) {
205
+ function agreeWithGate(root, gateOn) {
206
+ if (gateOn)
207
+ return root.state === 'armed' ? root : { state: 'armed', value: true };
208
+ return root.state === 'armed' ? { state: 'off', value: root.value } : root;
209
+ }
210
+ /** The root config file's four-state reading through `resolve`; unreadable reads as `absent`. */
211
+ function readRootKeyState(cwd, resolve) {
212
+ try {
213
+ const { parsed } = readConfigFile(cwd);
214
+ return resolve(parsed);
215
+ }
216
+ catch {
217
+ return { state: 'absent', value: undefined };
218
+ }
219
+ }
220
+ /** `vault.enabled` for the project at `cwd`: `armed` exactly when `isVaultGateActive` is
221
+ * (`agreeWithGate`); an unreadable config reads as `absent`. Never throws. */
222
+ function readVaultEnabledState(cwd) {
223
+ return agreeWithGate(readRootKeyState(cwd, resolveVaultEnabledState), gateReadsOn((v) => v.isVaultGateActive(cwd)));
224
+ }
225
+ /** `vault.practices_enabled` for the project at `cwd`: `armed` exactly when
226
+ * `isPracticesGateActive`, the one practices-switch reader (15-02), is ON. Never throws. */
227
+ function readVaultPracticesEnabledState(cwd) {
228
+ return agreeWithGate(readRootKeyState(cwd, resolveVaultPracticesEnabledState), gateReadsOn((v) => v.isPracticesGateActive(cwd)));
229
+ }
230
+ /**
231
+ * Whether the project at `cwd` is in standards mode, asked of the one mode owner
232
+ * (`isStandardsModeActive`, which honours the org-profile ladder). The /pp-init done line
233
+ * uses it so that, in standards mode, it names the standards record the `vault.trace`
234
+ * gate checks instead of practice recording, which standards mode does not do
235
+ * (15-REVIEW WR-09). Any throw reads as not standards. Never throws.
236
+ */
237
+ function readVaultStandardsModeActive(cwd) {
238
+ return gateReadsOn((v) => v.isStandardsModeActive(cwd));
239
+ }
240
+ /** `vault.required` for the project at `cwd`: `armed` exactly when the load gate's own
241
+ * answer (`resolveVaultRequiredFlag`) is true. Never throws. */
242
+ function readVaultRequiredState(cwd) {
243
+ return agreeWithGate(readRootKeyState(cwd, resolveVaultRequiredState), gateReadsOn((v) => v.resolveVaultRequiredFlag(cwd)));
244
+ }
245
+ /** True only for the JSON boolean `true` — a truthy string does not count.
246
+ * A non-boolean value is reported by `verifyInitInvariants` as `INIT_VAULT_ENABLED_MALFORMED`. */
247
+ function isVaultEnabledInConfig(parsed) {
248
+ return resolveVaultEnabledState(parsed).state === 'armed';
249
+ }
250
+ // ─── buildInitQuestions ────────────────────────────────────────────────────
251
+ /** The canonical /pp-init question set, as data; the session may answer some itself (see "Automatic answers").
252
+ * No org-profile question in this fork (PBI 74316); provider options come from `PROVIDERS`, preselected via `resolveProvider`. */
253
+ function buildInitQuestions(cwd) {
204
254
  let preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
205
255
  try {
206
256
  preselected = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
@@ -209,31 +259,6 @@ function buildInitQuestions(cwd, opts = {}) {
209
259
  preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
210
260
  }
211
261
  const providerOptions = Object.freeze([...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS]);
212
- const detectedOrgProfile = resolveDetectedOrgProfile(cwd, opts);
213
- // The template example is written as an angle-bracket placeholder, not a
214
- // literal package name. `tests/org-profile.test.cjs`'s client-confidentiality
215
- // guard scans this file for any concrete scope-slash-org-prefix package
216
- // identifier, so even a generic literal placeholder trips it — the template
217
- // keeps the naming convention visible while staying clean, and the guard
218
- // stays maximally strict, no allowlist entry was added for it. The detected
219
- // value itself is never a literal in this file — it is a runtime string
220
- // read from an installed package, interpolated into the prompt below.
221
- // A detected profile is a HINT, never a default: no `preselected`, so a
222
- // profile installed on the operator's machine (perhaps another client's)
223
- // never becomes a project's binding by pressing Enter. Enter skips (and
224
- // `initSessionAnswer` records the skip); typing the name selects it.
225
- const orgProfileQuestion = isUsableString(detectedOrgProfile)
226
- ? {
227
- id: 'org_profile',
228
- prompt: `Org profile package (optional — "${detectedOrgProfile}" is installed: type its name to use it, ` +
229
- 'or press Enter to skip)',
230
- optionalAnswer: true,
231
- }
232
- : {
233
- id: 'org_profile',
234
- prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
235
- optionalAnswer: true,
236
- };
237
262
  return [
238
263
  { id: 'project_name', prompt: 'Project name (human-readable, e.g. "Acme Dashboard")', optionalAnswer: false },
239
264
  { id: 'project_slug', prompt: 'Project slug (kebab-case, e.g. "acme-dashboard")', optionalAnswer: false },
@@ -248,7 +273,6 @@ function buildInitQuestions(cwd, opts = {}) {
248
273
  optionalAnswer: false,
249
274
  },
250
275
  { id: 'description', prompt: 'One-line description (what this project does)', optionalAnswer: false },
251
- orgProfileQuestion,
252
276
  {
253
277
  id: 'provider',
254
278
  prompt: 'Provider (gitlab / azure / github / other / none)',
@@ -260,179 +284,88 @@ function buildInitQuestions(cwd, opts = {}) {
260
284
  { id: 'visibility', prompt: 'Visibility (private / public / internal)', optionalAnswer: false },
261
285
  ];
262
286
  }
263
- // ─── verifyInitInvariants ───────────────────────────────────────────────────
264
- /** Conservative npm-package-name shape check (bare `name` or scoped
265
- * `@scope/name`; lowercase, digits, `.`/`_`/`-`, never leading `.`/`_` in
266
- * either segment). A directory name that does not match is never offered as
267
- * a detected default (T-QP-02): it cannot be a resolvable package pointer,
268
- * so rejecting it here keeps a hostile directory name (control characters,
269
- * terminal escapes) from ever reaching a rendered prompt. */
270
- const NPM_PACKAGE_NAME_SHAPE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
271
- /**
272
- * Walk `nodeModulesDir` exactly like the boolean check below (one level, or
273
- * one level inside an `@scope/` directory), collecting the name of every
274
- * package directory carrying `ORG_PROFILE_DATA_FILE` at its root instead of
275
- * stopping at the first hit. `opts.limit`, when set, stops the walk once
276
- * that many names have been collected — passing `1` reproduces the boolean
277
- * check's early-exit behaviour exactly. Bounded at `NODE_MODULES_SCAN_CAP`
278
- * total directory entries visited (T-34-54: this check must never hang on a
279
- * pathological `node_modules` tree).
280
- *
281
- * This is a heuristic, not a resolution: it answers "a package shaped like
282
- * an org profile is installed here", not "this exact profile would
283
- * resolve" — callers never claim more than that.
284
- */
285
- function collectOrgProfileShapedPackages(nodeModulesDir, opts = {}) {
286
- const names = [];
287
- let visited = 0;
288
- const limitReached = () => opts.limit !== undefined && names.length >= opts.limit;
289
- const addIfShaped = (name) => {
290
- if (NPM_PACKAGE_NAME_SHAPE_RE.test(name))
291
- names.push(name);
292
- };
293
- let entries;
294
- try {
295
- entries = node_fs_1.default.readdirSync(nodeModulesDir, { withFileTypes: true });
296
- }
297
- catch {
298
- return { names, capped: false };
299
- }
300
- for (const entry of entries) {
301
- if (limitReached())
302
- return { names, capped: false };
303
- if (visited >= NODE_MODULES_SCAN_CAP)
304
- return { names, capped: true };
305
- visited += 1;
306
- if (!entry.isDirectory())
307
- continue;
308
- if (entry.name.startsWith('@')) {
309
- const scopeDir = node_path_1.default.join(nodeModulesDir, entry.name);
310
- let scoped;
311
- try {
312
- scoped = node_fs_1.default.readdirSync(scopeDir, { withFileTypes: true });
313
- }
314
- catch {
315
- continue;
316
- }
317
- for (const scopedEntry of scoped) {
318
- if (limitReached())
319
- return { names, capped: false };
320
- if (visited >= NODE_MODULES_SCAN_CAP)
321
- return { names, capped: true };
322
- visited += 1;
323
- if (!scopedEntry.isDirectory())
324
- continue;
325
- try {
326
- if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
327
- addIfShaped(`${entry.name}/${scopedEntry.name}`);
328
- }
329
- }
330
- catch {
331
- continue;
332
- }
333
- }
334
- }
335
- else {
336
- try {
337
- if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
338
- addIfShaped(entry.name);
339
- }
340
- }
341
- catch {
342
- continue;
343
- }
344
- }
287
+ // ─── Automatic answers (PBI 74299): an org profile init section fixes answers and can derive slugs ───
288
+ /** The visibility answers the question accepts. */
289
+ const VISIBILITY_OPTIONS = Object.freeze(['private', 'public', 'internal']);
290
+ /** Longest client name an org profile may fix — the composer's seed-value bound. */
291
+ const INIT_FIXED_VALUE_MAX_LENGTH = 200;
292
+ function hasControlChar(value) {
293
+ for (let i = 0; i < value.length; i++) {
294
+ const c = value.charCodeAt(i);
295
+ if (c <= 0x1f || c === 0x7f)
296
+ return true;
345
297
  }
346
- return { names, capped: false };
347
- }
348
- /**
349
- * True when `nodeModulesDir` contains at least one package directory (one
350
- * level, or one level inside an `@scope/` directory) carrying a file named
351
- * `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
352
- * `resolveOrgProfileDocument` reads, imported rather than retyped. Reduced to
353
- * the collecting walk above with `limit: 1`, preserving its early-exit
354
- * behaviour exactly.
355
- */
356
- function containsOrgProfileShapedPackage(nodeModulesDir) {
357
- return collectOrgProfileShapedPackages(nodeModulesDir, { limit: 1 }).names.length > 0;
298
+ return false;
358
299
  }
359
- /**
360
- * The global roots DETECTION scans — the org-profile hint and the UNASKED
361
- * check — from the one candidate builder in `src/org-profile.cts`, never a
362
- * private copy (the single-root duplicate that lived here missed every
363
- * Homebrew layout). `explicitPrefixOnly`: an explicit npm prefix is
364
- * authoritative, so a hint or a violation never comes from a tree the
365
- * operator did not point at. The `_globalRoot` seam still wins.
366
- */
367
- function detectionGlobalRoots(opts) {
368
- return (0, org_profile_cjs_1.globalRootCandidatesFor)({ _globalRoot: opts._globalRoot, explicitPrefixOnly: true });
300
+ /** Derive a slug: diacritics stripped, lowercased, other characters collapsed to "-".
301
+ * Returns `null` when the result fails `SLUG_RE`, so the slug is asked instead. */
302
+ function deriveSlug(name) {
303
+ const slug = name
304
+ .normalize('NFKD')
305
+ .replace(/[̀-ͯ]/g, '')
306
+ .toLowerCase()
307
+ .replace(/[^a-z0-9]+/g, '-')
308
+ .replace(/^-+|-+$/g, '');
309
+ return slug !== '' && platform_bindings_cjs_1.SLUG_RE.test(slug) ? slug : null;
369
310
  }
370
- function orgProfilePackageResolvable(cwd, opts) {
311
+ /** The validated init section of the org profile; invalid values are dropped so their question is asked. Never throws. */
312
+ function readProfileInitAnswers(cwd, opts = {}) {
313
+ const none = { fixed: {}, deriveSlugs: false };
371
314
  try {
372
- const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
373
- if (node_fs_1.default.existsSync(projectNodeModules) && containsOrgProfileShapedPackage(projectNodeModules)) {
374
- return true;
315
+ const doc = (0, org_profile_cjs_1.resolveOrgProfileDocument)(cwd, { _globalRoot: opts._globalRoot });
316
+ if (!doc.resolved)
317
+ return none;
318
+ const section = doc.document[org_profile_cjs_1.ORG_PROFILE_INIT_SECTION];
319
+ if (!isObject(section))
320
+ return none;
321
+ const fixed = {};
322
+ const clientName = section.client_name;
323
+ if (typeof clientName === 'string' &&
324
+ clientName.trim() !== '' &&
325
+ clientName.length <= INIT_FIXED_VALUE_MAX_LENGTH &&
326
+ !hasControlChar(clientName)) {
327
+ fixed.client_name = clientName.trim();
375
328
  }
376
- for (const globalRoot of detectionGlobalRoots(opts)) {
377
- if (containsOrgProfileShapedPackage(globalRoot)) {
378
- return true;
379
- }
329
+ const providerOptions = [...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS];
330
+ if (typeof section.provider === 'string' && providerOptions.includes(section.provider.trim())) {
331
+ fixed.provider = section.provider.trim();
380
332
  }
381
- return false;
333
+ if (typeof section.visibility === 'string' && VISIBILITY_OPTIONS.includes(section.visibility.trim())) {
334
+ fixed.visibility = section.visibility.trim();
335
+ }
336
+ return { fixed, deriveSlugs: section.derive_slugs === true };
382
337
  }
383
338
  catch {
384
- return false;
339
+ return none;
385
340
  }
386
341
  }
387
- /**
388
- * Detect a single, unambiguous installed org-profile-shaped package
389
- * (INIT-PATCH-C): collects from the project's `node_modules` and — using the
390
- * same `opts._globalRoot` override convention `orgProfilePackageResolvable`
391
- * already follows — the global root, de-duplicates by package name across
392
- * both roots, and returns that single name only when exactly one distinct
393
- * name was found and NEITHER scan reported `capped`. A capped scan is
394
- * inconclusive and must never yield a default (T-QP-03). Two or more
395
- * distinct names, or none, returns null. Never throws.
396
- */
397
- function resolveDetectedOrgProfile(cwd, opts) {
398
- try {
399
- const names = new Set();
400
- let anyCapped = false;
401
- const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
402
- if (node_fs_1.default.existsSync(projectNodeModules)) {
403
- const projectResult = collectOrgProfileShapedPackages(projectNodeModules);
404
- if (projectResult.capped)
405
- anyCapped = true;
406
- for (const name of projectResult.names)
407
- names.add(name);
408
- }
409
- for (const globalRoot of detectionGlobalRoots(opts)) {
410
- const globalResult = collectOrgProfileShapedPackages(globalRoot);
411
- if (globalResult.capped)
412
- anyCapped = true;
413
- for (const name of globalResult.names)
414
- names.add(name);
415
- }
416
- if (anyCapped)
417
- return null;
418
- return names.size === 1 ? [...names][0] : null;
342
+ /** The answer the session fills in itself for question `id`, or `null` to ask it. */
343
+ function autoAnswerFor(id, answers, init) {
344
+ if (id === 'client_name' || id === 'provider' || id === 'visibility') {
345
+ const value = init.fixed[id];
346
+ return value === undefined ? null : { value, source: 'org-profile' };
419
347
  }
420
- catch {
421
- return null;
348
+ if (init.deriveSlugs && (id === 'project_slug' || id === 'client_slug')) {
349
+ const name = answers[id === 'project_slug' ? 'project_name' : 'client_name'];
350
+ const slug = typeof name === 'string' ? deriveSlug(name) : null;
351
+ return slug === null ? null : { value: slug, source: 'derived' };
422
352
  }
353
+ return null;
423
354
  }
424
- /** The one owner of the org-profile decline rule: a blank answer, or `none`
425
- * in any letter case with surrounding whitespace, skips the question. Enter
426
- * in the TTY driver and `--session-answer org_profile=none` / `org_profile=`
427
- * all end here. */
428
- function isOrgProfileDecline(value) {
429
- const trimmed = value.trim();
430
- return trimmed === '' || trimmed.toLowerCase() === 'none';
355
+ /** The session's automatic answers, in question order. */
356
+ function listAutoAnswers(cwd, session) {
357
+ const sources = session.autoAnswers ?? {};
358
+ const out = [];
359
+ for (const q of buildInitQuestions(cwd)) {
360
+ const source = sources[q.id];
361
+ const value = session.answers[q.id];
362
+ if (source !== undefined && typeof value === 'string')
363
+ out.push({ id: q.id, value, source });
364
+ }
365
+ return out;
431
366
  }
432
- /** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
433
- * "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
434
- * on the latter (see `verifyInitInvariants`'s own doc comment for why an
435
- * absent file must never trip it). Never throws. */
367
+ // ─── verifyInitInvariants ───────────────────────────────────────────────────
368
+ /** Read `.planning/config.json`, telling "absent" from "present but malformed". Never throws. */
436
369
  function readConfigFile(cwd) {
437
370
  const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
438
371
  let exists = false;
@@ -451,26 +384,8 @@ function readConfigFile(cwd) {
451
384
  return { exists, parsed: {} };
452
385
  }
453
386
  }
454
- /**
455
- * Inspect the project's RESULTING state at `cwd` after an `/pp-init` run
456
- * and report every named invariant it violates. Read-only: no filesystem
457
- * write anywhere in this function or in the `composeBindings` call it
458
- * makes (always `offline: true`, `write` omitted so it defaults to
459
- * `false`). Never throws — each check below is independently guarded so
460
- * one failing check cannot suppress the others; there is no outer
461
- * catch-all because none is needed (see the design note below).
462
- *
463
- * An empty directory — nothing in `.planning/`, no `.platform-bindings.json`
464
- * — returns `{ ok: true, violations: [] }`. Every check requires some
465
- * positive signal that a partial init was attempted (a config file present
466
- * for `INIT_IDENTITY_MISSING`; a "gitlab"/"azure" `project.provider` for
467
- * `INIT_VAULT_PROVIDER_UNSET`; an installed org-profile-shaped package for
468
- * `INIT_QUESTIONS_ORG_PROFILE_UNASKED`; a `composable: true` dry run for
469
- * `INIT_COMPOSITION_NOT_RUN`) — an empty directory carries none of them, so
470
- * nothing is reported as skipped. This is deliberate (see the module
471
- * header): nothing was attempted, so nothing can be reported as attempted-
472
- * and-abandoned.
473
- */
387
+ /** Report every invariant the project's state at `cwd` violates. Read-only and never throws;
388
+ * each check needs a positive signal that init was attempted, so an empty directory is ok. */
474
389
  function verifyInitInvariants(cwd, opts = {}) {
475
390
  const violations = [];
476
391
  const { exists: configExists, parsed } = readConfigFile(cwd);
@@ -491,14 +406,11 @@ function verifyInitInvariants(cwd, opts = {}) {
491
406
  catch {
492
407
  // fail open — one check's fault must not suppress the others
493
408
  }
494
- // INIT_VAULT_PROVIDER_UNSET — project.provider names a provisioning
495
- // provider (an identity record, D-13) but vault.provider —
496
- // resolveProvider's ONLY gating key — was never set, so the D-13
497
- // provisioning gate this phase built never fires.
409
+ // `project.provider` names a vault provider but `vault.provider` was never set.
498
410
  try {
499
411
  const projectProvider = getDotted(parsed, 'project.provider');
500
412
  const vaultProvider = getDotted(parsed, 'vault.provider');
501
- if ((projectProvider === 'gitlab' || projectProvider === 'azure') && !isUsableString(vaultProvider)) {
413
+ if (git_provider_cjs_1.PROVIDERS.includes(projectProvider) && !isUsableString(vaultProvider)) {
502
414
  violations.push({
503
415
  code: INVARIANT_CODE.VAULT_PROVIDER_UNSET,
504
416
  message: `project.provider is "${String(projectProvider)}" but vault.provider is unset — the D-13 provisioning gate is disabled.`,
@@ -508,31 +420,7 @@ function verifyInitInvariants(cwd, opts = {}) {
508
420
  catch {
509
421
  // fail open
510
422
  }
511
- // INIT_QUESTIONS_ORG_PROFILE_UNASKED — an org-profile-shaped package is
512
- // installed (project node_modules or the machine's global root) but
513
- // org.profile was never set, i.e. Question 6 appears to have been
514
- // skipped rather than answered blank.
515
- // A recorded skip (init.org_profile_declined, the JSON boolean true and
516
- // nothing else) is an answer. A set pointer is checked first; the marker is
517
- // checked before the node_modules scan.
518
- try {
519
- const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
520
- const orgProfileDeclined = getDotted(parsed, exports.INIT_ORG_PROFILE_DECLINED_KEY) === true;
521
- if (!isUsableString(orgProfilePointer) && !orgProfileDeclined && orgProfilePackageResolvable(cwd, opts)) {
522
- violations.push({
523
- code: INVARIANT_CODE.ORG_PROFILE_UNASKED,
524
- message: 'An org-profile-shaped package is installed but org.profile is unset and no skip was recorded — the pointer question (Question 6) appears not to have been answered. ' +
525
- 'Re-run "pp-tools init --reset" and either type the package name to use it, or press Enter (or type "none") to record a skip.',
526
- });
527
- }
528
- }
529
- catch {
530
- // fail open
531
- }
532
- // INIT_COMPOSITION_NOT_RUN — a binding was composable from the org
533
- // profile and project answers, but .platform-bindings.json was never
534
- // written and no explicit decline was recorded — Step 2.5 appears to
535
- // have been skipped rather than run-and-declined.
423
+ // A binding was composable but never written, and no decline was recorded.
536
424
  try {
537
425
  const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
538
426
  const bindingsExist = node_fs_1.default.existsSync(bindingsPath);
@@ -542,7 +430,8 @@ function verifyInitInvariants(cwd, opts = {}) {
542
430
  if (dryRun.composable === true) {
543
431
  violations.push({
544
432
  code: INVARIANT_CODE.COMPOSITION_NOT_RUN,
545
- message: 'A binding is composable from the org profile and project answers, but .platform-bindings.json was never written and no decline was recorded — Step 2.5 appears to have been skipped.',
433
+ message: 'A binding is composable from the org profile and project answers, but .platform-bindings.json was never written and no decline was recorded — Step 2.5 appears to have been skipped. ' +
434
+ 'Run "pp-tools init --reset" to redo the question set and reach the compose confirmation (init-friction F3: a completed session never re-offers composition on a bare re-run).',
546
435
  });
547
436
  }
548
437
  }
@@ -550,32 +439,111 @@ function verifyInitInvariants(cwd, opts = {}) {
550
439
  catch {
551
440
  // fail open
552
441
  }
442
+ // `vault.enabled` present but not a boolean: reported, never repaired (the session scope is exempt).
443
+ try {
444
+ if (opts.scope !== 'session' && configExists) {
445
+ const vaultEnabled = resolveVaultEnabledState(parsed);
446
+ if (vaultEnabled.state === 'malformed') {
447
+ // Bounded like every record field; JSON.stringify keeps the value's type visible.
448
+ const rendered = overrideHelpers().sanitizeOverrideReason(String(JSON.stringify(vaultEnabled.value)));
449
+ // Phase 15 D-32: what this value does to the gates is fixed BY CONSTRUCTION, so it is
450
+ // stated, not mirrored and not measured here (measuring would call loadConfig, which
451
+ // spawns git, and this check spawns nothing). The gates' config loader resolves vault
452
+ // keys through federated-config, which accepts only a JSON boolean and falls back to the
453
+ // default (off) for anything else. The old mirror, Boolean(value), told the operator
454
+ // that every truthy non-boolean armed the gates; the shipped gate armed on none of them.
455
+ const split = 'The config loader the vault gates read accepts only a JSON boolean and falls back to ' +
456
+ 'off, so from this value the two blocking vault gates do NOT arm, and /pp-init Step 2.6 ' +
457
+ 'and this reachability check require the boolean true, so they are inert too. ';
458
+ violations.push({
459
+ code: INVARIANT_CODE.VAULT_ENABLED_MALFORMED,
460
+ message: `vault.enabled in .planning/config.json is ${rendered}, which is not a JSON boolean. ` +
461
+ split +
462
+ 'Store the boolean with "pp-tools config-set vault.enabled true" (which accepts only ' +
463
+ 'true or false, and refuses any other spelling), or "pp-tools config-set vault.enabled ' +
464
+ 'false" to turn the capability off.',
465
+ });
466
+ }
467
+ }
468
+ }
469
+ catch {
470
+ // fail open — one check's fault must not suppress the others
471
+ }
472
+ // A proof is owed when the vault is armed, a profile applies and a binding exists (config only, no machine state).
473
+ // A failed or deferred record is reported whenever the vault is armed, binding or not.
474
+ try {
475
+ if (opts.scope !== 'session') {
476
+ const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
477
+ // FORK-ONLY (PBI 74316): with no pointer the embedded profile applies; only "none" opts out.
478
+ const profileApplies = isUsableString(orgProfilePointer)
479
+ ? orgProfilePointer.trim().toLowerCase() !== org_profile_cjs_1.ORG_PROFILE_NONE
480
+ : (0, org_profile_cjs_1.effectiveEmbeddedOrgProfilePath)() !== null;
481
+ const vaultArmed = configExists && isVaultEnabledInConfig(parsed);
482
+ const owesAProof = vaultArmed && profileApplies && node_fs_1.default.existsSync(node_path_1.default.join(cwd, '.platform-bindings.json'));
483
+ // Kept as an object: destructuring trips @typescript-eslint/unbound-method.
484
+ const helpers = overrideHelpers();
485
+ // Bound every record field again at print time; a non-string counts as absent.
486
+ const bounded = (v) => (typeof v === 'string' ? helpers.sanitizeOverrideReason(v) : '');
487
+ const profile = isUsableString(orgProfilePointer) ? bounded(orgProfilePointer) : org_profile_cjs_1.EMBEDDED_ORG_PROFILE_NAME;
488
+ const status = bounded(getDotted(parsed, exports.INIT_PROBE_STATUS_KEY));
489
+ const when = bounded(getDotted(parsed, exports.INIT_PROBE_WHEN_KEY));
490
+ const who = bounded(getDotted(parsed, exports.INIT_PROBE_WHO_KEY));
491
+ const note = bounded(getDotted(parsed, exports.INIT_PROBE_NOTE_KEY));
492
+ if (vaultArmed && status === exports.INIT_PROBE_STATUS.FAILED) {
493
+ violations.push({
494
+ code: INVARIANT_CODE.PROBE_FAILED,
495
+ message: `The reachability proof recorded FAILED on ${when} by ${who}: ${note}. ` +
496
+ 'Re-run "/pp-init" after fixing the remote, or record an attributed deferral with ' +
497
+ '"pp-tools query init-contract --defer-probe <reason>".',
498
+ });
499
+ }
500
+ else if (vaultArmed && status === exports.INIT_PROBE_STATUS.DEFERRED) {
501
+ // A deferral stays a violation until a passing probe overwrites it.
502
+ violations.push({
503
+ code: INVARIANT_CODE.PROBE_DEFERRED,
504
+ message: `The reachability proof was deferred by ${who} on ${when} for reason: ${note}. ` +
505
+ 'Init remains unproven until a re-run of "/pp-init" records a passing probe.',
506
+ });
507
+ }
508
+ else if (owesAProof) {
509
+ // Name the repair (re-run, or record a deferral) — never deletion.
510
+ const notRunRemedy = ' Re-run "/pp-init" to probe, or record an attributed deferral with ' +
511
+ '"pp-tools query init-contract --defer-probe <reason>".';
512
+ const notRun = (clause) => ({
513
+ code: INVARIANT_CODE.PROBE_NOT_RUN,
514
+ message: `The org profile "${profile}" resolves, vault.enabled is true and .platform-bindings.json ` +
515
+ `is present, but ${clause} — /pp-init Step 2.6 appears not to have run.` +
516
+ notRunRemedy,
517
+ });
518
+ if (status === exports.INIT_PROBE_STATUS.PASSED) {
519
+ // Only an attributed, timestamped pass clears the check.
520
+ if (!isUsableString(who) || !isUsableString(when)) {
521
+ violations.push(notRun('the reachability-proof record is malformed (init.probe_status is "passed" but ' +
522
+ `${!isUsableString(who) ? 'init.probe_who' : 'init.probe_when'} is absent, so the ` +
523
+ 'proof is unattributed)'));
524
+ }
525
+ }
526
+ else {
527
+ violations.push(notRun(status === ''
528
+ ? 'no reachability-proof record exists (init.probe_status is unset)'
529
+ : `no valid reachability-proof record exists (init.probe_status is "${status}", which is not a recognised status)`));
530
+ }
531
+ }
532
+ }
533
+ }
534
+ catch {
535
+ // fail open — one check's fault must not suppress the others
536
+ }
553
537
  return { ok: violations.length === 0, violations };
554
538
  }
555
- // ─── Session state machine (34-08 gap closure) ─────────────────────────────
556
- //
557
- // Why this exists at all: `buildInitQuestions`/`verifyInitInvariants` make
558
- // the CONTENT correct when called and the OMISSION provable afterwards —
559
- // they do not make skipping impossible, because prose can still hand an
560
- // agent the full 8-question list and the agent can still compress it (this
561
- // module's own header; `34-VERIFICATION.md`'s third walk). This session
562
- // machine removes the list itself: `initSessionNext` returns exactly ONE
563
- // step, `initSessionAnswer` records exactly ONE answer and rejects
564
- // anything out of order, and the CLI performs every side effect (identity
565
- // write, `vault.provider`, composition) at the state that needs it — never
566
- // leaving it to a workflow's own prose to remember. Neither
567
- // `buildInitQuestions` nor `verifyInitInvariants` nor `composeBindings` is
568
- // reimplemented here: this machine only sequences calls into them.
539
+ // ─── Session state machine: one step at a time, every side effect performed by the CLI ───
569
540
  function sessionFilePath(cwd) {
570
541
  return node_path_1.default.join(cwd, '.planning', INIT_SESSION_FILE_NAME);
571
542
  }
572
543
  function newSession() {
573
544
  return { version: 1, answers: {}, identityWritten: false, composeDecision: null, doneAt: null };
574
545
  }
575
- /** Validate the ENTIRE persisted shape before trusting it — a session file
576
- * that merely parses as JSON but carries the wrong shape (hand-edited,
577
- * truncated mid-write, a leftover from an incompatible future version) must
578
- * fail closed exactly like a parse error (T-34-63). */
546
+ /** Validate the whole persisted shape; a wrong shape fails closed like a parse error. */
579
547
  function isValidSessionShape(v) {
580
548
  if (!isObject(v))
581
549
  return false;
@@ -603,13 +571,18 @@ function isValidSessionShape(v) {
603
571
  }
604
572
  if (doneAt !== null && typeof doneAt !== 'string')
605
573
  return false;
574
+ const autoAnswers = v.autoAnswers;
575
+ if (autoAnswers !== undefined) {
576
+ if (!isObject(autoAnswers))
577
+ return false;
578
+ for (const src of Object.values(autoAnswers)) {
579
+ if (src !== 'org-profile' && src !== 'derived')
580
+ return false;
581
+ }
582
+ }
606
583
  return true;
607
584
  }
608
- /** Absent file → a brand-new session (the normal first call). Present and
609
- * valid → that session. Present but invalid in ANY way → `{ malformed:
610
- * true }`, and every caller below refuses to proceed rather than silently
611
- * re-initializing (T-34-63) — `initSessionReset` is the only documented
612
- * recovery. Never throws. */
585
+ /** Absent → a new session; invalid → `{ malformed: true }` (recover with `--session-reset`). Never throws. */
613
586
  function loadSession(cwd) {
614
587
  const p = sessionFilePath(cwd);
615
588
  try {
@@ -627,8 +600,7 @@ function loadSession(cwd) {
627
600
  return { malformed: true };
628
601
  }
629
602
  }
630
- /** CR-01: format an unexpected write failure (e.g. EACCES) as a bounded,
631
- * human-readable message — never a raw stack trace reaching the operator. */
603
+ /** Format an unexpected write failure as a bounded message — never a raw stack trace. */
632
604
  function writeFailedMessage(err) {
633
605
  const detail = err instanceof Error ? err.message : String(err);
634
606
  return (`A filesystem write inside the init session failed (${detail}). ` +
@@ -641,13 +613,7 @@ function persistSession(cwd, session) {
641
613
  (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
642
614
  (0, shell_command_projection_cjs_1.platformWriteSync)(sessionFilePath(cwd), JSON.stringify(session, null, 2) + '\n');
643
615
  }
644
- /** Read `.planning/config.json` for a MERGE write — distinct from
645
- * `readConfigFile` above (which reports `exists`/malformed for
646
- * `verifyInitInvariants`'s read-only purposes): this helper only ever
647
- * needs "the object to merge into", degrading to `{}` on any absence or
648
- * fault so a merge write never crashes on a pre-existing malformed file —
649
- * it simply starts the merge from empty and the write below then produces
650
- * a well-formed file going forward. */
616
+ /** The config to merge into; `{}` on absence or fault, so a merge write never crashes. */
651
617
  function readConfigForMerge(cwd) {
652
618
  try {
653
619
  const raw = node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8');
@@ -658,13 +624,7 @@ function readConfigForMerge(cwd) {
658
624
  return {};
659
625
  }
660
626
  }
661
- /** Shallow-recursive merge: object values merge key-by-key; any other
662
- * value (string, number, boolean, array) replaces the target outright.
663
- * Bounded by the patches this module ever constructs itself (`project.*`,
664
- * `vault.provider`, `org.profile`, `init.compose_declined` — never a
665
- * caller-supplied arbitrary depth), so no depth cap is needed here the way
666
- * `walkLeaves` in `compose-bindings.cts` needs one for untrusted file
667
- * content. */
627
+ /** Recursive merge of plain objects; any other value replaces the target. */
668
628
  function deepMergeConfigPatch(target, patch) {
669
629
  for (const k of Object.keys(patch)) {
670
630
  const pv = patch[k];
@@ -678,28 +638,14 @@ function deepMergeConfigPatch(target, patch) {
678
638
  }
679
639
  return target;
680
640
  }
681
- /** The ONLY place this module writes `.planning/config.json` — every side
682
- * effect the session performs (identity write, `vault.provider`,
683
- * `org.profile`, `init.compose_declined`) routes through here, merged
684
- * against whatever is already on disk so a session step never clobbers an
685
- * unrelated key a human or another tool wrote. Direct `fs`/`JSON` I/O,
686
- * bypassing `config-schema.cts`'s registered-key validation — the same
687
- * convention `compose-bindings.cts`'s `cfgGet` and this module's own
688
- * `readConfigFile` already use for `project.*`/`org.profile` reads; see
689
- * `.planning/todos/pending/project-config-key-unknown.md` for why `project`
690
- * is not (yet) a registered top-level key, out of scope for this plan. */
641
+ /** The only writer of `.planning/config.json` in this module; merges so unrelated keys survive. */
691
642
  function writeConfigMerge(cwd, patch) {
692
643
  const dir = node_path_1.default.join(cwd, '.planning');
693
644
  (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
694
645
  const merged = deepMergeConfigPatch(readConfigForMerge(cwd), patch);
695
646
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dir, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
696
647
  }
697
- /** The batch write Task 1 §2 describes: once all 8 answers are recorded,
698
- * write the `project` identity block and — only when the `provider` answer
699
- * is exactly `gitlab` or `azure` — `vault.provider`. `org_profile` is
700
- * excluded from this map: it is written immediately when THAT answer is
701
- * recorded (see `initSessionAnswer`), not deferred here, precisely so the
702
- * `provider` question re-resolves its `preselected` against it. */
648
+ /** Write the project identity block, and `vault.provider` when the provider is a vault provider (gitlab, azure or github). */
703
649
  function writeIdentityAndProvider(cwd, answers) {
704
650
  const projectPatch = {};
705
651
  for (const [questionId, configKey] of Object.entries(PROJECT_BLOCK_QUESTION_MAP)) {
@@ -710,14 +656,124 @@ function writeIdentityAndProvider(cwd, answers) {
710
656
  }
711
657
  const patch = { project: projectPatch };
712
658
  const providerAnswer = answers.provider;
713
- if (providerAnswer === 'gitlab' || providerAnswer === 'azure') {
659
+ if (git_provider_cjs_1.PROVIDERS.includes(providerAnswer)) {
714
660
  patch.vault = { provider: providerAnswer };
715
661
  }
716
662
  writeConfigMerge(cwd, patch);
717
663
  }
718
- /** Case/whitespace-tolerant yes/no parse for the `compose_confirm` answer.
719
- * `null` for anything else — never defaulted to a decline, so a typo'd
720
- * answer is rejected rather than silently treated as "no". */
664
+ /** The ONE owner of the arm-where-absent rule (quick 261003-kmd): arms `vault.enabled`,
665
+ * `vault.practices_enabled` and `vault.required` only where ABSENT, from one read in one write.
666
+ *
667
+ * A successful composition means a client vault exists, so the three keys are armed (OD-1,
668
+ * 260915-lmc, 260916-s5n). An explicit `false` or a malformed value is kept — that is the
669
+ * operator's decision, never overwritten (07-09 WR-07). The write happens only when there is
670
+ * something to arm: the nested- and flat-form byte-identity pins depend on a no-op writing
671
+ * nothing. Callers: the session's `'unchanged'` decision (how existing projects get armed by a
672
+ * plain re-run), the approved `compose_confirm`, and `armVaultFromProfile` (`/pp-new-project`).
673
+ * It exists because the two session copies had already drifted once — the practices key was
674
+ * added to both by hand in 260915-lmc — and a third route would have been a third copy.
675
+ *
676
+ * @returns the armed key names, in the fixed order enabled, practices_enabled, required. */
677
+ function armVaultKeysWhereAbsent(cwd) {
678
+ const { parsed } = readConfigFile(cwd);
679
+ const vaultPatch = {};
680
+ if (resolveVaultEnabledState(parsed).state === 'absent') {
681
+ vaultPatch.enabled = true;
682
+ }
683
+ if (resolveVaultPracticesEnabledState(parsed).state === 'absent') {
684
+ vaultPatch.practices_enabled = true;
685
+ }
686
+ if (resolveVaultRequiredState(parsed).state === 'absent') {
687
+ vaultPatch.required = true;
688
+ }
689
+ const armed = Object.keys(vaultPatch);
690
+ if (armed.length > 0) {
691
+ writeConfigMerge(cwd, { vault: vaultPatch });
692
+ }
693
+ return armed;
694
+ }
695
+ /** The reason triad and identity chain, reused from the vault-trace leaf (never re-derived here).
696
+ * Required lazily: pp-tools loads this module on every command, and the trace leaf is large. */
697
+ function overrideHelpers() {
698
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
699
+ const trace = require('./vault-trace.cjs');
700
+ return trace;
701
+ }
702
+ /** Build the record with `who` and `when` bounded; `status` is checked against `INIT_PROBE_STATUS`. */
703
+ function buildProbeRecord(cwd, status, note, opts, helpers) {
704
+ const injectedWho = typeof opts._who === 'string' && opts._who.trim() !== '' ? opts._who : null;
705
+ const rawWho = injectedWho ?? helpers.resolveOverrideIdentity(cwd);
706
+ // Never an empty string and never an omitted key — an absent `who` and an
707
+ // unattributable one must not look identical (the trace leaf's own rule).
708
+ const who = helpers.sanitizeOverrideReason(rawWho) || 'unknown';
709
+ const when = helpers.sanitizeOverrideReason(opts.now ?? new Date().toISOString());
710
+ return { status, when, who, note };
711
+ }
712
+ /** The shared record write: four keys merged under `init`. Never throws (`write_failed`). */
713
+ function writeProbeRecord(cwd, record) {
714
+ try {
715
+ writeConfigMerge(cwd, {
716
+ init: {
717
+ probe_status: record.status,
718
+ probe_when: record.when,
719
+ probe_who: record.who,
720
+ probe_note: record.note,
721
+ },
722
+ });
723
+ }
724
+ catch (err) {
725
+ return { ok: false, reason: 'write_failed', message: writeFailedMessage(err) };
726
+ }
727
+ return { ok: true, record };
728
+ }
729
+ /** Record that the Step 2.6 probe passed or failed; an unknown status or an empty note is refused, writing nothing.
730
+ * Deferrals go through `recordProbeDeferral`. */
731
+ function recordProbeOutcome(cwd, input, opts = {}) {
732
+ const helpers = overrideHelpers();
733
+ const status = input?.status;
734
+ if (status !== exports.INIT_PROBE_STATUS.PASSED && status !== exports.INIT_PROBE_STATUS.FAILED) {
735
+ return {
736
+ ok: false,
737
+ reason: 'invalid_probe_status',
738
+ message: `Cannot record probe status ${JSON.stringify(status)} — expected ` +
739
+ `"${exports.INIT_PROBE_STATUS.PASSED}" or "${exports.INIT_PROBE_STATUS.FAILED}". ` +
740
+ `A deferral is recorded with --defer-probe "<reason>", which requires a stated reason.`,
741
+ };
742
+ }
743
+ // A non-string note counts as no note (never String()-coerced).
744
+ const note = helpers.sanitizeOverrideReason(typeof input?.note === 'string' ? input.note : '');
745
+ if (note === '') {
746
+ return {
747
+ ok: false,
748
+ reason: 'probe_note_required',
749
+ message: 'Cannot record a probe outcome with no note. Say what the probe measured — which targets ' +
750
+ 'were reachable, and for a failure which one was not — so the record is evidence and not ' +
751
+ 'just a verdict.',
752
+ };
753
+ }
754
+ return writeProbeRecord(cwd, buildProbeRecord(cwd, status, note, opts, helpers));
755
+ }
756
+ /** Record a deferral of the Step 2.6 proof; the reason is required and validated, and a refusal writes nothing.
757
+ * `--verify` keeps reporting it until a passing probe overwrites it. */
758
+ function recordProbeDeferral(cwd, reason, opts = {}) {
759
+ const helpers = overrideHelpers();
760
+ const refusal = (rejection) => ({
761
+ ok: false,
762
+ reason: 'deferral_reason_rejected',
763
+ message: `Deferral refused: ${rejection}. State why the reachability proof is being deferred — ` +
764
+ 'at least 20 characters, containing letters, not a placeholder.',
765
+ });
766
+ if (typeof reason !== 'string' || reason.trim() === '') {
767
+ return refusal('no reason was given');
768
+ }
769
+ const verdict = helpers.validateOverrideReason(reason.trim());
770
+ if (verdict.state !== 'accepted') {
771
+ return refusal(verdict.state === 'rejected' ? verdict.rejection : 'the reason was not accepted');
772
+ }
773
+ const note = helpers.sanitizeOverrideReason(verdict.reason);
774
+ return writeProbeRecord(cwd, buildProbeRecord(cwd, exports.INIT_PROBE_STATUS.DEFERRED, note, opts, helpers));
775
+ }
776
+ /** Parse a `compose_confirm` answer; `null` for anything unrecognised (never a silent "no"). */
721
777
  function normalizeYesNo(value) {
722
778
  if (typeof value !== 'string')
723
779
  return null;
@@ -728,43 +784,27 @@ function normalizeYesNo(value) {
728
784
  return false;
729
785
  return null;
730
786
  }
731
- /**
732
- * The single decision function every public session entry point drives.
733
- * Recomputes `buildInitQuestions(cwd)` FRESH on every call — never cached
734
- * across calls — which is what makes the `provider` question's
735
- * `preselected` reflect an `org_profile` answer given moments earlier in
736
- * the SAME session (the walk-3 regression this plan's design hazards
737
- * name): by the time this function is asked for the `provider` question,
738
- * `org.profile` has already been written to disk by `initSessionAnswer`,
739
- * so `buildInitQuestions`'s own `resolveProvider` call sees it.
740
- *
741
- * Performs three idempotent side effects, each guarded by its own session
742
- * flag so re-calling this (as every public entry point does, to validate
743
- * the current step) never repeats a write: the identity+provider batch
744
- * write once all 8 questions are answered; marking `composeDecision:
745
- * 'skipped'` when nothing is composable at all (the real inert path,
746
- * INIT-02) or `composeDecision: 'unchanged'` when a binding IS composable
747
- * but the existing file already matches it (a healthy, up-to-date state —
748
- * NOT the inert path, and reported with its own distinct message; no
749
- * `compose_confirm` prompt either way, nothing written); and recording
750
- * `doneAt` once `verifyInitInvariants` first reports `ok: true`.
751
- */
787
+ /** Decide the current step, performing its idempotent side effects once: automatic answers, the identity write,
788
+ * the compose decision and `doneAt`. Recomputes the question set on every call. */
752
789
  function computeCurrentStep(cwd, session, opts) {
753
- // CR-01: every sibling module in this seam (composeBindings,
754
- // resolveOrgProfileDocument, resolveOrgProfile, verifyInitInvariants)
755
- // wraps its entire body in a top-level try/catch so a filesystem fault
756
- // degrades to a bounded result rather than crashing (D-09). This
757
- // function's writes (writeIdentityAndProvider/persistSession, below) did
758
- // not — an unwritable .planning directory threw an uncaught EACCES
759
- // straight out of initSessionNext/initSessionAnswer. Fixed by matching
760
- // the convention: any failure anywhere in this body degrades to a
761
- // `write_failed` verdict instead of propagating.
790
+ // Any failure degrades to a `write_failed` step instead of throwing.
762
791
  try {
763
- const questions = buildInitQuestions(cwd, { _globalRoot: opts._globalRoot });
792
+ const questions = buildInitQuestions(cwd);
793
+ // PBI 74299: read once per call; only consulted for an unanswered question.
794
+ let profileInit = null;
764
795
  for (const q of questions) {
765
- if (!Object.prototype.hasOwnProperty.call(session.answers, q.id)) {
796
+ if (Object.prototype.hasOwnProperty.call(session.answers, q.id))
797
+ continue;
798
+ profileInit ??= readProfileInitAnswers(cwd, { _globalRoot: opts._globalRoot });
799
+ const auto = autoAnswerFor(q.id, session.answers, profileInit);
800
+ if (auto === null) {
766
801
  return { kind: 'question', question: q };
767
802
  }
803
+ // Filled in once and recorded, so a --session-next poll writes the
804
+ // session file only the first time an answer is decided.
805
+ session.answers[q.id] = auto.value;
806
+ session.autoAnswers = { ...(session.autoAnswers ?? {}), [q.id]: auto.source };
807
+ persistSession(cwd, session);
768
808
  }
769
809
  if (!session.identityWritten) {
770
810
  writeIdentityAndProvider(cwd, session.answers);
@@ -773,20 +813,10 @@ function computeCurrentStep(cwd, session, opts) {
773
813
  }
774
814
  if (session.composeDecision === null) {
775
815
  const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: opts.offline === true, _globalRoot: opts._globalRoot });
776
- // WR-01: only offer compose_confirm when approving it would actually
777
- // change something. `action: 'unchanged'` means the existing file
778
- // already carries every key the composer would produce (D-08's
779
- // absent-only merge) — asking the operator to approve a write that
780
- // writes nothing is exactly the shape of prompt that produced the
781
- // false-success report this finding names. Fall through to the
782
- // messaging below instead of asking — but see the coordinator
783
- // follow-up: 'unchanged' and 'not composable at all' are DIFFERENT
784
- // end states and must record (and later report) distinct decisions,
785
- // never sharing the inert-path (INIT-02) message below.
816
+ // Offer compose_confirm only when approving would change something; 'unchanged' and 'skipped' are distinct end states.
786
817
  if (dryRun.composable === true && dryRun.action !== 'unchanged') {
787
- // Pack 6 D-07: `info` is display-only (the CLI and the TTY renderer
788
- // print it); approval re-composes with `write: true`, so the written
789
- // file keeps the RAW coordinates.
818
+ // VAULT-EGRESS-1: `info` is display-only (the CLI and the TTY renderer print it); approval
819
+ // re-composes with `write: true` below, so the written file keeps the RAW coordinates.
790
820
  const shown = (0, compose_bindings_cjs_1.redactComposeResultForEmission)(dryRun);
791
821
  return {
792
822
  kind: 'compose_confirm',
@@ -795,25 +825,24 @@ function computeCurrentStep(cwd, session, opts) {
795
825
  drift: shown.drift,
796
826
  action: dryRun.action,
797
827
  composed: shown.composed,
798
- warnings: shown.warnings,
828
+ warnings: dryRun.warnings,
799
829
  },
800
830
  };
801
831
  }
802
832
  session.composeDecision = dryRun.composable === true && dryRun.action === 'unchanged' ? 'unchanged' : 'skipped';
833
+ if (session.composeDecision === 'unchanged') {
834
+ armVaultKeysWhereAbsent(cwd);
835
+ }
803
836
  persistSession(cwd, session);
804
837
  }
805
- // Three genuinely different end states can reach `done`/`verify` with
806
- // nothing written, and each needs its own message — conflating them
807
- // (as an earlier pass of this fix did) tells an operator with a
808
- // healthy, up-to-date binding that their org profile produced nothing
809
- // composable, which is exactly the "tool reports a state that isn't
810
- // the real one" failure shape this phase exists to eliminate.
838
+ // Three different nothing-written end states, each with its own message.
811
839
  const skippedComposeInfo = session.composeDecision === 'skipped'
812
840
  ? 'Nothing was composable from the org profile and project answers — no compose_confirm step was shown and nothing was written (the inert path, INIT-02).'
813
841
  : session.composeDecision === 'unchanged'
814
842
  ? 'The existing .platform-bindings.json is already up to date with what the org profile and project answers would produce — no compose_confirm step was needed and nothing was written.'
815
843
  : undefined;
816
- const verifyResult = verifyInitInvariants(cwd, { _globalRoot: opts._globalRoot });
844
+ // The session verify skips only the INIT_PROBE_* check: Step 2.6 records the proof after init returns.
845
+ const verifyResult = verifyInitInvariants(cwd, { _globalRoot: opts._globalRoot, scope: 'session' });
817
846
  if (verifyResult.ok) {
818
847
  if (!session.doneAt) {
819
848
  session.doneAt = new Date().toISOString();
@@ -873,13 +902,7 @@ function currentStepToState(step) {
873
902
  };
874
903
  }
875
904
  }
876
- /**
877
- * Return the ONE next step of the `/pp-init` session at `cwd` — a question,
878
- * `compose_confirm`, `verify`, or `done`. Never a list. Fails closed (T-34-
879
- * 63) with `error: INIT_SESSION_MALFORMED` when the session file exists but
880
- * does not parse as valid session state; `--session-reset` is the
881
- * documented recovery, never a silent re-initialize.
882
- */
905
+ /** Return the ONE next step of the session; malformed session state fails closed (recover with `--session-reset`). */
883
906
  function initSessionNext(cwd, opts = {}) {
884
907
  const loaded = loadSession(cwd);
885
908
  if (loaded.malformed) {
@@ -892,25 +915,12 @@ function initSessionNext(cwd, opts = {}) {
892
915
  'Run "pp_run query init-contract --session-reset" to start over — it is never resumed silently into an unknown state.',
893
916
  };
894
917
  }
895
- return currentStepToState(computeCurrentStep(cwd, loaded.session, opts));
918
+ const session = loaded.session;
919
+ const state = currentStepToState(computeCurrentStep(cwd, session, opts));
920
+ const autoAnswered = listAutoAnswers(cwd, session);
921
+ return autoAnswered.length > 0 ? { ...state, autoAnswered } : state;
896
922
  }
897
- /**
898
- * Record ONE answer for the CURRENT step only. Rejects — with `ok: false`
899
- * and a named `reason`, never a throw — an answer for any step other than
900
- * the one `initSessionNext` would currently serve (T-34-60: an agent trying
901
- * to batch or skip ahead fails loudly), a blank answer to a
902
- * non-`optionalAnswer` question, an unparseable `compose_confirm` value, or
903
- * any answer once the session is already `done`.
904
- *
905
- * The `org_profile` answer is written to `.planning/config.json` (`org.profile`)
906
- * IMMEDIATELY when it selects a package — the one exception to the "batch
907
- * write after the last answer" rule in `computeCurrentStep`'s own doc
908
- * comment — because the very next question (`provider`) must see it to
909
- * re-resolve its `preselected` (the walk-3 regression Task 1's acceptance
910
- * names). A decline (`isOrgProfileDecline`: blank, or `none` in any case) is
911
- * recorded instead as `init.org_profile_declined: true` when a profile was
912
- * detectable.
913
- */
923
+ /** Record one answer for the current step only; out-of-order, blank required, unparseable or post-done answers are rejected. */
914
924
  function initSessionAnswer(cwd, stepId, value, opts = {}) {
915
925
  const loaded = loadSession(cwd);
916
926
  if (loaded.malformed) {
@@ -922,14 +932,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
922
932
  };
923
933
  }
924
934
  const session = loaded.session;
925
- // CR-01: wrap the entire remaining body — every write this function
926
- // performs directly (the `org_profile` immediate write, the batch
927
- // `persistSession` calls, the `compose_confirm` decline/approve writes)
928
- // — in a top-level try/catch, matching the D-09 convention every sibling
929
- // module in this seam already follows. `computeCurrentStep` degrades to
930
- // a `write_failed` step on its own internal failures (handled explicitly
931
- // below); this catch additionally covers the write sites that live in
932
- // THIS function, after `computeCurrentStep` returns.
935
+ // Any write failure in this function degrades to `write_failed` instead of throwing.
933
936
  try {
934
937
  const step = computeCurrentStep(cwd, session, opts);
935
938
  if (step.kind === 'write_failed') {
@@ -954,32 +957,6 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
954
957
  return { ok: false, reason: 'answer_required', message: `Question "${stepId}" requires a non-blank answer.` };
955
958
  }
956
959
  const answerValue = typeof value === 'string' ? value : '';
957
- if (stepId === 'org_profile') {
958
- if (isOrgProfileDecline(answerValue)) {
959
- // Enter and `none` leave the same session record. The marker is
960
- // written exactly when the UNASKED violation would otherwise fire
961
- // (the same predicate); with no profile detectable nothing was
962
- // offered, so nothing is recorded.
963
- session.answers[stepId] = '';
964
- if (orgProfilePackageResolvable(cwd, { _globalRoot: opts._globalRoot })) {
965
- writeConfigMerge(cwd, { init: { org_profile_declined: true } });
966
- }
967
- persistSession(cwd, session);
968
- return { ok: true };
969
- }
970
- const trimmed = answerValue.trim();
971
- session.answers[stepId] = answerValue;
972
- const orgPatch = { org: { profile: trimmed } };
973
- // A selection after an earlier recorded skip writes the marker back to
974
- // false in the same merge, so a stale decline never sits beside a set
975
- // pointer. No write when the marker is absent.
976
- if (getDotted(readConfigFile(cwd).parsed, exports.INIT_ORG_PROFILE_DECLINED_KEY) === true) {
977
- orgPatch.init = { org_profile_declined: false };
978
- }
979
- writeConfigMerge(cwd, orgPatch);
980
- persistSession(cwd, session);
981
- return { ok: true };
982
- }
983
960
  session.answers[stepId] = answerValue;
984
961
  persistSession(cwd, session);
985
962
  return { ok: true };
@@ -1001,24 +978,10 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
1001
978
  };
1002
979
  }
1003
980
  if (normalized === true) {
1004
- // WR-02: inspect the write-mode result before recording the
1005
- // decision. The dry run inside computeCurrentStep already reported
1006
- // composable:true/action!=='unchanged' moments earlier, but the
1007
- // world can change between that dry run and this write (the file
1008
- // became malformed on disk in between, a transient fault now caught
1009
- // internally by composeBindings's own outer catch, etc.) — in that
1010
- // case `wrote` comes back false even though the operator approved.
1011
- // Recording 'approved' unconditionally here would make the session
1012
- // proceed toward done/verify as though the write had succeeded,
1013
- // and the eventual INIT_COMPOSITION_NOT_RUN violation would then
1014
- // misdescribe what happened ("Step 2.5 appears to have been
1015
- // skipped") when in fact the user approved and the write failed.
981
+ // Check the write result before recording "approved": the file can change between the dry run and this write.
1016
982
  const writeResult = (0, compose_bindings_cjs_1.composeBindings)(cwd, { write: true, offline: opts.offline === true, _globalRoot: opts._globalRoot });
1017
983
  if (writeResult.composable !== true || writeResult.wrote !== true) {
1018
- // Do NOT record composeDecision here — leave it null so the next
1019
- // --session-next call re-evaluates from a fresh dry run (and, if
1020
- // still composable, re-offers compose_confirm) rather than
1021
- // silently treating this as skipped or declined.
984
+ // Leave the decision unset so the next call re-evaluates and can re-offer compose_confirm.
1022
985
  return {
1023
986
  ok: false,
1024
987
  reason: 'compose_write_failed',
@@ -1029,6 +992,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
1029
992
  };
1030
993
  }
1031
994
  session.composeDecision = 'approved';
995
+ armVaultKeysWhereAbsent(cwd);
1032
996
  }
1033
997
  else {
1034
998
  writeConfigMerge(cwd, { init: { compose_declined: true } });
@@ -1048,8 +1012,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
1048
1012
  return { ok: false, reason: 'write_failed', message: writeFailedMessage(err) };
1049
1013
  }
1050
1014
  }
1051
- /** Delete the session file, the documented recovery from a malformed
1052
- * session (T-34-63). Never throws; a missing file is not an error. */
1015
+ /** Delete the session file (the recovery from a malformed session). Never throws. */
1053
1016
  function initSessionReset(cwd) {
1054
1017
  try {
1055
1018
  const p = sessionFilePath(cwd);
@@ -1061,193 +1024,114 @@ function initSessionReset(cwd) {
1061
1024
  }
1062
1025
  return { ok: true };
1063
1026
  }
1064
- /**
1065
- * `init-contract --arm-vault`: turn the client vault on for a project whose
1066
- * client vault is bound. Writes `vault.enabled: true` ONLY when the key is
1067
- * absent — an explicit `false` (or any other explicit value) is the
1068
- * operator's decision and is kept. When the vault ends up enabled it also
1069
- * writes `vault.required: true` where that key is absent: a project with a
1070
- * bound client vault should stop when that vault stops loading, instead of
1071
- * building without it after a confirmation. An explicit `vault.required`
1072
- * (including `false`) is kept, and so is an org profile's value: when the
1073
- * profile sets `vault.required` nothing is written, because the gate already
1074
- * reads it (project > profile > default). It also writes
1075
- * `vault.practices_enabled: true` where that key is absent, so the project
1076
- * records which client practices each phase applied from its first phase on;
1077
- * an explicit value is kept. All keys go in one write. With no client vault bound (no or
1078
- * malformed `.platform-bindings.json`, or `client.vaultRepo` /
1079
- * `client.vaultRef` missing — the same "bound" the gate's verdict uses) it
1080
- * writes nothing and returns `ok: false` naming /pp-init. A present but
1081
- * unparseable `.planning/config.json` is never overwritten. Never throws.
1082
- * The reported coordinate is display-safe (D-07).
1083
- */
1084
- function armVault(cwd) {
1085
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1086
- const vaultMod = require('./vault.cjs');
1087
- let binding = { bound: false, repo: null, ref: null };
1088
- try {
1089
- binding = vaultMod.readClientBinding(cwd);
1090
- }
1091
- catch {
1092
- /* unbound */
1093
- }
1094
- const client = { repo: vaultMod.redactCloneTarget(binding.repo), ref: binding.ref };
1095
- const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
1096
- let cfg = {};
1097
- let cfgExists = false;
1027
+ /** Remedy every failure verdict names: repair through the interactive relay, never deletion. */
1028
+ const ARM_VAULT_REMEDY = 'Run "/pp-init" to relay the binding compose interactively and arm the vault.';
1029
+ /** Compose the binding from the resolved org profile and arm the vault keys where absent, with no prompt
1030
+ * (quick 261003-kmd, FIX-1). `/pp-new-project` calls this so a project created through it is governed the
1031
+ * same way `/pp-init` leaves one; before it existed, only `/pp-init` armed the vault and a new-project
1032
+ * route left it silently off.
1033
+ *
1034
+ * The binding is written through `composeBindings` — the SAME composer `/pp-init` approves through — so
1035
+ * its shape and provenance are identical, and the keys through `armVaultKeysWhereAbsent`, the one owner.
1036
+ * An inert verdict (a recorded decline, a `vault.enabled` that is set but not the boolean `true`, or no
1037
+ * org profile resolves) writes nothing of any kind and never creates `.planning/`. The `vault_disabled`
1038
+ * arm exists because this route has no human confirm: an explicit `false` (or a malformed value) is the
1039
+ * operator's opt-out, so writing a binding and two keys into that project would turn `vault.status` from
1040
+ * `inert` into a verdict and let a report say "armed" where the gate says the vault is not governing
1041
+ * (261003-kmd verification gap; the more inert reading). `/pp-init`'s approved compose is unchanged — a
1042
+ * human confirmed it. `vault.provider` is deliberately NOT written: `resolveProvider` in
1043
+ * `src/git-provider.cts` already layers the org profile's provider when the config key is absent, so the
1044
+ * fetch resolves the right provider without a second writer. Never reads or writes the session file.
1045
+ * Never throws. */
1046
+ function armVaultFromProfile(cwd, opts = {}) {
1098
1047
  try {
1099
- cfgExists = node_fs_1.default.existsSync(configPath);
1100
- if (cfgExists) {
1101
- const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
1102
- if (!isObject(parsed))
1103
- throw new Error('not an object');
1104
- cfg = parsed;
1048
+ const { parsed } = readConfigFile(cwd);
1049
+ if (capabilityActivation.getConfigValueEitherForm(parsed, exports.INIT_ORG_PROFILE_DECLINED_KEY).value === true) {
1050
+ return {
1051
+ ok: true,
1052
+ armed: false,
1053
+ reason: 'org_profile_declined',
1054
+ message: 'The org profile was declined for this project (init.org_profile_declined), so nothing was composed or armed.',
1055
+ };
1105
1056
  }
1106
- }
1107
- catch {
1108
- return {
1109
- ok: false,
1110
- armed: false,
1111
- enabled: undefined,
1112
- required: undefined,
1113
- practicesEnabled: undefined,
1114
- client,
1115
- message: '.planning/config.json is not valid JSON — fix it (or re-run /pp-init), then arm the vault again. Nothing was written.',
1116
- };
1117
- }
1118
- const vaultSection = isObject(cfg.vault) ? cfg.vault : null;
1119
- const nested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'enabled');
1120
- const flat = Object.prototype.hasOwnProperty.call(cfg, 'vault.enabled');
1121
- const current = nested && vaultSection ? vaultSection.enabled : flat ? cfg['vault.enabled'] : undefined;
1122
- const requiredNested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'required');
1123
- const requiredFlat = Object.prototype.hasOwnProperty.call(cfg, 'vault.required');
1124
- const currentRequired = requiredNested && vaultSection ? vaultSection.required : requiredFlat ? cfg['vault.required'] : undefined;
1125
- const practicesNested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'practices_enabled');
1126
- const practicesFlat = Object.prototype.hasOwnProperty.call(cfg, 'vault.practices_enabled');
1127
- const currentPractices = practicesNested && vaultSection
1128
- ? vaultSection.practices_enabled
1129
- : practicesFlat
1130
- ? cfg['vault.practices_enabled']
1131
- : undefined;
1132
- const practicesAbsent = !(practicesNested || practicesFlat);
1133
- if (!binding.bound) {
1134
- return {
1135
- ok: false,
1136
- armed: false,
1137
- enabled: current,
1138
- required: currentRequired,
1139
- practicesEnabled: currentPractices,
1140
- client,
1141
- message: 'No client vault is bound — .platform-bindings.json needs client.vaultRepo and client.vaultRef. Run /pp-init to bind it; vault.enabled was not changed.',
1142
- };
1143
- }
1144
- const enabledAbsent = !(nested || flat);
1145
- const enabledAfter = enabledAbsent ? true : current;
1146
- const requiredAbsent = !(requiredNested || requiredFlat);
1147
- // An org profile that sets vault.required already decides it for this project (the gate
1148
- // reads project > profile > default); arming must not override it with a project value.
1149
- let profileRequired = null;
1150
- if (requiredAbsent) {
1151
- try {
1152
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1153
- const orgProfile = require('./org-profile.cjs');
1154
- const r = orgProfile.resolveVaultRequired(cwd);
1155
- if (r.source === 'profile')
1156
- profileRequired = r.required;
1057
+ if (capabilityActivation.getConfigValueEitherForm(parsed, exports.INIT_COMPOSE_DECLINED_KEY).value === true) {
1058
+ return {
1059
+ ok: true,
1060
+ armed: false,
1061
+ reason: 'compose_declined',
1062
+ message: 'The binding compose was declined for this project (init.compose_declined), so nothing was composed or armed.',
1063
+ };
1157
1064
  }
1158
- catch {
1159
- /* no profile: arm as before */
1065
+ const enabled = resolveVaultEnabledState(parsed);
1066
+ if (enabled.state !== 'absent' && enabled.state !== 'armed') {
1067
+ return {
1068
+ ok: true,
1069
+ armed: false,
1070
+ reason: 'vault_disabled',
1071
+ message: enabled.state === 'off'
1072
+ ? 'vault.enabled is false for this project, so the vault was left off and nothing was composed or armed.'
1073
+ : 'vault.enabled is set but is not the boolean true, so the vault was left off and nothing was composed or armed.',
1074
+ };
1160
1075
  }
1161
- }
1162
- const patch = {};
1163
- if (enabledAbsent)
1164
- patch.enabled = true;
1165
- if (enabledAfter === true && requiredAbsent && profileRequired === null)
1166
- patch.required = true;
1167
- // A bound client vault also records which of its practices each phase applied,
1168
- // from the first phase on. An explicit value is the operator's choice and is kept.
1169
- if (enabledAfter === true && practicesAbsent)
1170
- patch.practices_enabled = true;
1171
- const clauses = [
1172
- enabledAbsent
1173
- ? 'vault.enabled set to true (it was absent)'
1174
- : current === true
1175
- ? 'vault.enabled is already true'
1176
- : `vault.enabled is explicitly set to ${JSON.stringify(current)} — kept as the operator's choice`,
1177
- ];
1178
- if (enabledAfter === true) {
1179
- clauses.push(!requiredAbsent
1180
- ? `vault.required is set to ${JSON.stringify(currentRequired)} — kept as the operator's choice`
1181
- : profileRequired !== null
1182
- ? `vault.required comes from the org profile (${profileRequired}) — not written to the project`
1183
- : 'vault.required set to true (it was absent): planning and the build now stop when the client vault does not load');
1184
- clauses.push(practicesAbsent
1185
- ? "vault.practices_enabled set to true (it was absent): each plan now carries the client vault's practices and a record of which applied"
1186
- : `vault.practices_enabled is set to ${JSON.stringify(currentPractices)} — kept as the operator's choice`);
1187
- }
1188
- const requiredAfter = Object.prototype.hasOwnProperty.call(patch, 'required')
1189
- ? true
1190
- : profileRequired !== null
1191
- ? profileRequired
1192
- : currentRequired;
1193
- const practicesAfter = Object.prototype.hasOwnProperty.call(patch, 'practices_enabled')
1194
- ? true
1195
- : currentPractices;
1196
- if (Object.keys(patch).length === 0) {
1076
+ if (!(0, org_profile_cjs_1.resolveOrgProfileDocument)(cwd, { _globalRoot: opts._globalRoot }).resolved) {
1077
+ return {
1078
+ ok: true,
1079
+ armed: false,
1080
+ reason: 'no_org_profile',
1081
+ message: 'No org profile resolves for this project, so there is no client vault to bind and nothing was written.',
1082
+ };
1083
+ }
1084
+ const compose = (0, compose_bindings_cjs_1.composeBindings)(cwd, { write: true, offline: opts.offline === true, _globalRoot: opts._globalRoot });
1085
+ if (compose.composable !== true) {
1086
+ return {
1087
+ ok: false,
1088
+ armed: false,
1089
+ reason: 'not_composable',
1090
+ message: 'An org profile resolves, but no valid client-vault binding could be composed from it, so the vault was NOT armed. ' +
1091
+ ARM_VAULT_REMEDY,
1092
+ warnings: compose.warnings,
1093
+ };
1094
+ }
1095
+ if ((compose.action === 'create' || compose.action === 'update') && compose.wrote !== true) {
1096
+ return {
1097
+ ok: false,
1098
+ armed: false,
1099
+ reason: 'compose_write_failed',
1100
+ message: 'The binding was composed but .platform-bindings.json was not written, so the vault was NOT armed. ' + ARM_VAULT_REMEDY,
1101
+ warnings: compose.warnings,
1102
+ };
1103
+ }
1104
+ const vaultKeysArmed = armVaultKeysWhereAbsent(cwd);
1197
1105
  return {
1198
- ok: true, armed: false, enabled: current, required: requiredAfter, practicesEnabled: practicesAfter, client,
1199
- message: `${clauses.join('; ')} — nothing to do.`,
1106
+ ok: true,
1107
+ armed: true,
1108
+ profile: {
1109
+ packageName: compose.profile.packageName,
1110
+ version: compose.profile.version,
1111
+ source: compose.profile.source,
1112
+ },
1113
+ compose: { action: compose.action, wrote: compose.wrote },
1114
+ vaultKeysArmed,
1115
+ // VAULT-EGRESS-1: the written file holds the raw coordinate; the report is display-safe.
1116
+ provenance: (0, compose_bindings_cjs_1.redactComposeResultForEmission)(compose).provenance,
1117
+ warnings: compose.warnings,
1118
+ note: 'Project identity (project.*) was not captured by this step; /pp-init completes it.',
1200
1119
  };
1201
1120
  }
1202
- try {
1203
- writeConfigMerge(cwd, { vault: patch });
1204
- }
1205
1121
  catch (err) {
1206
1122
  return {
1207
- ok: false, armed: false, enabled: current, required: currentRequired, practicesEnabled: currentPractices, client,
1208
- message: writeFailedMessage(err),
1123
+ ok: false,
1124
+ armed: false,
1125
+ reason: 'write_failed',
1126
+ message: writeFailedMessage(err) + ' ' + ARM_VAULT_REMEDY,
1127
+ warnings: [],
1209
1128
  };
1210
1129
  }
1211
- return {
1212
- ok: true, armed: true, enabled: enabledAfter, required: requiredAfter, practicesEnabled: practicesAfter, client,
1213
- message: `${clauses.join('; ')}.`,
1214
- };
1215
1130
  }
1216
1131
  // ─── cmdInitContract ────────────────────────────────────────────────────────
1217
- /**
1218
- * CLI adapter for `pp-tools query init-contract`.
1219
- *
1220
- * `--verify` runs `verifyInitInvariants` and always prints the full result
1221
- * as data (so `init.md` can read and show every violation verbatim), then
1222
- * sets `process.exitCode = 1` when the result is not `ok` — a data
1223
- * verdict, never an `error()` usage failure. This is the load-bearing
1224
- * property Task 2's acceptance requires: a caller cannot mistake a failed
1225
- * check for a pass, but the JSON body still reaches stdout either way.
1226
- *
1227
- * `--questions` (also the no-flag default, matching this seam's
1228
- * safe-default convention — mirrors `compose-bindings`'s dry-run default)
1229
- * is read-only and always exits 0.
1230
- *
1231
- * `--session-reset` / `--session-answer` / `--session-next` (34-08 gap
1232
- * closure) drive the session state machine above. Precedence — reset,
1233
- * then answer, then next, then verify, then the questions default — mirrors
1234
- * the order a relay loop actually calls them in (an agent never sends more
1235
- * than one of these per invocation; the order only matters for a caller
1236
- * that somehow set two, which should never happen from `pp-tools.cjs`'s own
1237
- * parsing). `--session-answer` and `--session-next` both set
1238
- * `process.exitCode = 1` on a data verdict that means "not ready to
1239
- * proceed" (`ok: false` / `error` present) — same never-a-usage-failure
1240
- * convention as `--verify`.
1241
- */
1132
+ /** CLI adapter for `pp-tools query init-contract`: the session, record, arm-vault, verify and questions flags.
1133
+ * Data verdicts set `exitCode = 1` when not ok, but the JSON result is always printed. */
1242
1134
  function cmdInitContract(cwd, options, raw) {
1243
- if (options.armVault === true) {
1244
- const result = armVault(cwd);
1245
- output(result, raw);
1246
- if (!result.ok) {
1247
- process.exitCode = 1;
1248
- }
1249
- return;
1250
- }
1251
1135
  if (options.sessionReset === true) {
1252
1136
  const result = initSessionReset(cwd);
1253
1137
  output(result, raw);
@@ -1271,6 +1155,32 @@ function cmdInitContract(cwd, options, raw) {
1271
1155
  }
1272
1156
  return;
1273
1157
  }
1158
+ // Record writes run before `--verify`, so a combined call verifies the written state.
1159
+ if (options.recordProbe !== undefined) {
1160
+ const result = recordProbeOutcome(cwd, options.recordProbe);
1161
+ output(result, raw);
1162
+ if (!result.ok) {
1163
+ process.exitCode = 1;
1164
+ }
1165
+ return;
1166
+ }
1167
+ if (options.deferProbe !== undefined) {
1168
+ const result = recordProbeDeferral(cwd, options.deferProbe);
1169
+ output(result, raw);
1170
+ if (!result.ok) {
1171
+ process.exitCode = 1;
1172
+ }
1173
+ return;
1174
+ }
1175
+ // An inert `armed: false` is a data verdict on exit 0, like the inert init path; only `ok: false` exits 1.
1176
+ if (options.armVault === true) {
1177
+ const result = armVaultFromProfile(cwd, { offline: options.offline === true });
1178
+ output(result, raw);
1179
+ if (result.ok === false) {
1180
+ process.exitCode = 1;
1181
+ }
1182
+ return;
1183
+ }
1274
1184
  if (options.verify === true) {
1275
1185
  const result = verifyInitInvariants(cwd);
1276
1186
  output(result, raw);