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