@mstar-harness/engine 2.4.1 → 3.1.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
@@ -81,27 +81,118 @@ function harnessVersionFrom(moduleDir) {
81
81
  function readHarnessVersion() {
82
82
  return harnessVersionFrom(dirname(fileURLToPath(import.meta.url)));
83
83
  }
84
+ // src/mstarc.ts
85
+ import { readFileSync as readFileSync2, statSync } from "node:fs";
86
+ import { dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
87
+ var MSTARC_FILE = ".mstarc";
88
+ var MSTARC_SECTION = "config";
89
+ var MSTARC_HARNESS_DIR_KEY = "harness_dir";
90
+ var MSTARC_PLAN_DIR_KEY = "plan_dir";
91
+ var MSTARC_SDD_DIR_KEY = "sdd_dir";
92
+ var MSTARC_ITERATION_DIR_KEY = "iteration_dir";
93
+ var MSTARC_KNOWLEDGE_DIR_KEY = "knowledge_dir";
94
+ var MSTARC_SPECS_DIR_KEY = "specs_dir";
95
+ var MSTARC_WORKFLOW_DIR_KEY = "workflow_dir";
96
+ var MSTARC_PROJECT_DIR_KEY = "project_dir";
97
+ var MSTARC_ENFORCEMENT_KEY = "enforcement";
98
+ var CONFIG_KEYS = {
99
+ [MSTARC_HARNESS_DIR_KEY]: "harnessDir",
100
+ [MSTARC_PLAN_DIR_KEY]: "planDir",
101
+ [MSTARC_SDD_DIR_KEY]: "sddDir",
102
+ [MSTARC_ITERATION_DIR_KEY]: "iterationDir",
103
+ [MSTARC_KNOWLEDGE_DIR_KEY]: "knowledgeDir",
104
+ [MSTARC_SPECS_DIR_KEY]: "specsDir",
105
+ [MSTARC_WORKFLOW_DIR_KEY]: "workflowDir",
106
+ [MSTARC_PROJECT_DIR_KEY]: "projectDir",
107
+ [MSTARC_ENFORCEMENT_KEY]: "enforcement"
108
+ };
109
+ function parseMstarc(text) {
110
+ let section = null;
111
+ const out = {};
112
+ for (const raw of text.split(/\r?\n/)) {
113
+ const line = raw.trim();
114
+ if (line === "" || line.startsWith("#") || line.startsWith(";"))
115
+ continue;
116
+ const header = /^\[([^\]]+)\]$/.exec(line);
117
+ if (header !== null) {
118
+ section = header[1].trim();
119
+ continue;
120
+ }
121
+ if (section !== MSTARC_SECTION)
122
+ continue;
123
+ const eq = line.indexOf("=");
124
+ if (eq === -1)
125
+ continue;
126
+ const field = CONFIG_KEYS[line.slice(0, eq).trim()];
127
+ if (field === undefined)
128
+ continue;
129
+ const value = line.slice(eq + 1).trim();
130
+ if (value === "")
131
+ continue;
132
+ if (field === "enforcement" && value !== "hard" && value !== "soft")
133
+ continue;
134
+ out[field] = value;
135
+ }
136
+ return out;
137
+ }
138
+ function isFile(file) {
139
+ try {
140
+ return statSync(file).isFile();
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+ function findMstarc(startDir, boundary) {
146
+ let dir = resolve2(startDir);
147
+ const bound = resolve2(boundary);
148
+ for (;; ) {
149
+ if (!isAtOrBelow(dir, bound))
150
+ return null;
151
+ const candidate = join2(dir, MSTARC_FILE);
152
+ if (isFile(candidate))
153
+ return candidate;
154
+ if (dir === bound)
155
+ return null;
156
+ const parent = dirname2(dir);
157
+ if (parent === dir)
158
+ return null;
159
+ dir = parent;
160
+ }
161
+ }
162
+ function loadMstarc(startDir, boundary) {
163
+ const file = findMstarc(startDir, boundary);
164
+ if (file === null)
165
+ return null;
166
+ return { file, dir: dirname2(file), config: parseMstarc(readFileSync2(file, "utf8")) };
167
+ }
168
+ function isAtOrBelow(dir, root) {
169
+ const rel = relative(root, dir);
170
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
171
+ }
84
172
  // src/path.ts
85
- import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, statSync } from "node:fs";
173
+ import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
86
174
  import { execFileSync } from "node:child_process";
87
- import { basename as basename2, dirname as dirname2, isAbsolute, join as join2, relative, resolve as resolve2 } from "node:path";
175
+ import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
88
176
  function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
89
- const start = resolve2(startDir);
177
+ const start = resolve3(startDir);
90
178
  const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
91
179
  if (explicit)
92
- return resolve2(start, explicit);
93
- const boundary = resolve2(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
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);
94
185
  let dir = start;
95
186
  for (;; ) {
96
- if (!isAtOrBelow(dir, boundary))
187
+ if (!isAtOrBelow2(dir, boundary))
97
188
  return null;
98
- for (const candidate of [join2(dir, ".mstar"), join2(dir, ".agents"), join2(dir, ".plans"), join2(dir, "plans")]) {
189
+ for (const candidate of [join3(dir, ".mstar"), join3(dir, ".agents"), join3(dir, ".plans"), join3(dir, "plans")]) {
99
190
  if (isDirectory(candidate))
100
191
  return candidate;
101
192
  }
102
193
  if (dir === boundary)
103
194
  return null;
104
- const parent = dirname2(dir);
195
+ const parent = dirname3(dir);
105
196
  if (parent === dir)
106
197
  return null;
107
198
  dir = parent;
@@ -119,41 +210,56 @@ function defaultWorkspaceRoot(startDir) {
119
210
  let boundary = startDir;
120
211
  for (const segment of cdup.split(/[\\/]/)) {
121
212
  if (segment && segment !== ".")
122
- boundary = dirname2(boundary);
213
+ boundary = dirname3(boundary);
123
214
  }
124
- return resolve2(boundary);
215
+ return resolve3(boundary);
125
216
  } catch {}
126
217
  return startDir;
127
218
  }
128
- function isAtOrBelow(dir, root) {
129
- const rel = relative(root, dir);
130
- return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
219
+ function isAtOrBelow2(dir, root) {
220
+ const rel = relative2(root, dir);
221
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
222
+ }
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;
131
228
  }
132
229
  function resolveSpecsDir(harnessDir, opts = {}) {
133
- const harness = resolve2(harnessDir);
134
- const repoRoot = dirname2(harness);
230
+ const declared = mstarcDirOverride(harnessDir, "specsDir");
231
+ if (declared !== null) {
232
+ if (opts.create !== false)
233
+ mkdirSync2(declared, { recursive: true });
234
+ return declared;
235
+ }
236
+ const harness = resolve3(harnessDir);
237
+ const repoRoot = dirname3(harness);
135
238
  const candidates = [
136
- join2(harness, "specs"),
137
- join2(repoRoot, "docs", "specs"),
138
- join2(repoRoot, "specs"),
139
- join2(harness, "designs"),
140
- join2(repoRoot, "designs")
239
+ join3(harness, "specs"),
240
+ join3(repoRoot, "docs", "specs"),
241
+ join3(repoRoot, "specs"),
242
+ join3(harness, "designs"),
243
+ join3(repoRoot, "designs")
141
244
  ];
142
245
  for (const candidate of candidates) {
143
246
  if (isDirectory(candidate) && hasFiles(candidate))
144
247
  return candidate;
145
248
  }
146
- const fallback = join2(harness, "specs");
249
+ const fallback = join3(harness, "specs");
147
250
  if (opts.create !== false)
148
251
  mkdirSync2(fallback, { recursive: true });
149
252
  return fallback;
150
253
  }
151
254
  function resolvePlanDir(harnessDir) {
152
- const dir = resolve2(harnessDir);
255
+ const declared = mstarcDirOverride(harnessDir, "planDir");
256
+ if (declared !== null)
257
+ return declared;
258
+ const dir = resolve3(harnessDir);
153
259
  const name = basename2(dir);
154
260
  if (name === ".plans" || name === "plans")
155
261
  return dir;
156
- return join2(dir, "plans");
262
+ return join3(dir, "plans");
157
263
  }
158
264
  function assertSafePathComponent(value, what) {
159
265
  if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
@@ -162,24 +268,48 @@ function assertSafePathComponent(value, what) {
162
268
  }
163
269
  function resolveSddDir(harnessDir, planId) {
164
270
  assertSafePathComponent(planId, "planId");
165
- return join2(resolve2(harnessDir), "sdd", 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);
166
275
  }
167
276
  function resolveIterationDir(harnessDir) {
168
- return join2(resolve2(harnessDir), "iterations");
277
+ const declared = mstarcDirOverride(harnessDir, "iterationDir");
278
+ if (declared !== null)
279
+ return declared;
280
+ return join3(resolve3(harnessDir), "iterations");
281
+ }
282
+ function resolveKnowledgeDir(harnessDir) {
283
+ const declared = mstarcDirOverride(harnessDir, "knowledgeDir");
284
+ if (declared !== null)
285
+ return declared;
286
+ return join3(resolve3(harnessDir), "knowledge");
287
+ }
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)`);
292
+ }
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");
298
+ }
299
+ function resolveProjectDir(startDir = process.cwd(), opts = {}) {
300
+ return resolveHarnessSubdir(startDir, opts, "projectDir", "projects");
169
301
  }
170
302
  var EMPTY_STATUS_TEMPLATE = {
171
- version: 1,
303
+ version: 2,
172
304
  updated_at: "1970-01-01",
173
- plans: [],
174
- residual_findings: {},
175
- metadata: {}
305
+ workflows: []
176
306
  };
177
307
  var SCAFFOLD_DIRS = ["plans", "iterations", "knowledge", "specs", "sdd"];
178
308
  function scaffoldHarness(root) {
179
- const harnessDir = join2(resolve2(root), ".mstar");
309
+ const harnessDir = join3(resolve3(root), ".mstar");
180
310
  for (const dir of SCAFFOLD_DIRS)
181
- mkdirSync2(join2(harnessDir, dir), { recursive: true });
182
- const statusPath = join2(harnessDir, "status.json");
311
+ mkdirSync2(join3(harnessDir, dir), { recursive: true });
312
+ const statusPath = join3(harnessDir, "status.json");
183
313
  if (Object.keys(readJson(statusPath)).length === 0)
184
314
  writeJson(statusPath, EMPTY_STATUS_TEMPLATE);
185
315
  return harnessDir;
@@ -193,6 +323,8 @@ var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
193
323
  !.mstar/knowledge/**
194
324
  !.mstar/specs/
195
325
  !.mstar/specs/**
326
+ # .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
327
+ .mstarc
196
328
  `;
197
329
  var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
198
330
  # Default-ignore everything under .agents/, then re-include the tracked results.
@@ -215,11 +347,11 @@ function emitGitignoreSnippet(kind) {
215
347
  return `${GITIGNORE_SNIPPET}${GITIGNORE_SNIPPET_AGENTS}`;
216
348
  }
217
349
  function validateGitignore(root) {
218
- const gitignorePath = join2(resolve2(root), ".gitignore");
350
+ const gitignorePath = join3(resolve3(root), ".gitignore");
219
351
  const kind = detectHarnessKind(resolveHarnessDir(root));
220
352
  let content;
221
353
  try {
222
- content = readFileSync2(gitignorePath, "utf8");
354
+ content = readFileSync3(gitignorePath, "utf8");
223
355
  } catch {
224
356
  return {
225
357
  ok: false,
@@ -263,7 +395,7 @@ function validateGitignore(root) {
263
395
  function detectHarnessKind(harnessDir) {
264
396
  if (!harnessDir)
265
397
  return null;
266
- const name = basename2(resolve2(harnessDir));
398
+ const name = basename2(resolve3(harnessDir));
267
399
  if (name === ".mstar")
268
400
  return "mstar";
269
401
  if (name === ".agents")
@@ -271,7 +403,7 @@ function detectHarnessKind(harnessDir) {
271
403
  return null;
272
404
  }
273
405
  function assertPlanWritingPath(planPath, harnessDir) {
274
- const planAbs = resolve2(planPath);
406
+ const planAbs = resolve3(planPath);
275
407
  if (!harnessDir) {
276
408
  return {
277
409
  ok: false,
@@ -282,8 +414,8 @@ function assertPlanWritingPath(planPath, harnessDir) {
282
414
  };
283
415
  }
284
416
  const planDir = resolvePlanDir(harnessDir);
285
- const rel = relative(planDir, planAbs);
286
- const inside = rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
417
+ const rel = relative2(planDir, planAbs);
418
+ const inside = rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
287
419
  if (!inside) {
288
420
  return {
289
421
  ok: false,
@@ -302,7 +434,7 @@ function assertPlanWritingPath(planPath, harnessDir) {
302
434
  }
303
435
  function isDirectory(dir) {
304
436
  try {
305
- return statSync(dir).isDirectory();
437
+ return statSync2(dir).isDirectory();
306
438
  } catch {
307
439
  return false;
308
440
  }
@@ -311,7 +443,7 @@ function hasFiles(dir) {
311
443
  try {
312
444
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
313
445
  if (entry.isDirectory()) {
314
- if (hasFiles(join2(dir, entry.name)))
446
+ if (hasFiles(join3(dir, entry.name)))
315
447
  return true;
316
448
  } else if (entry.isFile()) {
317
449
  return true;
@@ -323,12 +455,12 @@ function hasFiles(dir) {
323
455
  }
324
456
  }
325
457
  // src/status.ts
326
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
327
- import { join as join4, resolve as resolve4 } from "node:path";
458
+ import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
459
+ import { dirname as dirname5, join as join6, resolve as resolve5, sep } from "node:path";
328
460
 
329
461
  // src/lease.ts
330
- import { mkdirSync as mkdirSync3, rmdirSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
331
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join3, resolve as resolve3 } from "node:path";
462
+ import { mkdirSync as mkdirSync3, rmdirSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
463
+ import { dirname as dirname4, isAbsolute as isAbsolute3, join as join4, resolve as resolve4 } from "node:path";
332
464
  import { setTimeout as sleep } from "node:timers/promises";
333
465
  import { AsyncLocalStorage } from "node:async_hooks";
334
466
  function isPlainObject(value) {
@@ -370,7 +502,7 @@ function validateExecutionLease(lease) {
370
502
  violations.push(violation("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
371
503
  } else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
372
504
  violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
373
- } else if (!isAbsolute2(lease.worktree_path)) {
505
+ } else if (!isAbsolute3(lease.worktree_path)) {
374
506
  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)"));
375
507
  }
376
508
  validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
@@ -500,13 +632,11 @@ function canSteal(lease, holder, opts = {}) {
500
632
  return opts.userOverride === true;
501
633
  }
502
634
  function planExecutionLeaseLocations(row) {
503
- const meta = row.metadata;
504
- const metadataLease = meta && typeof meta === "object" && !Array.isArray(meta) ? meta.execution_lease : undefined;
505
- return { row: row.execution_lease, metadata: metadataLease };
635
+ return { row: row.execution_lease };
506
636
  }
507
637
  function verifyPlanExecutionLease(row, planId) {
508
- const { row: rowLease, metadata: metadataLease } = planExecutionLeaseLocations(row);
509
- const lease = rowLease !== undefined ? rowLease : metadataLease;
638
+ const { row: rowLease } = planExecutionLeaseLocations(row);
639
+ const lease = rowLease;
510
640
  if (lease === undefined) {
511
641
  if (row.status === "InProgress") {
512
642
  return {
@@ -519,16 +649,11 @@ function verifyPlanExecutionLease(row, planId) {
519
649
  return {
520
650
  ok: false,
521
651
  violations: [
522
- violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease (neither plans[].execution_lease nor legacy plans[].metadata.execution_lease)`)
652
+ violation("high", "lease.verify.missing", `plan ${planId} has no execution_lease at the SSOT location plans[].execution_lease`)
523
653
  ]
524
654
  };
525
655
  }
526
656
  const violations = [];
527
- if (rowLease !== undefined && metadataLease !== undefined) {
528
- violations.push(violation("high", "lease.verify.dual-write", "execution_lease present in BOTH plans[].execution_lease (SSOT) and plans[].metadata.execution_lease — the row-level lease wins; delete the metadata copy to remove the dual write"));
529
- } else if (rowLease === undefined) {
530
- violations.push(violation("high", "lease.verify.non-ssot-location", "execution_lease found only under plans[].metadata.execution_lease — the SSOT location is plans[].execution_lease; the metadata location is a legacy/hand-written read-compat fallback, not equivalent to SSOT success (migrate the lease to the plan row)"));
531
- }
532
657
  violations.push(...validateExecutionLease(lease).violations);
533
658
  return { ok: violations.length === 0, violations, lease };
534
659
  }
@@ -536,7 +661,7 @@ var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
536
661
  var LOCKDIR_HOLDER_PID = "holder.pid";
537
662
  var heldLockDirs = new AsyncLocalStorage;
538
663
  async function withStatusWriteLock(statusPath, fn, opts = {}) {
539
- const lockDir = join3(dirname3(resolve3(statusPath)), STATUS_WRITE_LOCKDIR);
664
+ const lockDir = join4(dirname4(resolve4(statusPath)), STATUS_WRITE_LOCKDIR);
540
665
  const held = heldLockDirs.getStore();
541
666
  if (held !== undefined && held.has(lockDir)) {
542
667
  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`);
@@ -548,7 +673,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
548
673
  for (;; ) {
549
674
  try {
550
675
  mkdirSync3(lockDir);
551
- const st = statSync2(lockDir);
676
+ const st = statSync3(lockDir);
552
677
  acquired = { dev: st.dev, ino: st.ino };
553
678
  break;
554
679
  } catch (error) {
@@ -561,7 +686,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
561
686
  }
562
687
  }
563
688
  try {
564
- writeFileSync2(join3(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
689
+ writeFileSync2(join4(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
565
690
  } catch {}
566
691
  const owns = held ?? new Set;
567
692
  owns.add(lockDir);
@@ -570,10 +695,10 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
570
695
  } finally {
571
696
  owns.delete(lockDir);
572
697
  try {
573
- const current = statSync2(lockDir);
698
+ const current = statSync3(lockDir);
574
699
  if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
575
700
  try {
576
- unlinkSync2(join3(lockDir, LOCKDIR_HOLDER_PID));
701
+ unlinkSync2(join4(lockDir, LOCKDIR_HOLDER_PID));
577
702
  } catch {}
578
703
  rmdirSync(lockDir);
579
704
  }
@@ -818,16 +943,141 @@ function antiRecursionPrecheck(subagentType, executeAs) {
818
943
  return { ok: true, violations: [] };
819
944
  }
820
945
 
946
+ // src/workflow.ts
947
+ import { mkdirSync as mkdirSync4 } from "node:fs";
948
+ import { join as join5 } from "node:path";
949
+ var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
950
+ var WORKFLOW_LIFECYCLE_STATUSES = ["running", "paused", "completed", "failed", "stopped"];
951
+ var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
952
+ var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
953
+ function isPlainObject2(value) {
954
+ return typeof value === "object" && value !== null && !Array.isArray(value);
955
+ }
956
+ function violation3(severity, code, message, fix) {
957
+ return { ok: false, severity, code, message, fix };
958
+ }
959
+ function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
960
+ if (value === undefined) {
961
+ violations.push(violation3("high", missingCode, `missing required field: ${field}`));
962
+ } else if (typeof value !== "string" || value.trim() === "") {
963
+ violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
964
+ }
965
+ }
966
+ function validateWorkflowSnapshot(doc) {
967
+ const violations = [];
968
+ if (!isPlainObject2(doc)) {
969
+ return {
970
+ ok: false,
971
+ violations: [violation3("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
972
+ };
973
+ }
974
+ if (doc.schema_version === undefined) {
975
+ violations.push(violation3("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
976
+ } else if (doc.schema_version !== 1) {
977
+ 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)`));
978
+ }
979
+ if (doc.version !== undefined) {
980
+ 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"));
981
+ }
982
+ validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
983
+ if (doc.type === undefined) {
984
+ violations.push(violation3("high", "workflow.snapshot.missing-type", "missing required field: type"));
985
+ } else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
986
+ violations.push(violation3("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
987
+ }
988
+ if (doc.status === undefined) {
989
+ violations.push(violation3("high", "workflow.snapshot.missing-status", "missing required field: status"));
990
+ } else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
991
+ violations.push(violation3("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
992
+ }
993
+ validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
994
+ validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
995
+ if (doc.ended_at !== undefined) {
996
+ validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
997
+ }
998
+ if (doc.phase !== undefined && typeof doc.phase !== "string") {
999
+ violations.push(violation3("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
1000
+ }
1001
+ if (doc.plans === undefined) {
1002
+ violations.push(violation3("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
1003
+ } else if (!Array.isArray(doc.plans)) {
1004
+ violations.push(violation3("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
1005
+ } else {
1006
+ for (const row of doc.plans) {
1007
+ violations.push(...validatePlanRow(row).violations);
1008
+ if (isPlainObject2(row) && row.execution_lease !== undefined) {
1009
+ violations.push(...validateExecutionLease(row.execution_lease).violations);
1010
+ }
1011
+ }
1012
+ }
1013
+ if (doc.execution_policy !== undefined) {
1014
+ if (!isPlainObject2(doc.execution_policy)) {
1015
+ violations.push(violation3("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
1016
+ }
1017
+ }
1018
+ if (doc.integration_merge_lease !== undefined) {
1019
+ violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
1020
+ }
1021
+ if (doc.branch !== undefined) {
1022
+ if (!isPlainObject2(doc.branch)) {
1023
+ violations.push(violation3("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
1024
+ } else {
1025
+ for (const key of ["base", "integration", "target"]) {
1026
+ if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
1027
+ violations.push(violation3("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
1028
+ }
1029
+ }
1030
+ }
1031
+ }
1032
+ if (doc.control_worktree_path !== undefined) {
1033
+ validateNonEmptyString2(violations, doc.control_worktree_path, "control_worktree_path", "workflow.snapshot.missing-control-worktree-path", "workflow.snapshot.invalid-control-worktree-path");
1034
+ }
1035
+ if (doc.legacy_metadata !== undefined && !isPlainObject2(doc.legacy_metadata)) {
1036
+ violations.push(violation3("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
1037
+ }
1038
+ if (doc.compass_ref !== undefined) {
1039
+ validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
1040
+ }
1041
+ const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
1042
+ if (terminal) {
1043
+ if (doc.ended_at === undefined) {
1044
+ 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`));
1045
+ }
1046
+ if (Array.isArray(doc.plans)) {
1047
+ for (const row of doc.plans) {
1048
+ if (isPlainObject2(row) && row.execution_lease !== undefined) {
1049
+ 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`));
1050
+ }
1051
+ }
1052
+ }
1053
+ if (doc.integration_merge_lease !== undefined) {
1054
+ 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"));
1055
+ }
1056
+ }
1057
+ return { ok: violations.length === 0, violations };
1058
+ }
1059
+ async function writeWorkflowSnapshot(snapshot, dir) {
1060
+ const gate = validateWorkflowSnapshot(snapshot);
1061
+ if (!gate.ok) {
1062
+ const detail = gate.violations.map((v) => v.message).join("; ");
1063
+ throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
1064
+ }
1065
+ const snapshotPath = join5(dir, WORKFLOW_SNAPSHOT_FILE);
1066
+ mkdirSync4(dir, { recursive: true });
1067
+ await withStatusWriteLock(snapshotPath, () => {
1068
+ writeJson(snapshotPath, snapshot);
1069
+ });
1070
+ }
1071
+
821
1072
  // src/status.ts
822
1073
  var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
823
1074
  var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
824
1075
  var RESIDUAL_DECISIONS = ["defer", "accept", "risk-accepted"];
825
1076
  var RESIDUAL_LIFECYCLES = ["open", "resolved", "waived", "superseded", "duplicate"];
826
- var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
827
- function isPlainObject2(value) {
1077
+ function isPlainObject3(value) {
828
1078
  return typeof value === "object" && value !== null && !Array.isArray(value);
829
1079
  }
830
- function violation3(severity, code, message, fix) {
1080
+ function violation4(severity, code, message, fix) {
831
1081
  return { ok: false, severity, code, message, fix };
832
1082
  }
833
1083
  function todayString() {
@@ -848,254 +1098,299 @@ function isOpenResidual(entry) {
848
1098
  const effective = lifecycle === false || lifecycle === null || lifecycle === undefined ? "open" : lifecycle;
849
1099
  return effective === "open";
850
1100
  }
851
- function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
1101
+ function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
852
1102
  if (value === undefined) {
853
- violations.push(violation3("high", missingCode, `missing required field: ${field}`));
1103
+ violations.push(violation4("high", missingCode, `missing required field: ${field}`));
854
1104
  } else if (typeof value !== "string" || value.trim() === "") {
855
- violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
1105
+ violations.push(violation4("medium", invalidCode, `${field} must be a non-empty string`));
856
1106
  }
857
1107
  }
858
1108
  function validatePlanRow(row) {
859
1109
  const violations = [];
860
- if (!isPlainObject2(row)) {
861
- return { ok: false, violations: [violation3("high", "status.plan-row.invalid", "plan row must be an object")] };
1110
+ if (!isPlainObject3(row)) {
1111
+ return { ok: false, violations: [violation4("high", "status.plan-row.invalid", "plan row must be an object")] };
862
1112
  }
863
1113
  const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
864
1114
  if (id === undefined && planId === undefined) {
865
- violations.push(violation3("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
1115
+ violations.push(violation4("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
866
1116
  } else {
867
1117
  if (id !== undefined) {
868
- validateNonEmptyString2(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
1118
+ validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
869
1119
  }
870
1120
  if (planId !== undefined) {
871
- validateNonEmptyString2(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
1121
+ validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
872
1122
  }
873
1123
  if (id !== undefined && planId !== undefined && id !== planId) {
874
- violations.push(violation3("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
1124
+ 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)"));
875
1125
  }
876
1126
  }
877
- validateNonEmptyString2(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
878
- validateNonEmptyString2(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
1127
+ validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
1128
+ validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
879
1129
  if (status === undefined) {
880
- violations.push(violation3("high", "status.plan-row.missing-status", "missing required field: status"));
1130
+ violations.push(violation4("high", "status.plan-row.missing-status", "missing required field: status"));
881
1131
  } else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
882
- violations.push(violation3("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
1132
+ violations.push(violation4("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
883
1133
  }
884
- if (metadata !== undefined && !isPlainObject2(metadata)) {
885
- violations.push(violation3("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
1134
+ if (metadata !== undefined && !isPlainObject3(metadata)) {
1135
+ violations.push(violation4("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
886
1136
  }
887
- if (execution_lease !== undefined && !isPlainObject2(execution_lease)) {
888
- violations.push(violation3("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
1137
+ if (execution_lease !== undefined && !isPlainObject3(execution_lease)) {
1138
+ violations.push(violation4("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
889
1139
  }
890
1140
  if (status === "Done" && execution_lease !== undefined) {
891
- violations.push(violation3("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"'));
1141
+ 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"'));
892
1142
  }
893
1143
  return { ok: violations.length === 0, violations };
894
1144
  }
895
1145
  function validateResidual(entry) {
896
1146
  const violations = [];
897
- if (!isPlainObject2(entry)) {
898
- return { ok: false, violations: [violation3("high", "status.residual.invalid", "residual entry must be an object")] };
1147
+ if (!isPlainObject3(entry)) {
1148
+ return { ok: false, violations: [violation4("high", "status.residual.invalid", "residual entry must be an object")] };
899
1149
  }
900
1150
  const { id, title, severity, source, scope, decision, owner, target, tracking, detail_doc, lifecycle, closed_at } = entry;
901
- validateNonEmptyString2(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
902
- validateNonEmptyString2(violations, title, "title", "status.residual.missing-title", "status.residual.invalid-title");
903
- validateNonEmptyString2(violations, source, "source", "status.residual.missing-source", "status.residual.invalid-source");
904
- validateNonEmptyString2(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
905
- validateNonEmptyString2(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
1151
+ validateNonEmptyString3(violations, id, "id", "status.residual.missing-id", "status.residual.invalid-id");
1152
+ validateNonEmptyString3(violations, title, "title", "status.residual.missing-title", "status.residual.invalid-title");
1153
+ validateNonEmptyString3(violations, source, "source", "status.residual.missing-source", "status.residual.invalid-source");
1154
+ validateNonEmptyString3(violations, scope, "scope", "status.residual.missing-scope", "status.residual.invalid-scope");
1155
+ validateNonEmptyString3(violations, owner, "owner", "status.residual.missing-owner", "status.residual.invalid-owner");
906
1156
  if (severity === undefined) {
907
- violations.push(violation3("high", "status.residual.missing-severity", "missing required field: severity"));
1157
+ violations.push(violation4("high", "status.residual.missing-severity", "missing required field: severity"));
908
1158
  } else if (typeof severity !== "string" || !SEVERITY_ORDER.includes(severity) && severity !== "warning") {
909
- violations.push(violation3("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
1159
+ violations.push(violation4("medium", "status.residual.invalid-severity", `severity must be one of ${SEVERITY_ORDER.join(" | ")} — got ${JSON.stringify(severity)}`));
910
1160
  } else if (severity === "warning") {
911
- violations.push(violation3("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')`));
1161
+ 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')`));
912
1162
  }
913
1163
  if (decision === undefined) {
914
- violations.push(violation3("high", "status.residual.missing-decision", "missing required field: decision"));
1164
+ violations.push(violation4("high", "status.residual.missing-decision", "missing required field: decision"));
915
1165
  } else if (typeof decision !== "string" || !RESIDUAL_DECISIONS.includes(decision)) {
916
- violations.push(violation3("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
1166
+ violations.push(violation4("medium", "status.residual.invalid-decision", `decision must be one of ${RESIDUAL_DECISIONS.join(" | ")} — got ${JSON.stringify(decision)}`));
917
1167
  }
918
1168
  if (target === undefined) {
919
- violations.push(violation3("high", "status.residual.missing-target", "missing required field: target"));
1169
+ violations.push(violation4("high", "status.residual.missing-target", "missing required field: target"));
920
1170
  } else if (typeof target !== "string" && target !== null) {
921
- violations.push(violation3("medium", "status.residual.invalid-target", "target must be a string or null"));
1171
+ violations.push(violation4("medium", "status.residual.invalid-target", "target must be a string or null"));
922
1172
  }
923
1173
  if (tracking === undefined) {
924
- violations.push(violation3("high", "status.residual.missing-tracking", "missing required field: tracking"));
1174
+ violations.push(violation4("high", "status.residual.missing-tracking", "missing required field: tracking"));
925
1175
  } else if (typeof tracking !== "string" && tracking !== null) {
926
- violations.push(violation3("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
1176
+ violations.push(violation4("medium", "status.residual.invalid-tracking", "tracking must be a string or null"));
927
1177
  }
928
1178
  if (detail_doc !== undefined && typeof detail_doc !== "string" && detail_doc !== null) {
929
- violations.push(violation3("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
1179
+ violations.push(violation4("medium", "status.residual.invalid-detail-doc", "detail_doc must be a string or null"));
930
1180
  }
931
1181
  if (closed_at !== undefined && (typeof closed_at !== "string" || !DATE_RE.test(closed_at))) {
932
- violations.push(violation3("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
1182
+ violations.push(violation4("medium", "status.residual.invalid-closed-at", "closed_at must be YYYY-MM-DD"));
933
1183
  }
934
1184
  if (lifecycle !== undefined) {
935
1185
  if (typeof lifecycle !== "string" || !RESIDUAL_LIFECYCLES.includes(lifecycle)) {
936
- violations.push(violation3("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
1186
+ violations.push(violation4("medium", "status.residual.invalid-lifecycle", `lifecycle must be one of ${RESIDUAL_LIFECYCLES.join(" | ")} — got ${JSON.stringify(lifecycle)}`));
937
1187
  } else if (lifecycle !== "open") {
938
1188
  if (closed_at === undefined) {
939
- violations.push(violation3("high", "status.residual.closed-missing-closed-at", `lifecycle "${lifecycle}" requires closed_at (YYYY-MM-DD)`, 'set closed_at (e.g. "2026-08-08")'));
1189
+ 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")'));
940
1190
  }
941
1191
  if (entry.closure_note === undefined) {
942
- violations.push(violation3("medium", "status.residual.closed-missing-closure-note", `lifecycle "${lifecycle}" requires closure_note (what changed; how verified)`, "add closure_note explaining the close"));
1192
+ 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"));
943
1193
  }
944
1194
  }
945
1195
  }
946
1196
  return { ok: violations.length === 0, violations };
947
1197
  }
948
- function validateStatus(docOrPath) {
1198
+ function isHarnessRelativePath(dir) {
1199
+ if (dir.startsWith("/") || dir.startsWith("\\"))
1200
+ return false;
1201
+ if (/^[A-Za-z]:[\\/]/.test(dir))
1202
+ return false;
1203
+ return !dir.split(/[\\/]+/).includes("..");
1204
+ }
1205
+ function validateWorkflowEntry(entry) {
1206
+ const violations = [];
1207
+ if (!isPlainObject3(entry)) {
1208
+ return {
1209
+ ok: false,
1210
+ violations: [violation4("high", "status.workflow.invalid", "workflow entry must be an object")]
1211
+ };
1212
+ }
1213
+ validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
1214
+ if (entry.type === undefined) {
1215
+ violations.push(violation4("high", "status.workflow.missing-type", "missing required field: type"));
1216
+ } else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
1217
+ violations.push(violation4("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
1218
+ }
1219
+ validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
1220
+ if (entry.dir === undefined) {
1221
+ violations.push(violation4("high", "status.workflow.missing-dir", "missing required field: dir"));
1222
+ } else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
1223
+ violations.push(violation4("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
1224
+ } else if (!isHarnessRelativePath(entry.dir)) {
1225
+ 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)}`));
1226
+ }
1227
+ return { ok: violations.length === 0, violations };
1228
+ }
1229
+ function validateStatusV2(docOrPath, opts = {}) {
949
1230
  let doc;
1231
+ let harnessDir = opts.harnessDir;
950
1232
  if (typeof docOrPath === "string") {
951
1233
  try {
952
1234
  doc = readJson(docOrPath);
1235
+ harnessDir = dirname5(resolve5(docOrPath));
953
1236
  } catch (error) {
954
1237
  return {
955
1238
  ok: false,
956
- violations: [violation3("high", "status.invalid-json", error.message)]
1239
+ violations: [violation4("high", "status.invalid-json", error.message)]
957
1240
  };
958
1241
  }
959
1242
  } else {
960
1243
  doc = docOrPath;
961
1244
  }
962
- const violations = [];
963
- const { version, updated_at, plans, residual_findings, metadata } = doc;
964
- if (version === undefined) {
965
- violations.push(violation3("high", "status.missing-version", "missing required field: version"));
966
- } else if (typeof version !== "number" || !Number.isInteger(version)) {
967
- violations.push(violation3("high", "status.invalid-version", "version must be an integer"));
968
- } else if (version !== 1) {
969
- violations.push(violation3("medium", "status.unsupported-version", `unsupported status.json schema version ${version} expected 1`));
970
- }
971
- if (updated_at === undefined) {
972
- violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
973
- } else if (typeof updated_at !== "string" || !DATE_RE.test(updated_at)) {
974
- violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
975
- }
976
- if (plans === undefined) {
977
- violations.push(violation3("high", "status.missing-plans", "missing required field: plans"));
978
- } else if (!Array.isArray(plans)) {
979
- violations.push(violation3("high", "status.invalid-plans", "plans must be an array"));
980
- } else {
981
- for (const row of plans) {
982
- violations.push(...validatePlanRow(row).violations);
983
- }
1245
+ if (!isPlainObject3(doc)) {
1246
+ return { ok: false, violations: [violation4("high", "status.invalid-doc", "status document must be an object")] };
1247
+ }
1248
+ if (doc.version !== 2) {
1249
+ return {
1250
+ ok: false,
1251
+ violations: [
1252
+ 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`")
1253
+ ]
1254
+ };
984
1255
  }
985
- if (residual_findings === undefined) {
986
- violations.push(violation3("high", "status.missing-residual-findings", "missing required field: residual_findings (root-only canonical)"));
987
- } else if (!isPlainObject2(residual_findings)) {
988
- violations.push(violation3("high", "status.invalid-residual-findings", "residual_findings must be an object at root"));
1256
+ if (Array.isArray(doc.plans)) {
1257
+ return {
1258
+ ok: false,
1259
+ violations: [
1260
+ 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`")
1261
+ ]
1262
+ };
1263
+ }
1264
+ if (doc.residual_findings !== undefined) {
1265
+ return {
1266
+ ok: false,
1267
+ violations: [
1268
+ 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`")
1269
+ ]
1270
+ };
1271
+ }
1272
+ const violations = [];
1273
+ if (doc.updated_at === undefined) {
1274
+ violations.push(violation4("high", "status.missing-updated-at", "missing required field: updated_at"));
1275
+ } else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
1276
+ violations.push(violation4("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
1277
+ }
1278
+ if (doc.workflows === undefined) {
1279
+ violations.push(violation4("high", "status.missing-workflows", "missing required field: workflows"));
1280
+ } else if (!Array.isArray(doc.workflows)) {
1281
+ violations.push(violation4("high", "status.invalid-workflows", "workflows must be an array"));
989
1282
  } else {
990
- for (const [planId, list] of Object.entries(residual_findings)) {
991
- if (!Array.isArray(list)) {
992
- violations.push(violation3("high", "status.residual.invalid-list", `residual_findings["${planId}"] must be an array`));
993
- } else if (list.length === 0) {
994
- violations.push(violation3("low", "status.residual.empty-key", `residual_findings["${planId}"] is empty — delete the key (no "plan-id": [])`));
995
- } else {
996
- for (const entry of list) {
997
- violations.push(...validateResidual(entry).violations);
1283
+ const seen = new Set;
1284
+ for (const entry of doc.workflows) {
1285
+ violations.push(...validateWorkflowEntry(entry).violations);
1286
+ if (isPlainObject3(entry) && typeof entry.id === "string") {
1287
+ if (seen.has(entry.id)) {
1288
+ violations.push(violation4("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
998
1289
  }
1290
+ seen.add(entry.id);
999
1291
  }
1000
1292
  }
1001
1293
  }
1002
- if (metadata === undefined) {
1003
- violations.push(violation3("high", "status.missing-metadata", "missing required field: metadata"));
1004
- } else if (!isPlainObject2(metadata)) {
1005
- violations.push(violation3("high", "status.invalid-metadata", "metadata must be an object"));
1006
- } else if (Object.prototype.hasOwnProperty.call(metadata, "residual_findings")) {
1007
- violations.push(violation3("medium", "status.dual-write-residuals", "residual_findings must be root-only — metadata.residual_findings is legacy read-only; remove it (no dual-write)", "move entries to root residual_findings and delete metadata.residual_findings"));
1294
+ if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
1295
+ let realHarnessDir = null;
1296
+ try {
1297
+ realHarnessDir = realpathSync(harnessDir);
1298
+ } catch {}
1299
+ for (const entry of doc.workflows) {
1300
+ if (!isPlainObject3(entry) || typeof entry.dir !== "string")
1301
+ continue;
1302
+ const relSnapshot = join6(entry.dir, WORKFLOW_SNAPSHOT_FILE);
1303
+ const snapshotPath = join6(harnessDir, relSnapshot);
1304
+ const label = typeof entry.id === "string" ? entry.id : relSnapshot;
1305
+ let physical;
1306
+ try {
1307
+ physical = realpathSync(snapshotPath);
1308
+ } catch {
1309
+ 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`));
1310
+ continue;
1311
+ }
1312
+ if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
1313
+ 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)}`));
1314
+ continue;
1315
+ }
1316
+ let snapshot;
1317
+ try {
1318
+ snapshot = readJson(snapshotPath);
1319
+ } catch (error) {
1320
+ violations.push(violation4("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
1321
+ continue;
1322
+ }
1323
+ if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
1324
+ 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`));
1325
+ }
1326
+ if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
1327
+ 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`));
1328
+ }
1329
+ if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
1330
+ 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)} — the root entry mirrors the snapshot; align them`));
1331
+ }
1332
+ }
1008
1333
  }
1009
1334
  return { ok: violations.length === 0, violations };
1010
1335
  }
1011
- async function archiveResiduals(planId, harnessDir) {
1012
- const dir = harnessDir !== undefined ? resolve4(harnessDir) : resolveHarnessDir();
1013
- if (dir === null) {
1014
- throw new Error(`harness dir not found from ${process.cwd()} — pass harnessDir or set MSTAR_HARNESS_DIR`);
1015
- }
1016
- assertSafePathComponent(planId, "planId");
1017
- const statusPath = join4(dir, "status.json");
1018
- if (!existsSync2(statusPath)) {
1019
- throw new Error(`status file not found: ${statusPath}`);
1336
+ var validateStatus = validateStatusV2;
1337
+ async function registerWorkflow(root, entry) {
1338
+ const entryGate = validateWorkflowEntry(entry);
1339
+ if (!entryGate.ok) {
1340
+ throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
1020
1341
  }
1342
+ const statusPath = resolve5(root);
1343
+ const harnessDir = dirname5(statusPath);
1021
1344
  return withStatusWriteLock(statusPath, () => {
1022
- const doc = readJson(statusPath);
1023
- if (!isPlainObject2(doc.residual_findings)) {
1024
- throw new Error(`status.json residual_findings must be an object: ${statusPath}`);
1025
- }
1026
- const open = doc.residual_findings[planId];
1027
- const archivePath = join4(dir, "archived", "residuals", `${planId}.json`);
1028
- if (!Array.isArray(open) || open.length === 0) {
1029
- return { planId, archived: 0, archivePath };
1030
- }
1031
- const archive = readJson(archivePath);
1032
- const existing = Array.isArray(archive.entries) ? archive.entries : [];
1033
- const existingIds = new Set(existing.map((e) => isPlainObject2(e) && typeof e.id === "string" ? e.id : undefined).filter((id) => id !== undefined));
1034
- const today = todayString();
1035
- const moved = open.filter((entry) => {
1036
- if (!isPlainObject2(entry) || typeof entry.id !== "string")
1037
- return true;
1038
- return !existingIds.has(entry.id);
1039
- }).map((entry) => ({ ...entry, archived_at: today }));
1040
- if (moved.length > 0) {
1041
- writeJson(archivePath, { plan_id: planId, schema_version: 1, entries: [...existing, ...moved] });
1345
+ const current = readJson(statusPath);
1346
+ const fresh = Object.keys(current).length === 0;
1347
+ const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
1348
+ if (!fresh && !Array.isArray(doc.workflows)) {
1349
+ throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
1350
+ }
1351
+ const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
1352
+ if (existing >= 0) {
1353
+ doc.workflows[existing] = entry;
1354
+ } else {
1355
+ doc.workflows.push(entry);
1356
+ }
1357
+ doc.updated_at = todayString();
1358
+ const gate = validateStatusV2(doc, { harnessDir });
1359
+ if (!gate.ok) {
1360
+ throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
1042
1361
  }
1043
- delete doc.residual_findings[planId];
1044
- doc.updated_at = today;
1045
1362
  writeJson(statusPath, doc);
1046
- return { planId, archived: moved.length, archivePath };
1363
+ return doc;
1047
1364
  });
1048
1365
  }
1049
- function planFindingsCleanup(doc, planId) {
1050
- if (!Array.isArray(doc.plans))
1051
- return;
1052
- for (const row of doc.plans) {
1053
- if (!isPlainObject2(row))
1054
- continue;
1055
- const rowId = row.id ?? row.plan_id;
1056
- if (rowId !== planId)
1057
- continue;
1058
- if (!isPlainObject2(row.metadata))
1059
- return;
1060
- const mode = row.metadata.findings_cleanup;
1061
- if (mode === "zero-residual" || mode === "allow-residual")
1062
- return mode;
1063
- return;
1366
+ async function unregisterWorkflow(root, id) {
1367
+ if (typeof id !== "string" || id.trim() === "") {
1368
+ throw new Error("refusing to unregister workflow: id must be a non-empty string");
1064
1369
  }
1065
- return;
1066
- }
1067
- function openResidualsOf(doc, planId) {
1068
- if (!isPlainObject2(doc.residual_findings))
1069
- return [];
1070
- const list = doc.residual_findings[planId];
1071
- if (!Array.isArray(list))
1072
- return [];
1073
- return list.filter((entry) => isPlainObject2(entry) && isOpenResidual(entry));
1074
- }
1075
- function findingsCleanupGate(doc, planId, opts) {
1076
- const mode = opts?.mode ?? planFindingsCleanup(doc, planId) ?? "allow-residual";
1077
- const violations = [];
1078
- const residuals = openResidualsOf(doc, planId);
1079
- for (const entry of residuals) {
1080
- const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
1081
- const label = `R#${id}`;
1082
- if (mode === "zero-residual") {
1083
- if (entry.severity === "nit") {
1084
- violations.push(violation3("medium", "findings.zero-residual-nit", `${label}: style-only nits must be fixed in-session or dropped — never left open under zero-residual`));
1085
- } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
1086
- violations.push(violation3("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
1087
- } else if (entry.decision === "defer") {
1088
- if (typeof entry.target !== "string" || entry.target.trim() === "") {
1089
- violations.push(violation3("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
1090
- }
1091
- } else {
1092
- violations.push(violation3("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
1093
- }
1094
- } else if (normalizeSeverity(entry.severity) === "critical") {
1095
- violations.push(violation3("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
1370
+ const statusPath = resolve5(root);
1371
+ const harnessDir = dirname5(statusPath);
1372
+ return withStatusWriteLock(statusPath, () => {
1373
+ const current = readJson(statusPath);
1374
+ if (Object.keys(current).length === 0) {
1375
+ return { version: 2, updated_at: todayString(), workflows: [] };
1096
1376
  }
1097
- }
1098
- return { ok: violations.length === 0, violations };
1377
+ const doc = current;
1378
+ if (!Array.isArray(doc.workflows)) {
1379
+ throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
1380
+ }
1381
+ const remaining = doc.workflows.filter((wf) => wf.id !== id);
1382
+ if (remaining.length === doc.workflows.length) {
1383
+ return doc;
1384
+ }
1385
+ doc.workflows = remaining;
1386
+ doc.updated_at = todayString();
1387
+ const gate = validateStatusV2(doc, { harnessDir });
1388
+ if (!gate.ok) {
1389
+ throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
1390
+ }
1391
+ writeJson(statusPath, doc);
1392
+ return doc;
1393
+ });
1099
1394
  }
1100
1395
  function resolveCompassEnforcement(harnessDir) {
1101
1396
  const iterationsDir = resolveIterationDir(harnessDir);
@@ -1110,12 +1405,12 @@ function resolveCompassEnforcement(harnessDir) {
1110
1405
  for (const entry of entries) {
1111
1406
  if (!entry.isDirectory())
1112
1407
  continue;
1113
- const compassPath = join4(iterationsDir, entry.name, "delivery-compass.md");
1408
+ const compassPath = join6(iterationsDir, entry.name, "delivery-compass.md");
1114
1409
  if (!existsSync2(compassPath))
1115
1410
  continue;
1116
1411
  let content;
1117
1412
  try {
1118
- content = readFileSync3(compassPath, "utf8");
1413
+ content = readFileSync4(compassPath, "utf8");
1119
1414
  } catch {
1120
1415
  continue;
1121
1416
  }
@@ -1129,58 +1424,26 @@ function resolveCompassEnforcement(harnessDir) {
1129
1424
  }
1130
1425
  return { hard: false, source: "none" };
1131
1426
  }
1132
- function groupCount(values) {
1133
- const counts = new Map;
1134
- for (const value of values) {
1135
- const key = typeof value === "string" ? value : String(value);
1136
- counts.set(key, (counts.get(key) ?? 0) + 1);
1137
- }
1138
- return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
1427
+ function resolveMstarcEnforcement(harnessDir) {
1428
+ const dir = resolve5(harnessDir);
1429
+ const rc = loadMstarc(dir, dirname5(dir));
1430
+ const value = rc?.config.enforcement;
1431
+ if (value === "hard")
1432
+ return { hard: true, source: "mstarc" };
1433
+ if (value === "soft")
1434
+ return { hard: false, source: "mstarc" };
1435
+ return { hard: false, source: "none" };
1139
1436
  }
1140
- function techDebtRollup(docOrPath) {
1141
- const doc = typeof docOrPath === "string" ? readJson(docOrPath) : docOrPath;
1142
- const canonical = isPlainObject2(doc.residual_findings) ? doc.residual_findings : {};
1143
- const metadata = isPlainObject2(doc.metadata) ? doc.metadata : {};
1144
- const legacy = isPlainObject2(metadata.residual_findings) ? metadata.residual_findings : {};
1145
- const merged = { ...canonical, ...legacy };
1146
- const items = [];
1147
- for (const [plan, list] of Object.entries(merged)) {
1148
- if (!Array.isArray(list))
1149
- continue;
1150
- for (const value of list) {
1151
- if (!isPlainObject2(value) || !isOpenResidual(value))
1152
- continue;
1153
- items.push({ plan, entry: value });
1154
- }
1155
- }
1156
- const bySeverity = {};
1157
- for (const severity of SEVERITY_ORDER) {
1158
- bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
1159
- }
1160
- const computed = {
1161
- total_open: items.length,
1162
- by_severity: bySeverity,
1163
- by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
1164
- by_plan: groupCount(items.map(({ plan }) => plan))
1165
- };
1166
- const storedRaw = metadata.tech_debt_summary ?? null;
1167
- const stored = storedRaw === null ? null : storedRaw;
1168
- const checks = ROLLUP_FIELDS.map((field) => {
1169
- const computedField = computed[field];
1170
- if (stored === null)
1171
- return { field, status: "DRIFT" };
1172
- const storedField = stored[field];
1173
- const storedCompared = storedField === false ? null : storedField ?? null;
1174
- const status = JSON.stringify(computedField) === JSON.stringify(storedCompared) ? "PASS" : "DRIFT";
1175
- return { field, status };
1176
- });
1177
- const overall = checks.every((check) => check.status === "PASS") ? "PASS" : "DRIFT";
1178
- return { computed, stored, checks, overall };
1437
+ function resolveRepoEnforcement(harnessDir) {
1438
+ const rc = resolveMstarcEnforcement(harnessDir);
1439
+ if (rc.source !== "none")
1440
+ return rc;
1441
+ return resolveCompassEnforcement(harnessDir);
1179
1442
  }
1180
1443
  // src/worktree.ts
1181
1444
  import { execFileSync as execFileSync2 } from "node:child_process";
1182
1445
  import { existsSync as existsSync3 } from "node:fs";
1183
- import { isAbsolute as isAbsolute3, resolve as resolve5 } from "node:path";
1446
+ import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
1184
1447
  var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
1185
1448
  function probeTimeoutMs() {
1186
1449
  const raw = process.env.MSTAR_GIT_PROBE_TIMEOUT_MS;
@@ -1189,7 +1452,7 @@ function probeTimeoutMs() {
1189
1452
  const parsed = Number(raw);
1190
1453
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PROBE_TIMEOUT_MS;
1191
1454
  }
1192
- function violation4(severity, code, message, fix) {
1455
+ function violation5(severity, code, message, fix) {
1193
1456
  return { ok: false, severity, code, message, fix };
1194
1457
  }
1195
1458
  function gate(violations) {
@@ -1223,25 +1486,25 @@ function l1PreDispatchCheck(input, opts = {}) {
1223
1486
  const violations = [];
1224
1487
  const { controlWorktreePath, leaseWorktreePath, leaseWorkingBranch, planId } = input;
1225
1488
  if (controlWorktreePath.trim() === "") {
1226
- violations.push(violation4("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"));
1489
+ 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"));
1227
1490
  }
1228
1491
  if (leaseWorktreePath.trim() === "") {
1229
- violations.push(violation4("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"));
1492
+ 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"));
1230
1493
  }
1231
1494
  if (leaseWorkingBranch.trim() === "") {
1232
- violations.push(violation4("high", "worktree.l1.lease-branch-missing", `execution_lease.working_branch is empty for plan "${planId}"`, "record the lease working_branch before dispatch"));
1495
+ 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"));
1233
1496
  }
1234
- if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve5(controlWorktreePath) === resolve5(leaseWorktreePath)) {
1235
- violations.push(violation4("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"));
1497
+ if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
1498
+ 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"));
1236
1499
  }
1237
1500
  if (leaseWorktreePath !== "" && !existsSync3(leaseWorktreePath)) {
1238
- violations.push(violation4("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>`));
1501
+ 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>`));
1239
1502
  } else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
1240
1503
  const probe = probeBranch(leaseWorktreePath, opts);
1241
1504
  if ("error" in probe) {
1242
- violations.push(violation4("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)"));
1505
+ 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)"));
1243
1506
  } else if (probe.branch !== leaseWorkingBranch) {
1244
- violations.push(violation4("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`));
1507
+ 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`));
1245
1508
  }
1246
1509
  }
1247
1510
  return gate(violations);
@@ -1251,41 +1514,41 @@ function l2PreDispatchCheck(input, opts = {}) {
1251
1514
  const tracks = input.tracks ?? [];
1252
1515
  const seenPaths = new Set;
1253
1516
  if (tracks.length < 1) {
1254
- violations.push(violation4("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"));
1517
+ 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"));
1255
1518
  }
1256
1519
  tracks.forEach((track, index) => {
1257
1520
  if (track.worktreePath.trim() === "" || track.workingBranch.trim() === "") {
1258
- violations.push(violation4("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
1521
+ violations.push(violation5("high", "worktree.l2.track-invalid", `track ${index + 1} is missing worktreePath and/or workingBranch`, "fill both fields for every track"));
1259
1522
  return;
1260
1523
  }
1261
- if (!isAbsolute3(track.worktreePath)) {
1262
- violations.push(violation4("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>)`));
1524
+ if (!isAbsolute4(track.worktreePath)) {
1525
+ 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>)`));
1263
1526
  return;
1264
1527
  }
1265
- const normalized = resolve5(track.worktreePath);
1528
+ const normalized = resolve6(track.worktreePath);
1266
1529
  if (seenPaths.has(normalized)) {
1267
- violations.push(violation4("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"));
1530
+ 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"));
1268
1531
  return;
1269
1532
  }
1270
1533
  seenPaths.add(normalized);
1271
1534
  if (!existsSync3(track.worktreePath)) {
1272
- violations.push(violation4("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
1535
+ 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}`));
1273
1536
  return;
1274
1537
  }
1275
1538
  const probe = probeBranch(track.worktreePath, opts);
1276
1539
  if ("error" in probe) {
1277
- violations.push(violation4("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)"));
1540
+ 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)"));
1278
1541
  } else if (probe.branch !== track.workingBranch) {
1279
- violations.push(violation4("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`));
1542
+ 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`));
1280
1543
  }
1281
1544
  });
1282
1545
  return gate(violations);
1283
1546
  }
1284
1547
  function assertControlVsFeaturePath(controlWorktreePath, featureWorktreePath) {
1285
1548
  const violations = [];
1286
- const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve5(controlWorktreePath) === resolve5(featureWorktreePath);
1549
+ const samePath = controlWorktreePath === "" && featureWorktreePath === "" || controlWorktreePath !== "" && featureWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(featureWorktreePath);
1287
1550
  if (samePath) {
1288
- violations.push(violation4("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"));
1551
+ 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"));
1289
1552
  }
1290
1553
  return gate(violations);
1291
1554
  }
@@ -1293,9 +1556,9 @@ function assertBranchAlignment(worktreePath, expectedBranch, opts = {}) {
1293
1556
  const violations = [];
1294
1557
  const probe = probeBranch(worktreePath, opts);
1295
1558
  if ("error" in probe) {
1296
- violations.push(violation4("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)"));
1559
+ 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)"));
1297
1560
  } else if (probe.branch !== expectedBranch) {
1298
- violations.push(violation4("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
1561
+ violations.push(violation5("high", "worktree.branch-mismatch", `worktree "${worktreePath}" is on branch "${probe.branch}", expected "${expectedBranch}" (Assignment Working branch)`, `checkout ${expectedBranch} in that worktree`));
1299
1562
  }
1300
1563
  return gate(violations);
1301
1564
  }
@@ -1310,7 +1573,7 @@ function assertQcAlignment(assignments) {
1310
1573
  for (const { key, label } of QC_ALIGNMENT_FIELDS) {
1311
1574
  const distinct = [...new Set(list.map((a) => a[key]))];
1312
1575
  if (distinct.length > 1) {
1313
- violations.push(violation4("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`));
1576
+ 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`));
1314
1577
  }
1315
1578
  }
1316
1579
  return gate(violations);
@@ -1320,19 +1583,19 @@ function singleReviewSnapshot(assignments) {
1320
1583
  const list = assignments ?? [];
1321
1584
  list.forEach((a, index) => {
1322
1585
  if ((a.head ?? "").trim() === "") {
1323
- violations.push(violation4("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"));
1586
+ 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"));
1324
1587
  }
1325
1588
  });
1326
1589
  const distinct = [...new Set(list.map((a) => a.head ?? "").filter((h) => h.trim() !== ""))];
1327
1590
  if (distinct.length > 1) {
1328
- violations.push(violation4("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"));
1591
+ 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"));
1329
1592
  }
1330
1593
  return gate(violations);
1331
1594
  }
1332
1595
  // src/sdd.ts
1333
1596
  import { execFileSync as execFileSync3 } from "node:child_process";
1334
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, realpathSync, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
1335
- import { basename as basename3, dirname as dirname4, isAbsolute as isAbsolute4, join as join5, resolve as resolve6 } from "node:path";
1597
+ import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
1598
+ import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join7, resolve as resolve7 } from "node:path";
1336
1599
  class SddScriptError extends Error {
1337
1600
  exitCode;
1338
1601
  constructor(message, exitCode) {
@@ -1343,14 +1606,14 @@ class SddScriptError extends Error {
1343
1606
  }
1344
1607
  function isDirectory2(dir) {
1345
1608
  try {
1346
- return statSync3(dir).isDirectory();
1609
+ return statSync4(dir).isDirectory();
1347
1610
  } catch {
1348
1611
  return false;
1349
1612
  }
1350
1613
  }
1351
- function isFile(file) {
1614
+ function isFile2(file) {
1352
1615
  try {
1353
- return statSync3(file).isFile();
1616
+ return statSync4(file).isFile();
1354
1617
  } catch {
1355
1618
  return false;
1356
1619
  }
@@ -1369,25 +1632,48 @@ function gitOut(cwd, args) {
1369
1632
  }
1370
1633
  }
1371
1634
  function probeHarnessWithStatus(root) {
1372
- if (isFile(join5(root, ".mstar", "status.json")))
1373
- return join5(root, ".mstar");
1374
- if (isFile(join5(root, ".agents", "status.json")))
1375
- return join5(root, ".agents");
1635
+ if (isFile2(join7(root, ".mstar", "status.json")))
1636
+ return join7(root, ".mstar");
1637
+ if (isFile2(join7(root, ".agents", "status.json")))
1638
+ return join7(root, ".agents");
1639
+ if (hasWorkflowSnapshot(join7(root, ".mstar")))
1640
+ return join7(root, ".mstar");
1641
+ if (hasWorkflowSnapshot(join7(root, ".agents")))
1642
+ return join7(root, ".agents");
1376
1643
  return null;
1377
1644
  }
1645
+ function hasWorkflowSnapshot(harnessDir) {
1646
+ let workflowsDir;
1647
+ try {
1648
+ workflowsDir = resolveWorkflowDir(harnessDir, { harnessDir });
1649
+ } catch {
1650
+ workflowsDir = join7(harnessDir, "workflows");
1651
+ }
1652
+ if (!isDirectory2(workflowsDir))
1653
+ return false;
1654
+ try {
1655
+ for (const entry of readdirSync3(workflowsDir, { withFileTypes: true })) {
1656
+ if (entry.isDirectory() && isFile2(join7(workflowsDir, entry.name, "snapshot.json")))
1657
+ return true;
1658
+ }
1659
+ } catch {
1660
+ return false;
1661
+ }
1662
+ return false;
1663
+ }
1378
1664
  function isLinkedWorktree(root) {
1379
1665
  const gitDirRaw = gitOut(root, ["rev-parse", "--git-dir"]);
1380
1666
  const commonRaw = gitOut(root, ["rev-parse", "--git-common-dir"]);
1381
1667
  if (gitDirRaw === null || commonRaw === null)
1382
1668
  return false;
1383
- const gitDir = isAbsolute4(gitDirRaw) ? gitDirRaw : join5(root, gitDirRaw);
1384
- const common = isAbsolute4(commonRaw) ? commonRaw : join5(root, commonRaw);
1669
+ const gitDir = isAbsolute5(gitDirRaw) ? gitDirRaw : join7(root, gitDirRaw);
1670
+ const common = isAbsolute5(commonRaw) ? commonRaw : join7(root, commonRaw);
1385
1671
  if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
1386
1672
  return true;
1387
1673
  try {
1388
- const gdParent = realpathSync(dirname4(gitDir));
1389
- const cmAbs = realpathSync(common);
1390
- return join5(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
1674
+ const gdParent = realpathSync2(dirname6(gitDir));
1675
+ const cmAbs = realpathSync2(common);
1676
+ return join7(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
1391
1677
  } catch {
1392
1678
  return false;
1393
1679
  }
@@ -1404,10 +1690,10 @@ function sddWorkspace(planId, opts = {}) {
1404
1690
  if (!isDirectory2(controlRoot)) {
1405
1691
  throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
1406
1692
  }
1407
- root = realpathSync(controlRoot);
1693
+ root = realpathSync2(controlRoot);
1408
1694
  } else {
1409
1695
  const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
1410
- root = realpathSync(topLevel ?? cwd);
1696
+ root = realpathSync2(topLevel ?? cwd);
1411
1697
  }
1412
1698
  if (!controlRoot && isLinkedWorktree(root)) {
1413
1699
  throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
@@ -1418,24 +1704,30 @@ function sddWorkspace(planId, opts = {}) {
1418
1704
  const harnessOverride = opts.harnessDir ?? (process.env.MSTAR_HARNESS_DIR || undefined);
1419
1705
  let harnessDir;
1420
1706
  if (harnessOverride) {
1421
- harnessDir = resolve6(root, harnessOverride);
1707
+ harnessDir = resolve7(root, harnessOverride);
1422
1708
  } else {
1423
- const probed = probeHarnessWithStatus(root);
1424
- if (probed) {
1425
- harnessDir = probed;
1426
- } else if (isDirectory2(join5(root, ".mstar"))) {
1427
- harnessDir = join5(root, ".mstar");
1428
- } else if (isDirectory2(join5(root, ".agents"))) {
1429
- harnessDir = join5(root, ".agents");
1709
+ const rc = findMstarc(root, root);
1710
+ const rcHarnessDir = rc !== null ? parseMstarc(readFileSync5(rc, "utf8")).harnessDir : undefined;
1711
+ if (rcHarnessDir) {
1712
+ harnessDir = resolve7(rc !== null ? dirname6(rc) : root, rcHarnessDir);
1430
1713
  } else {
1431
- harnessDir = join5(root, ".mstar");
1714
+ const probed = probeHarnessWithStatus(root);
1715
+ if (probed) {
1716
+ harnessDir = probed;
1717
+ } else if (isDirectory2(join7(root, ".mstar"))) {
1718
+ harnessDir = join7(root, ".mstar");
1719
+ } else if (isDirectory2(join7(root, ".agents"))) {
1720
+ harnessDir = join7(root, ".agents");
1721
+ } else {
1722
+ harnessDir = join7(root, ".mstar");
1723
+ }
1432
1724
  }
1433
1725
  }
1434
1726
  const sddDir = resolveSddDir(harnessDir, planId);
1435
- mkdirSync4(sddDir, { recursive: true });
1436
- writeFileSync3(join5(sddDir, ".gitignore"), `*
1727
+ mkdirSync5(sddDir, { recursive: true });
1728
+ writeFileSync3(join7(sddDir, ".gitignore"), `*
1437
1729
  `);
1438
- return realpathSync(sddDir);
1730
+ return realpathSync2(sddDir);
1439
1731
  }
1440
1732
  function taskBrief(planFile, taskN, outFile, opts = {}) {
1441
1733
  if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
@@ -1443,7 +1735,7 @@ function taskBrief(planFile, taskN, outFile, opts = {}) {
1443
1735
  }
1444
1736
  let content;
1445
1737
  try {
1446
- content = readFileSync4(planFile, "utf8");
1738
+ content = readFileSync5(planFile, "utf8");
1447
1739
  } catch {
1448
1740
  throw new SddScriptError(`no such plan file: ${planFile}`, 2);
1449
1741
  }
@@ -1455,8 +1747,8 @@ function taskBrief(planFile, taskN, outFile, opts = {}) {
1455
1747
  if (!sddDir) {
1456
1748
  throw new SddScriptError("mstar sdd task-brief: set SDD_DIR or pass OUTFILE (run mstar sdd workspace PLAN_ID first)", 2);
1457
1749
  }
1458
- mkdirSync4(sddDir, { recursive: true });
1459
- out = join5(sddDir, `task-${taskN}-brief.md`);
1750
+ mkdirSync5(sddDir, { recursive: true });
1751
+ out = join7(sddDir, `task-${taskN}-brief.md`);
1460
1752
  }
1461
1753
  const records = content.endsWith(`
1462
1754
  `) ? content.split(`
@@ -1506,10 +1798,10 @@ function reviewPackage(base, head, outFile, opts = {}) {
1506
1798
  if (!sddDir) {
1507
1799
  throw new SddScriptError("mstar sdd review-package: set SDD_DIR or pass OUTFILE", 2);
1508
1800
  }
1509
- mkdirSync4(sddDir, { recursive: true });
1801
+ mkdirSync5(sddDir, { recursive: true });
1510
1802
  const shortBase = gitOut(cwd, ["rev-parse", "--short", base]) ?? base;
1511
1803
  const shortHead = gitOut(cwd, ["rev-parse", "--short", head]) ?? head;
1512
- out = join5(sddDir, `review-${shortBase}..${shortHead}.diff`);
1804
+ out = join7(sddDir, `review-${shortBase}..${shortHead}.diff`);
1513
1805
  }
1514
1806
  const run = (args) => execFileSync3("git", args, { cwd, maxBuffer: GIT_CAPTURE_MAX_BYTES });
1515
1807
  const parts = [
@@ -1545,7 +1837,7 @@ function assertBaseSha(ref, opts = {}) {
1545
1837
  }
1546
1838
  function taskReportExists(sddDir, taskN) {
1547
1839
  try {
1548
- const st = statSync3(join5(sddDir, `task-${taskN}-report.md`));
1840
+ const st = statSync4(join7(sddDir, `task-${taskN}-report.md`));
1549
1841
  return st.isFile() && st.size > 0;
1550
1842
  } catch {
1551
1843
  return false;
@@ -1554,7 +1846,7 @@ function taskReportExists(sddDir, taskN) {
1554
1846
  function readProgressLedger(sddDir) {
1555
1847
  let content;
1556
1848
  try {
1557
- content = readFileSync4(join5(sddDir, "progress.md"), "utf8");
1849
+ content = readFileSync5(join7(sddDir, "progress.md"), "utf8");
1558
1850
  } catch {
1559
1851
  return [];
1560
1852
  }
@@ -1587,8 +1879,8 @@ function implementerSessionStickyRules(input) {
1587
1879
  return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
1588
1880
  }
1589
1881
  // src/iteration.ts
1590
- import { existsSync as existsSync4, readdirSync as readdirSync3, readFileSync as readFileSync5 } from "node:fs";
1591
- import { join as join6 } from "node:path";
1882
+ import { existsSync as existsSync4, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "node:fs";
1883
+ import { join as join8 } from "node:path";
1592
1884
  var COMPASS_STATUSES = ["active", "locked", "completed"];
1593
1885
  var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
1594
1886
  var PLAN_STATUS_DONE = "Done";
@@ -1666,18 +1958,18 @@ function validateCompassShape(doc) {
1666
1958
  }
1667
1959
  };
1668
1960
  }
1669
- function violation5(severity, code, message, fix) {
1961
+ function violation6(severity, code, message, fix) {
1670
1962
  return { ok: false, severity, code, message, fix };
1671
1963
  }
1672
- function isPlainObject3(value) {
1964
+ function isPlainObject4(value) {
1673
1965
  return typeof value === "object" && value !== null && !Array.isArray(value);
1674
1966
  }
1675
1967
  function validateCompassFrontmatter(doc) {
1676
- if (!isPlainObject3(doc)) {
1968
+ if (!isPlainObject4(doc)) {
1677
1969
  return {
1678
1970
  ok: false,
1679
1971
  violations: [
1680
- violation5("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")
1972
+ 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")
1681
1973
  ]
1682
1974
  };
1683
1975
  }
@@ -1687,17 +1979,17 @@ function validateCompassFrontmatter(doc) {
1687
1979
  ok: false,
1688
1980
  violations: parsed.issues.map((issue) => {
1689
1981
  const field = issue.path.join(".") || "(root)";
1690
- return violation5("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)`);
1982
+ 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)`);
1691
1983
  })
1692
1984
  };
1693
1985
  }
1694
1986
  const violations = [];
1695
1987
  const { status, end_date } = parsed.data;
1696
1988
  if (status === "completed" && end_date === undefined) {
1697
- violations.push(violation5("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"));
1989
+ 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"));
1698
1990
  }
1699
1991
  if (status !== "completed" && end_date !== undefined) {
1700
- violations.push(violation5("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"));
1992
+ 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"));
1701
1993
  }
1702
1994
  return { ok: violations.length === 0, violations };
1703
1995
  }
@@ -1706,11 +1998,11 @@ function registeredPlanIds(compassDoc) {
1706
1998
  return [];
1707
1999
  return compassDoc.plans.filter((plan) => typeof plan === "string" && plan.length > 0);
1708
2000
  }
1709
- function findPlanRow(statusDoc, planId) {
1710
- if (!Array.isArray(statusDoc.plans))
2001
+ function findPlanRow(snapshotDoc, planId) {
2002
+ if (!Array.isArray(snapshotDoc.plans))
1711
2003
  return null;
1712
- for (const row of statusDoc.plans) {
1713
- if (!isPlainObject3(row))
2004
+ for (const row of snapshotDoc.plans) {
2005
+ if (!isPlainObject4(row))
1714
2006
  continue;
1715
2007
  const rowId = typeof row.id === "string" ? row.id : typeof row.plan_id === "string" ? row.plan_id : null;
1716
2008
  if (rowId === planId)
@@ -1718,65 +2010,35 @@ function findPlanRow(statusDoc, planId) {
1718
2010
  }
1719
2011
  return null;
1720
2012
  }
1721
- function entryPlansAllDone(statusDoc, registered) {
2013
+ function entryPlansAllDone(snapshotDoc, registered) {
1722
2014
  const violations = [];
1723
2015
  if (registered.length === 0) {
1724
- violations.push(violation5("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`"));
2016
+ 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`"));
1725
2017
  return violations;
1726
2018
  }
1727
2019
  for (const planId of registered) {
1728
- const row = findPlanRow(statusDoc, planId);
2020
+ const row = findPlanRow(snapshotDoc, planId);
1729
2021
  if (row === null) {
1730
- violations.push(violation5("high", "PLAN_NOT_IN_STATUS", `Plan '${planId}' is registered in the compass frontmatter but has no row in status.json plans[] (mstar-iteration §3.1 entry item 1)`, "Add the plan row to {HARNESS_DIR}/status.json"));
2022
+ 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"));
1731
2023
  continue;
1732
2024
  }
1733
2025
  if (row.status !== PLAN_STATUS_DONE) {
1734
- violations.push(violation5("high", "PLAN_NOT_DONE", `Plan '${planId}' status is ${JSON.stringify(row.status)} in status.json — all compass-registered plans must be 'Done' before iteration-close (mstar-iteration §3.1 entry item 1)`));
2026
+ 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)`));
1735
2027
  }
1736
2028
  }
1737
2029
  return violations;
1738
2030
  }
1739
- function entryResidualsOpen(statusDoc, planId) {
1740
- const violations = [];
1741
- const residualRoot = statusDoc.residual_findings;
1742
- if (residualRoot === undefined || residualRoot === null)
1743
- return violations;
1744
- if (!isPlainObject3(residualRoot)) {
1745
- violations.push(violation5("medium", "RESIDUAL_MALFORMED", "status.json residual_findings must be a plan-id → entries object (mstar-iteration §3.1 entry item 2)"));
1746
- return violations;
1747
- }
1748
- const entries = residualRoot[planId];
1749
- if (entries === undefined)
1750
- return violations;
1751
- if (!Array.isArray(entries)) {
1752
- violations.push(violation5("medium", "RESIDUAL_MALFORMED", `status.json residual_findings['${planId}'] must be an array of residual entries (mstar-iteration §3.1 entry item 2)`));
1753
- return violations;
1754
- }
1755
- const openIds = [];
1756
- for (const entry of entries) {
1757
- if (!isPlainObject3(entry) || !isOpenResidual(entry))
1758
- continue;
1759
- const isBlockerDefer = entry.decision === "defer" && typeof entry.target === "string" && entry.target.trim() !== "";
1760
- if (isBlockerDefer)
1761
- continue;
1762
- openIds.push(typeof entry.id === "string" ? entry.id : "<unnamed>");
1763
- }
1764
- if (openIds.length > 0) {
1765
- violations.push(violation5("high", "OPEN_RESIDUALS", `Plan '${planId}' has ${openIds.length} open residual finding(s) not exempted as blocker-defers (${openIds.join(", ")}) — residuals must be closed/archived before iteration-close; only zero-residual blocker-defers (decision: defer + target) may stay open (mstar-iteration §3.1 entry item 2)`, "Close or archive the open residuals, or convert them into blocker-defers (decision: defer + non-empty target) per mstar-plan-artifacts Findings cleanup modes"));
1766
- }
1767
- return violations;
1768
- }
1769
2031
  function entryFrontmatterComplete(compassDoc) {
1770
2032
  return validateCompassFrontmatter(compassDoc).violations;
1771
2033
  }
1772
2034
  function exitFrontmatterClosed(compassDoc) {
1773
2035
  const violations = [];
1774
2036
  if (compassDoc.status !== "completed") {
1775
- violations.push(violation5("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)`));
2037
+ 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)`));
1776
2038
  }
1777
2039
  const endDate = compassDoc.end_date;
1778
2040
  if (typeof endDate !== "string" || !DATE_RE2.test(endDate)) {
1779
- violations.push(violation5("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)"));
2041
+ 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)"));
1780
2042
  }
1781
2043
  return violations;
1782
2044
  }
@@ -1784,9 +2046,9 @@ function exitBranchCheck(opts) {
1784
2046
  const violations = [];
1785
2047
  const { currentBranch, specIntegrationBranch } = opts;
1786
2048
  if (currentBranch === undefined || specIntegrationBranch === undefined) {
1787
- violations.push(violation5("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)"));
2049
+ 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)"));
1788
2050
  } else if (currentBranch !== specIntegrationBranch) {
1789
- violations.push(violation5("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
2051
+ violations.push(violation6("high", "EXIT_BRANCH_MISMATCH", `Current branch '${currentBranch}' is not the spec_integration_branch '${specIntegrationBranch}' (mstar-iteration §3.5 exit item 5)`));
1790
2052
  }
1791
2053
  return violations;
1792
2054
  }
@@ -1795,17 +2057,16 @@ function exitPrBaseCheck(compassDoc, opts) {
1795
2057
  const target = compassDoc.target_branch;
1796
2058
  const { prBaseBranch } = opts;
1797
2059
  if (prBaseBranch === undefined) {
1798
- violations.push(violation5("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
2060
+ violations.push(violation6("medium", "EXIT_PR_BASE_UNVERIFIABLE", "Cannot verify the PR base — missing prBaseBranch probe input (mstar-iteration §3.5 exit item 6)"));
1799
2061
  } else if (typeof target !== "string" || prBaseBranch !== target) {
1800
- violations.push(violation5("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)`));
2062
+ 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)`));
1801
2063
  }
1802
2064
  return violations;
1803
2065
  }
1804
- function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
2066
+ function evaluatePhaseGate(snapshotDoc, compassDoc, opts = {}) {
1805
2067
  const registered = registeredPlanIds(compassDoc);
1806
2068
  const entryViolations = [
1807
- ...entryPlansAllDone(statusDoc, registered),
1808
- ...registered.flatMap((planId) => entryResidualsOpen(statusDoc, planId)),
2069
+ ...entryPlansAllDone(snapshotDoc, registered),
1809
2070
  ...entryFrontmatterComplete(compassDoc)
1810
2071
  ];
1811
2072
  const exitViolations = [
@@ -1814,7 +2075,7 @@ function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
1814
2075
  ...exitPrBaseCheck(compassDoc, opts)
1815
2076
  ];
1816
2077
  const allPlansDone = registered.length > 0 && registered.every((planId) => {
1817
- const row = findPlanRow(statusDoc, planId);
2078
+ const row = findPlanRow(snapshotDoc, planId);
1818
2079
  return row !== null && row.status === PLAN_STATUS_DONE;
1819
2080
  });
1820
2081
  const entry = { ok: entryViolations.length === 0, violations: entryViolations };
@@ -1839,10 +2100,10 @@ function evaluatePhaseGate(statusDoc, compassDoc, opts = {}) {
1839
2100
  function pushCadenceProbe(ciRunning, reviewWaveActive) {
1840
2101
  const violations = [];
1841
2102
  if (ciRunning) {
1842
- violations.push(violation5("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"));
2103
+ 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"));
1843
2104
  }
1844
2105
  if (reviewWaveActive) {
1845
- violations.push(violation5("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"));
2106
+ 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"));
1846
2107
  }
1847
2108
  return { ok: violations.length === 0, violations };
1848
2109
  }
@@ -1851,24 +2112,24 @@ function assertIndexRowObligations(iterationsDir) {
1851
2112
  return {
1852
2113
  ok: false,
1853
2114
  violations: [
1854
- violation5("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
2115
+ violation6("high", "INDEX_ITERATIONS_DIR_MISSING", `{ITERATION_DIR} '${iterationsDir}' does not exist (mstar-iteration §1.4)`, "Create the iterations directory (path.resolveIterationDir)")
1855
2116
  ]
1856
2117
  };
1857
2118
  }
1858
- const iterationIds = readdirSync3(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync4(join6(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
1859
- const readmePath = join6(iterationsDir, INDEX_README);
2119
+ const iterationIds = readdirSync4(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync4(join8(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
2120
+ const readmePath = join8(iterationsDir, INDEX_README);
1860
2121
  if (!existsSync4(readmePath)) {
1861
2122
  return {
1862
2123
  ok: false,
1863
2124
  violations: [
1864
- violation5("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`)
2125
+ 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`)
1865
2126
  ]
1866
2127
  };
1867
2128
  }
1868
2129
  const violations = [];
1869
- const lines = readFileSync5(readmePath, "utf8").split(/\r?\n/);
2130
+ const lines = readFileSync6(readmePath, "utf8").split(/\r?\n/);
1870
2131
  if (!lines.some((line) => line.includes(INDEX_HEADER))) {
1871
- violations.push(violation5("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"));
2132
+ 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"));
1872
2133
  }
1873
2134
  const indexed = new Set;
1874
2135
  for (const line of lines) {
@@ -1878,13 +2139,15 @@ function assertIndexRowObligations(iterationsDir) {
1878
2139
  }
1879
2140
  for (const id of iterationIds) {
1880
2141
  if (!indexed.has(id)) {
1881
- violations.push(violation5("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> |`));
2142
+ 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> |`));
1882
2143
  }
1883
2144
  }
1884
2145
  return { ok: violations.length === 0, violations };
1885
2146
  }
1886
2147
  function parseCompassFrontmatter(filePath) {
1887
- const content = readFileSync5(filePath, "utf8");
2148
+ return parseCompassFrontmatterText(readFileSync6(filePath, "utf8"), filePath);
2149
+ }
2150
+ function parseCompassFrontmatterText(content, filePath) {
1888
2151
  const lines = content.split(/\r?\n/);
1889
2152
  if (lines[0]?.trim() !== "---") {
1890
2153
  throw new Error(`no YAML frontmatter fence in ${filePath} (expected first line "---")`);
@@ -1945,8 +2208,795 @@ function parseFlowArray(raw, filePath) {
1945
2208
  }
1946
2209
  return items;
1947
2210
  }
2211
+ // src/project.ts
2212
+ import { existsSync as existsSync5, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
2213
+ import { join as join9 } from "node:path";
2214
+ var PROJECT_ROADMAP_FILE = "roadmap.md";
2215
+ var PROJECT_REFERENCES_DIR = "references";
2216
+ var PROJECT_REGISTER_FILE = "residuals.json";
2217
+ var _DEFAULT_PROJECT = "_default";
2218
+ var ROADMAP_STATUSES = ["active", "paused", "completed"];
2219
+ var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
2220
+ var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
2221
+ function isPlainObject5(value) {
2222
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2223
+ }
2224
+ function violation7(severity, code, message, fix) {
2225
+ return { ok: false, severity, code, message, fix };
2226
+ }
2227
+ function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
2228
+ if (value === undefined) {
2229
+ violations.push(violation7("high", missingCode, `missing required field: ${field}`));
2230
+ } else if (typeof value !== "string" || value.trim() === "") {
2231
+ violations.push(violation7("medium", invalidCode, `${field} must be a non-empty string`));
2232
+ }
2233
+ }
2234
+ function validateRoadmap(filePath) {
2235
+ const violations = [];
2236
+ let content;
2237
+ try {
2238
+ content = readFileSync7(filePath, "utf8");
2239
+ } catch {
2240
+ return {
2241
+ ok: false,
2242
+ violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
2243
+ warnings: []
2244
+ };
2245
+ }
2246
+ let doc;
2247
+ try {
2248
+ doc = parseCompassFrontmatterText(content, filePath);
2249
+ } catch (err) {
2250
+ const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
2251
+ return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
2252
+ }
2253
+ validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
2254
+ validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
2255
+ if (doc.status === undefined) {
2256
+ violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
2257
+ } else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
2258
+ violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
2259
+ }
2260
+ if (doc.created_at === undefined) {
2261
+ violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
2262
+ } else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
2263
+ violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
2264
+ }
2265
+ if (doc.milestones !== undefined && doc.milestones !== null) {
2266
+ if (!Array.isArray(doc.milestones)) {
2267
+ violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
2268
+ } else {
2269
+ for (const item of doc.milestones) {
2270
+ if (typeof item !== "string" || item.trim() === "") {
2271
+ violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
2272
+ break;
2273
+ }
2274
+ }
2275
+ }
2276
+ }
2277
+ if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
2278
+ if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
2279
+ violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
2280
+ }
2281
+ }
2282
+ const warnings = [];
2283
+ const fenceEnd = linesIndexOfClosingFence(content);
2284
+ const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
2285
+ `);
2286
+ if (!/^##\s+Direction\s*$/m.test(body)) {
2287
+ warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
2288
+ }
2289
+ if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
2290
+ 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"));
2291
+ }
2292
+ return { ok: violations.length === 0, violations, warnings };
2293
+ }
2294
+ function linesIndexOfClosingFence(content) {
2295
+ return content.split(/\r?\n/).indexOf("---", 1);
2296
+ }
2297
+ function validateProjectRegister(doc) {
2298
+ const violations = [];
2299
+ if (!isPlainObject5(doc)) {
2300
+ return {
2301
+ ok: false,
2302
+ violations: [violation7("high", "project.register.invalid", "project register must be an object")]
2303
+ };
2304
+ }
2305
+ if (doc.entries === undefined) {
2306
+ violations.push(violation7("high", "project.register.missing-entries", "missing required field: entries"));
2307
+ } else if (!isPlainObject5(doc.entries)) {
2308
+ violations.push(violation7("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
2309
+ } else {
2310
+ for (const [key, entries] of Object.entries(doc.entries)) {
2311
+ if (key.trim() === "") {
2312
+ violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
2313
+ }
2314
+ if (!Array.isArray(entries)) {
2315
+ 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)`));
2316
+ continue;
2317
+ }
2318
+ for (const entry of entries) {
2319
+ violations.push(...validateResidual(entry).violations);
2320
+ if (!isPlainObject5(entry))
2321
+ continue;
2322
+ validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
2323
+ if (entry.registered_at === undefined) {
2324
+ violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
2325
+ } else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
2326
+ violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
2327
+ }
2328
+ if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
2329
+ violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
2330
+ }
2331
+ if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
2332
+ 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`));
2333
+ }
2334
+ }
2335
+ }
2336
+ }
2337
+ return { ok: violations.length === 0, violations };
2338
+ }
2339
+ function findingsCleanupGate(register, planId, opts) {
2340
+ const mode = opts?.mode ?? "allow-residual";
2341
+ const violations = [];
2342
+ const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
2343
+ if (entries === undefined) {
2344
+ return { ok: true, violations };
2345
+ }
2346
+ if (!Array.isArray(entries)) {
2347
+ 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)`));
2348
+ return { ok: false, violations };
2349
+ }
2350
+ if (entries.length === 0) {
2351
+ return { ok: true, violations };
2352
+ }
2353
+ for (const entry of entries) {
2354
+ if (!isOpenResidual(entry))
2355
+ continue;
2356
+ const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
2357
+ const label = `R#${id}`;
2358
+ if (mode === "zero-residual") {
2359
+ if (entry.severity === "nit") {
2360
+ 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`));
2361
+ } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
2362
+ violations.push(violation7("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
2363
+ } else if (entry.decision === "defer") {
2364
+ if (typeof entry.target !== "string" || entry.target.trim() === "") {
2365
+ violations.push(violation7("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
2366
+ }
2367
+ } else {
2368
+ 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`));
2369
+ }
2370
+ } else if (normalizeSeverity(entry.severity) === "critical") {
2371
+ violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
2372
+ }
2373
+ }
2374
+ return { ok: violations.length === 0, violations };
2375
+ }
2376
+ function groupCount(values) {
2377
+ const counts = new Map;
2378
+ for (const value of values) {
2379
+ const key = typeof value === "string" ? value : String(value);
2380
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2381
+ }
2382
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
2383
+ }
2384
+ function techDebtRollup(projectDir) {
2385
+ const items = [];
2386
+ let entries;
2387
+ try {
2388
+ entries = readdirSync5(projectDir, { withFileTypes: true });
2389
+ } catch {
2390
+ entries = [];
2391
+ }
2392
+ for (const project of entries) {
2393
+ if (!project.isDirectory())
2394
+ continue;
2395
+ const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
2396
+ if (!existsSync5(registerPath))
2397
+ continue;
2398
+ let register;
2399
+ try {
2400
+ register = readJson(registerPath);
2401
+ } catch {
2402
+ continue;
2403
+ }
2404
+ if (!isPlainObject5(register) || !isPlainObject5(register.entries))
2405
+ continue;
2406
+ for (const [plan, planEntries] of Object.entries(register.entries)) {
2407
+ if (!Array.isArray(planEntries))
2408
+ continue;
2409
+ for (const entry of planEntries) {
2410
+ if (!isPlainObject5(entry) || !isOpenResidual(entry))
2411
+ continue;
2412
+ items.push({ plan, entry });
2413
+ }
2414
+ }
2415
+ }
2416
+ const bySeverity = {};
2417
+ for (const severity of SEVERITY_ORDER) {
2418
+ bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
2419
+ }
2420
+ const computed = {
2421
+ total_open: items.length,
2422
+ by_severity: bySeverity,
2423
+ by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
2424
+ by_plan: groupCount(items.map(({ plan }) => plan))
2425
+ };
2426
+ const stored = null;
2427
+ const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
2428
+ const overall = "DRIFT";
2429
+ return { computed, stored, checks, overall };
2430
+ }
2431
+ function listProjectReferenceFiles(projectDir) {
2432
+ const root = join9(projectDir, PROJECT_REFERENCES_DIR);
2433
+ let entries;
2434
+ try {
2435
+ entries = readdirSync5(root, { withFileTypes: true });
2436
+ } catch {
2437
+ return [];
2438
+ }
2439
+ const files = [];
2440
+ for (const entry of entries) {
2441
+ if (entry.name === PROJECT_ROADMAP_FILE || entry.name === PROJECT_REGISTER_FILE)
2442
+ continue;
2443
+ if (entry.isFile()) {
2444
+ files.push(entry.name);
2445
+ } else if (entry.isDirectory()) {
2446
+ let nested;
2447
+ try {
2448
+ nested = readdirSync5(join9(root, entry.name), { withFileTypes: true });
2449
+ } catch {
2450
+ continue;
2451
+ }
2452
+ for (const child of nested) {
2453
+ if (child.isFile())
2454
+ files.push(`${entry.name}/${child.name}`);
2455
+ }
2456
+ }
2457
+ }
2458
+ return files.sort();
2459
+ }
2460
+ // src/migrate.ts
2461
+ import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "node:fs";
2462
+ import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
2463
+ var MIGRATE_STATUS_FILE = "status.json";
2464
+ var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
2465
+ var NOTES_LEDGER_FILE = "notes.jsonl";
2466
+ var DATE_RE4 = /^\d{4}-\d{2}-\d{2}$/;
2467
+ var ROOT_METADATA_LIFT_KEYS = {
2468
+ plan_parallelism: true,
2469
+ worktree_mode: true,
2470
+ push_policy: true,
2471
+ iteration_base_branch: true,
2472
+ target_branch: true,
2473
+ spec_integration_branch: true,
2474
+ control_worktree_path: true,
2475
+ integration_merge_lease: true,
2476
+ program_roadmap: true,
2477
+ updated_at: true
2478
+ };
2479
+ function isPlainObject6(value) {
2480
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2481
+ }
2482
+ function dateString(value) {
2483
+ return typeof value === "string" && DATE_RE4.test(value) ? value : undefined;
2484
+ }
2485
+ function rowIdOf(row) {
2486
+ if (typeof row.id === "string" && row.id.trim() !== "")
2487
+ return row.id;
2488
+ if (typeof row.plan_id === "string" && row.plan_id.trim() !== "")
2489
+ return row.plan_id;
2490
+ return null;
2491
+ }
2492
+ function compareIds(a, b) {
2493
+ return a < b ? -1 : a > b ? 1 : 0;
2494
+ }
2495
+ function todayString2() {
2496
+ const now = new Date;
2497
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2498
+ const day = String(now.getDate()).padStart(2, "0");
2499
+ return `${now.getFullYear()}-${month}-${day}`;
2500
+ }
2501
+ function scanCompasses(harnessDir) {
2502
+ const iterationsDir = join10(harnessDir, "iterations");
2503
+ const out = [];
2504
+ let entries;
2505
+ try {
2506
+ entries = readdirSync6(iterationsDir, { withFileTypes: true });
2507
+ } catch {
2508
+ return out;
2509
+ }
2510
+ for (const entry of entries) {
2511
+ if (!entry.isDirectory())
2512
+ continue;
2513
+ const compassPath = join10(iterationsDir, entry.name, "delivery-compass.md");
2514
+ let content;
2515
+ try {
2516
+ content = readFileSync8(compassPath, "utf8");
2517
+ } catch {
2518
+ continue;
2519
+ }
2520
+ const doc = parseCompassFrontmatterText(content, compassPath);
2521
+ const status = doc.status;
2522
+ if (status !== "active" && status !== "locked" && status !== "completed") {
2523
+ throw new Error(`refusing to migrate: compass ${JSON.stringify(compassPath)} has unsupported status ${JSON.stringify(status)} (expected active | locked | completed)`);
2524
+ }
2525
+ const plans = Array.isArray(doc.plans) ? doc.plans.filter((p) => typeof p === "string" && p !== "") : [];
2526
+ out.push({
2527
+ id: typeof doc.iteration_id === "string" && doc.iteration_id !== "" ? doc.iteration_id : entry.name,
2528
+ file: compassPath,
2529
+ status,
2530
+ plans,
2531
+ startDate: dateString(doc.start_date),
2532
+ endDate: dateString(doc.end_date)
2533
+ });
2534
+ }
2535
+ out.sort((a, b) => compareIds(a.id, b.id));
2536
+ return out;
2537
+ }
2538
+ function pickDate(preferred, fallback) {
2539
+ for (const candidate of preferred) {
2540
+ if (candidate !== undefined)
2541
+ return candidate;
2542
+ }
2543
+ return fallback;
2544
+ }
2545
+ function groupRows(rows, compasses) {
2546
+ const rowById = new Map;
2547
+ for (const row of rows) {
2548
+ const id = rowIdOf(row);
2549
+ if (id !== null && !rowById.has(id))
2550
+ rowById.set(id, row);
2551
+ }
2552
+ const byPlan = new Map;
2553
+ for (const compass of compasses) {
2554
+ for (const planId of compass.plans) {
2555
+ if (!byPlan.has(planId))
2556
+ byPlan.set(planId, compass);
2557
+ }
2558
+ }
2559
+ return { byPlan, rowById };
2560
+ }
2561
+ function buildIterationSnapshot(compass, rowById, rootUpdatedAt) {
2562
+ const rows = compass.plans.map((planId) => rowById.get(planId)).filter((row) => row !== undefined);
2563
+ rows.sort((a, b) => compareIds(rowIdOf(a) ?? "", rowIdOf(b) ?? ""));
2564
+ const rowDates = rows.flatMap((row) => [
2565
+ dateString(row.created_at),
2566
+ dateString(row.updated_at),
2567
+ dateString(row.done_at)
2568
+ ]);
2569
+ const startedAt = pickDate([compass.startDate, ...rowDates], rootUpdatedAt);
2570
+ const endedAt = pickDate([compass.endDate, ...rowDates], rootUpdatedAt);
2571
+ const status = compass.status === "completed" ? "completed" : "running";
2572
+ const compactMissing = compass.plans.filter((planId) => !rowById.has(planId)).sort();
2573
+ const snapshot = {
2574
+ schema_version: 1,
2575
+ id: compass.id,
2576
+ type: "iteration",
2577
+ status,
2578
+ started_at: startedAt,
2579
+ ...status === "completed" ? { ended_at: endedAt } : {},
2580
+ updated_at: status === "completed" ? endedAt : startedAt,
2581
+ plans: rows,
2582
+ compass_ref: `iterations/${compass.id}/delivery-compass.md`
2583
+ };
2584
+ const legacyMetadata = {};
2585
+ if (compactMissing.length > 0)
2586
+ legacyMetadata.compact_missing = compactMissing;
2587
+ if (Object.keys(legacyMetadata).length > 0)
2588
+ snapshot.legacy_metadata = legacyMetadata;
2589
+ return {
2590
+ id: compass.id,
2591
+ type: "iteration",
2592
+ status,
2593
+ file: join10("workflows", compass.id, WORKFLOW_SNAPSHOT_FILE),
2594
+ source: join10("iterations", compass.id, "delivery-compass.md"),
2595
+ data: snapshot
2596
+ };
2597
+ }
2598
+ function buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes) {
2599
+ const id = rowIdOf(row) ?? "<unnamed>";
2600
+ const statusValue = row.status;
2601
+ let status;
2602
+ if (statusValue === "Done")
2603
+ status = "completed";
2604
+ else if (statusValue === "InProgress" || statusValue === "InReview")
2605
+ status = "running";
2606
+ else if (statusValue === "Todo" || statusValue === "Blocked")
2607
+ status = "paused";
2608
+ else {
2609
+ status = "paused";
2610
+ migrationNotes.push(`row ${JSON.stringify(id)} has unrecognized v1 status ${JSON.stringify(statusValue)} — snapshot status defaults to paused (row status stays verbatim)`);
2611
+ }
2612
+ const startedAt = pickDate([dateString(row.created_at), dateString(row.updated_at)], rootUpdatedAt);
2613
+ const updatedAt = pickDate([dateString(row.updated_at), dateString(row.done_at), dateString(row.created_at)], rootUpdatedAt);
2614
+ const endedAt = pickDate([dateString(row.done_at), dateString(row.updated_at)], rootUpdatedAt);
2615
+ const snapshot = {
2616
+ schema_version: 1,
2617
+ id,
2618
+ type: "plan",
2619
+ status,
2620
+ started_at: startedAt,
2621
+ ...status === "completed" ? { ended_at: endedAt } : {},
2622
+ updated_at: status === "completed" ? endedAt : updatedAt,
2623
+ plans: [row]
2624
+ };
2625
+ return {
2626
+ id,
2627
+ type: "plan",
2628
+ status,
2629
+ file: join10("workflows", id, WORKFLOW_SNAPSHOT_FILE),
2630
+ source: "status.json plans[] row",
2631
+ data: snapshot
2632
+ };
2633
+ }
2634
+ function applyRootMetadataLift(snapshot, metadata, migrationNotes, activeIterations) {
2635
+ const data = snapshot.data;
2636
+ const policy = {};
2637
+ for (const key of ["plan_parallelism", "worktree_mode", "push_policy"]) {
2638
+ if (metadata[key] !== undefined)
2639
+ policy[key] = metadata[key];
2640
+ }
2641
+ if (Object.keys(policy).length > 0)
2642
+ data.execution_policy = policy;
2643
+ const branch = {};
2644
+ const branchKeys = [
2645
+ ["base", "iteration_base_branch"],
2646
+ ["integration", "spec_integration_branch"],
2647
+ ["target", "target_branch"]
2648
+ ];
2649
+ for (const [target, source] of branchKeys) {
2650
+ const value = metadata[source];
2651
+ if (typeof value === "string" && value !== "")
2652
+ branch[target] = value;
2653
+ }
2654
+ if (Object.keys(branch).length > 0)
2655
+ data.branch = branch;
2656
+ if (typeof metadata.control_worktree_path === "string" && metadata.control_worktree_path !== "") {
2657
+ data.control_worktree_path = metadata.control_worktree_path;
2658
+ }
2659
+ if (isPlainObject6(metadata.integration_merge_lease)) {
2660
+ data.integration_merge_lease = metadata.integration_merge_lease;
2661
+ }
2662
+ const legacyMetadata = isPlainObject6(data.legacy_metadata) ? { ...data.legacy_metadata } : {};
2663
+ for (const [key, value] of Object.entries(metadata)) {
2664
+ if (key === "harness_root")
2665
+ continue;
2666
+ if (ROOT_METADATA_LIFT_KEYS[key] === true)
2667
+ continue;
2668
+ legacyMetadata[key] = value;
2669
+ }
2670
+ if (metadata.harness_root !== undefined) {
2671
+ legacyMetadata.harness_root_note = `dropped as redundant (v2 harness dir derives from status.json location): ${String(metadata.harness_root)}`;
2672
+ }
2673
+ if (Object.keys(legacyMetadata).length > 0)
2674
+ data.legacy_metadata = legacyMetadata;
2675
+ if (activeIterations > 1) {
2676
+ migrationNotes.push(`${activeIterations} active iterations present — root-metadata lifts applied to ${JSON.stringify(snapshot.id)} only`);
2677
+ }
2678
+ }
2679
+ function buildRoadmap(programRoadmap, projectId, migratedAt) {
2680
+ const title = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
2681
+ const doc = typeof programRoadmap.doc === "string" ? programRoadmap.doc : "";
2682
+ const completionVersion = typeof programRoadmap.completion_version === "string" ? programRoadmap.completion_version : "";
2683
+ const branch = typeof programRoadmap.branch === "string" ? programRoadmap.branch : "";
2684
+ const milestones = Array.isArray(programRoadmap.slices) ? programRoadmap.slices.map((slice) => String(slice)).filter((slice) => slice !== "") : [];
2685
+ const deferred = Array.isArray(programRoadmap.deferred_beyond) ? programRoadmap.deferred_beyond.map((item) => String(item)).filter((item) => item !== "") : [];
2686
+ const lines = [
2687
+ "---",
2688
+ `project_id: ${projectId}`,
2689
+ `title: ${title}`,
2690
+ "status: active",
2691
+ `created_at: ${migratedAt}`
2692
+ ];
2693
+ if (milestones.length > 0) {
2694
+ lines.push("milestones:");
2695
+ for (const milestone of milestones)
2696
+ lines.push(` - ${milestone}`);
2697
+ }
2698
+ lines.push("residuals_ref: residuals.json", "---", "", "# Roadmap", "", "## Direction");
2699
+ const provenance = [doc !== "" ? `doc: ${doc}` : "", completionVersion !== "" ? `completion_version: ${completionVersion}` : "", branch !== "" ? `branch: ${branch}` : ""].filter((part) => part !== "").join(", ");
2700
+ lines.push(`Migrated from legacy status.json \`metadata.program_roadmap\`${provenance !== "" ? ` (${provenance})` : ""}.`);
2701
+ if (programRoadmap.no_intermediate_releases !== undefined) {
2702
+ lines.push(`no_intermediate_releases: ${String(programRoadmap.no_intermediate_releases)}`);
2703
+ }
2704
+ if (deferred.length > 0) {
2705
+ lines.push("", "### Deferred beyond", ...deferred.map((item) => `- ${item}`));
2706
+ }
2707
+ lines.push("");
2708
+ return lines.join(`
2709
+ `);
2710
+ }
2711
+ function buildRegister(residualFindings, byPlan, projectId, migratedAt) {
2712
+ const entries = {};
2713
+ const planKeys = Object.keys(residualFindings).sort();
2714
+ for (const planId of planKeys) {
2715
+ const raw = residualFindings[planId];
2716
+ if (!Array.isArray(raw))
2717
+ continue;
2718
+ const open = raw.filter((entry) => isPlainObject6(entry) && isOpenResidual(entry)).sort((a, b) => {
2719
+ const aId = typeof a.id === "string" ? a.id : "";
2720
+ const bId = typeof b.id === "string" ? b.id : "";
2721
+ return compareIds(aId, bId);
2722
+ });
2723
+ if (open.length === 0)
2724
+ continue;
2725
+ const owner = byPlan.get(planId);
2726
+ entries[planId] = open.map((entry) => ({
2727
+ ...entry,
2728
+ source_plan: planId,
2729
+ registered_at: migratedAt,
2730
+ ...owner !== undefined ? { lifecycle_id: owner.id } : {}
2731
+ }));
2732
+ }
2733
+ if (Object.keys(entries).length === 0)
2734
+ return null;
2735
+ const doc = { entries };
2736
+ return {
2737
+ file: join10("projects", projectId, PROJECT_REGISTER_FILE),
2738
+ source: "status.json residual_findings",
2739
+ data: doc
2740
+ };
2741
+ }
2742
+ function collectNotesFiles(snapshots) {
2743
+ const out = [];
2744
+ for (const snapshot of snapshots) {
2745
+ const lines = [];
2746
+ let source = "";
2747
+ for (const row of snapshot.data.plans) {
2748
+ if (Array.isArray(row.notes)) {
2749
+ source = "status.json plans[].notes arrays";
2750
+ for (const note of row.notes) {
2751
+ if (typeof note !== "string")
2752
+ continue;
2753
+ lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: note }));
2754
+ }
2755
+ }
2756
+ }
2757
+ if (snapshot.type === "plan" && snapshot.status === "paused") {
2758
+ const row = snapshot.data.plans[0];
2759
+ if (row !== undefined && row.status === "Todo") {
2760
+ source = source === "" ? "status.json plans[] Todo rows (not-started note)" : source;
2761
+ const noteText = typeof row.id === "string" ? `${row.id} not started (v1 row status Todo mapped to snapshot status paused)` : "not started (v1 row status Todo mapped to snapshot status paused)";
2762
+ lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: noteText }));
2763
+ }
2764
+ }
2765
+ if (lines.length === 0)
2766
+ continue;
2767
+ out.push({
2768
+ file: join10(dirname7(snapshot.file), NOTES_LEDGER_FILE),
2769
+ source,
2770
+ lines
2771
+ });
2772
+ }
2773
+ return out;
2774
+ }
2775
+ function migrateHarnessTree(root, opts = {}) {
2776
+ const harnessDir = resolve8(root);
2777
+ const workflowDir = resolveWorkflowDir(harnessDir, { harnessDir });
2778
+ const projectDir = resolveProjectDir(harnessDir, { harnessDir });
2779
+ const projectId = opts.projectId ?? _DEFAULT_PROJECT;
2780
+ const statusPath = join10(harnessDir, MIGRATE_STATUS_FILE);
2781
+ const legacy = readJson(statusPath);
2782
+ if (legacy.version === 2) {
2783
+ const updatedAt = typeof legacy.updated_at === "string" && legacy.updated_at !== "" ? legacy.updated_at : "1970-01-01";
2784
+ return {
2785
+ root: harnessDir,
2786
+ workflowDir,
2787
+ projectDir,
2788
+ dryRun: opts.dryRun === true,
2789
+ alreadyMigrated: true,
2790
+ message: `no-op: ${statusPath} is already at schema version 2 (migrated) — nothing to do`,
2791
+ snapshots: [],
2792
+ notesFiles: [],
2793
+ register: null,
2794
+ roadmap: null,
2795
+ rootV2: { file: MIGRATE_STATUS_FILE, data: { version: 2, updated_at: updatedAt, workflows: [] } },
2796
+ archive: { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE },
2797
+ migrationNotes: [],
2798
+ steps: []
2799
+ };
2800
+ }
2801
+ if (legacy.version !== undefined && legacy.version !== 1) {
2802
+ throw new Error(`refusing to migrate: ${statusPath} has unrecognized schema version ${JSON.stringify(legacy.version)} (expected 1)`);
2803
+ }
2804
+ if (legacy.version === undefined) {
2805
+ throw new Error(`refusing to migrate: no v1 status.json found at ${statusPath} (nothing to migrate)`);
2806
+ }
2807
+ const rows = Array.isArray(legacy.plans) ? legacy.plans.filter(isPlainObject6) : [];
2808
+ if (Array.isArray(legacy.plans)) {
2809
+ const unLiftable = [];
2810
+ const idCounts = new Map;
2811
+ for (const row of legacy.plans) {
2812
+ if (!isPlainObject6(row) || rowIdOf(row) === null) {
2813
+ unLiftable.push(row);
2814
+ continue;
2815
+ }
2816
+ const id = rowIdOf(row);
2817
+ assertSafePathComponent(id, "plan id");
2818
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
2819
+ }
2820
+ if (unLiftable.length > 0) {
2821
+ throw new Error(`refusing to migrate: ${unLiftable.length} plans[] row(s) cannot be lifted (missing id/plan_id or not an object) — every v1 row must land in exactly one snapshot`);
2822
+ }
2823
+ const duplicates = [...idCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id);
2824
+ if (duplicates.length > 0) {
2825
+ throw new Error(`refusing to migrate: ${duplicates.length} duplicate plan id(s) (${duplicates.join(", ")}) — every v1 row must land in exactly one snapshot`);
2826
+ }
2827
+ }
2828
+ const metadata = isPlainObject6(legacy.metadata) ? legacy.metadata : {};
2829
+ const rootUpdatedAt = dateString(legacy.updated_at) ?? dateString(metadata.updated_at) ?? todayString2();
2830
+ const migratedAt = dateString(metadata.updated_at) ?? rootUpdatedAt;
2831
+ const migrationNotes = [];
2832
+ const compasses = scanCompasses(harnessDir);
2833
+ for (const compass of compasses) {
2834
+ assertSafePathComponent(compass.id, "iteration id");
2835
+ }
2836
+ assertSafePathComponent(projectId, "projectId");
2837
+ const { byPlan, rowById } = groupRows(rows, compasses);
2838
+ const snapshots = compasses.map((compass) => buildIterationSnapshot(compass, rowById, rootUpdatedAt));
2839
+ for (const row of rows) {
2840
+ const id = rowIdOf(row);
2841
+ if (id !== null && !byPlan.has(id))
2842
+ snapshots.push(buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes));
2843
+ }
2844
+ const lifecycleSources = new Map;
2845
+ for (const snapshot of snapshots) {
2846
+ const sources = lifecycleSources.get(snapshot.id) ?? [];
2847
+ sources.push(snapshot.type === "iteration" ? "iteration" : "standalone plan");
2848
+ lifecycleSources.set(snapshot.id, sources);
2849
+ }
2850
+ const projectSources = lifecycleSources.get(projectId) ?? [];
2851
+ projectSources.push("project");
2852
+ lifecycleSources.set(projectId, projectSources);
2853
+ const collisions = [...lifecycleSources.entries()].filter(([, sources]) => sources.length > 1);
2854
+ if (collisions.length > 0) {
2855
+ throw new Error(`refusing to migrate: ${collisions.length} lifecycle id collision(s) (${collisions.map(([id, sources]) => `${JSON.stringify(id)} shared by ${sources.join(" + ")}`).join("; ")}) — every id must be unique across iterations, standalone plans and the project id (each id becomes a workflow/project dir segment)`);
2856
+ }
2857
+ snapshots.sort((a, b) => compareIds(a.id, b.id));
2858
+ const activeIterations = snapshots.filter((snapshot) => snapshot.status === "running" && snapshot.type === "iteration");
2859
+ if (activeIterations.length > 0) {
2860
+ applyRootMetadataLift(activeIterations[0], metadata, migrationNotes, activeIterations.length);
2861
+ } else if (Object.keys(metadata).length > 0) {
2862
+ migrationNotes.push("no active iteration snapshot found — root metadata execution-policy/branch keys have no lift home and stay unmapped (visible here, not silently dropped)");
2863
+ }
2864
+ const notesFiles = collectNotesFiles(snapshots);
2865
+ const residualFindings = isPlainObject6(legacy.residual_findings) ? legacy.residual_findings : {};
2866
+ const register = buildRegister(residualFindings, byPlan, projectId, migratedAt);
2867
+ const programRoadmap = isPlainObject6(metadata.program_roadmap) ? metadata.program_roadmap : null;
2868
+ let roadmap = null;
2869
+ if (programRoadmap) {
2870
+ const rawTitle = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
2871
+ const sanitizedTitle = rawTitle.replace(/[\r\n]+/g, " ").trim();
2872
+ if (sanitizedTitle !== rawTitle) {
2873
+ migrationNotes.push(`roadmap title sanitized for frontmatter (line breaks replaced with spaces): ${JSON.stringify(rawTitle)}`);
2874
+ }
2875
+ roadmap = {
2876
+ file: join10("projects", projectId, PROJECT_ROADMAP_FILE),
2877
+ source: "status.json metadata.program_roadmap",
2878
+ content: buildRoadmap({ ...programRoadmap, title: sanitizedTitle }, projectId, migratedAt)
2879
+ };
2880
+ }
2881
+ const rootV2 = {
2882
+ file: MIGRATE_STATUS_FILE,
2883
+ data: { version: 2, updated_at: migratedAt, workflows: [] }
2884
+ };
2885
+ const archive = { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE };
2886
+ const steps = [
2887
+ { kind: "archive-status-v1", source: archive.source, destination: archive.file },
2888
+ ...snapshots.map((snapshot) => ({
2889
+ kind: "write-snapshot",
2890
+ source: snapshot.source,
2891
+ destination: snapshot.file
2892
+ })),
2893
+ ...notesFiles.map((notes) => ({
2894
+ kind: "write-notes",
2895
+ source: notes.source,
2896
+ destination: notes.file
2897
+ }))
2898
+ ];
2899
+ if (register !== null)
2900
+ steps.push({ kind: "write-register", source: register.source, destination: register.file });
2901
+ if (roadmap !== null)
2902
+ steps.push({ kind: "write-roadmap", source: roadmap.source, destination: roadmap.file });
2903
+ steps.push({ kind: "replace-root-v2", source: `${MIGRATE_STATUS_FILE} (v1)`, destination: `${MIGRATE_STATUS_FILE} (v2)` });
2904
+ return {
2905
+ root: harnessDir,
2906
+ workflowDir,
2907
+ projectDir,
2908
+ dryRun: opts.dryRun === true,
2909
+ alreadyMigrated: false,
2910
+ message: `planned migration of ${snapshots.length} lifecycles (${steps.length} steps)`,
2911
+ snapshots,
2912
+ notesFiles,
2913
+ register,
2914
+ roadmap,
2915
+ rootV2,
2916
+ archive,
2917
+ migrationNotes,
2918
+ steps
2919
+ };
2920
+ }
2921
+ async function applyMigratePlan(plan) {
2922
+ if (plan.dryRun) {
2923
+ return { applied: false, message: `dry-run: ${plan.steps.length} steps planned (source → destination), zero writes` };
2924
+ }
2925
+ const statusPath = join10(plan.root, MIGRATE_STATUS_FILE);
2926
+ const current = readJson(statusPath);
2927
+ if (current.version === 2) {
2928
+ return { applied: false, message: "no-op: status.json already at schema version 2 (migrated) — nothing to do" };
2929
+ }
2930
+ const harnessRoot = resolve8(plan.root);
2931
+ const workflowRoot = resolve8(plan.workflowDir);
2932
+ const projectRoot = resolve8(plan.projectDir);
2933
+ if (!isAbsolute6(plan.workflowDir) || !isAbsolute6(plan.projectDir)) {
2934
+ throw new Error(`refusing to apply migration: plan workflowDir/projectDir must be absolute (got ${JSON.stringify(plan.workflowDir)} / ${JSON.stringify(plan.projectDir)})`);
2935
+ }
2936
+ const workflowTargetOf = (canonicalFile) => join10(workflowRoot, relative3("workflows", canonicalFile));
2937
+ const projectTargetOf = (canonicalFile) => join10(projectRoot, relative3("projects", canonicalFile));
2938
+ const allDestinations = [
2939
+ plan.archive.file,
2940
+ ...plan.snapshots.map((snapshot) => snapshot.file),
2941
+ ...plan.notesFiles.map((notes) => notes.file),
2942
+ ...plan.register !== null ? [plan.register.file] : [],
2943
+ ...plan.roadmap !== null ? [plan.roadmap.file] : []
2944
+ ];
2945
+ for (const destination of allDestinations) {
2946
+ const resolvedDest = resolve8(join10(plan.root, destination));
2947
+ const inside = (dir) => resolvedDest === dir || resolvedDest.startsWith(`${dir}${sep2}`);
2948
+ if (!inside(harnessRoot) && !inside(workflowRoot) && !inside(projectRoot)) {
2949
+ throw new Error(`refusing to apply migration: destination escapes the harness dir (${JSON.stringify(destination)}) — every write must stay under ${JSON.stringify(plan.root)}, the workflow dir (${JSON.stringify(plan.workflowDir)}) or the project dir (${JSON.stringify(plan.projectDir)})`);
2950
+ }
2951
+ }
2952
+ mkdirSync6(join10(plan.root, dirname7(plan.archive.file)), { recursive: true });
2953
+ copyFileSync(statusPath, join10(plan.root, plan.archive.file));
2954
+ for (const snapshot of plan.snapshots) {
2955
+ await writeWorkflowSnapshot(snapshot.data, dirname7(workflowTargetOf(snapshot.file)));
2956
+ }
2957
+ for (const notes of plan.notesFiles) {
2958
+ const filePath = workflowTargetOf(notes.file);
2959
+ mkdirSync6(dirname7(filePath), { recursive: true });
2960
+ const content = notes.lines.length > 0 ? `${notes.lines.join(`
2961
+ `)}
2962
+ ` : "";
2963
+ writeFileSync4(filePath, content, "utf8");
2964
+ }
2965
+ if (plan.register !== null) {
2966
+ const gate2 = validateProjectRegister(plan.register.data);
2967
+ if (!gate2.ok) {
2968
+ throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
2969
+ }
2970
+ const filePath = projectTargetOf(plan.register.file);
2971
+ mkdirSync6(dirname7(filePath), { recursive: true });
2972
+ writeJson(filePath, plan.register.data);
2973
+ }
2974
+ if (plan.roadmap !== null) {
2975
+ const filePath = projectTargetOf(plan.roadmap.file);
2976
+ mkdirSync6(dirname7(filePath), { recursive: true });
2977
+ writeFileSync4(filePath, plan.roadmap.content, "utf8");
2978
+ }
2979
+ const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
2980
+ if (!rootGate.ok) {
2981
+ throw new Error(`refusing to apply migration: invalid v2 root: ${rootGate.violations.map((v) => v.message).join("; ")}`);
2982
+ }
2983
+ return withStatusWriteLock(statusPath, () => {
2984
+ const latest = readJson(statusPath);
2985
+ if (latest.version === 2) {
2986
+ return {
2987
+ applied: false,
2988
+ message: "no-op: status.json already at schema version 2 (migrated) — nothing to do"
2989
+ };
2990
+ }
2991
+ writeJson(statusPath, plan.rootV2.data);
2992
+ return {
2993
+ applied: true,
2994
+ message: `migrated ${plan.snapshots.length} lifecycles into workflows/, project layer seeded, root status.json replaced (v1 archived to ${plan.archive.file})`
2995
+ };
2996
+ });
2997
+ }
1948
2998
  // src/design-md.ts
1949
- function violation6(severity, code, message, fix) {
2999
+ function violation8(severity, code, message, fix) {
1950
3000
  return { ok: false, severity, code, message, fix };
1951
3001
  }
1952
3002
  var RAW_GROUP = "__raw";
@@ -2059,7 +3109,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2059
3109
  const violations = [];
2060
3110
  const fm = parseDesignFrontmatter(frontmatterText);
2061
3111
  if (fm === null) {
2062
- violations.push(violation6("medium", "design-md.tokens.missing-frontmatter", "no `---` YAML frontmatter block found — DESIGN.md must open with a fenced frontmatter holding the token SSOT (design-md-spec §1.5)", "add a `---` fenced frontmatter with version, name, description, and the colors/typography/spacing/rounded groups"));
3112
+ violations.push(violation8("medium", "design-md.tokens.missing-frontmatter", "no `---` YAML frontmatter block found — DESIGN.md must open with a fenced frontmatter holding the token SSOT (design-md-spec §1.5)", "add a `---` fenced frontmatter with version, name, description, and the colors/typography/spacing/rounded groups"));
2063
3113
  return { ok: false, violations };
2064
3114
  }
2065
3115
  const groupEntries = (group) => {
@@ -2074,61 +3124,61 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2074
3124
  };
2075
3125
  for (const group of ["colors", "typography", "spacing", "rounded"]) {
2076
3126
  if (!isMap(fm[group])) {
2077
- violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
3127
+ violations.push(violation8("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
2078
3128
  } else if (!groupIsMap(group)) {
2079
- violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
3129
+ violations.push(violation8("medium", "design-md.tokens.group-not-map", `token group "${group}" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`${group}\` as a nested map`));
2080
3130
  } else if (groupEntries(group).length === 0) {
2081
- violations.push(violation6("medium", "design-md.tokens.missing-group", `missing required token group "${group}" — colors/typography/spacing/rounded are required by the frontmatter SSOT (design-md-spec §1.5)`, `add an active \`${group}:\` block with concrete token values`));
3131
+ violations.push(violation8("medium", "design-md.tokens.missing-group", `missing required token group "${group}" — colors/typography/spacing/rounded are required by the frontmatter SSOT (design-md-spec §1.5)`, `add an active \`${group}:\` block with concrete token values`));
2082
3132
  }
2083
3133
  }
2084
3134
  if (!isMap(fm.components) || !groupIsMap("components")) {
2085
- violations.push(violation6("medium", "design-md.tokens.group-not-map", `token group "components" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`components\` as a nested map`));
3135
+ violations.push(violation8("medium", "design-md.tokens.group-not-map", `token group "components" must be a YAML map, not a scalar (design-md-spec §1.5)`, `rewrite \`components\` as a nested map`));
2086
3136
  }
2087
- const placeholder = (group, name, value) => violations.push(violation6("low", "design-md.tokens.placeholder", `token "${group}.${name}" uses a "[...]" template value — placeholders never count as concrete tokens (completeness-checklist § How to use item 5)`, `replace \`${value}\` with a concrete value`));
3137
+ const placeholder = (group, name, value) => violations.push(violation8("low", "design-md.tokens.placeholder", `token "${group}.${name}" uses a "[...]" template value — placeholders never count as concrete tokens (completeness-checklist § How to use item 5)`, `replace \`${value}\` with a concrete value`));
2088
3138
  for (const [name, value] of groupEntries("colors")) {
2089
3139
  if (typeof value !== "string") {
2090
- violations.push(violation6("medium", "design-md.tokens.color-format", `color "${name}" must be a string value (design-md-spec §2.2)`, "quote the color value"));
3140
+ violations.push(violation8("medium", "design-md.tokens.color-format", `color "${name}" must be a string value (design-md-spec §2.2)`, "quote the color value"));
2091
3141
  continue;
2092
3142
  }
2093
3143
  if (isPlaceholder(value)) {
2094
3144
  placeholder("colors", name, value);
2095
3145
  } else if (!HEX_RE.test(value) && !OKLCH_RE.test(value)) {
2096
- violations.push(violation6("medium", "design-md.tokens.color-format", `color "${name}" = "${value}" is not a hex (\`#rrggbb\`/\`#rrggbbaa\`) or oklch() value (design-md-spec §2.2)`, "use an sRGB hex value, optionally with a `-p3` oklch() twin"));
3146
+ violations.push(violation8("medium", "design-md.tokens.color-format", `color "${name}" = "${value}" is not a hex (\`#rrggbb\`/\`#rrggbbaa\`) or oklch() value (design-md-spec §2.2)`, "use an sRGB hex value, optionally with a `-p3` oklch() twin"));
2097
3147
  }
2098
3148
  }
2099
3149
  for (const [name, value] of groupEntries("typography")) {
2100
3150
  if (!isMap(value)) {
2101
- violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" must be a map of the five properties (design-md-spec §1.5)`, "give it fontFamily/fontSize/fontWeight/lineHeight/letterSpacing"));
3151
+ violations.push(violation8("medium", "design-md.tokens.typography-shape", `typography token "${name}" must be a map of the five properties (design-md-spec §1.5)`, "give it fontFamily/fontSize/fontWeight/lineHeight/letterSpacing"));
2102
3152
  continue;
2103
3153
  }
2104
3154
  const keys = Object.keys(value);
2105
3155
  const missing = TYPOGRAPHY_PROPS.filter((p) => !keys.includes(p));
2106
3156
  const extra = keys.filter((k) => !TYPOGRAPHY_PROPS.includes(k));
2107
3157
  if (missing.length > 0 || extra.length > 0) {
2108
- violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" must have exactly the five properties fontFamily/fontSize/fontWeight/lineHeight/letterSpacing (design-md-spec §1.5)${missing.length > 0 ? ` — missing: ${missing.join(", ")}` : ""}${extra.length > 0 ? ` — extra: ${extra.join(", ")}` : ""}`, "align the token with the five-property shape"));
3158
+ violations.push(violation8("medium", "design-md.tokens.typography-shape", `typography token "${name}" must have exactly the five properties fontFamily/fontSize/fontWeight/lineHeight/letterSpacing (design-md-spec §1.5)${missing.length > 0 ? ` — missing: ${missing.join(", ")}` : ""}${extra.length > 0 ? ` — extra: ${extra.join(", ")}` : ""}`, "align the token with the five-property shape"));
2109
3159
  }
2110
3160
  for (const prop of ["fontFamily", "fontSize"]) {
2111
3161
  const v = value[prop];
2112
3162
  if (typeof v === "string" && isPlaceholder(v))
2113
3163
  placeholder("typography", name, v);
2114
3164
  else if (typeof v !== "string" || v.trim() === "") {
2115
- violations.push(violation6("medium", "design-md.tokens.typography-shape", `typography token "${name}" has an empty \`${prop}\` (design-md-spec §1.5)`, `fill \`${prop}\` with a concrete value`));
3165
+ violations.push(violation8("medium", "design-md.tokens.typography-shape", `typography token "${name}" has an empty \`${prop}\` (design-md-spec §1.5)`, `fill \`${prop}\` with a concrete value`));
2116
3166
  }
2117
3167
  }
2118
3168
  }
2119
3169
  if (groupIsMap("spacing")) {
2120
3170
  const spacing = fm.spacing;
2121
3171
  if (!Object.prototype.hasOwnProperty.call(spacing, "base")) {
2122
- violations.push(violation6("medium", "design-md.tokens.spacing-base", "spacing must declare the base unit as `base` (design-md-spec §2.4)", "add `base: 4px` (or 8px) to the spacing group"));
3172
+ violations.push(violation8("medium", "design-md.tokens.spacing-base", "spacing must declare the base unit as `base` (design-md-spec §2.4)", "add `base: 4px` (or 8px) to the spacing group"));
2123
3173
  }
2124
3174
  for (const [name, value] of Object.entries(spacing)) {
2125
3175
  if (name !== "base" && name !== RAW_GROUP && !/^\d+$/.test(name)) {
2126
- violations.push(violation6("medium", "design-md.tokens.spacing-key", `spacing key "${name}" must be \`base\` or a numeric multiplier (design-md-spec §1.5)`, "use numeric scale-step keys or `base`"));
3176
+ violations.push(violation8("medium", "design-md.tokens.spacing-key", `spacing key "${name}" must be \`base\` or a numeric multiplier (design-md-spec §1.5)`, "use numeric scale-step keys or `base`"));
2127
3177
  }
2128
3178
  if (typeof value === "string" && isPlaceholder(value)) {
2129
3179
  placeholder("spacing", name, value);
2130
3180
  } else if (typeof value !== "string" || !PX_RE.test(value)) {
2131
- violations.push(violation6("medium", "design-md.tokens.spacing-format", `spacing value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `4px`"));
3181
+ violations.push(violation8("medium", "design-md.tokens.spacing-format", `spacing value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `4px`"));
2132
3182
  }
2133
3183
  }
2134
3184
  }
@@ -2136,12 +3186,12 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2136
3186
  if (typeof value === "string" && isPlaceholder(value)) {
2137
3187
  placeholder("rounded", name, value);
2138
3188
  } else if (typeof value !== "string" || !PX_RE.test(value)) {
2139
- violations.push(violation6("medium", "design-md.tokens.rounded-format", `rounded value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `6px`"));
3189
+ violations.push(violation8("medium", "design-md.tokens.rounded-format", `rounded value "${name}" = "${String(value)}" is not a px length (design-md-spec §1.5)`, "use a pixel value like `6px`"));
2140
3190
  }
2141
3191
  }
2142
3192
  for (const [name, value] of groupEntries("components")) {
2143
3193
  if (!isMap(value)) {
2144
- violations.push(violation6("medium", "design-md.tokens.components-shape", `component token "${name}" must be a map of properties (design-md-spec §2.8)`, "give it backgroundColor/textColor/typography/rounded/padding/height"));
3194
+ violations.push(violation8("medium", "design-md.tokens.components-shape", `component token "${name}" must be a map of properties (design-md-spec §2.8)`, "give it backgroundColor/textColor/typography/rounded/padding/height"));
2145
3195
  continue;
2146
3196
  }
2147
3197
  for (const [prop, v] of Object.entries(value)) {
@@ -2157,7 +3207,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2157
3207
  const [, refGroup, refKey] = ref;
2158
3208
  const resolves = REF_GROUPS.includes(refGroup) && groupIsMap(refGroup) && Object.prototype.hasOwnProperty.call(fm[refGroup], refKey);
2159
3209
  if (!resolves) {
2160
- violations.push(violation6("medium", "design-md.tokens.ref-unresolved", `component "${name}" references "${v}" which does not resolve to an active token in this frontmatter (design-md-spec §6 — {path} refs MUST trace back to a key)`, `add the referenced token or use a literal value`));
3210
+ violations.push(violation8("medium", "design-md.tokens.ref-unresolved", `component "${name}" references "${v}" which does not resolve to an active token in this frontmatter (design-md-spec §6 — {path} refs MUST trace back to a key)`, `add the referenced token or use a literal value`));
2161
3211
  }
2162
3212
  }
2163
3213
  }
@@ -2169,7 +3219,7 @@ function assertLightDarkParity(lightFm, darkFm) {
2169
3219
  const light = parseDesignFrontmatter(lightFm);
2170
3220
  const dark = parseDesignFrontmatter(darkFm);
2171
3221
  if (light === null || dark === null) {
2172
- violations.push(violation6("medium", "design-md.parity.missing-frontmatter", `light/dark parity needs a YAML frontmatter in both files — ${light === null ? "DESIGN.md" : "DESIGN.dark.md"} has none (design-md-spec §4 rules 1–2)`, "add the fenced frontmatter to both theme files"));
3222
+ violations.push(violation8("medium", "design-md.parity.missing-frontmatter", `light/dark parity needs a YAML frontmatter in both files — ${light === null ? "DESIGN.md" : "DESIGN.dark.md"} has none (design-md-spec §4 rules 1–2)`, "add the fenced frontmatter to both theme files"));
2173
3223
  return { ok: false, violations };
2174
3224
  }
2175
3225
  const activeKeys = (fm) => {
@@ -2187,12 +3237,12 @@ function assertLightDarkParity(lightFm, darkFm) {
2187
3237
  const darkKeys = activeKeys(dark);
2188
3238
  for (const key of lightKeys) {
2189
3239
  if (!darkKeys.has(key)) {
2190
- violations.push(violation6("medium", "design-md.parity.missing-dark", `token "${key}" is active in DESIGN.md but missing from DESIGN.dark.md — both files must define the same token set (design-md-spec §4 rule 3)`, "add the token to DESIGN.dark.md with a dark-appropriate value"));
3240
+ violations.push(violation8("medium", "design-md.parity.missing-dark", `token "${key}" is active in DESIGN.md but missing from DESIGN.dark.md — both files must define the same token set (design-md-spec §4 rule 3)`, "add the token to DESIGN.dark.md with a dark-appropriate value"));
2191
3241
  }
2192
3242
  }
2193
3243
  for (const key of darkKeys) {
2194
3244
  if (!lightKeys.has(key)) {
2195
- violations.push(violation6("medium", "design-md.parity.missing-light", `token "${key}" is active in DESIGN.dark.md but missing from DESIGN.md — DESIGN.md is the SSOT for token names (design-md-spec §4 rules 3–4)`, "add the token to DESIGN.md, or remove it from the dark file"));
3245
+ violations.push(violation8("medium", "design-md.parity.missing-light", `token "${key}" is active in DESIGN.dark.md but missing from DESIGN.md — DESIGN.md is the SSOT for token names (design-md-spec §4 rules 3–4)`, "add the token to DESIGN.md, or remove it from the dark file"));
2196
3246
  }
2197
3247
  }
2198
3248
  return { ok: violations.length === 0, violations };
@@ -2346,9 +3396,9 @@ function completenessLevel(frontmatterText, checklist) {
2346
3396
  return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
2347
3397
  }
2348
3398
  // src/audit.ts
2349
- import { mkdirSync as mkdirSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "node:fs";
2350
- import { join as join7, resolve as resolve7 } from "node:path";
2351
- function violation7(severity, code, message, fix) {
3399
+ import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
3400
+ import { join as join11, resolve as resolve9 } from "node:path";
3401
+ function violation9(severity, code, message, fix) {
2352
3402
  return { ok: false, severity, code, message, fix };
2353
3403
  }
2354
3404
  var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
@@ -2392,14 +3442,14 @@ function validateAuditStatusBlocks(planText) {
2392
3442
  const violations = [];
2393
3443
  const blocks = parseStatusBlocks(planText);
2394
3444
  if (blocks.length === 0) {
2395
- violations.push(violation7("medium", "audit.status.missing-block", "no `## Status` block found — audit plan files carry the Status block fields (mstar-audit SKILL § Plan files)", "add a `## Status` block with Priority, Effort, Risk, Depends on, Category, Planned at"));
3445
+ violations.push(violation9("medium", "audit.status.missing-block", "no `## Status` block found — audit plan files carry the Status block fields (mstar-audit SKILL § Plan files)", "add a `## Status` block with Priority, Effort, Risk, Depends on, Category, Planned at"));
2396
3446
  return { ok: false, violations };
2397
3447
  }
2398
3448
  blocks.forEach((block, index) => {
2399
3449
  const label = blocks.length > 1 ? ` #${index + 1}` : "";
2400
3450
  for (const field of AUDIT_STATUS_FIELDS) {
2401
3451
  if (!block.fields.has(field)) {
2402
- violations.push(violation7("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL § Plan files)`, `add \`- **${field}**: <value>\` to the Status block`));
3452
+ violations.push(violation9("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL § Plan files)`, `add \`- **${field}**: <value>\` to the Status block`));
2403
3453
  }
2404
3454
  }
2405
3455
  const check = (field, pattern, code, expected) => {
@@ -2407,7 +3457,7 @@ function validateAuditStatusBlocks(planText) {
2407
3457
  if (value === undefined)
2408
3458
  return;
2409
3459
  if (!pattern.test(value)) {
2410
- violations.push(violation7("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
3460
+ violations.push(violation9("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
2411
3461
  }
2412
3462
  };
2413
3463
  check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
@@ -2521,7 +3571,7 @@ function renderPlanFile(finding, plannedAt) {
2521
3571
  `;
2522
3572
  }
2523
3573
  function readPlanFileSummary(filePath) {
2524
- const text = readFileSync6(filePath, "utf8");
3574
+ const text = readFileSync9(filePath, "utf8");
2525
3575
  const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
2526
3576
  const blocks = parseStatusBlocks(text);
2527
3577
  return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
@@ -2560,8 +3610,8 @@ function renderIndex(params) {
2560
3610
  function scaffoldAuditPlan(outDir, findings, options = {}) {
2561
3611
  const date = options.date ?? new Date().toISOString().slice(0, 10);
2562
3612
  const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
2563
- mkdirSync5(outDir, { recursive: true });
2564
- const existing = readdirSync4(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
3613
+ mkdirSync7(outDir, { recursive: true });
3614
+ const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
2565
3615
  let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
2566
3616
  const written = [];
2567
3617
  const usedSlugs = new Set;
@@ -2576,13 +3626,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
2576
3626
  }
2577
3627
  usedSlugs.add(slug);
2578
3628
  const file = `${num}-${slug}.md`;
2579
- writeFileSync4(join7(outDir, file), renderPlanFile(finding, plannedAt));
3629
+ writeFileSync5(join11(outDir, file), renderPlanFile(finding, plannedAt));
2580
3630
  written.push(file);
2581
3631
  next++;
2582
3632
  }
2583
3633
  const all = [...existing, ...written].sort();
2584
3634
  const rows = all.map((file) => {
2585
- const summary = readPlanFileSummary(join7(outDir, file));
3635
+ const summary = readPlanFileSummary(join11(outDir, file));
2586
3636
  const fields = summary.fields;
2587
3637
  return {
2588
3638
  num: file.slice(0, 3),
@@ -2614,19 +3664,19 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
2614
3664
  row.dependsOn = finding.dependsOn ?? "none";
2615
3665
  }
2616
3666
  });
2617
- writeFileSync4(join7(outDir, "README.md"), renderIndex({
3667
+ writeFileSync5(join11(outDir, "README.md"), renderIndex({
2618
3668
  date,
2619
3669
  repoName: options.repoName ?? "repo",
2620
3670
  repoShortSha: options.repoShortSha ?? "unknown",
2621
3671
  rows,
2622
3672
  rejected: options.rejected ?? []
2623
3673
  }));
2624
- return { outDir: resolve7(outDir), date, files: written, nextNumber: next };
3674
+ return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
2625
3675
  }
2626
3676
  // src/compound.ts
2627
- import { existsSync as existsSync5, readdirSync as readdirSync5, readFileSync as readFileSync7 } from "node:fs";
2628
- import { basename as basename4, isAbsolute as isAbsolute5, join as join8, relative as relative2, resolve as resolve8, sep } from "node:path";
2629
- function violation8(severity, code, message, fix) {
3677
+ import { existsSync as existsSync6, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
3678
+ import { basename as basename4, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
3679
+ function violation10(severity, code, message, fix) {
2630
3680
  return { ok: false, severity, code, message, fix };
2631
3681
  }
2632
3682
  var KNOWLEDGE_REQUIRED_FIELDS = ["module", "date", "problem_type", "category", "severity"];
@@ -2710,7 +3760,7 @@ var KNOWLEDGE_CATEGORY_MAP = {
2710
3760
  developer_experience: "developer-experience",
2711
3761
  documentation_gap: "documentation"
2712
3762
  };
2713
- var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
3763
+ var DATE_RE5 = /^\d{4}-\d{2}-\d{2}$/;
2714
3764
  var isMap2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2715
3765
  function parseScalar2(raw) {
2716
3766
  const trimmed = raw.trim();
@@ -2803,36 +3853,36 @@ function validateSchemaYaml(frontmatterText) {
2803
3853
  const violations = [];
2804
3854
  const doc = parseYamlLite(frontmatterText);
2805
3855
  if (doc === null) {
2806
- violations.push(violation8("medium", "compound.schema.missing-frontmatter", "no `---` YAML frontmatter block found — knowledge docs must open with the schema.yaml contract (mstar-compound/references/schema.yaml)", "add the fenced frontmatter with module, date, problem_type, category, severity"));
3856
+ violations.push(violation10("medium", "compound.schema.missing-frontmatter", "no `---` YAML frontmatter block found — knowledge docs must open with the schema.yaml contract (mstar-compound/references/schema.yaml)", "add the fenced frontmatter with module, date, problem_type, category, severity"));
2807
3857
  return { ok: false, violations };
2808
3858
  }
2809
3859
  const isStr = (v) => typeof v === "string";
2810
- const missing = (field) => violations.push(violation8("medium", "compound.schema.missing-field", `missing required frontmatter field "${field}" (schema.yaml required_fields)`, `add \`${field}: <value>\` to the frontmatter`));
3860
+ const missing = (field) => violations.push(violation10("medium", "compound.schema.missing-field", `missing required frontmatter field "${field}" (schema.yaml required_fields)`, `add \`${field}: <value>\` to the frontmatter`));
2811
3861
  for (const field of KNOWLEDGE_REQUIRED_FIELDS) {
2812
3862
  if (!(field in doc) || doc[field] === "")
2813
3863
  missing(field);
2814
3864
  }
2815
- if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE3.test(doc.date))) {
2816
- violations.push(violation8("medium", "compound.schema.invalid-date", `date "${String(doc.date)}" must be a YYYY-MM-DD string (schema.yaml required_fields.date)`, "use `YYYY-MM-DD`"));
3865
+ if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE5.test(doc.date))) {
3866
+ violations.push(violation10("medium", "compound.schema.invalid-date", `date "${String(doc.date)}" must be a YYYY-MM-DD string (schema.yaml required_fields.date)`, "use `YYYY-MM-DD`"));
2817
3867
  }
2818
3868
  const problemType = doc.problem_type;
2819
3869
  if (problemType !== undefined && !isStr(problemType)) {
2820
- violations.push(violation8("medium", "compound.schema.invalid-problem-type", `problem_type "${String(problemType)}" must be a string — one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
3870
+ violations.push(violation10("medium", "compound.schema.invalid-problem-type", `problem_type "${String(problemType)}" must be a string — one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
2821
3871
  }
2822
3872
  const problemTypeValid = isStr(problemType) && KNOWLEDGE_PROBLEM_TYPES.includes(problemType);
2823
3873
  if (isStr(problemType) && !problemTypeValid) {
2824
- violations.push(violation8("medium", "compound.schema.invalid-problem-type", `problem_type "${problemType}" is not one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
3874
+ violations.push(violation10("medium", "compound.schema.invalid-problem-type", `problem_type "${problemType}" is not one of the schema.yaml enum values (bug: build_error…config_error; knowledge: developer_experience…testing_pattern)`, "pick the narrowest applicable problem_type from schema.yaml"));
2825
3875
  }
2826
3876
  if (doc.severity !== undefined && !isStr(doc.severity)) {
2827
- violations.push(violation8("medium", "compound.schema.invalid-severity", `severity "${String(doc.severity)}" must be a string — critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
3877
+ violations.push(violation10("medium", "compound.schema.invalid-severity", `severity "${String(doc.severity)}" must be a string — critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
2828
3878
  }
2829
3879
  if (isStr(doc.severity) && !KNOWLEDGE_SEVERITIES.includes(doc.severity)) {
2830
- violations.push(violation8("medium", "compound.schema.invalid-severity", `severity "${doc.severity}" must be critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
3880
+ violations.push(violation10("medium", "compound.schema.invalid-severity", `severity "${doc.severity}" must be critical | high | medium | low (schema.yaml required_fields.severity)`, "use one of the four severity values"));
2831
3881
  }
2832
3882
  if (problemTypeValid && isStr(doc.category)) {
2833
3883
  const expected = KNOWLEDGE_CATEGORY_MAP[problemType];
2834
3884
  if (doc.category !== expected) {
2835
- violations.push(violation8("medium", "compound.schema.category-mismatch", `category "${doc.category}" does not match problem_type "${problemType}" — category-mapping.md rule 1 maps it to "${expected}"`, `set \`category: ${expected}\` (the directory name under {KNOWLEDGE_DIR})`));
3885
+ violations.push(violation10("medium", "compound.schema.category-mismatch", `category "${doc.category}" does not match problem_type "${problemType}" — category-mapping.md rule 1 maps it to "${expected}"`, `set \`category: ${expected}\` (the directory name under {KNOWLEDGE_DIR})`));
2836
3886
  }
2837
3887
  }
2838
3888
  if (problemTypeValid) {
@@ -2840,37 +3890,37 @@ function validateSchemaYaml(frontmatterText) {
2840
3890
  if (isBug) {
2841
3891
  for (const field of ["symptoms", "root_cause", "resolution_type"]) {
2842
3892
  if (!(field in doc)) {
2843
- violations.push(violation8("medium", "compound.schema.missing-track-field", `bug-track doc missing required field "${field}" (schema.yaml track_rules.bug)`, `add \`${field}:\` to the frontmatter`));
3893
+ violations.push(violation10("medium", "compound.schema.missing-track-field", `bug-track doc missing required field "${field}" (schema.yaml track_rules.bug)`, `add \`${field}:\` to the frontmatter`));
2844
3894
  }
2845
3895
  }
2846
3896
  if (doc.symptoms !== undefined && !Array.isArray(doc.symptoms)) {
2847
- violations.push(violation8("medium", "compound.schema.invalid-symptoms", "bug-track `symptoms` must be a YAML list (schema.yaml track_rules.bug)", "list the observable symptoms under `symptoms:`"));
3897
+ violations.push(violation10("medium", "compound.schema.invalid-symptoms", "bug-track `symptoms` must be a YAML list (schema.yaml track_rules.bug)", "list the observable symptoms under `symptoms:`"));
2848
3898
  }
2849
3899
  if (doc.root_cause !== undefined && !isStr(doc.root_cause)) {
2850
- violations.push(violation8("medium", "compound.schema.invalid-root-cause", "bug-track `root_cause` must be a string (schema.yaml track_rules.bug)", "write the fundamental technical cause as a string"));
3900
+ violations.push(violation10("medium", "compound.schema.invalid-root-cause", "bug-track `root_cause` must be a string (schema.yaml track_rules.bug)", "write the fundamental technical cause as a string"));
2851
3901
  }
2852
3902
  if (isStr(doc.resolution_type) && !KNOWLEDGE_RESOLUTION_TYPES.includes(doc.resolution_type)) {
2853
- violations.push(violation8("medium", "compound.schema.invalid-resolution-type", `resolution_type "${doc.resolution_type}" is not one of the schema.yaml track_rules.bug enum values`, "use code_fix | migration | config_change | test_fix | dependency_update | environment_setup | workflow_improvement | documentation_update | tooling_addition"));
3903
+ violations.push(violation10("medium", "compound.schema.invalid-resolution-type", `resolution_type "${doc.resolution_type}" is not one of the schema.yaml track_rules.bug enum values`, "use code_fix | migration | config_change | test_fix | dependency_update | environment_setup | workflow_improvement | documentation_update | tooling_addition"));
2854
3904
  }
2855
3905
  } else if (doc.applies_when !== undefined && !Array.isArray(doc.applies_when)) {
2856
- violations.push(violation8("low", "compound.schema.invalid-applies-when", "knowledge-track `applies_when` must be a YAML list when present (schema.yaml track_rules.knowledge)", "list the conditions under `applies_when:`"));
3906
+ violations.push(violation10("low", "compound.schema.invalid-applies-when", "knowledge-track `applies_when` must be a YAML list when present (schema.yaml track_rules.knowledge)", "list the conditions under `applies_when:`"));
2857
3907
  }
2858
3908
  }
2859
3909
  if (doc.plan_id !== undefined && !isStr(doc.plan_id)) {
2860
- violations.push(violation8("low", "compound.schema.invalid-plan-id", "optional `plan_id` must be a string (schema.yaml optional_fields.plan_id)", "reference the status.json plan id as a string"));
3910
+ violations.push(violation10("low", "compound.schema.invalid-plan-id", "optional `plan_id` must be a string (schema.yaml optional_fields.plan_id)", "reference the status.json plan id as a string"));
2861
3911
  }
2862
3912
  if (doc.tags !== undefined) {
2863
3913
  if (!Array.isArray(doc.tags)) {
2864
- violations.push(violation8("low", "compound.schema.invalid-tags", "optional `tags` must be a YAML list (schema.yaml optional_fields.tags)", "list lowercase, hyphen-separated keywords"));
3914
+ violations.push(violation10("low", "compound.schema.invalid-tags", "optional `tags` must be a YAML list (schema.yaml optional_fields.tags)", "list lowercase, hyphen-separated keywords"));
2865
3915
  } else if (doc.tags.length > 8) {
2866
- violations.push(violation8("low", "compound.schema.tags-too-many", `tags has ${doc.tags.length} entries — max 8 (schema.yaml optional_fields.tags.max_items)`, "trim the tag list to at most 8 keywords"));
3916
+ violations.push(violation10("low", "compound.schema.tags-too-many", `tags has ${doc.tags.length} entries — max 8 (schema.yaml optional_fields.tags.max_items)`, "trim the tag list to at most 8 keywords"));
2867
3917
  }
2868
3918
  }
2869
- if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE3.test(doc.last_updated))) {
2870
- violations.push(violation8("low", "compound.schema.invalid-last-updated", `last_updated "${String(doc.last_updated)}" must be YYYY-MM-DD (schema.yaml optional_fields.last_updated)`, "use `YYYY-MM-DD`"));
3919
+ if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE5.test(doc.last_updated))) {
3920
+ violations.push(violation10("low", "compound.schema.invalid-last-updated", `last_updated "${String(doc.last_updated)}" must be YYYY-MM-DD (schema.yaml optional_fields.last_updated)`, "use `YYYY-MM-DD`"));
2871
3921
  }
2872
3922
  if (doc.related_components !== undefined && !Array.isArray(doc.related_components)) {
2873
- violations.push(violation8("low", "compound.schema.invalid-related-components", "optional `related_components` must be a YAML list (schema.yaml optional_fields.related_components)", "list the other components involved"));
3923
+ violations.push(violation10("low", "compound.schema.invalid-related-components", "optional `related_components` must be a YAML list (schema.yaml optional_fields.related_components)", "list the other components involved"));
2874
3924
  }
2875
3925
  return { ok: violations.length === 0, violations };
2876
3926
  }
@@ -2892,7 +3942,7 @@ function referenceExists(repoRoot, docText) {
2892
3942
  if (ref === "" || seen.has(ref))
2893
3943
  continue;
2894
3944
  seen.add(ref);
2895
- if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute5(ref)) {
3945
+ if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute7(ref)) {
2896
3946
  continue;
2897
3947
  }
2898
3948
  if (ref.includes("/") || REF_EXT_RE.test(ref)) {
@@ -2911,7 +3961,7 @@ function referenceExists(repoRoot, docText) {
2911
3961
  const dir = stack.pop();
2912
3962
  let entries;
2913
3963
  try {
2914
- entries = readdirSync5(dir, { withFileTypes: true });
3964
+ entries = readdirSync8(dir, { withFileTypes: true });
2915
3965
  } catch {
2916
3966
  continue;
2917
3967
  }
@@ -2920,7 +3970,7 @@ function referenceExists(repoRoot, docText) {
2920
3970
  break;
2921
3971
  if (entry.isDirectory()) {
2922
3972
  if (!WALK_SKIP_DIRS.has(entry.name))
2923
- stack.push(join8(dir, entry.name));
3973
+ stack.push(join12(dir, entry.name));
2924
3974
  } else if (!entry.isSymbolicLink()) {
2925
3975
  const base = entry.name.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, "");
2926
3976
  if (moduleNames.has(base))
@@ -2932,15 +3982,15 @@ function referenceExists(repoRoot, docText) {
2932
3982
  for (const { ref, isSymbol, module } of refs) {
2933
3983
  if (!isSymbol || module === undefined) {
2934
3984
  const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
2935
- if (existsSync5(resolve8(repoRoot, candidate))) {
3985
+ if (existsSync6(resolve10(repoRoot, candidate))) {
2936
3986
  checked++;
2937
3987
  } else {
2938
- violations.push(violation8("medium", "compound.reference.missing-file", `referenced path \`${ref}\` does not exist under ${repoRoot} (compound-refresh Phase 2: referenced code still exists?)`, "update the doc to reference an existing path, or delete the stale reference"));
3988
+ violations.push(violation10("medium", "compound.reference.missing-file", `referenced path \`${ref}\` does not exist under ${repoRoot} (compound-refresh Phase 2: referenced code still exists?)`, "update the doc to reference an existing path, or delete the stale reference"));
2939
3989
  }
2940
3990
  } else if (foundModules.has(module)) {
2941
3991
  checked++;
2942
3992
  } else {
2943
- violations.push(violation8("low", "compound.reference.module-missing", `symbol ref \`${ref}\` — heuristic: no ${module}.ts|tsx|js|jsx|mjs|cjs module file found under ${repoRoot} (compound-refresh Phase 2)`, "verify the module file exists, or update the reference"));
3993
+ violations.push(violation10("low", "compound.reference.module-missing", `symbol ref \`${ref}\` — heuristic: no ${module}.ts|tsx|js|jsx|mjs|cjs module file found under ${repoRoot} (compound-refresh Phase 2)`, "verify the module file exists, or update the reference"));
2944
3994
  }
2945
3995
  }
2946
3996
  return { ok: violations.length === 0, violations, checked };
@@ -2952,18 +4002,18 @@ function collectKnowledgeDocs(dir) {
2952
4002
  const current = stack.pop();
2953
4003
  let entries;
2954
4004
  try {
2955
- entries = readdirSync5(current, { withFileTypes: true });
4005
+ entries = readdirSync8(current, { withFileTypes: true });
2956
4006
  } catch {
2957
4007
  continue;
2958
4008
  }
2959
4009
  for (const entry of entries) {
2960
4010
  if (entry.isSymbolicLink())
2961
4011
  continue;
2962
- const full = join8(current, entry.name);
4012
+ const full = join12(current, entry.name);
2963
4013
  if (entry.isDirectory()) {
2964
4014
  stack.push(full);
2965
4015
  } else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
2966
- docs.push(relative2(dir, full).split(sep).join("/"));
4016
+ docs.push(relative4(dir, full).split(sep3).join("/"));
2967
4017
  }
2968
4018
  }
2969
4019
  }
@@ -2979,14 +4029,14 @@ function normalizeIndexRef(cell) {
2979
4029
  }
2980
4030
  function assertIndexRows(knowledgeDir) {
2981
4031
  const violations = [];
2982
- const readmePath = join8(knowledgeDir, "README.md");
2983
- if (!existsSync5(readmePath)) {
2984
- violations.push(violation8("medium", "compound.index.missing-readme", `missing ${readmePath} — the knowledge index is required (mstar-compound Phase 6: every doc gets a README.md row)`, "create knowledge/README.md with a Document / Source Plan / Description / Status table"));
4032
+ const readmePath = join12(knowledgeDir, "README.md");
4033
+ if (!existsSync6(readmePath)) {
4034
+ violations.push(violation10("medium", "compound.index.missing-readme", `missing ${readmePath} — the knowledge index is required (mstar-compound Phase 6: every doc gets a README.md row)`, "create knowledge/README.md with a Document / Source Plan / Description / Status table"));
2985
4035
  return { ok: false, violations };
2986
4036
  }
2987
4037
  const docs = collectKnowledgeDocs(knowledgeDir);
2988
4038
  const rows = new Set;
2989
- for (const line of readFileSync7(readmePath, "utf8").split(/\r?\n/)) {
4039
+ for (const line of readFileSync10(readmePath, "utf8").split(/\r?\n/)) {
2990
4040
  if (!line.trim().startsWith("|"))
2991
4041
  continue;
2992
4042
  const cells = line.split("|").map((c) => c.trim());
@@ -2998,42 +4048,42 @@ function assertIndexRows(knowledgeDir) {
2998
4048
  }
2999
4049
  for (const doc of docs) {
3000
4050
  if (!rows.has(doc)) {
3001
- violations.push(violation8("medium", "compound.index.missing-row", `knowledge doc "${doc}" has no row in knowledge/README.md index (mstar-compound Phase 6 index obligations)`, `add a row \`| [<title>](${doc}) | <source plan> | <description> | <status> |\` to knowledge/README.md`));
4051
+ violations.push(violation10("medium", "compound.index.missing-row", `knowledge doc "${doc}" has no row in knowledge/README.md index (mstar-compound Phase 6 index obligations)`, `add a row \`| [<title>](${doc}) | <source plan> | <description> | <status> |\` to knowledge/README.md`));
3002
4052
  }
3003
4053
  }
3004
4054
  return { ok: violations.length === 0, violations };
3005
4055
  }
3006
4056
  function compoundRefreshScope(harnessDir, projectRoot) {
3007
4057
  return [
3008
- join8(harnessDir, "knowledge"),
3009
- join8(harnessDir, "knowledge", "README.md"),
3010
- join8(projectRoot, "CONCEPTS.md"),
3011
- join8(harnessDir, "status.json")
4058
+ join12(harnessDir, "knowledge"),
4059
+ join12(harnessDir, "knowledge", "README.md"),
4060
+ join12(projectRoot, "CONCEPTS.md"),
4061
+ join12(harnessDir, "status.json")
3012
4062
  ];
3013
4063
  }
3014
4064
  function isFileLikeRoot(root) {
3015
4065
  return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
3016
4066
  }
3017
4067
  function scopeGuard(path, allowedRoots) {
3018
- const resolved = resolve8(path);
4068
+ const resolved = resolve10(path);
3019
4069
  for (const root of allowedRoots) {
3020
- const r = resolve8(root);
4070
+ const r = resolve10(root);
3021
4071
  if (isFileLikeRoot(r)) {
3022
4072
  if (resolved === r)
3023
4073
  return { ok: true, violations: [] };
3024
- } else if (resolved === r || resolved.startsWith(r + sep)) {
4074
+ } else if (resolved === r || resolved.startsWith(r + sep3)) {
3025
4075
  return { ok: true, violations: [] };
3026
4076
  }
3027
4077
  }
3028
4078
  return {
3029
4079
  ok: false,
3030
4080
  violations: [
3031
- violation8("medium", "compound.scope.outside", `path "${path}" is outside the compound-refresh scope (allowed: ${allowedRoots.join(", ")}) — compound-refresh operates only on {HARNESS_DIR}/knowledge/**, {HARNESS_DIR}/knowledge/README.md, <repo-root>/CONCEPTS.md, {HARNESS_DIR}/status.json (mstar-compound-refresh SKILL.md § 产物与操作路径)`, "point the operation at one of the allowed paths")
4081
+ violation10("medium", "compound.scope.outside", `path "${path}" is outside the compound-refresh scope (allowed: ${allowedRoots.join(", ")}) — compound-refresh operates only on {HARNESS_DIR}/knowledge/**, {HARNESS_DIR}/knowledge/README.md, <repo-root>/CONCEPTS.md, {HARNESS_DIR}/status.json (mstar-compound-refresh SKILL.md § 产物与操作路径)`, "point the operation at one of the allowed paths")
3032
4082
  ]
3033
4083
  };
3034
4084
  }
3035
4085
  // src/lint.ts
3036
- function violation9(severity, code, message, fix) {
4086
+ function violation11(severity, code, message, fix) {
3037
4087
  return { ok: false, severity, code, message, fix };
3038
4088
  }
3039
4089
  var COMMENT_INTRODUCER = "(?:\\/\\/|\\/\\*|#|;|--|\\s\\*)";
@@ -3076,7 +4126,7 @@ function findTemporaryMarkers(fileText) {
3076
4126
  }
3077
4127
  markers.push({ line: i + 1, text, removalPath });
3078
4128
  if (removalPath === null) {
3079
- violations.push(violation9("medium", "lint.temporary.no-removal-path", `temporary marker at line ${i + 1} records no removal path (plan/status artifact reference) — record one before claiming the task complete (mstar-coding-behavior § Simplification markers)`, 'add a plan/status reference to the marker, e.g. "removal tracked in status.json" or "plan 20260808-slice2 removes this"'));
4129
+ violations.push(violation11("medium", "lint.temporary.no-removal-path", `temporary marker at line ${i + 1} records no removal path (plan/status artifact reference) — record one before claiming the task complete (mstar-coding-behavior § Simplification markers)`, 'add a plan/status reference to the marker, e.g. "removal tracked in status.json" or "plan 20260808-slice2 removes this"'));
3080
4130
  }
3081
4131
  }
3082
4132
  return { ok: violations.length === 0, violations, markers };
@@ -3123,13 +4173,13 @@ function assertSddTddTriple(reportText) {
3123
4173
  break;
3124
4174
  }
3125
4175
  if (!hasTests) {
3126
- violations.push(violation9("medium", "lint.sdd-tdd.missing-tests", "task report carries no test file reference — the TDD triple needs covering test file(s) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Covering test file(s): <path>.test.ts" line or a `.test.<ext>` path to the report'));
4176
+ violations.push(violation11("medium", "lint.sdd-tdd.missing-tests", "task report carries no test file reference — the TDD triple needs covering test file(s) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Covering test file(s): <path>.test.ts" line or a `.test.<ext>` path to the report'));
3127
4177
  }
3128
4178
  if (!hasCommand) {
3129
- violations.push(violation9("medium", "lint.sdd-tdd.missing-command", "task report carries no command — the TDD triple needs the exact command run (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Command run: `bun test <file>`" line to the report'));
4179
+ violations.push(violation11("medium", "lint.sdd-tdd.missing-command", "task report carries no command — the TDD triple needs the exact command run (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'add a "Command run: `bun test <file>`" line to the report'));
3130
4180
  }
3131
4181
  if (!hasOutput) {
3132
- violations.push(violation9("medium", "lint.sdd-tdd.missing-output", "task report carries no output evidence — the TDD triple needs the run output (pass/fail counts or exit code) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'paste the test-run output (e.g. "12 pass / 0 fail") into the report'));
4182
+ violations.push(violation11("medium", "lint.sdd-tdd.missing-output", "task report carries no output evidence — the TDD triple needs the run output (pass/fail counts or exit code) (mstar-coding-behavior § Integration Notes; mstar-sdd/references/file-handoffs.md)", 'paste the test-run output (e.g. "12 pass / 0 fail") into the report'));
3133
4183
  }
3134
4184
  return { ok: violations.length === 0, violations };
3135
4185
  }
@@ -3170,7 +4220,7 @@ function planQualityBar(planText) {
3170
4220
  if (token !== null) {
3171
4221
  const text = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
3172
4222
  findings.push({ token, line: i + 1, text });
3173
- violations.push(violation9("medium", "lint.plan-quality.placeholder", `placeholder token "${token}" at line ${i + 1}: "${text}"`, "replace the placeholder with concrete content before locking the plan (mstar-plan-artifacts/references/plan-quality-bar.md; templates/plan.main.md placeholder scan)"));
4223
+ violations.push(violation11("medium", "lint.plan-quality.placeholder", `placeholder token "${token}" at line ${i + 1}: "${text}"`, "replace the placeholder with concrete content before locking the plan (mstar-plan-artifacts/references/plan-quality-bar.md; templates/plan.main.md placeholder scan)"));
3174
4224
  }
3175
4225
  }
3176
4226
  return { ok: violations.length === 0, violations, findings };
@@ -3182,18 +4232,18 @@ function lintSkillFrontmatter(frontmatterText) {
3182
4232
  const violations = [];
3183
4233
  const fm = parseFrontmatter(frontmatterText);
3184
4234
  if (fm === null) {
3185
- violations.push(violation9("medium", "lint.frontmatter.missing", "no YAML frontmatter block found — a skill file must open with a `---` fenced frontmatter (mstar-skill-authoring § Frontmatter Contract)", "add a frontmatter block with `name` and `description` at the top of the file"));
4235
+ violations.push(violation11("medium", "lint.frontmatter.missing", "no YAML frontmatter block found — a skill file must open with a `---` fenced frontmatter (mstar-skill-authoring § Frontmatter Contract)", "add a frontmatter block with `name` and `description` at the top of the file"));
3186
4236
  return { ok: false, violations };
3187
4237
  }
3188
4238
  const name = fm.name ?? "";
3189
4239
  if (name === "") {
3190
- violations.push(violation9("medium", "lint.frontmatter.name.missing", "frontmatter `name` is missing — required (mstar-skill-authoring § Frontmatter Contract)", "add `name: <lowercase-hyphen-id>` to the frontmatter"));
4240
+ violations.push(violation11("medium", "lint.frontmatter.name.missing", "frontmatter `name` is missing — required (mstar-skill-authoring § Frontmatter Contract)", "add `name: <lowercase-hyphen-id>` to the frontmatter"));
3191
4241
  } else if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
3192
- violations.push(violation9("medium", "lint.frontmatter.name.format", `frontmatter \`name\` must be lowercase-hyphen ("${name}") — e.g. example-skill (mstar-skill-authoring § Frontmatter Contract)`, "rename to a stable lowercase-hyphen id, e.g. `name: example-skill`"));
4242
+ violations.push(violation11("medium", "lint.frontmatter.name.format", `frontmatter \`name\` must be lowercase-hyphen ("${name}") — e.g. example-skill (mstar-skill-authoring § Frontmatter Contract)`, "rename to a stable lowercase-hyphen id, e.g. `name: example-skill`"));
3193
4243
  }
3194
4244
  const description = fm.description ?? "";
3195
4245
  if (description === "") {
3196
- violations.push(violation9("medium", "lint.frontmatter.description.missing", "frontmatter `description` is missing — the trigger contract is required (mstar-skill-authoring § Frontmatter Contract)", "add a `description:` that states when the skill loads (symptoms, context, roles, exclusions)"));
4246
+ violations.push(violation11("medium", "lint.frontmatter.description.missing", "frontmatter `description` is missing — the trigger contract is required (mstar-skill-authoring § Frontmatter Contract)", "add a `description:` that states when the skill loads (symptoms, context, roles, exclusions)"));
3197
4247
  } else {
3198
4248
  const stripped = description.replace(/`[^`]*`/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3199
4249
  let pronoun = null;
@@ -3204,15 +4254,15 @@ function lintSkillFrontmatter(frontmatterText) {
3204
4254
  break;
3205
4255
  }
3206
4256
  if (pronoun !== null) {
3207
- violations.push(violation9("low", "lint.frontmatter.description.person", `description uses first/second-person pronoun "${pronoun[0]}" — keep the trigger contract third person (mstar-skill-authoring § Frontmatter Contract)`, 'rewrite without I/we/you/my/our/your/us, e.g. "Use when the user asks …"'));
4257
+ violations.push(violation11("low", "lint.frontmatter.description.person", `description uses first/second-person pronoun "${pronoun[0]}" — keep the trigger contract third person (mstar-skill-authoring § Frontmatter Contract)`, 'rewrite without I/we/you/my/our/your/us, e.g. "Use when the user asks …"'));
3208
4258
  }
3209
4259
  const start = description.trim().replace(/^[*_#>]+/, "").replace(/^["'`]+/, "").trim();
3210
4260
  if (WORKFLOW_VERB_START_RE.test(start)) {
3211
- violations.push(violation9("low", "lint.frontmatter.description.workflow", 'description reads as a workflow summary ("Explains/Describes/Covers …") — the description is the trigger contract, not a summary of steps (mstar-skill-authoring § Frontmatter Contract)', "describe when to load the skill (symptoms, context, roles, exclusions) instead of summarizing its steps"));
4261
+ violations.push(violation11("low", "lint.frontmatter.description.workflow", 'description reads as a workflow summary ("Explains/Describes/Covers …") — the description is the trigger contract, not a summary of steps (mstar-skill-authoring § Frontmatter Contract)', "describe when to load the skill (symptoms, context, roles, exclusions) instead of summarizing its steps"));
3212
4262
  } else {
3213
4263
  const words = description.trim().split(/\s+/).filter(Boolean).length;
3214
4264
  if (words > DESCRIPTION_MAX_WORDS) {
3215
- violations.push(violation9("low", "lint.frontmatter.description.workflow", `description is ${words} words — paragraph-length summaries bury the trigger contract (threshold ${DESCRIPTION_MAX_WORDS}, above the longest corpus description at 114 words, mstar-design-md; mstar-skill-authoring § Frontmatter Contract)`, "trim the description to a scannable trigger contract and move detail into the body"));
4265
+ violations.push(violation11("low", "lint.frontmatter.description.workflow", `description is ${words} words — paragraph-length summaries bury the trigger contract (threshold ${DESCRIPTION_MAX_WORDS}, above the longest corpus description at 114 words, mstar-design-md; mstar-skill-authoring § Frontmatter Contract)`, "trim the description to a scannable trigger contract and move detail into the body"));
3216
4266
  }
3217
4267
  }
3218
4268
  }
@@ -3259,15 +4309,15 @@ function lintStrategySections(docText) {
3259
4309
  }
3260
4310
  for (const required of REQUIRED_STRATEGY_SECTIONS) {
3261
4311
  if (!headings.has(required.toLowerCase())) {
3262
- violations.push(violation9("medium", "lint.strategy.missing-section", `missing required section "${required}" (mstar-strategy § STRATEGY.md structure)`, "add a `## <Section>` heading; required: Vision, What we build, What we don't build, Guiding Principles, Technology Direction, Decision Log"));
4312
+ violations.push(violation11("medium", "lint.strategy.missing-section", `missing required section "${required}" (mstar-strategy § STRATEGY.md structure)`, "add a `## <Section>` heading; required: Vision, What we build, What we don't build, Guiding Principles, Technology Direction, Decision Log"));
3263
4313
  }
3264
4314
  }
3265
4315
  return { ok: violations.length === 0, violations };
3266
4316
  }
3267
4317
  // src/roles.ts
3268
- import { existsSync as existsSync6 } from "node:fs";
3269
- import { join as join9 } from "node:path";
3270
- function violation10(severity, code, message, fix) {
4318
+ import { existsSync as existsSync7 } from "node:fs";
4319
+ import { join as join13 } from "node:path";
4320
+ function violation12(severity, code, message, fix) {
3271
4321
  return { ok: false, severity, code, message, fix };
3272
4322
  }
3273
4323
  var ROLE_MAPPING = [
@@ -3322,19 +4372,19 @@ function validateRoleMapping(rolesDir, options = {}) {
3322
4372
  const violations = [];
3323
4373
  const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
3324
4374
  for (const { agentId, reference } of mapping) {
3325
- if (!existsSync6(join9(rolesDir, reference))) {
3326
- violations.push(violation10("medium", "roles.mapping.reference.missing", `role "${agentId}" maps to ${reference} which does not exist under ${rolesDir} (mstar-roles § Role Reference Mapping)`, `create ${join9(rolesDir, reference)} or fix the mapping row`));
4375
+ if (!existsSync7(join13(rolesDir, reference))) {
4376
+ violations.push(violation12("medium", "roles.mapping.reference.missing", `role "${agentId}" maps to ${reference} which does not exist under ${rolesDir} (mstar-roles § Role Reference Mapping)`, `create ${join13(rolesDir, reference)} or fix the mapping row`));
3327
4377
  }
3328
4378
  }
3329
4379
  for (const { family, memberIds } of families) {
3330
4380
  const absent = memberIds.filter((id) => !referenceById.has(id));
3331
4381
  for (const id of absent) {
3332
- violations.push(violation10("medium", "roles.mapping.family.member.missing", `shared family "${family}" member "${id}" is absent from the role mapping (mstar-roles § Role Reference Mapping)`, `add "${id}" to the mapping`));
4382
+ violations.push(violation12("medium", "roles.mapping.family.member.missing", `shared family "${family}" member "${id}" is absent from the role mapping (mstar-roles § Role Reference Mapping)`, `add "${id}" to the mapping`));
3333
4383
  }
3334
4384
  if (absent.length === 0) {
3335
4385
  const refs = new Set(memberIds.map((id) => referenceById.get(id)));
3336
4386
  if (refs.size !== 1) {
3337
- violations.push(violation10("medium", "roles.mapping.family.shared", `shared family "${family}" (${memberIds.join(", ")}) must resolve to ONE shared reference file — got ${[...refs].join(", ")} (mstar-roles § Maintenance Rules: "Keep shared-family roles on one shared reference file")`, `point every "${family}" member at the same references/<role>-shared.md`));
4387
+ violations.push(violation12("medium", "roles.mapping.family.shared", `shared family "${family}" (${memberIds.join(", ")}) must resolve to ONE shared reference file — got ${[...refs].join(", ")} (mstar-roles § Maintenance Rules: "Keep shared-family roles on one shared reference file")`, `point every "${family}" member at the same references/<role>-shared.md`));
3338
4388
  }
3339
4389
  }
3340
4390
  }
@@ -3343,12 +4393,12 @@ function validateRoleMapping(rolesDir, options = {}) {
3343
4393
  for (const row of rows) {
3344
4394
  const existing = tableByRole.get(row.roleId);
3345
4395
  if (existing !== undefined) {
3346
- violations.push(violation10("medium", "roles.param.role.duplicate", `role "${row.roleId}" appears in both the ${existing} and ${table} parameter rows (mstar-roles § Parameter Table (SSOT))`, "remove the duplicate row"));
4396
+ violations.push(violation12("medium", "roles.param.role.duplicate", `role "${row.roleId}" appears in both the ${existing} and ${table} parameter rows (mstar-roles § Parameter Table (SSOT))`, "remove the duplicate row"));
3347
4397
  } else {
3348
4398
  tableByRole.set(row.roleId, table);
3349
4399
  }
3350
4400
  if (!referenceById.has(row.roleId)) {
3351
- violations.push(violation10("medium", "roles.param.role.missing", `${table} parameter row references unknown role "${row.roleId}" (mstar-roles § Parameter Table (SSOT))`, `add "${row.roleId}" to the role mapping or drop the row`));
4401
+ violations.push(violation12("medium", "roles.param.role.missing", `${table} parameter row references unknown role "${row.roleId}" (mstar-roles § Parameter Table (SSOT))`, `add "${row.roleId}" to the role mapping or drop the row`));
3352
4402
  }
3353
4403
  }
3354
4404
  };
@@ -3356,20 +4406,20 @@ function validateRoleMapping(rolesDir, options = {}) {
3356
4406
  checkParamRoles(qcReviewers, "QC reviewer");
3357
4407
  for (const row of devTrack) {
3358
4408
  if (row.track !== "primary" && row.track !== "parallel_secondary") {
3359
- violations.push(violation10("medium", "roles.param.track", `dev track for "${row.roleId}" is "${String(row.track)}" — must be primary or parallel_secondary (mstar-roles § Parameter Table (SSOT))`, 'set track to "primary" or "parallel_secondary"'));
4409
+ violations.push(violation12("medium", "roles.param.track", `dev track for "${row.roleId}" is "${String(row.track)}" — must be primary or parallel_secondary (mstar-roles § Parameter Table (SSOT))`, 'set track to "primary" or "parallel_secondary"'));
3360
4410
  }
3361
4411
  }
3362
4412
  const indices = qcReviewers.map((r) => r.reviewerIndex).sort((a, b) => a - b);
3363
4413
  const unique = new Set(indices);
3364
4414
  if (indices.length !== 3 || unique.size !== 3 || indices[0] !== 1 || indices[1] !== 2 || indices[2] !== 3) {
3365
- violations.push(violation10("high", "roles.param.qc.index.set", `QC reviewer_index must be exactly {1, 2, 3} across the three qc-specialist* seats — got [${indices.join(", ")}] (mstar-roles § Parameter Table (SSOT))`, "assign reviewer_index 1/2/3 to qc-specialist / qc-specialist-2 / qc-specialist-3"));
4415
+ violations.push(violation12("high", "roles.param.qc.index.set", `QC reviewer_index must be exactly {1, 2, 3} across the three qc-specialist* seats — got [${indices.join(", ")}] (mstar-roles § Parameter Table (SSOT))`, "assign reviewer_index 1/2/3 to qc-specialist / qc-specialist-2 / qc-specialist-3"));
3366
4416
  }
3367
4417
  for (const row of qcReviewers) {
3368
4418
  if (row.focus.trim() === "") {
3369
- violations.push(violation10("medium", "roles.param.qc.focus.missing", `QC seat "${row.roleId}" (reviewer_index ${row.reviewerIndex}) has an empty focus (mstar-roles § Parameter Table (SSOT))`, "add the review focus"));
4419
+ violations.push(violation12("medium", "roles.param.qc.focus.missing", `QC seat "${row.roleId}" (reviewer_index ${row.reviewerIndex}) has an empty focus (mstar-roles § Parameter Table (SSOT))`, "add the review focus"));
3370
4420
  }
3371
4421
  if (row.reportSuffix !== `qc${row.reviewerIndex}`) {
3372
- violations.push(violation10("medium", "roles.param.qc.suffix", `QC seat "${row.roleId}" report_suffix "${row.reportSuffix}" must equal qc${row.reviewerIndex} — tri reports land at {SDD_DIR}/review/qc1.md…qc3.md (mstar-roles § Parameter Table (SSOT))`, `set report_suffix to qc${row.reviewerIndex}`));
4422
+ violations.push(violation12("medium", "roles.param.qc.suffix", `QC seat "${row.roleId}" report_suffix "${row.reportSuffix}" must equal qc${row.reviewerIndex} — tri reports land at {SDD_DIR}/review/qc1.md…qc3.md (mstar-roles § Parameter Table (SSOT))`, `set report_suffix to qc${row.reviewerIndex}`));
3373
4423
  }
3374
4424
  }
3375
4425
  return { ok: violations.length === 0, violations };
@@ -3408,11 +4458,11 @@ function lintLoadOrder(skillTexts) {
3408
4458
  continue;
3409
4459
  const section = extractLoadOrderSection(text);
3410
4460
  if (section === null) {
3411
- violations.push(violation10("medium", "roles.loadorder.section.missing", `skill "${name}" has no Load Order / First action section — every mstar-* topic skill must declare its first read (mstar-harness-core § 加载约定; mstar-roles § Load Order (Required))`, `add a "## Load Order" section naming mstar-harness-core as the first read`));
4461
+ violations.push(violation12("medium", "roles.loadorder.section.missing", `skill "${name}" has no Load Order / First action section — every mstar-* topic skill must declare its first read (mstar-harness-core § 加载约定; mstar-roles § Load Order (Required))`, `add a "## Load Order" section naming mstar-harness-core as the first read`));
3412
4462
  continue;
3413
4463
  }
3414
4464
  if (!section.includes("mstar-harness-core")) {
3415
- violations.push(violation10("medium", "roles.loadorder.core.missing", `skill "${name}" Load Order section does not declare mstar-harness-core as its first dependency (mstar-harness-core § 加载约定: 凡 mstar-*(name ≠ mstar-harness-core)假定读者已 Read 本 skill)`, "name mstar-harness-core first in the Load Order section"));
4465
+ violations.push(violation12("medium", "roles.loadorder.core.missing", `skill "${name}" Load Order section does not declare mstar-harness-core as its first dependency (mstar-harness-core § 加载约定: 凡 mstar-*(name ≠ mstar-harness-core)假定读者已 Read 本 skill)`, "name mstar-harness-core first in the Load Order section"));
3416
4466
  }
3417
4467
  }
3418
4468
  return { ok: violations.length === 0, violations };
@@ -3458,7 +4508,7 @@ function resolveSkillRoot(host, paths) {
3458
4508
  }
3459
4509
  }
3460
4510
  // src/skill-authoring.ts
3461
- function violation11(severity, code, message, fix) {
4511
+ function violation13(severity, code, message, fix) {
3462
4512
  return { ok: false, severity, code, message, fix };
3463
4513
  }
3464
4514
  var FIVE_QUESTION_SECTIONS = [
@@ -3519,7 +4569,7 @@ function lintFiveQuestion(bodyText, mode = "authoring") {
3519
4569
  const aliases = mode === "runtime" ? RUNTIME_HEADING_ALIASES[section.key] ?? [] : [];
3520
4570
  const covered = headings.some((heading) => heading.includes(label) || aliases.some((alias) => heading.includes(alias)));
3521
4571
  if (!covered) {
3522
- violations.push(violation11("low", `skill-authoring.five-question.${section.key}`, `body does not answer "${section.question}" — no "${section.label}" section (mstar-skill-authoring § Body 必须回答的 5 问 / § 默认 Body 结构)`, `add a "## ${section.label}" section covering ${section.question}`));
4572
+ violations.push(violation13("low", `skill-authoring.five-question.${section.key}`, `body does not answer "${section.question}" — no "${section.label}" section (mstar-skill-authoring § Body 必须回答的 5 问 / § 默认 Body 结构)`, `add a "## ${section.label}" section covering ${section.question}`));
3523
4573
  }
3524
4574
  }
3525
4575
  return { ok: violations.length === 0, violations };
@@ -3528,13 +4578,19 @@ function resolveAssetPath(skillName, relPath, host) {
3528
4578
  return `skill \`${skillName}\` → ${relPath} (${resolveSkillRoot(host, { skill: skillName, rel: relPath })})`;
3529
4579
  }
3530
4580
  export {
4581
+ writeWorkflowSnapshot,
3531
4582
  writeJson,
3532
4583
  withStatusWriteLock,
3533
4584
  verifyPlanExecutionLease,
4585
+ validateWorkflowSnapshot,
4586
+ validateWorkflowEntry,
4587
+ validateStatusV2,
3534
4588
  validateStatus,
3535
4589
  validateSchemaYaml,
3536
4590
  validateRoleMapping,
4591
+ validateRoadmap,
3537
4592
  validateResidual,
4593
+ validateProjectRegister,
3538
4594
  validatePlanRow,
3539
4595
  validateIntegrationMergeLease,
3540
4596
  validateGitignore,
@@ -3543,6 +4599,7 @@ export {
3543
4599
  validateCompassFrontmatter,
3544
4600
  validateAuditStatusBlocks,
3545
4601
  validateAssignmentFields,
4602
+ unregisterWorkflow,
3546
4603
  techDebtRollup,
3547
4604
  taskReportExists,
3548
4605
  taskBrief,
@@ -3554,16 +4611,22 @@ export {
3554
4611
  scaffoldAuditPlan,
3555
4612
  sameHolderResume,
3556
4613
  reviewPackage,
4614
+ resolveWorkflowDir,
3557
4615
  resolveSpecsDir,
3558
4616
  resolveSkillRoot,
3559
4617
  resolveSddDir,
4618
+ resolveRepoEnforcement,
3560
4619
  resolveProjectRoot,
4620
+ resolveProjectDir,
3561
4621
  resolvePlanDir,
4622
+ resolveMstarcEnforcement,
4623
+ resolveKnowledgeDir,
3562
4624
  resolveIterationDir,
3563
4625
  resolveHarnessDir,
3564
4626
  resolveCompassEnforcement,
3565
4627
  resolveAssetPath,
3566
4628
  releaseLease,
4629
+ registerWorkflow,
3567
4630
  referenceExists,
3568
4631
  redactSecrets,
3569
4632
  readProgressLedger,
@@ -3572,13 +4635,17 @@ export {
3572
4635
  pushCadenceProbe,
3573
4636
  planQualityBar,
3574
4637
  planExecutionLeaseLocations,
4638
+ parseMstarc,
3575
4639
  parseEnforcementFlag,
3576
4640
  parseDesignFrontmatter,
4641
+ parseCompassFrontmatterText,
3577
4642
  parseCompassFrontmatter,
3578
4643
  parseBranchPolicyDirectOnBranch,
3579
4644
  parseAssignmentFields,
3580
4645
  parseAssignmentBranchForms,
3581
4646
  normalizeSeverity,
4647
+ migrateHarnessTree,
4648
+ listProjectReferenceFiles,
3582
4649
  lintStrategySections,
3583
4650
  lintSkillFrontmatter,
3584
4651
  lintLoadOrder,
@@ -3591,6 +4658,7 @@ export {
3591
4658
  findingsCleanupGate,
3592
4659
  findTemporaryMarkers,
3593
4660
  findSimplifyMarkers,
4661
+ findMstarc,
3594
4662
  findEphemeralCitations,
3595
4663
  executionModeToN,
3596
4664
  evaluatePhaseGate,
@@ -3613,15 +4681,31 @@ export {
3613
4681
  assertControlVsFeaturePath,
3614
4682
  assertBranchAlignment,
3615
4683
  assertBaseSha,
3616
- archiveResiduals,
4684
+ applyMigratePlan,
3617
4685
  applyEnforcement,
3618
4686
  antiRecursionPrecheck,
4687
+ _DEFAULT_PROJECT,
4688
+ WORKFLOW_TERMINAL_STATUSES,
4689
+ WORKFLOW_SNAPSHOT_FILE,
4690
+ WORKFLOW_LIFECYCLE_TYPES,
4691
+ WORKFLOW_LIFECYCLE_STATUSES,
3619
4692
  SddScriptError,
3620
4693
  SHARED_FAMILIES,
3621
4694
  SEVERITY_ORDER,
3622
4695
  RUNTIME_HEADING_ALIASES,
3623
4696
  ROLE_MAPPING,
4697
+ ROADMAP_STATUSES,
3624
4698
  QC_REVIEWER_PARAMS,
4699
+ PROJECT_ROADMAP_FILE,
4700
+ PROJECT_REGISTER_FILE,
4701
+ PROJECT_REFERENCES_DIR,
4702
+ NOTES_LEDGER_FILE,
4703
+ MSTARC_WORKFLOW_DIR_KEY,
4704
+ MSTARC_SECTION,
4705
+ MSTARC_PROJECT_DIR_KEY,
4706
+ MSTARC_HARNESS_DIR_KEY,
4707
+ MSTARC_FILE,
4708
+ MIGRATE_STATUS_FILE,
3625
4709
  KNOWLEDGE_SEVERITIES,
3626
4710
  KNOWLEDGE_RESOLUTION_TYPES,
3627
4711
  KNOWLEDGE_REQUIRED_FIELDS,
@@ -3634,5 +4718,6 @@ export {
3634
4718
  AUDIT_RISKS,
3635
4719
  AUDIT_PRIORITIES,
3636
4720
  AUDIT_EFFORTS,
3637
- AUDIT_CATEGORIES
4721
+ AUDIT_CATEGORIES,
4722
+ ARCHIVED_STATUS_V1_FILE
3638
4723
  };