@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.
Files changed (74) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-code-reviewer.md +1 -1
  3. package/agents/pp-executor.md +2 -13
  4. package/agents/pp-planner.md +1 -1
  5. package/agents/pp-vault-reviewer.md +51 -0
  6. package/commands/pp-ns-review.md +2 -1
  7. package/commands/pp-vault-review.md +33 -0
  8. package/gemini-extension.json +1 -1
  9. package/package.json +1 -1
  10. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  11. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  12. package/pp-core/bin/lib/clusters.cjs +1 -0
  13. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  14. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  15. package/pp-core/bin/lib/decisions.cjs +104 -27
  16. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  17. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  18. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  19. package/pp-core/bin/lib/init-contract.cjs +180 -66
  20. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  21. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  22. package/pp-core/bin/lib/loop-resolver.cjs +107 -45
  23. package/pp-core/bin/lib/org-profile.cjs +218 -44
  24. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  25. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  26. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  27. package/pp-core/bin/lib/phase.cjs +51 -1
  28. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  29. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  30. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  31. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  32. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  33. package/pp-core/bin/lib/vault-update.cjs +266 -0
  34. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  35. package/pp-core/bin/lib/vault.cjs +277 -12
  36. package/pp-core/bin/pp-tools.cjs +30 -13
  37. package/pp-core/bin/shared/model-catalog.json +2 -1
  38. package/pp-core/references/executor-vault-trace.md +47 -0
  39. package/pp-core/references/loop-hook-dispatch.md +13 -2
  40. package/pp-core/references/planner-vault-practices.md +46 -1
  41. package/pp-core/references/planning-config.md +1 -1
  42. package/pp-core/templates/practices/README.md +140 -11
  43. package/pp-core/templates/project-charter.md +226 -0
  44. package/pp-core/workflows/add-tests.md +4 -0
  45. package/pp-core/workflows/audit-fix.md +2 -0
  46. package/pp-core/workflows/check-todos.md +1 -1
  47. package/pp-core/workflows/code-review-fix.md +2 -0
  48. package/pp-core/workflows/debug.md +4 -0
  49. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  50. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  51. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  52. package/pp-core/workflows/discovery-sprint.md +1 -1
  53. package/pp-core/workflows/discovery-to-build.md +1 -1
  54. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  55. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  56. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  57. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  58. package/pp-core/workflows/execute-phase.md +22 -5
  59. package/pp-core/workflows/execute-plan.md +4 -0
  60. package/pp-core/workflows/fast.md +4 -0
  61. package/pp-core/workflows/help/modes/full.md +3 -2
  62. package/pp-core/workflows/init.md +2 -2
  63. package/pp-core/workflows/new-project.md +1 -1
  64. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  65. package/pp-core/workflows/plan-phase.md +7 -4
  66. package/pp-core/workflows/quick.md +4 -0
  67. package/pp-core/workflows/ship.md +5 -1
  68. package/pp-core/workflows/undo.md +1 -1
  69. package/pp-core/workflows/validate-phase.md +4 -0
  70. package/pp-core/workflows/vault-review.md +65 -0
  71. package/pp-core/workflows/verify-work.md +3 -0
  72. package/scripts/gen-capability-registry.cjs +11 -0
  73. package/scripts/lint-test-file-count.allowlist.json +10 -1
  74. package/scripts/prompt-injection-scan.sh +2 -0
@@ -44,6 +44,7 @@ exports.CLUSTERS = Object.freeze({
44
44
  'eval-review',
45
45
  'add-tests',
46
46
  'secure-phase',
47
+ 'vault-review',
47
48
  ]),
48
49
  milestone: Object.freeze([
49
50
  'new-milestone',
@@ -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
- const lsOutput = typeof opts._lsRemote === 'function'
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 trackable decisions from CONTEXT.md content.
105
+ * Parse CONTEXT.md content into BOTH channels: the decisions that were read,
106
+ * and the bullets that could not be.
44
107
  *
45
- * Returns ALL D-NN decisions found inside `<decisions>` (including
46
- * non-trackable ones, with `trackable: false`). Callers that only want the
47
- * gate-enforced decisions should filter `.filter(d => d.trackable)`.
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 parseDecisions(content) {
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 bulletMatch = line.match(bulletRe);
98
- if (bulletMatch) {
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-${bulletMatch[1]}`;
101
- const tags = bulletMatch[2]
102
- ? bulletMatch[2]
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: bulletMatch[3], category, tags, trackable };
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 `bulletRe` (e.g. a `:` or `*` inside the pre-colon run) must NOT
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 and nyquist auditor. `totals.spawns` counts every agent spawn of every stage, as an upper
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 string.
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 resolveBaseBranch(cwd, deps) {
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
- if (configured)
168
- return configured;
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