@pareto4/pp-core 1.9.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.
Files changed (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-fixer.md +11 -0
  3. package/agents/pp-code-reviewer.md +77 -1
  4. package/agents/pp-executor.md +34 -1
  5. package/agents/pp-planner.md +24 -103
  6. package/commands/pp-ns-manage.md +2 -1
  7. package/commands/pp-vault-promote.md +31 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +5 -3
  10. package/pareto-skills/pp-brand/SKILL.md +1 -1
  11. package/pp-core/bin/lib/capability-registry.cjs +160 -3
  12. package/pp-core/bin/lib/capability-state.cjs +41 -3
  13. package/pp-core/bin/lib/check-command-router.cjs +473 -3
  14. package/pp-core/bin/lib/clusters.cjs +3 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +736 -0
  16. package/pp-core/bin/lib/git-provider.cjs +279 -0
  17. package/pp-core/bin/lib/init-contract.cjs +989 -0
  18. package/pp-core/bin/lib/init-interactive.cjs +183 -0
  19. package/pp-core/bin/lib/io.cjs +9 -0
  20. package/pp-core/bin/lib/org-profile.cjs +696 -0
  21. package/pp-core/bin/lib/platform-bindings.cjs +268 -64
  22. package/pp-core/bin/lib/practices-review-check.cjs +938 -0
  23. package/pp-core/bin/lib/vault-command-router.cjs +239 -2
  24. package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
  25. package/pp-core/bin/lib/vault-practices.cjs +982 -0
  26. package/pp-core/bin/lib/vault-write-back.cjs +842 -0
  27. package/pp-core/bin/lib/vault.cjs +170 -51
  28. package/pp-core/bin/pp-tools.cjs +225 -2
  29. package/pp-core/references/planner-checkpoints.md +72 -0
  30. package/pp-core/references/planner-tdd.md +55 -0
  31. package/pp-core/references/planner-vault-practices.md +67 -0
  32. package/pp-core/templates/practices/README.md +122 -0
  33. package/pp-core/templates/practices/error-handling.md +18 -0
  34. package/pp-core/templates/practices/minimal-comments.md +14 -0
  35. package/pp-core/templates/practices/naming-convention.md +18 -0
  36. package/pp-core/templates/practices/practice-template.md +23 -0
  37. package/pp-core/workflows/code-review.md +74 -18
  38. package/pp-core/workflows/discovery-sprint.md +1 -1
  39. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
  40. package/pp-core/workflows/init.md +137 -76
  41. package/pp-core/workflows/ship.md +1 -0
  42. package/pp-core/workflows/vault-write-back.md +213 -0
  43. package/scripts/gen-capability-registry.cjs +68 -8
  44. package/scripts/gen-platform-bindings-schema.cjs +148 -0
  45. package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
  46. package/scripts/lint-descriptions.cjs +3 -1
  47. package/scripts/lint-legacy-dir-name.cjs +5 -0
  48. package/scripts/lint-test-file-count.allowlist.json +10 -1
@@ -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,12 +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.
181
+ /**
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.
188
+ */
178
189
  function cloneTier(tier, exec, destDir, warnings) {
179
- const { repo, ref, host } = tier;
180
- const url = `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;
181
196
  // Sparse path (client tier with explicit vaultPaths) — git only.
182
197
  if (tier.kind === 'client' && tier.paths && tier.paths.length > 0) {
183
- 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 });
184
199
  if (cl.exitCode !== 0)
185
200
  return { ok: false, stderr: cl.stderr };
186
201
  exec.git(['sparse-checkout', 'init', '--cone'], { cwd: destDir, timeout: CLONE_TIMEOUT_MS });
@@ -193,18 +208,31 @@ function cloneTier(tier, exec, destDir, warnings) {
193
208
  if (tier.kind === 'client') {
194
209
  warnings.push('fetching whole vault repo — set client.vaultPaths to limit');
195
210
  }
196
- // Whole-repo clone: glab first (uses stored glab auth), then git fallback
197
- // (credential-helper). A missing glab binary surfaces as exitCode 127.
198
- const g = exec.glab(['repo', 'clone', repo, destDir, '--', '--depth', '1', '--branch', ref, '--single-branch'], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
199
- if (g.exitCode === 0)
200
- return { ok: true };
201
- // Reset the dest before the git attempt so a partially-populated dir from a
202
- // failed glab clone can't make `git clone` fail on a non-empty target.
203
- safeRm(destDir);
204
- const gi = exec.git(['clone', '--depth', '1', '--branch', ref, '--single-branch', url, destDir], { env: { GITLAB_HOST: host }, timeout: CLONE_TIMEOUT_MS });
211
+ // A literal path/URL is not a glab coordinate — go straight to git. Trying
212
+ // glab first would only spend a failed subprocess before the same fallback.
213
+ if (literal) {
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 });
215
+ return direct.exitCode === 0 ? { ok: true } : { ok: false, stderr: direct.stderr };
216
+ }
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 });
205
233
  if (gi.exitCode === 0)
206
234
  return { ok: true };
207
- return { ok: false, stderr: gi.stderr || g.stderr };
235
+ return { ok: false, stderr: gi.stderr || hostCliStderr };
208
236
  }
209
237
  // ─── Degrade (never-abort terminal states) ─────────────────────────────────────
210
238
  function priorContentExists(vaultDirPath) {
@@ -280,43 +308,82 @@ function vaultFetch(cwd, deps = {}) {
280
308
  if (!node_fs_1.default.existsSync(node_path_1.default.join(cwd, '.platform-bindings.json'))) {
281
309
  return degrade(finalDir, 'no .platform-bindings.json', warnings);
282
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);
283
319
  const client = isObject(bindings.client) ? bindings.client : null;
284
- const platform = isObject(bindings.platform) ? bindings.platform : null;
285
- const clientRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
286
- const clientRef = client && typeof client.vaultRef === 'string' && client.vaultRef ? client.vaultRef : 'main';
287
- const clientHost = client && typeof client.vaultHost === 'string' && client.vaultHost ? client.vaultHost : null;
288
320
  const clientPaths = client && Array.isArray(client.vaultPaths)
289
321
  ? client.vaultPaths.filter((p) => typeof p === 'string' && p.length > 0)
290
322
  : null;
291
- const platformHost = platform && typeof platform.host === 'string' && platform.host ? platform.host : null;
292
- // 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.
293
347
  const ttlRaw = cfgGet(cwd, 'vault.cache_ttl_hours', DEFAULT_TTL_HOURS);
294
348
  const ttlHours = Number.isFinite(Number(ttlRaw)) ? Number(ttlRaw) : DEFAULT_TTL_HOURS;
295
- const fbRepoRaw = cfgGet(cwd, 'vault.fallback_repo', DEFAULT_FALLBACK_REPO);
296
- const fbRepo = typeof fbRepoRaw === 'string' ? fbRepoRaw : DEFAULT_FALLBACK_REPO;
297
- const fbRefRaw = cfgGet(cwd, 'vault.fallback_ref', DEFAULT_FALLBACK_REF);
298
- const fbRef = typeof fbRefRaw === 'string' && fbRefRaw ? fbRefRaw : DEFAULT_FALLBACK_REF;
299
- // 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.
300
365
  const ladder = [];
301
- if (clientRepo) {
366
+ if (clientCoord.repo) {
302
367
  ladder.push({
303
- repo: clientRepo,
304
- ref: clientRef,
368
+ repo: clientCoord.repo,
369
+ ref: clientCoord.ref,
305
370
  kind: 'client',
306
- host: clientHost || platformHost || 'gitlab.com',
371
+ host: clientCoord.host,
307
372
  paths: clientPaths && clientPaths.length > 0 ? clientPaths : null,
373
+ provider,
308
374
  });
309
375
  }
310
- if (fbRepo) {
376
+ if (sharedCoord.repo) {
311
377
  ladder.push({
312
- repo: fbRepo,
313
- ref: fbRef,
378
+ repo: sharedCoord.repo,
379
+ ref: sharedCoord.ref,
314
380
  kind: 'fallback',
315
- host: platformHost || 'gitlab.com',
381
+ host: sharedCoord.host,
316
382
  paths: null,
383
+ provider,
317
384
  });
318
385
  }
319
- let fallbackReason = clientRepo ? null : 'no client vault configured';
386
+ let fallbackReason = clientCoord.repo ? null : 'no client vault configured';
320
387
  // Cache-freshness short-circuit (no clone). Fresh ⇔ prior was a real fetch,
321
388
  // ttl > 0, age within ttl, and the ref matches the top tier we'd fetch.
322
389
  const prior = readManifest(finalDir);
@@ -385,14 +452,26 @@ function vaultFetch(cwd, deps = {}) {
385
452
  fetchedAt: new Date().toISOString(),
386
453
  files,
387
454
  fallbackReason: tier.kind === 'fallback' ? fallbackReason : null,
455
+ host: tier.host,
388
456
  };
389
457
  if (warnings.length > 0)
390
458
  manifest.warnings = warnings.slice();
391
459
  writeManifest(finalDir, manifest);
392
460
  return manifest;
393
461
  }
394
- // All tiers failed/empty — keep prior cache or record a miss. Never throws.
395
- 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);
396
475
  }
397
476
  catch (e) {
398
477
  // Any unexpected throw must degrade, never propagate to a non-zero exit.
@@ -648,28 +727,63 @@ function vaultSummary(cwd) {
648
727
  * (Layer 1); this in-process assertion is advisory only and exists so a buggy
649
728
  * caller cannot resolve a write to the wrong repo without tripping a hard throw.
650
729
  *
651
- * 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:
652
751
  * - no client vault is configured;
653
- * - the target is the shared fallback vault; or
654
- * - 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.
655
755
  *
656
756
  * @param cwd Project root (parent of `.planning/`).
657
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.
658
763
  */
659
- function assertVaultWriteTarget(cwd, targetRepo) {
660
- const bindings = readBindings(cwd);
661
- const client = isObject(bindings.client) ? bindings.client : null;
662
- const clientRepo = client && typeof client.vaultRepo === 'string' ? client.vaultRepo : null;
663
- const fbRepoRaw = cfgGet(cwd, 'vault.fallback_repo', DEFAULT_FALLBACK_REPO);
664
- 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;
665
772
  if (!clientRepo) {
666
- 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}").`);
667
774
  }
668
- if (targetRepo === fbRepo) {
669
- 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.`);
670
780
  }
671
- if (targetRepo !== clientRepo) {
672
- 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);
673
787
  }
674
788
  }
675
789
  module.exports = {
@@ -683,4 +797,9 @@ module.exports = {
683
797
  usableFiles,
684
798
  cfgGet,
685
799
  readBindings,
800
+ // Additive exports (Phase 24-01): both functions already existed above —
801
+ // this only widens the export list so src/vault-practices.cts can reuse
802
+ // them instead of re-implementing a second bounded-read/secret-filter.
803
+ readHeadBounded,
804
+ isSecretFile,
686
805
  };
@@ -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
 
@@ -612,6 +620,7 @@ async function main() {
612
620
  function captureStdoutSyncWrites(run) {
613
621
  const originalWriteSync = fs.writeSync;
614
622
  let captured = '';
623
+ let settled = false;
615
624
 
616
625
  fs.writeSync = function patchedWriteSync(fd, data, ...rest) {
617
626
  if (fd === 1) {
@@ -632,12 +641,37 @@ function captureStdoutSyncWrites(run) {
632
641
  fs.writeSync = originalWriteSync;
633
642
  };
634
643
 
644
+ // Safety net (#3097-adjacent, D-16 refusal contract): a router can call
645
+ // io.error() -> process.exit() synchronously from deep inside run() (e.g.
646
+ // an action command that emits a structured verdict to stdout and THEN
647
+ // refuses). process.exit() tears the process down before the .then()/
648
+ // .catch() below ever runs, so the fd-1 monkey-patch above would silently
649
+ // swallow every byte the command wrote to stdout before it errored —
650
+ // never flushed to the REAL fd 1. process.exit() still synchronously
651
+ // fires 'exit' listeners before the process truly terminates, so
652
+ // restoring fs.writeSync and flushing the captured buffer here is safe.
653
+ // The `settled` guard prevents a double-write: the normal completion path
654
+ // below removes this listener before it can fire.
655
+ const flushOnExit = () => {
656
+ if (settled) return;
657
+ settled = true;
658
+ restore();
659
+ if (captured.length > 0) {
660
+ originalWriteSync.call(fs, 1, resolveAtFileOutput(captured));
661
+ }
662
+ };
663
+ process.on('exit', flushOnExit);
664
+
635
665
  return Promise.resolve()
636
666
  .then(() => run())
637
667
  .then(() => {
668
+ process.removeListener('exit', flushOnExit);
669
+ settled = true;
638
670
  restore();
639
671
  return captured;
640
672
  }, (err) => {
673
+ process.removeListener('exit', flushOnExit);
674
+ settled = true;
641
675
  restore();
642
676
  throw err;
643
677
  });
@@ -671,6 +705,43 @@ function extractField(obj, fieldPath) {
671
705
  return current;
672
706
  }
673
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
+ }
729
+ if (question.optionalAnswer) {
730
+ rendered += '\n (press Enter to skip)';
731
+ }
732
+ return new Promise((resolve) => {
733
+ rl.question(`${rendered}\n> `, (answer) => resolve(answer));
734
+ });
735
+ },
736
+ print(text) {
737
+ process.stdout.write(`${text}\n`);
738
+ },
739
+ close() {
740
+ rl.close();
741
+ },
742
+ };
743
+ }
744
+
674
745
  async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) {
675
746
  switch (command) {
676
747
  case 'agent': {
@@ -709,6 +780,108 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
709
780
  break;
710
781
  }
711
782
 
783
+ case 'compose-bindings': {
784
+ // compose-bindings [--write] [--offline]
785
+ // A bare invocation is a dry run — it composes, validates, and reports
786
+ // provenance/drift without touching disk (D-10). Only --write applies.
787
+ const cbArgs = args.slice(1);
788
+ const unrecognizedCbFlag = cbArgs.find(
789
+ (a) => a.startsWith('--') && !composeBindings.COMPOSE_BINDINGS_FLAGS.includes(a),
790
+ );
791
+ if (unrecognizedCbFlag !== undefined) {
792
+ error(
793
+ `Unrecognized flag "${unrecognizedCbFlag}" for compose-bindings. Supported: --write, --offline.`,
794
+ ERROR_REASON.SDK_FAIL_FAST,
795
+ );
796
+ break;
797
+ }
798
+ const cbOptions = {};
799
+ for (let i = 0; i < cbArgs.length; i++) {
800
+ const a = cbArgs[i];
801
+ if (a === '--write') {
802
+ cbOptions.write = true;
803
+ } else if (a === '--offline') {
804
+ cbOptions.offline = true;
805
+ }
806
+ }
807
+ composeBindings.cmdComposeBindings(cwd, cbOptions, raw);
808
+ break;
809
+ }
810
+
811
+ case 'init-contract': {
812
+ // init-contract [--questions] [--verify] [--session-next]
813
+ // [--session-answer <stepId>=<value>] [--session-reset] [--offline]
814
+ // --questions (also the no-flag default, D-05): read-only, always
815
+ // exits 0, prints the canonical /pp-init question set as data.
816
+ // --verify: runs the end-state invariant check (Phase 34-07 gap
817
+ // closure) and exits non-zero when any invariant is violated — a
818
+ // data verdict, never a usage error; the JSON body still reaches
819
+ // stdout either way so init.md can show every violation verbatim.
820
+ // --session-next / --session-answer / --session-reset (Phase 34-08
821
+ // gap closure): the session relay an agent drives one step at a time
822
+ // — see src/init-contract.cts's session-machine header for why this
823
+ // exists. --session-answer takes its "<stepId>=<value>" payload as
824
+ // the NEXT argv token (same convention as --cwd/--pick), extracted
825
+ // BEFORE the unrecognized-flag scan below since the payload itself
826
+ // is not a flag. --offline forwards to every composeBindings call
827
+ // the session makes (parity with compose-bindings --offline).
828
+ const icArgs = args.slice(1);
829
+ let sessionAnswerRaw;
830
+ const saIdx = icArgs.indexOf('--session-answer');
831
+ if (saIdx !== -1) {
832
+ sessionAnswerRaw = icArgs[saIdx + 1];
833
+ if (!sessionAnswerRaw || sessionAnswerRaw.startsWith('--')) {
834
+ error(
835
+ 'Missing value for --session-answer. Usage: --session-answer <stepId>=<value>',
836
+ ERROR_REASON.SDK_MISSING_ARG,
837
+ );
838
+ break;
839
+ }
840
+ icArgs.splice(saIdx, 2);
841
+ }
842
+ const unrecognizedIcFlag = icArgs.find(
843
+ (a) => a.startsWith('--') && !initContract.INIT_CONTRACT_FLAGS.includes(a),
844
+ );
845
+ if (unrecognizedIcFlag !== undefined) {
846
+ error(
847
+ `Unrecognized flag "${unrecognizedIcFlag}" for init-contract. Supported: --questions, --verify, --session-next, --session-answer, --session-reset, --offline.`,
848
+ ERROR_REASON.SDK_FAIL_FAST,
849
+ );
850
+ break;
851
+ }
852
+ const icOptions = {};
853
+ for (let i = 0; i < icArgs.length; i++) {
854
+ const a = icArgs[i];
855
+ if (a === '--questions') {
856
+ icOptions.questions = true;
857
+ } else if (a === '--verify') {
858
+ icOptions.verify = true;
859
+ } else if (a === '--session-next') {
860
+ icOptions.sessionNext = true;
861
+ } else if (a === '--session-reset') {
862
+ icOptions.sessionReset = true;
863
+ } else if (a === '--offline') {
864
+ icOptions.offline = true;
865
+ }
866
+ }
867
+ if (sessionAnswerRaw !== undefined) {
868
+ const eqIdx = sessionAnswerRaw.indexOf('=');
869
+ if (eqIdx === -1) {
870
+ error(
871
+ `Invalid --session-answer value "${sessionAnswerRaw}" — expected <stepId>=<value>.`,
872
+ ERROR_REASON.USAGE,
873
+ );
874
+ break;
875
+ }
876
+ icOptions.sessionAnswer = {
877
+ stepId: sessionAnswerRaw.slice(0, eqIdx),
878
+ value: sessionAnswerRaw.slice(eqIdx + 1),
879
+ };
880
+ }
881
+ initContract.cmdInitContract(cwd, icOptions, raw);
882
+ break;
883
+ }
884
+
712
885
  case 'banner': {
713
886
  // Pareto brand banner (Phase 17 / UIBR-02). Workflows shell out via
714
887
  // `pp_run banner <phaseKey> "<STAGE TEXT>"`. The banner reaches the
@@ -1297,6 +1470,56 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
1297
1470
  }
1298
1471
 
1299
1472
  case 'init': {
1473
+ // `pp-tools init [--offline] [--reset]` (Phase 34-09 gap closure) —
1474
+ // the user-facing interactive entry point that drives the 34-08
1475
+ // session machine (src/init-interactive.cts) against a real
1476
+ // terminal. Deliberately DISTINCT from `pp-tools query init.*`
1477
+ // (init-command-router.cjs's execute-phase/plan-phase/new-project/...
1478
+ // workflow-orchestration verbs, unchanged below) — args[1] decides
1479
+ // which: a bare invocation or a flag starting with "--" is always
1480
+ // this interactive command; anything else (a bare subcommand word
1481
+ // like "execute-phase") routes to the existing family untouched.
1482
+ const initSub = args[1];
1483
+ const isInteractiveInvocation = initSub === undefined || initSub.startsWith('--');
1484
+ if (isInteractiveInvocation) {
1485
+ const INIT_INTERACTIVE_FLAGS = ['--offline', '--reset'];
1486
+ const unrecognizedInitFlag = args.slice(1).find((a) => !INIT_INTERACTIVE_FLAGS.includes(a));
1487
+ if (unrecognizedInitFlag !== undefined) {
1488
+ error(
1489
+ `Unrecognized flag "${unrecognizedInitFlag}" for init. Supported: --offline, --reset. ` +
1490
+ 'For init workflow subcommands (execute-phase, plan-phase, new-project, ...), use "pp-tools query init.<subcommand>".',
1491
+ ERROR_REASON.SDK_FAIL_FAST,
1492
+ );
1493
+ break;
1494
+ }
1495
+ // Refuse to prompt when stdin is not a TTY — never block on stdin
1496
+ // in CI; a hang here would be worse than a failure (T-34-71).
1497
+ if (!process.stdin.isTTY) {
1498
+ error(
1499
+ '"pp-tools init" requires an interactive terminal (stdin is not a TTY) and will not prompt. ' +
1500
+ 'For a non-interactive/CI-driven session, use "pp-tools query init-contract --session-next" / ' +
1501
+ '"--session-answer <stepId>=<value>" directly instead.',
1502
+ ERROR_REASON.INIT_NON_INTERACTIVE,
1503
+ );
1504
+ break;
1505
+ }
1506
+ if (args.includes('--reset')) {
1507
+ initContract.initSessionReset(cwd);
1508
+ }
1509
+ const prompter = createReadlinePrompter();
1510
+ try {
1511
+ const result = await initInteractive.runInitInteractive(cwd, prompter, {
1512
+ offline: args.includes('--offline'),
1513
+ });
1514
+ output(result, raw);
1515
+ if (!result.ok) {
1516
+ process.exitCode = 1;
1517
+ }
1518
+ } finally {
1519
+ prompter.close();
1520
+ }
1521
+ break;
1522
+ }
1300
1523
  routeInitCommand({
1301
1524
  init,
1302
1525
  args,