@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
@@ -0,0 +1,527 @@
1
+ "use strict";
2
+ /**
3
+ * Phase manifest — the writer of `.phase-manifest.json`, which `/pp-undo
4
+ * --phase` reads (`manifest.phases[NN].commits`, reverted newest first).
5
+ *
6
+ * Command surface (routed by phase.cts):
7
+ * pp-tools phase manifest start <phase> anchor the phase: startCommit = HEAD, write-once
8
+ * pp-tools phase manifest record <phase> commits = rev-list --reverse --no-merges <startCommit>..HEAD
9
+ *
10
+ * execute-phase calls `start` before its first executor and `record` after
11
+ * every wave's tracking commit and after the phase completion commit, so the
12
+ * list always covers the phase's executor, merge and tracking commits. Both
13
+ * are orchestrator writes; no executor calls them.
14
+ *
15
+ * The manifest sits two levels above the phase directory:
16
+ * `.planning/.phase-manifest.json` on the flat layout (the file `/pp-undo`
17
+ * reads), `.planning/workstreams/<ws>/.phase-manifest.json` on a partitioned
18
+ * one, so two workstreams' `05` entries never share a file. It is local
19
+ * execution metadata: the writer adds `.phase-manifest.json` to the
20
+ * repository's shared `info/exclude`, so the file never shows as untracked
21
+ * (which `/pp-undo`'s clean-tree guard would refuse) and is never committed by
22
+ * a broad `git add`.
23
+ *
24
+ * Never throws; a refusal is `{ ok: false, action, reason }` with a
25
+ * `PHASE_MANIFEST_WRITE_REASON` member, and the workflows print the one-line
26
+ * rendering and continue — a manifest that could not be written leaves
27
+ * `/pp-undo --phase` on its git-log fallback, which is what it had before.
28
+ *
29
+ * `readPhaseManifestEntry` is the READER: the phase's recorded `startCommit`
30
+ * and `commits`, read from exactly the file the writer writes, or the named
31
+ * ground on which there is no usable record. `vault audit` scopes its diff
32
+ * from it (src/vault-audit.cts).
33
+ */
34
+ var __importDefault = (this && this.__importDefault) || function (mod) {
35
+ return (mod && mod.__esModule) ? mod : { "default": mod };
36
+ };
37
+ const node_fs_1 = __importDefault(require("node:fs"));
38
+ const node_path_1 = __importDefault(require("node:path"));
39
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
40
+ /** The manifest file `/pp-undo` reads. */
41
+ const PHASE_MANIFEST_FILE = '.phase-manifest.json';
42
+ /**
43
+ * Every sha is interpolated into a git ARGV, where a leading `-` would be read
44
+ * as a flag. The manifest is editable planning data, so this is the one shape
45
+ * a sha may have.
46
+ */
47
+ const COMMIT_SHA_SHAPE = /^[0-9a-fA-F]{7,64}$/;
48
+ /** The leading `NN` or `NN.M` token of a phase directory slug. */
49
+ const PHASE_SLUG_SHAPE = /^(\d+(?:\.\d+)?)/;
50
+ const TOKEN_MAX = 120;
51
+ /** Why `recordPhaseStart` / `recordPhaseCommits` wrote nothing. */
52
+ const PHASE_MANIFEST_WRITE_REASON = Object.freeze({
53
+ /** The phase directory is not an existing directory inside the project root. */
54
+ PHASE_DIR_UNRESOLVABLE: 'phase-dir-unresolvable',
55
+ /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
56
+ PHASE_SLUG_UNRECOGNISED: 'phase-slug-unrecognised',
57
+ /** git could not name the commit HEAD points at (an unborn branch, or no repository). */
58
+ HEAD_UNRESOLVABLE: 'head-unresolvable',
59
+ /** The manifest exists and is not a JSON object with an object `phases`; it is left untouched. */
60
+ MANIFEST_MALFORMED: 'manifest-malformed',
61
+ /** `record` found no start commit for this phase — `start` never ran for it. */
62
+ START_NOT_RECORDED: 'start-not-recorded',
63
+ /** `record` found a start commit that git does not verify. */
64
+ START_UNVERIFIABLE: 'start-unverifiable',
65
+ /**
66
+ * `start` found no anchor for this phase, and its directory already holds a
67
+ * `*-SUMMARY.md`: the phase has been executed, so HEAD sits AFTER its own
68
+ * commits and an anchor there would put the phase's work outside its own
69
+ * range. Nothing is written.
70
+ */
71
+ PHASE_ALREADY_EXECUTED: 'phase-already-executed',
72
+ /** The manifest could not be written (an errno is in `detail`). */
73
+ WRITE_FAILED: 'write-failed',
74
+ });
75
+ function boundedToken(v) {
76
+ let s;
77
+ try {
78
+ s = typeof v === 'string' ? v : JSON.stringify(v) ?? String(v);
79
+ }
80
+ catch {
81
+ s = String(v);
82
+ }
83
+ s = s.replace(/[\r\n]+/g, ' ');
84
+ return s.length > TOKEN_MAX ? `${s.slice(0, TOKEN_MAX)}…` : s;
85
+ }
86
+ function errnoOf(e) {
87
+ const code = e?.code;
88
+ return typeof code === 'string' && code.length > 0 ? boundedToken(code) : 'unknown error';
89
+ }
90
+ /** Is this resolved absolute path the root itself, or somewhere beneath it? */
91
+ function isContainedIn(abs, rootAbs) {
92
+ return abs === rootAbs || abs.startsWith(rootAbs + node_path_1.default.sep);
93
+ }
94
+ /** The root with every symlinked component resolved (a macOS temp root is `/var` and `/private/var` at once). */
95
+ function resolvedRootOf(rootAbs) {
96
+ try {
97
+ return node_fs_1.default.realpathSync(rootAbs);
98
+ }
99
+ catch {
100
+ return rootAbs;
101
+ }
102
+ }
103
+ /**
104
+ * Is this manifest entry stamped for a DIFFERENT phase directory? Entries are
105
+ * keyed by phase NUMBER, and `/pp-new-milestone --reset-phase-numbers` reuses
106
+ * numbers, so a new milestone's `01-new` would otherwise inherit `01-old`'s
107
+ * anchor and commits. An entry with NO stamp (legacy or hand-written) is not
108
+ * stale.
109
+ */
110
+ function isStaleEntry(entry, slug) {
111
+ return typeof entry.phaseDir === 'string' && entry.phaseDir !== slug;
112
+ }
113
+ /**
114
+ * Where this phase's manifest lives, or the named ground on which it cannot.
115
+ * `phaseDir` resolves against `cwd`, then against `root`; the first that is an
116
+ * existing directory CONTAINED in the root wins, lexically or with symlinks
117
+ * resolved on both sides.
118
+ */
119
+ function resolveManifestTarget(opts) {
120
+ const unresolvable = { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
121
+ if (typeof opts?.root !== 'string' || opts.root === '')
122
+ return unresolvable;
123
+ if (typeof opts?.phaseDir !== 'string' || opts.phaseDir === '' || opts.phaseDir.includes('\0'))
124
+ return unresolvable;
125
+ const rootLexical = node_path_1.default.resolve(opts.root);
126
+ const rootReal = resolvedRootOf(rootLexical);
127
+ const bases = [typeof opts.cwd === 'string' && opts.cwd !== '' ? opts.cwd : process.cwd(), rootLexical];
128
+ let phaseDirAbs = null;
129
+ let rootAbs = rootLexical;
130
+ for (const base of bases) {
131
+ const abs = node_path_1.default.resolve(base, opts.phaseDir);
132
+ try {
133
+ if (!node_fs_1.default.statSync(abs).isDirectory())
134
+ continue;
135
+ }
136
+ catch {
137
+ continue;
138
+ }
139
+ if (isContainedIn(abs, rootLexical)) {
140
+ phaseDirAbs = abs;
141
+ rootAbs = rootLexical;
142
+ break;
143
+ }
144
+ const real = resolvedRootOf(abs);
145
+ if (isContainedIn(real, rootReal)) {
146
+ phaseDirAbs = real;
147
+ rootAbs = rootReal;
148
+ break;
149
+ }
150
+ }
151
+ if (phaseDirAbs === null)
152
+ return unresolvable;
153
+ const slugMatch = PHASE_SLUG_SHAPE.exec(node_path_1.default.basename(phaseDirAbs));
154
+ if (!slugMatch)
155
+ return { ok: false, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_SLUG_UNRECOGNISED };
156
+ const manifestAbs = node_path_1.default.join(node_path_1.default.dirname(node_path_1.default.dirname(phaseDirAbs)), PHASE_MANIFEST_FILE);
157
+ if (!isContainedIn(manifestAbs, rootAbs))
158
+ return unresolvable;
159
+ return {
160
+ ok: true,
161
+ target: {
162
+ rootAbs,
163
+ manifestAbs,
164
+ manifestPath: node_path_1.default.relative(rootAbs, manifestAbs).split(node_path_1.default.sep).join('/'),
165
+ rawToken: slugMatch[1],
166
+ phaseDirAbs,
167
+ phaseSlug: node_path_1.default.basename(phaseDirAbs),
168
+ },
169
+ };
170
+ }
171
+ /**
172
+ * The existing manifest, parsed for an ADDITIVE write — or `null` when it must
173
+ * not be overwritten: unparseable JSON, a non-object document or a non-object
174
+ * `phases`. An absent file is an empty document, not a refusal.
175
+ */
176
+ function readManifestForWrite(manifestAbs) {
177
+ if (!node_fs_1.default.existsSync(manifestAbs))
178
+ return { doc: {}, phases: {}, raw: null };
179
+ try {
180
+ const raw = node_fs_1.default.readFileSync(manifestAbs, 'utf8');
181
+ const parsed = JSON.parse(raw);
182
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
183
+ return null;
184
+ const doc = parsed;
185
+ if (doc.phases === undefined)
186
+ return { doc, phases: {}, raw };
187
+ if (!doc.phases || typeof doc.phases !== 'object' || Array.isArray(doc.phases))
188
+ return null;
189
+ return { doc, phases: doc.phases, raw };
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ /** The key this phase's entry lives under: an existing spelling wins, else the slug's own token. */
196
+ function manifestKeyFor(phases, rawToken) {
197
+ const spellings = [rawToken, String(Number(rawToken))];
198
+ return spellings.find((key) => Object.prototype.hasOwnProperty.call(phases, key)) ?? rawToken;
199
+ }
200
+ /** The entry under `key` as a plain object, `{}` when absent, `null` when present and not an object. */
201
+ function manifestEntryOf(phases, key) {
202
+ if (!Object.prototype.hasOwnProperty.call(phases, key))
203
+ return {};
204
+ const entry = phases[key];
205
+ return entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null;
206
+ }
207
+ /** Two-space JSON with a trailing newline — the one serialization, so a re-record is byte-stable. */
208
+ function serializeManifest(doc) {
209
+ return `${JSON.stringify(doc, null, 2)}\n`;
210
+ }
211
+ /** Write atomically (sibling temp file, then rename). Returns `null` on success, else an errno. */
212
+ function writeManifestAtomic(manifestAbs, serialized) {
213
+ const tmp = `${manifestAbs}.${process.pid}.tmp`;
214
+ try {
215
+ node_fs_1.default.writeFileSync(tmp, serialized, 'utf8');
216
+ node_fs_1.default.renameSync(tmp, manifestAbs);
217
+ return null;
218
+ }
219
+ catch (err) {
220
+ try {
221
+ node_fs_1.default.unlinkSync(tmp);
222
+ }
223
+ catch {
224
+ // Nothing to remove, or it cannot be removed; the refusal stands.
225
+ }
226
+ return errnoOf(err);
227
+ }
228
+ }
229
+ /** Has this phase already been executed? `null` when the directory cannot be listed. */
230
+ function phaseHasSummary(phaseDirAbs) {
231
+ try {
232
+ return node_fs_1.default.readdirSync(phaseDirAbs).some((name) => name.endsWith('-SUMMARY.md'));
233
+ }
234
+ catch {
235
+ return null;
236
+ }
237
+ }
238
+ /** A verified commit sha from a `rev-parse --verify` probe, or `null`. */
239
+ function verifiedSha(exec, rev, cwd) {
240
+ const probe = exec(['rev-parse', '--verify', rev], { cwd });
241
+ if (probe.exitCode !== 0 || probe.error)
242
+ return null;
243
+ const sha = String(probe.stdout || '').trim();
244
+ return COMMIT_SHA_SHAPE.test(sha) ? sha : null;
245
+ }
246
+ /** The exclude pattern: the basename, so the flat and the workstream manifests are both covered. */
247
+ const EXCLUDE_LINE = PHASE_MANIFEST_FILE;
248
+ /**
249
+ * Keep the manifest out of git: add `.phase-manifest.json` to the repository's
250
+ * SHARED `info/exclude` (the git common dir, so every linked worktree is
251
+ * covered), never to the project's own committed `.gitignore`. Best effort:
252
+ * returns `added`, `present` or `failed`, and never throws.
253
+ */
254
+ function ensureManifestExcluded(exec, rootAbs) {
255
+ try {
256
+ const r = exec(['rev-parse', '--git-common-dir'], { cwd: rootAbs });
257
+ if (r.exitCode !== 0 || r.error)
258
+ return 'failed';
259
+ const common = String(r.stdout || '').trim();
260
+ if (common === '')
261
+ return 'failed';
262
+ const commonAbs = node_path_1.default.resolve(rootAbs, common);
263
+ const excludeAbs = node_path_1.default.join(commonAbs, 'info', 'exclude');
264
+ let current = '';
265
+ try {
266
+ current = node_fs_1.default.readFileSync(excludeAbs, 'utf8');
267
+ }
268
+ catch {
269
+ current = '';
270
+ }
271
+ if (current.split(/\r?\n/).some((line) => line.trim() === EXCLUDE_LINE))
272
+ return 'present';
273
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(excludeAbs), { recursive: true });
274
+ const sep = current === '' || current.endsWith('\n') ? '' : '\n';
275
+ node_fs_1.default.appendFileSync(excludeAbs, `${sep}# pp-core: local phase manifest, read by /pp-undo --phase\n${EXCLUDE_LINE}\n`, 'utf8');
276
+ return 'added';
277
+ }
278
+ catch {
279
+ return 'failed';
280
+ }
281
+ }
282
+ /**
283
+ * Anchor the phase: `phases[NN].startCommit = HEAD`, WRITE-ONCE.
284
+ *
285
+ * 1. WRITE-ONCE. An entry for THIS phase directory that already carries a
286
+ * start commit is left byte-identical (`unchanged: true`), so a resume or
287
+ * a `--gaps-only` run keeps the anchor the phase started from.
288
+ * 2. ALREADY EXECUTED. With no usable anchor, a phase directory that already
289
+ * holds a `*-SUMMARY.md` is refused (`phase-already-executed`); HEAD on an
290
+ * executed phase sits after its own commits.
291
+ * 3. STALE. An entry stamped for another phase directory is REPLACED by a
292
+ * fresh `{ startCommit, phaseDir }`; its old commits are dropped
293
+ * (`replacedStale: true`).
294
+ *
295
+ * Every write stamps `phaseDir` with the directory basename. Never throws.
296
+ */
297
+ function recordPhaseStart(opts) {
298
+ const action = 'start';
299
+ try {
300
+ const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGit;
301
+ const resolved = resolveManifestTarget(opts);
302
+ if (!resolved.ok)
303
+ return { ok: false, action, reason: resolved.reason };
304
+ const { target } = resolved;
305
+ const read = readManifestForWrite(target.manifestAbs);
306
+ if (read === null)
307
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
308
+ const key = manifestKeyFor(read.phases, target.rawToken);
309
+ const entry = manifestEntryOf(read.phases, key);
310
+ if (entry === null)
311
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
312
+ const stale = isStaleEntry(entry, target.phaseSlug);
313
+ if (!stale && typeof entry.startCommit === 'string' && COMMIT_SHA_SHAPE.test(entry.startCommit)) {
314
+ return { ok: true, action, manifestPath: target.manifestPath, phase: key, startCommit: entry.startCommit, unchanged: true };
315
+ }
316
+ // Checked before HEAD is read: a refusal asks git nothing.
317
+ const executed = phaseHasSummary(target.phaseDirAbs);
318
+ if (executed === null)
319
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
320
+ if (executed)
321
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.PHASE_ALREADY_EXECUTED };
322
+ const head = verifiedSha(exec, 'HEAD^{commit}', target.rootAbs);
323
+ if (head === null)
324
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE };
325
+ const fresh = stale ? { startCommit: head, phaseDir: target.phaseSlug } : { ...entry, startCommit: head, phaseDir: target.phaseSlug };
326
+ const doc = { ...read.doc, phases: { ...read.phases, [key]: fresh } };
327
+ const failure = writeManifestAtomic(target.manifestAbs, serializeManifest(doc));
328
+ if (failure !== null)
329
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
330
+ const excluded = ensureManifestExcluded(exec, target.rootAbs);
331
+ return {
332
+ ok: true,
333
+ action,
334
+ manifestPath: target.manifestPath,
335
+ phase: key,
336
+ startCommit: head,
337
+ unchanged: false,
338
+ excluded,
339
+ ...(stale ? { replacedStale: true } : {}),
340
+ };
341
+ }
342
+ catch (err) {
343
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
344
+ }
345
+ }
346
+ /**
347
+ * Close the phase's range: `phases[NN].commits` =
348
+ * `git rev-list --reverse --no-merges <startCommit>..HEAD`, oldest first.
349
+ * Recomputed from the anchor on every call, so two consecutive records leave
350
+ * the file byte-identical. An empty list is a valid record. No anchor, or an
351
+ * entry stamped for another phase directory, answers `start-not-recorded`.
352
+ * Never throws.
353
+ */
354
+ function recordPhaseCommits(opts) {
355
+ const action = 'record';
356
+ try {
357
+ const exec = typeof opts?.exec === 'function' ? opts.exec : shell_command_projection_cjs_1.execGit;
358
+ const resolved = resolveManifestTarget(opts);
359
+ if (!resolved.ok)
360
+ return { ok: false, action, reason: resolved.reason };
361
+ const { target } = resolved;
362
+ const read = readManifestForWrite(target.manifestAbs);
363
+ if (read === null)
364
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
365
+ const key = manifestKeyFor(read.phases, target.rawToken);
366
+ const entry = manifestEntryOf(read.phases, key);
367
+ if (entry === null)
368
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED };
369
+ if (isStaleEntry(entry, target.phaseSlug)) {
370
+ return {
371
+ ok: false,
372
+ action,
373
+ reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED,
374
+ detail: 'the entry under this phase number was recorded for another phase directory',
375
+ };
376
+ }
377
+ const start = entry.startCommit;
378
+ if (typeof start !== 'string' || !COMMIT_SHA_SHAPE.test(start)) {
379
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_NOT_RECORDED };
380
+ }
381
+ if (verifiedSha(exec, `${start}^{commit}`, target.rootAbs) === null) {
382
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.START_UNVERIFIABLE };
383
+ }
384
+ const listed = exec(['rev-list', '--reverse', '--no-merges', `${start}..HEAD`], { cwd: target.rootAbs });
385
+ if (listed.exitCode !== 0 || listed.error) {
386
+ return {
387
+ ok: false,
388
+ action,
389
+ reason: PHASE_MANIFEST_WRITE_REASON.HEAD_UNRESOLVABLE,
390
+ detail: 'git rev-list could not list the commits since the start commit',
391
+ };
392
+ }
393
+ const commits = String(listed.stdout || '')
394
+ .split('\n')
395
+ .map((line) => line.trim())
396
+ .filter((line) => COMMIT_SHA_SHAPE.test(line));
397
+ const doc = { ...read.doc, phases: { ...read.phases, [key]: { ...entry, commits, phaseDir: target.phaseSlug } } };
398
+ const serialized = serializeManifest(doc);
399
+ if (serialized !== read.raw) {
400
+ const failure = writeManifestAtomic(target.manifestAbs, serialized);
401
+ if (failure !== null)
402
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: failure };
403
+ }
404
+ return { ok: true, action, manifestPath: target.manifestPath, phase: key, startCommit: start, commits, commitCount: commits.length };
405
+ }
406
+ catch (err) {
407
+ return { ok: false, action, reason: PHASE_MANIFEST_WRITE_REASON.WRITE_FAILED, detail: errnoOf(err) };
408
+ }
409
+ }
410
+ /** The one-line operator rendering of a writer result; the workflows print it and continue. */
411
+ function renderManifestResult(result) {
412
+ const r = (result && typeof result === 'object' ? result : {});
413
+ if (r.ok !== true)
414
+ return `Phase manifest not written: ${boundedToken(r.reason ?? 'unknown')}`;
415
+ const sha7 = typeof r.startCommit === 'string' ? r.startCommit.slice(0, 7) : '';
416
+ if (r.action === 'record') {
417
+ return `Phase ${boundedToken(r.phase)}: ${Number(r.commitCount)} commit(s) recorded from ${sha7}..HEAD in ${boundedToken(r.manifestPath)}`;
418
+ }
419
+ return (`Phase ${boundedToken(r.phase)} anchored at ${sha7} in ${boundedToken(r.manifestPath)}` +
420
+ (r.unchanged === true ? ' (already anchored; unchanged)' : '') +
421
+ (r.replacedStale === true ? ' (replaced a stale entry recorded for another phase directory)' : ''));
422
+ }
423
+ // ─── The reader ───────────────────────────────────────────────────────────────
424
+ /**
425
+ * Why a phase has no usable manifest record. The strings are shared with the
426
+ * write reasons where the fact is the same (an unkeyable slug, an unparseable
427
+ * file), so one fact has one spelling.
428
+ */
429
+ const PHASE_MANIFEST_READ_MISS = Object.freeze({
430
+ /** The phase directory's name does not begin with an `NN` or `NN.M` token. */
431
+ PHASE_SLUG_UNRECOGNISED: PHASE_MANIFEST_WRITE_REASON.PHASE_SLUG_UNRECOGNISED,
432
+ /** No manifest at the writer's location for this phase directory. */
433
+ MANIFEST_NOT_FOUND: 'manifest-not-found',
434
+ /** A manifest exists and is not a JSON object with an object `phases`. */
435
+ MANIFEST_MALFORMED: PHASE_MANIFEST_WRITE_REASON.MANIFEST_MALFORMED,
436
+ /** The manifest parsed and holds no usable entry for this phase under either key spelling. */
437
+ PHASE_ENTRY_MISSING: 'phase-entry-missing',
438
+ /** The phase's entry exists and records neither a start commit nor any commit. */
439
+ NO_COMMITS_RECORDED: 'no-commits-recorded',
440
+ });
441
+ /** The one sentence a stale entry is disclosed with. */
442
+ function staleEntryNote(key, recorded, slug) {
443
+ return (`the phase manifest entry "${boundedToken(key)}" is stale: it was recorded for phase directory ` +
444
+ `"${boundedToken(recorded)}", not "${boundedToken(slug)}" (a phase number reused after a milestone reset), ` +
445
+ 'so its startCommit and commits were ignored');
446
+ }
447
+ /**
448
+ * Read this phase's record from the manifest the writer keeps — the file two
449
+ * levels above the phase directory, the same location `recordPhaseStart`
450
+ * writes — or name the ground on which there is none.
451
+ *
452
+ * Both key spellings (`05` and `5`) are read before answering, so an anchor
453
+ * under one is not hidden by a commit list under the other. An entry stamped
454
+ * for ANOTHER phase directory is stale (a phase number reused after a
455
+ * milestone reset): it is no record for this phase, and it is disclosed in
456
+ * `notes`. An unstamped (legacy) entry counts as this phase's. A manifest
457
+ * location outside `root` is never read. Pure read; never throws.
458
+ */
459
+ function readPhaseManifestEntry(opts) {
460
+ const none = (miss, notes = []) => ({ commits: [], startCommit: null, miss, notes });
461
+ try {
462
+ const phaseDirAbs = node_path_1.default.resolve(opts.phaseDir);
463
+ const slug = node_path_1.default.basename(phaseDirAbs);
464
+ const slugMatch = PHASE_SLUG_SHAPE.exec(slug);
465
+ if (!slugMatch)
466
+ return none(PHASE_MANIFEST_READ_MISS.PHASE_SLUG_UNRECOGNISED);
467
+ const manifestAbs = node_path_1.default.join(node_path_1.default.dirname(node_path_1.default.dirname(phaseDirAbs)), PHASE_MANIFEST_FILE);
468
+ const rootAbs = node_path_1.default.resolve(opts.root);
469
+ if (!isContainedIn(manifestAbs, rootAbs) && !isContainedIn(resolvedRootOf(node_path_1.default.dirname(manifestAbs)), resolvedRootOf(rootAbs))) {
470
+ return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
471
+ }
472
+ if (!node_fs_1.default.existsSync(manifestAbs))
473
+ return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
474
+ let phases;
475
+ try {
476
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(manifestAbs, 'utf8'));
477
+ const candidate = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.phases : undefined;
478
+ if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
479
+ return none(PHASE_MANIFEST_READ_MISS.MANIFEST_MALFORMED);
480
+ phases = candidate;
481
+ }
482
+ catch {
483
+ return none(PHASE_MANIFEST_READ_MISS.MANIFEST_MALFORMED);
484
+ }
485
+ const rawToken = slugMatch[1];
486
+ const keys = [...new Set([rawToken, String(Number(rawToken))])];
487
+ const notes = [];
488
+ let present = false;
489
+ let startCommit = null;
490
+ let commits = null;
491
+ for (const key of keys) {
492
+ if (!Object.prototype.hasOwnProperty.call(phases, key))
493
+ continue;
494
+ const entry = phases[key];
495
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
496
+ continue;
497
+ const record = entry;
498
+ if (isStaleEntry(record, slug)) {
499
+ notes.push(staleEntryNote(key, record.phaseDir, slug));
500
+ continue;
501
+ }
502
+ present = true;
503
+ if (startCommit === null && typeof record.startCommit === 'string' && COMMIT_SHA_SHAPE.test(record.startCommit)) {
504
+ startCommit = record.startCommit;
505
+ }
506
+ if (commits === null && Array.isArray(record.commits) && record.commits.length > 0) {
507
+ commits = record.commits.filter((c) => typeof c === 'string');
508
+ }
509
+ }
510
+ if (commits !== null || startCommit !== null)
511
+ return { commits: commits ?? [], startCommit, miss: null, notes };
512
+ return none(present ? PHASE_MANIFEST_READ_MISS.NO_COMMITS_RECORDED : PHASE_MANIFEST_READ_MISS.PHASE_ENTRY_MISSING, notes);
513
+ }
514
+ catch {
515
+ return none(PHASE_MANIFEST_READ_MISS.MANIFEST_NOT_FOUND);
516
+ }
517
+ }
518
+ module.exports = {
519
+ PHASE_MANIFEST_FILE,
520
+ PHASE_MANIFEST_WRITE_REASON,
521
+ PHASE_MANIFEST_READ_MISS,
522
+ COMMIT_SHA_SHAPE,
523
+ readPhaseManifestEntry,
524
+ recordPhaseStart,
525
+ recordPhaseCommits,
526
+ renderManifestResult,
527
+ };
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * A tier is a named preset over the existing gate booleans
6
6
  * `workflow.{research, pattern_mapper, plan_check, verifier,
7
- * nyquist_validation, code_review, post_planning_gaps, security_enforcement}`:
7
+ * nyquist_validation, code_review, post_planning_gaps, security_enforcement,
8
+ * vault_review}`:
8
9
  *
9
10
  * light — planner and executor only; every gate off.
10
11
  * standard — verifier and code review (with its fix loop) on, plan checker
@@ -14,6 +15,11 @@
14
15
  * bounce on, and the fresh-install verification slot
15
16
  * (`install_verification`).
16
17
  *
18
+ * `vault_review` is the agent practice review (`pp-vault-reviewer`): on only in
19
+ * heavy, and spawned only where the project's vault practices apply
20
+ * (`vaultReviewApplies`, src/vault.cts). The vault's load, trace and audit gates
21
+ * are NOT tier gates: they are cheap code checks and run in every tier.
22
+ *
17
23
  * ## Precedence (`resolvePhaseTier`)
18
24
  *
19
25
  * CLI `--tier` > the phase's ROADMAP entry `**Tier:**` field > project
@@ -32,7 +38,8 @@
32
38
  *
33
39
  * ## Proposal (`proposePhaseTier`)
34
40
  *
35
- * heavy — a plan touches a gate, init, redaction, config-precedence or
41
+ * heavy — a plan touches a gate (the vault's gate, verdict and redaction
42
+ * owners included), init, redaction, config-precedence or
36
43
  * hook-dispatch file (`HEAVY_PATH_RULES`), or — before any plan
37
44
  * exists — the requirement tags or ROADMAP text name one
38
45
  * (`HEAVY_TEXT_RULES`);
@@ -83,6 +90,7 @@ exports.TIER_GATE_KEYS = [
83
90
  'code_review',
84
91
  'post_planning_gaps',
85
92
  'security_enforcement',
93
+ 'vault_review',
86
94
  ];
87
95
  /**
88
96
  * The plan-checker loop's current maximum: plan-phase §12 "Revision Loop (Max
@@ -184,6 +192,11 @@ exports.HEAVY_PATH_RULES = Object.freeze([
184
192
  { category: 'gate', example: 'src/ui-safety-gate.cts', pattern: new RegExp(`${LIB}(?:[^/]+/)*[^/]*gate[^/]*\\.c?[jt]s$`) },
185
193
  { category: 'gate', example: 'src/check-command-router.cts', pattern: new RegExp(`${LIB}(?:check-command-router|prohibition-enforcement|gap-checker|plan-graph|dispatch-plan)\\.c?[jt]s$`) },
186
194
  { category: 'gate', example: 'src/practices-review-check.cts', pattern: new RegExp(`${LIB}[^/]+-(?:check|guard)\\.c?[jt]s$`) },
195
+ // The vault's gate, verdict and redaction owners (`vault`, `vault-trace`,
196
+ // `vault-audit`, `vault-review`, `vault-practices`, …): a phase editing them
197
+ // changes what the load, trace and audit gates decide and what remote text is
198
+ // redacted, so it is proposed heavy.
199
+ { category: 'gate', example: 'src/vault.cts', pattern: new RegExp(`${LIB}vault[^/]*\\.c?[jt]s$`) },
187
200
  { category: 'init', example: 'src/init.cts', pattern: new RegExp(`${LIB}init[^/]*\\.c?[jt]s$`) },
188
201
  { category: 'redaction', example: 'src/observability/redaction.cts', pattern: /(?:^|\/)[^/]*(?:redact|secret)[^/]*$/i },
189
202
  { category: 'config-precedence', example: 'src/config-loader.cts', pattern: new RegExp(`${LIB}(?:config[^/]*|configuration|federated-config|capability-activation|phase-tier)\\.c?[jt]s$`) },
@@ -60,6 +60,10 @@ const plan_graph_cjs_1 = require("./plan-graph.cjs");
60
60
  const dispatch_plan_cjs_1 = require("./dispatch-plan.cjs");
61
61
  const phase_tier_cjs_1 = require("./phase-tier.cjs");
62
62
  const install_run_cjs_1 = require("./install-run.cjs");
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- loop-resolver.cjs is an export= CommonJS module
64
+ const loopResolver = require("./loop-resolver.cjs");
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-manifest.cjs is an export= CommonJS module
66
+ const phaseManifest = require("./phase-manifest.cjs");
63
67
  const { planningDir, withPlanningLock, milestonePathsFor } = planningWorkspace;
64
68
  const { extractFrontmatter } = frontmatterMod;
65
69
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
@@ -452,7 +456,22 @@ function cmdPhaseDispatchPlan(cwd, args, raw) {
452
456
  });
453
457
  const phaseDirRel = node_path_1.default.relative(cwd, built.phaseDir).split(node_path_1.default.sep).join('/');
454
458
  const tier = (0, phase_tier_cjs_1.resolvePhaseTierForProject)(cwd, parsed.phase, parsed.tier);
455
- output((0, dispatch_plan_cjs_1.computeDispatchPlan)(built.result['phase'], phaseDirRel, plans, policy, tier, { planBounceScript: tier.plan_bounce_script }), raw);
459
+ // The executor brief: active execute:pre `into: executor` contributions,
460
+ // resolved by the loop resolver under this phase's tier. An unreadable config
461
+ // refuses here exactly as `loop render-hooks` does — a brief computed from
462
+ // swallowed defaults would silently drop a capability's executor contract.
463
+ const hooks = loopResolver.resolveProjectLoopHooks(cwd, 'execute:pre', { phaseTier: tier, what: 'phase.dispatch-plan' });
464
+ if (!hooks.ok) {
465
+ error(hooks.message, hooks.reason === 'config_unreadable' ? ERROR_REASON.CONFIG_PARSE_FAILED : undefined);
466
+ return;
467
+ }
468
+ const executorContributions = loopResolver.contributionsInto(hooks.resolved, 'executor');
469
+ // The agent practice review's one applicability predicate (src/vault.cts),
470
+ // required lazily: only this command needs the vault module here.
471
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
472
+ const vaultMod = require('./vault.cjs');
473
+ const vaultReview = vaultMod.vaultReviewApplies(cwd);
474
+ output((0, dispatch_plan_cjs_1.computeDispatchPlan)(built.result['phase'], phaseDirRel, plans, policy, tier, { planBounceScript: tier.plan_bounce_script, vaultReview, executorContributions }), raw);
456
475
  }
457
476
  /**
458
477
  * `phase.install-run <phase> [--tarball <tgz>] [--package-dir <dir>] [--no-build]`:
@@ -479,6 +498,36 @@ function cmdPhaseInstallRun(cwd, args, raw) {
479
498
  * `phase.tier <phase> [--tier light|standard|heavy]`: the phase's resolved
480
499
  * ceremony tier — tier, source, gate set, suppressed gates (src/phase-tier.cts).
481
500
  */
501
+ /**
502
+ * `phase.manifest start|record <phase>`: write `.phase-manifest.json`, the
503
+ * file `/pp-undo --phase` reads (src/phase-manifest.cts). `start` anchors the
504
+ * phase at HEAD (write-once); `record` lists `<startCommit>..HEAD`. Always
505
+ * exits 0: a refusal is reported (`ok: false`, `reason`) and the caller
506
+ * continues — `/pp-undo` then falls back to its git-log search.
507
+ */
508
+ function cmdPhaseManifest(cwd, args, raw) {
509
+ const usage = 'Usage: phase manifest start|record <phase>';
510
+ const positional = args.filter((a) => a !== '--raw');
511
+ const action = positional[0];
512
+ const phaseArg = positional[1];
513
+ if ((action !== 'start' && action !== 'record') || !phaseArg || positional.length > 2) {
514
+ error(usage, ERROR_REASON.USAGE);
515
+ return;
516
+ }
517
+ const built = buildPhasePlanIndex(cwd, phaseArg);
518
+ if (built.fatal !== undefined) {
519
+ error(built.fatal);
520
+ return;
521
+ }
522
+ if (built.phaseDir === null) {
523
+ const result = { ok: false, action, reason: phaseManifest.PHASE_MANIFEST_WRITE_REASON.PHASE_DIR_UNRESOLVABLE };
524
+ output(result, raw, phaseManifest.renderManifestResult(result));
525
+ return;
526
+ }
527
+ const opts = { root: cwd, phaseDir: built.phaseDir, cwd };
528
+ const result = action === 'start' ? phaseManifest.recordPhaseStart(opts) : phaseManifest.recordPhaseCommits(opts);
529
+ output(result, raw, phaseManifest.renderManifestResult(result));
530
+ }
482
531
  function cmdPhaseTier(cwd, args, raw) {
483
532
  const usage = 'Usage: phase tier <phase> [--tier light|standard|heavy]';
484
533
  const parsed = (0, phase_tier_cjs_1.parseTierFlag)(args.filter((a) => a !== '--raw'));
@@ -1661,6 +1710,7 @@ module.exports = {
1661
1710
  cmdPhaseInstallRun,
1662
1711
  cmdPhaseTier,
1663
1712
  cmdPhaseTierPropose,
1713
+ cmdPhaseManifest,
1664
1714
  cmdPhaseAdd,
1665
1715
  cmdPhaseAddBatch,
1666
1716
  cmdPhaseMvpMode,