@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.
@@ -35,14 +35,14 @@ const node_fs_1 = __importDefault(require("node:fs"));
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
36
  const node_crypto_1 = __importDefault(require("node:crypto"));
37
37
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
38
+ const git_provider_cjs_1 = require("./git-provider.cjs");
39
+ const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
38
40
  const CLONE_TIMEOUT_MS = 60_000; // explicit cap so a hung clone surfaces as failed → next tier
39
41
  const REVPARSE_TIMEOUT_MS = 10_000;
40
42
  // Default built-in config values — Phase 12 reads-with-default because the
41
43
  // capability.json that declares these keys lands in plan 12-03. The impl must
42
44
  // never depend on the registry supplying them.
43
45
  const DEFAULT_TTL_HOURS = 24;
44
- const DEFAULT_FALLBACK_REPO = 'pareto4/pareto-vault';
45
- const DEFAULT_FALLBACK_REF = 'main';
46
46
  // ─── Default exec seam ──────────────────────────────────────────────────────────
47
47
  const _defaultExec = {
48
48
  glab: (args, opts = {}) => (0, shell_command_projection_cjs_1.execTool)('glab', args, opts),
@@ -175,43 +175,27 @@ function atomicSwap(stageDir, finalDir) {
175
175
  * - fallback tier → always whole-repo (glab→git); never applies vaultPaths.
176
176
  * Returns a coarse ok/fail classification; the raw stderr is recorded for diagnosis.
177
177
  */
178
+ // `isLiteralCloneTarget` — the single definition lives in `src/git-provider.cts`
179
+ // (imported above). Do not reintroduce a local copy here: a read/write
180
+ // divergence in this predicate was defect P-1 in Phase 29.
178
181
  /**
179
- * True when `repo` is already something git can clone directly — an absolute
180
- * path, a scheme-qualified URL, or scp-style `user@host:path` — rather than a
181
- * `group/project` slug that must be composed onto a host.
182
- *
183
- * MUST mirror `resolveCloneTarget` in `src/vault-write-back.cts`: the read and
184
- * write paths have to agree on what `client.vaultRepo` means. They disagreed
185
- * before — the write path used the value verbatim while this one always
186
- * composed a URL — so a slug broke writing and a path broke reading, and a
187
- * path-configured project silently fell through this ladder to the SHARED
188
- * vault and fetched it over the network.
182
+ * Doc comment: cloneTier's sparse, whole-repo and fallback paths all route
183
+ * through the provider seam now — composition (`composeCloneTarget`), host
184
+ * env (`cloneEnv`), and whether a host CLI may be spawned (`providerUsesHostCli`)
185
+ * are all provider-driven. `glab` is only ever attempted when the resolved
186
+ * provider has one (gitlab); azure (and any future host-CLI-less provider)
187
+ * goes straight to git.
189
188
  */
190
- // Security (T-29-26): a value git would read as an OPTION must never take the
191
- // verbatim branch. The scheme test is anchored at the START — an earlier version
192
- // used `value.includes('://')`, so `--upload-pack=…#x://` passed through as a
193
- // flag — and the scp-style test may not begin with `-`. This function is also
194
- // the newer half of the pair: before it existed, this path ALWAYS composed a
195
- // URL, so a flag-shaped repo could not reach git here at all. Callers pass `--`
196
- // before the target as the second layer.
197
- function isLiteralCloneTarget(value) {
198
- if (value.startsWith('-'))
199
- return false;
200
- return (/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) ||
201
- /^[\w.][\w.-]*@/.test(value) ||
202
- value.startsWith('/') ||
203
- value.startsWith('./') ||
204
- value.startsWith('../') ||
205
- value.startsWith('~') ||
206
- /^[A-Za-z]:[\\/]/.test(value));
207
- }
208
189
  function cloneTier(tier, exec, destDir, warnings) {
209
- const { repo, ref, host } = tier;
210
- const literal = isLiteralCloneTarget(repo.trim());
211
- const url = literal ? repo.trim() : `https://${host}/${repo}.git`;
190
+ const { repo, ref, host, provider } = tier;
191
+ const literal = (0, git_provider_cjs_1.isLiteralCloneTarget)(repo.trim());
192
+ const composed = (0, git_provider_cjs_1.composeCloneTarget)(provider, repo, host);
193
+ if (composed.warnings.length > 0)
194
+ warnings.push(...composed.warnings);
195
+ const url = composed.url;
212
196
  // Sparse path (client tier with explicit vaultPaths) — git only.
213
197
  if (tier.kind === 'client' && tier.paths && tier.paths.length > 0) {
214
- const cl = exec.git(['clone', '--no-checkout', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
198
+ const cl = exec.git(['clone', '--no-checkout', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: (0, git_provider_cjs_1.cloneEnv)(provider, host), timeout: CLONE_TIMEOUT_MS });
215
199
  if (cl.exitCode !== 0)
216
200
  return { ok: false, stderr: cl.stderr };
217
201
  exec.git(['sparse-checkout', 'init', '--cone'], { cwd: destDir, timeout: CLONE_TIMEOUT_MS });
@@ -227,21 +211,28 @@ function cloneTier(tier, exec, destDir, warnings) {
227
211
  // A literal path/URL is not a glab coordinate — go straight to git. Trying
228
212
  // glab first would only spend a failed subprocess before the same fallback.
229
213
  if (literal) {
230
- const direct = exec.git(['clone', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
214
+ const direct = exec.git(['clone', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: (0, git_provider_cjs_1.cloneEnv)(provider, host), timeout: CLONE_TIMEOUT_MS });
231
215
  return direct.exitCode === 0 ? { ok: true } : { ok: false, stderr: direct.stderr };
232
216
  }
233
- // Whole-repo clone: glab first (uses stored glab auth), then git fallback
234
- // (credential-helper). A missing glab binary surfaces as exitCode 127.
235
- const g = exec.glab(['repo', 'clone', repo, destDir, '--', '--depth', '1', '--branch', ref, '--single-branch'], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
236
- if (g.exitCode === 0)
237
- return { ok: true };
238
- // Reset the dest before the git attempt so a partially-populated dir from a
239
- // failed glab clone can't make `git clone` fail on a non-empty target.
240
- safeRm(destDir);
241
- const gi = exec.git(['clone', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
217
+ // Whole-repo clone: the host CLI first (uses its own stored auth), when the
218
+ // resolved provider has one, then git fallback (credential-helper). A
219
+ // provider with no host CLI (azure) skips straight to git — no glab
220
+ // attempt, no partially-populated dir to reset.
221
+ let hostCliStderr = '';
222
+ if ((0, git_provider_cjs_1.providerUsesHostCli)(provider)) {
223
+ const g = exec.glab(['repo', 'clone', repo, destDir, '--', '--depth', '1', '--branch', ref, '--single-branch'], { env: (0, git_provider_cjs_1.cloneEnv)(provider, host), timeout: CLONE_TIMEOUT_MS });
224
+ if (g.exitCode === 0)
225
+ return { ok: true };
226
+ hostCliStderr = g.stderr;
227
+ // Reset the dest before the git attempt so a partially-populated dir from
228
+ // a failed host-CLI clone can't make `git clone` fail on a non-empty
229
+ // target.
230
+ safeRm(destDir);
231
+ }
232
+ const gi = exec.git(['clone', '--depth', '1', '--branch', ref, '--single-branch', '--', url, destDir], { env: (0, git_provider_cjs_1.cloneEnv)(provider, host), timeout: CLONE_TIMEOUT_MS });
242
233
  if (gi.exitCode === 0)
243
234
  return { ok: true };
244
- return { ok: false, stderr: gi.stderr || g.stderr };
235
+ return { ok: false, stderr: gi.stderr || hostCliStderr };
245
236
  }
246
237
  // ─── Degrade (never-abort terminal states) ─────────────────────────────────────
247
238
  function priorContentExists(vaultDirPath) {
@@ -317,43 +308,82 @@ function vaultFetch(cwd, deps = {}) {
317
308
  if (!node_fs_1.default.existsSync(node_path_1.default.join(cwd, '.platform-bindings.json'))) {
318
309
  return degrade(finalDir, 'no .platform-bindings.json', warnings);
319
310
  }
311
+ // PROV-01: resolve the configured provider once per fetch, from
312
+ // `.planning/config.json` alone — never inferred from a host/repo string.
313
+ // Any warning (e.g. an unrecognized value) is folded into the same
314
+ // `warnings` array the rest of this function already accumulates.
315
+ const providerResolution = (0, git_provider_cjs_1.resolveProvider)(cwd);
316
+ const provider = providerResolution.provider;
317
+ if (providerResolution.warnings.length > 0)
318
+ warnings.push(...providerResolution.warnings);
320
319
  const client = isObject(bindings.client) ? bindings.client : null;
321
- const platform = isObject(bindings.platform) ? bindings.platform : null;
322
- const clientRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
323
- const clientRef = client && typeof client.vaultRef === 'string' && client.vaultRef ? client.vaultRef : 'main';
324
- const clientHost = client && typeof client.vaultHost === 'string' && client.vaultHost ? client.vaultHost : null;
325
320
  const clientPaths = client && Array.isArray(client.vaultPaths)
326
321
  ? client.vaultPaths.filter((p) => typeof p === 'string' && p.length > 0)
327
322
  : null;
328
- const platformHost = platform && typeof platform.host === 'string' && platform.host ? platform.host : null;
329
- // Config (read-with-default; capability declaration lands in 12-03).
323
+ // CFG-01: every read-path coordinate (repo, ref, host) for both tiers now
324
+ // comes from the single resolver in ./vault-coordinates.cjs — no inline
325
+ // host-fallback chain and no compiled-in shared-repo/ref constant survive
326
+ // here. Each resolution's `warnings` are folded into the same
327
+ // accumulator the provider resolution's warnings already reach; the
328
+ // shared fold is deduped against what the client fold already added so a
329
+ // `platform.host` read from `.planning/config.json` (D-09), which both
330
+ // resolutions can independently surface, reaches the manifest exactly
331
+ // once rather than twice.
332
+ const clientCoord = (0, vault_coordinates_cjs_1.resolveClientCoordinates)(cwd, provider);
333
+ if (clientCoord.warnings.length > 0)
334
+ warnings.push(...clientCoord.warnings);
335
+ const sharedCoord = (0, vault_coordinates_cjs_1.resolveSharedCoordinates)(cwd, provider);
336
+ for (const w of sharedCoord.warnings) {
337
+ if (!warnings.includes(w))
338
+ warnings.push(w);
339
+ }
340
+ // D-02: kept in a local rather than folded into `fallbackReason` here —
341
+ // composed at the terminal degrade call below, where it must join
342
+ // whichever client-tier failure reason (if any) is already in play
343
+ // without clobbering it.
344
+ const suppressedReason = sharedCoord.suppressedReason;
345
+ // Config (read-with-default; capability declaration lands in 12-03). TTL
346
+ // is not a coordinate — the resolver does not own it.
330
347
  const ttlRaw = cfgGet(cwd, 'vault.cache_ttl_hours', DEFAULT_TTL_HOURS);
331
348
  const ttlHours = Number.isFinite(Number(ttlRaw)) ? Number(ttlRaw) : DEFAULT_TTL_HOURS;
332
- const fbRepoRaw = cfgGet(cwd, 'vault.fallback_repo', DEFAULT_FALLBACK_REPO);
333
- const fbRepo = typeof fbRepoRaw === 'string' ? fbRepoRaw : DEFAULT_FALLBACK_REPO;
334
- const fbRefRaw = cfgGet(cwd, 'vault.fallback_ref', DEFAULT_FALLBACK_REF);
335
- const fbRef = typeof fbRefRaw === 'string' && fbRefRaw ? fbRefRaw : DEFAULT_FALLBACK_REF;
336
- // Build the ladder, skipping empty entries.
349
+ // Build the ladder, skipping empty entries. One resolved provider per
350
+ // project — attached to BOTH the client and the fallback tier, never
351
+ // re-resolved per tier (D-01: one provider per project is a permanent
352
+ // decision; a client does not straddle two providers). T-30-14's
353
+ // consequence is now materially narrower than it was: previously an
354
+ // azure-provider project addressed the shared fallback tier with the
355
+ // azure form because a built-in gitlab-only default was always applied;
356
+ // now `resolveSharedCoordinates` suppresses the shared tier entirely for
357
+ // a non-gitlab project with no `vault.fallback_repo` configured (D-02),
358
+ // so there is no fallback tier to address at all in that case. The fix
359
+ // for the azure-composes-a-gitlab-URL hazard is per-client shared
360
+ // coordinates (`vault.fallback_repo` / `vault.fallback_host`), not
361
+ // per-tier provider selection — PROV-03 still forbids a provider
362
+ // spawning another provider's CLI, so an azure project that DOES
363
+ // configure a gitlab shared vault still fetches it with plain git rather
364
+ // than glab.
337
365
  const ladder = [];
338
- if (clientRepo) {
366
+ if (clientCoord.repo) {
339
367
  ladder.push({
340
- repo: clientRepo,
341
- ref: clientRef,
368
+ repo: clientCoord.repo,
369
+ ref: clientCoord.ref,
342
370
  kind: 'client',
343
- host: clientHost || platformHost || 'gitlab.com',
371
+ host: clientCoord.host,
344
372
  paths: clientPaths && clientPaths.length > 0 ? clientPaths : null,
373
+ provider,
345
374
  });
346
375
  }
347
- if (fbRepo) {
376
+ if (sharedCoord.repo) {
348
377
  ladder.push({
349
- repo: fbRepo,
350
- ref: fbRef,
378
+ repo: sharedCoord.repo,
379
+ ref: sharedCoord.ref,
351
380
  kind: 'fallback',
352
- host: platformHost || 'gitlab.com',
381
+ host: sharedCoord.host,
353
382
  paths: null,
383
+ provider,
354
384
  });
355
385
  }
356
- let fallbackReason = clientRepo ? null : 'no client vault configured';
386
+ let fallbackReason = clientCoord.repo ? null : 'no client vault configured';
357
387
  // Cache-freshness short-circuit (no clone). Fresh ⇔ prior was a real fetch,
358
388
  // ttl > 0, age within ttl, and the ref matches the top tier we'd fetch.
359
389
  const prior = readManifest(finalDir);
@@ -422,14 +452,26 @@ function vaultFetch(cwd, deps = {}) {
422
452
  fetchedAt: new Date().toISOString(),
423
453
  files,
424
454
  fallbackReason: tier.kind === 'fallback' ? fallbackReason : null,
455
+ host: tier.host,
425
456
  };
426
457
  if (warnings.length > 0)
427
458
  manifest.warnings = warnings.slice();
428
459
  writeManifest(finalDir, manifest);
429
460
  return manifest;
430
461
  }
431
- // All tiers failed/empty — keep prior cache or record a miss. Never throws.
432
- return degrade(finalDir, fallbackReason, warnings);
462
+ // All tiers failed/empty — keep prior cache or record a miss. Never
463
+ // throws. D-02: when the shared tier was suppressed, that reason joins
464
+ // whichever client-tier failure reason is already in `fallbackReason`
465
+ // (or stands alone when there is none) rather than replacing it, so a
466
+ // degraded manifest on a non-gitlab project with no shared vault
467
+ // configured explains both why the client tier failed and why there was
468
+ // no fallback to try.
469
+ const degradeReason = suppressedReason
470
+ ? fallbackReason
471
+ ? `${fallbackReason}; ${suppressedReason}`
472
+ : suppressedReason
473
+ : fallbackReason;
474
+ return degrade(finalDir, degradeReason, warnings);
433
475
  }
434
476
  catch (e) {
435
477
  // Any unexpected throw must degrade, never propagate to a non-zero exit.
@@ -685,28 +727,63 @@ function vaultSummary(cwd) {
685
727
  * (Layer 1); this in-process assertion is advisory only and exists so a buggy
686
728
  * caller cannot resolve a write to the wrong repo without tripping a hard throw.
687
729
  *
688
- * Reuses the in-scope binding/config readers (no new imports). Throws when:
730
+ * D-10 (Phase 31, CFG-02): identity is the resolved `(repo, host)` pair for
731
+ * BOTH tiers, not the repo slug alone — the same `group/project` path on two
732
+ * different hosts is two different vaults, so comparing repo strings only
733
+ * left a hole the moment the host became configurable. Both pairs are
734
+ * resolved through the one coordinate resolver in `./vault-coordinates.cjs`
735
+ * (the same resolver the read path uses) rather than read here a second
736
+ * time.
737
+ *
738
+ * D-11: the shared-tier check is SKIPPED, not failed, when no shared
739
+ * coordinates resolve (`resolveSharedCoordinates(...).repo === null`, e.g.
740
+ * `vault.fallback_repo` set to the empty string, or unset on a non-gitlab
741
+ * provider). Failing closed was rejected — it would block a client from
742
+ * promoting until their own shared vault exists, breaking ROADMAP SC3. The
743
+ * residual risk is bounded three ways: the client-pair match check below
744
+ * still gates every write regardless of whether the shared check ran; a
745
+ * refusal caused by the skip says so explicitly in its message rather than
746
+ * silently; and this whole guard is only the advisory Layer-2 control — the
747
+ * authoritative write-isolation boundary remains the host's group/role
748
+ * topology, which is what makes the permissive branch acceptable.
749
+ *
750
+ * Throws when:
689
751
  * - no client vault is configured;
690
- * - the target is the shared fallback vault; or
691
- * - the target is any repo other than the configured client vault.
752
+ * - the target pair equals the resolved shared pair (the built-in Pareto
753
+ * default or a project-configured shared vault); or
754
+ * - the target pair is not the resolved client pair.
692
755
  *
693
756
  * @param cwd Project root (parent of `.planning/`).
694
757
  * @param targetRepo The `group/project` slug a write-back would push to.
758
+ * @param targetHost The host a write-back would push to. Required, with no
759
+ * default: an optional host on a security control means a
760
+ * caller that forgets it gets a check that trivially
761
+ * passes, which is the silent-bypass shape D-10 exists to
762
+ * remove.
695
763
  */
696
- function assertVaultWriteTarget(cwd, targetRepo) {
697
- const bindings = readBindings(cwd);
698
- const client = isObject(bindings.client) ? bindings.client : null;
699
- const clientRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
700
- const fbRepoRaw = cfgGet(cwd, 'vault.fallback_repo', DEFAULT_FALLBACK_REPO);
701
- const fbRepo = typeof fbRepoRaw === 'string' ? fbRepoRaw : DEFAULT_FALLBACK_REPO;
764
+ function assertVaultWriteTarget(cwd, targetRepo, targetHost) {
765
+ const provider = (0, git_provider_cjs_1.resolveProvider)(cwd).provider;
766
+ const clientCoord = (0, vault_coordinates_cjs_1.resolveClientCoordinates)(cwd, provider);
767
+ const sharedCoord = (0, vault_coordinates_cjs_1.resolveSharedCoordinates)(cwd, provider);
768
+ const clientRepo = clientCoord.repo;
769
+ const clientHost = clientCoord.host;
770
+ const sharedRepo = sharedCoord.repo;
771
+ const sharedHost = sharedCoord.host;
702
772
  if (!clientRepo) {
703
- throw new Error(`vault write refused: no client.vaultRepo configured — cannot resolve a write target (got "${targetRepo}").`);
773
+ throw new Error(`vault write refused: no client.vaultRepo configured — cannot resolve a write target (got "${targetRepo}" at "${targetHost}").`);
704
774
  }
705
- if (targetRepo === fbRepo) {
706
- throw new Error(`vault write refused: target "${targetRepo}" is the shared Pareto vault, which is strictly read-only.`);
775
+ if (sharedRepo !== null && targetRepo === sharedRepo && targetHost === sharedHost) {
776
+ if (sharedRepo === vault_coordinates_cjs_1.DEFAULT_SHARED_REPO) {
777
+ throw new Error(`vault write refused: target "${targetRepo}" at "${targetHost}" is the shared Pareto vault, which is strictly read-only.`);
778
+ }
779
+ throw new Error(`vault write refused: target "${targetRepo}" at "${targetHost}" is this project's configured shared vault, which is strictly read-only — the same protection the shared Pareto vault has.`);
707
780
  }
708
- if (targetRepo !== clientRepo) {
709
- throw new Error(`vault write refused: target "${targetRepo}" does not match the configured client vault "${clientRepo}".`);
781
+ if (targetRepo !== clientRepo || targetHost !== clientHost) {
782
+ let message = `vault write refused: target "${targetRepo}" at "${targetHost}" does not match the configured client vault "${clientRepo}" at "${clientHost}".`;
783
+ if (sharedRepo === null) {
784
+ message += ' No shared vault is configured, so the shared-vault check was not applied.';
785
+ }
786
+ throw new Error(message);
710
787
  }
711
788
  }
712
789
  module.exports = {
@@ -193,6 +193,7 @@
193
193
 
194
194
  const fs = require('fs');
195
195
  const path = require('path');
196
+ const readline = require('readline');
196
197
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
197
198
  const io = require('./lib/io.cjs');
198
199
  const { error, ERROR_REASON, setJsonErrorMode, output } = io;
@@ -210,6 +211,9 @@ const roadmap = require('./lib/roadmap.cjs');
210
211
  const verify = require('./lib/verify.cjs');
211
212
  const config = require('./lib/config.cjs');
212
213
  const platformBindings = require('./lib/platform-bindings.cjs');
214
+ const composeBindings = require('./lib/compose-bindings.cjs');
215
+ const initContract = require('./lib/init-contract.cjs');
216
+ const initInteractive = require('./lib/init-interactive.cjs');
213
217
  const template = require('./lib/template.cjs');
214
218
  const milestone = require('./lib/milestone.cjs');
215
219
  const commands = require('./lib/commands.cjs');
@@ -520,10 +524,10 @@ async function main() {
520
524
  // phase / roadmap / milestone / progress / etc.
521
525
  const TOP_LEVEL_USAGE = 'Usage: pp-tools <command> [args] [--raw] [--pick <field>] [--cwd <path>] [--ws <name>] [--json-errors]\n' +
522
526
  'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, ' +
523
- 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
527
+ 'compose-bindings, config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' +
524
528
  'current-timestamp, detect-custom-files, docs-init, drift-guard, effort, extract-messages, find-phase, ' +
525
529
  'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
526
- 'generate-client-context, generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
530
+ 'generate-client-context, generate-dev-preferences, generate-slug, graphify, history-digest, init, init-contract, intel, ' +
527
531
  'capability, classify-confidence, git, learnings, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, platform-bindings, profile-questionnaire, ' +
528
532
  'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' +
529
533
  'task, template, user-story, validate, validate-bindings, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' +
@@ -533,6 +537,10 @@ async function main() {
533
537
  ' --cwd <path> Override working directory for project-root resolution\n' +
534
538
  ' --ws <name> Override active workstream (or set GSD_WORKSTREAM)\n' +
535
539
  ' --json-errors Emit structured JSON error objects on stderr (or set GSD_JSON_ERRORS=1)\n\n' +
540
+ 'Note on `init`: `pp-tools init` (bare, or with only --offline/--reset) runs the interactive\n' +
541
+ '/pp-init identity+binding prompt against a real terminal. `pp-tools init <subcommand>` (e.g.\n' +
542
+ 'execute-phase, plan-phase, new-project) and `pp-tools query init.<subcommand>` are a separate,\n' +
543
+ 'distinct family of workflow-orchestration verbs — they do not prompt.\n\n' +
536
544
  'For command-specific argument requirements, invoke the command without args ' +
537
545
  '(e.g. `pp-tools phase add`) — the resulting error lists what is required.';
538
546
 
@@ -697,6 +705,51 @@ function extractField(obj, fieldPath) {
697
705
  return current;
698
706
  }
699
707
 
708
+ /**
709
+ * The production `Prompter` (src/init-interactive.cts's injectable seam)
710
+ * for `pp-tools init` — a real terminal via `node:readline`. Deliberately
711
+ * the ONLY place in this codebase that constructs a readline interface for
712
+ * `runInitInteractive`; the module itself never imports `node:readline`
713
+ * (Phase 34-09's own design hazard) so the interactive flow stays testable
714
+ * with a scripted fake and no pty.
715
+ */
716
+ function createReadlinePrompter() {
717
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
718
+ return {
719
+ ask(question) {
720
+ let rendered = question.prompt;
721
+ if (question.options && question.options.length > 0) {
722
+ const optionsRendered = question.options
723
+ .map((opt) =>
724
+ opt === question.preselected ? `${opt} (${question.preselectedLabel || 'preselected'})` : opt,
725
+ )
726
+ .join(' / ');
727
+ rendered += `\n Options: ${optionsRendered}`;
728
+ } else if (question.preselected) {
729
+ // INIT-PATCH-C: an option-less question carrying a `preselected`
730
+ // (e.g. a detected org profile) rendered no default at all today —
731
+ // render one, naming it via `preselectedLabel` when present.
732
+ rendered += `\n Default: ${question.preselectedLabel || question.preselected} (press Enter to accept)`;
733
+ }
734
+ if (question.optionalAnswer) {
735
+ // The unconditional "Enter skips" hint would be a lie once a
736
+ // default is carried — Enter now ACCEPTS the default above, so the
737
+ // skip hint must point at "none" instead.
738
+ rendered += question.preselected ? '\n (type "none" to skip)' : '\n (press Enter to skip)';
739
+ }
740
+ return new Promise((resolve) => {
741
+ rl.question(`${rendered}\n> `, (answer) => resolve(answer));
742
+ });
743
+ },
744
+ print(text) {
745
+ process.stdout.write(`${text}\n`);
746
+ },
747
+ close() {
748
+ rl.close();
749
+ },
750
+ };
751
+ }
752
+
700
753
  async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
701
754
  switch (command) {
702
755
  case 'agent': {
@@ -735,6 +788,108 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
735
788
  break;
736
789
  }
737
790
 
791
+ case 'compose-bindings': {
792
+ // compose-bindings [--write] [--offline]
793
+ // A bare invocation is a dry run — it composes, validates, and reports
794
+ // provenance/drift without touching disk (D-10). Only --write applies.
795
+ const cbArgs = args.slice(1);
796
+ const unrecognizedCbFlag = cbArgs.find(
797
+ (a) => a.startsWith('--') && !composeBindings.COMPOSE_BINDINGS_FLAGS.includes(a),
798
+ );
799
+ if (unrecognizedCbFlag !== undefined) {
800
+ error(
801
+ `Unrecognized flag "${unrecognizedCbFlag}" for compose-bindings. Supported: --write, --offline.`,
802
+ ERROR_REASON.SDK_FAIL_FAST,
803
+ );
804
+ break;
805
+ }
806
+ const cbOptions = {};
807
+ for (let i = 0; i < cbArgs.length; i++) {
808
+ const a = cbArgs[i];
809
+ if (a === '--write') {
810
+ cbOptions.write = true;
811
+ } else if (a === '--offline') {
812
+ cbOptions.offline = true;
813
+ }
814
+ }
815
+ composeBindings.cmdComposeBindings(cwd, cbOptions, raw);
816
+ break;
817
+ }
818
+
819
+ case 'init-contract': {
820
+ // init-contract [--questions] [--verify] [--session-next]
821
+ // [--session-answer <stepId>=<value>] [--session-reset] [--offline]
822
+ // --questions (also the no-flag default, D-05): read-only, always
823
+ // exits 0, prints the canonical /pp-init question set as data.
824
+ // --verify: runs the end-state invariant check (Phase 34-07 gap
825
+ // closure) and exits non-zero when any invariant is violated — a
826
+ // data verdict, never a usage error; the JSON body still reaches
827
+ // stdout either way so init.md can show every violation verbatim.
828
+ // --session-next / --session-answer / --session-reset (Phase 34-08
829
+ // gap closure): the session relay an agent drives one step at a time
830
+ // — see src/init-contract.cts's session-machine header for why this
831
+ // exists. --session-answer takes its "<stepId>=<value>" payload as
832
+ // the NEXT argv token (same convention as --cwd/--pick), extracted
833
+ // BEFORE the unrecognized-flag scan below since the payload itself
834
+ // is not a flag. --offline forwards to every composeBindings call
835
+ // the session makes (parity with compose-bindings --offline).
836
+ const icArgs = args.slice(1);
837
+ let sessionAnswerRaw;
838
+ const saIdx = icArgs.indexOf('--session-answer');
839
+ if (saIdx !== -1) {
840
+ sessionAnswerRaw = icArgs[saIdx + 1];
841
+ if (!sessionAnswerRaw || sessionAnswerRaw.startsWith('--')) {
842
+ error(
843
+ 'Missing value for --session-answer. Usage: --session-answer <stepId>=<value>',
844
+ ERROR_REASON.SDK_MISSING_ARG,
845
+ );
846
+ break;
847
+ }
848
+ icArgs.splice(saIdx, 2);
849
+ }
850
+ const unrecognizedIcFlag = icArgs.find(
851
+ (a) => a.startsWith('--') && !initContract.INIT_CONTRACT_FLAGS.includes(a),
852
+ );
853
+ if (unrecognizedIcFlag !== undefined) {
854
+ error(
855
+ `Unrecognized flag "${unrecognizedIcFlag}" for init-contract. Supported: --questions, --verify, --session-next, --session-answer, --session-reset, --offline.`,
856
+ ERROR_REASON.SDK_FAIL_FAST,
857
+ );
858
+ break;
859
+ }
860
+ const icOptions = {};
861
+ for (let i = 0; i < icArgs.length; i++) {
862
+ const a = icArgs[i];
863
+ if (a === '--questions') {
864
+ icOptions.questions = true;
865
+ } else if (a === '--verify') {
866
+ icOptions.verify = true;
867
+ } else if (a === '--session-next') {
868
+ icOptions.sessionNext = true;
869
+ } else if (a === '--session-reset') {
870
+ icOptions.sessionReset = true;
871
+ } else if (a === '--offline') {
872
+ icOptions.offline = true;
873
+ }
874
+ }
875
+ if (sessionAnswerRaw !== undefined) {
876
+ const eqIdx = sessionAnswerRaw.indexOf('=');
877
+ if (eqIdx === -1) {
878
+ error(
879
+ `Invalid --session-answer value "${sessionAnswerRaw}" — expected <stepId>=<value>.`,
880
+ ERROR_REASON.USAGE,
881
+ );
882
+ break;
883
+ }
884
+ icOptions.sessionAnswer = {
885
+ stepId: sessionAnswerRaw.slice(0, eqIdx),
886
+ value: sessionAnswerRaw.slice(eqIdx + 1),
887
+ };
888
+ }
889
+ initContract.cmdInitContract(cwd, icOptions, raw);
890
+ break;
891
+ }
892
+
738
893
  case 'banner': {
739
894
  // Pareto brand banner (Phase 17 / UIBR-02). Workflows shell out via
740
895
  // `pp_run banner <phaseKey> "<STAGE TEXT>"`. The banner reaches the
@@ -1323,6 +1478,58 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1323
1478
  }
1324
1479
 
1325
1480
  case 'init': {
1481
+ // `pp-tools init [--offline] [--reset]` (Phase 34-09 gap closure) —
1482
+ // the user-facing interactive entry point that drives the 34-08
1483
+ // session machine (src/init-interactive.cts) against a real
1484
+ // terminal. Deliberately DISTINCT from `pp-tools query init.*`
1485
+ // (init-command-router.cjs's execute-phase/plan-phase/new-project/...
1486
+ // workflow-orchestration verbs, unchanged below) — args[1] decides
1487
+ // which: a bare invocation or a flag starting with "--" is always
1488
+ // this interactive command; anything else (a bare subcommand word
1489
+ // like "execute-phase") routes to the existing family untouched.
1490
+ const initSub = args[1];
1491
+ const isInteractiveInvocation = initSub === undefined || initSub.startsWith('--');
1492
+ if (isInteractiveInvocation) {
1493
+ const INIT_INTERACTIVE_FLAGS = ['--offline', '--reset'];
1494
+ const unrecognizedInitFlag = args.slice(1).find((a) => !INIT_INTERACTIVE_FLAGS.includes(a));
1495
+ if (unrecognizedInitFlag !== undefined) {
1496
+ error(
1497
+ `Unrecognized flag "${unrecognizedInitFlag}" for init. Supported: --offline, --reset. ` +
1498
+ 'For init workflow subcommands (execute-phase, plan-phase, new-project, ...), use "pp-tools query init.<subcommand>".',
1499
+ ERROR_REASON.SDK_FAIL_FAST,
1500
+ );
1501
+ break;
1502
+ }
1503
+ // Refuse to prompt when stdin is not a TTY — never block on stdin
1504
+ // in CI; a hang here would be worse than a failure (T-34-71).
1505
+ if (!process.stdin.isTTY) {
1506
+ error(
1507
+ '"pp-tools init" requires an interactive terminal (stdin is not a TTY) and will not prompt. ' +
1508
+ 'This command cannot run inside an AI-agent shell or a piped session — open a regular ' +
1509
+ 'terminal window (e.g. Terminal, iTerm, or Windows Terminal) and run it there. ' +
1510
+ 'For a non-interactive/CI-driven session, use "pp-tools query init-contract --session-next" / ' +
1511
+ '"--session-answer <stepId>=<value>" directly instead.',
1512
+ ERROR_REASON.INIT_NON_INTERACTIVE,
1513
+ );
1514
+ break;
1515
+ }
1516
+ if (args.includes('--reset')) {
1517
+ initContract.initSessionReset(cwd);
1518
+ }
1519
+ const prompter = createReadlinePrompter();
1520
+ try {
1521
+ const result = await initInteractive.runInitInteractive(cwd, prompter, {
1522
+ offline: args.includes('--offline'),
1523
+ });
1524
+ output(result, raw);
1525
+ if (!result.ok) {
1526
+ process.exitCode = 1;
1527
+ }
1528
+ } finally {
1529
+ prompter.close();
1530
+ }
1531
+ break;
1532
+ }
1326
1533
  routeInitCommand({
1327
1534
  init,
1328
1535
  args,
@@ -757,7 +757,7 @@ This session uses the **Session 3: Process-First Protocol** (Steps 5A–5G descr
757
757
  - What SLAs exist for external system availability?
758
758
 
759
759
  5. **Architecture Decision Records (ADRs)**
760
- *Why it matters: ADRs are the client-facing artifact a Finaps-style reviewer checks for rationale traceability. Skip when: never — always capture at least the decisions already made.*
760
+ *Why it matters: ADRs are the client-facing artifact an external reviewer checks for rationale traceability. Skip when: never — always capture at least the decisions already made.*
761
761
  - Document key decisions with context, decision, and consequences
762
762
  - What alternatives were considered and why were they rejected?
763
763
  - What are the trade-offs of the chosen approach?
@@ -8,7 +8,7 @@
8
8
  > non-recursive) — this is the sanctioned progressive-disclosure escape hatch
9
9
  > for a DEFAULT-tier workflow file, not a workaround.
10
10
  >
11
- > Purpose: answers Finaps finding #9 — the client gets a real architect-grade
11
+ > Purpose: answers an external-review finding — the client gets a real architect-grade
12
12
  > handoff, not just process state. This step is factored on purpose so it can
13
13
  > be re-run standalone later if Discovery sessions change, without touching
14
14
  > the main `discovery-to-build.md` flow.