@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.
Files changed (81) 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-activation.cjs +19 -9
  11. package/pp-core/bin/lib/capability-registry.cjs +200 -10
  12. package/pp-core/bin/lib/check-command-router.cjs +1242 -37
  13. package/pp-core/bin/lib/clusters.cjs +1 -0
  14. package/pp-core/bin/lib/command-aliases.cjs +7 -0
  15. package/pp-core/bin/lib/compose-bindings.cjs +41 -27
  16. package/pp-core/bin/lib/config-loader.cjs +151 -8
  17. package/pp-core/bin/lib/decisions.cjs +104 -27
  18. package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
  19. package/pp-core/bin/lib/frontmatter.cjs +37 -0
  20. package/pp-core/bin/lib/git-base-branch.cjs +136 -10
  21. package/pp-core/bin/lib/init-contract.cjs +180 -66
  22. package/pp-core/bin/lib/init-interactive.cjs +4 -20
  23. package/pp-core/bin/lib/loop-gates.cjs +213 -0
  24. package/pp-core/bin/lib/loop-resolver.cjs +111 -35
  25. package/pp-core/bin/lib/org-profile.cjs +218 -44
  26. package/pp-core/bin/lib/phase-command-router.cjs +7 -1
  27. package/pp-core/bin/lib/phase-manifest.cjs +527 -0
  28. package/pp-core/bin/lib/phase-tier.cjs +15 -2
  29. package/pp-core/bin/lib/phase.cjs +51 -1
  30. package/pp-core/bin/lib/vault-audit.cjs +2070 -0
  31. package/pp-core/bin/lib/vault-command-router.cjs +460 -1
  32. package/pp-core/bin/lib/vault-practices.cjs +1142 -104
  33. package/pp-core/bin/lib/vault-review.cjs +2359 -0
  34. package/pp-core/bin/lib/vault-trace.cjs +2831 -0
  35. package/pp-core/bin/lib/vault-update.cjs +266 -0
  36. package/pp-core/bin/lib/vault-write-back.cjs +19 -0
  37. package/pp-core/bin/lib/vault.cjs +313 -11
  38. package/pp-core/bin/pp-tools.cjs +30 -13
  39. package/pp-core/bin/shared/model-catalog.json +2 -1
  40. package/pp-core/references/executor-vault-trace.md +47 -0
  41. package/pp-core/references/loop-hook-dispatch.md +20 -2
  42. package/pp-core/references/planner-vault-practices.md +46 -1
  43. package/pp-core/references/planning-config.md +1 -1
  44. package/pp-core/templates/practices/README.md +140 -11
  45. package/pp-core/templates/project-charter.md +226 -0
  46. package/pp-core/workflows/add-tests.md +4 -0
  47. package/pp-core/workflows/audit-fix.md +2 -0
  48. package/pp-core/workflows/audit-milestone.md +1 -1
  49. package/pp-core/workflows/autonomous.md +3 -3
  50. package/pp-core/workflows/check-todos.md +1 -1
  51. package/pp-core/workflows/code-review-fix.md +3 -1
  52. package/pp-core/workflows/code-review.md +1 -1
  53. package/pp-core/workflows/debug.md +4 -0
  54. package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
  55. package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
  56. package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
  57. package/pp-core/workflows/discovery-sprint.md +1 -1
  58. package/pp-core/workflows/discovery-to-build.md +1 -1
  59. package/pp-core/workflows/discuss-phase.md +2 -2
  60. package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
  61. package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
  62. package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
  63. package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
  64. package/pp-core/workflows/execute-phase.md +29 -12
  65. package/pp-core/workflows/execute-plan.md +4 -0
  66. package/pp-core/workflows/fast.md +4 -0
  67. package/pp-core/workflows/help/modes/full.md +3 -2
  68. package/pp-core/workflows/init.md +2 -2
  69. package/pp-core/workflows/new-project.md +1 -1
  70. package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
  71. package/pp-core/workflows/plan-phase.md +13 -10
  72. package/pp-core/workflows/quick.md +5 -1
  73. package/pp-core/workflows/secure-phase.md +1 -1
  74. package/pp-core/workflows/ship.md +7 -3
  75. package/pp-core/workflows/undo.md +1 -1
  76. package/pp-core/workflows/validate-phase.md +5 -1
  77. package/pp-core/workflows/vault-review.md +65 -0
  78. package/pp-core/workflows/verify-work.md +6 -3
  79. package/scripts/gen-capability-registry.cjs +11 -0
  80. package/scripts/lint-test-file-count.allowlist.json +10 -1
  81. 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>`.
@@ -43,6 +43,9 @@ const model_catalog_cjs_1 = require("./model-catalog.cjs");
43
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports
44
44
  const federatedConfigModule = require("./federated-config.cjs");
45
45
  const { mergeFederatedConfig } = federatedConfigModule;
46
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
47
+ const capabilityActivationModule = require("./capability-activation.cjs");
48
+ const { activationConfigFilesFor } = capabilityActivationModule;
46
49
  // The capability-registry.cjs is generated and lives in the same gsd-core/bin/lib/ output dir.
47
50
  // Both config-loader.cjs and capability-registry.cjs land in gsd-core/bin/lib/ at build time.
48
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
@@ -341,16 +344,154 @@ function _applyFederatedOverlay(baseConfig, userConfig) {
341
344
  _applyFederatedValues(cloned, _fedOverlay.values, _fedOverlay.validKeys);
342
345
  return cloned;
343
346
  }
344
- function loadConfig(cwd, options = {}) {
347
+ /**
348
+ * The workstream `loadConfig` reads for `options`: an explicit `workstream`
349
+ * option, then `workstreamContext.ws`, then `GSD_WORKSTREAM`. Shared with
350
+ * `configFilesFor` so the two can never resolve different workstreams.
351
+ */
352
+ function _activeWorkstreamFor(options) {
345
353
  const activeWorkstream = Object.prototype.hasOwnProperty.call(options, 'workstream')
346
354
  ? options['workstream']
347
355
  : (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
348
356
  ? options['workstreamContext']['ws']
349
357
  : (process.env['GSD_WORKSTREAM'] || null);
358
+ return typeof activeWorkstream === 'string' ? activeWorkstream : null;
359
+ }
360
+ function _configPathsFor(cwd, ws) {
361
+ return {
362
+ rootConfigPath: ws ? node_path_1.default.join(planningRoot(cwd), 'config.json') : null,
363
+ configPath: node_path_1.default.join(planningDir(cwd, ws), 'config.json'),
364
+ };
365
+ }
366
+ /** `~/.gsd/defaults.json` (or under `GSD_HOME`): read only in a pre-project context (#1683). */
367
+ function _globalDefaultsPath() {
368
+ return node_path_1.default.join(process.env['GSD_HOME'] || node_os_1.default.homedir(), '.gsd', 'defaults.json');
369
+ }
370
+ /** The #1683 predicate: the global defaults are consulted only when the active planning dir is absent. */
371
+ function _readsGlobalDefaults(cwd, ws) {
372
+ return !node_fs_1.default.existsSync(planningDir(cwd, ws));
373
+ }
374
+ /**
375
+ * Every config file `loadConfig(cwd, options)` reads, as absolute paths in read
376
+ * order. ONE owner of "which config files does the loader read": `loadConfig`
377
+ * takes its paths from the same `_configPathsFor`, so a reader that must refuse
378
+ * an unreadable config (`unreadableConfigFiles` below) parses exactly these
379
+ * files without restating the workstream ladder.
380
+ *
381
+ * Why a caller needs it: `loadConfig` swallows a parse failure in every one of
382
+ * these files (root → "workstream config stands alone", active → the defaults),
383
+ * so its RESULT cannot say whether it read the project's config or read
384
+ * nothing. Files that do not exist are still listed; absence is not a failure
385
+ * for `loadConfig`, and the caller decides what it means.
386
+ */
387
+ function configFilesFor(cwd, options = {}) {
388
+ const ws = _activeWorkstreamFor(options);
389
+ const { rootConfigPath, configPath } = _configPathsFor(cwd, ws);
390
+ const files = [];
391
+ if (rootConfigPath)
392
+ files.push(rootConfigPath);
393
+ files.push(configPath);
394
+ if (_readsGlobalDefaults(cwd, ws))
395
+ files.push(_globalDefaultsPath());
396
+ return files;
397
+ }
398
+ /** The error text is bounded; composed text never reaches the cap, but it holds anyway. */
399
+ const UNREADABLE_ERROR_CAP = 200;
400
+ /**
401
+ * Describe why a config file could not be used WITHOUT echoing it. V8's
402
+ * `JSON.parse` messages sometimes quote the input (`Unexpected token '}',
403
+ * "{"a":tru}" is not valid JSON`), so only the position is carried over.
404
+ */
405
+ function _describeConfigReadFailure(err) {
406
+ const e = err;
407
+ if (e && typeof e.code === 'string' && /^[A-Z][A-Z0-9_]*$/.test(e.code))
408
+ return `cannot be read (${e.code})`;
409
+ const message = e && typeof e.message === 'string' ? e.message : '';
410
+ const lineCol = /\(line (\d+) column (\d+)\)/.exec(message);
411
+ if (lineCol)
412
+ return `not valid JSON (line ${lineCol[1]}, column ${lineCol[2]})`;
413
+ const position = /at position (\d+)/.exec(message);
414
+ if (position)
415
+ return `not valid JSON (at position ${position[1]})`;
416
+ if (/Unexpected end of JSON input/.test(message))
417
+ return 'not valid JSON (the text ends before the JSON does)';
418
+ return 'not valid JSON';
419
+ }
420
+ /** How a refusal names a config file: never an absolute user path. */
421
+ function _configDisplayPath(cwd, abs) {
422
+ const under = (base) => {
423
+ const rel = node_path_1.default.relative(base, abs);
424
+ return rel !== '' && !rel.startsWith('..') && !node_path_1.default.isAbsolute(rel) ? rel.split(node_path_1.default.sep).join('/') : null;
425
+ };
426
+ const inProject = under(cwd);
427
+ if (inProject !== null)
428
+ return inProject;
429
+ const homeOverride = process.env['GSD_HOME'];
430
+ const inHomeOverride = homeOverride ? under(homeOverride) : null;
431
+ if (inHomeOverride !== null)
432
+ return `$GSD_HOME/${inHomeOverride}`;
433
+ const inHome = under(node_os_1.default.homedir());
434
+ if (inHome !== null)
435
+ return `~/${inHome}`;
436
+ return node_path_1.default.basename(abs);
437
+ }
438
+ /**
439
+ * Every config file the hook resolver would read that EXISTS but cannot be
440
+ * used: unreadable, not JSON, or JSON that is not an object (`loadConfig`
441
+ * reads none of an array's or a null's keys and answers the defaults, the same
442
+ * silent fallback as a parse failure). Empty when every file is usable or
443
+ * absent: a MISSING config is a fresh project and reads as the defaults.
444
+ *
445
+ * The files are the union, in first-seen order, of the loader's list
446
+ * (`configFilesFor`) and the activation resolver's raw levels
447
+ * (`activationConfigFilesFor`, which can name a root config the loader never
448
+ * opens under `GSD_PROJECT`). Reads go through `platformReadSync`, the reader
449
+ * `loadConfig` uses, so a file the loader can read is never called unreadable.
450
+ *
451
+ * This is the ONE owner of "can the project's config be read". Both swallowing
452
+ * readers (`loadConfig`, `resolveConfigKey`) stay tolerant for their many
453
+ * callers; a caller whose answer governs a gate (`loop render-hooks`, the vault
454
+ * load verdict) asks this first and refuses. Never throws.
455
+ */
456
+ function unreadableConfigFiles(cwd, options = {}) {
457
+ const out = [];
458
+ let files;
459
+ try {
460
+ files = [...new Set([...configFilesFor(cwd, options), ...activationConfigFilesFor(cwd)])];
461
+ }
462
+ catch (err) {
463
+ // An invalid GSD_WORKSTREAM / GSD_PROJECT segment: the config cannot be located, so it cannot be read.
464
+ return [{ file: '.planning/config.json', error: `cannot be located (${err instanceof Error ? err.message : String(err)})`.slice(0, UNREADABLE_ERROR_CAP) }];
465
+ }
466
+ for (const file of files) {
467
+ let error = null;
468
+ try {
469
+ const raw = (0, shell_command_projection_cjs_1.platformReadSync)(file);
470
+ if (raw === null)
471
+ continue;
472
+ const parsed = JSON.parse(raw);
473
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
474
+ error = 'valid JSON but not an object, so none of its settings can be read';
475
+ }
476
+ }
477
+ catch (err) {
478
+ error = _describeConfigReadFailure(err);
479
+ }
480
+ if (error !== null)
481
+ out.push({ file: _configDisplayPath(cwd, file), error: error.slice(0, UNREADABLE_ERROR_CAP) });
482
+ }
483
+ return out;
484
+ }
485
+ /** One line per unreadable file, `<file>: <error>`, for a refusal message. */
486
+ function describeUnreadableConfig(unreadable) {
487
+ return unreadable.map((u) => `${u.file}: ${u.error}`).join('; ');
488
+ }
489
+ function loadConfig(cwd, options = {}) {
350
490
  // When GSD_WORKSTREAM is set, load root config first so workstream config
351
491
  // can inherit from it. This prevents users from duplicating model_overrides,
352
492
  // workflow.*, etc. across every workstream config (#2714).
353
- const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
493
+ const ws = _activeWorkstreamFor(options);
494
+ const paths = _configPathsFor(cwd, ws);
354
495
  // #315 — per-call lazy memo: all three detection sites inside this loadConfig
355
496
  // call operate on the same cwd and the subrepo set cannot change mid-call, so
356
497
  // a single scan is sufficient. The memo is scoped to THIS call (not module-level)
@@ -364,8 +505,8 @@ function loadConfig(cwd, options = {}) {
364
505
  return cachedSubRepos.slice();
365
506
  };
366
507
  let rootParsed = null;
367
- if (ws) {
368
- const rootConfigPath = node_path_1.default.join(planningRoot(cwd), 'config.json');
508
+ if (paths.rootConfigPath) {
509
+ const rootConfigPath = paths.rootConfigPath;
369
510
  try {
370
511
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(rootConfigPath);
371
512
  if (raw === null)
@@ -400,7 +541,7 @@ function loadConfig(cwd, options = {}) {
400
541
  // Root config missing or unparseable — workstream config stands alone
401
542
  }
402
543
  }
403
- const configPath = node_path_1.default.join(planningDir(cwd, ws), 'config.json');
544
+ const configPath = paths.configPath;
404
545
  const defaults = CONFIG_DEFAULTS;
405
546
  try {
406
547
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(configPath);
@@ -625,7 +766,7 @@ function loadConfig(cwd, options = {}) {
625
766
  }
626
767
  catch {
627
768
  // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
628
- if (node_fs_1.default.existsSync(planningDir(cwd, ws))) {
769
+ if (!_readsGlobalDefaults(cwd, ws)) {
629
770
  if (rootParsed) {
630
771
  // Workstream has no config.json: re-parse using root config as the sole source.
631
772
  // (FIX 2: overlay is applied recursively in the re-entrant loadConfig call)
@@ -642,8 +783,7 @@ function loadConfig(cwd, options = {}) {
642
783
  }
643
784
  }
644
785
  try {
645
- const home = process.env['GSD_HOME'] || node_os_1.default.homedir();
646
- const globalDefaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
786
+ const globalDefaultsPath = _globalDefaultsPath();
647
787
  const raw = (0, shell_command_projection_cjs_1.platformReadSync)(globalDefaultsPath);
648
788
  if (raw === null)
649
789
  throw new Error('missing');
@@ -698,6 +838,9 @@ function loadConfig(cwd, options = {}) {
698
838
  }
699
839
  module.exports = {
700
840
  loadConfig,
841
+ configFilesFor,
842
+ unreadableConfigFiles,
843
+ describeUnreadableConfig,
701
844
  isGitIgnored,
702
845
  CONFIG_DEFAULTS,
703
846
  _getConfigDefault,
@@ -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
  }