@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.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-fixer.md +11 -0
- package/agents/pp-code-reviewer.md +77 -1
- package/agents/pp-executor.md +34 -1
- package/agents/pp-planner.md +24 -103
- package/commands/pp-ns-manage.md +2 -1
- package/commands/pp-vault-promote.md +31 -0
- 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 +160 -3
- package/pp-core/bin/lib/capability-state.cjs +41 -3
- package/pp-core/bin/lib/check-command-router.cjs +473 -3
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/compose-bindings.cjs +736 -0
- package/pp-core/bin/lib/git-provider.cjs +279 -0
- package/pp-core/bin/lib/init-contract.cjs +989 -0
- package/pp-core/bin/lib/init-interactive.cjs +183 -0
- package/pp-core/bin/lib/io.cjs +9 -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/practices-review-check.cjs +938 -0
- package/pp-core/bin/lib/vault-command-router.cjs +239 -2
- package/pp-core/bin/lib/vault-coordinates.cjs +485 -0
- package/pp-core/bin/lib/vault-practices.cjs +982 -0
- package/pp-core/bin/lib/vault-write-back.cjs +842 -0
- package/pp-core/bin/lib/vault.cjs +170 -51
- package/pp-core/bin/pp-tools.cjs +225 -2
- package/pp-core/references/planner-checkpoints.md +72 -0
- package/pp-core/references/planner-tdd.md +55 -0
- package/pp-core/references/planner-vault-practices.md +67 -0
- package/pp-core/templates/practices/README.md +122 -0
- package/pp-core/templates/practices/error-handling.md +18 -0
- package/pp-core/templates/practices/minimal-comments.md +14 -0
- package/pp-core/templates/practices/naming-convention.md +18 -0
- package/pp-core/templates/practices/practice-template.md +23 -0
- package/pp-core/workflows/code-review.md +74 -18
- 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/pp-core/workflows/ship.md +1 -0
- package/pp-core/workflows/vault-write-back.md +213 -0
- package/scripts/gen-capability-registry.cjs +68 -8
- package/scripts/gen-platform-bindings-schema.cjs +148 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +8 -1
- package/scripts/lint-descriptions.cjs +3 -1
- package/scripts/lint-legacy-dir-name.cjs +5 -0
- 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
|
|
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:
|
|
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
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
return { ok: true };
|
|
201
|
-
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
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 ||
|
|
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
|
-
|
|
292
|
-
//
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
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 (
|
|
366
|
+
if (clientCoord.repo) {
|
|
302
367
|
ladder.push({
|
|
303
|
-
repo:
|
|
304
|
-
ref:
|
|
368
|
+
repo: clientCoord.repo,
|
|
369
|
+
ref: clientCoord.ref,
|
|
305
370
|
kind: 'client',
|
|
306
|
-
host:
|
|
371
|
+
host: clientCoord.host,
|
|
307
372
|
paths: clientPaths && clientPaths.length > 0 ? clientPaths : null,
|
|
373
|
+
provider,
|
|
308
374
|
});
|
|
309
375
|
}
|
|
310
|
-
if (
|
|
376
|
+
if (sharedCoord.repo) {
|
|
311
377
|
ladder.push({
|
|
312
|
-
repo:
|
|
313
|
-
ref:
|
|
378
|
+
repo: sharedCoord.repo,
|
|
379
|
+
ref: sharedCoord.ref,
|
|
314
380
|
kind: 'fallback',
|
|
315
|
-
host:
|
|
381
|
+
host: sharedCoord.host,
|
|
316
382
|
paths: null,
|
|
383
|
+
provider,
|
|
317
384
|
});
|
|
318
385
|
}
|
|
319
|
-
let fallbackReason =
|
|
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
|
|
395
|
-
|
|
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
|
-
*
|
|
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
|
|
654
|
-
*
|
|
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
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
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;
|
|
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 ===
|
|
669
|
-
|
|
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
|
-
|
|
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
|
};
|
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
|
|
|
@@ -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,
|