@pareto4/pp-core 1.12.1 → 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-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/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 +107 -45
- 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 +277 -12
- 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 +13 -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/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -0
- 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/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 +22 -5
- 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 +7 -4
- package/pp-core/workflows/quick.md +4 -0
- package/pp-core/workflows/ship.md +5 -1
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +4 -0
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +3 -0
- 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
|
@@ -712,6 +712,13 @@ exports.NON_FAMILY_COMMAND_ALIASES = [
|
|
|
712
712
|
],
|
|
713
713
|
"mutation": true
|
|
714
714
|
},
|
|
715
|
+
{
|
|
716
|
+
"canonical": "phase.manifest",
|
|
717
|
+
"aliases": [
|
|
718
|
+
"phase manifest"
|
|
719
|
+
],
|
|
720
|
+
"mutation": true
|
|
721
|
+
},
|
|
715
722
|
{
|
|
716
723
|
"canonical": "phase.mvp-mode",
|
|
717
724
|
"aliases": [
|
|
@@ -47,6 +47,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
48
|
exports.COMPOSE_BINDINGS_FLAGS = exports.SEED_VALUE_MAX_LENGTH = void 0;
|
|
49
49
|
exports._resetPlatformTagMemoForTests = _resetPlatformTagMemoForTests;
|
|
50
|
+
exports.listStableTagsAt = listStableTagsAt;
|
|
51
|
+
exports.pickLatestStableTag = pickLatestStableTag;
|
|
50
52
|
exports.resolveCreatedWithFrom = resolveCreatedWithFrom;
|
|
51
53
|
exports.composeBindings = composeBindings;
|
|
52
54
|
exports.redactComposeResultForEmission = redactComposeResultForEmission;
|
|
@@ -398,33 +400,7 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
|
|
|
398
400
|
return cached;
|
|
399
401
|
let result;
|
|
400
402
|
try {
|
|
401
|
-
|
|
402
|
-
? opts._lsRemote(url)
|
|
403
|
-
: node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', url, 'v*'], {
|
|
404
|
-
encoding: 'utf-8',
|
|
405
|
-
timeout: 10_000,
|
|
406
|
-
maxBuffer: 512 * 1024,
|
|
407
|
-
windowsHide: true,
|
|
408
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
409
|
-
});
|
|
410
|
-
const tags = lsOutput
|
|
411
|
-
.split('\n')
|
|
412
|
-
.map((line) => {
|
|
413
|
-
const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
|
|
414
|
-
return m ? m[1].trim() : '';
|
|
415
|
-
})
|
|
416
|
-
.filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
|
|
417
|
-
if (tags.length === 0) {
|
|
418
|
-
result = { tag: null, warnings: [] };
|
|
419
|
-
}
|
|
420
|
-
else {
|
|
421
|
-
let latest = tags[0];
|
|
422
|
-
for (const t of tags) {
|
|
423
|
-
if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
|
|
424
|
-
latest = t;
|
|
425
|
-
}
|
|
426
|
-
result = { tag: latest, warnings: [] };
|
|
427
|
-
}
|
|
403
|
+
result = { tag: pickLatestStableTag(listStableTagsAt(url, opts)), warnings: [] };
|
|
428
404
|
}
|
|
429
405
|
catch {
|
|
430
406
|
result = {
|
|
@@ -437,6 +413,44 @@ function resolveLatestPlatformTag(cwd, repo, provider, opts) {
|
|
|
437
413
|
platformTagMemo.set(url, result);
|
|
438
414
|
return result;
|
|
439
415
|
}
|
|
416
|
+
/**
|
|
417
|
+
* List the stable `vX.Y.Z` tags at `url`, never shell-eval'd (T-34-25):
|
|
418
|
+
* `git ls-remote` is spawned with an argv array, bounded by
|
|
419
|
+
* `timeout`/`maxBuffer`/`windowsHide` (T-34-26), with the child's stderr piped
|
|
420
|
+
* and discarded. `--` ends the options, so a flag-shaped target can never be
|
|
421
|
+
* read as one. Filtered with `isStableTripletSemver`, the single blessed
|
|
422
|
+
* predicate. THROWS on any git fault — callers own the degrade policy. Shared
|
|
423
|
+
* by the platform-tag lookup and `vault update`.
|
|
424
|
+
*/
|
|
425
|
+
function listStableTagsAt(url, opts = {}) {
|
|
426
|
+
const lsOutput = typeof opts._lsRemote === 'function'
|
|
427
|
+
? opts._lsRemote(url)
|
|
428
|
+
: node_child_process_1.default.execFileSync('git', ['ls-remote', '--tags', '--refs', '--sort=-v:refname', '--', url, 'v*'], {
|
|
429
|
+
encoding: 'utf-8',
|
|
430
|
+
timeout: 10_000,
|
|
431
|
+
maxBuffer: 512 * 1024,
|
|
432
|
+
windowsHide: true,
|
|
433
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
434
|
+
});
|
|
435
|
+
return lsOutput
|
|
436
|
+
.split('\n')
|
|
437
|
+
.map((line) => {
|
|
438
|
+
const m = line.match(/\brefs\/tags\/([^\s^]+)$/);
|
|
439
|
+
return m ? m[1].trim() : '';
|
|
440
|
+
})
|
|
441
|
+
.filter((t) => t && (0, semver_compare_cjs_1.isStableTripletSemver)(t));
|
|
442
|
+
}
|
|
443
|
+
/** The highest tag by `compareSemverCore`, or `null` for an empty list. */
|
|
444
|
+
function pickLatestStableTag(tags) {
|
|
445
|
+
if (tags.length === 0)
|
|
446
|
+
return null;
|
|
447
|
+
let latest = tags[0];
|
|
448
|
+
for (const t of tags) {
|
|
449
|
+
if ((0, semver_compare_cjs_1.compareSemverCore)(t, latest) > 0)
|
|
450
|
+
latest = t;
|
|
451
|
+
}
|
|
452
|
+
return latest;
|
|
453
|
+
}
|
|
440
454
|
/**
|
|
441
455
|
* Read the first readable, semver-shaped `VERSION` file among the candidate
|
|
442
456
|
* locations resolved against `baseDir`, returning `<binName>@<version>`.
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* CJS callers that only use {id, text} safely ignore the extra fields.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.parseDecisionsReport = parseDecisionsReport;
|
|
13
14
|
exports.parseDecisions = parseDecisions;
|
|
14
15
|
const DISCRETION_HEADINGS = new Set([
|
|
15
16
|
"claude's discretion",
|
|
@@ -17,6 +18,67 @@ const DISCRETION_HEADINGS = new Set([
|
|
|
17
18
|
'claude discretion',
|
|
18
19
|
]);
|
|
19
20
|
const NON_TRACKABLE_TAGS = new Set(['informational', 'folded', 'deferred']);
|
|
21
|
+
// ── The decision-bullet grammar ────────────────────────────────────────────────────────────────────────────
|
|
22
|
+
//
|
|
23
|
+
// Why a grammar rather than one regex: this repository writes THREE bullet
|
|
24
|
+
// forms, and originally only the first was readable. The two others were
|
|
25
|
+
// not exotic — they are what `discuss-phase` emits and what hand-authored
|
|
26
|
+
// CONTEXT.md files use — so a parser that reads only form A hands a blocking
|
|
27
|
+
// coverage gate an empty decision set and lets it report a clean pass. That is
|
|
28
|
+
// the defect this grammar retires (todo: decision-coverage-gate-false-pass).
|
|
29
|
+
//
|
|
30
|
+
// Form A `- **D-NN:** text` → arm A
|
|
31
|
+
// Form B `- **D-NN: Title.** text` → arm B (title may wrap: arm D)
|
|
32
|
+
// Form C `- **D-NN** text` → arm C
|
|
33
|
+
//
|
|
34
|
+
// HEAD matches the bullet opener and splits off `rest` (everything after the id
|
|
35
|
+
// and its optional `[tags]` group). The id rule is #3575's, unchanged: the first
|
|
36
|
+
// character after `D-` must be alphanumeric, so `D--x` and `D-_x` still fail
|
|
37
|
+
// HEAD and reach the parse-miss guard.
|
|
38
|
+
const BULLET_HEAD_RE = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?(.*)$/;
|
|
39
|
+
// Arm A — form A, widened. The run before the FIRST `:**` may now contain `:`.
|
|
40
|
+
// #1343 wrote that run as `[^:*]*`, which is what dropped every bold-title
|
|
41
|
+
// bullet AND `- **D-07 ratio 3:1:** w` (that miss used to be pinned as a floor;
|
|
42
|
+
// it now parses as D-07 / "w"). Lazy `[^*]*?` still stops at the first `:**`.
|
|
43
|
+
const ARM_A_RE = /^([^*]*?):\*\*\s*(.*)$/;
|
|
44
|
+
// Arm B — form B, the bold title. The title MAY contain `:` because real
|
|
45
|
+
// titles carry backticked loop points (`plan:post`, `execute:post`, `check:`);
|
|
46
|
+
// a title regex that excluded `:` measured four misses across two phases.
|
|
47
|
+
const ARM_B_RE = /^([^*]*?):\s*([^*]*?)\s*\*\*\s*(.*)$/;
|
|
48
|
+
// Arm C — the bold closes with no colon before it (`- **D-VH-1** body`, and
|
|
49
|
+
// `- **D-13 → see D-14.** *(reserved)*`). The only thing this form lacks is
|
|
50
|
+
// punctuation; the id is unambiguous, so it is admitted.
|
|
51
|
+
const ARM_C_RE = /^([^*]*?)\s*\*\*\s*(.*)$/;
|
|
52
|
+
// Arm D — no closing `**` on this line: a form-B title that wraps onto the
|
|
53
|
+
// next line. The existing continuation logic appends the following indented
|
|
54
|
+
// lines, stray `**` included (they are cosmetic in the resulting text).
|
|
55
|
+
const ARM_D_RE = /^([^*]*)$/;
|
|
56
|
+
/**
|
|
57
|
+
* Read a decision bullet's text out of `rest` (the remainder of the bullet line
|
|
58
|
+
* after the id and optional `[tags]`), trying arms A → B → C → D; first match
|
|
59
|
+
* wins. Returns null when no arm matches, which sends the line to the
|
|
60
|
+
* parse-miss guard.
|
|
61
|
+
*
|
|
62
|
+
* The guard is NOT vacuous after this widening: a malformed id fails HEAD, and
|
|
63
|
+
* a `rest` carrying a `*` in no arm shape (`- **D-01 *italic* no close`)
|
|
64
|
+
* matches no arm.
|
|
65
|
+
*/
|
|
66
|
+
function readBulletText(rest) {
|
|
67
|
+
const a = rest.match(ARM_A_RE);
|
|
68
|
+
if (a)
|
|
69
|
+
return a[2];
|
|
70
|
+
const b = rest.match(ARM_B_RE);
|
|
71
|
+
// Headline first, so a six-word `softPhrase` is drawn from the title.
|
|
72
|
+
if (b)
|
|
73
|
+
return (b[2] + ' ' + b[3]).trim();
|
|
74
|
+
const c = rest.match(ARM_C_RE);
|
|
75
|
+
if (c)
|
|
76
|
+
return (c[1] + ' ' + c[2]).trim();
|
|
77
|
+
const d = rest.match(ARM_D_RE);
|
|
78
|
+
if (d)
|
|
79
|
+
return d[1].replace(/^\s*:?\s*/, '').trim();
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
20
82
|
/**
|
|
21
83
|
* Strip fenced code blocks from `content` so example `<decisions>` snippets
|
|
22
84
|
* inside ```` ``` ```` do not pollute the parser (review F11).
|
|
@@ -40,34 +102,28 @@ function extractDecisionsBlock(content) {
|
|
|
40
102
|
return matches.map((m) => m[1]).join('\n\n');
|
|
41
103
|
}
|
|
42
104
|
/**
|
|
43
|
-
* Parse
|
|
105
|
+
* Parse CONTEXT.md content into BOTH channels: the decisions that were read,
|
|
106
|
+
* and the bullets that could not be.
|
|
44
107
|
*
|
|
45
|
-
*
|
|
46
|
-
* non-trackable ones, with `trackable: false`).
|
|
47
|
-
*
|
|
108
|
+
* `decisions` holds ALL D-NN decisions found inside `<decisions>` (including
|
|
109
|
+
* non-trackable ones, with `trackable: false`). `unparsed` holds every line
|
|
110
|
+
* that opened like a decision bullet and matched no arm of the grammar.
|
|
111
|
+
*
|
|
112
|
+
* The second channel exists because "the block was empty" and "the parser
|
|
113
|
+
* could not read any of it" are opposite situations that a coverage gate used
|
|
114
|
+
* to report identically — as a clean pass on a total of zero.
|
|
48
115
|
*/
|
|
49
|
-
function
|
|
116
|
+
function parseDecisionsReport(content) {
|
|
50
117
|
if (!content || typeof content !== 'string')
|
|
51
|
-
return [];
|
|
118
|
+
return { decisions: [], unparsed: [] };
|
|
52
119
|
const block = extractDecisionsBlock(content);
|
|
53
120
|
if (block === null)
|
|
54
|
-
return [];
|
|
121
|
+
return { decisions: [], unparsed: [] };
|
|
55
122
|
const lines = block.split(/\r?\n/);
|
|
56
123
|
const out = [];
|
|
124
|
+
const unparsed = [];
|
|
57
125
|
let category = '';
|
|
58
126
|
let inDiscretion = false;
|
|
59
|
-
// Bullet line: `- **D-NN[ [tags]]:** text`
|
|
60
|
-
// Phase 6 (#3575): aligned to CJS regex — accepts alphanumeric IDs (D-01, D-INFRA-01, D-FOO_BAR)
|
|
61
|
-
// in addition to numeric-only IDs (D-42). The first character after `D-` must
|
|
62
|
-
// be alphanumeric, so malformed shapes like `D--foo` or `D-_bar` are rejected.
|
|
63
|
-
// CJS callers consume {id, text} and ignore the optional extras.
|
|
64
|
-
// #1343: `[^:*]*` replaces the old `\s*` before `:**` so that a freeform run
|
|
65
|
-
// such as `(parenthetical)`, an em-dash, or other prose between the optional
|
|
66
|
-
// bracket-tag group and the closing `:**` is tolerated rather than silently
|
|
67
|
-
// dropping the whole decision. `[^:*]*` subsumes plain whitespace and stops
|
|
68
|
-
// correctly at `:**`. Capture groups 1 (id), 2 (bracket tags), 3 (text) are
|
|
69
|
-
// unchanged.
|
|
70
|
-
const bulletRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([^\]]+)\])?[^:*]*:\*\*\s*(.*)$/;
|
|
71
127
|
let current = null;
|
|
72
128
|
const flush = () => {
|
|
73
129
|
if (current) {
|
|
@@ -94,29 +150,36 @@ function parseDecisions(content) {
|
|
|
94
150
|
inDiscretion = DISCRETION_HEADINGS.has(normalized);
|
|
95
151
|
continue;
|
|
96
152
|
}
|
|
97
|
-
const
|
|
98
|
-
|
|
153
|
+
const headMatch = line.match(BULLET_HEAD_RE);
|
|
154
|
+
const bulletText = headMatch ? readBulletText(headMatch[3]) : null;
|
|
155
|
+
if (headMatch && bulletText !== null) {
|
|
99
156
|
flush();
|
|
100
|
-
const id = `D-${
|
|
101
|
-
const tags =
|
|
102
|
-
?
|
|
157
|
+
const id = `D-${headMatch[1]}`;
|
|
158
|
+
const tags = headMatch[2]
|
|
159
|
+
? headMatch[2]
|
|
103
160
|
.split(',')
|
|
104
161
|
.map((t) => t.trim().toLowerCase())
|
|
105
162
|
.filter(Boolean)
|
|
106
163
|
: [];
|
|
107
164
|
const trackable = !inDiscretion && !tags.some((t) => NON_TRACKABLE_TAGS.has(t));
|
|
108
|
-
current = { id, text:
|
|
165
|
+
current = { id, text: bulletText, category, tags, trackable };
|
|
109
166
|
continue;
|
|
110
167
|
}
|
|
111
168
|
// Parse-miss guard (#1343): a line that looks like a `D-NN` decision bullet
|
|
112
|
-
// but failed
|
|
169
|
+
// but failed HEAD (malformed id) or matched no arm of the grammar must NOT
|
|
113
170
|
// be silently dropped — a narrowed trackable set lets a blocking coverage gate
|
|
114
171
|
// report a false pass. Surface it loudly instead.
|
|
172
|
+
//
|
|
173
|
+
// The miss is now recorded as DATA as well, so a gate can fail
|
|
174
|
+
// on it rather than warning past it. The `console.warn` stays: the stderr
|
|
175
|
+
// signal is what a human running the command by hand sees, and it is pinned
|
|
176
|
+
// by the #1343 regression tests.
|
|
115
177
|
if (/^\s*-\s+\*\*D-/.test(line)) {
|
|
116
178
|
// A malformed D-bullet still starts a (failed) new decision, so it ends the
|
|
117
179
|
// previous one — flush before warning so a following continuation line cannot
|
|
118
180
|
// be mis-appended to the prior valid decision.
|
|
119
181
|
flush();
|
|
182
|
+
unparsed.push({ line: trimmed, category });
|
|
120
183
|
console.warn(`parseDecisions: ignored unparseable decision bullet: ${trimmed}`);
|
|
121
184
|
continue;
|
|
122
185
|
}
|
|
@@ -132,5 +195,19 @@ function parseDecisions(content) {
|
|
|
132
195
|
}
|
|
133
196
|
}
|
|
134
197
|
flush();
|
|
135
|
-
return out;
|
|
198
|
+
return { decisions: out, unparsed };
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Parse trackable decisions from CONTEXT.md content.
|
|
202
|
+
*
|
|
203
|
+
* Returns ALL D-NN decisions found inside `<decisions>` (including
|
|
204
|
+
* non-trackable ones, with `trackable: false`). Callers that only want the
|
|
205
|
+
* gate-enforced decisions should filter `.filter(d => d.trackable)`.
|
|
206
|
+
*
|
|
207
|
+
* This signature is unchanged, and so is its behaviour. A caller that must not
|
|
208
|
+
* treat an unreadable bullet as an ABSENT decision — any gate that reports a
|
|
209
|
+
* verdict — should call `parseDecisionsReport` and read `unparsed`.
|
|
210
|
+
*/
|
|
211
|
+
function parseDecisions(content) {
|
|
212
|
+
return parseDecisionsReport(content).decisions;
|
|
136
213
|
}
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* here): researcher, pattern mapper,
|
|
35
35
|
* planner (always) and plan checker passes; then code reviewer and fixer,
|
|
36
36
|
* the heavy-tier install run (`phase.install-run`, src/install-run.cts),
|
|
37
|
-
* verifier, security auditor
|
|
37
|
+
* verifier, security auditor, nyquist auditor and — heavy tier, vault
|
|
38
|
+
* practices on — the agent practice review. `totals.spawns` counts every agent spawn of every stage, as an upper
|
|
38
39
|
* bound: entries marked `conditional` run only when the step before them asks
|
|
39
40
|
* for it (a revision after a failed check, a fix after review findings).
|
|
40
41
|
*/
|
|
@@ -69,6 +70,7 @@ exports.STAGE_AGENTS = Object.freeze({
|
|
|
69
70
|
codeFixer: 'pp-code-fixer',
|
|
70
71
|
nyquistAuditor: 'pp-nyquist-auditor',
|
|
71
72
|
securityAuditor: 'pp-security-auditor',
|
|
73
|
+
vaultReviewer: 'pp-vault-reviewer',
|
|
72
74
|
});
|
|
73
75
|
function spawn(id, agent, gate, reason, conditional = false) {
|
|
74
76
|
return { id, kind: 'spawn', agent, gate, reason, conditional };
|
|
@@ -79,6 +81,9 @@ function step(id, gate, reason) {
|
|
|
79
81
|
/**
|
|
80
82
|
* The planning and verification stages of a phase, from its resolved tier.
|
|
81
83
|
* `planBounceScript`: bounce runs only with a configured script.
|
|
84
|
+
* `vaultReview`: the project's vault practices apply (`vaultReviewApplies`,
|
|
85
|
+
* src/vault.cts — the caller reads it); the agent practice review is spawned
|
|
86
|
+
* only when that AND the tier's `vault_review` gate are both on.
|
|
82
87
|
*/
|
|
83
88
|
function computeTierStages(tier, opts = {}) {
|
|
84
89
|
const g = tier.gates;
|
|
@@ -117,6 +122,9 @@ function computeTierStages(tier, opts = {}) {
|
|
|
117
122
|
verify.push(spawn('verify-security', exports.STAGE_AGENTS.securityAuditor, 'security_enforcement', 'threat-model verification (SECURITY.md)'));
|
|
118
123
|
if (g.nyquist_validation)
|
|
119
124
|
verify.push(spawn('verify-nyquist', exports.STAGE_AGENTS.nyquistAuditor, 'nyquist_validation', 'validation-gap audit (VALIDATION.md)'));
|
|
125
|
+
if (g.vault_review && opts.vaultReview === true) {
|
|
126
|
+
verify.push(spawn('verify-vault-review', exports.STAGE_AGENTS.vaultReviewer, 'vault_review', 'agent practice review of the client vault (VAULT-REVIEW.md); reports, never gates'));
|
|
127
|
+
}
|
|
120
128
|
return { plan, verify };
|
|
121
129
|
}
|
|
122
130
|
function normalizePath(file) {
|
|
@@ -255,6 +263,7 @@ function waveDefects(spawns, byId) {
|
|
|
255
263
|
* order; `phaseDir` is the phase directory relative to the project root.
|
|
256
264
|
*/
|
|
257
265
|
function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOpts = {}) {
|
|
266
|
+
const contributions = (tierOpts.executorContributions ?? []).map((c) => ({ capId: c.capId, fragment: c.fragment }));
|
|
258
267
|
const byId = new Map(plans.map((p) => [p.id, p]));
|
|
259
268
|
const skippedComplete = plans.filter((p) => p.has_summary).map((p) => p.id);
|
|
260
269
|
const incomplete = plans.filter((p) => !p.has_summary && (!policy.gapsOnly || p.gap_closure === true));
|
|
@@ -294,6 +303,7 @@ function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOp
|
|
|
294
303
|
build_reason: buildReason(members),
|
|
295
304
|
checkpoint: members.some((q) => !q.autonomous),
|
|
296
305
|
expected_summaries: entries.map((e) => e.summary),
|
|
306
|
+
contributions: contributions.map((c) => ({ ...c })),
|
|
297
307
|
});
|
|
298
308
|
continue;
|
|
299
309
|
}
|
|
@@ -311,6 +321,7 @@ function computeDispatchPlan(phase, phaseDir, plans, policy, tier = null, tierOp
|
|
|
311
321
|
build_reason: buildReason([p]),
|
|
312
322
|
checkpoint: !p.autonomous,
|
|
313
323
|
expected_summaries: [entry.summary],
|
|
324
|
+
contributions: contributions.map((c) => ({ ...c })),
|
|
314
325
|
});
|
|
315
326
|
}
|
|
316
327
|
const defects = waveDefects(spawns, byId);
|
|
@@ -216,6 +216,24 @@ function reconstructFrontmatter(obj) {
|
|
|
216
216
|
}
|
|
217
217
|
return lines.join('\n');
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The raw lines of a top-level `key:` block inside a `---` frontmatter match —
|
|
221
|
+
* the `key:` line plus every following line that is blank or indented, up to
|
|
222
|
+
* the next column-0 key — with trailing blank lines dropped, or `null` when the
|
|
223
|
+
* frontmatter has no such top-level block.
|
|
224
|
+
*/
|
|
225
|
+
function rawTopLevelBlock(frontmatterMatch, key) {
|
|
226
|
+
const lines = frontmatterMatch.split(/\r?\n/).slice(1, -1);
|
|
227
|
+
const start = lines.findIndex((l) => l === `${key}:` || new RegExp(`^${key}:\\s*$`).test(l));
|
|
228
|
+
if (start === -1)
|
|
229
|
+
return null;
|
|
230
|
+
let end = start + 1;
|
|
231
|
+
while (end < lines.length && (lines[end].trim() === '' || /^\s/.test(lines[end])))
|
|
232
|
+
end += 1;
|
|
233
|
+
while (end > start + 1 && lines[end - 1].trim() === '')
|
|
234
|
+
end -= 1;
|
|
235
|
+
return lines.slice(start, end).join('\n');
|
|
236
|
+
}
|
|
219
237
|
function spliceFrontmatter(content, newObj) {
|
|
220
238
|
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
|
221
239
|
if (match) {
|
|
@@ -238,6 +256,25 @@ function spliceFrontmatter(content, newObj) {
|
|
|
238
256
|
catch {
|
|
239
257
|
/* fall through to regeneration on any comparison hiccup */
|
|
240
258
|
}
|
|
259
|
+
// The practice-trace ledger (`vault:`, docs/vault-trace-format.md) is a list
|
|
260
|
+
// of maps the lossy serializer cannot re-emit. When the caller left it
|
|
261
|
+
// untouched — `frontmatter set <plan> wave 2`, the plan-graph write-back —
|
|
262
|
+
// its raw lines are carried over verbatim instead of being regenerated.
|
|
263
|
+
const ledger = rawTopLevelBlock(match[0], 'vault');
|
|
264
|
+
if (ledger !== null) {
|
|
265
|
+
try {
|
|
266
|
+
const original = extractFrontmatter(content);
|
|
267
|
+
if (frontmatterDeepEqual(original.vault, newObj.vault)) {
|
|
268
|
+
const rest = { ...newObj };
|
|
269
|
+
delete rest.vault;
|
|
270
|
+
const restStr = reconstructFrontmatter(rest);
|
|
271
|
+
return `---\n${restStr}${restStr === '' ? '' : '\n'}${ledger}\n---` + content.slice(match[0].length);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
/* fall through to regeneration */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
241
278
|
const yamlStr = reconstructFrontmatter(newObj);
|
|
242
279
|
return `---\n${yamlStr}\n---` + content.slice(match[0].length);
|
|
243
280
|
}
|
|
@@ -27,10 +27,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
27
27
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
28
28
|
};
|
|
29
29
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.BASE_BRANCH_REFUSED_REASON = void 0;
|
|
31
|
+
exports.isPlausibleBranchRef = isPlausibleBranchRef;
|
|
30
32
|
exports.readConfigBaseBranch = readConfigBaseBranch;
|
|
31
33
|
exports.trySymbolicRef = trySymbolicRef;
|
|
32
34
|
exports.tryRemoteShow = tryRemoteShow;
|
|
33
35
|
exports.tryLocalBranch = tryLocalBranch;
|
|
36
|
+
exports.resolveBaseBranchResult = resolveBaseBranchResult;
|
|
34
37
|
exports.resolveBaseBranch = resolveBaseBranch;
|
|
35
38
|
exports.gitWorktreeInfoInternal = gitWorktreeInfoInternal;
|
|
36
39
|
exports.cmdGitBaseBranch = cmdGitBaseBranch;
|
|
@@ -38,6 +41,99 @@ const node_fs_1 = __importDefault(require("node:fs"));
|
|
|
38
41
|
const node_path_1 = __importDefault(require("node:path"));
|
|
39
42
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
40
43
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* The one thing that makes a base branch dangerous, and the only rule here that
|
|
46
|
+
* is OURS rather than git's.
|
|
47
|
+
*
|
|
48
|
+
* `execGit` uses argv, so a configured base branch is not shell injection. It is
|
|
49
|
+
* something narrower and worse: a value beginning with `-` is not a revision to
|
|
50
|
+
* git, it is an OPTION. `git log "--output=<path>..HEAD"` exits 0 and creates
|
|
51
|
+
* that file; so does `git diff`. A `.planning/config.json` shipped inside a
|
|
52
|
+
* cloned repository was therefore an arbitrary-file-creation-and-truncation
|
|
53
|
+
* primitive running as the developer or the CI runner (T-6-49).
|
|
54
|
+
*
|
|
55
|
+
* A dash INSIDE a name is an ordinary branch: `feature/-x` is a name git creates
|
|
56
|
+
* and resolves. Only the leading position turns a value into an option, so only
|
|
57
|
+
* the leading position is refused.
|
|
58
|
+
*/
|
|
59
|
+
const OPTION_SHAPED = /^-/;
|
|
60
|
+
/**
|
|
61
|
+
* The characters git itself forbids in a ref name: ASCII control characters and
|
|
62
|
+
* space (`\x00`-`\x20`), DEL, and `~ ^ : ? * [ \`.
|
|
63
|
+
*
|
|
64
|
+
* REFUSING MORE THAN THIS IS ITS OWN DEFECT (W16). The first form of this
|
|
65
|
+
* validator was an allowlist of `[A-Za-z0-9._/-]`, which reads as prudent and is
|
|
66
|
+
* not: `feature/a+b`, `feature/a@b` and `café` are all names
|
|
67
|
+
* `git check-ref-format --branch` accepts and `git branch` creates, and all
|
|
68
|
+
* three were silently discarded in favour of whatever the ladder found next — a
|
|
69
|
+
* review computed over a range the project never asked for, which is the exact
|
|
70
|
+
* failure the ladder exists to end. None of `+ @ % = ( ) ; | $ !` can make git
|
|
71
|
+
* do anything, because nothing here reaches a shell. So the rule is git's rule,
|
|
72
|
+
* plus the leading dash, and nothing else.
|
|
73
|
+
*/
|
|
74
|
+
const FORBIDDEN_REF_CHARS = /[\x00-\x20\x7f~^:?*[\\]/;
|
|
75
|
+
/** Sequences git forbids anywhere inside a ref name. */
|
|
76
|
+
const FORBIDDEN_REF_SEQUENCES = ['..', '@{', '//'];
|
|
77
|
+
/** A bound, so a pathological value cannot become a pathological command line. */
|
|
78
|
+
const MAX_REF_LENGTH = 255;
|
|
79
|
+
/**
|
|
80
|
+
* Is this a name git could actually resolve as a branch?
|
|
81
|
+
*
|
|
82
|
+
* The predicate is `git check-ref-format --branch` re-implemented, with a
|
|
83
|
+
* leading `-` refused on top. Re-implemented rather than shelled out to: this
|
|
84
|
+
* function is pure, total and called on a path that must never spawn a
|
|
85
|
+
* subprocess to decide whether it is safe to spawn a subprocess. The equivalence
|
|
86
|
+
* is not asserted here in prose — `tests/git-base-branch.test.cjs` drives
|
|
87
|
+
* the admit half from `git check-ref-format --branch` AS THE ORACLE, so git
|
|
88
|
+
* decides what must be admitted and this comment cannot quietly stop being true.
|
|
89
|
+
*
|
|
90
|
+
* Total over any input; never throws. Answering `false` is not an error — it is
|
|
91
|
+
* the resolver's cue to fall THROUGH to the next tier of the ladder, which is
|
|
92
|
+
* the behaviour a project with no override already gets.
|
|
93
|
+
*/
|
|
94
|
+
function isPlausibleBranchRef(value) {
|
|
95
|
+
if (typeof value !== 'string')
|
|
96
|
+
return false;
|
|
97
|
+
if (value === '' || value.length > MAX_REF_LENGTH)
|
|
98
|
+
return false;
|
|
99
|
+
if (OPTION_SHAPED.test(value))
|
|
100
|
+
return false;
|
|
101
|
+
if (FORBIDDEN_REF_CHARS.test(value))
|
|
102
|
+
return false;
|
|
103
|
+
for (const bad of FORBIDDEN_REF_SEQUENCES) {
|
|
104
|
+
if (value.includes(bad))
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
if (value.startsWith('/') || value.endsWith('/') || value.endsWith('.'))
|
|
108
|
+
return false;
|
|
109
|
+
// No path component may be empty, begin with `.`, or end with `.lock`.
|
|
110
|
+
for (const part of value.split('/')) {
|
|
111
|
+
if (part === '' || part.startsWith('.') || part.endsWith('.lock'))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// git's branch shorthand refuses this one by name, so this resolver does too.
|
|
115
|
+
if (value === 'HEAD')
|
|
116
|
+
return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* WHAT A CALLER SAYS WHEN THE OVERRIDE IS REFUSED. One sentence, exported, for
|
|
121
|
+
* every channel — three call sites state it and none of them spells it.
|
|
122
|
+
*
|
|
123
|
+
* A second spelling of this sentence would be a second definition of what the
|
|
124
|
+
* mechanism did. The `check vault.audit` notes and the `check ship.practices`
|
|
125
|
+
* message carry these exact bytes.
|
|
126
|
+
*
|
|
127
|
+
* The reason carries NO copy of the refused value: the value is client-supplied
|
|
128
|
+
* and one of its shapes is an absolute filesystem path, so echoing it back onto
|
|
129
|
+
* an agent-readable surface would trade an injection for a disclosure (CR-03).
|
|
130
|
+
*/
|
|
131
|
+
exports.BASE_BRANCH_REFUSED_REASON = 'the configured git.base_branch is not a name git could resolve as a branch, so it was REJECTED ' +
|
|
132
|
+
'and the base-branch ladder continued without it. A value beginning with "-" is an option to git ' +
|
|
133
|
+
'rather than a revision, and options such as --output= make git create and truncate the file they ' +
|
|
134
|
+
'name; every other rule applied here is git\'s own ref-name grammar, so a name git itself would ' +
|
|
135
|
+
'refuse is refused too. The refused value is deliberately not repeated: it is client-supplied, ' +
|
|
136
|
+
'and one of its shapes is a filesystem path.';
|
|
41
137
|
/**
|
|
42
138
|
* Safely look up `git.base_branch` from the project's config.json.
|
|
43
139
|
* Returns the configured value (a non-empty, non-null string) or null.
|
|
@@ -153,33 +249,63 @@ function tryLocalBranch(cwd, execGit) {
|
|
|
153
249
|
}
|
|
154
250
|
}
|
|
155
251
|
/**
|
|
156
|
-
* Resolve the default/base branch for the repository at `cwd
|
|
252
|
+
* Resolve the default/base branch for the repository at `cwd`, and REPORT a
|
|
253
|
+
* refused config override rather than swallowing it.
|
|
157
254
|
*
|
|
158
|
-
* Consults the full precedence ladder and always returns a non-empty
|
|
255
|
+
* Consults the full precedence ladder and always returns a non-empty branch.
|
|
159
256
|
* Never throws.
|
|
257
|
+
*
|
|
258
|
+
* ONLY TIER 1 IS SHAPE-CHECKED, and that is the whole point. Tier 1 is the one
|
|
259
|
+
* value that comes from a file a cloned repository can carry; tiers 2-4 are
|
|
260
|
+
* git's own answers about its own refs, and putting them through the same
|
|
261
|
+
* allowlist would silently demote a legitimate branch whose name this pattern
|
|
262
|
+
* happens not to describe — trading a closed threat for a new silent failure in
|
|
263
|
+
* the one function whose job is to stop guessing.
|
|
160
264
|
*/
|
|
161
|
-
function
|
|
265
|
+
function resolveBaseBranchResult(cwd, deps) {
|
|
162
266
|
const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
163
267
|
// Derive .planning dir relative to cwd (mirrors planningDir() in planning-workspace.cjs)
|
|
164
268
|
const planningDir = node_path_1.default.join(cwd, '.planning');
|
|
165
|
-
// 1. Config override
|
|
269
|
+
// 1. Config override — the only untrusted tier, so the only checked one.
|
|
166
270
|
const configured = readConfigBaseBranch(planningDir, deps);
|
|
167
|
-
|
|
168
|
-
|
|
271
|
+
let configRejected = null;
|
|
272
|
+
if (configured) {
|
|
273
|
+
if (isPlausibleBranchRef(configured))
|
|
274
|
+
return { branch: configured, configRejected: null };
|
|
275
|
+
configRejected = { reason: exports.BASE_BRANCH_REFUSED_REASON };
|
|
276
|
+
}
|
|
169
277
|
// 2. symbolic-ref (fast, no network)
|
|
170
278
|
const symref = trySymbolicRef(cwd, execGit);
|
|
171
279
|
if (symref)
|
|
172
|
-
return symref;
|
|
280
|
+
return { branch: symref, configRejected };
|
|
173
281
|
// 3. git remote show origin (authoritative when origin/HEAD unset)
|
|
174
282
|
const remoteShow = tryRemoteShow(cwd, execGit);
|
|
175
283
|
if (remoteShow)
|
|
176
|
-
return remoteShow;
|
|
284
|
+
return { branch: remoteShow, configRejected };
|
|
177
285
|
// 4. Local branch existence
|
|
178
286
|
const local = tryLocalBranch(cwd, execGit);
|
|
179
287
|
if (local)
|
|
180
|
-
return local;
|
|
288
|
+
return { branch: local, configRejected };
|
|
181
289
|
// 5. Last-resort default
|
|
182
|
-
return 'main';
|
|
290
|
+
return { branch: 'main', configRejected };
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Resolve the default/base branch for the repository at `cwd`.
|
|
294
|
+
*
|
|
295
|
+
* Consults the full precedence ladder and always returns a non-empty string.
|
|
296
|
+
* Never throws.
|
|
297
|
+
*
|
|
298
|
+
* The bare-string form. A consumer that computes a range and has a channel to
|
|
299
|
+
* speak on takes `resolveBaseBranchResult` instead and states the refusal
|
|
300
|
+
* (`vault-audit.cts`, the blocking audit gate, and `cmdShipPractices`, the
|
|
301
|
+
* ship:pre check): a gate that ran over a range the project never configured
|
|
302
|
+
* and said nothing would report success on the wrong input. This form stays
|
|
303
|
+
* for callers with nowhere to put a sentence, such as `cmdGitBaseBranch`,
|
|
304
|
+
* whose contract is one branch name on stdout. Either form refuses an
|
|
305
|
+
* option-shaped override.
|
|
306
|
+
*/
|
|
307
|
+
function resolveBaseBranch(cwd, deps) {
|
|
308
|
+
return resolveBaseBranchResult(cwd, deps).branch;
|
|
183
309
|
}
|
|
184
310
|
/**
|
|
185
311
|
* Detect whether `cwd` sits inside a git worktree, and if so, return the
|