@pareto4/pp-core 1.10.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1080 @@
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
+ */
43
+ var __importDefault = (this && this.__importDefault) || function (mod) {
44
+ return (mod && mod.__esModule) ? mod : { "default": mod };
45
+ };
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.INIT_SESSION_WRITE_FAILED = exports.INIT_SESSION_MALFORMED = exports.INIT_CONTRACT_FLAGS = exports.INIT_COMPOSE_DECLINED_KEY = void 0;
48
+ exports.buildInitQuestions = buildInitQuestions;
49
+ exports.verifyInitInvariants = verifyInitInvariants;
50
+ exports.initSessionNext = initSessionNext;
51
+ exports.initSessionAnswer = initSessionAnswer;
52
+ exports.initSessionReset = initSessionReset;
53
+ exports.cmdInitContract = cmdInitContract;
54
+ const node_fs_1 = __importDefault(require("node:fs"));
55
+ const node_path_1 = __importDefault(require("node:path"));
56
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
57
+ const io = require("./io.cjs");
58
+ const { output } = io;
59
+ const git_provider_cjs_1 = require("./git-provider.cjs");
60
+ const org_profile_cjs_1 = require("./org-profile.cjs");
61
+ const compose_bindings_cjs_1 = require("./compose-bindings.cjs");
62
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
63
+ // ─── Constants ───────────────────────────────────────────────────────────────
64
+ /**
65
+ * The dotted `.planning/config.json` key `/pp-init` Step 2.5 sets when the
66
+ * operator explicitly declines the compose-and-write confirmation. This is
67
+ * the ONLY signal that distinguishes "the operator was asked and said no"
68
+ * from "the step never ran at all" — both leave `.platform-bindings.json`
69
+ * absent, and `verifyInitInvariants` cannot tell them apart any other way
70
+ * (see `INVARIANT_CODE.COMPOSITION_NOT_RUN` below). Registered as a
71
+ * `vault` capability config key (`capabilities/vault/capability.json`) so
72
+ * `pp-tools config-set` accepts it without the "unknown config key" error
73
+ * a bare unregistered top-level namespace would trip.
74
+ */
75
+ exports.INIT_COMPOSE_DECLINED_KEY = 'init.compose_declined';
76
+ /** The identity-only choices appended after the provider seam's own
77
+ * `PROVIDERS` list — never retyped ahead of `PROVIDERS` itself (T-34-50). */
78
+ const IDENTITY_ONLY_PROVIDER_OPTIONS = Object.freeze(['github', 'other', 'none']);
79
+ /** Bounds the `node_modules` scan `verifyInitInvariants` runs to answer
80
+ * "could an org profile package have been pointed to" — a DoS guard, the
81
+ * same convention `src/org-profile.cts`'s `PROFILE_FLATTEN_LEAF_CAP` and
82
+ * `PROFILE_FLATTEN_DEPTH_CAP` use for the analogous profile-document walk. */
83
+ const NODE_MODULES_SCAN_CAP = 2000;
84
+ const INVARIANT_CODE = Object.freeze({
85
+ ORG_PROFILE_UNASKED: 'INIT_QUESTIONS_ORG_PROFILE_UNASKED',
86
+ COMPOSITION_NOT_RUN: 'INIT_COMPOSITION_NOT_RUN',
87
+ VAULT_PROVIDER_UNSET: 'INIT_VAULT_PROVIDER_UNSET',
88
+ IDENTITY_MISSING: 'INIT_IDENTITY_MISSING',
89
+ });
90
+ /** The recognized flag set for the `init-contract` CLI arm (the
91
+ * `pp-tools.cjs` fail-fast unrecognized-flag guard reads this, mirroring
92
+ * `COMPOSE_BINDINGS_FLAGS`). Extended by Task 2 (34-08 gap closure) with the
93
+ * session relay flags — `--session-answer` additionally consumes the next
94
+ * argv token as its `<stepId>=<value>` payload (parsed in `pp-tools.cjs`,
95
+ * mirroring the `--cwd`/`--pick` convention), and `--offline` forwards to
96
+ * every `composeBindings` call the session makes so a CLI-driven loop can
97
+ * run with no network access — parity with `compose-bindings --offline`. */
98
+ exports.INIT_CONTRACT_FLAGS = Object.freeze([
99
+ '--questions',
100
+ '--verify',
101
+ '--session-next',
102
+ '--session-answer',
103
+ '--session-reset',
104
+ '--offline',
105
+ ]);
106
+ /** `.planning/.init-session.json` — the CLI-owned session state file
107
+ * (Artifacts table, 34-08). Never hand-edited; malformed content is
108
+ * refused, not silently repaired (T-34-63). */
109
+ const INIT_SESSION_FILE_NAME = '.init-session.json';
110
+ /** The `error` code `initSessionNext`/`initSessionAnswer` report when the
111
+ * session file exists but does not parse as valid session state — the ONLY
112
+ * recovery is `--session-reset` (T-34-63); this module never resumes a
113
+ * malformed file into a guessed-at state. */
114
+ exports.INIT_SESSION_MALFORMED = 'INIT_SESSION_MALFORMED';
115
+ /** CR-01: the `error`/`reason` code `initSessionNext`/`initSessionAnswer`
116
+ * report when a filesystem write this module performs (`persistSession`,
117
+ * `writeConfigMerge`, the write-mode `composeBindings` call) fails — e.g. an
118
+ * unwritable `.planning` directory (permissions, a read-only mount, a full
119
+ * disk). Every sibling module in this seam (`composeBindings`,
120
+ * `resolveOrgProfileDocument`, `resolveOrgProfile`, `verifyInitInvariants`)
121
+ * never throws and degrades to a bounded data verdict instead (D-09); this
122
+ * module previously did not follow that convention on its write paths and
123
+ * let the exception escape uncaught. Recovery is to fix the underlying
124
+ * filesystem issue and retry the same step — nothing is left half-recorded,
125
+ * since the throw is caught before `session` is persisted. */
126
+ exports.INIT_SESSION_WRITE_FAILED = 'INIT_SESSION_WRITE_FAILED';
127
+ /** compose_confirm's answer vocabulary — deliberately permissive so a human
128
+ * relaying through `AskUserQuestion`/TEXT_MODE can answer naturally; every
129
+ * other string is rejected (never silently treated as a decline). */
130
+ const YES_VALUES = Object.freeze(['yes', 'y', 'approve', 'approved', 'true']);
131
+ const NO_VALUES = Object.freeze(['no', 'n', 'decline', 'declined', 'false']);
132
+ /** Maps each of the 7 identity/provider/visibility question ids (every
133
+ * question but `org_profile`, which is written immediately on answer — see
134
+ * `initSessionAnswer` — so the very next question, `provider`, can
135
+ * re-resolve its `preselected` against it) to the short key it is written
136
+ * under inside `.planning/config.json`'s `project` block. */
137
+ const PROJECT_BLOCK_QUESTION_MAP = Object.freeze({
138
+ project_name: 'name',
139
+ project_slug: 'slug',
140
+ client_name: 'client_name',
141
+ client_slug: 'client_slug',
142
+ description: 'description',
143
+ provider: 'provider',
144
+ visibility: 'visibility',
145
+ });
146
+ // ─── Small local helpers (deliberate per-module duplication — this repo's
147
+ // established convention for small, cycle-risk-free helpers; see
148
+ // `src/git-provider.cts`'s `cfgGet` for the canonical example) ────────────────
149
+ function isObject(v) {
150
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
151
+ }
152
+ function isUsableString(v) {
153
+ return typeof v === 'string' && v.trim() !== '';
154
+ }
155
+ function getDotted(obj, dottedPath) {
156
+ return dottedPath.split('.').reduce((cur, key) => (isObject(cur) ? cur[key] : undefined), obj);
157
+ }
158
+ // ─── buildInitQuestions ────────────────────────────────────────────────────
159
+ /**
160
+ * The canonical `/pp-init` question set, as data. `init.md` renders exactly
161
+ * what this returns, in the order returned, none merged, reordered or
162
+ * dropped — see the module header for why this moved out of prose.
163
+ *
164
+ * Question 6 (`org_profile`) is the org profile pointer, marked
165
+ * `optionalAnswer: true` — that flag describes the ANSWER (pressing Enter
166
+ * is a complete, valid response), never whether the question is asked.
167
+ *
168
+ * Question 6 (`org_profile`)'s `preselected` is resolved via
169
+ * `resolveDetectedOrgProfile` — set only when exactly one org-profile-shaped
170
+ * package is installed (INIT-PATCH-C); the prompt text is built from that
171
+ * detected value rather than kept as two hard-coded strings that can drift.
172
+ * `opts._globalRoot` is forwarded to it unchanged, the same test-seam
173
+ * convention `verifyInitInvariants`'s own `_globalRoot` already follows —
174
+ * production callers omit it.
175
+ *
176
+ * Question 7 (`provider`) carries the fixed, complete option list: every
177
+ * value in `PROVIDERS` (imported, never retyped — this is what keeps
178
+ * `azure` from being silently dropped again, T-34-50) plus the three
179
+ * identity-only choices. `preselected` is resolved via `resolveProvider`,
180
+ * the same authority the `compose-bindings --offline --pick provider`
181
+ * probe reads (pure config + org-profile consultation, no network) — so
182
+ * calling it directly here is equivalent, not a second implementation.
183
+ */
184
+ function buildInitQuestions(cwd, opts = {}) {
185
+ let preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
186
+ try {
187
+ preselected = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
188
+ }
189
+ catch {
190
+ preselected = git_provider_cjs_1.DEFAULT_PROVIDER;
191
+ }
192
+ const providerOptions = Object.freeze([...git_provider_cjs_1.PROVIDERS, ...IDENTITY_ONLY_PROVIDER_OPTIONS]);
193
+ const detectedOrgProfile = resolveDetectedOrgProfile(cwd, opts);
194
+ // The template example is written as an angle-bracket placeholder, not a
195
+ // literal package name. `tests/org-profile.test.cjs`'s client-confidentiality
196
+ // guard scans this file for any concrete scope-slash-org-prefix package
197
+ // identifier, so even a generic literal placeholder trips it — the template
198
+ // keeps the naming convention visible while staying clean, and the guard
199
+ // stays maximally strict, no allowlist entry was added for it. The detected
200
+ // value itself is never a literal in this file — it is a runtime string
201
+ // read from an installed package, interpolated into the prompt below.
202
+ const orgProfileQuestion = isUsableString(detectedOrgProfile)
203
+ ? {
204
+ id: 'org_profile',
205
+ prompt: `Org profile package (optional — a single installed org profile was detected: "${detectedOrgProfile}". ` +
206
+ 'Press Enter to use it, type "none" to skip it, or type a different package name.)',
207
+ optionalAnswer: true,
208
+ preselected: detectedOrgProfile,
209
+ preselectedLabel: `${detectedOrgProfile} (detected installed profile)`,
210
+ }
211
+ : {
212
+ id: 'org_profile',
213
+ prompt: 'Org profile package (optional — press Enter to skip, e.g. "@<your-scope>/org-<name>")',
214
+ optionalAnswer: true,
215
+ };
216
+ return [
217
+ { id: 'project_name', prompt: 'Project name (human-readable, e.g. "Acme Dashboard")', optionalAnswer: false },
218
+ { id: 'project_slug', prompt: 'Project slug (kebab-case, e.g. "acme-dashboard")', optionalAnswer: false },
219
+ {
220
+ id: 'client_name',
221
+ prompt: 'Client name (company / team, e.g. "Acme Corp") — type "internal" if none',
222
+ optionalAnswer: false,
223
+ },
224
+ {
225
+ id: 'client_slug',
226
+ prompt: 'Client slug (kebab-case, e.g. "acme") — type "internal" if none',
227
+ optionalAnswer: false,
228
+ },
229
+ { id: 'description', prompt: 'One-line description (what this project does)', optionalAnswer: false },
230
+ orgProfileQuestion,
231
+ {
232
+ id: 'provider',
233
+ prompt: 'Provider (gitlab / azure / github / other / none)',
234
+ optionalAnswer: false,
235
+ options: providerOptions,
236
+ preselected,
237
+ preselectedLabel: `${preselected} (resolved default)`,
238
+ },
239
+ { id: 'visibility', prompt: 'Visibility (private / public / internal)', optionalAnswer: false },
240
+ ];
241
+ }
242
+ // ─── verifyInitInvariants ───────────────────────────────────────────────────
243
+ /**
244
+ * Byte-for-byte the same heuristic `src/org-profile.cts`'s own (private,
245
+ * unexported) `resolveGlobalPackageRoot` uses. Duplicated here because it
246
+ * is not part of that module's public surface — the same per-module
247
+ * duplication convention every sibling seam module in this repo follows
248
+ * for helpers it cannot import without either widening an unrelated
249
+ * module's exports or risking a require cycle.
250
+ */
251
+ function resolveGlobalNodeModulesRoot() {
252
+ try {
253
+ const prefixEnv = process.env.NPM_CONFIG_PREFIX ?? process.env.npm_config_prefix;
254
+ let candidate;
255
+ if (typeof prefixEnv === 'string' && prefixEnv.trim() !== '') {
256
+ const prefix = prefixEnv.trim();
257
+ candidate =
258
+ process.platform === 'win32' ? node_path_1.default.join(prefix, 'node_modules') : node_path_1.default.join(prefix, 'lib', 'node_modules');
259
+ }
260
+ else {
261
+ const execDir = node_path_1.default.dirname(process.execPath);
262
+ candidate =
263
+ process.platform === 'win32'
264
+ ? node_path_1.default.join(execDir, 'node_modules')
265
+ : node_path_1.default.resolve(execDir, '..', 'lib', 'node_modules');
266
+ }
267
+ return node_fs_1.default.existsSync(candidate) ? candidate : null;
268
+ }
269
+ catch {
270
+ return null;
271
+ }
272
+ }
273
+ /** Conservative npm-package-name shape check (bare `name` or scoped
274
+ * `@scope/name`; lowercase, digits, `.`/`_`/`-`, never leading `.`/`_` in
275
+ * either segment). A directory name that does not match is never offered as
276
+ * a detected default (T-QP-02): it cannot be a resolvable package pointer,
277
+ * so rejecting it here keeps a hostile directory name (control characters,
278
+ * terminal escapes) from ever reaching a rendered prompt. */
279
+ const NPM_PACKAGE_NAME_SHAPE_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
280
+ /**
281
+ * Walk `nodeModulesDir` exactly like the boolean check below (one level, or
282
+ * one level inside an `@scope/` directory), collecting the name of every
283
+ * package directory carrying `ORG_PROFILE_DATA_FILE` at its root instead of
284
+ * stopping at the first hit. `opts.limit`, when set, stops the walk once
285
+ * that many names have been collected — passing `1` reproduces the boolean
286
+ * check's early-exit behaviour exactly. Bounded at `NODE_MODULES_SCAN_CAP`
287
+ * total directory entries visited (T-34-54: this check must never hang on a
288
+ * pathological `node_modules` tree).
289
+ *
290
+ * This is a heuristic, not a resolution: it answers "a package shaped like
291
+ * an org profile is installed here", not "this exact profile would
292
+ * resolve" — callers never claim more than that.
293
+ */
294
+ function collectOrgProfileShapedPackages(nodeModulesDir, opts = {}) {
295
+ const names = [];
296
+ let visited = 0;
297
+ const limitReached = () => opts.limit !== undefined && names.length >= opts.limit;
298
+ const addIfShaped = (name) => {
299
+ if (NPM_PACKAGE_NAME_SHAPE_RE.test(name))
300
+ names.push(name);
301
+ };
302
+ let entries;
303
+ try {
304
+ entries = node_fs_1.default.readdirSync(nodeModulesDir, { withFileTypes: true });
305
+ }
306
+ catch {
307
+ return { names, capped: false };
308
+ }
309
+ for (const entry of entries) {
310
+ if (limitReached())
311
+ return { names, capped: false };
312
+ if (visited >= NODE_MODULES_SCAN_CAP)
313
+ return { names, capped: true };
314
+ visited += 1;
315
+ if (!entry.isDirectory())
316
+ continue;
317
+ if (entry.name.startsWith('@')) {
318
+ const scopeDir = node_path_1.default.join(nodeModulesDir, entry.name);
319
+ let scoped;
320
+ try {
321
+ scoped = node_fs_1.default.readdirSync(scopeDir, { withFileTypes: true });
322
+ }
323
+ catch {
324
+ continue;
325
+ }
326
+ for (const scopedEntry of scoped) {
327
+ if (limitReached())
328
+ return { names, capped: false };
329
+ if (visited >= NODE_MODULES_SCAN_CAP)
330
+ return { names, capped: true };
331
+ visited += 1;
332
+ if (!scopedEntry.isDirectory())
333
+ continue;
334
+ try {
335
+ if (node_fs_1.default.existsSync(node_path_1.default.join(scopeDir, scopedEntry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
336
+ addIfShaped(`${entry.name}/${scopedEntry.name}`);
337
+ }
338
+ }
339
+ catch {
340
+ continue;
341
+ }
342
+ }
343
+ }
344
+ else {
345
+ try {
346
+ if (node_fs_1.default.existsSync(node_path_1.default.join(nodeModulesDir, entry.name, org_profile_cjs_1.ORG_PROFILE_DATA_FILE))) {
347
+ addIfShaped(entry.name);
348
+ }
349
+ }
350
+ catch {
351
+ continue;
352
+ }
353
+ }
354
+ }
355
+ return { names, capped: false };
356
+ }
357
+ /**
358
+ * True when `nodeModulesDir` contains at least one package directory (one
359
+ * level, or one level inside an `@scope/` directory) carrying a file named
360
+ * `ORG_PROFILE_DATA_FILE` at its root — the same fixed filename
361
+ * `resolveOrgProfileDocument` reads, imported rather than retyped. Reduced to
362
+ * the collecting walk above with `limit: 1`, preserving its early-exit
363
+ * behaviour exactly.
364
+ */
365
+ function containsOrgProfileShapedPackage(nodeModulesDir) {
366
+ return collectOrgProfileShapedPackages(nodeModulesDir, { limit: 1 }).names.length > 0;
367
+ }
368
+ function orgProfilePackageResolvable(cwd, opts) {
369
+ try {
370
+ const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
371
+ if (node_fs_1.default.existsSync(projectNodeModules) && containsOrgProfileShapedPackage(projectNodeModules)) {
372
+ return true;
373
+ }
374
+ const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
375
+ if (globalRoot && containsOrgProfileShapedPackage(globalRoot)) {
376
+ return true;
377
+ }
378
+ return false;
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ /**
385
+ * Detect a single, unambiguous installed org-profile-shaped package
386
+ * (INIT-PATCH-C): collects from the project's `node_modules` and — using the
387
+ * same `opts._globalRoot` override convention `orgProfilePackageResolvable`
388
+ * already follows — the global root, de-duplicates by package name across
389
+ * both roots, and returns that single name only when exactly one distinct
390
+ * name was found and NEITHER scan reported `capped`. A capped scan is
391
+ * inconclusive and must never yield a default (T-QP-03). Two or more
392
+ * distinct names, or none, returns null. Never throws.
393
+ */
394
+ function resolveDetectedOrgProfile(cwd, opts) {
395
+ try {
396
+ const names = new Set();
397
+ let anyCapped = false;
398
+ const projectNodeModules = node_path_1.default.join(cwd, 'node_modules');
399
+ if (node_fs_1.default.existsSync(projectNodeModules)) {
400
+ const projectResult = collectOrgProfileShapedPackages(projectNodeModules);
401
+ if (projectResult.capped)
402
+ anyCapped = true;
403
+ for (const name of projectResult.names)
404
+ names.add(name);
405
+ }
406
+ const globalRoot = opts._globalRoot !== undefined ? opts._globalRoot : resolveGlobalNodeModulesRoot();
407
+ if (globalRoot) {
408
+ const globalResult = collectOrgProfileShapedPackages(globalRoot);
409
+ if (globalResult.capped)
410
+ anyCapped = true;
411
+ for (const name of globalResult.names)
412
+ names.add(name);
413
+ }
414
+ if (anyCapped)
415
+ return null;
416
+ return names.size === 1 ? [...names][0] : null;
417
+ }
418
+ catch {
419
+ return null;
420
+ }
421
+ }
422
+ /** Read `.planning/config.json` at `cwd`, distinguishing "file absent" from
423
+ * "file present but empty/malformed" — `INIT_IDENTITY_MISSING` only fires
424
+ * on the latter (see `verifyInitInvariants`'s own doc comment for why an
425
+ * absent file must never trip it). Never throws. */
426
+ function readConfigFile(cwd) {
427
+ const configPath = node_path_1.default.join(cwd, '.planning', 'config.json');
428
+ let exists = false;
429
+ try {
430
+ exists = node_fs_1.default.existsSync(configPath);
431
+ if (!exists) {
432
+ return { exists: false, parsed: {} };
433
+ }
434
+ const raw = node_fs_1.default.readFileSync(configPath, 'utf-8');
435
+ const parsedUnknown = JSON.parse(raw);
436
+ return { exists: true, parsed: isObject(parsedUnknown) ? parsedUnknown : {} };
437
+ }
438
+ catch {
439
+ // A present-but-unreadable/malformed file still counts as "a config
440
+ // file exists" for INIT_IDENTITY_MISSING's purposes.
441
+ return { exists, parsed: {} };
442
+ }
443
+ }
444
+ /**
445
+ * Inspect the project's RESULTING state at `cwd` after an `/pp-init` run
446
+ * and report every named invariant it violates. Read-only: no filesystem
447
+ * write anywhere in this function or in the `composeBindings` call it
448
+ * makes (always `offline: true`, `write` omitted so it defaults to
449
+ * `false`). Never throws — each check below is independently guarded so
450
+ * one failing check cannot suppress the others; there is no outer
451
+ * catch-all because none is needed (see the design note below).
452
+ *
453
+ * An empty directory — nothing in `.planning/`, no `.platform-bindings.json`
454
+ * — returns `{ ok: true, violations: [] }`. Every check requires some
455
+ * positive signal that a partial init was attempted (a config file present
456
+ * for `INIT_IDENTITY_MISSING`; a "gitlab"/"azure" `project.provider` for
457
+ * `INIT_VAULT_PROVIDER_UNSET`; an installed org-profile-shaped package for
458
+ * `INIT_QUESTIONS_ORG_PROFILE_UNASKED`; a `composable: true` dry run for
459
+ * `INIT_COMPOSITION_NOT_RUN`) — an empty directory carries none of them, so
460
+ * nothing is reported as skipped. This is deliberate (see the module
461
+ * header): nothing was attempted, so nothing can be reported as attempted-
462
+ * and-abandoned.
463
+ */
464
+ function verifyInitInvariants(cwd, opts = {}) {
465
+ const violations = [];
466
+ const { exists: configExists, parsed } = readConfigFile(cwd);
467
+ // INIT_IDENTITY_MISSING — only meaningful once a config file exists at
468
+ // all; an absent file is "nothing was attempted", not a violation.
469
+ try {
470
+ if (configExists) {
471
+ const projectBlock = getDotted(parsed, 'project');
472
+ const hasProjectBlock = isObject(projectBlock) && Object.keys(projectBlock).length > 0;
473
+ if (!hasProjectBlock) {
474
+ violations.push({
475
+ code: INVARIANT_CODE.IDENTITY_MISSING,
476
+ message: 'No "project" block found in .planning/config.json — Step 2 identity capture did not complete.',
477
+ });
478
+ }
479
+ }
480
+ }
481
+ catch {
482
+ // fail open — one check's fault must not suppress the others
483
+ }
484
+ // INIT_VAULT_PROVIDER_UNSET — project.provider names a provisioning
485
+ // provider (an identity record, D-13) but vault.provider —
486
+ // resolveProvider's ONLY gating key — was never set, so the D-13
487
+ // provisioning gate this phase built never fires.
488
+ try {
489
+ const projectProvider = getDotted(parsed, 'project.provider');
490
+ const vaultProvider = getDotted(parsed, 'vault.provider');
491
+ if ((projectProvider === 'gitlab' || projectProvider === 'azure') && !isUsableString(vaultProvider)) {
492
+ violations.push({
493
+ code: INVARIANT_CODE.VAULT_PROVIDER_UNSET,
494
+ message: `project.provider is "${String(projectProvider)}" but vault.provider is unset — the D-13 provisioning gate is disabled.`,
495
+ });
496
+ }
497
+ }
498
+ catch {
499
+ // fail open
500
+ }
501
+ // INIT_QUESTIONS_ORG_PROFILE_UNASKED — an org-profile-shaped package is
502
+ // installed (project node_modules or the machine's global root) but
503
+ // org.profile was never set, i.e. Question 6 appears to have been
504
+ // skipped rather than answered blank.
505
+ try {
506
+ const orgProfilePointer = getDotted(parsed, org_profile_cjs_1.ORG_PROFILE_CONFIG_KEY);
507
+ if (!isUsableString(orgProfilePointer) && orgProfilePackageResolvable(cwd, opts)) {
508
+ violations.push({
509
+ code: INVARIANT_CODE.ORG_PROFILE_UNASKED,
510
+ message: 'An org-profile-shaped package is installed but org.profile is unset — the pointer question (Question 6) appears to have been skipped rather than answered.',
511
+ });
512
+ }
513
+ }
514
+ catch {
515
+ // fail open
516
+ }
517
+ // INIT_COMPOSITION_NOT_RUN — a binding was composable from the org
518
+ // profile and project answers, but .platform-bindings.json was never
519
+ // written and no explicit decline was recorded — Step 2.5 appears to
520
+ // have been skipped rather than run-and-declined.
521
+ try {
522
+ const bindingsPath = node_path_1.default.join(cwd, '.platform-bindings.json');
523
+ const bindingsExist = node_fs_1.default.existsSync(bindingsPath);
524
+ const declined = getDotted(parsed, exports.INIT_COMPOSE_DECLINED_KEY) === true;
525
+ if (!bindingsExist && !declined) {
526
+ const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: true, _globalRoot: opts._globalRoot });
527
+ if (dryRun.composable === true) {
528
+ violations.push({
529
+ code: INVARIANT_CODE.COMPOSITION_NOT_RUN,
530
+ 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.',
531
+ });
532
+ }
533
+ }
534
+ }
535
+ catch {
536
+ // fail open
537
+ }
538
+ return { ok: violations.length === 0, violations };
539
+ }
540
+ // ─── Session state machine (34-08 gap closure) ─────────────────────────────
541
+ //
542
+ // Why this exists at all: `buildInitQuestions`/`verifyInitInvariants` make
543
+ // the CONTENT correct when called and the OMISSION provable afterwards —
544
+ // they do not make skipping impossible, because prose can still hand an
545
+ // agent the full 8-question list and the agent can still compress it (this
546
+ // module's own header; `34-VERIFICATION.md`'s third walk). This session
547
+ // machine removes the list itself: `initSessionNext` returns exactly ONE
548
+ // step, `initSessionAnswer` records exactly ONE answer and rejects
549
+ // anything out of order, and the CLI performs every side effect (identity
550
+ // write, `vault.provider`, composition) at the state that needs it — never
551
+ // leaving it to a workflow's own prose to remember. Neither
552
+ // `buildInitQuestions` nor `verifyInitInvariants` nor `composeBindings` is
553
+ // reimplemented here: this machine only sequences calls into them.
554
+ function sessionFilePath(cwd) {
555
+ return node_path_1.default.join(cwd, '.planning', INIT_SESSION_FILE_NAME);
556
+ }
557
+ function newSession() {
558
+ return { version: 1, answers: {}, identityWritten: false, composeDecision: null, doneAt: null };
559
+ }
560
+ /** Validate the ENTIRE persisted shape before trusting it — a session file
561
+ * that merely parses as JSON but carries the wrong shape (hand-edited,
562
+ * truncated mid-write, a leftover from an incompatible future version) must
563
+ * fail closed exactly like a parse error (T-34-63). */
564
+ function isValidSessionShape(v) {
565
+ if (!isObject(v))
566
+ return false;
567
+ const version = v.version;
568
+ const answers = v.answers;
569
+ const identityWritten = v.identityWritten;
570
+ const composeDecision = v.composeDecision;
571
+ const doneAt = v.doneAt;
572
+ if (version !== 1)
573
+ return false;
574
+ if (!isObject(answers))
575
+ return false;
576
+ for (const val of Object.values(answers)) {
577
+ if (typeof val !== 'string')
578
+ return false;
579
+ }
580
+ if (typeof identityWritten !== 'boolean')
581
+ return false;
582
+ if (composeDecision !== null &&
583
+ composeDecision !== 'approved' &&
584
+ composeDecision !== 'declined' &&
585
+ composeDecision !== 'skipped' &&
586
+ composeDecision !== 'unchanged') {
587
+ return false;
588
+ }
589
+ if (doneAt !== null && typeof doneAt !== 'string')
590
+ return false;
591
+ return true;
592
+ }
593
+ /** Absent file → a brand-new session (the normal first call). Present and
594
+ * valid → that session. Present but invalid in ANY way → `{ malformed:
595
+ * true }`, and every caller below refuses to proceed rather than silently
596
+ * re-initializing (T-34-63) — `initSessionReset` is the only documented
597
+ * recovery. Never throws. */
598
+ function loadSession(cwd) {
599
+ const p = sessionFilePath(cwd);
600
+ try {
601
+ if (!node_fs_1.default.existsSync(p)) {
602
+ return { malformed: false, session: newSession() };
603
+ }
604
+ const raw = node_fs_1.default.readFileSync(p, 'utf-8');
605
+ const parsed = JSON.parse(raw);
606
+ if (!isValidSessionShape(parsed)) {
607
+ return { malformed: true };
608
+ }
609
+ return { malformed: false, session: parsed };
610
+ }
611
+ catch {
612
+ return { malformed: true };
613
+ }
614
+ }
615
+ /** CR-01: format an unexpected write failure (e.g. EACCES) as a bounded,
616
+ * human-readable message — never a raw stack trace reaching the operator. */
617
+ function writeFailedMessage(err) {
618
+ const detail = err instanceof Error ? err.message : String(err);
619
+ return (`A filesystem write inside the init session failed (${detail}). ` +
620
+ 'This project\'s .planning directory (or .platform-bindings.json) may not be writable ' +
621
+ '(permissions, a read-only mount, a full disk). Nothing further was recorded for this step — ' +
622
+ 'fix the underlying filesystem issue and try the same step again.');
623
+ }
624
+ function persistSession(cwd, session) {
625
+ const dir = node_path_1.default.join(cwd, '.planning');
626
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
627
+ (0, shell_command_projection_cjs_1.platformWriteSync)(sessionFilePath(cwd), JSON.stringify(session, null, 2) + '\n');
628
+ }
629
+ /** Read `.planning/config.json` for a MERGE write — distinct from
630
+ * `readConfigFile` above (which reports `exists`/malformed for
631
+ * `verifyInitInvariants`'s read-only purposes): this helper only ever
632
+ * needs "the object to merge into", degrading to `{}` on any absence or
633
+ * fault so a merge write never crashes on a pre-existing malformed file —
634
+ * it simply starts the merge from empty and the write below then produces
635
+ * a well-formed file going forward. */
636
+ function readConfigForMerge(cwd) {
637
+ try {
638
+ const raw = node_fs_1.default.readFileSync(node_path_1.default.join(cwd, '.planning', 'config.json'), 'utf-8');
639
+ const parsed = JSON.parse(raw);
640
+ return isObject(parsed) ? parsed : {};
641
+ }
642
+ catch {
643
+ return {};
644
+ }
645
+ }
646
+ /** Shallow-recursive merge: object values merge key-by-key; any other
647
+ * value (string, number, boolean, array) replaces the target outright.
648
+ * Bounded by the patches this module ever constructs itself (`project.*`,
649
+ * `vault.provider`, `org.profile`, `init.compose_declined` — never a
650
+ * caller-supplied arbitrary depth), so no depth cap is needed here the way
651
+ * `walkLeaves` in `compose-bindings.cts` needs one for untrusted file
652
+ * content. */
653
+ function deepMergeConfigPatch(target, patch) {
654
+ for (const k of Object.keys(patch)) {
655
+ const pv = patch[k];
656
+ const existing = target[k];
657
+ if (isObject(pv) && isObject(existing)) {
658
+ target[k] = deepMergeConfigPatch({ ...existing }, pv);
659
+ }
660
+ else {
661
+ target[k] = pv;
662
+ }
663
+ }
664
+ return target;
665
+ }
666
+ /** The ONLY place this module writes `.planning/config.json` — every side
667
+ * effect the session performs (identity write, `vault.provider`,
668
+ * `org.profile`, `init.compose_declined`) routes through here, merged
669
+ * against whatever is already on disk so a session step never clobbers an
670
+ * unrelated key a human or another tool wrote. Direct `fs`/`JSON` I/O,
671
+ * bypassing `config-schema.cts`'s registered-key validation — the same
672
+ * convention `compose-bindings.cts`'s `cfgGet` and this module's own
673
+ * `readConfigFile` already use for `project.*`/`org.profile` reads; see
674
+ * `.planning/todos/pending/project-config-key-unknown.md` for why `project`
675
+ * is not (yet) a registered top-level key, out of scope for this plan. */
676
+ function writeConfigMerge(cwd, patch) {
677
+ const dir = node_path_1.default.join(cwd, '.planning');
678
+ (0, shell_command_projection_cjs_1.platformEnsureDir)(dir);
679
+ const merged = deepMergeConfigPatch(readConfigForMerge(cwd), patch);
680
+ (0, shell_command_projection_cjs_1.platformWriteSync)(node_path_1.default.join(dir, 'config.json'), JSON.stringify(merged, null, 2) + '\n');
681
+ }
682
+ /** The batch write Task 1 §2 describes: once all 8 answers are recorded,
683
+ * write the `project` identity block and — only when the `provider` answer
684
+ * is exactly `gitlab` or `azure` — `vault.provider`. `org_profile` is
685
+ * excluded from this map: it is written immediately when THAT answer is
686
+ * recorded (see `initSessionAnswer`), not deferred here, precisely so the
687
+ * `provider` question re-resolves its `preselected` against it. */
688
+ function writeIdentityAndProvider(cwd, answers) {
689
+ const projectPatch = {};
690
+ for (const [questionId, configKey] of Object.entries(PROJECT_BLOCK_QUESTION_MAP)) {
691
+ const raw = answers[questionId];
692
+ if (isUsableString(raw)) {
693
+ projectPatch[configKey] = raw.trim();
694
+ }
695
+ }
696
+ const patch = { project: projectPatch };
697
+ const providerAnswer = answers.provider;
698
+ if (providerAnswer === 'gitlab' || providerAnswer === 'azure') {
699
+ patch.vault = { provider: providerAnswer };
700
+ }
701
+ writeConfigMerge(cwd, patch);
702
+ }
703
+ /** Case/whitespace-tolerant yes/no parse for the `compose_confirm` answer.
704
+ * `null` for anything else — never defaulted to a decline, so a typo'd
705
+ * answer is rejected rather than silently treated as "no". */
706
+ function normalizeYesNo(value) {
707
+ if (typeof value !== 'string')
708
+ return null;
709
+ const v = value.trim().toLowerCase();
710
+ if (YES_VALUES.includes(v))
711
+ return true;
712
+ if (NO_VALUES.includes(v))
713
+ return false;
714
+ return null;
715
+ }
716
+ /**
717
+ * The single decision function every public session entry point drives.
718
+ * Recomputes `buildInitQuestions(cwd)` FRESH on every call — never cached
719
+ * across calls — which is what makes the `provider` question's
720
+ * `preselected` reflect an `org_profile` answer given moments earlier in
721
+ * the SAME session (the walk-3 regression this plan's design hazards
722
+ * name): by the time this function is asked for the `provider` question,
723
+ * `org.profile` has already been written to disk by `initSessionAnswer`,
724
+ * so `buildInitQuestions`'s own `resolveProvider` call sees it.
725
+ *
726
+ * Performs three idempotent side effects, each guarded by its own session
727
+ * flag so re-calling this (as every public entry point does, to validate
728
+ * the current step) never repeats a write: the identity+provider batch
729
+ * write once all 8 questions are answered; marking `composeDecision:
730
+ * 'skipped'` when nothing is composable at all (the real inert path,
731
+ * INIT-02) or `composeDecision: 'unchanged'` when a binding IS composable
732
+ * but the existing file already matches it (a healthy, up-to-date state —
733
+ * NOT the inert path, and reported with its own distinct message; no
734
+ * `compose_confirm` prompt either way, nothing written); and recording
735
+ * `doneAt` once `verifyInitInvariants` first reports `ok: true`.
736
+ */
737
+ function computeCurrentStep(cwd, session, opts) {
738
+ // CR-01: every sibling module in this seam (composeBindings,
739
+ // resolveOrgProfileDocument, resolveOrgProfile, verifyInitInvariants)
740
+ // wraps its entire body in a top-level try/catch so a filesystem fault
741
+ // degrades to a bounded result rather than crashing (D-09). This
742
+ // function's writes (writeIdentityAndProvider/persistSession, below) did
743
+ // not — an unwritable .planning directory threw an uncaught EACCES
744
+ // straight out of initSessionNext/initSessionAnswer. Fixed by matching
745
+ // the convention: any failure anywhere in this body degrades to a
746
+ // `write_failed` verdict instead of propagating.
747
+ try {
748
+ const questions = buildInitQuestions(cwd, { _globalRoot: opts._globalRoot });
749
+ for (const q of questions) {
750
+ if (!Object.prototype.hasOwnProperty.call(session.answers, q.id)) {
751
+ return { kind: 'question', question: q };
752
+ }
753
+ }
754
+ if (!session.identityWritten) {
755
+ writeIdentityAndProvider(cwd, session.answers);
756
+ session.identityWritten = true;
757
+ persistSession(cwd, session);
758
+ }
759
+ if (session.composeDecision === null) {
760
+ const dryRun = (0, compose_bindings_cjs_1.composeBindings)(cwd, { offline: opts.offline === true, _globalRoot: opts._globalRoot });
761
+ // WR-01: only offer compose_confirm when approving it would actually
762
+ // change something. `action: 'unchanged'` means the existing file
763
+ // already carries every key the composer would produce (D-08's
764
+ // absent-only merge) — asking the operator to approve a write that
765
+ // writes nothing is exactly the shape of prompt that produced the
766
+ // false-success report this finding names. Fall through to the
767
+ // messaging below instead of asking — but see the coordinator
768
+ // follow-up: 'unchanged' and 'not composable at all' are DIFFERENT
769
+ // end states and must record (and later report) distinct decisions,
770
+ // never sharing the inert-path (INIT-02) message below.
771
+ if (dryRun.composable === true && dryRun.action !== 'unchanged') {
772
+ return {
773
+ kind: 'compose_confirm',
774
+ info: {
775
+ provenance: dryRun.provenance,
776
+ drift: dryRun.drift,
777
+ action: dryRun.action,
778
+ composed: dryRun.composed,
779
+ warnings: dryRun.warnings,
780
+ },
781
+ };
782
+ }
783
+ session.composeDecision = dryRun.composable === true && dryRun.action === 'unchanged' ? 'unchanged' : 'skipped';
784
+ persistSession(cwd, session);
785
+ }
786
+ // Three genuinely different end states can reach `done`/`verify` with
787
+ // nothing written, and each needs its own message — conflating them
788
+ // (as an earlier pass of this fix did) tells an operator with a
789
+ // healthy, up-to-date binding that their org profile produced nothing
790
+ // composable, which is exactly the "tool reports a state that isn't
791
+ // the real one" failure shape this phase exists to eliminate.
792
+ const skippedComposeInfo = session.composeDecision === 'skipped'
793
+ ? '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).'
794
+ : session.composeDecision === 'unchanged'
795
+ ? '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.'
796
+ : undefined;
797
+ const verifyResult = verifyInitInvariants(cwd, { _globalRoot: opts._globalRoot });
798
+ if (verifyResult.ok) {
799
+ if (!session.doneAt) {
800
+ session.doneAt = new Date().toISOString();
801
+ persistSession(cwd, session);
802
+ }
803
+ return { kind: 'done', skippedComposeInfo };
804
+ }
805
+ return { kind: 'verify', violations: verifyResult.violations, skippedComposeInfo };
806
+ }
807
+ catch (err) {
808
+ return { kind: 'write_failed', message: writeFailedMessage(err) };
809
+ }
810
+ }
811
+ function currentStepToState(step) {
812
+ switch (step.kind) {
813
+ case 'question': {
814
+ const q = step.question;
815
+ const state = { done: false, step: q.id, prompt: q.prompt, optionalAnswer: q.optionalAnswer };
816
+ if (q.options !== undefined)
817
+ state.options = q.options;
818
+ if (q.preselected !== undefined)
819
+ state.preselected = q.preselected;
820
+ if (q.preselectedLabel !== undefined)
821
+ state.preselectedLabel = q.preselectedLabel;
822
+ return state;
823
+ }
824
+ case 'compose_confirm':
825
+ return {
826
+ done: false,
827
+ step: 'compose_confirm',
828
+ prompt: 'A binding can be composed from the org profile and project answers. Compose and write .platform-bindings.json now? (yes/no)',
829
+ optionalAnswer: false,
830
+ info: step.info,
831
+ };
832
+ case 'verify':
833
+ return {
834
+ done: false,
835
+ step: 'verify',
836
+ prompt: null,
837
+ violations: step.violations,
838
+ ...(step.skippedComposeInfo !== undefined ? { info: step.skippedComposeInfo } : {}),
839
+ };
840
+ case 'done':
841
+ return {
842
+ done: true,
843
+ step: 'done',
844
+ prompt: null,
845
+ ...(step.skippedComposeInfo !== undefined ? { info: step.skippedComposeInfo } : {}),
846
+ };
847
+ case 'write_failed':
848
+ return {
849
+ done: false,
850
+ step: null,
851
+ prompt: null,
852
+ error: exports.INIT_SESSION_WRITE_FAILED,
853
+ message: step.message,
854
+ };
855
+ }
856
+ }
857
+ /**
858
+ * Return the ONE next step of the `/pp-init` session at `cwd` — a question,
859
+ * `compose_confirm`, `verify`, or `done`. Never a list. Fails closed (T-34-
860
+ * 63) with `error: INIT_SESSION_MALFORMED` when the session file exists but
861
+ * does not parse as valid session state; `--session-reset` is the
862
+ * documented recovery, never a silent re-initialize.
863
+ */
864
+ function initSessionNext(cwd, opts = {}) {
865
+ const loaded = loadSession(cwd);
866
+ if (loaded.malformed) {
867
+ return {
868
+ done: false,
869
+ step: null,
870
+ prompt: null,
871
+ error: exports.INIT_SESSION_MALFORMED,
872
+ message: 'The init session file at .planning/.init-session.json exists but is malformed or unreadable. ' +
873
+ 'Run "pp_run query init-contract --session-reset" to start over — it is never resumed silently into an unknown state.',
874
+ };
875
+ }
876
+ return currentStepToState(computeCurrentStep(cwd, loaded.session, opts));
877
+ }
878
+ /**
879
+ * Record ONE answer for the CURRENT step only. Rejects — with `ok: false`
880
+ * and a named `reason`, never a throw — an answer for any step other than
881
+ * the one `initSessionNext` would currently serve (T-34-60: an agent trying
882
+ * to batch or skip ahead fails loudly), a blank answer to a
883
+ * non-`optionalAnswer` question, an unparseable `compose_confirm` value, or
884
+ * any answer once the session is already `done`.
885
+ *
886
+ * The `org_profile` answer is written to `.planning/config.json` (`org.profile`)
887
+ * IMMEDIATELY when non-blank — the one exception to the "batch write after
888
+ * the last answer" rule in `computeCurrentStep`'s own doc comment — because
889
+ * the very next question (`provider`) must see it to re-resolve its
890
+ * `preselected` (the walk-3 regression Task 1's acceptance names).
891
+ */
892
+ function initSessionAnswer(cwd, stepId, value, opts = {}) {
893
+ const loaded = loadSession(cwd);
894
+ if (loaded.malformed) {
895
+ return {
896
+ ok: false,
897
+ reason: 'session_malformed',
898
+ message: 'The init session file at .planning/.init-session.json exists but is malformed or unreadable. ' +
899
+ 'Run "pp_run query init-contract --session-reset" to start over.',
900
+ };
901
+ }
902
+ const session = loaded.session;
903
+ // CR-01: wrap the entire remaining body — every write this function
904
+ // performs directly (the `org_profile` immediate write, the batch
905
+ // `persistSession` calls, the `compose_confirm` decline/approve writes)
906
+ // — in a top-level try/catch, matching the D-09 convention every sibling
907
+ // module in this seam already follows. `computeCurrentStep` degrades to
908
+ // a `write_failed` step on its own internal failures (handled explicitly
909
+ // below); this catch additionally covers the write sites that live in
910
+ // THIS function, after `computeCurrentStep` returns.
911
+ try {
912
+ const step = computeCurrentStep(cwd, session, opts);
913
+ if (step.kind === 'write_failed') {
914
+ return { ok: false, reason: 'write_failed', message: step.message };
915
+ }
916
+ if (step.kind === 'done') {
917
+ return {
918
+ ok: false,
919
+ reason: 'session_already_done',
920
+ message: 'The init session is already complete; there is nothing left to answer. Run --session-reset to start over.',
921
+ };
922
+ }
923
+ if (step.kind === 'question') {
924
+ if (step.question.id !== stepId) {
925
+ return {
926
+ ok: false,
927
+ reason: 'step_mismatch',
928
+ message: `Expected an answer for step "${step.question.id}" but received one for "${stepId}". Answers must be submitted in the order the session serves them — call --session-next to see the actual current step.`,
929
+ };
930
+ }
931
+ if (!step.question.optionalAnswer && (typeof value !== 'string' || value.trim() === '')) {
932
+ return { ok: false, reason: 'answer_required', message: `Question "${stepId}" requires a non-blank answer.` };
933
+ }
934
+ session.answers[stepId] = typeof value === 'string' ? value : '';
935
+ if (stepId === 'org_profile') {
936
+ const trimmed = session.answers[stepId].trim();
937
+ if (trimmed !== '') {
938
+ writeConfigMerge(cwd, { org: { profile: trimmed } });
939
+ }
940
+ }
941
+ persistSession(cwd, session);
942
+ return { ok: true };
943
+ }
944
+ if (step.kind === 'compose_confirm') {
945
+ if (stepId !== 'compose_confirm') {
946
+ return {
947
+ ok: false,
948
+ reason: 'step_mismatch',
949
+ message: `Expected an answer for step "compose_confirm" but received one for "${stepId}". Call --session-next to see the actual current step.`,
950
+ };
951
+ }
952
+ const normalized = normalizeYesNo(value);
953
+ if (normalized === null) {
954
+ return {
955
+ ok: false,
956
+ reason: 'invalid_answer',
957
+ message: 'compose_confirm requires "yes" or "no" (also accepts approve/decline, y/n, true/false).',
958
+ };
959
+ }
960
+ if (normalized === true) {
961
+ // WR-02: inspect the write-mode result before recording the
962
+ // decision. The dry run inside computeCurrentStep already reported
963
+ // composable:true/action!=='unchanged' moments earlier, but the
964
+ // world can change between that dry run and this write (the file
965
+ // became malformed on disk in between, a transient fault now caught
966
+ // internally by composeBindings's own outer catch, etc.) — in that
967
+ // case `wrote` comes back false even though the operator approved.
968
+ // Recording 'approved' unconditionally here would make the session
969
+ // proceed toward done/verify as though the write had succeeded,
970
+ // and the eventual INIT_COMPOSITION_NOT_RUN violation would then
971
+ // misdescribe what happened ("Step 2.5 appears to have been
972
+ // skipped") when in fact the user approved and the write failed.
973
+ const writeResult = (0, compose_bindings_cjs_1.composeBindings)(cwd, { write: true, offline: opts.offline === true, _globalRoot: opts._globalRoot });
974
+ if (writeResult.composable !== true || writeResult.wrote !== true) {
975
+ // Do NOT record composeDecision here — leave it null so the next
976
+ // --session-next call re-evaluates from a fresh dry run (and, if
977
+ // still composable, re-offers compose_confirm) rather than
978
+ // silently treating this as skipped or declined.
979
+ return {
980
+ ok: false,
981
+ reason: 'compose_write_failed',
982
+ message: 'compose_confirm was approved, but the write to .platform-bindings.json did not succeed — ' +
983
+ 'this was NOT recorded as skipped or declined.' +
984
+ (writeResult.warnings.length > 0 ? ` Warnings: ${writeResult.warnings.join('; ')}.` : '') +
985
+ ' Call --session-next to see the current state and try again.',
986
+ };
987
+ }
988
+ session.composeDecision = 'approved';
989
+ }
990
+ else {
991
+ writeConfigMerge(cwd, { init: { compose_declined: true } });
992
+ session.composeDecision = 'declined';
993
+ }
994
+ persistSession(cwd, session);
995
+ return { ok: true };
996
+ }
997
+ // step.kind === 'verify': a system-computed check, never answerable.
998
+ return {
999
+ ok: false,
1000
+ reason: 'step_not_answerable',
1001
+ message: 'Step "verify" does not accept an answer — it is a system-computed check. Call --session-next to see its result.',
1002
+ };
1003
+ }
1004
+ catch (err) {
1005
+ return { ok: false, reason: 'write_failed', message: writeFailedMessage(err) };
1006
+ }
1007
+ }
1008
+ /** Delete the session file, the documented recovery from a malformed
1009
+ * session (T-34-63). Never throws; a missing file is not an error. */
1010
+ function initSessionReset(cwd) {
1011
+ try {
1012
+ const p = sessionFilePath(cwd);
1013
+ if (node_fs_1.default.existsSync(p))
1014
+ node_fs_1.default.unlinkSync(p);
1015
+ }
1016
+ catch {
1017
+ // best-effort — nothing further this function can do
1018
+ }
1019
+ return { ok: true };
1020
+ }
1021
+ // ─── cmdInitContract ────────────────────────────────────────────────────────
1022
+ /**
1023
+ * CLI adapter for `pp-tools query init-contract`.
1024
+ *
1025
+ * `--verify` runs `verifyInitInvariants` and always prints the full result
1026
+ * as data (so `init.md` can read and show every violation verbatim), then
1027
+ * sets `process.exitCode = 1` when the result is not `ok` — a data
1028
+ * verdict, never an `error()` usage failure. This is the load-bearing
1029
+ * property Task 2's acceptance requires: a caller cannot mistake a failed
1030
+ * check for a pass, but the JSON body still reaches stdout either way.
1031
+ *
1032
+ * `--questions` (also the no-flag default, matching this seam's
1033
+ * safe-default convention — mirrors `compose-bindings`'s dry-run default)
1034
+ * is read-only and always exits 0.
1035
+ *
1036
+ * `--session-reset` / `--session-answer` / `--session-next` (34-08 gap
1037
+ * closure) drive the session state machine above. Precedence — reset,
1038
+ * then answer, then next, then verify, then the questions default — mirrors
1039
+ * the order a relay loop actually calls them in (an agent never sends more
1040
+ * than one of these per invocation; the order only matters for a caller
1041
+ * that somehow set two, which should never happen from `pp-tools.cjs`'s own
1042
+ * parsing). `--session-answer` and `--session-next` both set
1043
+ * `process.exitCode = 1` on a data verdict that means "not ready to
1044
+ * proceed" (`ok: false` / `error` present) — same never-a-usage-failure
1045
+ * convention as `--verify`.
1046
+ */
1047
+ function cmdInitContract(cwd, options, raw) {
1048
+ if (options.sessionReset === true) {
1049
+ const result = initSessionReset(cwd);
1050
+ output(result, raw);
1051
+ return;
1052
+ }
1053
+ if (options.sessionAnswer !== undefined) {
1054
+ const result = initSessionAnswer(cwd, options.sessionAnswer.stepId, options.sessionAnswer.value, {
1055
+ offline: options.offline === true,
1056
+ });
1057
+ output(result, raw);
1058
+ if (!result.ok) {
1059
+ process.exitCode = 1;
1060
+ }
1061
+ return;
1062
+ }
1063
+ if (options.sessionNext === true) {
1064
+ const state = initSessionNext(cwd, { offline: options.offline === true });
1065
+ output(state, raw);
1066
+ if (state.error !== undefined) {
1067
+ process.exitCode = 1;
1068
+ }
1069
+ return;
1070
+ }
1071
+ if (options.verify === true) {
1072
+ const result = verifyInitInvariants(cwd);
1073
+ output(result, raw);
1074
+ if (!result.ok) {
1075
+ process.exitCode = 1;
1076
+ }
1077
+ return;
1078
+ }
1079
+ output({ questions: buildInitQuestions(cwd) }, raw);
1080
+ }