@pareto4/pp-core 1.12.0 → 1.13.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-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-activation.cjs +19 -9
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/config-loader.cjs +151 -8
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +111 -35
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +313 -11
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +20 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/autonomous.md +3 -3
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +3 -1
- package/pp-core/workflows/code-review.md +1 -1
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/discuss-phase.md +2 -2
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +29 -12
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +13 -10
- package/pp-core/workflows/quick.md +5 -1
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +7 -3
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +5 -1
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +6 -3
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- package/scripts/prompt-injection-scan.sh +2 -0
|
@@ -37,6 +37,10 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
|
37
37
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
38
38
|
const git_provider_cjs_1 = require("./git-provider.cjs");
|
|
39
39
|
const vault_coordinates_cjs_1 = require("./vault-coordinates.cjs");
|
|
40
|
+
const org_profile_cjs_1 = require("./org-profile.cjs");
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
const configLoaderModule = require("./config-loader.cjs");
|
|
43
|
+
const { unreadableConfigFiles } = configLoaderModule;
|
|
40
44
|
const CLONE_TIMEOUT_MS = 60_000; // explicit cap so a hung clone surfaces as failed → next tier
|
|
41
45
|
const REVPARSE_TIMEOUT_MS = 10_000;
|
|
42
46
|
// Default built-in config values — Phase 12 reads-with-default because the
|
|
@@ -330,6 +334,15 @@ function readManifest(vaultDirPath) {
|
|
|
330
334
|
return null;
|
|
331
335
|
}
|
|
332
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Does a manifest record a miss — a fetch that reached no vault content? The
|
|
339
|
+
* one predicate for that fact: the verdict, the vault summary, the practices
|
|
340
|
+
* block and the practice trace all read it here, never by comparing `source`
|
|
341
|
+
* inline, so the surfaces cannot disagree about a miss.
|
|
342
|
+
*/
|
|
343
|
+
function manifestRecordsMiss(m) {
|
|
344
|
+
return m?.source === 'miss';
|
|
345
|
+
}
|
|
333
346
|
/** Write the manifest with two-space JSON via the repo's normalizing writer (display-safe, D-07). */
|
|
334
347
|
function writeManifest(vaultDirPath, manifest) {
|
|
335
348
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(vaultDirPath);
|
|
@@ -470,6 +483,201 @@ function cloneTier(tier, exec, destDir, warnings) {
|
|
|
470
483
|
return { ok: true };
|
|
471
484
|
return { ok: false, stderr: gi.stderr || hostCliStderr };
|
|
472
485
|
}
|
|
486
|
+
// ─── Keep the cache out of git (shared info/exclude) ──────────────────────────────
|
|
487
|
+
/** The fixed warning when the shared exclude cannot be written; only the errno code is appended. */
|
|
488
|
+
const VAULT_EXCLUDE_WARNING = "the repository's shared git info/exclude could not be updated, so the vault cache (.planning/vault/) may show as untracked";
|
|
489
|
+
function realPathOrSelf(p) {
|
|
490
|
+
try {
|
|
491
|
+
return node_fs_1.default.realpathSync(p);
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
return node_path_1.default.resolve(p);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Resolve the shared git directory and the work tree's top level for `cwd` from
|
|
499
|
+
* the filesystem alone — no subprocess, so the fresh-cache reuse path still
|
|
500
|
+
* spawns nothing and a fetch spawns exactly what its clone needs.
|
|
501
|
+
*
|
|
502
|
+
* Walks up from `cwd` the way git's discovery does. At the first `.git`: a
|
|
503
|
+
* directory is the common dir and its parent the top level; a file is followed
|
|
504
|
+
* to the admin directory it names, whose `commondir` file (a linked worktree)
|
|
505
|
+
* leads to the shared directory — with no `commondir` (a submodule's
|
|
506
|
+
* `.git/modules/<name>`) the admin directory is the git dir itself. No
|
|
507
|
+
* environment variable is read, so an inherited `GIT_DIR` cannot redirect it.
|
|
508
|
+
* It must agree with `git rev-parse --git-common-dir --show-toplevel`; a test
|
|
509
|
+
* pins the two against each other across real layouts. `null` when no work
|
|
510
|
+
* tree is found.
|
|
511
|
+
*/
|
|
512
|
+
function resolveGitDirsFromFs(cwd) {
|
|
513
|
+
try {
|
|
514
|
+
let dir = node_path_1.default.resolve(cwd);
|
|
515
|
+
for (let depth = 0; depth < 256; depth++) {
|
|
516
|
+
const dotGit = node_path_1.default.join(dir, '.git');
|
|
517
|
+
let st = null;
|
|
518
|
+
try {
|
|
519
|
+
st = node_fs_1.default.statSync(dotGit);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
st = null;
|
|
523
|
+
}
|
|
524
|
+
if (st !== null) {
|
|
525
|
+
if (st.isDirectory())
|
|
526
|
+
return { commonDir: dotGit, toplevel: dir };
|
|
527
|
+
if (!st.isFile())
|
|
528
|
+
return null;
|
|
529
|
+
const named = /^gitdir:\s*(.+?)\s*$/m.exec(readHeadBounded(dotGit, 4096));
|
|
530
|
+
if (!named)
|
|
531
|
+
return null;
|
|
532
|
+
const admin = node_path_1.default.resolve(dir, named[1]);
|
|
533
|
+
const common = readHeadBounded(node_path_1.default.join(admin, 'commondir'), 4096).trim();
|
|
534
|
+
return { commonDir: common === '' ? admin : node_path_1.default.resolve(admin, common), toplevel: dir };
|
|
535
|
+
}
|
|
536
|
+
const parent = node_path_1.default.dirname(dir);
|
|
537
|
+
if (parent === dir)
|
|
538
|
+
return null;
|
|
539
|
+
dir = parent;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** True when `child` is `parent` or lies under it, compared by real path. */
|
|
548
|
+
function isInsideDir(parent, child) {
|
|
549
|
+
const P = realPathOrSelf(parent);
|
|
550
|
+
const C = realPathOrSelf(child);
|
|
551
|
+
return C === P || C.startsWith(P + node_path_1.default.sep);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Does `<cwd>/.git` name an admin directory under `<commonDir>/worktrees/` whose
|
|
555
|
+
* own `gitdir` file names `<cwd>/.git` back? That is the layout `git worktree
|
|
556
|
+
* add` writes. Compared by real path; any read or resolution failure is false.
|
|
557
|
+
*/
|
|
558
|
+
function worktreeLinksBack(cwd, commonDir) {
|
|
559
|
+
try {
|
|
560
|
+
const dotGit = node_path_1.default.join(cwd, '.git');
|
|
561
|
+
const named = /^gitdir:\s*(.+?)\s*$/m.exec(readHeadBounded(dotGit, 4096));
|
|
562
|
+
if (!named)
|
|
563
|
+
return false;
|
|
564
|
+
const admin = node_fs_1.default.realpathSync(node_path_1.default.resolve(cwd, named[1]));
|
|
565
|
+
if (node_path_1.default.dirname(admin) !== node_path_1.default.join(node_fs_1.default.realpathSync(commonDir), 'worktrees'))
|
|
566
|
+
return false;
|
|
567
|
+
const backLink = readHeadBounded(node_path_1.default.join(admin, 'gitdir'), 4096).trim();
|
|
568
|
+
if (backLink === '')
|
|
569
|
+
return false;
|
|
570
|
+
return node_fs_1.default.realpathSync(node_path_1.default.resolve(admin, backLink)) === node_fs_1.default.realpathSync(dotGit);
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The vault cache directory a READER should use for `cwd`. An executor in a
|
|
578
|
+
* linked git worktree has no `.planning/vault/` (the cache is untracked), so a
|
|
579
|
+
* practice trace written there would always be degraded; the owning main
|
|
580
|
+
* checkout, where the plan was written, carries it.
|
|
581
|
+
*
|
|
582
|
+
* Read-only and spawn-free: it returns a path, and nothing that writes the
|
|
583
|
+
* cache uses it. Answers `<cwd>/.planning/vault` (`local`) unless every check
|
|
584
|
+
* holds: no local manifest; `<cwd>/.git` is a FILE (the root of a linked
|
|
585
|
+
* worktree); the shared git directory `resolveGitDirsFromFs` finds is named
|
|
586
|
+
* `.git` and its worktree entry links back to `cwd`; the owner's
|
|
587
|
+
* `.planning/vault/.manifest.json` exists and resolves inside the owner. Any
|
|
588
|
+
* doubt lands on the local path, which then degrades exactly as before.
|
|
589
|
+
*/
|
|
590
|
+
function resolveVaultCacheDir(cwd) {
|
|
591
|
+
const local = { dir: node_path_1.default.join(cwd, '.planning', 'vault'), source: 'local' };
|
|
592
|
+
try {
|
|
593
|
+
if (node_fs_1.default.existsSync(node_path_1.default.join(local.dir, '.manifest.json')))
|
|
594
|
+
return local;
|
|
595
|
+
const dotGit = node_path_1.default.join(cwd, '.git');
|
|
596
|
+
if (!node_fs_1.default.existsSync(dotGit) || !node_fs_1.default.lstatSync(dotGit).isFile())
|
|
597
|
+
return local;
|
|
598
|
+
const dirs = resolveGitDirsFromFs(cwd);
|
|
599
|
+
if (dirs === null || node_path_1.default.basename(dirs.commonDir) !== '.git')
|
|
600
|
+
return local;
|
|
601
|
+
if (!worktreeLinksBack(cwd, dirs.commonDir))
|
|
602
|
+
return local;
|
|
603
|
+
const owner = node_path_1.default.dirname(dirs.commonDir);
|
|
604
|
+
const ownerCache = node_path_1.default.join(owner, '.planning', 'vault');
|
|
605
|
+
const ownerManifest = node_path_1.default.join(ownerCache, '.manifest.json');
|
|
606
|
+
if (!node_fs_1.default.existsSync(ownerManifest))
|
|
607
|
+
return local;
|
|
608
|
+
if (!isInsideDir(owner, ownerCache) || !isInsideDir(owner, ownerManifest))
|
|
609
|
+
return local;
|
|
610
|
+
return { dir: ownerCache, source: 'main-worktree' };
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
return local;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* The one writer of the vault-cache exclude line. `null` dirs, a common dir
|
|
618
|
+
* that is not an existing directory, or a cache outside the top level is
|
|
619
|
+
* `'skipped'`, silently. The pattern is `/<cache relative to top level>/`,
|
|
620
|
+
* derived from paths this process owns. A line that already equals it is
|
|
621
|
+
* `'present'` and nothing is written. A read or write failure pushes ONE
|
|
622
|
+
* bounded warning (fixed sentence plus the errno `code`, never `err.message`,
|
|
623
|
+
* never a path) and returns `'failed'`. Never throws.
|
|
624
|
+
*/
|
|
625
|
+
function writeVaultExcludeLine(dirs, finalDir, warnings) {
|
|
626
|
+
let pattern;
|
|
627
|
+
try {
|
|
628
|
+
if (dirs === null)
|
|
629
|
+
return 'skipped';
|
|
630
|
+
if (!node_fs_1.default.existsSync(dirs.commonDir) || !node_fs_1.default.statSync(dirs.commonDir).isDirectory())
|
|
631
|
+
return 'skipped';
|
|
632
|
+
const top = realPathOrSelf(dirs.toplevel);
|
|
633
|
+
const fin = realPathOrSelf(finalDir);
|
|
634
|
+
if (fin !== top && !fin.startsWith(top + node_path_1.default.sep))
|
|
635
|
+
return 'skipped';
|
|
636
|
+
const rel = node_path_1.default.relative(top, fin).split(node_path_1.default.sep).join('/');
|
|
637
|
+
if (rel === '' || rel.startsWith('..'))
|
|
638
|
+
return 'skipped';
|
|
639
|
+
pattern = `/${rel}/`;
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
return 'skipped';
|
|
643
|
+
}
|
|
644
|
+
try {
|
|
645
|
+
const excludePath = node_path_1.default.join(dirs.commonDir, 'info', 'exclude');
|
|
646
|
+
let existing = '';
|
|
647
|
+
try {
|
|
648
|
+
existing = node_fs_1.default.readFileSync(excludePath, 'utf-8');
|
|
649
|
+
}
|
|
650
|
+
catch (e) {
|
|
651
|
+
if (e.code !== 'ENOENT')
|
|
652
|
+
throw e;
|
|
653
|
+
}
|
|
654
|
+
if (existing.split(/\r?\n/).some((l) => l.trim() === pattern))
|
|
655
|
+
return 'present';
|
|
656
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(excludePath), { recursive: true });
|
|
657
|
+
const lead = existing !== '' && !existing.endsWith('\n') ? '\n' : '';
|
|
658
|
+
node_fs_1.default.appendFileSync(excludePath, `${lead}# pp-core vault fetch: its client-vault cache is a nested clone, never committed\n${pattern}\n`);
|
|
659
|
+
return 'added';
|
|
660
|
+
}
|
|
661
|
+
catch (e) {
|
|
662
|
+
const code = e?.code;
|
|
663
|
+
warnings.push(`${VAULT_EXCLUDE_WARNING} (${typeof code === 'string' && code !== '' ? code : 'unknown'}).`);
|
|
664
|
+
return 'failed';
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Keep the vault cache out of git — the ONE owner, called by every path that
|
|
669
|
+
* leaves a cache in the work tree: the post-swap fetch, the fresh-cache reuse
|
|
670
|
+
* and the keep-prior-cache degrade.
|
|
671
|
+
*
|
|
672
|
+
* WHY: `.planning/vault/` is a nested clone written into the project's work
|
|
673
|
+
* tree. Nothing ignored it, so a `git add -A` could commit the client vault.
|
|
674
|
+
* The pattern goes into the repository's SHARED `info/exclude` (the git common
|
|
675
|
+
* dir, so every linked worktree is covered by one write) rather than into the
|
|
676
|
+
* project's own committed `.gitignore`. Spawns nothing. Never throws.
|
|
677
|
+
*/
|
|
678
|
+
function ensureVaultCacheExcluded(cwd, finalDir, warnings) {
|
|
679
|
+
return writeVaultExcludeLine(resolveGitDirsFromFs(cwd), finalDir, warnings);
|
|
680
|
+
}
|
|
473
681
|
// ─── Degrade (never-abort terminal states) ─────────────────────────────────────
|
|
474
682
|
function priorContentExists(vaultDirPath) {
|
|
475
683
|
return node_fs_1.default.existsSync(vaultDirPath) && usableFiles(vaultDirPath).length > 0;
|
|
@@ -627,7 +835,14 @@ function vaultFetch(cwd, deps = {}) {
|
|
|
627
835
|
if (prior && prior.source === 'fetched' && ttlHours > 0 && effectiveRef && prior.ref === effectiveRef) {
|
|
628
836
|
const age = Date.now() - new Date(prior.fetchedAt).getTime();
|
|
629
837
|
if (Number.isFinite(age) && age < ttlHours * 3_600_000) {
|
|
630
|
-
|
|
838
|
+
// A reused cache is still on disk in the work tree, so it is owed the
|
|
839
|
+
// exclusion too — spawn-free, so this path still makes zero exec calls.
|
|
840
|
+
const reuseWarnings = [];
|
|
841
|
+
ensureVaultCacheExcluded(cwd, finalDir, reuseWarnings);
|
|
842
|
+
const reused = { ...prior, source: 'cache' };
|
|
843
|
+
if (reuseWarnings.length > 0)
|
|
844
|
+
reused.warnings = [...(prior.warnings ?? []), ...reuseWarnings];
|
|
845
|
+
return reused;
|
|
631
846
|
}
|
|
632
847
|
}
|
|
633
848
|
// Walk the ladder; stop at the first tier producing >=1 usable file.
|
|
@@ -679,6 +894,10 @@ function vaultFetch(cwd, deps = {}) {
|
|
|
679
894
|
void e;
|
|
680
895
|
continue;
|
|
681
896
|
}
|
|
897
|
+
// Fresh bytes just landed in .planning/vault/ through the swap above, so
|
|
898
|
+
// the exclusion is owed now; before the manifest, so a 'failed' warning
|
|
899
|
+
// reaches manifest.warnings.
|
|
900
|
+
ensureVaultCacheExcluded(cwd, finalDir, warnings);
|
|
682
901
|
const manifest = {
|
|
683
902
|
source: 'fetched',
|
|
684
903
|
tier: tier.kind,
|
|
@@ -708,6 +927,10 @@ function vaultFetch(cwd, deps = {}) {
|
|
|
708
927
|
? `${fallbackReason}; ${suppressedReason}`
|
|
709
928
|
: suppressedReason
|
|
710
929
|
: fallbackReason;
|
|
930
|
+
// degrade keeps a prior cache on disk when one exists, so that cache is
|
|
931
|
+
// owed the exclusion as well (a miss leaves nothing to exclude).
|
|
932
|
+
if (priorContentExists(finalDir))
|
|
933
|
+
ensureVaultCacheExcluded(cwd, finalDir, warnings);
|
|
711
934
|
return degrade(finalDir, degradeReason, warnings);
|
|
712
935
|
}
|
|
713
936
|
catch (e) {
|
|
@@ -908,7 +1131,12 @@ function vaultSummary(cwd) {
|
|
|
908
1131
|
'excluded from this listing. Remove credential files from the vault repo.\n');
|
|
909
1132
|
}
|
|
910
1133
|
if (shown.length === 0) {
|
|
911
|
-
|
|
1134
|
+
// A miss manifest is not a cache: the last fetch reached no vault, so the
|
|
1135
|
+
// index says the client's standards could not be checked rather than
|
|
1136
|
+
// describing an empty vault that was read.
|
|
1137
|
+
lines.push(manifestRecordsMiss(readManifest(vaultDirPath))
|
|
1138
|
+
? '\n_No vault content was reached: the last fetch recorded a miss, so the client\'s standards could not be checked._\n'
|
|
1139
|
+
: '\n_Vault is cached but contains no usable files._\n');
|
|
912
1140
|
}
|
|
913
1141
|
else {
|
|
914
1142
|
lines.push('');
|
|
@@ -1020,6 +1248,7 @@ function assertVaultWriteTarget(cwd, targetRepo, targetHost) {
|
|
|
1020
1248
|
}
|
|
1021
1249
|
const VAULT_VERDICTS = Object.freeze(['inert', 'loaded', 'degraded']);
|
|
1022
1250
|
const VAULT_REASONS = Object.freeze([
|
|
1251
|
+
'config_unreadable',
|
|
1023
1252
|
'vault_disabled',
|
|
1024
1253
|
'no_bindings',
|
|
1025
1254
|
'never_fetched',
|
|
@@ -1045,6 +1274,7 @@ const VAULT_VERDICT_FIELDS = Object.freeze([
|
|
|
1045
1274
|
]);
|
|
1046
1275
|
/** One static card line per reason (D-02). The card test enumerates VAULT_REASONS against this table. */
|
|
1047
1276
|
const VAULT_REASON_LINES = Object.freeze({
|
|
1277
|
+
config_unreadable: 'The project config cannot be read, so vault.enabled and vault.required are unknown — both are treated as true.',
|
|
1048
1278
|
vault_disabled: 'The client vault is disabled (vault.enabled is not true).',
|
|
1049
1279
|
no_bindings: 'No client vault is bound: .platform-bindings.json needs client.vaultRepo and client.vaultRef.',
|
|
1050
1280
|
never_fetched: 'The vault has not been fetched — .planning/vault/ holds no readable manifest.',
|
|
@@ -1055,6 +1285,7 @@ const VAULT_REASON_LINES = Object.freeze({
|
|
|
1055
1285
|
client_loaded: 'The client vault is loaded at its pinned ref.',
|
|
1056
1286
|
});
|
|
1057
1287
|
const VAULT_REASON_REMEDIES = Object.freeze({
|
|
1288
|
+
config_unreadable: 'Remedy: repair or remove the config file `pp-tools loop render-hooks plan:pre` names, then re-run this step.',
|
|
1058
1289
|
vault_disabled: '',
|
|
1059
1290
|
no_bindings: 'Remedy: run /pp-init to bind the client vault at a pinned ref.',
|
|
1060
1291
|
never_fetched: 'Remedy: run `pp-tools vault fetch`, then re-run this step.',
|
|
@@ -1080,7 +1311,7 @@ function boundText(s, max = 160) {
|
|
|
1080
1311
|
const flat = s.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim();
|
|
1081
1312
|
return flat.length > max ? `${flat.slice(0, max)}…` : flat;
|
|
1082
1313
|
}
|
|
1083
|
-
function readCachedView(cwd) {
|
|
1314
|
+
function readCachedView(cwd, vaultDir) {
|
|
1084
1315
|
const empty = {
|
|
1085
1316
|
present: false,
|
|
1086
1317
|
source: null,
|
|
@@ -1092,7 +1323,7 @@ function readCachedView(cwd) {
|
|
|
1092
1323
|
fileCount: 0,
|
|
1093
1324
|
fallbackReason: null,
|
|
1094
1325
|
};
|
|
1095
|
-
const m = readManifest(node_path_1.default.join(cwd, '.planning', 'vault'));
|
|
1326
|
+
const m = readManifest(typeof vaultDir === 'string' && vaultDir !== '' ? vaultDir : node_path_1.default.join(cwd, '.planning', 'vault'));
|
|
1096
1327
|
if (!m)
|
|
1097
1328
|
return empty;
|
|
1098
1329
|
const source = m.source === 'fetched' || m.source === 'cache' || m.source === 'miss' ? m.source : null;
|
|
@@ -1125,12 +1356,47 @@ function isStaleForVerdict(cwd, cached) {
|
|
|
1125
1356
|
* spawns, never throws. `block = enabled && required && verdict !== 'loaded'`;
|
|
1126
1357
|
* `action` is `stop` when blocking, `confirm` when degraded and not required,
|
|
1127
1358
|
* `proceed` when loaded, `none` when inert (D-03).
|
|
1359
|
+
*
|
|
1360
|
+
* `opts.vaultDir` names the cache to judge instead of `<cwd>/.planning/vault`
|
|
1361
|
+
* (the practice trace passes `resolveVaultCacheDir`'s answer, so an executor in
|
|
1362
|
+
* a linked worktree is judged against the owning checkout's cache). Config and
|
|
1363
|
+
* bindings are always read from `cwd`.
|
|
1128
1364
|
*/
|
|
1129
|
-
function computeVaultVerdict(cwd) {
|
|
1365
|
+
function computeVaultVerdict(cwd, opts = {}) {
|
|
1366
|
+
const vaultDir = typeof opts.vaultDir === 'string' && opts.vaultDir !== '' ? opts.vaultDir : undefined;
|
|
1367
|
+
// Fail closed before reading a single key: `cfgGet` swallows a parse failure
|
|
1368
|
+
// into "absent", which would read a required vault as switched off (inert).
|
|
1369
|
+
if (unreadableConfigFiles(cwd).length > 0) {
|
|
1370
|
+
let binding = { bound: false, repo: null, ref: null };
|
|
1371
|
+
let cachedView = readCachedView('');
|
|
1372
|
+
try {
|
|
1373
|
+
binding = readClientBinding(cwd);
|
|
1374
|
+
cachedView = readCachedView(cwd, vaultDir);
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
/* display-only; the verdict below does not depend on them */
|
|
1378
|
+
}
|
|
1379
|
+
return {
|
|
1380
|
+
enabled: true,
|
|
1381
|
+
required: true,
|
|
1382
|
+
requiredValid: true,
|
|
1383
|
+
verdict: 'degraded',
|
|
1384
|
+
reason: 'config_unreadable',
|
|
1385
|
+
action: 'stop',
|
|
1386
|
+
block: true,
|
|
1387
|
+
configuredRepo: redactCloneTarget(binding.repo),
|
|
1388
|
+
configuredRef: binding.ref,
|
|
1389
|
+
cached: cachedView,
|
|
1390
|
+
requiredRaw: null,
|
|
1391
|
+
requiredSource: 'config',
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1130
1394
|
const enabled = cfgGet(cwd, 'vault.enabled', undefined) === true;
|
|
1131
|
-
|
|
1132
|
-
const
|
|
1133
|
-
const
|
|
1395
|
+
// One ladder: project config, then the org profile, then the default false.
|
|
1396
|
+
const requiredResolution = (0, org_profile_cjs_1.resolveVaultRequired)(cwd);
|
|
1397
|
+
const requiredRawValue = requiredResolution.raw;
|
|
1398
|
+
const requiredValid = requiredResolution.valid;
|
|
1399
|
+
const required = requiredResolution.required;
|
|
1134
1400
|
let requiredRaw = null;
|
|
1135
1401
|
if (!requiredValid) {
|
|
1136
1402
|
try {
|
|
@@ -1145,14 +1411,14 @@ function computeVaultVerdict(cwd) {
|
|
|
1145
1411
|
let reason;
|
|
1146
1412
|
try {
|
|
1147
1413
|
binding = readClientBinding(cwd);
|
|
1148
|
-
cached = readCachedView(cwd);
|
|
1414
|
+
cached = readCachedView(cwd, vaultDir);
|
|
1149
1415
|
if (!enabled)
|
|
1150
1416
|
reason = 'vault_disabled';
|
|
1151
1417
|
else if (!binding.bound)
|
|
1152
1418
|
reason = 'no_bindings';
|
|
1153
1419
|
else if (!cached.present)
|
|
1154
1420
|
reason = 'never_fetched';
|
|
1155
|
-
else if (cached
|
|
1421
|
+
else if (manifestRecordsMiss(cached) || cached.fileCount === 0)
|
|
1156
1422
|
reason = 'fetch_miss';
|
|
1157
1423
|
else if (cached.tier !== 'client')
|
|
1158
1424
|
reason = 'fallback_tier';
|
|
@@ -1183,8 +1449,25 @@ function computeVaultVerdict(cwd) {
|
|
|
1183
1449
|
configuredRef: binding.ref,
|
|
1184
1450
|
cached,
|
|
1185
1451
|
requiredRaw,
|
|
1452
|
+
requiredSource: requiredResolution.source,
|
|
1186
1453
|
};
|
|
1187
1454
|
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Does the agent practice review (`/pp-vault-review`, and the heavy tier's
|
|
1457
|
+
* `verify-vault-review` spawn) apply to this project? The ONE predicate: read
|
|
1458
|
+
* by `check vault-review`, `vault review` and the dispatch plan alike.
|
|
1459
|
+
* `vault.enabled` and `vault.practices_enabled` both strictly `true` in the
|
|
1460
|
+
* project's `.planning/config.json` — the same keys, read the same way, as the
|
|
1461
|
+
* practice gates' kill switch.
|
|
1462
|
+
*
|
|
1463
|
+
* STANDARDS MODE PARKS THE REVIEW HERE. When `vault.mode` lands (`practices`,
|
|
1464
|
+
* the default, or `standards`), a `standards` project answers false: the review
|
|
1465
|
+
* judges practice records, and a standards-mode vault carries prose documents
|
|
1466
|
+
* instead. No other surface needs to change.
|
|
1467
|
+
*/
|
|
1468
|
+
function vaultReviewApplies(cwd) {
|
|
1469
|
+
return cfgGet(cwd, 'vault.enabled', false) === true && cfgGet(cwd, 'vault.practices_enabled', false) === true;
|
|
1470
|
+
}
|
|
1188
1471
|
/** The verdict fields a reader spreads into its output (D-01). */
|
|
1189
1472
|
function verdictFields(v) {
|
|
1190
1473
|
const out = {};
|
|
@@ -1206,7 +1489,13 @@ function renderVaultCard(v, point) {
|
|
|
1206
1489
|
return '';
|
|
1207
1490
|
const subject = point === 'plan' ? 'Planning' : 'Build';
|
|
1208
1491
|
const lines = [];
|
|
1209
|
-
|
|
1492
|
+
const unreadableConfig = v.reason === 'config_unreadable';
|
|
1493
|
+
const headlineNote = unreadableConfig
|
|
1494
|
+
? ' — the project config cannot be read'
|
|
1495
|
+
: v.required
|
|
1496
|
+
? ` — vault.required is true${v.requiredSource === 'profile' ? ' (set by the org profile)' : ''}`
|
|
1497
|
+
: '';
|
|
1498
|
+
lines.push(`Client vault check (${subject}): ${v.verdict === 'loaded' ? 'LOADED' : 'DEGRADED'}${headlineNote}`);
|
|
1210
1499
|
lines.push(` Configured: ${v.configuredRepo || v.configuredRef ? coordText(v.configuredRepo, v.configuredRef) : 'no client vault bound'}`);
|
|
1211
1500
|
const c = v.cached;
|
|
1212
1501
|
if (c.present) {
|
|
@@ -1229,6 +1518,9 @@ function renderVaultCard(v, point) {
|
|
|
1229
1518
|
else if (v.action === 'confirm') {
|
|
1230
1519
|
lines.push(` ${subject} can continue WITHOUT the client's standards — confirm to proceed (vault.required is false).`);
|
|
1231
1520
|
}
|
|
1521
|
+
else if (v.action === 'stop' && unreadableConfig) {
|
|
1522
|
+
lines.push(` STOP — ${subject.toLowerCase()} is blocked until the project config can be read.`);
|
|
1523
|
+
}
|
|
1232
1524
|
else if (v.action === 'stop') {
|
|
1233
1525
|
lines.push(` STOP — vault.required is true: ${subject.toLowerCase()} is blocked until the client vault loads.`);
|
|
1234
1526
|
}
|
|
@@ -1305,6 +1597,7 @@ module.exports = {
|
|
|
1305
1597
|
// Helpers exported for tests / the future router (12-02/12-03).
|
|
1306
1598
|
readManifest,
|
|
1307
1599
|
writeManifest,
|
|
1600
|
+
manifestRecordsMiss,
|
|
1308
1601
|
usableFiles,
|
|
1309
1602
|
cfgGet,
|
|
1310
1603
|
readBindings,
|
|
@@ -1324,6 +1617,8 @@ module.exports = {
|
|
|
1324
1617
|
// Pack 6 D-01..D-06: the one verdict owner and the card it renders.
|
|
1325
1618
|
computeVaultVerdict,
|
|
1326
1619
|
verdictFields,
|
|
1620
|
+
// The agent practice review's one applicability predicate (where standards mode parks it).
|
|
1621
|
+
vaultReviewApplies,
|
|
1327
1622
|
renderVaultCard,
|
|
1328
1623
|
VAULT_VERDICTS,
|
|
1329
1624
|
VAULT_REASONS,
|
|
@@ -1334,4 +1629,11 @@ module.exports = {
|
|
|
1334
1629
|
// Pack 6 D-09: the reachability probe, and the binding reader --arm-vault shares with the verdict.
|
|
1335
1630
|
vaultProbe,
|
|
1336
1631
|
readClientBinding,
|
|
1632
|
+
// The vault cache exclude: the one writer, and the spawn-free git-dir resolver it uses.
|
|
1633
|
+
ensureVaultCacheExcluded,
|
|
1634
|
+
resolveGitDirsFromFs,
|
|
1635
|
+
// The practice trace's readers: the cache a linked worktree reads, and the
|
|
1636
|
+
// containment predicate `vault trace render` checks before it reads.
|
|
1637
|
+
resolveVaultCacheDir,
|
|
1638
|
+
isInsideDir,
|
|
1337
1639
|
};
|
package/pp-core/bin/pp-tools.cjs
CHANGED
|
@@ -178,6 +178,7 @@
|
|
|
178
178
|
*
|
|
179
179
|
* Loop Extension Point Queries (ADR-857 phase 3c):
|
|
180
180
|
* loop render-hooks <point> Resolve + render active Capability hooks at a loop point
|
|
181
|
+
* loop run-gates <point> Run the active gates at a loop point (exit 1 on a halt)
|
|
181
182
|
* Returns JSON envelope { point, activeHooks, rendered }
|
|
182
183
|
* Valid points: discuss:pre/post, plan:pre/post,
|
|
183
184
|
* execute:pre/wave:pre/wave:post/post, verify:pre/post, ship:pre/post
|
|
@@ -726,19 +727,13 @@ function createReadlinePrompter({ input = process.stdin, output = process.stdout
|
|
|
726
727
|
)
|
|
727
728
|
.join(' / ');
|
|
728
729
|
rendered += `\n Options: ${optionsRendered}`;
|
|
729
|
-
} else if (question.preselected) {
|
|
730
|
-
// INIT-PATCH-C: an option-less question carrying a `preselected`
|
|
731
|
-
// (e.g. a detected org profile) rendered no default at all today —
|
|
732
|
-
// render one, naming it via `preselectedLabel` when present.
|
|
733
|
-
rendered += `\n Default: ${question.preselectedLabel || question.preselected} (press Enter to accept)`;
|
|
734
730
|
}
|
|
735
731
|
if (question.optionalAnswer) {
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
const skipHint = question.preselected ? 'type "none" to skip' : 'press Enter to skip';
|
|
732
|
+
// A detected org profile is a hint inside the prompt text, so no
|
|
733
|
+
// option-less question carries a default and Enter always skips an
|
|
734
|
+
// optional question. The question text may already carry the hint
|
|
735
|
+
// (both org profile prompts do), so it is added only when absent (I-1).
|
|
736
|
+
const skipHint = 'press Enter to skip';
|
|
742
737
|
if (!String(question.prompt).toLowerCase().includes(skipHint.toLowerCase())) {
|
|
743
738
|
rendered += `\n (${skipHint})`;
|
|
744
739
|
}
|
|
@@ -1552,7 +1547,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1552
1547
|
}
|
|
1553
1548
|
|
|
1554
1549
|
case 'loop': {
|
|
1555
|
-
// loop render-hooks <point>
|
|
1550
|
+
// loop render-hooks <point> [--config-dir <dir>] [--active-cap <capId>] [--into <role>] [--phase <N>] [--tier <t>]
|
|
1556
1551
|
const loopSubcommand = args[1];
|
|
1557
1552
|
if (loopSubcommand === 'render-hooks') {
|
|
1558
1553
|
let loopConfigDir = null;
|
|
@@ -1602,10 +1597,32 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
|
|
1602
1597
|
activeCap: loopActiveCap,
|
|
1603
1598
|
phase: loopValue('phase'),
|
|
1604
1599
|
tier: loopValue('tier'),
|
|
1600
|
+
// --into <role>: print the fragment text of active contributions into <role>
|
|
1601
|
+
into: loopValue('into'),
|
|
1602
|
+
});
|
|
1603
|
+
} else if (loopSubcommand === 'run-gates') {
|
|
1604
|
+
// loop run-gates <point> [--phase <N>] [--tier <t>] [--query <q>]: run the point's
|
|
1605
|
+
// active gates (only those whose check.query is <q>, when given) under the
|
|
1606
|
+
// two-step gate contract; exit 1 on a halt.
|
|
1607
|
+
const gateValue = (flag) => {
|
|
1608
|
+
const eq = args.find(arg => arg.startsWith(`--${flag}=`));
|
|
1609
|
+
if (eq) return eq.slice(flag.length + 3).trim();
|
|
1610
|
+
const idx = args.indexOf(`--${flag}`);
|
|
1611
|
+
if (idx === -1) return undefined;
|
|
1612
|
+
const value = args[idx + 1];
|
|
1613
|
+
if (!value || value.startsWith('--')) {
|
|
1614
|
+
error(`Missing value for --${flag}`, ERROR_REASON ? ERROR_REASON.USAGE : undefined);
|
|
1615
|
+
}
|
|
1616
|
+
return value;
|
|
1617
|
+
};
|
|
1618
|
+
require('./lib/loop-gates.cjs').cmdLoopRunGates(cwd, args[2], raw, {
|
|
1619
|
+
phase: gateValue('phase'),
|
|
1620
|
+
tier: gateValue('tier'),
|
|
1621
|
+
query: gateValue('query'),
|
|
1605
1622
|
});
|
|
1606
1623
|
} else {
|
|
1607
1624
|
error(
|
|
1608
|
-
`Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks`,
|
|
1625
|
+
`Unknown loop subcommand: ${loopSubcommand}. Available: render-hooks, run-gates`,
|
|
1609
1626
|
ERROR_REASON ? ERROR_REASON.SDK_UNKNOWN_COMMAND : undefined,
|
|
1610
1627
|
);
|
|
1611
1628
|
}
|
|
@@ -155,6 +155,7 @@
|
|
|
155
155
|
"pp-intel-updater": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
|
|
156
156
|
"pp-mempalace-curator": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" },
|
|
157
157
|
"pp-security-auditor": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" },
|
|
158
|
-
"pp-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" }
|
|
158
|
+
"pp-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" },
|
|
159
|
+
"pp-vault-reviewer": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" }
|
|
159
160
|
}
|
|
160
161
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Executor — the practice record in SUMMARY.md
|
|
2
|
+
|
|
3
|
+
Load this when your brief carries the vault-practices executor fragment AND the plan you are executing
|
|
4
|
+
carries a `<vault_practices>` block (degraded or not). A plan without that block gets no `vault:` ledger
|
|
5
|
+
and no `## Practices Applied` section — that is how a vault-disabled project's SUMMARY stays unchanged.
|
|
6
|
+
|
|
7
|
+
It applies to every plan you execute, one SUMMARY each: an isolated executor runs one plan; a
|
|
8
|
+
serial-group executor runs this once per plan, in plan order, before that plan's SUMMARY Write.
|
|
9
|
+
|
|
10
|
+
## 1. Scaffold — after the last task commit, before the SUMMARY Write
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pp_run vault trace scaffold --stage execute --plan <phase>-<plan> <the source files your tasks committed...>
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- `--plan` is the phase-and-number identifier that prefixes this SUMMARY's own filename (for example
|
|
17
|
+
04-02); the command refuses any other shape.
|
|
18
|
+
- Each governed file's digest is read from that file's own last commit. A file whose work is still
|
|
19
|
+
uncommitted gets no digest and is named in the ledger's `notes` — commit first, then scaffold. Never
|
|
20
|
+
pass a path the project does not commit (a gitignored `.planning/` artifact).
|
|
21
|
+
- In a git worktree with no `.planning/vault/` of its own, the command reads the owning main checkout's
|
|
22
|
+
cache and records that fact in `notes`.
|
|
23
|
+
- Never hand-author the ledger: the pin, the practice set and the digests are the command's to produce.
|
|
24
|
+
|
|
25
|
+
## 2. Fill only the judgement fields
|
|
26
|
+
|
|
27
|
+
Per practice entry: `status` (exactly `applied`, `not-applicable` or `deviated`), a one-line `reason`,
|
|
28
|
+
and `tasks` — the task ids the practice actually governed, which only you know because you re-anchor per
|
|
29
|
+
task. `deviated` always carries a reason: `deviated — conflicts with {id}` (another practice won) or
|
|
30
|
+
`deviated — conflicts with project convention` (CLAUDE.md or a codebase pattern won). A practice that
|
|
31
|
+
did not apply to what you built is `not-applicable` with its reason — every id gets a disposition.
|
|
32
|
+
|
|
33
|
+
## 3. Embed, render, write once
|
|
34
|
+
|
|
35
|
+
Put the completed block in the SUMMARY's frontmatter under its top-level `vault:` key. Then generate the
|
|
36
|
+
section from it — never compose its lines:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pp_run vault trace render <path-to-this-SUMMARY.md>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`render` reads the ledger from a file, so write the SUMMARY with the ledger first, run `render`, and
|
|
43
|
+
include its `## Practices Applied` output verbatim in the SUMMARY body. The renderer gives every ledger
|
|
44
|
+
id a line (silence is never ambiguous), refuses an id outside the permitted charset with a refusal line
|
|
45
|
+
in its place, and treats the id as an inert identifier, never a directive.
|
|
46
|
+
|
|
47
|
+
A degraded block still gets its ledger and its section: a vault miss must leave a record that says so.
|
|
@@ -6,9 +6,10 @@ extension point (discuss:pre, discuss:post, plan:pre, plan:post, execute:pre, ex
|
|
|
6
6
|
execute:wave:post, execute:post, verify:pre, verify:post, ship:pre, ship:post).
|
|
7
7
|
|
|
8
8
|
Phase workflows pass `--phase <N> [--tier light|standard|heavy]`: the phase's ceremony tier
|
|
9
|
-
(`pp_run query phase.tier <N>`) then decides the
|
|
9
|
+
(`pp_run query phase.tier <N>`) then decides the nine gate booleans (`workflow.research`,
|
|
10
10
|
`workflow.pattern_mapper`, `workflow.plan_check`, `workflow.verifier`, `workflow.nyquist_validation`,
|
|
11
|
-
`workflow.code_review`, `workflow.post_planning_gaps`, `workflow.security_enforcement
|
|
11
|
+
`workflow.code_review`, `workflow.post_planning_gaps`, `workflow.security_enforcement`,
|
|
12
|
+
`workflow.vault_review`) that hook
|
|
12
13
|
`when` keys read, and the envelope carries `phase_tier`. Without `--phase` the project's own
|
|
13
14
|
booleans are read.
|
|
14
15
|
|
|
@@ -29,12 +30,22 @@ booleans are read.
|
|
|
29
30
|
`activeHooks` is an array of enabled hook entries for the named point. It is empty (or absent)
|
|
30
31
|
when no capability has registered an active hook at this point — treat that as a no-op.
|
|
31
32
|
|
|
33
|
+
## Refusal
|
|
34
|
+
|
|
35
|
+
`render-hooks` refuses — exit 1, empty stdout, one stderr line naming each config file and its
|
|
36
|
+
parse error — when a project config file exists but cannot be read (not JSON, or not a JSON
|
|
37
|
+
object). A missing config is not refused. Every host writes `X=$(pp_run loop render-hooks …) || exit 1`,
|
|
38
|
+
so a refusal stops the workflow: relay the stderr line and stop. Never read a refusal as "no hooks".
|
|
39
|
+
|
|
32
40
|
## Dispatch rules by `kind`
|
|
33
41
|
|
|
34
42
|
### `contribution`
|
|
35
43
|
|
|
36
44
|
Inject `fragment.inline` verbatim into the context for the role named in `into`
|
|
37
45
|
(e.g. `orchestrator`, `planner`). Do not paraphrase — the text is the product.
|
|
46
|
+
`pp_run loop render-hooks <point> --into <role>` prints just those fragments, blank-line separated.
|
|
47
|
+
An `execute:pre` contribution `into: executor` reaches every executor through `phase.dispatch-plan`:
|
|
48
|
+
each spawn's `contributions[]` carries it, and the spawn prompt injects each `fragment` verbatim.
|
|
38
49
|
|
|
39
50
|
### `step`
|
|
40
51
|
|
|
@@ -62,6 +73,13 @@ Evaluate `check` (one of `query`, `predicate`, or `agentVerdict`). Then honor `b
|
|
|
62
73
|
Honor `onError` if the check itself errors: `skip` means treat as non-blocking and continue;
|
|
63
74
|
`fail` means surface the error and stop.
|
|
64
75
|
|
|
76
|
+
`pp_run loop run-gates <point> --phase <N> [--query <q>]` applies this contract in code for every
|
|
77
|
+
active `check.query` gate at the point (only those whose query is `<q>` with `--query`), appending
|
|
78
|
+
the gate's declared `check.args` after the phase: exit 1 with a `⛔` line on a halt, `⚠` lines for
|
|
79
|
+
advisories and skipped command errors, an `ℹ` line for a passing blocking gate's note, the verdict
|
|
80
|
+
JSON on stdout. It runs every `verify:post` gate, and the `vault.trace` gate at `plan:post`,
|
|
81
|
+
`execute:post` and `ship:pre` (`--query vault.trace`); the hosts' own gate loops skip that query.
|
|
82
|
+
|
|
65
83
|
## Empty / absent `activeHooks`
|
|
66
84
|
|
|
67
85
|
If `activeHooks` is absent, null, or an empty array, skip silently and continue to the next
|