@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,183 @@
1
+ "use strict";
2
+ /**
3
+ * src/init-interactive.cts — drives the 34-08 `/pp-init` session state
4
+ * machine against an injectable prompt seam (milestone v1.11.0, Phase
5
+ * 34-09, gap closure).
6
+ *
7
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
8
+ * compiles to the git-ignored `pp-core/bin/lib/init-interactive.cjs`; do NOT
9
+ * hand-edit the compiled output. Behaviour is locked by
10
+ * `tests/init-interactive.test.cjs`.
11
+ *
12
+ * Born from a documented failure, not a speculative hardening. Four human
13
+ * UAT walks of `/pp-init` on 2026-07-31 (`34-VALIDATION.md` § Manual-Only
14
+ * Verifications) all failed at the identical point, across four escalating
15
+ * enforcement strategies — prose, hardened prose, a data-supplying CLI arm
16
+ * (34-07), and 34-08's one-step-at-a-time session state machine. The agent
17
+ * executed every pure-bash step reliably in all four walks and completed
18
+ * Step 3 remote wiring every time; the ONLY step it never once performed as
19
+ * written is the one whose job is to ask the user a structured question
20
+ * set — at that boundary it substitutes its own `AskUserQuestion` wizard
21
+ * and discards the document. 34-08 gave the CLI the sequence but left the
22
+ * agent the interaction; this module gives the CLI the interaction itself.
23
+ *
24
+ * `runInitInteractive` reimplements NOTHING from `src/init-contract.cts` —
25
+ * it only sequences calls into `initSessionNext`/`initSessionAnswer`, which
26
+ * remain the sole authority for the question set, the identity/provider
27
+ * writes, and the compose-and-write decision. The `prompter` parameter is
28
+ * the seam: an object with `ask()`/`print()` that the CLI (pp-tools.cjs)
29
+ * implements with `node:readline` against a real terminal, and that tests
30
+ * implement with a scripted fake. This module deliberately never imports
31
+ * `node:readline` itself — that is what keeps the whole interactive flow
32
+ * testable by `node:test` with no pty (see the plan's own design hazards).
33
+ *
34
+ * Residual risk, stated plainly (see the plan's threat register): nothing
35
+ * here compels a user to run `pp-tools init`. What it removes is the
36
+ * failure mode that actually bit four times — `/pp-init` appearing to
37
+ * complete while silently producing no binding.
38
+ */
39
+ var __importDefault = (this && this.__importDefault) || function (mod) {
40
+ return (mod && mod.__esModule) ? mod : { "default": mod };
41
+ };
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.runInitInteractive = runInitInteractive;
44
+ const node_fs_1 = __importDefault(require("node:fs"));
45
+ const node_path_1 = __importDefault(require("node:path"));
46
+ const init_contract_cjs_1 = require("./init-contract.cjs");
47
+ // ─── Constants ───────────────────────────────────────────────────────────────
48
+ /** Defensive circuit breaker against a runaway loop (a misbehaving
49
+ * prompter or an unexpected session state that never reaches `done` or
50
+ * `verify`). The real session never exceeds ~10 steps (8 questions +
51
+ * `compose_confirm` + `done`); this is headroom, not a real budget. */
52
+ const MAX_SESSION_STEPS = 100;
53
+ // ─── Small local helpers ───────────────────────────────────────────────────
54
+ function isRecord(v) {
55
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
56
+ }
57
+ function bindingsPathFor(cwd) {
58
+ return node_path_1.default.join(cwd, '.platform-bindings.json');
59
+ }
60
+ /** Snapshot `.platform-bindings.json`'s content (`null` when absent) — used
61
+ * to compute `wroteBinding` from an actual pre/post diff rather than a bare
62
+ * post-hoc `existsSync`, which reads a pre-existing file (predating this
63
+ * run) as "we wrote it" (WR-01). Read failures degrade to `null` — this is
64
+ * a diagnostic snapshot, not a source of truth `initSessionAnswer` itself
65
+ * relies on, so it must never throw. */
66
+ function readBindingsContent(cwd) {
67
+ try {
68
+ return node_fs_1.default.readFileSync(bindingsPathFor(cwd), 'utf8');
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** Render `compose_confirm`'s provenance table, drift list and action to
75
+ * the prompter, BEFORE the caller asks for approval — plan Task 1's own
76
+ * ordering requirement. */
77
+ function renderComposeConfirm(prompter, info) {
78
+ if (!isRecord(info))
79
+ return;
80
+ const typed = info;
81
+ prompter.print('A binding can be composed from the org profile and project answers:');
82
+ for (const entry of typed.provenance ?? []) {
83
+ prompter.print(` ${entry.field} = ${JSON.stringify(entry.value)} (from ${entry.source})`);
84
+ }
85
+ const drift = typed.drift ?? [];
86
+ if (drift.length > 0) {
87
+ prompter.print('Drift (the already-committed file wins — nothing here is a proposed change):');
88
+ for (const d of drift) {
89
+ prompter.print(` the profile would have supplied ${JSON.stringify(d.profileValue)} for "${d.key}"; ` +
90
+ `the file already says ${JSON.stringify(d.effectiveValue)}, and ${JSON.stringify(d.effectiveValue)} wins.`);
91
+ }
92
+ }
93
+ for (const warning of typed.warnings ?? []) {
94
+ prompter.print(` warning: ${warning}`);
95
+ }
96
+ prompter.print(`Action: ${typed.action ?? 'unknown'}`);
97
+ }
98
+ // ─── runInitInteractive ─────────────────────────────────────────────────────
99
+ /**
100
+ * Drive the `/pp-init` session at `cwd` to completion, asking `prompter`
101
+ * for exactly one answer per step and submitting it back to the session.
102
+ * Reimplements no session logic — `initSessionNext`/`initSessionAnswer`
103
+ * remain the sole authority for sequencing, validation and every write.
104
+ *
105
+ * Loop, per session step:
106
+ * - `error` (malformed session file) → print and stop, `ok: false`.
107
+ * - `done` → print a short summary (what was written, or that nothing was
108
+ * composed and why) and stop, `ok: true`.
109
+ * - `verify` (unresolved invariant violations) → print every violation
110
+ * and stop, `ok: false` — this is the intentional "visibly unfinished"
111
+ * outcome the session design is built around, never a crash.
112
+ * - `question` / `compose_confirm` → render (provenance/drift/action for
113
+ * `compose_confirm`, BEFORE asking), then `ask` the prompter and submit
114
+ * the answer. A REJECTED answer (`initSessionAnswer` returns `ok:
115
+ * false`) is re-asked with the session's own `message`, in a bounded
116
+ * inner loop — never swallowed, never silently treated as accepted.
117
+ */
118
+ async function runInitInteractive(cwd, prompter, opts = {}) {
119
+ const sessionOpts = { offline: opts.offline === true, _globalRoot: opts._globalRoot };
120
+ let wroteBinding = false;
121
+ for (let steps = 1; steps <= MAX_SESSION_STEPS; steps++) {
122
+ const state = (0, init_contract_cjs_1.initSessionNext)(cwd, sessionOpts);
123
+ if (state.error !== undefined) {
124
+ prompter.print(`Error: ${state.message ?? state.error}`);
125
+ return { ok: false, wroteBinding, steps, violations: [] };
126
+ }
127
+ if (state.done) {
128
+ if (wroteBinding) {
129
+ prompter.print('Done — .platform-bindings.json was composed and written.');
130
+ }
131
+ else if (typeof state.info === 'string') {
132
+ prompter.print(state.info);
133
+ }
134
+ else {
135
+ prompter.print('Done — nothing was composed (declined, or nothing was composable).');
136
+ }
137
+ return { ok: true, wroteBinding, steps, violations: [] };
138
+ }
139
+ if (state.step === 'verify') {
140
+ const violations = state.violations ?? [];
141
+ prompter.print('The session could not reach a clean end state:');
142
+ for (const v of violations) {
143
+ prompter.print(` - [${v.code}] ${v.message}`);
144
+ }
145
+ return { ok: false, wroteBinding, steps, violations };
146
+ }
147
+ if (state.step === null) {
148
+ // Defensive: the session contract never returns a null step outside
149
+ // `done`/`verify`/`error` above. Fail closed rather than crash.
150
+ prompter.print('Unexpected session state: no step id was returned.');
151
+ return { ok: false, wroteBinding, steps, violations: [] };
152
+ }
153
+ const stepId = state.step;
154
+ if (stepId === 'compose_confirm') {
155
+ renderComposeConfirm(prompter, state.info);
156
+ }
157
+ // Ask, re-asking on rejection — never swallowed (T-34-73).
158
+ for (;;) {
159
+ // WR-01: capture the pre-answer state of the binding file BEFORE
160
+ // submitting the answer, so a true write can be told apart from a
161
+ // pre-existing file that predates this run (a bare post-hoc
162
+ // `existsSync` cannot make that distinction).
163
+ const preBindings = stepId === 'compose_confirm' ? readBindingsContent(cwd) : null;
164
+ const answer = await prompter.ask({
165
+ prompt: state.prompt ?? '',
166
+ options: state.options,
167
+ preselected: state.preselected,
168
+ preselectedLabel: state.preselectedLabel,
169
+ optionalAnswer: state.optionalAnswer,
170
+ });
171
+ const result = (0, init_contract_cjs_1.initSessionAnswer)(cwd, stepId, answer, sessionOpts);
172
+ if (result.ok) {
173
+ if (stepId === 'compose_confirm') {
174
+ wroteBinding = readBindingsContent(cwd) !== preBindings;
175
+ }
176
+ break;
177
+ }
178
+ prompter.print(result.message ?? `Rejected (${result.reason ?? 'unknown reason'}) — please answer again.`);
179
+ }
180
+ }
181
+ prompter.print('init session exceeded the maximum step count — aborting to avoid an infinite loop.');
182
+ return { ok: false, wroteBinding, steps: MAX_SESSION_STEPS, violations: [] };
183
+ }
@@ -177,6 +177,8 @@ const ERROR_REASON = Object.freeze({
177
177
  SECURITY_SCAN_FAILED: 'security_scan_failed',
178
178
  // platform-bindings
179
179
  PLATFORM_BINDINGS_PARSE_FAILED: 'platform_bindings_parse_failed',
180
+ // init (Phase 34-09 gap closure: `pp-tools init` interactive command)
181
+ INIT_NON_INTERACTIVE: 'init_non_interactive',
180
182
  // vault write-back promote arm (Phase 29 WRB-01/03)
181
183
  VAULT_PROMOTE_DISABLED: 'vault_promote_disabled',
182
184
  VAULT_PROMOTE_INVALID_PRACTICE: 'vault_promote_invalid_practice',