@pareto4/pp-core 1.12.1 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/pp-code-reviewer.md +1 -1
- package/agents/pp-executor.md +2 -13
- package/agents/pp-planner.md +1 -1
- package/agents/pp-vault-reviewer.md +51 -0
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-vault-review.md +33 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/pp-core/bin/lib/capability-registry.cjs +200 -10
- package/pp-core/bin/lib/check-command-router.cjs +1242 -37
- package/pp-core/bin/lib/clusters.cjs +1 -0
- package/pp-core/bin/lib/command-aliases.cjs +7 -0
- package/pp-core/bin/lib/compose-bindings.cjs +41 -27
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/dispatch-plan.cjs +12 -1
- package/pp-core/bin/lib/frontmatter.cjs +37 -0
- package/pp-core/bin/lib/git-base-branch.cjs +136 -10
- package/pp-core/bin/lib/init-contract.cjs +180 -66
- package/pp-core/bin/lib/init-interactive.cjs +4 -20
- package/pp-core/bin/lib/loop-gates.cjs +213 -0
- package/pp-core/bin/lib/loop-resolver.cjs +107 -45
- package/pp-core/bin/lib/org-profile.cjs +218 -44
- package/pp-core/bin/lib/phase-command-router.cjs +7 -1
- package/pp-core/bin/lib/phase-manifest.cjs +527 -0
- package/pp-core/bin/lib/phase-tier.cjs +15 -2
- package/pp-core/bin/lib/phase.cjs +51 -1
- package/pp-core/bin/lib/vault-audit.cjs +2070 -0
- package/pp-core/bin/lib/vault-command-router.cjs +460 -1
- package/pp-core/bin/lib/vault-practices.cjs +1142 -104
- package/pp-core/bin/lib/vault-review.cjs +2359 -0
- package/pp-core/bin/lib/vault-trace.cjs +2831 -0
- package/pp-core/bin/lib/vault-update.cjs +266 -0
- package/pp-core/bin/lib/vault-write-back.cjs +19 -0
- package/pp-core/bin/lib/vault.cjs +277 -12
- package/pp-core/bin/pp-tools.cjs +30 -13
- package/pp-core/bin/shared/model-catalog.json +2 -1
- package/pp-core/references/executor-vault-trace.md +47 -0
- package/pp-core/references/loop-hook-dispatch.md +13 -2
- package/pp-core/references/planner-vault-practices.md +46 -1
- package/pp-core/references/planning-config.md +1 -1
- package/pp-core/templates/practices/README.md +140 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/add-tests.md +4 -0
- package/pp-core/workflows/audit-fix.md +2 -0
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -0
- package/pp-core/workflows/debug.md +4 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +9 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +2 -1
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +37 -0
- package/pp-core/workflows/discovery-sprint.md +1 -1
- package/pp-core/workflows/discovery-to-build.md +1 -1
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +1 -0
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +19 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +1 -1
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +10 -8
- package/pp-core/workflows/execute-phase.md +22 -5
- package/pp-core/workflows/execute-plan.md +4 -0
- package/pp-core/workflows/fast.md +4 -0
- package/pp-core/workflows/help/modes/full.md +3 -2
- package/pp-core/workflows/init.md +2 -2
- package/pp-core/workflows/new-project.md +1 -1
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +9 -0
- package/pp-core/workflows/plan-phase.md +7 -4
- package/pp-core/workflows/quick.md +4 -0
- package/pp-core/workflows/ship.md +5 -1
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/validate-phase.md +4 -0
- package/pp-core/workflows/vault-review.md +65 -0
- package/pp-core/workflows/verify-work.md +3 -0
- package/scripts/gen-capability-registry.cjs +11 -0
- package/scripts/lint-test-file-count.allowlist.json +10 -1
- package/scripts/prompt-injection-scan.sh +2 -0
|
@@ -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
|
|
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
|
-
|
|
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,
|