@pareto4/pp-core 1.13.0 → 1.18.0

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