@pareto4/pp-core 1.12.1 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.claude-plugin/plugin.json +3 -3
  2. package/README.md +26 -9
  3. package/agents/pp-code-reviewer.md +3 -2
  4. package/agents/pp-debug-session-manager.md +9 -6
  5. package/agents/pp-executor.md +7 -21
  6. package/agents/pp-plan-checker.md +4 -4
  7. package/agents/pp-planner.md +23 -9
  8. package/agents/pp-vault-reviewer.md +274 -0
  9. package/agents/pp-verifier.md +7 -3
  10. package/bin/install.js +10 -3
  11. package/commands/pp-execute-phase.md +1 -3
  12. package/commands/pp-ns-review.md +2 -1
  13. package/commands/pp-plan-phase.md +1 -2
  14. package/commands/pp-vault-review.md +35 -0
  15. package/commands/pp-verify-work.md +1 -2
  16. package/gemini-extension.json +1 -1
  17. package/hooks/dist/pp-check-update.js +0 -0
  18. package/hooks/dist/pp-cursor-session-start.js +1 -1
  19. package/hooks/dist/pp-statusline.js +1 -1
  20. package/hooks/dist/pp-update-banner.js +0 -0
  21. package/hooks/pp-check-update.js +0 -0
  22. package/hooks/pp-cursor-session-start.js +1 -1
  23. package/hooks/pp-statusline.js +1 -1
  24. package/hooks/pp-update-banner.js +0 -0
  25. package/package.json +14 -19
  26. package/pareto-skills/_shared/create-project-board.sh +0 -0
  27. package/pareto-skills/pp-project-setup/SKILL.md +1 -1
  28. package/pp-core/bin/check-latest-version.cjs +0 -0
  29. package/pp-core/bin/detect-build-commands.sh +2 -2
  30. package/pp-core/bin/lib/capability-activation.cjs +83 -9
  31. package/pp-core/bin/lib/capability-registry.cjs +526 -22
  32. package/pp-core/bin/lib/capability-state.cjs +160 -4
  33. package/pp-core/bin/lib/capability-writer.cjs +19 -5
  34. package/pp-core/bin/lib/check-command-router.cjs +3867 -274
  35. package/pp-core/bin/lib/clusters.cjs +4 -0
  36. package/pp-core/bin/lib/command-aliases.cjs +0 -28
  37. package/pp-core/bin/lib/commands.cjs +9 -4
  38. package/pp-core/bin/lib/compose-bindings.cjs +253 -76
  39. package/pp-core/bin/lib/config-loader.cjs +7 -103
  40. package/pp-core/bin/lib/config-schema.cjs +1 -1
  41. package/pp-core/bin/lib/config.cjs +154 -62
  42. package/pp-core/bin/lib/decisions.cjs +104 -27
  43. package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
  44. package/pp-core/bin/lib/frontmatter.cjs +1 -1
  45. package/pp-core/bin/lib/git-base-branch.cjs +138 -10
  46. package/pp-core/bin/lib/git-provider.cjs +4 -4
  47. package/pp-core/bin/lib/init-command-router.cjs +5 -8
  48. package/pp-core/bin/lib/init-contract.cjs +653 -629
  49. package/pp-core/bin/lib/init-interactive.cjs +77 -21
  50. package/pp-core/bin/lib/init.cjs +24 -51
  51. package/pp-core/bin/lib/install-profiles.cjs +1 -1
  52. package/pp-core/bin/lib/io.cjs +8 -0
  53. package/pp-core/bin/lib/loop-resolver.cjs +169 -47
  54. package/pp-core/bin/lib/org-profile.cjs +544 -53
  55. package/pp-core/bin/lib/package-identity.cjs +3 -3
  56. package/pp-core/bin/lib/phase-command-router.cjs +1 -23
  57. package/pp-core/bin/lib/phase-scope.cjs +1 -1
  58. package/pp-core/bin/lib/phase.cjs +11 -203
  59. package/pp-core/bin/lib/platform-bindings.cjs +40 -19
  60. package/pp-core/bin/lib/practices-review-check.cjs +15 -9
  61. package/pp-core/bin/lib/profile-output.cjs +1 -1
  62. package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
  63. package/pp-core/bin/lib/state.cjs +11 -85
  64. package/pp-core/bin/lib/template.cjs +1 -1
  65. package/pp-core/bin/lib/vault-audit.cjs +2594 -0
  66. package/pp-core/bin/lib/vault-command-router.cjs +2756 -60
  67. package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
  68. package/pp-core/bin/lib/vault-index.cjs +699 -0
  69. package/pp-core/bin/lib/vault-mode.cjs +235 -0
  70. package/pp-core/bin/lib/vault-practices.cjs +1194 -89
  71. package/pp-core/bin/lib/vault-review.cjs +2102 -0
  72. package/pp-core/bin/lib/vault-seal.cjs +1936 -0
  73. package/pp-core/bin/lib/vault-standards.cjs +1505 -0
  74. package/pp-core/bin/lib/vault-trace.cjs +3184 -0
  75. package/pp-core/bin/lib/vault-update.cjs +242 -0
  76. package/pp-core/bin/lib/vault-write-back.cjs +78 -31
  77. package/pp-core/bin/lib/vault.cjs +2713 -634
  78. package/pp-core/bin/lib/verify.cjs +0 -11
  79. package/pp-core/bin/portable-timeout.sh +1 -1
  80. package/pp-core/bin/pp-tools.cjs +184 -37
  81. package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
  82. package/pp-core/bin/shared/config-schema.manifest.json +0 -3
  83. package/pp-core/bin/shared/model-catalog.json +1 -0
  84. package/pp-core/bin/verify-reapply-patches.cjs +0 -0
  85. package/pp-core/org-profile.json +26 -0
  86. package/pp-core/references/autonomous-best-effort.md +8 -7
  87. package/pp-core/references/loop-hook-dispatch.md +0 -14
  88. package/pp-core/references/planner-gap-closure.md +0 -1
  89. package/pp-core/references/planner-tdd.md +0 -1
  90. package/pp-core/references/planner-vault-practices.md +31 -3
  91. package/pp-core/references/planner-vault-standards.md +320 -0
  92. package/pp-core/references/planning-config.md +0 -8
  93. package/pp-core/references/practice-arithmetic-contract.md +121 -0
  94. package/pp-core/references/tdd.md +0 -1
  95. package/pp-core/references/verifier-practice-verdicts.md +263 -0
  96. package/pp-core/templates/README.md +1 -0
  97. package/pp-core/templates/VAULT-REVIEW.md +202 -0
  98. package/pp-core/templates/phase-prompt.md +2 -6
  99. package/pp-core/templates/practices/README.md +137 -11
  100. package/pp-core/templates/project-charter.md +226 -0
  101. package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
  102. package/pp-core/workflows/add-tests.md +55 -0
  103. package/pp-core/workflows/audit-fix.md +63 -0
  104. package/pp-core/workflows/audit-milestone.md +1 -1
  105. package/pp-core/workflows/autonomous.md +85 -11
  106. package/pp-core/workflows/check-todos.md +13 -2
  107. package/pp-core/workflows/code-review-fix.md +59 -1
  108. package/pp-core/workflows/code-review.md +10 -1
  109. package/pp-core/workflows/complete-milestone.md +1 -1
  110. package/pp-core/workflows/debug.md +56 -2
  111. package/pp-core/workflows/discovery-sprint/prefill.md +21 -0
  112. package/pp-core/workflows/discovery-sprint/prototype.md +46 -48
  113. package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
  114. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
  115. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
  116. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
  117. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
  118. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
  119. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
  120. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
  121. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
  122. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
  123. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
  124. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
  125. package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
  126. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
  127. package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
  128. package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
  129. package/pp-core/workflows/discovery-sprint.md +7 -3
  130. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
  131. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
  132. package/pp-core/workflows/discovery-to-build.md +9 -1
  133. package/pp-core/workflows/discuss-phase.md +3 -10
  134. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
  135. package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
  136. package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
  137. package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
  138. package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
  139. package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
  140. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
  141. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -18
  142. package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
  143. package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
  144. package/pp-core/workflows/execute-phase.md +154 -110
  145. package/pp-core/workflows/execute-plan.md +58 -4
  146. package/pp-core/workflows/fast.md +53 -0
  147. package/pp-core/workflows/help/modes/full.md +1 -0
  148. package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
  149. package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
  150. package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
  151. package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
  152. package/pp-core/workflows/init.md +232 -272
  153. package/pp-core/workflows/new-project.md +31 -23
  154. package/pp-core/workflows/plan-phase.md +99 -43
  155. package/pp-core/workflows/quick.md +120 -2
  156. package/pp-core/workflows/secure-phase.md +1 -1
  157. package/pp-core/workflows/ship.md +57 -24
  158. package/pp-core/workflows/update.md +3 -3
  159. package/pp-core/workflows/validate-phase.md +60 -1
  160. package/pp-core/workflows/vault-review.md +325 -0
  161. package/pp-core/workflows/verify-work.md +13 -12
  162. package/scripts/changeset/cli.cjs +6 -5
  163. package/scripts/changeset/lint.cjs +0 -0
  164. package/scripts/changeset/new.cjs +33 -50
  165. package/scripts/dev-env.cjs +25 -85
  166. package/scripts/gen-capability-registry.cjs +199 -0
  167. package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
  168. package/scripts/lib/allowlist-ratchet.cjs +1 -103
  169. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  170. package/scripts/lint-docs-required.cjs +0 -0
  171. package/scripts/lint-pr-check-project-dir.cjs +1 -3
  172. package/scripts/lint-test-file-count.allowlist.json +13 -2
  173. package/scripts/practice-arithmetic-contract.allow.json +35 -0
  174. package/scripts/prompt-injection-scan.sh +22 -15
  175. package/scripts/record-vault-mode-golden.cjs +89 -0
  176. package/scripts/release-channel.cjs +0 -0
  177. package/scripts/sync-manifest-versions.cjs +5 -4
  178. package/scripts/sync-rulesets.sh +1 -1
  179. package/scripts/update-size-baseline.cjs +30 -274
  180. package/scripts/vault-calibrate.cjs +996 -0
  181. package/scripts/verify-opengsd-sweep.cjs +3 -5
  182. package/scripts/workflow-size.cjs +9 -61
  183. package/pp-core/bin/lib/dispatch-plan.cjs +0 -472
  184. package/pp-core/bin/lib/install-run.cjs +0 -536
  185. package/pp-core/bin/lib/phase-tier.cjs +0 -467
  186. package/pp-core/bin/lib/plan-graph.cjs +0 -368
  187. package/pp-core/references/planner-plan-graph.md +0 -66
  188. package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
  189. package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
  190. package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
  191. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -72
  192. package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -34
  193. package/scripts/lib/size-budget.cjs +0 -311
  194. package/scripts/lint-neutrality-names.cjs +0 -694
  195. package/scripts/lint-org-coordinates.allowlist.json +0 -35
  196. package/scripts/lint-org-coordinates.cjs +0 -426
@@ -1,101 +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_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
- /** The identity-only choices appended after the provider seam's own
78
- * `PROVIDERS` list — never retyped ahead of `PROVIDERS` itself (T-34-50). */
79
- const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['github', 'other', 'none']);
80
- /** Bounds the `node_modules` scan `verifyInitInvariants` runs to answer
81
- * "could an org profile package have been pointed to" — a DoS guard, the
82
- * same convention `src/org-profile.cts`'s `PROFILE_FLATTEN_LEAF_CAP` and
83
- * `PROFILE_FLATTEN_DEPTH_CAP` use for the analogous profile-document walk. */
84
- const NODE_MODULES_SCAN_CAP = 2000;
43
+ /** Legacy upstream marker for a skipped org profile; nothing writes it any more, kept registered so old configs load. */
44
+ exports.INIT_ORG_PROFILE_DECLINED_KEY = 'init.org_profile_declined';
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']);
85
63
  const INVARIANT_CODE = Object.freeze({
86
- ORG_PROFILE_UNASKED: 'INIT_QUESTIONS_ORG_PROFILE_UNASKED',
87
64
  COMPOSITION_NOT_RUN: 'INIT_COMPOSITION_NOT_RUN',
88
65
  VAULT_PROVIDER_UNSET: 'INIT_VAULT_PROVIDER_UNSET',
89
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',
90
73
  });
91
- /** The recognized flag set for the `init-contract` CLI arm (the
92
- * `pp-tools.cjs` fail-fast unrecognized-flag guard reads this, mirroring
93
- * `COMPOSE_BINDINGS_FLAGS`). Extended by Task 2 (34-08 gap closure) with the
94
- * session relay flags — `--session-answer` additionally consumes the next
95
- * argv token as its `<stepId>=<value>` payload (parsed in `pp-tools.cjs`,
96
- * mirroring the `--cwd`/`--pick` convention), and `--offline` forwards to
97
- * every `composeBindings` call the session makes so a CLI-driven loop can
98
- * 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. */
99
76
  exports.INIT_CONTRACT_FLAGS = Object.freeze([
100
77
  '--questions',
101
78
  '--verify',
@@ -103,39 +80,21 @@ exports.INIT_CONTRACT_FLAGS = Object.freeze([
103
80
  '--session-answer',
104
81
  '--session-reset',
105
82
  '--offline',
83
+ '--record-probe',
84
+ '--defer-probe',
106
85
  '--arm-vault',
107
86
  ]);
108
- /** `.planning/.init-session.json` — the CLI-owned session state file
109
- * (Artifacts table, 34-08). Never hand-edited; malformed content is
110
- * refused, not silently repaired (T-34-63). */
87
+ /** CLI-owned session state file; malformed content is refused, never repaired. */
111
88
  const INIT_SESSION_FILE_NAME = '.init-session.json';
112
- /** The `error` code `initSessionNext`/`initSessionAnswer` report when the
113
- * session file exists but does not parse as valid session state — the ONLY
114
- * recovery is `--session-reset` (T-34-63); this module never resumes a
115
- * malformed file into a guessed-at state. */
89
+ /** Reported when the session file is malformed; the only recovery is `--session-reset`. */
116
90
  exports.INIT_SESSION_MALFORMED = 'INIT_SESSION_MALFORMED';
117
- /** CR-01: the `error`/`reason` code `initSessionNext`/`initSessionAnswer`
118
- * report when a filesystem write this module performs (`persistSession`,
119
- * `writeConfigMerge`, the write-mode `composeBindings` call) fails — e.g. an
120
- * unwritable `.planning` directory (permissions, a read-only mount, a full
121
- * disk). Every sibling module in this seam (`composeBindings`,
122
- * `resolveOrgProfileDocument`, `resolveOrgProfile`, `verifyInitInvariants`)
123
- * never throws and degrades to a bounded data verdict instead (D-09); this
124
- * module previously did not follow that convention on its write paths and
125
- * let the exception escape uncaught. Recovery is to fix the underlying
126
- * filesystem issue and retry the same step — nothing is left half-recorded,
127
- * 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. */
128
92
  exports.INIT_SESSION_WRITE_FAILED = 'INIT_SESSION_WRITE_FAILED';
129
- /** compose_confirm's answer vocabulary — deliberately permissive so a human
130
- * relaying through `AskUserQuestion`/TEXT_MODE can answer naturally; every
131
- * other string is rejected (never silently treated as a decline). */
93
+ /** `compose_confirm` answers; anything else is rejected, never treated as a decline. */
132
94
  const YES_VALUES = Object.freeze(['yes', 'y', 'approve', 'approved', 'true']);
133
95
  const NO_VALUES = Object.freeze(['no', 'n', 'decline', 'declined', 'false']);
134
- /** Maps each of the 7 identity/provider/visibility question ids (every
135
- * question but `org_profile`, which is written immediately on answer — see
136
- * `initSessionAnswer` — so the very next question, `provider`, can
137
- * re-resolve its `preselected` against it) to the short key it is written
138
- * 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. */
139
98
  const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
140
99
  project_name: 'name',
141
100
  project_slug: 'slug',
@@ -145,9 +104,7 @@ const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
145
104
  provider: 'provider',
146
105
  visibility: 'visibility',
147
106
  });
148
- // ─── Small local helpers (deliberate per-module duplication — this repo's
149
- // established convention for small, cycle-risk-free helpers; see
150
- // `src/git-provider.cts`'s `cfgGet` for the canonical example) ────────────────
107
+ // ─── Small local helpers (duplicated per module to avoid require cycles) ──────────
151
108
  function isObject(v) {
152
109
  return typeof v === 'object' && v !== null && !Array.isArray(v);
153
110
  }
@@ -157,33 +114,143 @@ function isUsableString(v) {
157
114
  function getDotted(obj, dottedPath) {
158
115
  return dottedPath.split('.').reduce((cur, key) => (isObject(cur) ? cur[key] : undefined), obj);
159
116
  }
160
- // ─── 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
+ }
161
152
  /**
162
- * The canonical `/pp-init` question set, as data. `init.md` renders exactly
163
- * what this returns, in the order returned, none merged, reordered or
164
- * dropped — see the module header for why this moved out of prose.
165
- *
166
- * Question 6 (`org_profile`) is the org profile pointer, marked
167
- * `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
168
- * 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).
169
160
  *
170
- * Question 6 (`org_profile`)'s `preselected` is resolved via
171
- * `resolveDetectedOrgProfile` — set only when exactly one org-profile-shaped
172
- * package is installed (INIT-PATCH-C); the prompt text is built from that
173
- * detected value rather than kept as two hard-coded strings that can drift.
174
- * `opts._globalRoot` is forwarded to it unchanged, the same test-seam
175
- * convention `verifyInitInvariants`'s own `_globalRoot` already follows —
176
- * production callers omit it.
177
- *
178
- * Question 7 (`provider`) carries the fixed, complete option list: every
179
- * value in `PROVIDERS` (imported, never retyped — this is what keeps
180
- * `azure` from being silently dropped again, T-34-50) plus the three
181
- * identity-only choices. `preselected` is resolved via `resolveProvider`,
182
- * the same authority the `compose-bindings --offline --pick provider`
183
- * probe reads (pure config + org-profile consultation, no network) — so
184
- * 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).
185
204
  */
186
- 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) {
187
254
  let preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
188
255
  try {
189
256
  preselected = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
@@ -192,29 +259,6 @@ function buildInitQuestions(cwd, opts = {}) {
192
259
  preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
193
260
  }
194
261
  const providerOptions = Object.freeze([...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS]);
195
- const detectedOrgProfile = resolveDetectedOrgProfile(cwd, opts);
196
- // The template example is written as an angle-bracket placeholder, not a
197
- // literal package name. `tests/org-profile.test.cjs`'s client-confidentiality
198
- // guard scans this file for any concrete scope-slash-org-prefix package
199
- // identifier, so even a generic literal placeholder trips it — the template
200
- // keeps the naming convention visible while staying clean, and the guard
201
- // stays maximally strict, no allowlist entry was added for it. The detected
202
- // value itself is never a literal in this file — it is a runtime string
203
- // read from an installed package, interpolated into the prompt below.
204
- const orgProfileQuestion = isUsableString(detectedOrgProfile)
205
- ? {
206
- id: 'org_profile',
207
- prompt: `Org profile package (optional — a single installed org profile was detected: "${detectedOrgProfile}". ` +
208
- 'Press Enter to use it, type "none" to skip it, or type a different package name.)',
209
- optionalAnswer: true,
210
- preselected: detectedOrgProfile,
211
- preselectedLabel: `${detectedOrgProfile} (detected installed profile)`,
212
- }
213
- : {
214
- id: 'org_profile',
215
- prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
216
- optionalAnswer: true,
217
- };
218
262
  return [
219
263
  { id: 'project_name', prompt: 'Project name (human-readable, e.g. "Acme Dashboard")', optionalAnswer: false },
220
264
  { id: 'project_slug', prompt: 'Project slug (kebab-case, e.g. "acme-dashboard")', optionalAnswer: false },
@@ -229,7 +273,6 @@ function buildInitQuestions(cwd, opts = {}) {
229
273
  optionalAnswer: false,
230
274
  },
231
275
  { id: 'description', prompt: 'One-line description (what this project does)', optionalAnswer: false },
232
- orgProfileQuestion,
233
276
  {
234
277
  id: 'provider',
235
278
  prompt: 'Provider (gitlab / azure / github / other / none)',
@@ -241,190 +284,88 @@ function buildInitQuestions(cwd, opts = {}) {
241
284
  { id: 'visibility', prompt: 'Visibility (private / public / internal)', optionalAnswer: false },
242
285
  ];
243
286
  }
244
- // ─── verifyInitInvariants ───────────────────────────────────────────────────
245
- /**
246
- * Byte-for-byte the same heuristic `src/org-profile.cts`'s own (private,
247
- * unexported) `resolveGlobalPackageRoot` uses. Duplicated here because it
248
- * is not part of that module's public surface — the same per-module
249
- * duplication convention every sibling seam module in this repo follows
250
- * for helpers it cannot import without either widening an unrelated
251
- * module's exports or risking a require cycle.
252
- */
253
- function resolveGlobalNodeModulesRoot() {
254
- try {
255
- const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
256
- let candidate;
257
- if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
258
- const prefix = prefixEnv.trim();
259
- candidate =
260
- process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
261
- }
262
- else {
263
- const execDir = node_path_1.default.dirname(process.execPath);
264
- candidate =
265
- process.platform === 'win32'
266
- ? node_path_1.default.join(execDir, 'node_modules')
267
- : node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
268
- }
269
- return node_fs_1.default.existsSync(candidate) ? candidate : null;
270
- }
271
- catch {
272
- return null;
273
- }
274
- }
275
- /** Conservative npm-package-name shape check (bare `name` or scoped
276
- * `@scope/name`; lowercase, digits, `.`/`_`/`-`, never leading `.`/`_` in
277
- * either segment). A directory name that does not match is never offered as
278
- * a detected default (T-QP-02): it cannot be a resolvable package pointer,
279
- * so rejecting it here keeps a hostile directory name (control characters,
280
- * terminal escapes) from ever reaching a rendered prompt. */
281
- const NPM_PACKAGE_NAME_SHAPE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
282
- /**
283
- * Walk `nodeModulesDir` exactly like the boolean check below (one level, or
284
- * one level inside an `@scope/` directory), collecting the name of every
285
- * package directory carrying `ORG_PROFILE_DATA_FILE` at its root instead of
286
- * stopping at the first hit. `opts.limit`, when set, stops the walk once
287
- * that many names have been collected — passing `1` reproduces the boolean
288
- * check's early-exit behaviour exactly. Bounded at `NODE_MODULES_SCAN_CAP`
289
- * total directory entries visited (T-34-54: this check must never hang on a
290
- * pathological `node_modules` tree).
291
- *
292
- * This is a heuristic, not a resolution: it answers "a package shaped like
293
- * an org profile is installed here", not "this exact profile would
294
- * resolve" — callers never claim more than that.
295
- */
296
- function collectOrgProfileShapedPackages(nodeModulesDir, opts = {}) {
297
- const names = [];
298
- let visited = 0;
299
- const limitReached = () => opts.limit !== undefined && names.length >= opts.limit;
300
- const addIfShaped = (name) => {
301
- if (NPM_PACKAGE_NAME_SHAPE_RE.test(name))
302
- names.push(name);
303
- };
304
- let entries;
305
- try {
306
- entries = node_fs_1.default.readdirSync(nodeModulesDir, { withFileTypes: true });
307
- }
308
- catch {
309
- return { names, capped: false };
310
- }
311
- for (const entry of entries) {
312
- if (limitReached())
313
- return { names, capped: false };
314
- if (visited >= NODE_MODULES_SCAN_CAP)
315
- return { names, capped: true };
316
- visited += 1;
317
- if (!entry.isDirectory())
318
- continue;
319
- if (entry.name.startsWith('@')) {
320
- const scopeDir = node_path_1.default.join(nodeModulesDir, entry.name);
321
- let scoped;
322
- try {
323
- scoped = node_fs_1.default.readdirSync(scopeDir, { withFileTypes: true });
324
- }
325
- catch {
326
- continue;
327
- }
328
- for (const scopedEntry of scoped) {
329
- if (limitReached())
330
- return { names, capped: false };
331
- if (visited >= NODE_MODULES_SCAN_CAP)
332
- return { names, capped: true };
333
- visited += 1;
334
- if (!scopedEntry.isDirectory())
335
- continue;
336
- try {
337
- if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
338
- addIfShaped(`${entry.name}/${scopedEntry.name}`);
339
- }
340
- }
341
- catch {
342
- continue;
343
- }
344
- }
345
- }
346
- else {
347
- try {
348
- if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
349
- addIfShaped(entry.name);
350
- }
351
- }
352
- catch {
353
- continue;
354
- }
355
- }
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;
356
297
  }
357
- return { names, capped: false };
298
+ return false;
358
299
  }
359
- /**
360
- * True when `nodeModulesDir` contains at least one package directory (one
361
- * level, or one level inside an `@scope/` directory) carrying a file named
362
- * `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
363
- * `resolveOrgProfileDocument` reads, imported rather than retyped. Reduced to
364
- * the collecting walk above with `limit: 1`, preserving its early-exit
365
- * behaviour exactly.
366
- */
367
- function containsOrgProfileShapedPackage(nodeModulesDir) {
368
- return collectOrgProfileShapedPackages(nodeModulesDir, { limit: 1 }).names.length > 0;
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
- const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
377
- if (globalRoot && containsOrgProfileShapedPackage(globalRoot)) {
378
- return true;
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();
379
332
  }
380
- 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 };
381
337
  }
382
338
  catch {
383
- return false;
339
+ return none;
384
340
  }
385
341
  }
386
- /**
387
- * Detect a single, unambiguous installed org-profile-shaped package
388
- * (INIT-PATCH-C): collects from the project's `node_modules` and — using the
389
- * same `opts._globalRoot` override convention `orgProfilePackageResolvable`
390
- * already follows — the global root, de-duplicates by package name across
391
- * both roots, and returns that single name only when exactly one distinct
392
- * name was found and NEITHER scan reported `capped`. A capped scan is
393
- * inconclusive and must never yield a default (T-QP-03). Two or more
394
- * distinct names, or none, returns null. Never throws.
395
- */
396
- function resolveDetectedOrgProfile(cwd, opts) {
397
- try {
398
- const names = new Set();
399
- let anyCapped = false;
400
- const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
401
- if (node_fs_1.default.existsSync(projectNodeModules)) {
402
- const projectResult = collectOrgProfileShapedPackages(projectNodeModules);
403
- if (projectResult.capped)
404
- anyCapped = true;
405
- for (const name of projectResult.names)
406
- names.add(name);
407
- }
408
- const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
409
- if (globalRoot) {
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
- /** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
425
- * "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
426
- * on the latter (see `verifyInitInvariants`'s own doc comment for why an
427
- * absent file must never trip it). Never throws. */
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;
366
+ }
367
+ // ─── verifyInitInvariants ───────────────────────────────────────────────────
368
+ /** Read `.planning/config.json`, telling "absent" from "present but malformed". Never throws. */
428
369
  function readConfigFile(cwd) {
429
370
  const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
430
371
  let exists = false;
@@ -443,26 +384,8 @@ function readConfigFile(cwd) {
443
384
  return { exists, parsed: {} };
444
385
  }
445
386
  }
446
- /**
447
- * Inspect the project's RESULTING state at `cwd` after an `/pp-init` run
448
- * and report every named invariant it violates. Read-only: no filesystem
449
- * write anywhere in this function or in the `composeBindings` call it
450
- * makes (always `offline: true`, `write` omitted so it defaults to
451
- * `false`). Never throws — each check below is independently guarded so
452
- * one failing check cannot suppress the others; there is no outer
453
- * catch-all because none is needed (see the design note below).
454
- *
455
- * An empty directory — nothing in `.planning/`, no `.platform-bindings.json`
456
- * — returns `{ ok: true, violations: [] }`. Every check requires some
457
- * positive signal that a partial init was attempted (a config file present
458
- * for `INIT_IDENTITY_MISSING`; a "gitlab"/"azure" `project.provider` for
459
- * `INIT_VAULT_PROVIDER_UNSET`; an installed org-profile-shaped package for
460
- * `INIT_QUESTIONS_ORG_PROFILE_UNASKED`; a `composable: true` dry run for
461
- * `INIT_COMPOSITION_NOT_RUN`) — an empty directory carries none of them, so
462
- * nothing is reported as skipped. This is deliberate (see the module
463
- * header): nothing was attempted, so nothing can be reported as attempted-
464
- * and-abandoned.
465
- */
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. */
466
389
  function verifyInitInvariants(cwd, opts = {}) {
467
390
  const violations = [];
468
391
  const { exists: configExists, parsed } = readConfigFile(cwd);
@@ -483,14 +406,11 @@ function verifyInitInvariants(cwd, opts = {}) {
483
406
  catch {
484
407
  // fail open — one check's fault must not suppress the others
485
408
  }
486
- // INIT_VAULT_PROVIDER_UNSET — project.provider names a provisioning
487
- // provider (an identity record, D-13) but vault.provider —
488
- // resolveProvider's ONLY gating key — was never set, so the D-13
489
- // provisioning gate this phase built never fires.
409
+ // `project.provider` names a vault provider but `vault.provider` was never set.
490
410
  try {
491
411
  const projectProvider = getDotted(parsed, 'project.provider');
492
412
  const vaultProvider = getDotted(parsed, 'vault.provider');
493
- if ((projectProvider === 'gitlab' || projectProvider === 'azure') && !isUsableString(vaultProvider)) {
413
+ if (git_provider_cjs_1.PROVIDERS.includes(projectProvider) && !isUsableString(vaultProvider)) {
494
414
  violations.push({
495
415
  code: INVARIANT_CODE.VAULT_PROVIDER_UNSET,
496
416
  message: `project.provider is "${String(projectProvider)}" but vault.provider is unset — the D-13 provisioning gate is disabled.`,
@@ -500,26 +420,7 @@ function verifyInitInvariants(cwd, opts = {}) {
500
420
  catch {
501
421
  // fail open
502
422
  }
503
- // INIT_QUESTIONS_ORG_PROFILE_UNASKED — an org-profile-shaped package is
504
- // installed (project node_modules or the machine's global root) but
505
- // org.profile was never set, i.e. Question 6 appears to have been
506
- // skipped rather than answered blank.
507
- try {
508
- const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
509
- if (!isUsableString(orgProfilePointer) && orgProfilePackageResolvable(cwd, opts)) {
510
- violations.push({
511
- code: INVARIANT_CODE.ORG_PROFILE_UNASKED,
512
- message: 'An org-profile-shaped package is installed but org.profile is unset — the pointer question (Question 6) appears to have been skipped rather than answered.',
513
- });
514
- }
515
- }
516
- catch {
517
- // fail open
518
- }
519
- // INIT_COMPOSITION_NOT_RUN — a binding was composable from the org
520
- // profile and project answers, but .platform-bindings.json was never
521
- // written and no explicit decline was recorded — Step 2.5 appears to
522
- // have been skipped rather than run-and-declined.
423
+ // A binding was composable but never written, and no decline was recorded.
523
424
  try {
524
425
  const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
525
426
  const bindingsExist = node_fs_1.default.existsSync(bindingsPath);
@@ -529,7 +430,8 @@ function verifyInitInvariants(cwd, opts = {}) {
529
430
  if (dryRun.composable === true) {
530
431
  violations.push({
531
432
  code: INVARIANT_CODE.COMPOSITION_NOT_RUN,
532
- 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).',
533
435
  });
534
436
  }
535
437
  }
@@ -537,32 +439,111 @@ function verifyInitInvariants(cwd, opts = {}) {
537
439
  catch {
538
440
  // fail open
539
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
+ }
540
537
  return { ok: violations.length === 0, violations };
541
538
  }
542
- // ─── Session state machine (34-08 gap closure) ─────────────────────────────
543
- //
544
- // Why this exists at all: `buildInitQuestions`/`verifyInitInvariants` make
545
- // the CONTENT correct when called and the OMISSION provable afterwards —
546
- // they do not make skipping impossible, because prose can still hand an
547
- // agent the full 8-question list and the agent can still compress it (this
548
- // module's own header; `34-VERIFICATION.md`'s third walk). This session
549
- // machine removes the list itself: `initSessionNext` returns exactly ONE
550
- // step, `initSessionAnswer` records exactly ONE answer and rejects
551
- // anything out of order, and the CLI performs every side effect (identity
552
- // write, `vault.provider`, composition) at the state that needs it — never
553
- // leaving it to a workflow's own prose to remember. Neither
554
- // `buildInitQuestions` nor `verifyInitInvariants` nor `composeBindings` is
555
- // 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 ───
556
540
  function sessionFilePath(cwd) {
557
541
  return node_path_1.default.join(cwd, '.planning', INIT_SESSION_FILE_NAME);
558
542
  }
559
543
  function newSession() {
560
544
  return { version: 1, answers: {}, identityWritten: false, composeDecision: null, doneAt: null };
561
545
  }
562
- /** Validate the ENTIRE persisted shape before trusting it — a session file
563
- * that merely parses as JSON but carries the wrong shape (hand-edited,
564
- * truncated mid-write, a leftover from an incompatible future version) must
565
- * 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. */
566
547
  function isValidSessionShape(v) {
567
548
  if (!isObject(v))
568
549
  return false;
@@ -590,13 +571,18 @@ function isValidSessionShape(v) {
590
571
  }
591
572
  if (doneAt !== null && typeof doneAt !== 'string')
592
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
+ }
593
583
  return true;
594
584
  }
595
- /** Absent file → a brand-new session (the normal first call). Present and
596
- * valid → that session. Present but invalid in ANY way → `{ malformed:
597
- * true }`, and every caller below refuses to proceed rather than silently
598
- * re-initializing (T-34-63) — `initSessionReset` is the only documented
599
- * recovery. Never throws. */
585
+ /** Absent → a new session; invalid → `{ malformed: true }` (recover with `--session-reset`). Never throws. */
600
586
  function loadSession(cwd) {
601
587
  const p = sessionFilePath(cwd);
602
588
  try {
@@ -614,8 +600,7 @@ function loadSession(cwd) {
614
600
  return { malformed: true };
615
601
  }
616
602
  }
617
- /** CR-01: format an unexpected write failure (e.g. EACCES) as a bounded,
618
- * 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. */
619
604
  function writeFailedMessage(err) {
620
605
  const detail = err instanceof Error ? err.message : String(err);
621
606
  return (`A filesystem write inside the init session failed (${detail}). ` +
@@ -628,13 +613,7 @@ function persistSession(cwd, session) {
628
613
  (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
629
614
  (0, shell_command_projection_cjs_1.platformWriteSync)(sessionFilePath(cwd), JSON.stringify(session, null, 2) + '\n');
630
615
  }
631
- /** Read `.planning/config.json` for a MERGE write — distinct from
632
- * `readConfigFile` above (which reports `exists`/malformed for
633
- * `verifyInitInvariants`'s read-only purposes): this helper only ever
634
- * needs "the object to merge into", degrading to `{}` on any absence or
635
- * fault so a merge write never crashes on a pre-existing malformed file —
636
- * it simply starts the merge from empty and the write below then produces
637
- * a well-formed file going forward. */
616
+ /** The config to merge into; `{}` on absence or fault, so a merge write never crashes. */
638
617
  function readConfigForMerge(cwd) {
639
618
  try {
640
619
  const raw = node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8');
@@ -645,13 +624,7 @@ function readConfigForMerge(cwd) {
645
624
  return {};
646
625
  }
647
626
  }
648
- /** Shallow-recursive merge: object values merge key-by-key; any other
649
- * value (string, number, boolean, array) replaces the target outright.
650
- * Bounded by the patches this module ever constructs itself (`project.*`,
651
- * `vault.provider`, `org.profile`, `init.compose_declined` — never a
652
- * caller-supplied arbitrary depth), so no depth cap is needed here the way
653
- * `walkLeaves` in `compose-bindings.cts` needs one for untrusted file
654
- * content. */
627
+ /** Recursive merge of plain objects; any other value replaces the target. */
655
628
  function deepMergeConfigPatch(target, patch) {
656
629
  for (const k of Object.keys(patch)) {
657
630
  const pv = patch[k];
@@ -665,28 +638,14 @@ function deepMergeConfigPatch(target, patch) {
665
638
  }
666
639
  return target;
667
640
  }
668
- /** The ONLY place this module writes `.planning/config.json` — every side
669
- * effect the session performs (identity write, `vault.provider`,
670
- * `org.profile`, `init.compose_declined`) routes through here, merged
671
- * against whatever is already on disk so a session step never clobbers an
672
- * unrelated key a human or another tool wrote. Direct `fs`/`JSON` I/O,
673
- * bypassing `config-schema.cts`'s registered-key validation — the same
674
- * convention `compose-bindings.cts`'s `cfgGet` and this module's own
675
- * `readConfigFile` already use for `project.*`/`org.profile` reads; see
676
- * `.planning/todos/pending/project-config-key-unknown.md` for why `project`
677
- * 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. */
678
642
  function writeConfigMerge(cwd, patch) {
679
643
  const dir = node_path_1.default.join(cwd, '.planning');
680
644
  (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
681
645
  const merged = deepMergeConfigPatch(readConfigForMerge(cwd), patch);
682
646
  (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dir, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
683
647
  }
684
- /** The batch write Task 1 §2 describes: once all 8 answers are recorded,
685
- * write the `project` identity block and — only when the `provider` answer
686
- * is exactly `gitlab` or `azure` — `vault.provider`. `org_profile` is
687
- * excluded from this map: it is written immediately when THAT answer is
688
- * recorded (see `initSessionAnswer`), not deferred here, precisely so the
689
- * `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). */
690
649
  function writeIdentityAndProvider(cwd, answers) {
691
650
  const projectPatch = {};
692
651
  for (const [questionId, configKey] of Object.entries(PROJECT_BLOCK_QUESTION_MAP)) {
@@ -697,14 +656,124 @@ function writeIdentityAndProvider(cwd, answers) {
697
656
  }
698
657
  const patch = { project: projectPatch };
699
658
  const providerAnswer = answers.provider;
700
- if (providerAnswer === 'gitlab' || providerAnswer === 'azure') {
659
+ if (git_provider_cjs_1.PROVIDERS.includes(providerAnswer)) {
701
660
  patch.vault = { provider: providerAnswer };
702
661
  }
703
662
  writeConfigMerge(cwd, patch);
704
663
  }
705
- /** Case/whitespace-tolerant yes/no parse for the `compose_confirm` answer.
706
- * `null` for anything else — never defaulted to a decline, so a typo'd
707
- * 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"). */
708
777
  function normalizeYesNo(value) {
709
778
  if (typeof value !== 'string')
710
779
  return null;
@@ -715,43 +784,27 @@ function normalizeYesNo(value) {
715
784
  return false;
716
785
  return null;
717
786
  }
718
- /**
719
- * The single decision function every public session entry point drives.
720
- * Recomputes `buildInitQuestions(cwd)` FRESH on every call — never cached
721
- * across calls — which is what makes the `provider` question's
722
- * `preselected` reflect an `org_profile` answer given moments earlier in
723
- * the SAME session (the walk-3 regression this plan's design hazards
724
- * name): by the time this function is asked for the `provider` question,
725
- * `org.profile` has already been written to disk by `initSessionAnswer`,
726
- * so `buildInitQuestions`'s own `resolveProvider` call sees it.
727
- *
728
- * Performs three idempotent side effects, each guarded by its own session
729
- * flag so re-calling this (as every public entry point does, to validate
730
- * the current step) never repeats a write: the identity+provider batch
731
- * write once all 8 questions are answered; marking `composeDecision:
732
- * 'skipped'` when nothing is composable at all (the real inert path,
733
- * INIT-02) or `composeDecision: 'unchanged'` when a binding IS composable
734
- * but the existing file already matches it (a healthy, up-to-date state —
735
- * NOT the inert path, and reported with its own distinct message; no
736
- * `compose_confirm` prompt either way, nothing written); and recording
737
- * `doneAt` once `verifyInitInvariants` first reports `ok: true`.
738
- */
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. */
739
789
  function computeCurrentStep(cwd, session, opts) {
740
- // CR-01: every sibling module in this seam (composeBindings,
741
- // resolveOrgProfileDocument, resolveOrgProfile, verifyInitInvariants)
742
- // wraps its entire body in a top-level try/catch so a filesystem fault
743
- // degrades to a bounded result rather than crashing (D-09). This
744
- // function's writes (writeIdentityAndProvider/persistSession, below) did
745
- // not — an unwritable .planning directory threw an uncaught EACCES
746
- // straight out of initSessionNext/initSessionAnswer. Fixed by matching
747
- // the convention: any failure anywhere in this body degrades to a
748
- // `write_failed` verdict instead of propagating.
790
+ // Any failure degrades to a `write_failed` step instead of throwing.
749
791
  try {
750
- 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;
751
795
  for (const q of questions) {
752
- 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) {
753
801
  return { kind: 'question', question: q };
754
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);
755
808
  }
756
809
  if (!session.identityWritten) {
757
810
  writeIdentityAndProvider(cwd, session.answers);
@@ -760,20 +813,10 @@ function computeCurrentStep(cwd, session, opts) {
760
813
  }
761
814
  if (session.composeDecision === null) {
762
815
  const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: opts.offline === true, _globalRoot: opts._globalRoot });
763
- // WR-01: only offer compose_confirm when approving it would actually
764
- // change something. `action: 'unchanged'` means the existing file
765
- // already carries every key the composer would produce (D-08's
766
- // absent-only merge) — asking the operator to approve a write that
767
- // writes nothing is exactly the shape of prompt that produced the
768
- // false-success report this finding names. Fall through to the
769
- // messaging below instead of asking — but see the coordinator
770
- // follow-up: 'unchanged' and 'not composable at all' are DIFFERENT
771
- // end states and must record (and later report) distinct decisions,
772
- // 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.
773
817
  if (dryRun.composable === true && dryRun.action !== 'unchanged') {
774
- // Pack 6 D-07: `info` is display-only (the CLI and the TTY renderer
775
- // print it); approval re-composes with `write: true`, so the written
776
- // 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.
777
820
  const shown = (0, compose_bindings_cjs_1.redactComposeResultForEmission)(dryRun);
778
821
  return {
779
822
  kind: 'compose_confirm',
@@ -782,25 +825,24 @@ function computeCurrentStep(cwd, session, opts) {
782
825
  drift: shown.drift,
783
826
  action: dryRun.action,
784
827
  composed: shown.composed,
785
- warnings: shown.warnings,
828
+ warnings: dryRun.warnings,
786
829
  },
787
830
  };
788
831
  }
789
832
  session.composeDecision = dryRun.composable === true && dryRun.action === 'unchanged' ? 'unchanged' : 'skipped';
833
+ if (session.composeDecision === 'unchanged') {
834
+ armVaultKeysWhereAbsent(cwd);
835
+ }
790
836
  persistSession(cwd, session);
791
837
  }
792
- // Three genuinely different end states can reach `done`/`verify` with
793
- // nothing written, and each needs its own message — conflating them
794
- // (as an earlier pass of this fix did) tells an operator with a
795
- // healthy, up-to-date binding that their org profile produced nothing
796
- // composable, which is exactly the "tool reports a state that isn't
797
- // the real one" failure shape this phase exists to eliminate.
838
+ // Three different nothing-written end states, each with its own message.
798
839
  const skippedComposeInfo = session.composeDecision === 'skipped'
799
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).'
800
841
  : session.composeDecision === 'unchanged'
801
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.'
802
843
  : undefined;
803
- 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' });
804
846
  if (verifyResult.ok) {
805
847
  if (!session.doneAt) {
806
848
  session.doneAt = new Date().toISOString();
@@ -860,13 +902,7 @@ function currentStepToState(step) {
860
902
  };
861
903
  }
862
904
  }
863
- /**
864
- * Return the ONE next step of the `/pp-init` session at `cwd` — a question,
865
- * `compose_confirm`, `verify`, or `done`. Never a list. Fails closed (T-34-
866
- * 63) with `error: INIT_SESSION_MALFORMED` when the session file exists but
867
- * does not parse as valid session state; `--session-reset` is the
868
- * documented recovery, never a silent re-initialize.
869
- */
905
+ /** Return the ONE next step of the session; malformed session state fails closed (recover with `--session-reset`). */
870
906
  function initSessionNext(cwd, opts = {}) {
871
907
  const loaded = loadSession(cwd);
872
908
  if (loaded.malformed) {
@@ -879,22 +915,12 @@ function initSessionNext(cwd, opts = {}) {
879
915
  'Run "pp_run query init-contract --session-reset" to start over — it is never resumed silently into an unknown state.',
880
916
  };
881
917
  }
882
- 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;
883
922
  }
884
- /**
885
- * Record ONE answer for the CURRENT step only. Rejects — with `ok: false`
886
- * and a named `reason`, never a throw — an answer for any step other than
887
- * the one `initSessionNext` would currently serve (T-34-60: an agent trying
888
- * to batch or skip ahead fails loudly), a blank answer to a
889
- * non-`optionalAnswer` question, an unparseable `compose_confirm` value, or
890
- * any answer once the session is already `done`.
891
- *
892
- * The `org_profile` answer is written to `.planning/config.json` (`org.profile`)
893
- * IMMEDIATELY when non-blank — the one exception to the "batch write after
894
- * the last answer" rule in `computeCurrentStep`'s own doc comment — because
895
- * the very next question (`provider`) must see it to re-resolve its
896
- * `preselected` (the walk-3 regression Task 1's acceptance names).
897
- */
923
+ /** Record one answer for the current step only; out-of-order, blank required, unparseable or post-done answers are rejected. */
898
924
  function initSessionAnswer(cwd, stepId, value, opts = {}) {
899
925
  const loaded = loadSession(cwd);
900
926
  if (loaded.malformed) {
@@ -906,14 +932,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
906
932
  };
907
933
  }
908
934
  const session = loaded.session;
909
- // CR-01: wrap the entire remaining body — every write this function
910
- // performs directly (the `org_profile` immediate write, the batch
911
- // `persistSession` calls, the `compose_confirm` decline/approve writes)
912
- // — in a top-level try/catch, matching the D-09 convention every sibling
913
- // module in this seam already follows. `computeCurrentStep` degrades to
914
- // a `write_failed` step on its own internal failures (handled explicitly
915
- // below); this catch additionally covers the write sites that live in
916
- // THIS function, after `computeCurrentStep` returns.
935
+ // Any write failure in this function degrades to `write_failed` instead of throwing.
917
936
  try {
918
937
  const step = computeCurrentStep(cwd, session, opts);
919
938
  if (step.kind === 'write_failed') {
@@ -937,13 +956,8 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
937
956
  if (!step.question.optionalAnswer && (typeof value !== 'string' || value.trim() === '')) {
938
957
  return { ok: false, reason: 'answer_required', message: `Question "${stepId}" requires a non-blank answer.` };
939
958
  }
940
- session.answers[stepId] = typeof value === 'string' ? value : '';
941
- if (stepId === 'org_profile') {
942
- const trimmed = session.answers[stepId].trim();
943
- if (trimmed !== '') {
944
- writeConfigMerge(cwd, { org: { profile: trimmed } });
945
- }
946
- }
959
+ const answerValue = typeof value === 'string' ? value : '';
960
+ session.answers[stepId] = answerValue;
947
961
  persistSession(cwd, session);
948
962
  return { ok: true };
949
963
  }
@@ -964,24 +978,10 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
964
978
  };
965
979
  }
966
980
  if (normalized === true) {
967
- // WR-02: inspect the write-mode result before recording the
968
- // decision. The dry run inside computeCurrentStep already reported
969
- // composable:true/action!=='unchanged' moments earlier, but the
970
- // world can change between that dry run and this write (the file
971
- // became malformed on disk in between, a transient fault now caught
972
- // internally by composeBindings's own outer catch, etc.) — in that
973
- // case `wrote` comes back false even though the operator approved.
974
- // Recording 'approved' unconditionally here would make the session
975
- // proceed toward done/verify as though the write had succeeded,
976
- // and the eventual INIT_COMPOSITION_NOT_RUN violation would then
977
- // misdescribe what happened ("Step 2.5 appears to have been
978
- // 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.
979
982
  const writeResult = (0, compose_bindings_cjs_1.composeBindings)(cwd, { write: true, offline: opts.offline === true, _globalRoot: opts._globalRoot });
980
983
  if (writeResult.composable !== true || writeResult.wrote !== true) {
981
- // Do NOT record composeDecision here — leave it null so the next
982
- // --session-next call re-evaluates from a fresh dry run (and, if
983
- // still composable, re-offers compose_confirm) rather than
984
- // silently treating this as skipped or declined.
984
+ // Leave the decision unset so the next call re-evaluates and can re-offer compose_confirm.
985
985
  return {
986
986
  ok: false,
987
987
  reason: 'compose_write_failed',
@@ -992,6 +992,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
992
992
  };
993
993
  }
994
994
  session.composeDecision = 'approved';
995
+ armVaultKeysWhereAbsent(cwd);
995
996
  }
996
997
  else {
997
998
  writeConfigMerge(cwd, { init: { compose_declined: true } });
@@ -1011,8 +1012,7 @@ function initSessionAnswer(cwd, stepId, value, opts = {}) {
1011
1012
  return { ok: false, reason: 'write_failed', message: writeFailedMessage(err) };
1012
1013
  }
1013
1014
  }
1014
- /** Delete the session file, the documented recovery from a malformed
1015
- * 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. */
1016
1016
  function initSessionReset(cwd) {
1017
1017
  try {
1018
1018
  const p = sessionFilePath(cwd);
@@ -1024,116 +1024,114 @@ function initSessionReset(cwd) {
1024
1024
  }
1025
1025
  return { ok: true };
1026
1026
  }
1027
- /**
1028
- * `init-contract --arm-vault`: turn the client vault on for a project whose
1029
- * client vault is bound. Writes `vault.enabled: true` ONLY when the key is
1030
- * absent — an explicit `false` (or any other explicit value) is the
1031
- * operator's decision and is kept. With no client vault bound (no or
1032
- * malformed `.platform-bindings.json`, or `client.vaultRepo` /
1033
- * `client.vaultRef` missing — the same "bound" the gate's verdict uses) it
1034
- * writes nothing and returns `ok: false` naming /pp-init. A present but
1035
- * unparseable `.planning/config.json` is never overwritten. Never throws.
1036
- * The reported coordinate is display-safe (D-07).
1037
- */
1038
- function armVault(cwd) {
1039
- // eslint-disable-next-line @typescript-eslint/no-require-imports
1040
- const vaultMod = require('./vault.cjs');
1041
- let binding = { bound: false, repo: null, ref: null };
1042
- try {
1043
- binding = vaultMod.readClientBinding(cwd);
1044
- }
1045
- catch {
1046
- /* unbound */
1047
- }
1048
- const client = { repo: vaultMod.redactCloneTarget(binding.repo), ref: binding.ref };
1049
- const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
1050
- let cfg = {};
1051
- 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 = {}) {
1052
1047
  try {
1053
- cfgExists = node_fs_1.default.existsSync(configPath);
1054
- if (cfgExists) {
1055
- const parsed = JSON.parse(node_fs_1.default.readFileSync(configPath, 'utf-8'));
1056
- if (!isObject(parsed))
1057
- throw new Error('not an object');
1058
- 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
+ };
1059
1056
  }
1060
- }
1061
- catch {
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
+ };
1064
+ }
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
+ };
1075
+ }
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);
1062
1105
  return {
1063
- ok: false,
1064
- armed: false,
1065
- enabled: undefined,
1066
- client,
1067
- message: '.planning/config.json is not valid JSON — fix it (or re-run /pp-init), then arm the vault again. Nothing was written.',
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.',
1068
1119
  };
1069
1120
  }
1070
- const vaultSection = isObject(cfg.vault) ? cfg.vault : null;
1071
- const nested = vaultSection && Object.prototype.hasOwnProperty.call(vaultSection, 'enabled');
1072
- const flat = Object.prototype.hasOwnProperty.call(cfg, 'vault.enabled');
1073
- const current = nested && vaultSection ? vaultSection.enabled : flat ? cfg['vault.enabled'] : undefined;
1074
- if (!binding.bound) {
1121
+ catch (err) {
1075
1122
  return {
1076
1123
  ok: false,
1077
1124
  armed: false,
1078
- enabled: current,
1079
- client,
1080
- 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.',
1125
+ reason: 'write_failed',
1126
+ message: writeFailedMessage(err) + ' ' + ARM_VAULT_REMEDY,
1127
+ warnings: [],
1081
1128
  };
1082
1129
  }
1083
- if (nested || flat) {
1084
- return {
1085
- ok: true,
1086
- armed: false,
1087
- enabled: current,
1088
- client,
1089
- message: current === true
1090
- ? 'vault.enabled is already true — nothing to do.'
1091
- : `vault.enabled is explicitly set to ${JSON.stringify(current)} — kept as the operator's choice.`,
1092
- };
1093
- }
1094
- try {
1095
- writeConfigMerge(cwd, { vault: { enabled: true } });
1096
- }
1097
- catch (err) {
1098
- return { ok: false, armed: false, enabled: undefined, client, message: writeFailedMessage(err) };
1099
- }
1100
- return { ok: true, armed: true, enabled: true, client, message: 'vault.enabled set to true (it was absent).' };
1101
1130
  }
1102
1131
  // ─── cmdInitContract ────────────────────────────────────────────────────────
1103
- /**
1104
- * CLI adapter for `pp-tools query init-contract`.
1105
- *
1106
- * `--verify` runs `verifyInitInvariants` and always prints the full result
1107
- * as data (so `init.md` can read and show every violation verbatim), then
1108
- * sets `process.exitCode = 1` when the result is not `ok` — a data
1109
- * verdict, never an `error()` usage failure. This is the load-bearing
1110
- * property Task 2's acceptance requires: a caller cannot mistake a failed
1111
- * check for a pass, but the JSON body still reaches stdout either way.
1112
- *
1113
- * `--questions` (also the no-flag default, matching this seam's
1114
- * safe-default convention — mirrors `compose-bindings`'s dry-run default)
1115
- * is read-only and always exits 0.
1116
- *
1117
- * `--session-reset` / `--session-answer` / `--session-next` (34-08 gap
1118
- * closure) drive the session state machine above. Precedence — reset,
1119
- * then answer, then next, then verify, then the questions default — mirrors
1120
- * the order a relay loop actually calls them in (an agent never sends more
1121
- * than one of these per invocation; the order only matters for a caller
1122
- * that somehow set two, which should never happen from `pp-tools.cjs`'s own
1123
- * parsing). `--session-answer` and `--session-next` both set
1124
- * `process.exitCode = 1` on a data verdict that means "not ready to
1125
- * proceed" (`ok: false` / `error` present) — same never-a-usage-failure
1126
- * convention as `--verify`.
1127
- */
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. */
1128
1134
  function cmdInitContract(cwd, options, raw) {
1129
- if (options.armVault === true) {
1130
- const result = armVault(cwd);
1131
- output(result, raw);
1132
- if (!result.ok) {
1133
- process.exitCode = 1;
1134
- }
1135
- return;
1136
- }
1137
1135
  if (options.sessionReset === true) {
1138
1136
  const result = initSessionReset(cwd);
1139
1137
  output(result, raw);
@@ -1157,6 +1155,32 @@ function cmdInitContract(cwd, options, raw) {
1157
1155
  }
1158
1156
  return;
1159
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
+ }
1160
1184
  if (options.verify === true) {
1161
1185
  const result = verifyInitInvariants(cwd);
1162
1186
  output(result, raw);