@mstar-harness/engine 3.2.6 → 3.3.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/dist/engine.js CHANGED
@@ -170,327 +170,365 @@ function isAtOrBelow(dir, root) {
170
170
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
171
171
  }
172
172
  // src/path.ts
173
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, realpathSync, statSync as statSync2 } from "node:fs";
173
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync2, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
174
174
  import { execFileSync } from "node:child_process";
175
- import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
176
- function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
177
- const start = resolve3(startDir);
178
- const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
179
- if (explicit)
180
- return resolve3(start, explicit);
181
- const boundary = resolve3(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
182
- const rc = loadMstarc(start, boundary);
183
- if (rc !== null && rc.config.harnessDir)
184
- return resolve3(rc.dir, rc.config.harnessDir);
185
- let dir = start;
186
- for (;; ) {
187
- if (!isAtOrBelow2(dir, boundary))
188
- return null;
189
- for (const candidate of [join3(dir, ".mstar"), join3(dir, ".agents"), join3(dir, ".plans"), join3(dir, "plans")]) {
190
- if (isDirectory(candidate))
191
- return candidate;
175
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute3, join as join8, relative as relative2, resolve as resolve5 } from "node:path";
176
+
177
+ // src/project.ts
178
+ import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "node:fs";
179
+ import { join as join7 } from "node:path";
180
+
181
+ // src/iteration.ts
182
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
183
+ import { join as join3 } from "node:path";
184
+ var COMPASS_STATUSES = ["active", "locked", "completed"];
185
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
186
+ var PLAN_STATUS_DONE = "Done";
187
+ var COMPASS_FILE = "delivery-compass.md";
188
+ var INDEX_README = "README.md";
189
+ var INDEX_HEADER = "| Iteration | Path | Description | Status |";
190
+ function typeName(value) {
191
+ if (value === null)
192
+ return "null";
193
+ if (Array.isArray(value))
194
+ return "array";
195
+ return typeof value;
196
+ }
197
+ function validateCompassShape(doc) {
198
+ const issues = [];
199
+ const expectString = (key, opts = {}) => {
200
+ const value = doc[key];
201
+ if (typeof value !== "string") {
202
+ issues.push({ path: [key], message: `expected string, received ${typeName(value)}` });
203
+ return;
192
204
  }
193
- if (dir === boundary)
194
- return null;
195
- const parent = dirname3(dir);
196
- if (parent === dir)
197
- return null;
198
- dir = parent;
205
+ if (opts.min !== undefined && value.length < opts.min) {
206
+ issues.push({ path: [key], message: `string must contain at least ${opts.min} character(s)` });
207
+ return;
208
+ }
209
+ if (opts.regex !== undefined && !opts.regex.test(value)) {
210
+ issues.push({ path: [key], message: `string must match ${opts.regex}` });
211
+ }
212
+ };
213
+ expectString("iteration_id", { min: 1 });
214
+ expectString("start_date", { regex: DATE_RE });
215
+ const status = doc.status;
216
+ if (typeof status !== "string" || !COMPASS_STATUSES.includes(status)) {
217
+ issues.push({
218
+ path: ["status"],
219
+ message: `expected one of ${COMPASS_STATUSES.map((s) => `'${s}'`).join(" | ")}, received ${typeName(status)}`
220
+ });
199
221
  }
200
- }
201
- function defaultWorkspaceRoot(startDir) {
202
- try {
203
- const cdup = execFileSync("git", ["rev-parse", "--show-cdup"], {
204
- cwd: startDir,
205
- encoding: "utf8",
206
- stdio: ["ignore", "pipe", "ignore"]
207
- }).trim();
208
- if (!cdup)
209
- return startDir;
210
- let boundary = startDir;
211
- for (const segment of cdup.split(/[\\/]/)) {
212
- if (segment && segment !== ".")
213
- boundary = dirname3(boundary);
222
+ expectString("iteration_base_branch", { min: 1 });
223
+ expectString("target_branch", { min: 1 });
224
+ const plans = doc.plans;
225
+ if (plans !== undefined) {
226
+ if (!Array.isArray(plans)) {
227
+ issues.push({ path: ["plans"], message: `expected array, received ${typeName(plans)}` });
228
+ } else {
229
+ plans.forEach((entry, index) => {
230
+ if (typeof entry !== "string") {
231
+ issues.push({ path: ["plans", index], message: `expected string, received ${typeName(entry)}` });
232
+ } else if (entry.length < 1) {
233
+ issues.push({ path: ["plans", index], message: "string must contain at least 1 character(s)" });
234
+ }
235
+ });
214
236
  }
215
- return resolve3(boundary);
216
- } catch {}
217
- return startDir;
237
+ }
238
+ const end_date = doc.end_date;
239
+ if (end_date !== undefined) {
240
+ if (typeof end_date !== "string") {
241
+ issues.push({ path: ["end_date"], message: `expected string, received ${typeName(end_date)}` });
242
+ } else if (!DATE_RE.test(end_date)) {
243
+ issues.push({ path: ["end_date"], message: `string must match ${DATE_RE}` });
244
+ }
245
+ }
246
+ if (issues.length > 0)
247
+ return { ok: false, issues };
248
+ return {
249
+ ok: true,
250
+ data: {
251
+ iteration_id: doc.iteration_id,
252
+ start_date: doc.start_date,
253
+ status,
254
+ iteration_base_branch: doc.iteration_base_branch,
255
+ target_branch: doc.target_branch,
256
+ ...plans !== undefined ? { plans } : {},
257
+ ...end_date !== undefined ? { end_date } : {}
258
+ }
259
+ };
218
260
  }
219
- function isAtOrBelow2(dir, root) {
220
- const rel = relative2(root, dir);
221
- return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
261
+ function violation(severity, code, message, fix) {
262
+ return { ok: false, severity, code, message, fix };
222
263
  }
223
- function mstarcDirOverride(harnessDir, key) {
224
- const dir = resolve3(harnessDir);
225
- const rc = loadMstarc(dir, dirname3(dir));
226
- const declared = rc?.config[key];
227
- return declared ? resolve3(rc.dir, declared) : null;
264
+ function isPlainObject(value) {
265
+ return typeof value === "object" && value !== null && !Array.isArray(value);
228
266
  }
229
- function resolveSpecsDir(harnessDir, opts = {}) {
230
- const declared = mstarcDirOverride(harnessDir, "specsDir");
231
- if (declared !== null) {
232
- if (opts.create !== false)
233
- mkdirSync2(declared, { recursive: true });
234
- return declared;
267
+ function validateCompassFrontmatter(doc) {
268
+ if (!isPlainObject(doc)) {
269
+ return {
270
+ ok: false,
271
+ violations: [
272
+ violation("medium", "COMPASS_INVALID_FIELD", "Compass frontmatter must be a YAML object with iteration_id / start_date / status / iteration_base_branch / target_branch (template: mstar-iteration §1.3)", "Fix the frontmatter of {ITERATION_DIR}/<iteration-id>/delivery-compass.md")
273
+ ]
274
+ };
235
275
  }
236
- const harness = resolve3(harnessDir);
237
- const repoRoot = dirname3(harness);
238
- const candidates = [
239
- join3(harness, "specs"),
240
- join3(repoRoot, "docs", "specs"),
241
- join3(repoRoot, "specs"),
242
- join3(harness, "designs"),
243
- join3(repoRoot, "designs")
244
- ];
245
- for (const candidate of candidates) {
246
- if (isDirectory(candidate) && hasFiles(candidate))
247
- return candidate;
276
+ const parsed = validateCompassShape(doc);
277
+ if (!parsed.ok) {
278
+ return {
279
+ ok: false,
280
+ violations: parsed.issues.map((issue) => {
281
+ const field = issue.path.join(".") || "(root)";
282
+ return violation("medium", "COMPASS_INVALID_FIELD", `Compass frontmatter field '${field}' is invalid: ${issue.message}`, `Fix '${field}' in {ITERATION_DIR}/<iteration-id>/delivery-compass.md frontmatter (template: mstar-iteration §1.3)`);
283
+ })
284
+ };
248
285
  }
249
- const fallback = join3(harness, "specs");
250
- if (opts.create !== false)
251
- mkdirSync2(fallback, { recursive: true });
252
- return fallback;
286
+ const violations = [];
287
+ const { status, end_date } = parsed.data;
288
+ if (status === "completed" && end_date === undefined) {
289
+ violations.push(violation("high", "COMPASS_END_DATE_REQUIRED", "Compass frontmatter status is 'completed' but end_date is missing — end_date is required at iteration-close (mstar-iteration §3.4, template Fields guide)", "Add `end_date: YYYY-MM-DD` to the frontmatter"));
290
+ }
291
+ if (status !== "completed" && end_date !== undefined) {
292
+ violations.push(violation("medium", "COMPASS_END_DATE_NOT_ALLOWED", `Compass frontmatter sets end_date while status is '${status}' — end_date is only written at iteration-close (mstar-iteration §3.4)`, "Remove end_date until iteration-close"));
293
+ }
294
+ return { ok: violations.length === 0, violations };
253
295
  }
254
- function resolvePlanDir(harnessDir) {
255
- const declared = mstarcDirOverride(harnessDir, "planDir");
256
- if (declared !== null)
257
- return declared;
258
- const dir = resolve3(harnessDir);
259
- const name = basename2(dir);
260
- if (name === ".plans" || name === "plans")
261
- return dir;
262
- return join3(dir, "plans");
296
+ function registeredPlanIds(compassDoc) {
297
+ if (!Array.isArray(compassDoc.plans))
298
+ return [];
299
+ return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
263
300
  }
264
- function assertSafePathComponent(value, what) {
265
- if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
266
- throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
301
+ function findPlanRow(snapshotDoc, planId) {
302
+ if (!Array.isArray(snapshotDoc.plans))
303
+ return null;
304
+ for (const row of snapshotDoc.plans) {
305
+ if (!isPlainObject(row))
306
+ continue;
307
+ const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
308
+ if (rowId === planId)
309
+ return row;
267
310
  }
311
+ return null;
268
312
  }
269
- function resolveSddDir(harnessDir, planId) {
270
- assertSafePathComponent(planId, "planId");
271
- const base = resolve3(harnessDir);
272
- const declared = mstarcDirOverride(base, "sddDir");
273
- const sddBase = declared !== null ? declared : join3(base, "sdd");
274
- return join3(sddBase, planId);
275
- }
276
- function resolveIterationDir(harnessDir) {
277
- const declared = mstarcDirOverride(harnessDir, "iterationDir");
278
- if (declared !== null)
279
- return declared;
280
- return join3(resolve3(harnessDir), "iterations");
313
+ function entryPlansAllDone(snapshotDoc, registered) {
314
+ const violations = [];
315
+ if (registered.length === 0) {
316
+ violations.push(violation("medium", "COMPASS_NO_PLANS", "Compass frontmatter registers no plans — the all-plans-Done transition cannot be verified (mstar-iteration §1.3 / Phase transition gates)", "List the iteration's plan ids in the compass frontmatter `plans`"));
317
+ return violations;
318
+ }
319
+ for (const planId of registered) {
320
+ const row = findPlanRow(snapshotDoc, planId);
321
+ if (row === null) {
322
+ violations.push(violation("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in the workflow snapshot plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/workflows/<id>/snapshot.json"));
323
+ continue;
324
+ }
325
+ if (row.status !== PLAN_STATUS_DONE) {
326
+ violations.push(violation("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in the workflow snapshot — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
327
+ }
328
+ }
329
+ return violations;
281
330
  }
282
- function resolveKnowledgeDir(harnessDir) {
283
- const declared = mstarcDirOverride(harnessDir, "knowledgeDir");
284
- if (declared !== null)
285
- return declared;
286
- return join3(resolve3(harnessDir), "knowledge");
331
+ function entryFrontmatterComplete(compassDoc) {
332
+ return validateCompassFrontmatter(compassDoc).violations;
287
333
  }
288
- function resolveHarnessSubdir(startDir, opts, key, fallback) {
289
- const harness = resolveHarnessDir(startDir, opts);
290
- if (harness === null) {
291
- throw new Error(`harness dir not found from ${resolve3(startDir)} cannot resolve the ${fallback} dir (run \`mstar init\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
334
+ function exitFrontmatterClosed(compassDoc) {
335
+ const violations = [];
336
+ if (compassDoc.status !== "completed") {
337
+ violations.push(violation("high", "EXIT_STATUS_NOT_COMPLETED", `Compass frontmatter status must be 'completed' at close exit current: ${JSON.stringify(compassDoc.status)} (mstar-iteration §3.4 / §3.5 exit item 4)`));
292
338
  }
293
- const declared = mstarcDirOverride(harness, key);
294
- return declared !== null ? declared : join3(resolve3(harness), fallback);
295
- }
296
- function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
297
- return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
339
+ const endDate = compassDoc.end_date;
340
+ if (typeof endDate !== "string" || !DATE_RE.test(endDate)) {
341
+ violations.push(violation("high", "EXIT_END_DATE_REQUIRED", "Compass frontmatter end_date (YYYY-MM-DD) is required when closing (mstar-iteration §3.4 / §3.5 exit item 4)"));
342
+ }
343
+ return violations;
298
344
  }
299
- function resolveProjectDir(startDir = process.cwd(), opts = {}) {
300
- return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
345
+ function exitBranchCheck(opts) {
346
+ const violations = [];
347
+ const { currentBranch, specIntegrationBranch } = opts;
348
+ if (currentBranch === undefined || specIntegrationBranch === undefined) {
349
+ violations.push(violation("medium", "EXIT_BRANCH_UNVERIFIABLE", "Cannot verify the current branch is spec_integration_branch — missing currentBranch / specIntegrationBranch probe inputs (mstar-iteration §3.5 exit item 5)"));
350
+ } else if (currentBranch !== specIntegrationBranch) {
351
+ violations.push(violation("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
352
+ }
353
+ return violations;
301
354
  }
302
- var EMPTY_STATUS_TEMPLATE = {
303
- version: 2,
304
- updated_at: "1970-01-01",
305
- workflows: []
306
- };
307
- var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
308
- function scaffoldHarness(root) {
309
- const harnessDir = join3(resolve3(root), ".mstar");
310
- for (const dir of SCAFFOLD_DIRS)
311
- mkdirSync2(join3(harnessDir, dir), { recursive: true });
312
- const statusPath = join3(harnessDir, "status.json");
313
- if (Object.keys(readJson(statusPath)).length === 0)
314
- writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
315
- return harnessDir;
355
+ function exitPrBaseCheck(compassDoc, opts) {
356
+ const violations = [];
357
+ const target = compassDoc.target_branch;
358
+ const { prBaseBranch } = opts;
359
+ if (prBaseBranch === undefined) {
360
+ violations.push(violation("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
361
+ } else if (typeof target !== "string" || prBaseBranch !== target) {
362
+ violations.push(violation("high", "EXIT_PR_BASE_MISMATCH", `PR base '${prBaseBranch}' must equal the compass target_branch '${String(target)}' — not an undocumented branch (mstar-iteration §3.5 exit item 6)`));
363
+ }
364
+ return violations;
316
365
  }
317
- var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
318
- # Principle: process stays local; results are shared with the team.
319
- # Default-ignore everything under .mstar/, then re-include the tracked results.
320
- .mstar/**
321
- !.mstar/AGENTS.md
322
- !.mstar/knowledge/
323
- !.mstar/knowledge/**
324
- !.mstar/specs/
325
- !.mstar/specs/**
326
- # .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
327
- .mstarc
328
- `;
329
- var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
330
- # Default-ignore everything under .agents/, then re-include the tracked results.
331
- .agents/**
332
- !.agents/AGENTS.md
333
- !.agents/knowledge/
334
- !.agents/knowledge/**
335
- !.agents/specs/
336
- !.agents/specs/**
337
- `;
338
- var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
339
- `).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
340
- var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
341
- `).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
342
- function emitGitignoreSnippet(kind) {
343
- if (kind === "agents")
344
- return GITIGNORE_SNIPPET_AGENTS;
345
- if (kind === "mstar")
346
- return GITIGNORE_SNIPPET;
347
- return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
348
- }
349
- function validateGitignore(root) {
350
- const gitignorePath = join3(resolve3(root), ".gitignore");
351
- const kind = detectHarnessKind(resolveHarnessDir(root));
352
- let content;
353
- try {
354
- content = readFileSync3(gitignorePath, "utf8");
355
- } catch {
356
- return {
357
- ok: false,
358
- severity: "medium",
359
- code: "gitignore.missing",
360
- message: `no .gitignore found at ${gitignorePath}`,
361
- fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
362
- };
363
- }
364
- const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0));
365
- const mstarMissing = GITIGNORE_PROCESS_ENTRIES.filter((entry) => !lines.has(entry));
366
- const agentsMissing = GITIGNORE_PROCESS_ENTRIES_AGENTS.filter((entry) => !lines.has(entry));
367
- let missing;
368
- let label;
369
- if (kind === "agents") {
370
- missing = agentsMissing;
371
- label = ".agents/ set";
372
- } else if (kind === "mstar") {
373
- missing = mstarMissing;
374
- label = ".mstar/ set";
375
- } else {
376
- label = "either .mstar/ or .agents/ set";
377
- missing = mstarMissing.length === 0 || agentsMissing.length === 0 ? [] : mstarMissing.length <= agentsMissing.length ? mstarMissing : agentsMissing;
378
- }
379
- if (missing.length > 0) {
380
- return {
381
- ok: false,
382
- severity: "medium",
383
- code: "gitignore.missing-entries",
384
- message: `.gitignore at ${gitignorePath} is missing canonical harness ignore entries (${label}): ${missing.join(", ")}`,
385
- fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
386
- };
387
- }
366
+ function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
367
+ const registered = registeredPlanIds(compassDoc);
368
+ const entryViolations = [
369
+ ...entryPlansAllDone(snapshotDoc, registered),
370
+ ...entryFrontmatterComplete(compassDoc)
371
+ ];
372
+ const exitViolations = [
373
+ ...exitFrontmatterClosed(compassDoc),
374
+ ...exitBranchCheck(opts),
375
+ ...exitPrBaseCheck(compassDoc, opts)
376
+ ];
377
+ const allPlansDone = registered.length > 0 && registered.every((planId) => {
378
+ const row = findPlanRow(snapshotDoc, planId);
379
+ return row !== null && row.status === PLAN_STATUS_DONE;
380
+ });
381
+ const entry = { ok: entryViolations.length === 0, violations: entryViolations };
382
+ const exit = { ok: exitViolations.length === 0, violations: exitViolations };
383
+ let transition;
384
+ if (!allPlansDone)
385
+ transition = "phase-2-execute";
386
+ else if (entry.ok && exit.ok)
387
+ transition = "phase-4-pr-delivery";
388
+ else
389
+ transition = "phase-3-close";
390
+ const gateBlocking = allPlansDone ? [...entryViolations, ...exitViolations] : [];
388
391
  return {
389
- ok: true,
390
- severity: "low",
391
- code: "gitignore.ok",
392
- message: `.gitignore at ${gitignorePath} contains a complete canonical harness ignore set — default-ignore + tracked re-includes (${label})`
392
+ transition,
393
+ allPlansDone,
394
+ entry,
395
+ exit,
396
+ ok: gateBlocking.length === 0,
397
+ violations: gateBlocking
393
398
  };
394
399
  }
395
- function detectHarnessKind(harnessDir) {
396
- if (!harnessDir)
397
- return null;
398
- const name = basename2(resolve3(harnessDir));
399
- if (name === ".mstar")
400
- return "mstar";
401
- if (name === ".agents")
402
- return "agents";
403
- return null;
400
+ function pushCadenceProbe(ciRunning, reviewWaveActive) {
401
+ const violations = [];
402
+ if (ciRunning) {
403
+ violations.push(violation("high", "PUSH_BLOCKED_CI", "CI checks are still queued/in_progress on the current head — do not push until the wave completes (mstar-iteration §5.1a push gate 1)", "Wait for CI to settle, then push once with the whole local batch"));
404
+ }
405
+ if (reviewWaveActive) {
406
+ violations.push(violation("high", "PUSH_BLOCKED_REVIEW_WAVE", "An AI/bot review wave is still running on the current head — do not push until it settles (mstar-iteration §5.1a push gate 2)", "Wait for the review wave, then push once"));
407
+ }
408
+ return { ok: violations.length === 0, violations };
404
409
  }
405
- function assertPlanWritingPath(planPath, harnessDir) {
406
- const planAbs = resolve3(planPath);
407
- if (!harnessDir) {
410
+ function assertIndexRowObligations(iterationsDir) {
411
+ if (!existsSync2(iterationsDir)) {
408
412
  return {
409
413
  ok: false,
410
- severity: "high",
411
- code: "plan-path.no-harness",
412
- message: `persistent plan tracking is not enabled — cannot place plan ${planAbs} under {PLAN_DIR}`,
413
- fix: "initialize the harness (scaffoldHarness) so plans land in {PLAN_DIR}"
414
+ violations: [
415
+ violation("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
416
+ ]
414
417
  };
415
418
  }
416
- const planDir = resolvePlanDir(harnessDir);
417
- const rel = relative2(planDir, planAbs);
418
- const inside = rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
419
- if (!inside) {
419
+ const iterationIds = readdirSync(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync2(join3(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
420
+ const readmePath = join3(iterationsDir, INDEX_README);
421
+ if (!existsSync2(readmePath)) {
420
422
  return {
421
423
  ok: false,
422
- severity: "high",
423
- code: "plan-path.outside-plan-dir",
424
- message: `plan file ${planAbs} is outside {PLAN_DIR} (${planDir})`,
425
- fix: `write the plan under ${planDir}`
424
+ violations: [
425
+ violation("high", "INDEX_README_MISSING", `{ITERATION_DIR}/README.md does not exist — one row per iteration is required (mstar-iteration §1.4)`, `Create {ITERATION_DIR}/README.md with the header '${INDEX_HEADER}' and one row per iteration`)
426
+ ]
426
427
  };
427
428
  }
428
- if (existsSync2(planAbs)) {
429
- try {
430
- const canonicalPlan = realpathSync(planAbs);
431
- const canonicalPlanDir = existsSync2(planDir) ? realpathSync(planDir) : resolve3(planDir);
432
- const canonicalRel = relative2(canonicalPlanDir, canonicalPlan);
433
- const canonicalInside = canonicalRel === "" || !canonicalRel.startsWith("..") && !isAbsolute2(canonicalRel);
434
- if (!canonicalInside) {
435
- return {
436
- ok: false,
437
- severity: "high",
438
- code: "plan-path.symlink-escape",
439
- message: `plan file ${planAbs} resolves to ${canonicalPlan}, outside {PLAN_DIR} (${canonicalPlanDir})`,
440
- fix: `write the plan under ${planDir}`
441
- };
442
- }
443
- } catch {}
429
+ const violations = [];
430
+ const lines = readFileSync3(readmePath, "utf8").split(/\r?\n/);
431
+ if (!lines.some((line) => line.includes(INDEX_HEADER))) {
432
+ violations.push(violation("medium", "INDEX_HEADER_MISSING", `{ITERATION_DIR}/README.md lacks the table header '${INDEX_HEADER}' (mstar-iteration §1.4)`, "Add the header row on first creation"));
444
433
  }
445
- return {
446
- ok: true,
447
- severity: "low",
448
- code: "plan-path.ok",
449
- message: `plan file ${planAbs} lives under {PLAN_DIR} (${planDir})`
450
- };
434
+ const indexed = new Set;
435
+ for (const line of lines) {
436
+ const match = line.match(/^\s*\|\s*`([^`]+)`\s*\|/);
437
+ if (match)
438
+ indexed.add(match[1].trim());
439
+ }
440
+ for (const id of iterationIds) {
441
+ if (!indexed.has(id)) {
442
+ violations.push(violation("medium", "INDEX_ROW_MISSING", `Iteration '${id}' has a delivery-compass.md but no index row in {ITERATION_DIR}/README.md — one row per iteration (mstar-iteration §1.4)`, `Add | \`${id}\` | [\`${id}/\`](${id}/) | <description> | <status> |`));
443
+ }
444
+ }
445
+ return { ok: violations.length === 0, violations };
451
446
  }
452
- function isDirectory(dir) {
453
- try {
454
- return statSync2(dir).isDirectory();
455
- } catch {
456
- return false;
447
+ function parseCompassFrontmatter(filePath) {
448
+ return parseCompassFrontmatterText(readFileSync3(filePath, "utf8"), filePath);
449
+ }
450
+ function parseCompassFrontmatterText(content, filePath) {
451
+ const lines = content.split(/\r?\n/);
452
+ if (lines[0]?.trim() !== "---") {
453
+ throw new Error(`no YAML frontmatter fence in ${filePath} (expected first line "---")`);
457
454
  }
455
+ const end = lines.indexOf("---", 1);
456
+ if (end === -1) {
457
+ throw new Error(`unterminated YAML frontmatter in ${filePath} (no closing "---")`);
458
+ }
459
+ const doc = {};
460
+ let listKey = null;
461
+ for (let i = 1;i < end; i += 1) {
462
+ const line = lines[i] ?? "";
463
+ if (!line.trim() || line.trim().startsWith("#"))
464
+ continue;
465
+ if (listKey !== null && /^\s*-\s+/.test(line)) {
466
+ const item = line.replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "");
467
+ if (!Array.isArray(doc[listKey]))
468
+ doc[listKey] = [];
469
+ doc[listKey].push(item);
470
+ continue;
471
+ }
472
+ listKey = null;
473
+ const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
474
+ if (!kv) {
475
+ throw new Error(`unsupported frontmatter line in ${filePath}: ${JSON.stringify(line)}`);
476
+ }
477
+ const value = kv[2].trim();
478
+ doc[kv[1]] = value === "" ? null : /^\[.*\]$/.test(value) ? parseFlowArray(value, filePath) : value.replace(/^["']|["']$/g, "");
479
+ listKey = value === "" ? kv[1] : null;
480
+ }
481
+ return doc;
458
482
  }
459
- function hasFiles(dir) {
460
- try {
461
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
462
- if (entry.isDirectory()) {
463
- if (hasFiles(join3(dir, entry.name)))
464
- return true;
465
- } else if (entry.isFile()) {
466
- return true;
467
- }
483
+ function parseFlowArray(raw, filePath) {
484
+ const inner = raw.slice(1, -1);
485
+ if (/[[\]]/.test(inner)) {
486
+ throw new Error(`nested flow-style array in ${filePath}: ${JSON.stringify(raw)} — only flat scalar items are supported (e.g. [a, b])`);
487
+ }
488
+ let quote = null;
489
+ for (const ch of inner) {
490
+ if (ch === '"' || ch === "'") {
491
+ if (quote === null)
492
+ quote = ch;
493
+ else if (quote === ch)
494
+ quote = null;
495
+ } else if (ch === "," && quote !== null) {
496
+ throw new Error(`ambiguous flow-style array in ${filePath}: ${JSON.stringify(raw)} — quoted item containing comma cannot be split unambiguously (flat scalar items only)`);
468
497
  }
469
- return false;
470
- } catch {
471
- return false;
472
498
  }
499
+ if (quote !== null) {
500
+ throw new Error(`unterminated ${quote} quote in flow-style array in ${filePath}: ${JSON.stringify(raw)}`);
501
+ }
502
+ const items = [];
503
+ for (const part of inner.split(",")) {
504
+ const item = part.trim().replace(/^["']|["']$/g, "");
505
+ if (item === "")
506
+ continue;
507
+ items.push(item);
508
+ }
509
+ return items;
473
510
  }
511
+
474
512
  // src/status.ts
475
- import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync as realpathSync2 } from "node:fs";
476
- import { dirname as dirname5, join as join6, resolve as resolve5, sep } from "node:path";
513
+ import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
514
+ import { dirname as dirname4, join as join6, resolve as resolve4, sep } from "node:path";
477
515
 
478
516
  // src/lease.ts
479
- import { mkdirSync as mkdirSync3, rmdirSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
480
- import { dirname as dirname4, isAbsolute as isAbsolute3, join as join4, resolve as resolve4 } from "node:path";
517
+ import { mkdirSync as mkdirSync2, rmdirSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
518
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join4, resolve as resolve3 } from "node:path";
481
519
  import { setTimeout as sleep } from "node:timers/promises";
482
520
  import { AsyncLocalStorage } from "node:async_hooks";
483
- function isPlainObject(value) {
521
+ function isPlainObject2(value) {
484
522
  return typeof value === "object" && value !== null && !Array.isArray(value);
485
523
  }
486
- function violation(severity, code, message, fix) {
524
+ function violation2(severity, code, message, fix) {
487
525
  return { ok: false, severity, code, message, fix };
488
526
  }
489
527
  function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
490
528
  if (value === undefined) {
491
- violations.push(violation("high", missingCode, `missing required field: ${field}`));
529
+ violations.push(violation2("high", missingCode, `missing required field: ${field}`));
492
530
  } else if (typeof value !== "string" || value.trim() === "") {
493
- violations.push(violation("medium", invalidCode, `${field} must be a non-empty string`));
531
+ violations.push(violation2("medium", invalidCode, `${field} must be a non-empty string`));
494
532
  }
495
533
  }
496
534
  var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
@@ -501,66 +539,66 @@ function isValidClaimedAt(value) {
501
539
  }
502
540
  function validateExecutionLease(lease) {
503
541
  const violations = [];
504
- if (!isPlainObject(lease)) {
542
+ if (!isPlainObject2(lease)) {
505
543
  return {
506
544
  ok: false,
507
545
  violations: [
508
- violation("high", "lease.execution-lease.invalid", "execution_lease must be an object — null and tombstone objects are invalid; writers delete the key on release")
546
+ violation2("high", "lease.execution-lease.invalid", "execution_lease must be an object — null and tombstone objects are invalid; writers delete the key on release")
509
547
  ]
510
548
  };
511
549
  }
512
550
  validateNonEmptyString(violations, lease.holder, "holder", "lease.execution-lease.missing-holder", "lease.execution-lease.invalid-holder");
513
551
  if (lease.claimed_at === undefined) {
514
- violations.push(violation("high", "lease.execution-lease.missing-claimed-at", "missing required field: claimed_at"));
552
+ violations.push(violation2("high", "lease.execution-lease.missing-claimed-at", "missing required field: claimed_at"));
515
553
  } else if (!isValidClaimedAt(lease.claimed_at)) {
516
- violations.push(violation("medium", "lease.execution-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T02:30:00Z) or a YYYY-MM-DD date"));
554
+ violations.push(violation2("medium", "lease.execution-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T02:30:00Z) or a YYYY-MM-DD date"));
517
555
  }
518
556
  if (lease.worktree_path === undefined) {
519
- violations.push(violation("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
557
+ violations.push(violation2("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
520
558
  } else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
521
- violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
522
- } else if (!isAbsolute3(lease.worktree_path)) {
523
- violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be an absolute path — it identifies the dedicated feature-worktree root (and MUST differ from metadata.control_worktree_path)"));
559
+ violations.push(violation2("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
560
+ } else if (!isAbsolute2(lease.worktree_path)) {
561
+ violations.push(violation2("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be an absolute path — it identifies the dedicated feature-worktree root (and MUST differ from metadata.control_worktree_path)"));
524
562
  }
525
563
  validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
526
564
  if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
527
- violations.push(violation("medium", "lease.execution-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
565
+ violations.push(violation2("medium", "lease.execution-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
528
566
  }
529
567
  return { ok: violations.length === 0, violations };
530
568
  }
531
569
  function validateIntegrationMergeLease(lease) {
532
570
  const violations = [];
533
- if (!isPlainObject(lease)) {
571
+ if (!isPlainObject2(lease)) {
534
572
  return {
535
573
  ok: false,
536
574
  violations: [
537
- violation("high", "lease.merge-lease.invalid", "integration_merge_lease must be an object — absent means unclaimed; null and tombstone objects are invalid; writers delete the key on release")
575
+ violation2("high", "lease.merge-lease.invalid", "integration_merge_lease must be an object — absent means unclaimed; null and tombstone objects are invalid; writers delete the key on release")
538
576
  ]
539
577
  };
540
578
  }
541
579
  validateNonEmptyString(violations, lease.holder, "holder", "lease.merge-lease.missing-holder", "lease.merge-lease.invalid-holder");
542
580
  if (lease.claimed_at === undefined) {
543
- violations.push(violation("high", "lease.merge-lease.missing-claimed-at", "missing required field: claimed_at"));
581
+ violations.push(violation2("high", "lease.merge-lease.missing-claimed-at", "missing required field: claimed_at"));
544
582
  } else if (!isValidClaimedAt(lease.claimed_at)) {
545
- violations.push(violation("medium", "lease.merge-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T04:00:00Z) or a YYYY-MM-DD date"));
583
+ violations.push(violation2("medium", "lease.merge-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T04:00:00Z) or a YYYY-MM-DD date"));
546
584
  }
547
585
  validateNonEmptyString(violations, lease.plan_id, "plan_id", "lease.merge-lease.missing-plan-id", "lease.merge-lease.invalid-plan-id");
548
586
  validateNonEmptyString(violations, lease.source_branch, "source_branch", "lease.merge-lease.missing-source-branch", "lease.merge-lease.invalid-source-branch");
549
587
  validateNonEmptyString(violations, lease.target_branch, "target_branch", "lease.merge-lease.missing-target-branch", "lease.merge-lease.invalid-target-branch");
550
588
  if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
551
- violations.push(violation("medium", "lease.merge-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
589
+ violations.push(violation2("medium", "lease.merge-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
552
590
  }
553
591
  return { ok: violations.length === 0, violations };
554
592
  }
555
593
  function claimLease(row, holder, fields) {
556
594
  const lease = row.execution_lease;
557
595
  if (lease !== undefined) {
558
- if (!isPlainObject(lease)) {
596
+ if (!isPlainObject2(lease)) {
559
597
  return {
560
598
  ok: false,
561
599
  row,
562
600
  violations: [
563
- violation("high", "lease.claim.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before claiming")
601
+ violation2("high", "lease.claim.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before claiming")
564
602
  ]
565
603
  };
566
604
  }
@@ -569,7 +607,7 @@ function claimLease(row, holder, fields) {
569
607
  ok: false,
570
608
  row,
571
609
  violations: [
572
- violation("high", "lease.claim.other-holder", `execution_lease held by ${JSON.stringify(lease.holder)} — no timestamp makes it stealable; Blocked unless the current-turn user explicitly overrides (then audit plans[].notes)`)
610
+ violation2("high", "lease.claim.other-holder", `execution_lease held by ${JSON.stringify(lease.holder)} — no timestamp makes it stealable; Blocked unless the current-turn user explicitly overrides (then audit plans[].notes)`)
573
611
  ]
574
612
  };
575
613
  }
@@ -578,7 +616,7 @@ function claimLease(row, holder, fields) {
578
616
  ok: false,
579
617
  row,
580
618
  violations: [
581
- violation("high", "lease.claim.verify-held-lease", `same holder but lease ${lease.worktree_path} @ ${lease.working_branch} does not match the Assignment ${fields.worktree_path} @ ${fields.working_branch} — verify-held-lease failed`)
619
+ violation2("high", "lease.claim.verify-held-lease", `same holder but lease ${lease.worktree_path} @ ${lease.working_branch} does not match the Assignment ${fields.worktree_path} @ ${fields.working_branch} — verify-held-lease failed`)
582
620
  ]
583
621
  };
584
622
  }
@@ -589,7 +627,7 @@ function claimLease(row, holder, fields) {
589
627
  ok: false,
590
628
  row,
591
629
  violations: [
592
- violation("high", "lease.claim.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery); do not invent a lease")
630
+ violation2("high", "lease.claim.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery); do not invent a lease")
593
631
  ]
594
632
  };
595
633
  }
@@ -598,7 +636,7 @@ function claimLease(row, holder, fields) {
598
636
  ok: false,
599
637
  row,
600
638
  violations: [
601
- violation("high", "lease.claim.status", `claim requires status Todo or Blocked (got ${JSON.stringify(row.status)}) — claim-before-InProgress contract`)
639
+ violation2("high", "lease.claim.status", `claim requires status Todo or Blocked (got ${JSON.stringify(row.status)}) — claim-before-InProgress contract`)
602
640
  ]
603
641
  };
604
642
  }
@@ -619,12 +657,12 @@ function releaseLease(row, holder) {
619
657
  if (row.execution_lease === undefined) {
620
658
  return { ok: true, row, outcome: "released", violations: [] };
621
659
  }
622
- if (!isPlainObject(row.execution_lease)) {
660
+ if (!isPlainObject2(row.execution_lease)) {
623
661
  return {
624
662
  ok: false,
625
663
  row,
626
664
  violations: [
627
- violation("high", "lease.release.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before releasing")
665
+ violation2("high", "lease.release.tombstone", "execution_lease must be an object — null and tombstone objects are invalid; resolve the corrupt state before releasing")
628
666
  ]
629
667
  };
630
668
  }
@@ -633,7 +671,7 @@ function releaseLease(row, holder) {
633
671
  ok: false,
634
672
  row,
635
673
  violations: [
636
- violation("high", "lease.release.other-holder", `execution_lease held by ${JSON.stringify(row.execution_lease.holder)} — release requires the same-session holder; a different holder must Blocked (no timestamp makes it stealable)`)
674
+ violation2("high", "lease.release.other-holder", `execution_lease held by ${JSON.stringify(row.execution_lease.holder)} — release requires the same-session holder; a different holder must Blocked (no timestamp makes it stealable)`)
637
675
  ]
638
676
  };
639
677
  }
@@ -641,10 +679,10 @@ function releaseLease(row, holder) {
641
679
  return { ok: true, row: rest, outcome: "released", violations: [] };
642
680
  }
643
681
  function sameHolderResume(lease, holder) {
644
- return isPlainObject(lease) && lease.holder === holder;
682
+ return isPlainObject2(lease) && lease.holder === holder;
645
683
  }
646
684
  function canSteal(lease, holder, opts = {}) {
647
- if (!isPlainObject(lease) || lease.holder === holder)
685
+ if (!isPlainObject2(lease) || lease.holder === holder)
648
686
  return false;
649
687
  return opts.userOverride === true;
650
688
  }
@@ -659,14 +697,14 @@ function verifyPlanExecutionLease(row, planId) {
659
697
  return {
660
698
  ok: false,
661
699
  violations: [
662
- violation("high", "lease.verify.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery)")
700
+ violation2("high", "lease.verify.orphan", "plan is InProgress without an execution_lease — orphan: STOP, no writable dispatch until recovery (status-and-residuals.md § Orphan recovery)")
663
701
  ]
664
702
  };
665
703
  }
666
704
  return {
667
705
  ok: false,
668
706
  violations: [
669
- violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease at the SSOT location plans[].execution_lease`)
707
+ violation2("high", "lease.verify.missing", `plan ${planId} has no execution_lease at the SSOT location plans[].execution_lease`)
670
708
  ]
671
709
  };
672
710
  }
@@ -678,7 +716,7 @@ var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
678
716
  var LOCKDIR_HOLDER_PID = "holder.pid";
679
717
  var heldLockDirs = new AsyncLocalStorage;
680
718
  async function withStatusWriteLock(statusPath, fn, opts = {}) {
681
- const lockDir = join4(dirname4(resolve4(statusPath)), STATUS_WRITE_LOCKDIR);
719
+ const lockDir = join4(dirname3(resolve3(statusPath)), STATUS_WRITE_LOCKDIR);
682
720
  const held = heldLockDirs.getStore();
683
721
  if (held !== undefined && held.has(lockDir)) {
684
722
  throw new Error(`${lockDir} is already held by this process in this async context — withStatusWriteLock is not reentrant; a nested acquisition on the same status.json is a bug`);
@@ -689,8 +727,8 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
689
727
  let acquired = null;
690
728
  for (;; ) {
691
729
  try {
692
- mkdirSync3(lockDir);
693
- const st = statSync3(lockDir);
730
+ mkdirSync2(lockDir);
731
+ const st = statSync2(lockDir);
694
732
  acquired = { dev: st.dev, ino: st.ino };
695
733
  break;
696
734
  } catch (error) {
@@ -712,7 +750,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
712
750
  } finally {
713
751
  owns.delete(lockDir);
714
752
  try {
715
- const current = statSync3(lockDir);
753
+ const current = statSync2(lockDir);
716
754
  if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
717
755
  try {
718
756
  unlinkSync2(join4(lockDir, LOCKDIR_HOLDER_PID));
@@ -730,7 +768,7 @@ var REQUIRED_FIELDS = [
730
768
  { key: "delegation", label: "Delegation", code: "delegation" },
731
769
  { key: "taskCategory", label: "Task category", code: "task-category" }
732
770
  ];
733
- function violation2(severity, code, message, fix) {
771
+ function violation3(severity, code, message, fix) {
734
772
  return { ok: false, severity, code, message, fix };
735
773
  }
736
774
  function parseAssignmentFields(assignmentText) {
@@ -780,11 +818,11 @@ function parseEnforcementFlag(text) {
780
818
  }
781
819
  function requireField(violations, value, label, code) {
782
820
  if (value === undefined) {
783
- const v = violation2("high", `assignment.field.missing-${code}`, `missing required Assignment field: ${label}`, `add "**${label}**: <value>" to the Assignment`);
821
+ const v = violation3("high", `assignment.field.missing-${code}`, `missing required Assignment field: ${label}`, `add "**${label}**: <value>" to the Assignment`);
784
822
  v.aliases = [`assignment.presence.missing-${code}`];
785
823
  violations.push(v);
786
824
  } else if (value === "") {
787
- const v = violation2("high", `assignment.field.invalid-${code}`, `${label} must be non-empty`, `fill in "**${label}**: <value>"`);
825
+ const v = violation3("high", `assignment.field.invalid-${code}`, `${label} must be non-empty`, `fill in "**${label}**: <value>"`);
788
826
  v.aliases = [`assignment.presence.missing-${code}`];
789
827
  violations.push(v);
790
828
  }
@@ -843,20 +881,20 @@ function validateAssignmentFields(assignmentText, opts = {}) {
843
881
  const formCount = Number(workingPresent) + Number(policyPresent);
844
882
  const forms = parseAssignmentBranchForms(assignmentText);
845
883
  if (formCount === 0) {
846
- violations.push(violation2("high", "assignment.field.branch-missing", "writable assignment must contain exactly one branch form", `add exactly one of: ${BRANCH_FORMS_HINT}`));
884
+ violations.push(violation3("high", "assignment.field.branch-missing", "writable assignment must contain exactly one branch form", `add exactly one of: ${BRANCH_FORMS_HINT}`));
847
885
  } else if (formCount > 1) {
848
- violations.push(violation2("high", "assignment.field.branch-multiple", `writable assignment contains ${formCount} branch forms (Working branch + Branch policy) — exactly one required`, `keep exactly one of: ${BRANCH_FORMS_HINT}`));
886
+ violations.push(violation3("high", "assignment.field.branch-multiple", `writable assignment contains ${formCount} branch forms (Working branch + Branch policy) — exactly one required`, `keep exactly one of: ${BRANCH_FORMS_HINT}`));
849
887
  } else if (workingPresent) {
850
888
  const create = forms.createForm;
851
889
  if (create !== undefined && (create.base === undefined || create.base.trim() === "" || create.name.trim() === "")) {
852
- violations.push(violation2("high", "assignment.field.branch-missing-base", `create-form Working branch is incomplete: "${fields.workingBranch}" (expected "create <new-branch> from <base>")`, "write both the new branch name and the ancestor branch after `from` (main / existing feature branch / remote-tracking branch / `current`)"));
890
+ violations.push(violation3("high", "assignment.field.branch-missing-base", `create-form Working branch is incomplete: "${fields.workingBranch}" (expected "create <new-branch> from <base>")`, "write both the new branch name and the ancestor branch after `from` (main / existing feature branch / remote-tracking branch / `current`)"));
853
891
  }
854
892
  } else if (policyPresent) {
855
893
  const direct = forms.directOn;
856
894
  if (direct === undefined) {
857
- violations.push(violation2("high", "assignment.field.branch-policy-missing-branch", `unparseable Branch policy: "${fields.branchPolicy}" (expected "direct on <branch> — <reason>")`, "start the field with `direct on <branch>`"));
895
+ violations.push(violation3("high", "assignment.field.branch-policy-missing-branch", `unparseable Branch policy: "${fields.branchPolicy}" (expected "direct on <branch> — <reason>")`, "start the field with `direct on <branch>`"));
858
896
  } else if (direct.reason === "") {
859
- violations.push(violation2("high", "assignment.field.branch-policy-missing-reason", `Branch policy "direct on ${direct.branch}" is missing the reason`, 'append "— <reason>" after the branch name'));
897
+ violations.push(violation3("high", "assignment.field.branch-policy-missing-reason", `Branch policy "direct on ${direct.branch}" is missing the reason`, 'append "— <reason>" after the branch name'));
860
898
  }
861
899
  }
862
900
  }
@@ -867,7 +905,7 @@ function assertDefaultBranchProtected(branch, opts = {}) {
867
905
  const violations = [];
868
906
  const normalized = branch.trim();
869
907
  if (normalized !== "" && defaultBranches.includes(normalized) && opts.directOnException !== true) {
870
- violations.push(violation2("high", "dispatch.default-branch.protected", `writable work on default protected branch "${normalized}" requires an explicit direct-on exception`, `add "Branch policy: direct on ${normalized} — <reason>" to the Assignment, or use a feature branch`));
908
+ violations.push(violation3("high", "dispatch.default-branch.protected", `writable work on default protected branch "${normalized}" requires an explicit direct-on exception`, `add "Branch policy: direct on ${normalized} — <reason>" to the Assignment, or use a feature branch`));
871
909
  }
872
910
  return { ok: violations.length === 0, violations };
873
911
  }
@@ -876,7 +914,7 @@ function executionModeToN(executionMode, opts = {}) {
876
914
  const mode = executionMode.trim().toLowerCase().split(/\s+/)[0] ?? "";
877
915
  let n;
878
916
  if (mode === "") {
879
- violations.push(violation2("high", "dispatch.execution-mode.missing", "missing required Assignment field: Execution mode", 'add "**Execution mode**: sdd | inline | targeted"'));
917
+ violations.push(violation3("high", "dispatch.execution-mode.missing", "missing required Assignment field: Execution mode", 'add "**Execution mode**: sdd | inline | targeted"'));
880
918
  } else if (mode === "sdd") {
881
919
  n = 3;
882
920
  } else if (mode === "inline") {
@@ -884,14 +922,14 @@ function executionModeToN(executionMode, opts = {}) {
884
922
  } else if (mode === "targeted") {
885
923
  const seats = [...new Set((opts.seats ?? []).map((s) => s.trim()).filter((s) => s !== ""))];
886
924
  if (seats.length === 0) {
887
- violations.push(violation2("high", "dispatch.execution-mode.missing-seats", 'execution mode "targeted" requires listed reviewer seats', 'add "QC re-review: targeted — reviewers: <role-id>, …" to the Assignment and pass the seats'));
925
+ violations.push(violation3("high", "dispatch.execution-mode.missing-seats", 'execution mode "targeted" requires listed reviewer seats', 'add "QC re-review: targeted — reviewers: <role-id>, …" to the Assignment and pass the seats'));
888
926
  } else if (seats.length > 3) {
889
- violations.push(violation2("high", "dispatch.execution-mode.too-many-seats", `execution mode "targeted" lists ${seats.length} reviewer seats — at most 3 (targeted re-review seats are the tri seats, N = 1–3)`, "list at most three reviewer seats for the targeted re-review"));
927
+ violations.push(violation3("high", "dispatch.execution-mode.too-many-seats", `execution mode "targeted" lists ${seats.length} reviewer seats — at most 3 (targeted re-review seats are the tri seats, N = 1–3)`, "list at most three reviewer seats for the targeted re-review"));
890
928
  } else {
891
929
  n = seats.length;
892
930
  }
893
931
  } else {
894
- violations.push(violation2("high", "dispatch.execution-mode.unknown", `unknown execution mode "${executionMode.trim()}" (expected sdd | inline | targeted)`, "fix the Execution mode field"));
932
+ violations.push(violation3("high", "dispatch.execution-mode.unknown", `unknown execution mode "${executionMode.trim()}" (expected sdd | inline | targeted)`, "fix the Execution mode field"));
895
933
  }
896
934
  return n === undefined ? { ok: false, violations } : { ok: true, violations, n };
897
935
  }
@@ -905,7 +943,7 @@ function assertTriIdentity(reviewerRoles) {
905
943
  return {
906
944
  ok: false,
907
945
  violations: [
908
- violation2("high", "dispatch.tri-identity.invalid", `tri-review initial wave must be exactly qc-specialist / qc-specialist-2 / qc-specialist-3, got: ${got}`, "dispatch qc-specialist, qc-specialist-2 and qc-specialist-3 for the initial wave")
946
+ violation3("high", "dispatch.tri-identity.invalid", `tri-review initial wave must be exactly qc-specialist / qc-specialist-2 / qc-specialist-3, got: ${got}`, "dispatch qc-specialist, qc-specialist-2 and qc-specialist-3 for the initial wave")
909
947
  ]
910
948
  };
911
949
  }
@@ -951,7 +989,7 @@ function antiRecursionPrecheck(subagentType, executeAs) {
951
989
  return {
952
990
  ok: false,
953
991
  violations: [
954
- violation2("critical", "dispatch.anti-recursion.empty-binding", `empty host role binding — the host cannot report which agent is calling, so anti-recursion cannot be proven (a dispatch could silently recurse)`, "set the host role-binding field (omp task entry `agent` / opencode `subagent` / cursor `subagent_type` / dsh `dispatchBinding`) before dispatching")
992
+ violation3("critical", "dispatch.anti-recursion.empty-binding", `empty host role binding — the host cannot report which agent is calling, so anti-recursion cannot be proven (a dispatch could silently recurse)`, "set the host role-binding field (omp task entry `agent` / opencode `subagent` / cursor `subagent_type` / dsh `dispatchBinding`) before dispatching")
955
993
  ]
956
994
  };
957
995
  }
@@ -959,7 +997,7 @@ function antiRecursionPrecheck(subagentType, executeAs) {
959
997
  return {
960
998
  ok: false,
961
999
  violations: [
962
- violation2("critical", "dispatch.anti-recursion.self-type", `recursive dispatch refused: role binding "${subagentType}" equals Execute as "${executeAs}" (leaf executors must not re-invoke their own role)`, "complete the work in this session, or return Blocked to project-manager")
1000
+ violation3("critical", "dispatch.anti-recursion.self-type", `recursive dispatch refused: role binding "${subagentType}" equals Execute as "${executeAs}" (leaf executors must not re-invoke their own role)`, "complete the work in this session, or return Blocked to project-manager")
963
1001
  ]
964
1002
  };
965
1003
  }
@@ -967,51 +1005,51 @@ function antiRecursionPrecheck(subagentType, executeAs) {
967
1005
  }
968
1006
 
969
1007
  // src/workflow.ts
970
- import { mkdirSync as mkdirSync4 } from "node:fs";
1008
+ import { mkdirSync as mkdirSync3 } from "node:fs";
971
1009
  import { join as join5 } from "node:path";
972
1010
  var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
973
1011
  var WORKFLOW_LIFECYCLE_STATUSES = ["running", "paused", "completed", "failed", "stopped"];
974
1012
  var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
975
1013
  var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
976
- function isPlainObject2(value) {
1014
+ function isPlainObject3(value) {
977
1015
  return typeof value === "object" && value !== null && !Array.isArray(value);
978
1016
  }
979
- function violation3(severity, code, message, fix) {
1017
+ function violation4(severity, code, message, fix) {
980
1018
  return { ok: false, severity, code, message, fix };
981
1019
  }
982
1020
  function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
983
1021
  if (value === undefined) {
984
- violations.push(violation3("high", missingCode, `missing required field: ${field}`));
1022
+ violations.push(violation4("high", missingCode, `missing required field: ${field}`));
985
1023
  } else if (typeof value !== "string" || value.trim() === "") {
986
- violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
1024
+ violations.push(violation4("medium", invalidCode, `${field} must be a non-empty string`));
987
1025
  }
988
1026
  }
989
1027
  function validateWorkflowSnapshot(doc) {
990
1028
  const violations = [];
991
- if (!isPlainObject2(doc)) {
1029
+ if (!isPlainObject3(doc)) {
992
1030
  return {
993
1031
  ok: false,
994
- violations: [violation3("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
1032
+ violations: [violation4("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
995
1033
  };
996
1034
  }
997
1035
  if (doc.schema_version === undefined) {
998
- violations.push(violation3("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
1036
+ violations.push(violation4("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
999
1037
  } else if (doc.schema_version !== 1) {
1000
- violations.push(violation3("high", "workflow.snapshot.invalid-schema-version", `schema_version must be 1 — got ${JSON.stringify(doc.schema_version)} (version is reserved for the root file discriminator)`));
1038
+ violations.push(violation4("high", "workflow.snapshot.invalid-schema-version", `schema_version must be 1 — got ${JSON.stringify(doc.schema_version)} (version is reserved for the root file discriminator)`));
1001
1039
  }
1002
1040
  if (doc.version !== undefined) {
1003
- violations.push(violation3("medium", "workflow.snapshot.reserved-version", `top-level version is reserved for the root status.json discriminator — snapshots use schema_version; remove the version key (got ${JSON.stringify(doc.version)})`, "remove the version key from the snapshot"));
1041
+ violations.push(violation4("medium", "workflow.snapshot.reserved-version", `top-level version is reserved for the root status.json discriminator — snapshots use schema_version; remove the version key (got ${JSON.stringify(doc.version)})`, "remove the version key from the snapshot"));
1004
1042
  }
1005
1043
  validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
1006
1044
  if (doc.type === undefined) {
1007
- violations.push(violation3("high", "workflow.snapshot.missing-type", "missing required field: type"));
1045
+ violations.push(violation4("high", "workflow.snapshot.missing-type", "missing required field: type"));
1008
1046
  } else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
1009
- violations.push(violation3("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
1047
+ violations.push(violation4("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
1010
1048
  }
1011
1049
  if (doc.status === undefined) {
1012
- violations.push(violation3("high", "workflow.snapshot.missing-status", "missing required field: status"));
1050
+ violations.push(violation4("high", "workflow.snapshot.missing-status", "missing required field: status"));
1013
1051
  } else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
1014
- violations.push(violation3("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
1052
+ violations.push(violation4("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
1015
1053
  }
1016
1054
  validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
1017
1055
  validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
@@ -1019,35 +1057,35 @@ function validateWorkflowSnapshot(doc) {
1019
1057
  validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
1020
1058
  }
1021
1059
  if (doc.phase !== undefined && typeof doc.phase !== "string") {
1022
- violations.push(violation3("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
1060
+ violations.push(violation4("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
1023
1061
  }
1024
1062
  if (doc.plans === undefined) {
1025
- violations.push(violation3("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
1063
+ violations.push(violation4("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
1026
1064
  } else if (!Array.isArray(doc.plans)) {
1027
- violations.push(violation3("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
1065
+ violations.push(violation4("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
1028
1066
  } else {
1029
1067
  for (const row of doc.plans) {
1030
1068
  violations.push(...validatePlanRow(row).violations);
1031
- if (isPlainObject2(row) && row.execution_lease !== undefined) {
1069
+ if (isPlainObject3(row) && row.execution_lease !== undefined) {
1032
1070
  violations.push(...validateExecutionLease(row.execution_lease).violations);
1033
1071
  }
1034
1072
  }
1035
1073
  }
1036
1074
  if (doc.execution_policy !== undefined) {
1037
- if (!isPlainObject2(doc.execution_policy)) {
1038
- violations.push(violation3("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
1075
+ if (!isPlainObject3(doc.execution_policy)) {
1076
+ violations.push(violation4("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
1039
1077
  }
1040
1078
  }
1041
1079
  if (doc.integration_merge_lease !== undefined) {
1042
1080
  violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
1043
1081
  }
1044
1082
  if (doc.branch !== undefined) {
1045
- if (!isPlainObject2(doc.branch)) {
1046
- violations.push(violation3("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
1083
+ if (!isPlainObject3(doc.branch)) {
1084
+ violations.push(violation4("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
1047
1085
  } else {
1048
1086
  for (const key of ["base", "integration", "target"]) {
1049
1087
  if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
1050
- violations.push(violation3("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
1088
+ violations.push(violation4("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
1051
1089
  }
1052
1090
  }
1053
1091
  }
@@ -1055,8 +1093,8 @@ function validateWorkflowSnapshot(doc) {
1055
1093
  if (doc.control_worktree_path !== undefined) {
1056
1094
  validateNonEmptyString2(violations, doc.control_worktree_path, "control_worktree_path", "workflow.snapshot.missing-control-worktree-path", "workflow.snapshot.invalid-control-worktree-path");
1057
1095
  }
1058
- if (doc.legacy_metadata !== undefined && !isPlainObject2(doc.legacy_metadata)) {
1059
- violations.push(violation3("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
1096
+ if (doc.legacy_metadata !== undefined && !isPlainObject3(doc.legacy_metadata)) {
1097
+ violations.push(violation4("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
1060
1098
  }
1061
1099
  if (doc.compass_ref !== undefined) {
1062
1100
  validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
@@ -1064,17 +1102,17 @@ function validateWorkflowSnapshot(doc) {
1064
1102
  const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
1065
1103
  if (terminal) {
1066
1104
  if (doc.ended_at === undefined) {
1067
- violations.push(violation3("high", "workflow.snapshot.missing-ended-at", `terminal status ${JSON.stringify(doc.status)} requires ended_at — a terminal snapshot must record when the lifecycle ended`));
1105
+ violations.push(violation4("high", "workflow.snapshot.missing-ended-at", `terminal status ${JSON.stringify(doc.status)} requires ended_at — a terminal snapshot must record when the lifecycle ended`));
1068
1106
  }
1069
1107
  if (Array.isArray(doc.plans)) {
1070
1108
  for (const row of doc.plans) {
1071
- if (isPlainObject2(row) && row.execution_lease !== undefined) {
1072
- violations.push(violation3("high", "workflow.snapshot.terminal-dangling-execution-lease", `terminal snapshot must not carry a row execution_lease (dangling lease) — release every lease before the lifecycle ends`));
1109
+ if (isPlainObject3(row) && row.execution_lease !== undefined) {
1110
+ violations.push(violation4("high", "workflow.snapshot.terminal-dangling-execution-lease", `terminal snapshot must not carry a row execution_lease (dangling lease) — release every lease before the lifecycle ends`));
1073
1111
  }
1074
1112
  }
1075
1113
  }
1076
1114
  if (doc.integration_merge_lease !== undefined) {
1077
- violations.push(violation3("high", "workflow.snapshot.terminal-dangling-merge-lease", "terminal snapshot must not carry integration_merge_lease (dangling lease) — release the merge lease before the lifecycle ends"));
1115
+ violations.push(violation4("high", "workflow.snapshot.terminal-dangling-merge-lease", "terminal snapshot must not carry integration_merge_lease (dangling lease) — release the merge lease before the lifecycle ends"));
1078
1116
  }
1079
1117
  }
1080
1118
  return { ok: violations.length === 0, violations };
@@ -1086,21 +1124,21 @@ async function writeWorkflowSnapshot(snapshot, dir) {
1086
1124
  throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
1087
1125
  }
1088
1126
  const snapshotPath = join5(dir, WORKFLOW_SNAPSHOT_FILE);
1089
- mkdirSync4(dir, { recursive: true });
1127
+ mkdirSync3(dir, { recursive: true });
1090
1128
  await withStatusWriteLock(snapshotPath, () => {
1091
1129
  writeJson(snapshotPath, snapshot);
1092
1130
  });
1093
1131
  }
1094
1132
 
1095
1133
  // src/status.ts
1096
- var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
1134
+ var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
1097
1135
  var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
1098
1136
  var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
1099
1137
  var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
1100
- function isPlainObject3(value) {
1138
+ function isPlainObject4(value) {
1101
1139
  return typeof value === "object" && value !== null && !Array.isArray(value);
1102
1140
  }
1103
- function violation4(severity, code, message, fix) {
1141
+ function violation5(severity, code, message, fix) {
1104
1142
  return { ok: false, severity, code, message, fix };
1105
1143
  }
1106
1144
  function todayString() {
@@ -1123,19 +1161,19 @@ function isOpenResidual(entry) {
1123
1161
  }
1124
1162
  function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
1125
1163
  if (value === undefined) {
1126
- violations.push(violation4("high", missingCode, `missing required field: ${field}`));
1164
+ violations.push(violation5("high", missingCode, `missing required field: ${field}`));
1127
1165
  } else if (typeof value !== "string" || value.trim() === "") {
1128
- violations.push(violation4("medium", invalidCode, `${field} must be a non-empty string`));
1166
+ violations.push(violation5("medium", invalidCode, `${field} must be a non-empty string`));
1129
1167
  }
1130
1168
  }
1131
1169
  function validatePlanRow(row) {
1132
1170
  const violations = [];
1133
- if (!isPlainObject3(row)) {
1134
- return { ok: false, violations: [violation4("high", "status.plan-row.invalid", "plan row must be an object")] };
1171
+ if (!isPlainObject4(row)) {
1172
+ return { ok: false, violations: [violation5("high", "status.plan-row.invalid", "plan row must be an object")] };
1135
1173
  }
1136
1174
  const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
1137
1175
  if (id === undefined && planId === undefined) {
1138
- violations.push(violation4("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
1176
+ violations.push(violation5("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
1139
1177
  } else {
1140
1178
  if (id !== undefined) {
1141
1179
  validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
@@ -1144,31 +1182,31 @@ function validatePlanRow(row) {
1144
1182
  validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
1145
1183
  }
1146
1184
  if (id !== undefined && planId !== undefined && id !== planId) {
1147
- violations.push(violation4("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
1185
+ violations.push(violation5("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
1148
1186
  }
1149
1187
  }
1150
1188
  validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
1151
1189
  validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
1152
1190
  if (status === undefined) {
1153
- violations.push(violation4("high", "status.plan-row.missing-status", "missing required field: status"));
1191
+ violations.push(violation5("high", "status.plan-row.missing-status", "missing required field: status"));
1154
1192
  } else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
1155
- violations.push(violation4("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
1193
+ violations.push(violation5("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
1156
1194
  }
1157
- if (metadata !== undefined && !isPlainObject3(metadata)) {
1158
- violations.push(violation4("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
1195
+ if (metadata !== undefined && !isPlainObject4(metadata)) {
1196
+ violations.push(violation5("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
1159
1197
  }
1160
- if (execution_lease !== undefined && !isPlainObject3(execution_lease)) {
1161
- violations.push(violation4("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
1198
+ if (execution_lease !== undefined && !isPlainObject4(execution_lease)) {
1199
+ violations.push(violation5("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
1162
1200
  }
1163
1201
  if (status === "Done" && execution_lease !== undefined) {
1164
- violations.push(violation4("medium", "status.plan-row.done-with-lease", 'plan status Done must not carry an execution_lease — the Done authority deletes the lease in the same complete-file update as status: "Done" (status-and-residuals.md § Hold, release, and override)', 'delete plans[].execution_lease in the same update that sets status: "Done"'));
1202
+ violations.push(violation5("medium", "status.plan-row.done-with-lease", 'plan status Done must not carry an execution_lease — the Done authority deletes the lease in the same complete-file update as status: "Done" (status-and-residuals.md § Hold, release, and override)', 'delete plans[].execution_lease in the same update that sets status: "Done"'));
1165
1203
  }
1166
1204
  return { ok: violations.length === 0, violations };
1167
1205
  }
1168
1206
  function validateResidual(entry) {
1169
1207
  const violations = [];
1170
- if (!isPlainObject3(entry)) {
1171
- return { ok: false, violations: [violation4("high", "status.residual.invalid", "residual entry must be an object")] };
1208
+ if (!isPlainObject4(entry)) {
1209
+ return { ok: false, violations: [violation5("high", "status.residual.invalid", "residual entry must be an object")] };
1172
1210
  }
1173
1211
  const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
1174
1212
  validateNonEmptyString3(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
@@ -1177,42 +1215,42 @@ function validateResidual(entry) {
1177
1215
  validateNonEmptyString3(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
1178
1216
  validateNonEmptyString3(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
1179
1217
  if (severity === undefined) {
1180
- violations.push(violation4("high", "status.residual.missing-severity", "missing required field: severity"));
1218
+ violations.push(violation5("high", "status.residual.missing-severity", "missing required field: severity"));
1181
1219
  } else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
1182
- violations.push(violation4("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
1220
+ violations.push(violation5("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
1183
1221
  } else if (severity === "warning") {
1184
- violations.push(violation4("low", "status.residual.legacy-warning", `severity "warning" is legacy — forbidden on new entries; read paths normalize it to "low"`, `use "low" (normalizeSeverity maps 'warning' → 'low')`));
1222
+ violations.push(violation5("low", "status.residual.legacy-warning", `severity "warning" is legacy — forbidden on new entries; read paths normalize it to "low"`, `use "low" (normalizeSeverity maps 'warning' → 'low')`));
1185
1223
  }
1186
1224
  if (decision === undefined) {
1187
- violations.push(violation4("high", "status.residual.missing-decision", "missing required field: decision"));
1225
+ violations.push(violation5("high", "status.residual.missing-decision", "missing required field: decision"));
1188
1226
  } else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
1189
- violations.push(violation4("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
1227
+ violations.push(violation5("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
1190
1228
  }
1191
1229
  if (target === undefined) {
1192
- violations.push(violation4("high", "status.residual.missing-target", "missing required field: target"));
1230
+ violations.push(violation5("high", "status.residual.missing-target", "missing required field: target"));
1193
1231
  } else if (typeof target !== "string" && target !== null) {
1194
- violations.push(violation4("medium", "status.residual.invalid-target", "target must be a string or null"));
1232
+ violations.push(violation5("medium", "status.residual.invalid-target", "target must be a string or null"));
1195
1233
  }
1196
1234
  if (tracking === undefined) {
1197
- violations.push(violation4("high", "status.residual.missing-tracking", "missing required field: tracking"));
1235
+ violations.push(violation5("high", "status.residual.missing-tracking", "missing required field: tracking"));
1198
1236
  } else if (typeof tracking !== "string" && tracking !== null) {
1199
- violations.push(violation4("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
1237
+ violations.push(violation5("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
1200
1238
  }
1201
1239
  if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
1202
- violations.push(violation4("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
1240
+ violations.push(violation5("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
1203
1241
  }
1204
- if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE.test(closed_at))) {
1205
- violations.push(violation4("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
1242
+ if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE2.test(closed_at))) {
1243
+ violations.push(violation5("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
1206
1244
  }
1207
1245
  if (lifecycle !== undefined) {
1208
1246
  if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
1209
- violations.push(violation4("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
1247
+ violations.push(violation5("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
1210
1248
  } else if (lifecycle !== "open") {
1211
1249
  if (closed_at === undefined) {
1212
- violations.push(violation4("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
1250
+ violations.push(violation5("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
1213
1251
  }
1214
1252
  if (entry.closure_note === undefined) {
1215
- violations.push(violation4("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
1253
+ violations.push(violation5("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
1216
1254
  }
1217
1255
  }
1218
1256
  }
@@ -1227,25 +1265,25 @@ function isHarnessRelativePath(dir) {
1227
1265
  }
1228
1266
  function validateWorkflowEntry(entry) {
1229
1267
  const violations = [];
1230
- if (!isPlainObject3(entry)) {
1268
+ if (!isPlainObject4(entry)) {
1231
1269
  return {
1232
1270
  ok: false,
1233
- violations: [violation4("high", "status.workflow.invalid", "workflow entry must be an object")]
1271
+ violations: [violation5("high", "status.workflow.invalid", "workflow entry must be an object")]
1234
1272
  };
1235
1273
  }
1236
1274
  validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
1237
1275
  if (entry.type === undefined) {
1238
- violations.push(violation4("high", "status.workflow.missing-type", "missing required field: type"));
1276
+ violations.push(violation5("high", "status.workflow.missing-type", "missing required field: type"));
1239
1277
  } else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
1240
- violations.push(violation4("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
1278
+ violations.push(violation5("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
1241
1279
  }
1242
1280
  validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
1243
1281
  if (entry.dir === undefined) {
1244
- violations.push(violation4("high", "status.workflow.missing-dir", "missing required field: dir"));
1282
+ violations.push(violation5("high", "status.workflow.missing-dir", "missing required field: dir"));
1245
1283
  } else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
1246
- violations.push(violation4("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
1284
+ violations.push(violation5("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
1247
1285
  } else if (!isHarnessRelativePath(entry.dir)) {
1248
- violations.push(violation4("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
1286
+ violations.push(violation5("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
1249
1287
  }
1250
1288
  return { ok: violations.length === 0, violations };
1251
1289
  }
@@ -1255,24 +1293,24 @@ function validateStatusV2(docOrPath, opts = {}) {
1255
1293
  if (typeof docOrPath === "string") {
1256
1294
  try {
1257
1295
  doc = readJson(docOrPath);
1258
- harnessDir = dirname5(resolve5(docOrPath));
1296
+ harnessDir = dirname4(resolve4(docOrPath));
1259
1297
  } catch (error) {
1260
1298
  return {
1261
1299
  ok: false,
1262
- violations: [violation4("high", "status.invalid-json", error.message)]
1300
+ violations: [violation5("high", "status.invalid-json", error.message)]
1263
1301
  };
1264
1302
  }
1265
1303
  } else {
1266
1304
  doc = docOrPath;
1267
1305
  }
1268
- if (!isPlainObject3(doc)) {
1269
- return { ok: false, violations: [violation4("high", "status.invalid-doc", "status document must be an object")] };
1306
+ if (!isPlainObject4(doc)) {
1307
+ return { ok: false, violations: [violation5("high", "status.invalid-doc", "status document must be an object")] };
1270
1308
  }
1271
1309
  if (doc.version !== 2) {
1272
1310
  return {
1273
1311
  ok: false,
1274
1312
  violations: [
1275
- violation4("high", "status.migration-required", `status.json schema version 2 required — got ${JSON.stringify(doc.version)} (v1 or unknown version); run \`mstar migrate\` to convert the tree`, "run `mstar migrate`")
1313
+ violation5("high", "status.migration-required", `status.json schema version 2 required — got ${JSON.stringify(doc.version)} (v1 or unknown version); run \`mstar migrate\` to convert the tree`, "run `mstar migrate`")
1276
1314
  ]
1277
1315
  };
1278
1316
  }
@@ -1280,7 +1318,7 @@ function validateStatusV2(docOrPath, opts = {}) {
1280
1318
  return {
1281
1319
  ok: false,
1282
1320
  violations: [
1283
- violation4("high", "status.migration-required", "v1-shaped status.json (root plans[]) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
1321
+ violation5("high", "status.migration-required", "v1-shaped status.json (root plans[]) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
1284
1322
  ]
1285
1323
  };
1286
1324
  }
@@ -1288,27 +1326,27 @@ function validateStatusV2(docOrPath, opts = {}) {
1288
1326
  return {
1289
1327
  ok: false,
1290
1328
  violations: [
1291
- violation4("high", "status.migration-required", "v1-shaped status.json (root residual_findings) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
1329
+ violation5("high", "status.migration-required", "v1-shaped status.json (root residual_findings) is not a v2 document — run `mstar migrate` to convert the tree", "run `mstar migrate`")
1292
1330
  ]
1293
1331
  };
1294
1332
  }
1295
1333
  const violations = [];
1296
1334
  if (doc.updated_at === undefined) {
1297
- violations.push(violation4("high", "status.missing-updated-at", "missing required field: updated_at"));
1298
- } else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
1299
- violations.push(violation4("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
1335
+ violations.push(violation5("high", "status.missing-updated-at", "missing required field: updated_at"));
1336
+ } else if (typeof doc.updated_at !== "string" || !DATE_RE2.test(doc.updated_at)) {
1337
+ violations.push(violation5("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
1300
1338
  }
1301
1339
  if (doc.workflows === undefined) {
1302
- violations.push(violation4("high", "status.missing-workflows", "missing required field: workflows"));
1340
+ violations.push(violation5("high", "status.missing-workflows", "missing required field: workflows"));
1303
1341
  } else if (!Array.isArray(doc.workflows)) {
1304
- violations.push(violation4("high", "status.invalid-workflows", "workflows must be an array"));
1342
+ violations.push(violation5("high", "status.invalid-workflows", "workflows must be an array"));
1305
1343
  } else {
1306
1344
  const seen = new Set;
1307
1345
  for (const entry of doc.workflows) {
1308
1346
  violations.push(...validateWorkflowEntry(entry).violations);
1309
- if (isPlainObject3(entry) && typeof entry.id === "string") {
1347
+ if (isPlainObject4(entry) && typeof entry.id === "string") {
1310
1348
  if (seen.has(entry.id)) {
1311
- violations.push(violation4("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
1349
+ violations.push(violation5("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
1312
1350
  }
1313
1351
  seen.add(entry.id);
1314
1352
  }
@@ -1317,40 +1355,40 @@ function validateStatusV2(docOrPath, opts = {}) {
1317
1355
  if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
1318
1356
  let realHarnessDir = null;
1319
1357
  try {
1320
- realHarnessDir = realpathSync2(harnessDir);
1358
+ realHarnessDir = realpathSync(harnessDir);
1321
1359
  } catch {}
1322
1360
  for (const entry of doc.workflows) {
1323
- if (!isPlainObject3(entry) || typeof entry.dir !== "string")
1361
+ if (!isPlainObject4(entry) || typeof entry.dir !== "string")
1324
1362
  continue;
1325
1363
  const relSnapshot = join6(entry.dir, WORKFLOW_SNAPSHOT_FILE);
1326
1364
  const snapshotPath = join6(harnessDir, relSnapshot);
1327
1365
  const label = typeof entry.id === "string" ? entry.id : relSnapshot;
1328
1366
  let physical;
1329
1367
  try {
1330
- physical = realpathSync2(snapshotPath);
1368
+ physical = realpathSync(snapshotPath);
1331
1369
  } catch {
1332
- violations.push(violation4("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
1370
+ violations.push(violation5("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
1333
1371
  continue;
1334
1372
  }
1335
1373
  if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
1336
- violations.push(violation4("high", "status.workflow.snapshot-outside-harness", `workflows[] lists ${JSON.stringify(label)} but its snapshot resolves outside the harness dir (${JSON.stringify(physical)}) — symlinked snapshot paths are rejected; the snapshot must physically live under ${JSON.stringify(harnessDir)}`));
1374
+ violations.push(violation5("high", "status.workflow.snapshot-outside-harness", `workflows[] lists ${JSON.stringify(label)} but its snapshot resolves outside the harness dir (${JSON.stringify(physical)}) — symlinked snapshot paths are rejected; the snapshot must physically live under ${JSON.stringify(harnessDir)}`));
1337
1375
  continue;
1338
1376
  }
1339
1377
  let snapshot;
1340
1378
  try {
1341
1379
  snapshot = readJson(snapshotPath);
1342
1380
  } catch (error) {
1343
- violations.push(violation4("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
1381
+ violations.push(violation5("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
1344
1382
  continue;
1345
1383
  }
1346
1384
  if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
1347
- violations.push(violation4("high", "status.workflow.terminal-listed", `workflows[] lists ${JSON.stringify(label)} whose snapshot status is terminal (${snapshot.status}) — removal-at-terminal: terminal writers unregister AFTER the snapshot write`));
1385
+ violations.push(violation5("high", "status.workflow.terminal-listed", `workflows[] lists ${JSON.stringify(label)} whose snapshot status is terminal (${snapshot.status}) — removal-at-terminal: terminal writers unregister AFTER the snapshot write`));
1348
1386
  }
1349
1387
  if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
1350
- violations.push(violation4("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
1388
+ violations.push(violation5("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
1351
1389
  }
1352
1390
  if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
1353
- violations.push(violation4("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — workflow ${JSON.stringify(label)} collided with another writer (e.g. a concurrent/re-run \`audit promote\` with the same workflow id rewrote the snapshot); the root entry mirrors the snapshot — align them or remove the colliding workflow`));
1391
+ violations.push(violation5("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — workflow ${JSON.stringify(label)} collided with another writer (e.g. a concurrent/re-run \`audit promote\` with the same workflow id rewrote the snapshot); the root entry mirrors the snapshot — align them or remove the colliding workflow`));
1354
1392
  }
1355
1393
  }
1356
1394
  }
@@ -1358,7 +1396,7 @@ function validateStatusV2(docOrPath, opts = {}) {
1358
1396
  }
1359
1397
  var validateStatus = validateStatusV2;
1360
1398
  function registerWorkflowEntryLocked(statusPath, entry) {
1361
- const harnessDir = dirname5(statusPath);
1399
+ const harnessDir = dirname4(statusPath);
1362
1400
  const current = readJson(statusPath);
1363
1401
  const fresh = Object.keys(current).length === 0;
1364
1402
  const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
@@ -1384,15 +1422,15 @@ async function registerWorkflow(root, entry) {
1384
1422
  if (!entryGate.ok) {
1385
1423
  throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
1386
1424
  }
1387
- const statusPath = resolve5(root);
1425
+ const statusPath = resolve4(root);
1388
1426
  return withStatusWriteLock(statusPath, () => registerWorkflowEntryLocked(statusPath, entry));
1389
1427
  }
1390
1428
  async function unregisterWorkflow(root, id) {
1391
1429
  if (typeof id !== "string" || id.trim() === "") {
1392
1430
  throw new Error("refusing to unregister workflow: id must be a non-empty string");
1393
1431
  }
1394
- const statusPath = resolve5(root);
1395
- const harnessDir = dirname5(statusPath);
1432
+ const statusPath = resolve4(root);
1433
+ const harnessDir = dirname4(statusPath);
1396
1434
  return withStatusWriteLock(statusPath, () => {
1397
1435
  const current = readJson(statusPath);
1398
1436
  if (Object.keys(current).length === 0) {
@@ -1449,8 +1487,8 @@ function resolveCompassEnforcement(harnessDir) {
1449
1487
  return { hard: false, source: "none" };
1450
1488
  }
1451
1489
  function resolveMstarcEnforcement(harnessDir) {
1452
- const dir = resolve5(harnessDir);
1453
- const rc = loadMstarc(dir, dirname5(dir));
1490
+ const dir = resolve4(harnessDir);
1491
+ const rc = loadMstarc(dir, dirname4(dir));
1454
1492
  const value = rc?.config.enforcement;
1455
1493
  if (value === "hard")
1456
1494
  return { hard: true, source: "mstarc" };
@@ -1464,1025 +1502,1030 @@ function resolveRepoEnforcement(harnessDir) {
1464
1502
  return rc;
1465
1503
  return resolveCompassEnforcement(harnessDir);
1466
1504
  }
1467
- // src/worktree.ts
1468
- import { execFileSync as execFileSync2 } from "node:child_process";
1469
- import { existsSync as existsSync4 } from "node:fs";
1470
- import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
1471
- var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
1472
- function probeTimeoutMs() {
1473
- const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
1474
- if (raw === undefined || raw.trim() === "")
1475
- return DEFAULT_PROBE_TIMEOUT_MS;
1476
- const parsed = Number(raw);
1477
- return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
1505
+
1506
+ // src/project.ts
1507
+ var PROJECT_ROADMAP_FILE = "roadmap.md";
1508
+ var PROJECT_REFERENCES_DIR = "references";
1509
+ var PROJECT_REGISTER_FILE = "residuals.json";
1510
+ var _DEFAULT_PROJECT = "_default";
1511
+ var ROADMAP_STATUSES = ["active", "paused", "completed"];
1512
+ var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
1513
+ var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
1514
+ function isPlainObject5(value) {
1515
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1478
1516
  }
1479
- function violation5(severity, code, message, fix) {
1517
+ function violation6(severity, code, message, fix) {
1480
1518
  return { ok: false, severity, code, message, fix };
1481
1519
  }
1482
- function gate(violations) {
1483
- return { ok: violations.length === 0, violations };
1484
- }
1485
- function probeBranch(worktreePath, opts) {
1486
- const precomputed = opts.branchOf?.(worktreePath);
1487
- if (precomputed !== undefined)
1488
- return { branch: precomputed };
1489
- const timeout = opts.timeoutMs ?? probeTimeoutMs();
1490
- try {
1491
- const stdout = execFileSync2(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
1492
- encoding: "utf8",
1493
- stdio: ["ignore", "pipe", "pipe"],
1494
- timeout
1495
- });
1496
- const branch = stdout.trim();
1497
- if (branch === "")
1498
- return { error: `no branch checked out (detached HEAD?) at "${worktreePath}"` };
1499
- return { branch };
1500
- } catch (err) {
1501
- const e = err;
1502
- if (e.killed === true || e.signal !== undefined) {
1503
- return { error: `git probe timed out after ${timeout}ms (killed by ${e.signal ?? "SIGTERM"})` };
1504
- }
1505
- const detail = (e.stderr !== undefined ? e.stderr.toString().trim() : "") || e.message || "git probe failed";
1506
- return { error: detail };
1520
+ function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
1521
+ if (value === undefined) {
1522
+ violations.push(violation6("high", missingCode, `missing required field: ${field}`));
1523
+ } else if (typeof value !== "string" || value.trim() === "") {
1524
+ violations.push(violation6("medium", invalidCode, `${field} must be a non-empty string`));
1507
1525
  }
1508
1526
  }
1509
- function l1PreDispatchCheck(input, opts = {}) {
1527
+ function validateRoadmap(filePath) {
1510
1528
  const violations = [];
1511
- const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
1512
- if (controlWorktreePath.trim() === "") {
1513
- violations.push(violation5("high", "worktree.l1.control-missing", "metadata.control_worktree_path is not recorded — the L1 control worktree (integration-branch checkout) must be recorded in status.json before writable dispatch", "record the control worktree path in status.json metadata.control_worktree_path"));
1529
+ let content;
1530
+ try {
1531
+ content = readFileSync5(filePath, "utf8");
1532
+ } catch {
1533
+ return {
1534
+ ok: false,
1535
+ violations: [violation6("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
1536
+ warnings: []
1537
+ };
1514
1538
  }
1515
- if (leaseWorktreePath.trim() === "") {
1516
- violations.push(violation5("high", "worktree.l1.lease-missing", `execution_lease.worktree_path is empty for plan "${planId}" — no verified execution_lease to dispatch against`, "claim the execution_lease with an absolute feature worktree path before dispatch"));
1539
+ let doc;
1540
+ try {
1541
+ doc = parseCompassFrontmatterText(content, filePath);
1542
+ } catch (err) {
1543
+ const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
1544
+ return { ok: false, violations: [violation6("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
1517
1545
  }
1518
- if (leaseWorkingBranch.trim() === "") {
1519
- violations.push(violation5("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
1546
+ validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
1547
+ validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
1548
+ if (doc.status === undefined) {
1549
+ violations.push(violation6("high", "project.roadmap.missing-status", "missing required field: status"));
1550
+ } else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
1551
+ violations.push(violation6("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
1520
1552
  }
1521
- if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
1522
- violations.push(violation5("critical", "worktree.l1.lease-equals-control", `execution_lease.worktree_path "${leaseWorktreePath}" equals metadata.control_worktree_path — the feature worktree MUST differ from the control worktree (L1 isolation; product edits never land in the control checkout)`, "use a distinct feature worktree for the plan (git worktree add <path> <branch>) and update the lease"));
1553
+ if (doc.created_at === undefined) {
1554
+ violations.push(violation6("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
1555
+ } else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
1556
+ violations.push(violation6("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
1523
1557
  }
1524
- if (leaseWorktreePath !== "" && !existsSync4(leaseWorktreePath)) {
1525
- violations.push(violation5("high", "worktree.l1.feature-missing", `feature worktree directory "${leaseWorktreePath}" does not exist for plan "${planId}"`, `create it before dispatch: git worktree add ${leaseWorktreePath} <working-branch>`));
1526
- } else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
1527
- const probe = probeBranch(leaseWorktreePath, opts);
1528
- if ("error" in probe) {
1529
- violations.push(violation5("high", "worktree.l1.branch-probe-failed", `cannot probe branch at "${leaseWorktreePath}" for plan "${planId}": ${probe.error}`, "verify the path is a git worktree checkout on the lease working branch (not detached)"));
1530
- } else if (probe.branch !== leaseWorkingBranch) {
1531
- violations.push(violation5("high", "worktree.l1.branch-mismatch", `feature worktree "${leaseWorktreePath}" is on branch "${probe.branch}", expected execution_lease.working_branch "${leaseWorkingBranch}" (plan "${planId}")`, `checkout ${leaseWorkingBranch} in the feature worktree`));
1558
+ if (doc.milestones !== undefined && doc.milestones !== null) {
1559
+ if (!Array.isArray(doc.milestones)) {
1560
+ violations.push(violation6("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
1561
+ } else {
1562
+ for (const item of doc.milestones) {
1563
+ if (typeof item !== "string" || item.trim() === "") {
1564
+ violations.push(violation6("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
1565
+ break;
1566
+ }
1567
+ }
1532
1568
  }
1533
1569
  }
1534
- return gate(violations);
1535
- }
1536
- function l2PreDispatchCheck(input, opts = {}) {
1537
- const violations = [];
1538
- const tracks = input.tracks ?? [];
1539
- const seenPaths = new Set;
1540
- if (tracks.length < 1) {
1541
- violations.push(violation5("high", "worktree.l2.no-tracks", "no parallel writable tracks — the L2 pre-dispatch checklist requires at least one track with an absolute worktreePath and Working branch", "pass each track's absolute Worktree path and PM-approved Working branch"));
1542
- }
1543
- tracks.forEach((track, index) => {
1544
- if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
1545
- violations.push(violation5("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
1546
- return;
1547
- }
1548
- if (!isAbsolute4(track.worktreePath)) {
1549
- violations.push(violation5("high", "worktree.l2.track-path-relative", `track ${index + 1} worktreePath "${track.worktreePath}" is not an absolute path — L2 tracks MUST use absolute worktree checkout paths (consistent with the lease validator's absolute worktree_path enforcement)`, `use an absolute path for track ${index + 1} (e.g. /Users/<you>/worktrees/<branch>)`));
1550
- return;
1551
- }
1552
- const normalized = resolve6(track.worktreePath);
1553
- if (seenPaths.has(normalized)) {
1554
- violations.push(violation5("high", "worktree.l2.track-path-collision", `duplicate worktreePath "${track.worktreePath}" across parallel tracks — L2 parallel-writable isolation requires a distinct absolute Worktree path per track (N parallel invokes ≠ isolation)`, "give every parallel track its own git worktree checkout"));
1555
- return;
1556
- }
1557
- seenPaths.add(normalized);
1558
- if (!existsSync4(track.worktreePath)) {
1559
- violations.push(violation5("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
1560
- return;
1561
- }
1562
- const probe = probeBranch(track.worktreePath, opts);
1563
- if ("error" in probe) {
1564
- violations.push(violation5("high", "worktree.l2.branch-probe-failed", `cannot probe branch at "${track.worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on its Working branch (not detached)"));
1565
- } else if (probe.branch !== track.workingBranch) {
1566
- violations.push(violation5("high", "worktree.l2.branch-mismatch", `track worktree "${track.worktreePath}" is on branch "${probe.branch}", expected Working branch "${track.workingBranch}"`, `checkout ${track.workingBranch} in that worktree`));
1570
+ if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
1571
+ if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
1572
+ violations.push(violation6("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
1567
1573
  }
1568
- });
1569
- return gate(violations);
1570
- }
1571
- function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
1572
- const violations = [];
1573
- const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
1574
- if (samePath) {
1575
- violations.push(violation5("critical", "worktree.control-feature.same", `control worktree path equals feature/lease worktree path "${controlWorktreePath}" — execution_lease.worktree_path MUST differ from metadata.control_worktree_path`, "use a distinct feature worktree for the plan's product edits"));
1576
1574
  }
1577
- return gate(violations);
1578
- }
1579
- function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
1580
- const violations = [];
1581
- const probe = probeBranch(worktreePath, opts);
1582
- if ("error" in probe) {
1583
- violations.push(violation5("high", "worktree.branch-probe-failed", `cannot probe branch at "${worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on the expected branch (not detached)"));
1584
- } else if (probe.branch !== expectedBranch) {
1585
- violations.push(violation5("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
1575
+ const warnings = [];
1576
+ const fenceEnd = linesIndexOfClosingFence(content);
1577
+ const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
1578
+ `);
1579
+ if (!/^##\s+Direction\s*$/m.test(body)) {
1580
+ warnings.push(violation6("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
1586
1581
  }
1587
- return gate(violations);
1582
+ if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
1583
+ warnings.push(violation6("low", "project.roadmap.body.no-goal-items", "roadmap body has no goal-item task list (documented body convention) — list goals as `- [ ]` / `- [x]` markdown task items"));
1584
+ }
1585
+ return { ok: violations.length === 0, violations, warnings };
1588
1586
  }
1589
- var QC_ALIGNMENT_FIELDS = [
1590
- { key: "planId", label: "plan_id" },
1591
- { key: "reviewRange", label: "Review range" },
1592
- { key: "diffBasis", label: "Diff basis" }
1593
- ];
1594
- function assertQcAlignment(assignments) {
1587
+ function linesIndexOfClosingFence(content) {
1588
+ return content.split(/\r?\n/).indexOf("---", 1);
1589
+ }
1590
+ function validateProjectRegister(doc) {
1595
1591
  const violations = [];
1596
- const list = assignments ?? [];
1597
- for (const { key, label } of QC_ALIGNMENT_FIELDS) {
1598
- const distinct = [...new Set(list.map((a) => a[key]))];
1599
- if (distinct.length > 1) {
1600
- violations.push(violation5("high", "qc.alignment.mismatch", `QC/QA alignment field "${label}" is not byte-identical across ${list.length} assignments: ${distinct.map((v) => `"${v}"`).join(" vs ")}`, `copy the same ${label} value verbatim into every QC tri and QA Assignment`));
1592
+ if (!isPlainObject5(doc)) {
1593
+ return {
1594
+ ok: false,
1595
+ violations: [violation6("high", "project.register.invalid", "project register must be an object")]
1596
+ };
1597
+ }
1598
+ if (doc.entries === undefined) {
1599
+ violations.push(violation6("high", "project.register.missing-entries", "missing required field: entries"));
1600
+ } else if (!isPlainObject5(doc.entries)) {
1601
+ violations.push(violation6("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
1602
+ } else {
1603
+ for (const [key, entries] of Object.entries(doc.entries)) {
1604
+ if (key.trim() === "") {
1605
+ violations.push(violation6("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
1606
+ }
1607
+ if (!Array.isArray(entries)) {
1608
+ violations.push(violation6("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(key)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
1609
+ continue;
1610
+ }
1611
+ for (const entry of entries) {
1612
+ violations.push(...validateResidual(entry).violations);
1613
+ if (!isPlainObject5(entry))
1614
+ continue;
1615
+ validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
1616
+ if (entry.registered_at === undefined) {
1617
+ violations.push(violation6("high", "project.register.missing-registered-at", "missing required field: registered_at"));
1618
+ } else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
1619
+ violations.push(violation6("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
1620
+ }
1621
+ if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
1622
+ violations.push(violation6("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
1623
+ }
1624
+ if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
1625
+ violations.push(violation6("medium", "project.register.mismatched-source-plan", `source_plan ${JSON.stringify(entry.source_plan)} does not match the entries key ${JSON.stringify(key)} — entries are keyed by plan id`));
1626
+ }
1627
+ }
1601
1628
  }
1602
1629
  }
1603
- return gate(violations);
1630
+ return { ok: violations.length === 0, violations };
1604
1631
  }
1605
- function singleReviewSnapshot(assignments) {
1632
+ function findingsCleanupGate(register, planId, opts) {
1633
+ const mode = opts?.mode ?? "allow-residual";
1606
1634
  const violations = [];
1607
- const list = assignments ?? [];
1608
- list.forEach((a, index) => {
1609
- if ((a.head ?? "").trim() === "") {
1610
- violations.push(violation5("high", "qc.alignment.snapshot-missing", `review head not provided for assignment ${index + 1} (plan_id "${a.planId}") — cannot confirm the single review snapshot precondition`, "precompute and pass the review HEAD (full SHA) for every assignment"));
1611
- }
1612
- });
1613
- const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
1614
- if (distinct.length > 1) {
1615
- violations.push(violation5("high", "qc.alignment.single-snapshot", `assignments cover ${distinct.length} different review heads (${distinct.join(", ")}) — all reviewable commits must sit on ONE Working branch HEAD before QC tri + QA`, "merge the parallel tracks to a single Working branch HEAD, then re-derive the heads"));
1635
+ const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
1636
+ if (entries === undefined) {
1637
+ return { ok: true, violations };
1616
1638
  }
1617
- return gate(violations);
1618
- }
1619
- // src/sdd.ts
1620
- import { execFileSync as execFileSync3 } from "node:child_process";
1621
- import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync3, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
1622
- import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join7, resolve as resolve7 } from "node:path";
1623
- class SddScriptError extends Error {
1624
- exitCode;
1625
- constructor(message, exitCode) {
1626
- super(message);
1627
- this.name = "SddScriptError";
1628
- this.exitCode = exitCode;
1639
+ if (!Array.isArray(entries)) {
1640
+ violations.push(violation6("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(planId)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
1641
+ return { ok: false, violations };
1629
1642
  }
1630
- }
1631
- function isDirectory2(dir) {
1632
- try {
1633
- return statSync4(dir).isDirectory();
1634
- } catch {
1635
- return false;
1643
+ if (entries.length === 0) {
1644
+ return { ok: true, violations };
1636
1645
  }
1637
- }
1638
- function isFile2(file) {
1639
- try {
1640
- return statSync4(file).isFile();
1641
- } catch {
1642
- return false;
1646
+ for (const entry of entries) {
1647
+ if (!isOpenResidual(entry))
1648
+ continue;
1649
+ const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
1650
+ const label = `R#${id}`;
1651
+ if (mode === "zero-residual") {
1652
+ if (entry.severity === "nit") {
1653
+ violations.push(violation6("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
1654
+ } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
1655
+ violations.push(violation6("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
1656
+ } else if (entry.decision === "defer") {
1657
+ if (typeof entry.target !== "string" || entry.target.trim() === "") {
1658
+ violations.push(violation6("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
1659
+ }
1660
+ } else {
1661
+ violations.push(violation6("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
1662
+ }
1663
+ } else if (normalizeSeverity(entry.severity) === "critical") {
1664
+ violations.push(violation6("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
1665
+ }
1643
1666
  }
1667
+ return { ok: violations.length === 0, violations };
1644
1668
  }
1645
- var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
1646
- function gitOut(cwd, args) {
1647
- try {
1648
- return execFileSync3("git", args, {
1649
- cwd,
1650
- encoding: "utf8",
1651
- stdio: ["ignore", "pipe", "pipe"],
1652
- maxBuffer: GIT_CAPTURE_MAX_BYTES
1653
- }).trim();
1654
- } catch {
1655
- return null;
1669
+ function groupCount(values) {
1670
+ const counts = new Map;
1671
+ for (const value of values) {
1672
+ const key = typeof value === "string" ? value : String(value);
1673
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1656
1674
  }
1675
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
1657
1676
  }
1658
- function probeHarnessWithStatus(root) {
1659
- if (isFile2(join7(root, ".mstar", "status.json")))
1660
- return join7(root, ".mstar");
1661
- if (isFile2(join7(root, ".agents", "status.json")))
1662
- return join7(root, ".agents");
1663
- if (hasWorkflowSnapshot(join7(root, ".mstar")))
1664
- return join7(root, ".mstar");
1665
- if (hasWorkflowSnapshot(join7(root, ".agents")))
1666
- return join7(root, ".agents");
1667
- return null;
1668
- }
1669
- function hasWorkflowSnapshot(harnessDir) {
1670
- let workflowsDir;
1677
+ function techDebtRollup(projectDir) {
1678
+ const items = [];
1679
+ let entries;
1671
1680
  try {
1672
- workflowsDir = resolveWorkflowDir(harnessDir, { harnessDir });
1681
+ entries = readdirSync3(projectDir, { withFileTypes: true });
1673
1682
  } catch {
1674
- workflowsDir = join7(harnessDir, "workflows");
1683
+ entries = [];
1675
1684
  }
1676
- if (!isDirectory2(workflowsDir))
1677
- return false;
1678
- try {
1679
- for (const entry of readdirSync3(workflowsDir, { withFileTypes: true })) {
1680
- if (entry.isDirectory() && isFile2(join7(workflowsDir, entry.name, "snapshot.json")))
1681
- return true;
1685
+ for (const project of entries) {
1686
+ if (!project.isDirectory())
1687
+ continue;
1688
+ const registerPath = join7(projectDir, project.name, PROJECT_REGISTER_FILE);
1689
+ if (!existsSync4(registerPath))
1690
+ continue;
1691
+ let register;
1692
+ try {
1693
+ register = readJson(registerPath);
1694
+ } catch {
1695
+ continue;
1696
+ }
1697
+ if (!isPlainObject5(register) || !isPlainObject5(register.entries))
1698
+ continue;
1699
+ for (const [plan, planEntries] of Object.entries(register.entries)) {
1700
+ if (!Array.isArray(planEntries))
1701
+ continue;
1702
+ for (const entry of planEntries) {
1703
+ if (!isPlainObject5(entry) || !isOpenResidual(entry))
1704
+ continue;
1705
+ items.push({ plan, entry });
1706
+ }
1682
1707
  }
1683
- } catch {
1684
- return false;
1685
1708
  }
1686
- return false;
1709
+ const bySeverity = {};
1710
+ for (const severity of SEVERITY_ORDER) {
1711
+ bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
1712
+ }
1713
+ const computed = {
1714
+ total_open: items.length,
1715
+ by_severity: bySeverity,
1716
+ by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
1717
+ by_plan: groupCount(items.map(({ plan }) => plan))
1718
+ };
1719
+ const stored = null;
1720
+ const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
1721
+ const overall = "DRIFT";
1722
+ return { computed, stored, checks, overall };
1687
1723
  }
1688
- function isLinkedWorktree(root) {
1689
- const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
1690
- const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
1691
- if (gitDirRaw === null || commonRaw === null)
1692
- return false;
1693
- const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join7(root, gitDirRaw);
1694
- const common = isAbsolute5(commonRaw) ? commonRaw : join7(root, commonRaw);
1695
- if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
1696
- return true;
1724
+ function listProjectReferenceFiles(projectDir) {
1725
+ const root = join7(projectDir, PROJECT_REFERENCES_DIR);
1726
+ let entries;
1697
1727
  try {
1698
- const gdParent = realpathSync3(dirname6(gitDir));
1699
- const cmAbs = realpathSync3(common);
1700
- return join7(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
1728
+ entries = readdirSync3(root, { withFileTypes: true });
1701
1729
  } catch {
1702
- return false;
1703
- }
1704
- }
1705
- function sddWorkspace(planId, opts = {}) {
1706
- if (!planId) {
1707
- throw new SddScriptError(`usage: mstar sdd workspace PLAN_ID [CONTROL_ROOT]
1708
- ` + " Set MSTAR_CONTROL_ROOT=<control_worktree_path> when running from a feature worktree.", 2);
1709
- }
1710
- const cwd = opts.cwd ?? process.cwd();
1711
- const controlRoot = opts.controlRoot ?? (process.env.MSTAR_CONTROL_ROOT || undefined);
1712
- let root;
1713
- if (controlRoot) {
1714
- if (!isDirectory2(controlRoot)) {
1715
- throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
1716
- }
1717
- root = realpathSync3(controlRoot);
1718
- } else {
1719
- const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
1720
- root = realpathSync3(topLevel ?? cwd);
1721
- }
1722
- if (!controlRoot && isLinkedWorktree(root)) {
1723
- throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
1724
- ` + ` Refusing to create a second SDD tree under the feature checkout.
1725
- ` + ` Re-run with MSTAR_CONTROL_ROOT=<control_worktree_path> or: mstar sdd workspace ${planId} <control_worktree_path>
1726
- ` + ` See mstar-branch-worktree «Harness path SSOT under default gitignore».`, 1);
1730
+ return [];
1727
1731
  }
1728
- const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
1729
- let harnessDir;
1730
- if (harnessOverride) {
1731
- harnessDir = resolve7(root, harnessOverride);
1732
- } else {
1733
- const rc = findMstarc(root, root);
1734
- const rcHarnessDir = rc !== null ? parseMstarc(readFileSync5(rc, "utf8")).harnessDir : undefined;
1735
- if (rcHarnessDir) {
1736
- harnessDir = resolve7(rc !== null ? dirname6(rc) : root, rcHarnessDir);
1737
- } else {
1738
- const probed = probeHarnessWithStatus(root);
1739
- if (probed) {
1740
- harnessDir = probed;
1741
- } else if (isDirectory2(join7(root, ".mstar"))) {
1742
- harnessDir = join7(root, ".mstar");
1743
- } else if (isDirectory2(join7(root, ".agents"))) {
1744
- harnessDir = join7(root, ".agents");
1745
- } else {
1746
- harnessDir = join7(root, ".mstar");
1732
+ const files = [];
1733
+ for (const entry of entries) {
1734
+ if (entry.name === PROJECT_ROADMAP_FILE || entry.name === PROJECT_REGISTER_FILE)
1735
+ continue;
1736
+ if (entry.isFile()) {
1737
+ files.push(entry.name);
1738
+ } else if (entry.isDirectory()) {
1739
+ let nested;
1740
+ try {
1741
+ nested = readdirSync3(join7(root, entry.name), { withFileTypes: true });
1742
+ } catch {
1743
+ continue;
1744
+ }
1745
+ for (const child of nested) {
1746
+ if (child.isFile())
1747
+ files.push(`${entry.name}/${child.name}`);
1747
1748
  }
1748
1749
  }
1749
1750
  }
1750
- const sddDir = resolveSddDir(harnessDir, planId);
1751
- mkdirSync5(sddDir, { recursive: true });
1752
- writeFileSync3(join7(sddDir, ".gitignore"), `*
1753
- `);
1754
- return realpathSync3(sddDir);
1751
+ return files.sort();
1755
1752
  }
1756
- function taskBrief(planFile, taskN, outFile, opts = {}) {
1757
- if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
1758
- throw new SddScriptError("usage: mstar sdd task-brief PLAN_FILE TASK_NUMBER [OUTFILE]", 2);
1759
- }
1760
- let content;
1761
- try {
1762
- content = readFileSync5(planFile, "utf8");
1763
- } catch {
1764
- throw new SddScriptError(`no such plan file: ${planFile}`, 2);
1765
- }
1766
- let out;
1767
- if (outFile) {
1768
- out = outFile;
1769
- } else {
1770
- const sddDir = opts.sddDir ?? process.env.SDD_DIR;
1771
- if (!sddDir) {
1772
- throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
1753
+
1754
+ // src/path.ts
1755
+ function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
1756
+ const start = resolve5(startDir);
1757
+ const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
1758
+ if (explicit)
1759
+ return resolve5(start, explicit);
1760
+ const boundary = resolve5(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
1761
+ const rc = loadMstarc(start, boundary);
1762
+ if (rc !== null && rc.config.harnessDir)
1763
+ return resolve5(rc.dir, rc.config.harnessDir);
1764
+ let dir = start;
1765
+ for (;; ) {
1766
+ if (!isAtOrBelow2(dir, boundary))
1767
+ return null;
1768
+ for (const candidate of [join8(dir, ".mstar"), join8(dir, ".agents"), join8(dir, ".plans"), join8(dir, "plans")]) {
1769
+ if (isDirectory(candidate))
1770
+ return candidate;
1773
1771
  }
1774
- mkdirSync5(sddDir, { recursive: true });
1775
- out = join7(sddDir, `task-${taskN}-brief.md`);
1776
- }
1777
- const records = content.endsWith(`
1778
- `) ? content.split(`
1779
- `).slice(0, -1) : content.split(`
1780
- `);
1781
- const headingRe = /^#+[ \t]+Task[ \t]+[0-9]+/;
1782
- const targetRe = new RegExp(`^#+[ ]+Task[ ]+${taskN}([^0-9]|$)`);
1783
- let infence = false;
1784
- let intask = false;
1785
- const printed = [];
1786
- for (const line of records) {
1787
- if (/^```/.test(line))
1788
- infence = !infence;
1789
- if (!infence && headingRe.test(line))
1790
- intask = targetRe.test(line);
1791
- if (intask)
1792
- printed.push(line);
1793
- }
1794
- const output = printed.length > 0 ? `${printed.join(`
1795
- `)}
1796
- ` : "";
1797
- writeFileSync3(out, output);
1798
- if (printed.length === 0) {
1799
- throw new SddScriptError(`task ${taskN} not found in ${planFile} (no heading matching Task ${taskN})`, 3);
1772
+ if (dir === boundary)
1773
+ return null;
1774
+ const parent = dirname5(dir);
1775
+ if (parent === dir)
1776
+ return null;
1777
+ dir = parent;
1800
1778
  }
1801
- return out;
1802
1779
  }
1803
- function reviewPackage(base, head, outFile, opts = {}) {
1804
- if (!base || !head) {
1805
- throw new SddScriptError("usage: mstar sdd review-package BASE HEAD [OUTFILE]", 2);
1806
- }
1807
- const cwd = opts.cwd ?? process.cwd();
1808
- const verifyRef = (ref, what) => {
1809
- try {
1810
- execFileSync3("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
1811
- } catch {
1812
- throw new SddScriptError(`bad ${what}: ${ref}`, 2);
1813
- }
1814
- };
1815
- verifyRef(base, "BASE");
1816
- verifyRef(head, "HEAD");
1817
- let out;
1818
- if (outFile) {
1819
- out = outFile;
1820
- } else {
1821
- const sddDir = opts.sddDir ?? process.env.SDD_DIR;
1822
- if (!sddDir) {
1823
- throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
1780
+ function defaultWorkspaceRoot(startDir) {
1781
+ try {
1782
+ const cdup = execFileSync("git", ["rev-parse", "--show-cdup"], {
1783
+ cwd: startDir,
1784
+ encoding: "utf8",
1785
+ stdio: ["ignore", "pipe", "ignore"]
1786
+ }).trim();
1787
+ if (!cdup)
1788
+ return startDir;
1789
+ let boundary = startDir;
1790
+ for (const segment of cdup.split(/[\\/]/)) {
1791
+ if (segment && segment !== ".")
1792
+ boundary = dirname5(boundary);
1824
1793
  }
1825
- mkdirSync5(sddDir, { recursive: true });
1826
- const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
1827
- const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
1828
- out = join7(sddDir, `review-${shortBase}..${shortHead}.diff`);
1794
+ return resolve5(boundary);
1795
+ } catch {}
1796
+ return startDir;
1797
+ }
1798
+ function isAtOrBelow2(dir, root) {
1799
+ const rel = relative2(root, dir);
1800
+ return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
1801
+ }
1802
+ function mstarcDirOverride(harnessDir, key) {
1803
+ const dir = resolve5(harnessDir);
1804
+ const rc = loadMstarc(dir, dirname5(dir));
1805
+ const declared = rc?.config[key];
1806
+ return declared ? resolve5(rc.dir, declared) : null;
1807
+ }
1808
+ function resolveSpecsDir(harnessDir, opts = {}) {
1809
+ const declared = mstarcDirOverride(harnessDir, "specsDir");
1810
+ if (declared !== null) {
1811
+ if (opts.create !== false)
1812
+ mkdirSync4(declared, { recursive: true });
1813
+ return declared;
1829
1814
  }
1830
- const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
1831
- const parts = [
1832
- Buffer.from(`# Review package: ${base}..${head}
1833
-
1834
- ## Commits
1835
- `),
1836
- run(["log", "--oneline", `${base}..${head}`]),
1837
- Buffer.from(`
1838
- ## Files changed
1839
- `),
1840
- run(["diff", "--stat", `${base}..${head}`]),
1841
- Buffer.from(`
1842
- ## Diff
1843
- `),
1844
- run(["diff", "-U10", `${base}..${head}`])
1815
+ const harness = resolve5(harnessDir);
1816
+ const repoRoot = dirname5(harness);
1817
+ const candidates = [
1818
+ join8(harness, "specs"),
1819
+ join8(repoRoot, "docs", "specs"),
1820
+ join8(repoRoot, "specs"),
1821
+ join8(harness, "designs"),
1822
+ join8(repoRoot, "designs")
1845
1823
  ];
1846
- writeFileSync3(out, Buffer.concat(parts));
1847
- return out;
1848
- }
1849
- function assertBaseSha(ref, opts = {}) {
1850
- if (typeof ref !== "string" || !/^[0-9a-f]{4,40}$/i.test(ref)) {
1851
- throw new SddScriptError(`assertBaseSha: BASE must be a commit SHA (full or prefix); got ${JSON.stringify(ref)}. ` + "Never use HEAD~1 as review BASE (multi-commit tasks truncate).", 2);
1824
+ for (const candidate of candidates) {
1825
+ if (isDirectory(candidate) && hasFiles(candidate))
1826
+ return candidate;
1852
1827
  }
1853
- try {
1854
- execFileSync3("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
1855
- cwd: opts.cwd,
1856
- stdio: ["ignore", "pipe", "pipe"]
1857
- });
1858
- } catch {
1859
- throw new SddScriptError(`assertBaseSha: commit not found: ${ref}`, 2);
1828
+ const fallback = join8(harness, "specs");
1829
+ if (opts.create !== false)
1830
+ mkdirSync4(fallback, { recursive: true });
1831
+ return fallback;
1832
+ }
1833
+ function resolvePlanDir(harnessDir) {
1834
+ const declared = mstarcDirOverride(harnessDir, "planDir");
1835
+ if (declared !== null)
1836
+ return declared;
1837
+ const dir = resolve5(harnessDir);
1838
+ const name = basename2(dir);
1839
+ if (name === ".plans" || name === "plans")
1840
+ return dir;
1841
+ return join8(dir, "plans");
1842
+ }
1843
+ function assertSafePathComponent(value, what) {
1844
+ if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
1845
+ throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
1860
1846
  }
1861
1847
  }
1862
- function taskReportExists(sddDir, taskN) {
1863
- try {
1864
- const st = statSync4(join7(sddDir, `task-${taskN}-report.md`));
1865
- return st.isFile() && st.size > 0;
1866
- } catch {
1867
- return false;
1848
+ function resolveSddDir(harnessDir, planId) {
1849
+ assertSafePathComponent(planId, "planId");
1850
+ const base = resolve5(harnessDir);
1851
+ const declared = mstarcDirOverride(base, "sddDir");
1852
+ const sddBase = declared !== null ? declared : join8(base, "sdd");
1853
+ return join8(sddBase, planId);
1854
+ }
1855
+ function resolveIterationDir(harnessDir) {
1856
+ const declared = mstarcDirOverride(harnessDir, "iterationDir");
1857
+ if (declared !== null)
1858
+ return declared;
1859
+ return join8(resolve5(harnessDir), "iterations");
1860
+ }
1861
+ function resolveKnowledgeDir(harnessDir) {
1862
+ const declared = mstarcDirOverride(harnessDir, "knowledgeDir");
1863
+ if (declared !== null)
1864
+ return declared;
1865
+ return join8(resolve5(harnessDir), "knowledge");
1866
+ }
1867
+ function resolveHarnessSubdir(startDir, opts, key, fallback) {
1868
+ const harness = resolveHarnessDir(startDir, opts);
1869
+ if (harness === null) {
1870
+ throw new Error(`harness dir not found from ${resolve5(startDir)} — cannot resolve the ${fallback} dir (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
1868
1871
  }
1872
+ const declared = mstarcDirOverride(harness, key);
1873
+ return declared !== null ? declared : join8(resolve5(harness), fallback);
1869
1874
  }
1870
- function readProgressLedger(sddDir) {
1875
+ function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
1876
+ return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
1877
+ }
1878
+ function resolveProjectDir(startDir = process.cwd(), opts = {}) {
1879
+ return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
1880
+ }
1881
+ var EMPTY_STATUS_TEMPLATE = {
1882
+ version: 2,
1883
+ updated_at: "1970-01-01",
1884
+ workflows: []
1885
+ };
1886
+ var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
1887
+ function resolveScaffoldDirs(root) {
1888
+ const start = resolve5(root);
1889
+ const boundary = resolve5(start, defaultWorkspaceRoot(start));
1890
+ const rc = loadMstarc(start, boundary);
1891
+ const explicit = process.env.MSTAR_HARNESS_DIR;
1892
+ const harnessDir = explicit ? resolve5(start, explicit) : rc !== null && rc.config.harnessDir ? resolve5(rc.dir, rc.config.harnessDir) : join8(start, ".mstar");
1893
+ const declaredProjectDir = mstarcDirOverride(harnessDir, "projectDir");
1894
+ const projectDir = declaredProjectDir !== null ? declaredProjectDir : join8(harnessDir, "projects");
1895
+ return { harnessDir, projectDir };
1896
+ }
1897
+ var ROADMAP_TEMPLATE = `---
1898
+ project_id: _default
1899
+ title: Default Project
1900
+ status: active
1901
+ created_at: {created_at}
1902
+ ---
1903
+
1904
+ # Roadmap
1905
+
1906
+ ## Direction
1907
+
1908
+ State the project direction here.
1909
+ `;
1910
+ var EMPTY_REGISTER_TEMPLATE = {
1911
+ entries: {}
1912
+ };
1913
+ function scaffoldHarness(root) {
1914
+ const { harnessDir, projectDir } = resolveScaffoldDirs(root);
1915
+ for (const dir of SCAFFOLD_DIRS)
1916
+ mkdirSync4(join8(harnessDir, dir), { recursive: true });
1917
+ const statusPath = join8(harnessDir, "status.json");
1918
+ if (Object.keys(readJson(statusPath)).length === 0)
1919
+ writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
1920
+ const defaultProjectDir = join8(projectDir, _DEFAULT_PROJECT);
1921
+ mkdirSync4(defaultProjectDir, { recursive: true });
1922
+ const roadmapPath = join8(defaultProjectDir, PROJECT_ROADMAP_FILE);
1923
+ if (!existsSync5(roadmapPath)) {
1924
+ const created = new Date().toISOString().slice(0, 10);
1925
+ writeFileSync3(roadmapPath, ROADMAP_TEMPLATE.replace("{created_at}", created), "utf8");
1926
+ }
1927
+ const registerPath = join8(defaultProjectDir, PROJECT_REGISTER_FILE);
1928
+ if (Object.keys(readJson(registerPath)).length === 0)
1929
+ writeJson(registerPath, EMPTY_REGISTER_TEMPLATE);
1930
+ return harnessDir;
1931
+ }
1932
+ var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
1933
+ # Principle: process stays local; results are shared with the team.
1934
+ # Default-ignore everything under .mstar/, then re-include the tracked results.
1935
+ .mstar/**
1936
+ !.mstar/AGENTS.md
1937
+ !.mstar/knowledge/
1938
+ !.mstar/knowledge/**
1939
+ !.mstar/specs/
1940
+ !.mstar/specs/**
1941
+ # .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
1942
+ .mstarc
1943
+ `;
1944
+ var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
1945
+ # Default-ignore everything under .agents/, then re-include the tracked results.
1946
+ .agents/**
1947
+ !.agents/AGENTS.md
1948
+ !.agents/knowledge/
1949
+ !.agents/knowledge/**
1950
+ !.agents/specs/
1951
+ !.agents/specs/**
1952
+ `;
1953
+ var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
1954
+ `).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
1955
+ var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
1956
+ `).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
1957
+ function emitGitignoreSnippet(kind) {
1958
+ if (kind === "agents")
1959
+ return GITIGNORE_SNIPPET_AGENTS;
1960
+ if (kind === "mstar")
1961
+ return GITIGNORE_SNIPPET;
1962
+ return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
1963
+ }
1964
+ function validateGitignore(root) {
1965
+ const gitignorePath = join8(resolve5(root), ".gitignore");
1966
+ const kind = detectHarnessKind(resolveHarnessDir(root));
1871
1967
  let content;
1872
1968
  try {
1873
- content = readFileSync5(join7(sddDir, "progress.md"), "utf8");
1969
+ content = readFileSync6(gitignorePath, "utf8");
1874
1970
  } catch {
1875
- return [];
1876
- }
1877
- return content.split(`
1878
- `).map((line) => line.trim()).filter((line) => line.length > 0);
1879
- }
1880
- function implementerSessionStickyRules(input) {
1881
- const { session, nextTask, microBatchTasks = 1 } = input;
1882
- if (session.session_mode !== "sticky") {
1883
- return { resume: false, reason: `session_mode is '${session.session_mode}'; sticky resume requires 'sticky'` };
1884
- }
1885
- if (typeof session.host_agent_id !== "string" || session.host_agent_id.length === 0) {
1886
- return {
1887
- resume: false,
1888
- reason: "host_agent_id is missing from implementer-session.json; fall back to fresh for this task " + "(mstar-sdd SKILL.md red flag: resume implementer without host_agent_id)"
1889
- };
1890
- }
1891
- if (nextTask <= session.last_task) {
1892
- return {
1893
- resume: false,
1894
- reason: `nextTask ${nextTask} <= last_task ${session.last_task}; task already completed in this session`
1895
- };
1896
- }
1897
- if (microBatchTasks < 1 || microBatchTasks > 3) {
1898
1971
  return {
1899
- resume: false,
1900
- reason: `micro-batch of ${microBatchTasks} tasks is outside 1..3 (max 3 without user override, ` + "sticky-implementer-session.md § Micro-batch fallback)"
1972
+ ok: false,
1973
+ severity: "medium",
1974
+ code: "gitignore.missing",
1975
+ message: `no .gitignore found at ${gitignorePath}`,
1976
+ fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
1901
1977
  };
1902
1978
  }
1903
- return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
1904
- }
1905
- // src/iteration.ts
1906
- import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "node:fs";
1907
- import { join as join8 } from "node:path";
1908
- var COMPASS_STATUSES = ["active", "locked", "completed"];
1909
- var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
1910
- var PLAN_STATUS_DONE = "Done";
1911
- var COMPASS_FILE = "delivery-compass.md";
1912
- var INDEX_README = "README.md";
1913
- var INDEX_HEADER = "| Iteration | Path | Description | Status |";
1914
- function typeName(value) {
1915
- if (value === null)
1916
- return "null";
1917
- if (Array.isArray(value))
1918
- return "array";
1919
- return typeof value;
1920
- }
1921
- function validateCompassShape(doc) {
1922
- const issues = [];
1923
- const expectString = (key, opts = {}) => {
1924
- const value = doc[key];
1925
- if (typeof value !== "string") {
1926
- issues.push({ path: [key], message: `expected string, received ${typeName(value)}` });
1927
- return;
1928
- }
1929
- if (opts.min !== undefined && value.length < opts.min) {
1930
- issues.push({ path: [key], message: `string must contain at least ${opts.min} character(s)` });
1931
- return;
1932
- }
1933
- if (opts.regex !== undefined && !opts.regex.test(value)) {
1934
- issues.push({ path: [key], message: `string must match ${opts.regex}` });
1935
- }
1936
- };
1937
- expectString("iteration_id", { min: 1 });
1938
- expectString("start_date", { regex: DATE_RE2 });
1939
- const status = doc.status;
1940
- if (typeof status !== "string" || !COMPASS_STATUSES.includes(status)) {
1941
- issues.push({
1942
- path: ["status"],
1943
- message: `expected one of ${COMPASS_STATUSES.map((s) => `'${s}'`).join(" | ")}, received ${typeName(status)}`
1944
- });
1945
- }
1946
- expectString("iteration_base_branch", { min: 1 });
1947
- expectString("target_branch", { min: 1 });
1948
- const plans = doc.plans;
1949
- if (plans !== undefined) {
1950
- if (!Array.isArray(plans)) {
1951
- issues.push({ path: ["plans"], message: `expected array, received ${typeName(plans)}` });
1952
- } else {
1953
- plans.forEach((entry, index) => {
1954
- if (typeof entry !== "string") {
1955
- issues.push({ path: ["plans", index], message: `expected string, received ${typeName(entry)}` });
1956
- } else if (entry.length < 1) {
1957
- issues.push({ path: ["plans", index], message: "string must contain at least 1 character(s)" });
1958
- }
1959
- });
1960
- }
1979
+ const lines = new Set(content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0));
1980
+ const mstarMissing = GITIGNORE_PROCESS_ENTRIES.filter((entry) => !lines.has(entry));
1981
+ const agentsMissing = GITIGNORE_PROCESS_ENTRIES_AGENTS.filter((entry) => !lines.has(entry));
1982
+ let missing;
1983
+ let label;
1984
+ if (kind === "agents") {
1985
+ missing = agentsMissing;
1986
+ label = ".agents/ set";
1987
+ } else if (kind === "mstar") {
1988
+ missing = mstarMissing;
1989
+ label = ".mstar/ set";
1990
+ } else {
1991
+ label = "either .mstar/ or .agents/ set";
1992
+ missing = mstarMissing.length === 0 || agentsMissing.length === 0 ? [] : mstarMissing.length <= agentsMissing.length ? mstarMissing : agentsMissing;
1961
1993
  }
1962
- const end_date = doc.end_date;
1963
- if (end_date !== undefined) {
1964
- if (typeof end_date !== "string") {
1965
- issues.push({ path: ["end_date"], message: `expected string, received ${typeName(end_date)}` });
1966
- } else if (!DATE_RE2.test(end_date)) {
1967
- issues.push({ path: ["end_date"], message: `string must match ${DATE_RE2}` });
1968
- }
1994
+ if (missing.length > 0) {
1995
+ return {
1996
+ ok: false,
1997
+ severity: "medium",
1998
+ code: "gitignore.missing-entries",
1999
+ message: `.gitignore at ${gitignorePath} is missing canonical harness ignore entries (${label}): ${missing.join(", ")}`,
2000
+ fix: `append the canonical snippet (emitGitignoreSnippet(${kind ? `"${kind}"` : ""})) to ${gitignorePath}`
2001
+ };
1969
2002
  }
1970
- if (issues.length > 0)
1971
- return { ok: false, issues };
1972
2003
  return {
1973
2004
  ok: true,
1974
- data: {
1975
- iteration_id: doc.iteration_id,
1976
- start_date: doc.start_date,
1977
- status,
1978
- iteration_base_branch: doc.iteration_base_branch,
1979
- target_branch: doc.target_branch,
1980
- ...plans !== undefined ? { plans } : {},
1981
- ...end_date !== undefined ? { end_date } : {}
1982
- }
2005
+ severity: "low",
2006
+ code: "gitignore.ok",
2007
+ message: `.gitignore at ${gitignorePath} contains a complete canonical harness ignore set — default-ignore + tracked re-includes (${label})`
1983
2008
  };
1984
2009
  }
1985
- function violation6(severity, code, message, fix) {
1986
- return { ok: false, severity, code, message, fix };
1987
- }
1988
- function isPlainObject4(value) {
1989
- return typeof value === "object" && value !== null && !Array.isArray(value);
2010
+ function detectHarnessKind(harnessDir) {
2011
+ if (!harnessDir)
2012
+ return null;
2013
+ const name = basename2(resolve5(harnessDir));
2014
+ if (name === ".mstar")
2015
+ return "mstar";
2016
+ if (name === ".agents")
2017
+ return "agents";
2018
+ return null;
1990
2019
  }
1991
- function validateCompassFrontmatter(doc) {
1992
- if (!isPlainObject4(doc)) {
2020
+ function assertPlanWritingPath(planPath, harnessDir) {
2021
+ const planAbs = resolve5(planPath);
2022
+ if (!harnessDir) {
1993
2023
  return {
1994
2024
  ok: false,
1995
- violations: [
1996
- violation6("medium", "COMPASS_INVALID_FIELD", "Compass frontmatter must be a YAML object with iteration_id / start_date / status / iteration_base_branch / target_branch (template: mstar-iteration §1.3)", "Fix the frontmatter of {ITERATION_DIR}/<iteration-id>/delivery-compass.md")
1997
- ]
2025
+ severity: "high",
2026
+ code: "plan-path.no-harness",
2027
+ message: `persistent plan tracking is not enabled — cannot place plan ${planAbs} under {PLAN_DIR}`,
2028
+ fix: "initialize the harness (scaffoldHarness) so plans land in {PLAN_DIR}"
1998
2029
  };
1999
2030
  }
2000
- const parsed = validateCompassShape(doc);
2001
- if (!parsed.ok) {
2031
+ const planDir = resolvePlanDir(harnessDir);
2032
+ const rel = relative2(planDir, planAbs);
2033
+ const inside = rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
2034
+ if (!inside) {
2002
2035
  return {
2003
2036
  ok: false,
2004
- violations: parsed.issues.map((issue) => {
2005
- const field = issue.path.join(".") || "(root)";
2006
- return violation6("medium", "COMPASS_INVALID_FIELD", `Compass frontmatter field '${field}' is invalid: ${issue.message}`, `Fix '${field}' in {ITERATION_DIR}/<iteration-id>/delivery-compass.md frontmatter (template: mstar-iteration §1.3)`);
2007
- })
2037
+ severity: "high",
2038
+ code: "plan-path.outside-plan-dir",
2039
+ message: `plan file ${planAbs} is outside {PLAN_DIR} (${planDir})`,
2040
+ fix: `write the plan under ${planDir}`
2008
2041
  };
2009
2042
  }
2010
- const violations = [];
2011
- const { status, end_date } = parsed.data;
2012
- if (status === "completed" && end_date === undefined) {
2013
- violations.push(violation6("high", "COMPASS_END_DATE_REQUIRED", "Compass frontmatter status is 'completed' but end_date is missing — end_date is required at iteration-close (mstar-iteration §3.4, template Fields guide)", "Add `end_date: YYYY-MM-DD` to the frontmatter"));
2014
- }
2015
- if (status !== "completed" && end_date !== undefined) {
2016
- violations.push(violation6("medium", "COMPASS_END_DATE_NOT_ALLOWED", `Compass frontmatter sets end_date while status is '${status}' — end_date is only written at iteration-close (mstar-iteration §3.4)`, "Remove end_date until iteration-close"));
2043
+ if (existsSync5(planAbs)) {
2044
+ try {
2045
+ const canonicalPlan = realpathSync2(planAbs);
2046
+ const canonicalPlanDir = existsSync5(planDir) ? realpathSync2(planDir) : resolve5(planDir);
2047
+ const canonicalRel = relative2(canonicalPlanDir, canonicalPlan);
2048
+ const canonicalInside = canonicalRel === "" || !canonicalRel.startsWith("..") && !isAbsolute3(canonicalRel);
2049
+ if (!canonicalInside) {
2050
+ return {
2051
+ ok: false,
2052
+ severity: "high",
2053
+ code: "plan-path.symlink-escape",
2054
+ message: `plan file ${planAbs} resolves to ${canonicalPlan}, outside {PLAN_DIR} (${canonicalPlanDir})`,
2055
+ fix: `write the plan under ${planDir}`
2056
+ };
2057
+ }
2058
+ } catch {}
2017
2059
  }
2018
- return { ok: violations.length === 0, violations };
2019
- }
2020
- function registeredPlanIds(compassDoc) {
2021
- if (!Array.isArray(compassDoc.plans))
2022
- return [];
2023
- return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
2060
+ return {
2061
+ ok: true,
2062
+ severity: "low",
2063
+ code: "plan-path.ok",
2064
+ message: `plan file ${planAbs} lives under {PLAN_DIR} (${planDir})`
2065
+ };
2024
2066
  }
2025
- function findPlanRow(snapshotDoc, planId) {
2026
- if (!Array.isArray(snapshotDoc.plans))
2027
- return null;
2028
- for (const row of snapshotDoc.plans) {
2029
- if (!isPlainObject4(row))
2030
- continue;
2031
- const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
2032
- if (rowId === planId)
2033
- return row;
2067
+ function isDirectory(dir) {
2068
+ try {
2069
+ return statSync3(dir).isDirectory();
2070
+ } catch {
2071
+ return false;
2034
2072
  }
2035
- return null;
2036
2073
  }
2037
- function entryPlansAllDone(snapshotDoc, registered) {
2038
- const violations = [];
2039
- if (registered.length === 0) {
2040
- violations.push(violation6("medium", "COMPASS_NO_PLANS", "Compass frontmatter registers no plans — the all-plans-Done transition cannot be verified (mstar-iteration §1.3 / Phase transition gates)", "List the iteration's plan ids in the compass frontmatter `plans`"));
2041
- return violations;
2042
- }
2043
- for (const planId of registered) {
2044
- const row = findPlanRow(snapshotDoc, planId);
2045
- if (row === null) {
2046
- violations.push(violation6("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in the workflow snapshot plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/workflows/<id>/snapshot.json"));
2047
- continue;
2048
- }
2049
- if (row.status !== PLAN_STATUS_DONE) {
2050
- violations.push(violation6("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in the workflow snapshot — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
2074
+ function hasFiles(dir) {
2075
+ try {
2076
+ for (const entry of readdirSync4(dir, { withFileTypes: true })) {
2077
+ if (entry.isDirectory()) {
2078
+ if (hasFiles(join8(dir, entry.name)))
2079
+ return true;
2080
+ } else if (entry.isFile()) {
2081
+ return true;
2082
+ }
2051
2083
  }
2084
+ return false;
2085
+ } catch {
2086
+ return false;
2052
2087
  }
2053
- return violations;
2054
2088
  }
2055
- function entryFrontmatterComplete(compassDoc) {
2056
- return validateCompassFrontmatter(compassDoc).violations;
2089
+ // src/worktree.ts
2090
+ import { execFileSync as execFileSync2 } from "node:child_process";
2091
+ import { existsSync as existsSync6 } from "node:fs";
2092
+ import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
2093
+ var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
2094
+ function probeTimeoutMs() {
2095
+ const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
2096
+ if (raw === undefined || raw.trim() === "")
2097
+ return DEFAULT_PROBE_TIMEOUT_MS;
2098
+ const parsed = Number(raw);
2099
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
2057
2100
  }
2058
- function exitFrontmatterClosed(compassDoc) {
2059
- const violations = [];
2060
- if (compassDoc.status !== "completed") {
2061
- violations.push(violation6("high", "EXIT_STATUS_NOT_COMPLETED", `Compass frontmatter status must be 'completed' at close exit — current: ${JSON.stringify(compassDoc.status)} (mstar-iteration §3.4 / §3.5 exit item 4)`));
2062
- }
2063
- const endDate = compassDoc.end_date;
2064
- if (typeof endDate !== "string" || !DATE_RE2.test(endDate)) {
2065
- violations.push(violation6("high", "EXIT_END_DATE_REQUIRED", "Compass frontmatter end_date (YYYY-MM-DD) is required when closing (mstar-iteration §3.4 / §3.5 exit item 4)"));
2066
- }
2067
- return violations;
2101
+ function violation7(severity, code, message, fix) {
2102
+ return { ok: false, severity, code, message, fix };
2068
2103
  }
2069
- function exitBranchCheck(opts) {
2070
- const violations = [];
2071
- const { currentBranch, specIntegrationBranch } = opts;
2072
- if (currentBranch === undefined || specIntegrationBranch === undefined) {
2073
- violations.push(violation6("medium", "EXIT_BRANCH_UNVERIFIABLE", "Cannot verify the current branch is spec_integration_branch — missing currentBranch / specIntegrationBranch probe inputs (mstar-iteration §3.5 exit item 5)"));
2074
- } else if (currentBranch !== specIntegrationBranch) {
2075
- violations.push(violation6("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
2076
- }
2077
- return violations;
2104
+ function gate(violations) {
2105
+ return { ok: violations.length === 0, violations };
2078
2106
  }
2079
- function exitPrBaseCheck(compassDoc, opts) {
2080
- const violations = [];
2081
- const target = compassDoc.target_branch;
2082
- const { prBaseBranch } = opts;
2083
- if (prBaseBranch === undefined) {
2084
- violations.push(violation6("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
2085
- } else if (typeof target !== "string" || prBaseBranch !== target) {
2086
- violations.push(violation6("high", "EXIT_PR_BASE_MISMATCH", `PR base '${prBaseBranch}' must equal the compass target_branch '${String(target)}' — not an undocumented branch (mstar-iteration §3.5 exit item 6)`));
2107
+ function probeBranch(worktreePath, opts) {
2108
+ const precomputed = opts.branchOf?.(worktreePath);
2109
+ if (precomputed !== undefined)
2110
+ return { branch: precomputed };
2111
+ const timeout = opts.timeoutMs ?? probeTimeoutMs();
2112
+ try {
2113
+ const stdout = execFileSync2(opts.gitPath ?? "git", ["-C", worktreePath, "branch", "--show-current"], {
2114
+ encoding: "utf8",
2115
+ stdio: ["ignore", "pipe", "pipe"],
2116
+ timeout
2117
+ });
2118
+ const branch = stdout.trim();
2119
+ if (branch === "")
2120
+ return { error: `no branch checked out (detached HEAD?) at "${worktreePath}"` };
2121
+ return { branch };
2122
+ } catch (err) {
2123
+ const e = err;
2124
+ if (e.killed === true || e.signal !== undefined) {
2125
+ return { error: `git probe timed out after ${timeout}ms (killed by ${e.signal ?? "SIGTERM"})` };
2126
+ }
2127
+ const detail = (e.stderr !== undefined ? e.stderr.toString().trim() : "") || e.message || "git probe failed";
2128
+ return { error: detail };
2087
2129
  }
2088
- return violations;
2089
- }
2090
- function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
2091
- const registered = registeredPlanIds(compassDoc);
2092
- const entryViolations = [
2093
- ...entryPlansAllDone(snapshotDoc, registered),
2094
- ...entryFrontmatterComplete(compassDoc)
2095
- ];
2096
- const exitViolations = [
2097
- ...exitFrontmatterClosed(compassDoc),
2098
- ...exitBranchCheck(opts),
2099
- ...exitPrBaseCheck(compassDoc, opts)
2100
- ];
2101
- const allPlansDone = registered.length > 0 && registered.every((planId) => {
2102
- const row = findPlanRow(snapshotDoc, planId);
2103
- return row !== null && row.status === PLAN_STATUS_DONE;
2104
- });
2105
- const entry = { ok: entryViolations.length === 0, violations: entryViolations };
2106
- const exit = { ok: exitViolations.length === 0, violations: exitViolations };
2107
- let transition;
2108
- if (!allPlansDone)
2109
- transition = "phase-2-execute";
2110
- else if (entry.ok && exit.ok)
2111
- transition = "phase-4-pr-delivery";
2112
- else
2113
- transition = "phase-3-close";
2114
- const gateBlocking = allPlansDone ? [...entryViolations, ...exitViolations] : [];
2115
- return {
2116
- transition,
2117
- allPlansDone,
2118
- entry,
2119
- exit,
2120
- ok: gateBlocking.length === 0,
2121
- violations: gateBlocking
2122
- };
2123
2130
  }
2124
- function pushCadenceProbe(ciRunning, reviewWaveActive) {
2131
+ function l1PreDispatchCheck(input, opts = {}) {
2125
2132
  const violations = [];
2126
- if (ciRunning) {
2127
- violations.push(violation6("high", "PUSH_BLOCKED_CI", "CI checks are still queued/in_progress on the current head — do not push until the wave completes (mstar-iteration §5.1a push gate 1)", "Wait for CI to settle, then push once with the whole local batch"));
2128
- }
2129
- if (reviewWaveActive) {
2130
- violations.push(violation6("high", "PUSH_BLOCKED_REVIEW_WAVE", "An AI/bot review wave is still running on the current head — do not push until it settles (mstar-iteration §5.1a push gate 2)", "Wait for the review wave, then push once"));
2131
- }
2132
- return { ok: violations.length === 0, violations };
2133
- }
2134
- function assertIndexRowObligations(iterationsDir) {
2135
- if (!existsSync5(iterationsDir)) {
2136
- return {
2137
- ok: false,
2138
- violations: [
2139
- violation6("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
2140
- ]
2141
- };
2133
+ const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
2134
+ if (controlWorktreePath.trim() === "") {
2135
+ violations.push(violation7("high", "worktree.l1.control-missing", "metadata.control_worktree_path is not recorded — the L1 control worktree (integration-branch checkout) must be recorded in status.json before writable dispatch", "record the control worktree path in status.json metadata.control_worktree_path"));
2142
2136
  }
2143
- const iterationIds = readdirSync4(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync5(join8(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
2144
- const readmePath = join8(iterationsDir, INDEX_README);
2145
- if (!existsSync5(readmePath)) {
2146
- return {
2147
- ok: false,
2148
- violations: [
2149
- violation6("high", "INDEX_README_MISSING", `{ITERATION_DIR}/README.md does not exist — one row per iteration is required (mstar-iteration §1.4)`, `Create {ITERATION_DIR}/README.md with the header '${INDEX_HEADER}' and one row per iteration`)
2150
- ]
2151
- };
2137
+ if (leaseWorktreePath.trim() === "") {
2138
+ violations.push(violation7("high", "worktree.l1.lease-missing", `execution_lease.worktree_path is empty for plan "${planId}" — no verified execution_lease to dispatch against`, "claim the execution_lease with an absolute feature worktree path before dispatch"));
2152
2139
  }
2153
- const violations = [];
2154
- const lines = readFileSync6(readmePath, "utf8").split(/\r?\n/);
2155
- if (!lines.some((line) => line.includes(INDEX_HEADER))) {
2156
- violations.push(violation6("medium", "INDEX_HEADER_MISSING", `{ITERATION_DIR}/README.md lacks the table header '${INDEX_HEADER}' (mstar-iteration §1.4)`, "Add the header row on first creation"));
2140
+ if (leaseWorkingBranch.trim() === "") {
2141
+ violations.push(violation7("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
2157
2142
  }
2158
- const indexed = new Set;
2159
- for (const line of lines) {
2160
- const match = line.match(/^\s*\|\s*`([^`]+)`\s*\|/);
2161
- if (match)
2162
- indexed.add(match[1].trim());
2143
+ if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
2144
+ violations.push(violation7("critical", "worktree.l1.lease-equals-control", `execution_lease.worktree_path "${leaseWorktreePath}" equals metadata.control_worktree_path — the feature worktree MUST differ from the control worktree (L1 isolation; product edits never land in the control checkout)`, "use a distinct feature worktree for the plan (git worktree add <path> <branch>) and update the lease"));
2163
2145
  }
2164
- for (const id of iterationIds) {
2165
- if (!indexed.has(id)) {
2166
- violations.push(violation6("medium", "INDEX_ROW_MISSING", `Iteration '${id}' has a delivery-compass.md but no index row in {ITERATION_DIR}/README.md — one row per iteration (mstar-iteration §1.4)`, `Add | \`${id}\` | [\`${id}/\`](${id}/) | <description> | <status> |`));
2146
+ if (leaseWorktreePath !== "" && !existsSync6(leaseWorktreePath)) {
2147
+ violations.push(violation7("high", "worktree.l1.feature-missing", `feature worktree directory "${leaseWorktreePath}" does not exist for plan "${planId}"`, `create it before dispatch: git worktree add ${leaseWorktreePath} <working-branch>`));
2148
+ } else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
2149
+ const probe = probeBranch(leaseWorktreePath, opts);
2150
+ if ("error" in probe) {
2151
+ violations.push(violation7("high", "worktree.l1.branch-probe-failed", `cannot probe branch at "${leaseWorktreePath}" for plan "${planId}": ${probe.error}`, "verify the path is a git worktree checkout on the lease working branch (not detached)"));
2152
+ } else if (probe.branch !== leaseWorkingBranch) {
2153
+ violations.push(violation7("high", "worktree.l1.branch-mismatch", `feature worktree "${leaseWorktreePath}" is on branch "${probe.branch}", expected execution_lease.working_branch "${leaseWorkingBranch}" (plan "${planId}")`, `checkout ${leaseWorkingBranch} in the feature worktree`));
2167
2154
  }
2168
2155
  }
2169
- return { ok: violations.length === 0, violations };
2170
- }
2171
- function parseCompassFrontmatter(filePath) {
2172
- return parseCompassFrontmatterText(readFileSync6(filePath, "utf8"), filePath);
2156
+ return gate(violations);
2173
2157
  }
2174
- function parseCompassFrontmatterText(content, filePath) {
2175
- const lines = content.split(/\r?\n/);
2176
- if (lines[0]?.trim() !== "---") {
2177
- throw new Error(`no YAML frontmatter fence in ${filePath} (expected first line "---")`);
2178
- }
2179
- const end = lines.indexOf("---", 1);
2180
- if (end === -1) {
2181
- throw new Error(`unterminated YAML frontmatter in ${filePath} (no closing "---")`);
2158
+ function l2PreDispatchCheck(input, opts = {}) {
2159
+ const violations = [];
2160
+ const tracks = input.tracks ?? [];
2161
+ const seenPaths = new Set;
2162
+ if (tracks.length < 1) {
2163
+ violations.push(violation7("high", "worktree.l2.no-tracks", "no parallel writable tracks — the L2 pre-dispatch checklist requires at least one track with an absolute worktreePath and Working branch", "pass each track's absolute Worktree path and PM-approved Working branch"));
2182
2164
  }
2183
- const doc = {};
2184
- let listKey = null;
2185
- for (let i = 1;i < end; i += 1) {
2186
- const line = lines[i] ?? "";
2187
- if (!line.trim() || line.trim().startsWith("#"))
2188
- continue;
2189
- if (listKey !== null && /^\s*-\s+/.test(line)) {
2190
- const item = line.replace(/^\s*-\s+/, "").trim().replace(/^["']|["']$/g, "");
2191
- if (!Array.isArray(doc[listKey]))
2192
- doc[listKey] = [];
2193
- doc[listKey].push(item);
2194
- continue;
2165
+ tracks.forEach((track, index) => {
2166
+ if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
2167
+ violations.push(violation7("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
2168
+ return;
2195
2169
  }
2196
- listKey = null;
2197
- const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/);
2198
- if (!kv) {
2199
- throw new Error(`unsupported frontmatter line in ${filePath}: ${JSON.stringify(line)}`);
2170
+ if (!isAbsolute4(track.worktreePath)) {
2171
+ violations.push(violation7("high", "worktree.l2.track-path-relative", `track ${index + 1} worktreePath "${track.worktreePath}" is not an absolute path — L2 tracks MUST use absolute worktree checkout paths (consistent with the lease validator's absolute worktree_path enforcement)`, `use an absolute path for track ${index + 1} (e.g. /Users/<you>/worktrees/<branch>)`));
2172
+ return;
2200
2173
  }
2201
- const value = kv[2].trim();
2202
- doc[kv[1]] = value === "" ? null : /^\[.*\]$/.test(value) ? parseFlowArray(value, filePath) : value.replace(/^["']|["']$/g, "");
2203
- listKey = value === "" ? kv[1] : null;
2174
+ const normalized = resolve6(track.worktreePath);
2175
+ if (seenPaths.has(normalized)) {
2176
+ violations.push(violation7("high", "worktree.l2.track-path-collision", `duplicate worktreePath "${track.worktreePath}" across parallel tracks — L2 parallel-writable isolation requires a distinct absolute Worktree path per track (N parallel invokes ≠ isolation)`, "give every parallel track its own git worktree checkout"));
2177
+ return;
2178
+ }
2179
+ seenPaths.add(normalized);
2180
+ if (!existsSync6(track.worktreePath)) {
2181
+ violations.push(violation7("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
2182
+ return;
2183
+ }
2184
+ const probe = probeBranch(track.worktreePath, opts);
2185
+ if ("error" in probe) {
2186
+ violations.push(violation7("high", "worktree.l2.branch-probe-failed", `cannot probe branch at "${track.worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on its Working branch (not detached)"));
2187
+ } else if (probe.branch !== track.workingBranch) {
2188
+ violations.push(violation7("high", "worktree.l2.branch-mismatch", `track worktree "${track.worktreePath}" is on branch "${probe.branch}", expected Working branch "${track.workingBranch}"`, `checkout ${track.workingBranch} in that worktree`));
2189
+ }
2190
+ });
2191
+ return gate(violations);
2192
+ }
2193
+ function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
2194
+ const violations = [];
2195
+ const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
2196
+ if (samePath) {
2197
+ violations.push(violation7("critical", "worktree.control-feature.same", `control worktree path equals feature/lease worktree path "${controlWorktreePath}" — execution_lease.worktree_path MUST differ from metadata.control_worktree_path`, "use a distinct feature worktree for the plan's product edits"));
2204
2198
  }
2205
- return doc;
2199
+ return gate(violations);
2206
2200
  }
2207
- function parseFlowArray(raw, filePath) {
2208
- const inner = raw.slice(1, -1);
2209
- if (/[[\]]/.test(inner)) {
2210
- throw new Error(`nested flow-style array in ${filePath}: ${JSON.stringify(raw)} — only flat scalar items are supported (e.g. [a, b])`);
2201
+ function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
2202
+ const violations = [];
2203
+ const probe = probeBranch(worktreePath, opts);
2204
+ if ("error" in probe) {
2205
+ violations.push(violation7("high", "worktree.branch-probe-failed", `cannot probe branch at "${worktreePath}": ${probe.error}`, "verify the path is a git worktree checkout on the expected branch (not detached)"));
2206
+ } else if (probe.branch !== expectedBranch) {
2207
+ violations.push(violation7("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
2211
2208
  }
2212
- let quote = null;
2213
- for (const ch of inner) {
2214
- if (ch === '"' || ch === "'") {
2215
- if (quote === null)
2216
- quote = ch;
2217
- else if (quote === ch)
2218
- quote = null;
2219
- } else if (ch === "," && quote !== null) {
2220
- throw new Error(`ambiguous flow-style array in ${filePath}: ${JSON.stringify(raw)} — quoted item containing comma cannot be split unambiguously (flat scalar items only)`);
2209
+ return gate(violations);
2210
+ }
2211
+ var QC_ALIGNMENT_FIELDS = [
2212
+ { key: "planId", label: "plan_id" },
2213
+ { key: "reviewRange", label: "Review range" },
2214
+ { key: "diffBasis", label: "Diff basis" }
2215
+ ];
2216
+ function assertQcAlignment(assignments) {
2217
+ const violations = [];
2218
+ const list = assignments ?? [];
2219
+ for (const { key, label } of QC_ALIGNMENT_FIELDS) {
2220
+ const distinct = [...new Set(list.map((a) => a[key]))];
2221
+ if (distinct.length > 1) {
2222
+ violations.push(violation7("high", "qc.alignment.mismatch", `QC/QA alignment field "${label}" is not byte-identical across ${list.length} assignments: ${distinct.map((v) => `"${v}"`).join(" vs ")}`, `copy the same ${label} value verbatim into every QC tri and QA Assignment`));
2221
2223
  }
2222
2224
  }
2223
- if (quote !== null) {
2224
- throw new Error(`unterminated ${quote} quote in flow-style array in ${filePath}: ${JSON.stringify(raw)}`);
2225
- }
2226
- const items = [];
2227
- for (const part of inner.split(",")) {
2228
- const item = part.trim().replace(/^["']|["']$/g, "");
2229
- if (item === "")
2230
- continue;
2231
- items.push(item);
2232
- }
2233
- return items;
2234
- }
2235
- // src/project.ts
2236
- import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
2237
- import { join as join9 } from "node:path";
2238
- var PROJECT_ROADMAP_FILE = "roadmap.md";
2239
- var PROJECT_REFERENCES_DIR = "references";
2240
- var PROJECT_REGISTER_FILE = "residuals.json";
2241
- var _DEFAULT_PROJECT = "_default";
2242
- var ROADMAP_STATUSES = ["active", "paused", "completed"];
2243
- var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
2244
- var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
2245
- function isPlainObject5(value) {
2246
- return typeof value === "object" && value !== null && !Array.isArray(value);
2225
+ return gate(violations);
2247
2226
  }
2248
- function violation7(severity, code, message, fix) {
2249
- return { ok: false, severity, code, message, fix };
2227
+ function singleReviewSnapshot(assignments) {
2228
+ const violations = [];
2229
+ const list = assignments ?? [];
2230
+ list.forEach((a, index) => {
2231
+ if ((a.head ?? "").trim() === "") {
2232
+ violations.push(violation7("high", "qc.alignment.snapshot-missing", `review head not provided for assignment ${index + 1} (plan_id "${a.planId}") — cannot confirm the single review snapshot precondition`, "precompute and pass the review HEAD (full SHA) for every assignment"));
2233
+ }
2234
+ });
2235
+ const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
2236
+ if (distinct.length > 1) {
2237
+ violations.push(violation7("high", "qc.alignment.single-snapshot", `assignments cover ${distinct.length} different review heads (${distinct.join(", ")}) — all reviewable commits must sit on ONE Working branch HEAD before QC tri + QA`, "merge the parallel tracks to a single Working branch HEAD, then re-derive the heads"));
2238
+ }
2239
+ return gate(violations);
2250
2240
  }
2251
- function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
2252
- if (value === undefined) {
2253
- violations.push(violation7("high", missingCode, `missing required field: ${field}`));
2254
- } else if (typeof value !== "string" || value.trim() === "") {
2255
- violations.push(violation7("medium", invalidCode, `${field} must be a non-empty string`));
2241
+ // src/sdd.ts
2242
+ import { execFileSync as execFileSync3 } from "node:child_process";
2243
+ import { mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync3, statSync as statSync4, writeFileSync as writeFileSync4 } from "node:fs";
2244
+ import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join9, resolve as resolve7 } from "node:path";
2245
+ class SddScriptError extends Error {
2246
+ exitCode;
2247
+ constructor(message, exitCode) {
2248
+ super(message);
2249
+ this.name = "SddScriptError";
2250
+ this.exitCode = exitCode;
2256
2251
  }
2257
2252
  }
2258
- function validateRoadmap(filePath) {
2259
- const violations = [];
2260
- let content;
2253
+ function isDirectory2(dir) {
2261
2254
  try {
2262
- content = readFileSync7(filePath, "utf8");
2255
+ return statSync4(dir).isDirectory();
2263
2256
  } catch {
2264
- return {
2265
- ok: false,
2266
- violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
2267
- warnings: []
2268
- };
2257
+ return false;
2269
2258
  }
2270
- let doc;
2259
+ }
2260
+ function isFile2(file) {
2271
2261
  try {
2272
- doc = parseCompassFrontmatterText(content, filePath);
2273
- } catch (err) {
2274
- const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
2275
- return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
2276
- }
2277
- validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
2278
- validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
2279
- if (doc.status === undefined) {
2280
- violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
2281
- } else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
2282
- violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
2283
- }
2284
- if (doc.created_at === undefined) {
2285
- violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
2286
- } else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
2287
- violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
2288
- }
2289
- if (doc.milestones !== undefined && doc.milestones !== null) {
2290
- if (!Array.isArray(doc.milestones)) {
2291
- violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
2292
- } else {
2293
- for (const item of doc.milestones) {
2294
- if (typeof item !== "string" || item.trim() === "") {
2295
- violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
2296
- break;
2297
- }
2298
- }
2299
- }
2300
- }
2301
- if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
2302
- if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
2303
- violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
2304
- }
2305
- }
2306
- const warnings = [];
2307
- const fenceEnd = linesIndexOfClosingFence(content);
2308
- const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
2309
- `);
2310
- if (!/^##\s+Direction\s*$/m.test(body)) {
2311
- warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
2262
+ return statSync4(file).isFile();
2263
+ } catch {
2264
+ return false;
2312
2265
  }
2313
- if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
2314
- warnings.push(violation7("low", "project.roadmap.body.no-goal-items", "roadmap body has no goal-item task list (documented body convention) — list goals as `- [ ]` / `- [x]` markdown task items"));
2266
+ }
2267
+ var GIT_CAPTURE_MAX_BYTES = 64 * 1024 * 1024;
2268
+ function gitOut(cwd, args) {
2269
+ try {
2270
+ return execFileSync3("git", args, {
2271
+ cwd,
2272
+ encoding: "utf8",
2273
+ stdio: ["ignore", "pipe", "pipe"],
2274
+ maxBuffer: GIT_CAPTURE_MAX_BYTES
2275
+ }).trim();
2276
+ } catch {
2277
+ return null;
2315
2278
  }
2316
- return { ok: violations.length === 0, violations, warnings };
2317
2279
  }
2318
- function linesIndexOfClosingFence(content) {
2319
- return content.split(/\r?\n/).indexOf("---", 1);
2280
+ function probeHarnessWithStatus(root) {
2281
+ if (isFile2(join9(root, ".mstar", "status.json")))
2282
+ return join9(root, ".mstar");
2283
+ if (isFile2(join9(root, ".agents", "status.json")))
2284
+ return join9(root, ".agents");
2285
+ if (hasWorkflowSnapshot(join9(root, ".mstar")))
2286
+ return join9(root, ".mstar");
2287
+ if (hasWorkflowSnapshot(join9(root, ".agents")))
2288
+ return join9(root, ".agents");
2289
+ return null;
2320
2290
  }
2321
- function validateProjectRegister(doc) {
2322
- const violations = [];
2323
- if (!isPlainObject5(doc)) {
2324
- return {
2325
- ok: false,
2326
- violations: [violation7("high", "project.register.invalid", "project register must be an object")]
2327
- };
2291
+ function hasWorkflowSnapshot(harnessDir) {
2292
+ let workflowsDir;
2293
+ try {
2294
+ workflowsDir = resolveWorkflowDir(harnessDir, { harnessDir });
2295
+ } catch {
2296
+ workflowsDir = join9(harnessDir, "workflows");
2328
2297
  }
2329
- if (doc.entries === undefined) {
2330
- violations.push(violation7("high", "project.register.missing-entries", "missing required field: entries"));
2331
- } else if (!isPlainObject5(doc.entries)) {
2332
- violations.push(violation7("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
2333
- } else {
2334
- for (const [key, entries] of Object.entries(doc.entries)) {
2335
- if (key.trim() === "") {
2336
- violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
2337
- }
2338
- if (!Array.isArray(entries)) {
2339
- violations.push(violation7("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(key)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
2340
- continue;
2341
- }
2342
- for (const entry of entries) {
2343
- violations.push(...validateResidual(entry).violations);
2344
- if (!isPlainObject5(entry))
2345
- continue;
2346
- validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
2347
- if (entry.registered_at === undefined) {
2348
- violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
2349
- } else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
2350
- violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
2351
- }
2352
- if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
2353
- violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
2354
- }
2355
- if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
2356
- violations.push(violation7("medium", "project.register.mismatched-source-plan", `source_plan ${JSON.stringify(entry.source_plan)} does not match the entries key ${JSON.stringify(key)} — entries are keyed by plan id`));
2357
- }
2358
- }
2298
+ if (!isDirectory2(workflowsDir))
2299
+ return false;
2300
+ try {
2301
+ for (const entry of readdirSync5(workflowsDir, { withFileTypes: true })) {
2302
+ if (entry.isDirectory() && isFile2(join9(workflowsDir, entry.name, "snapshot.json")))
2303
+ return true;
2359
2304
  }
2305
+ } catch {
2306
+ return false;
2307
+ }
2308
+ return false;
2309
+ }
2310
+ function isLinkedWorktree(root) {
2311
+ const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
2312
+ const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
2313
+ if (gitDirRaw === null || commonRaw === null)
2314
+ return false;
2315
+ const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join9(root, gitDirRaw);
2316
+ const common = isAbsolute5(commonRaw) ? commonRaw : join9(root, commonRaw);
2317
+ if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
2318
+ return true;
2319
+ try {
2320
+ const gdParent = realpathSync3(dirname6(gitDir));
2321
+ const cmAbs = realpathSync3(common);
2322
+ return join9(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
2323
+ } catch {
2324
+ return false;
2360
2325
  }
2361
- return { ok: violations.length === 0, violations };
2362
2326
  }
2363
- function findingsCleanupGate(register, planId, opts) {
2364
- const mode = opts?.mode ?? "allow-residual";
2365
- const violations = [];
2366
- const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
2367
- if (entries === undefined) {
2368
- return { ok: true, violations };
2327
+ function sddWorkspace(planId, opts = {}) {
2328
+ if (!planId) {
2329
+ throw new SddScriptError(`usage: mstar sdd workspace PLAN_ID [CONTROL_ROOT]
2330
+ ` + " Set MSTAR_CONTROL_ROOT=<control_worktree_path> when running from a feature worktree.", 2);
2369
2331
  }
2370
- if (!Array.isArray(entries)) {
2371
- violations.push(violation7("high", "project.register.invalid-entry-list", `entries[${JSON.stringify(planId)}] must be an array of residual entries (one entry per residual; v1 multi-finding semantics)`));
2372
- return { ok: false, violations };
2332
+ const cwd = opts.cwd ?? process.cwd();
2333
+ const controlRoot = opts.controlRoot ?? (process.env.MSTAR_CONTROL_ROOT || undefined);
2334
+ let root;
2335
+ if (controlRoot) {
2336
+ if (!isDirectory2(controlRoot)) {
2337
+ throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
2338
+ }
2339
+ root = realpathSync3(controlRoot);
2340
+ } else {
2341
+ const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
2342
+ root = realpathSync3(topLevel ?? cwd);
2373
2343
  }
2374
- if (entries.length === 0) {
2375
- return { ok: true, violations };
2344
+ if (!controlRoot && isLinkedWorktree(root)) {
2345
+ throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
2346
+ ` + ` Refusing to create a second SDD tree under the feature checkout.
2347
+ ` + ` Re-run with MSTAR_CONTROL_ROOT=<control_worktree_path> or: mstar sdd workspace ${planId} <control_worktree_path>
2348
+ ` + ` See mstar-branch-worktree «Harness path SSOT under default gitignore».`, 1);
2376
2349
  }
2377
- for (const entry of entries) {
2378
- if (!isOpenResidual(entry))
2379
- continue;
2380
- const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
2381
- const label = `R#${id}`;
2382
- if (mode === "zero-residual") {
2383
- if (entry.severity === "nit") {
2384
- violations.push(violation7("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
2385
- } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
2386
- violations.push(violation7("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
2387
- } else if (entry.decision === "defer") {
2388
- if (typeof entry.target !== "string" || entry.target.trim() === "") {
2389
- violations.push(violation7("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
2390
- }
2350
+ const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
2351
+ let harnessDir;
2352
+ if (harnessOverride) {
2353
+ harnessDir = resolve7(root, harnessOverride);
2354
+ } else {
2355
+ const rc = findMstarc(root, root);
2356
+ const rcHarnessDir = rc !== null ? parseMstarc(readFileSync7(rc, "utf8")).harnessDir : undefined;
2357
+ if (rcHarnessDir) {
2358
+ harnessDir = resolve7(rc !== null ? dirname6(rc) : root, rcHarnessDir);
2359
+ } else {
2360
+ const probed = probeHarnessWithStatus(root);
2361
+ if (probed) {
2362
+ harnessDir = probed;
2363
+ } else if (isDirectory2(join9(root, ".mstar"))) {
2364
+ harnessDir = join9(root, ".mstar");
2365
+ } else if (isDirectory2(join9(root, ".agents"))) {
2366
+ harnessDir = join9(root, ".agents");
2391
2367
  } else {
2392
- violations.push(violation7("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
2368
+ harnessDir = join9(root, ".mstar");
2393
2369
  }
2394
- } else if (normalizeSeverity(entry.severity) === "critical") {
2395
- violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
2396
2370
  }
2397
2371
  }
2398
- return { ok: violations.length === 0, violations };
2372
+ const sddDir = resolveSddDir(harnessDir, planId);
2373
+ mkdirSync5(sddDir, { recursive: true });
2374
+ writeFileSync4(join9(sddDir, ".gitignore"), `*
2375
+ `);
2376
+ return realpathSync3(sddDir);
2399
2377
  }
2400
- function groupCount(values) {
2401
- const counts = new Map;
2402
- for (const value of values) {
2403
- const key = typeof value === "string" ? value : String(value);
2404
- counts.set(key, (counts.get(key) ?? 0) + 1);
2378
+ function taskBrief(planFile, taskN, outFile, opts = {}) {
2379
+ if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
2380
+ throw new SddScriptError("usage: mstar sdd task-brief PLAN_FILE TASK_NUMBER [OUTFILE]", 2);
2405
2381
  }
2406
- return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
2407
- }
2408
- function techDebtRollup(projectDir) {
2409
- const items = [];
2410
- let entries;
2382
+ let content;
2411
2383
  try {
2412
- entries = readdirSync5(projectDir, { withFileTypes: true });
2384
+ content = readFileSync7(planFile, "utf8");
2413
2385
  } catch {
2414
- entries = [];
2386
+ throw new SddScriptError(`no such plan file: ${planFile}`, 2);
2415
2387
  }
2416
- for (const project of entries) {
2417
- if (!project.isDirectory())
2418
- continue;
2419
- const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
2420
- if (!existsSync6(registerPath))
2421
- continue;
2422
- let register;
2388
+ let out;
2389
+ if (outFile) {
2390
+ out = outFile;
2391
+ } else {
2392
+ const sddDir = opts.sddDir ?? process.env.SDD_DIR;
2393
+ if (!sddDir) {
2394
+ throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
2395
+ }
2396
+ mkdirSync5(sddDir, { recursive: true });
2397
+ out = join9(sddDir, `task-${taskN}-brief.md`);
2398
+ }
2399
+ const records = content.endsWith(`
2400
+ `) ? content.split(`
2401
+ `).slice(0, -1) : content.split(`
2402
+ `);
2403
+ const headingRe = /^#+[ \t]+Task[ \t]+[0-9]+/;
2404
+ const targetRe = new RegExp(`^#+[ ]+Task[ ]+${taskN}([^0-9]|$)`);
2405
+ let infence = false;
2406
+ let intask = false;
2407
+ const printed = [];
2408
+ for (const line of records) {
2409
+ if (/^```/.test(line))
2410
+ infence = !infence;
2411
+ if (!infence && headingRe.test(line))
2412
+ intask = targetRe.test(line);
2413
+ if (intask)
2414
+ printed.push(line);
2415
+ }
2416
+ const output = printed.length > 0 ? `${printed.join(`
2417
+ `)}
2418
+ ` : "";
2419
+ writeFileSync4(out, output);
2420
+ if (printed.length === 0) {
2421
+ throw new SddScriptError(`task ${taskN} not found in ${planFile} (no heading matching Task ${taskN})`, 3);
2422
+ }
2423
+ return out;
2424
+ }
2425
+ function reviewPackage(base, head, outFile, opts = {}) {
2426
+ if (!base || !head) {
2427
+ throw new SddScriptError("usage: mstar sdd review-package BASE HEAD [OUTFILE]", 2);
2428
+ }
2429
+ const cwd = opts.cwd ?? process.cwd();
2430
+ const verifyRef = (ref, what) => {
2423
2431
  try {
2424
- register = readJson(registerPath);
2432
+ execFileSync3("git", ["rev-parse", "--verify", "--quiet", ref], { cwd, stdio: ["ignore", "pipe", "pipe"] });
2425
2433
  } catch {
2426
- continue;
2434
+ throw new SddScriptError(`bad ${what}: ${ref}`, 2);
2427
2435
  }
2428
- if (!isPlainObject5(register) || !isPlainObject5(register.entries))
2429
- continue;
2430
- for (const [plan, planEntries] of Object.entries(register.entries)) {
2431
- if (!Array.isArray(planEntries))
2432
- continue;
2433
- for (const entry of planEntries) {
2434
- if (!isPlainObject5(entry) || !isOpenResidual(entry))
2435
- continue;
2436
- items.push({ plan, entry });
2437
- }
2436
+ };
2437
+ verifyRef(base, "BASE");
2438
+ verifyRef(head, "HEAD");
2439
+ let out;
2440
+ if (outFile) {
2441
+ out = outFile;
2442
+ } else {
2443
+ const sddDir = opts.sddDir ?? process.env.SDD_DIR;
2444
+ if (!sddDir) {
2445
+ throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
2438
2446
  }
2447
+ mkdirSync5(sddDir, { recursive: true });
2448
+ const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
2449
+ const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
2450
+ out = join9(sddDir, `review-${shortBase}..${shortHead}.diff`);
2439
2451
  }
2440
- const bySeverity = {};
2441
- for (const severity of SEVERITY_ORDER) {
2442
- bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
2452
+ const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
2453
+ const parts = [
2454
+ Buffer.from(`# Review package: ${base}..${head}
2455
+
2456
+ ## Commits
2457
+ `),
2458
+ run(["log", "--oneline", `${base}..${head}`]),
2459
+ Buffer.from(`
2460
+ ## Files changed
2461
+ `),
2462
+ run(["diff", "--stat", `${base}..${head}`]),
2463
+ Buffer.from(`
2464
+ ## Diff
2465
+ `),
2466
+ run(["diff", "-U10", `${base}..${head}`])
2467
+ ];
2468
+ writeFileSync4(out, Buffer.concat(parts));
2469
+ return out;
2470
+ }
2471
+ function assertBaseSha(ref, opts = {}) {
2472
+ if (typeof ref !== "string" || !/^[0-9a-f]{4,40}$/i.test(ref)) {
2473
+ throw new SddScriptError(`assertBaseSha: BASE must be a commit SHA (full or prefix); got ${JSON.stringify(ref)}. ` + "Never use HEAD~1 as review BASE (multi-commit tasks truncate).", 2);
2474
+ }
2475
+ try {
2476
+ execFileSync3("git", ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
2477
+ cwd: opts.cwd,
2478
+ stdio: ["ignore", "pipe", "pipe"]
2479
+ });
2480
+ } catch {
2481
+ throw new SddScriptError(`assertBaseSha: commit not found: ${ref}`, 2);
2443
2482
  }
2444
- const computed = {
2445
- total_open: items.length,
2446
- by_severity: bySeverity,
2447
- by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
2448
- by_plan: groupCount(items.map(({ plan }) => plan))
2449
- };
2450
- const stored = null;
2451
- const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
2452
- const overall = "DRIFT";
2453
- return { computed, stored, checks, overall };
2454
2483
  }
2455
- function listProjectReferenceFiles(projectDir) {
2456
- const root = join9(projectDir, PROJECT_REFERENCES_DIR);
2457
- let entries;
2484
+ function taskReportExists(sddDir, taskN) {
2485
+ try {
2486
+ const st = statSync4(join9(sddDir, `task-${taskN}-report.md`));
2487
+ return st.isFile() && st.size > 0;
2488
+ } catch {
2489
+ return false;
2490
+ }
2491
+ }
2492
+ function readProgressLedger(sddDir) {
2493
+ let content;
2458
2494
  try {
2459
- entries = readdirSync5(root, { withFileTypes: true });
2495
+ content = readFileSync7(join9(sddDir, "progress.md"), "utf8");
2460
2496
  } catch {
2461
2497
  return [];
2462
2498
  }
2463
- const files = [];
2464
- for (const entry of entries) {
2465
- if (entry.name === PROJECT_ROADMAP_FILE || entry.name === PROJECT_REGISTER_FILE)
2466
- continue;
2467
- if (entry.isFile()) {
2468
- files.push(entry.name);
2469
- } else if (entry.isDirectory()) {
2470
- let nested;
2471
- try {
2472
- nested = readdirSync5(join9(root, entry.name), { withFileTypes: true });
2473
- } catch {
2474
- continue;
2475
- }
2476
- for (const child of nested) {
2477
- if (child.isFile())
2478
- files.push(`${entry.name}/${child.name}`);
2479
- }
2480
- }
2499
+ return content.split(`
2500
+ `).map((line) => line.trim()).filter((line) => line.length > 0);
2501
+ }
2502
+ function implementerSessionStickyRules(input) {
2503
+ const { session, nextTask, microBatchTasks = 1 } = input;
2504
+ if (session.session_mode !== "sticky") {
2505
+ return { resume: false, reason: `session_mode is '${session.session_mode}'; sticky resume requires 'sticky'` };
2481
2506
  }
2482
- return files.sort();
2507
+ if (typeof session.host_agent_id !== "string" || session.host_agent_id.length === 0) {
2508
+ return {
2509
+ resume: false,
2510
+ reason: "host_agent_id is missing from implementer-session.json; fall back to fresh for this task " + "(mstar-sdd SKILL.md red flag: resume implementer without host_agent_id)"
2511
+ };
2512
+ }
2513
+ if (nextTask <= session.last_task) {
2514
+ return {
2515
+ resume: false,
2516
+ reason: `nextTask ${nextTask} <= last_task ${session.last_task}; task already completed in this session`
2517
+ };
2518
+ }
2519
+ if (microBatchTasks < 1 || microBatchTasks > 3) {
2520
+ return {
2521
+ resume: false,
2522
+ reason: `micro-batch of ${microBatchTasks} tasks is outside 1..3 (max 3 without user override, ` + "sticky-implementer-session.md § Micro-batch fallback)"
2523
+ };
2524
+ }
2525
+ return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
2483
2526
  }
2484
2527
  // src/migrate.ts
2485
- import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "node:fs";
2528
+ import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync5 } from "node:fs";
2486
2529
  import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
2487
2530
  var MIGRATE_STATUS_FILE = "status.json";
2488
2531
  var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
@@ -2984,7 +3027,7 @@ async function applyMigratePlan(plan) {
2984
3027
  const content = notes.lines.length > 0 ? `${notes.lines.join(`
2985
3028
  `)}
2986
3029
  ` : "";
2987
- writeFileSync4(filePath, content, "utf8");
3030
+ writeFileSync5(filePath, content, "utf8");
2988
3031
  }
2989
3032
  if (plan.register !== null) {
2990
3033
  const gate2 = validateProjectRegister(plan.register.data);
@@ -3000,7 +3043,7 @@ async function applyMigratePlan(plan) {
3000
3043
  if (plan.roadmap !== null) {
3001
3044
  const filePath = projectTargetOf(plan.roadmap.file);
3002
3045
  mkdirSync6(dirname7(filePath), { recursive: true });
3003
- writeFileSync4(filePath, plan.roadmap.content, "utf8");
3046
+ writeFileSync5(filePath, plan.roadmap.content, "utf8");
3004
3047
  }
3005
3048
  const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
3006
3049
  if (!rootGate.ok) {
@@ -3422,7 +3465,7 @@ function completenessLevel(frontmatterText, checklist) {
3422
3465
  return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
3423
3466
  }
3424
3467
  // src/audit.ts
3425
- import { existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
3468
+ import { existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync6 } from "node:fs";
3426
3469
  import { basename as basename4, join as join11, resolve as resolve9, sep as sep3 } from "node:path";
3427
3470
  function violation9(severity, code, message, fix) {
3428
3471
  return { ok: false, severity, code, message, fix };
@@ -3534,8 +3577,17 @@ function readPlanFileSummary(filePath) {
3534
3577
  const blocks = parseStatusBlocks(text);
3535
3578
  return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
3536
3579
  }
3580
+ function extractSecurityDispositionSections(text) {
3581
+ const grab = (heading) => {
3582
+ const match = text.match(new RegExp(`(?:^|\\r?\\n)##[ \\t]+${heading}[ \\t]*\\r?\\n(?:[ \\t]*\\r?\\n)?([\\s\\S]*?)(?=\\r?\\n## |$)`, "i"));
3583
+ if (!match)
3584
+ return [];
3585
+ return match[1].split(/\r?\n/).map((line) => line.replace(/\r$/, "")).filter((line) => line.startsWith("- "));
3586
+ };
3587
+ return { needsVerification: grab("Needs verification"), hardeningChecked: grab("Hardening & checked notes") };
3588
+ }
3537
3589
  function renderIndex(params) {
3538
- const { date, repoName, repoShortSha, rows, rejected } = params;
3590
+ const { date, repoName, repoShortSha, rows, rejected, needsVerification, hardeningChecked } = params;
3539
3591
  const findingsRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.category} | ${escapeCell(truncate(r.impact, 80))} | ${r.effort} | ${r.risk} | ${r.confidence} | ${escapeCell(truncate(r.evidence, 80))} |`).join(`
3540
3592
  `);
3541
3593
  const directionRows = rows.filter((r) => r.category === "direction").map((r) => `- ${escapeCell(r.title)} — ${escapeCell(truncate(r.impact, 120))}`).join(`
@@ -3556,6 +3608,12 @@ function renderIndex(params) {
3556
3608
  if (directionRows !== "") {
3557
3609
  sections.push("", "## Direction", "", directionRows);
3558
3610
  }
3611
+ if (needsVerification.length > 0) {
3612
+ sections.push("", "## Needs verification", "", ...needsVerification);
3613
+ }
3614
+ if (hardeningChecked.length > 0) {
3615
+ sections.push("", "## Hardening & checked notes", "", ...hardeningChecked);
3616
+ }
3559
3617
  sections.push("", "## Execution order & status", "", "| Plan | Title | Priority | Effort | Depends on | Status |", "|------|-------|----------|--------|------------|--------|", executionRows);
3560
3618
  if (rejectedRows !== "") {
3561
3619
  sections.push("", "## Findings considered and rejected", "", rejectedRows);
@@ -3569,6 +3627,8 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
3569
3627
  const date = options.date ?? new Date().toISOString().slice(0, 10);
3570
3628
  const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
3571
3629
  mkdirSync7(outDir, { recursive: true });
3630
+ const existingReadme = join11(outDir, "README.md");
3631
+ const carried = existsSync7(existingReadme) ? extractSecurityDispositionSections(readFileSync9(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
3572
3632
  const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
3573
3633
  let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
3574
3634
  const written = [];
@@ -3584,7 +3644,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
3584
3644
  }
3585
3645
  usedSlugs.add(slug);
3586
3646
  const file = `${num}-${slug}.md`;
3587
- writeFileSync5(join11(outDir, file), renderPlanFile(finding, plannedAt));
3647
+ writeFileSync6(join11(outDir, file), renderPlanFile(finding, plannedAt));
3588
3648
  written.push(file);
3589
3649
  next++;
3590
3650
  }
@@ -3622,12 +3682,16 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
3622
3682
  row.dependsOn = finding.dependsOn ?? "none";
3623
3683
  }
3624
3684
  });
3625
- writeFileSync5(join11(outDir, "README.md"), renderIndex({
3685
+ const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(nv.lead)}: ${escapeCell(nv.how)}${nv.evidence ? ` (${escapeCell(nv.evidence)})` : ""}`) : carried.needsVerification;
3686
+ const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(hc.text)}`) : carried.hardeningChecked;
3687
+ writeFileSync6(join11(outDir, "README.md"), renderIndex({
3626
3688
  date,
3627
3689
  repoName: options.repoName ?? "repo",
3628
3690
  repoShortSha: options.repoShortSha ?? "unknown",
3629
3691
  rows,
3630
- rejected: options.rejected ?? []
3692
+ rejected: options.rejected ?? [],
3693
+ needsVerification: needsVerificationLines,
3694
+ hardeningChecked: hardeningCheckedLines
3631
3695
  }));
3632
3696
  return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
3633
3697
  }
@@ -4704,6 +4768,7 @@ export {
4704
4768
  resolveSpecsDir,
4705
4769
  resolveSkillRoot,
4706
4770
  resolveSddDir,
4771
+ resolveScaffoldDirs,
4707
4772
  resolveRepoEnforcement,
4708
4773
  resolveProjectRoot,
4709
4774
  resolveProjectDir,
@@ -4753,6 +4818,7 @@ export {
4753
4818
  evaluatePhaseGate,
4754
4819
  emitGitignoreSnippet,
4755
4820
  detectHost,
4821
+ detectHarnessKind,
4756
4822
  compoundRefreshScope,
4757
4823
  composeDispatchGate,
4758
4824
  completenessLevel,