@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.
- package/.claude-plugin/plugin.json +1 -1
- package/gemini-extension.json +1 -1
- package/package.json +5 -3
- package/pareto-skills/pp-brand/SKILL.md +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +49 -1
- package/pp-core/bin/lib/compose-bindings.cjs +785 -0
- package/pp-core/bin/lib/git-provider.cjs +279 -0
- package/pp-core/bin/lib/init-contract.cjs +1080 -0
- package/pp-core/bin/lib/init-interactive.cjs +202 -0
- package/pp-core/bin/lib/io.cjs +2 -0
- package/pp-core/bin/lib/org-profile.cjs +696 -0
- package/pp-core/bin/lib/platform-bindings.cjs +268 -64
- package/pp-core/bin/lib/vault-command-router.cjs +133 -2
- package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
- package/pp-core/bin/lib/vault-write-back.cjs +178 -92
- package/pp-core/bin/lib/vault.cjs +158 -81
- package/pp-core/bin/pp-tools.cjs +209 -2
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +1 -1
- package/pp-core/workflows/init.md +137 -76
- package/scripts/gen-platform-bindings-schema.cjs +148 -0
|
@@ -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
|
-
*
|
|
180
|
-
*
|
|
181
|
-
* `
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
234
|
-
// (credential-helper). A
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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 ||
|
|
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
|
-
|
|
329
|
-
//
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
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 (
|
|
366
|
+
if (clientCoord.repo) {
|
|
339
367
|
ladder.push({
|
|
340
|
-
repo:
|
|
341
|
-
ref:
|
|
368
|
+
repo: clientCoord.repo,
|
|
369
|
+
ref: clientCoord.ref,
|
|
342
370
|
kind: 'client',
|
|
343
|
-
host:
|
|
371
|
+
host: clientCoord.host,
|
|
344
372
|
paths: clientPaths && clientPaths.length > 0 ? clientPaths : null,
|
|
373
|
+
provider,
|
|
345
374
|
});
|
|
346
375
|
}
|
|
347
|
-
if (
|
|
376
|
+
if (sharedCoord.repo) {
|
|
348
377
|
ladder.push({
|
|
349
|
-
repo:
|
|
350
|
-
ref:
|
|
378
|
+
repo: sharedCoord.repo,
|
|
379
|
+
ref: sharedCoord.ref,
|
|
351
380
|
kind: 'fallback',
|
|
352
|
-
host:
|
|
381
|
+
host: sharedCoord.host,
|
|
353
382
|
paths: null,
|
|
383
|
+
provider,
|
|
354
384
|
});
|
|
355
385
|
}
|
|
356
|
-
let fallbackReason =
|
|
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
|
|
432
|
-
|
|
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
|
-
*
|
|
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
|
|
691
|
-
*
|
|
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
|
|
698
|
-
const
|
|
699
|
-
const
|
|
700
|
-
const
|
|
701
|
-
const
|
|
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 ===
|
|
706
|
-
|
|
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
|
-
|
|
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 = {
|
package/pp-core/bin/pp-tools.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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.
|