@mstar-harness/engine 2.4.1 → 3.0.1

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")] };
984
1247
  }
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"));
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
+ };
1255
+ }
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,765 @@ 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_REGISTER_FILE = "residuals.json";
2216
+ var _DEFAULT_PROJECT = "_default";
2217
+ var ROADMAP_STATUSES = ["active", "paused", "completed"];
2218
+ var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
2219
+ var ROLLUP_FIELDS = ["total_open", "by_severity", "by_target", "by_plan"];
2220
+ function isPlainObject5(value) {
2221
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2222
+ }
2223
+ function violation7(severity, code, message, fix) {
2224
+ return { ok: false, severity, code, message, fix };
2225
+ }
2226
+ function validateNonEmptyString4(violations, value, field, missingCode, invalidCode) {
2227
+ if (value === undefined) {
2228
+ violations.push(violation7("high", missingCode, `missing required field: ${field}`));
2229
+ } else if (typeof value !== "string" || value.trim() === "") {
2230
+ violations.push(violation7("medium", invalidCode, `${field} must be a non-empty string`));
2231
+ }
2232
+ }
2233
+ function validateRoadmap(filePath) {
2234
+ const violations = [];
2235
+ let content;
2236
+ try {
2237
+ content = readFileSync7(filePath, "utf8");
2238
+ } catch {
2239
+ return {
2240
+ ok: false,
2241
+ violations: [violation7("high", "project.roadmap.unreadable", `cannot read roadmap file: ${filePath}`)],
2242
+ warnings: []
2243
+ };
2244
+ }
2245
+ let doc;
2246
+ try {
2247
+ doc = parseCompassFrontmatterText(content, filePath);
2248
+ } catch (err) {
2249
+ const message = err instanceof Error ? err.message : `invalid roadmap frontmatter in ${filePath}`;
2250
+ return { ok: false, violations: [violation7("high", "project.roadmap.invalid-frontmatter", message)], warnings: [] };
2251
+ }
2252
+ validateNonEmptyString4(violations, doc.project_id, "project_id", "project.roadmap.missing-project-id", "project.roadmap.invalid-project-id");
2253
+ validateNonEmptyString4(violations, doc.title, "title", "project.roadmap.missing-title", "project.roadmap.invalid-title");
2254
+ if (doc.status === undefined) {
2255
+ violations.push(violation7("high", "project.roadmap.missing-status", "missing required field: status"));
2256
+ } else if (typeof doc.status !== "string" || !ROADMAP_STATUSES.includes(doc.status)) {
2257
+ violations.push(violation7("medium", "project.roadmap.invalid-status", `status must be one of ${ROADMAP_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
2258
+ }
2259
+ if (doc.created_at === undefined) {
2260
+ violations.push(violation7("high", "project.roadmap.missing-created-at", "missing required field: created_at"));
2261
+ } else if (typeof doc.created_at !== "string" || !DATE_RE3.test(doc.created_at)) {
2262
+ violations.push(violation7("medium", "project.roadmap.invalid-created-at", "created_at must be YYYY-MM-DD"));
2263
+ }
2264
+ if (doc.milestones !== undefined && doc.milestones !== null) {
2265
+ if (!Array.isArray(doc.milestones)) {
2266
+ violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones must be a list of milestone names"));
2267
+ } else {
2268
+ for (const item of doc.milestones) {
2269
+ if (typeof item !== "string" || item.trim() === "") {
2270
+ violations.push(violation7("medium", "project.roadmap.invalid-milestones", "milestones items must be non-empty strings"));
2271
+ break;
2272
+ }
2273
+ }
2274
+ }
2275
+ }
2276
+ if (doc.residuals_ref !== undefined && doc.residuals_ref !== null) {
2277
+ if (typeof doc.residuals_ref !== "string" || doc.residuals_ref.trim() === "") {
2278
+ violations.push(violation7("medium", "project.roadmap.invalid-residuals-ref", "residuals_ref must be a non-empty string"));
2279
+ }
2280
+ }
2281
+ const warnings = [];
2282
+ const fenceEnd = linesIndexOfClosingFence(content);
2283
+ const body = content.split(/\r?\n/).slice(fenceEnd + 1).join(`
2284
+ `);
2285
+ if (!/^##\s+Direction\s*$/m.test(body)) {
2286
+ warnings.push(violation7("low", "project.roadmap.body.missing-direction", "roadmap body has no `## Direction` section (documented body convention) — state the project direction there"));
2287
+ }
2288
+ if (!/^\s*[-*]\s+\[[xX ]\]/m.test(body)) {
2289
+ 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"));
2290
+ }
2291
+ return { ok: violations.length === 0, violations, warnings };
2292
+ }
2293
+ function linesIndexOfClosingFence(content) {
2294
+ return content.split(/\r?\n/).indexOf("---", 1);
2295
+ }
2296
+ function validateProjectRegister(doc) {
2297
+ const violations = [];
2298
+ if (!isPlainObject5(doc)) {
2299
+ return {
2300
+ ok: false,
2301
+ violations: [violation7("high", "project.register.invalid", "project register must be an object")]
2302
+ };
2303
+ }
2304
+ if (doc.entries === undefined) {
2305
+ violations.push(violation7("high", "project.register.missing-entries", "missing required field: entries"));
2306
+ } else if (!isPlainObject5(doc.entries)) {
2307
+ violations.push(violation7("high", "project.register.invalid-entries", "entries must be an object keyed by plan id"));
2308
+ } else {
2309
+ for (const [key, entries] of Object.entries(doc.entries)) {
2310
+ if (key.trim() === "") {
2311
+ violations.push(violation7("medium", "project.register.invalid-key", "entries keys must be non-empty plan ids"));
2312
+ }
2313
+ if (!Array.isArray(entries)) {
2314
+ 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)`));
2315
+ continue;
2316
+ }
2317
+ for (const entry of entries) {
2318
+ violations.push(...validateResidual(entry).violations);
2319
+ if (!isPlainObject5(entry))
2320
+ continue;
2321
+ validateNonEmptyString4(violations, entry.source_plan, "source_plan", "project.register.missing-source-plan", "project.register.invalid-source-plan");
2322
+ if (entry.registered_at === undefined) {
2323
+ violations.push(violation7("high", "project.register.missing-registered-at", "missing required field: registered_at"));
2324
+ } else if (typeof entry.registered_at !== "string" || !DATE_RE3.test(entry.registered_at)) {
2325
+ violations.push(violation7("medium", "project.register.invalid-registered-at", "registered_at must be YYYY-MM-DD"));
2326
+ }
2327
+ if (entry.lifecycle_id !== undefined && (typeof entry.lifecycle_id !== "string" || entry.lifecycle_id.trim() === "")) {
2328
+ violations.push(violation7("medium", "project.register.invalid-lifecycle-id", "lifecycle_id must be a non-empty string"));
2329
+ }
2330
+ if (typeof entry.source_plan === "string" && entry.source_plan.trim() !== "" && entry.source_plan !== key) {
2331
+ 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`));
2332
+ }
2333
+ }
2334
+ }
2335
+ }
2336
+ return { ok: violations.length === 0, violations };
2337
+ }
2338
+ function findingsCleanupGate(register, planId, opts) {
2339
+ const mode = opts?.mode ?? "allow-residual";
2340
+ const violations = [];
2341
+ const entries = isPlainObject5(register.entries) ? register.entries[planId] : undefined;
2342
+ if (entries === undefined) {
2343
+ return { ok: true, violations };
2344
+ }
2345
+ if (!Array.isArray(entries)) {
2346
+ 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)`));
2347
+ return { ok: false, violations };
2348
+ }
2349
+ if (entries.length === 0) {
2350
+ return { ok: true, violations };
2351
+ }
2352
+ for (const entry of entries) {
2353
+ if (!isOpenResidual(entry))
2354
+ continue;
2355
+ const id = typeof entry.id === "string" ? entry.id : "<unnamed>";
2356
+ const label = `R#${id}`;
2357
+ if (mode === "zero-residual") {
2358
+ if (entry.severity === "nit") {
2359
+ 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`));
2360
+ } else if (entry.decision === "risk-accepted" || entry.lifecycle === "waived") {
2361
+ violations.push(violation7("medium", "findings.zero-residual-risk-accepted", `${label}: waived/risk-accepted findings must be closed/archived, not left open under zero-residual`));
2362
+ } else if (entry.decision === "defer") {
2363
+ if (typeof entry.target !== "string" || entry.target.trim() === "") {
2364
+ violations.push(violation7("medium", "findings.zero-residual-defer-no-target", `${label}: blocker-defer requires a target (next iteration/milestone) under zero-residual`));
2365
+ }
2366
+ } else {
2367
+ violations.push(violation7("medium", "findings.zero-residual-open-fixable", `${label}: fixable finding must not remain open under zero-residual — fix now or convert to a blocker-defer`));
2368
+ }
2369
+ } else if (normalizeSeverity(entry.severity) === "critical") {
2370
+ violations.push(violation7("high", "findings.allow-residual-critical", `${label}: unresolved critical blocks Approve with residuals`));
2371
+ }
2372
+ }
2373
+ return { ok: violations.length === 0, violations };
2374
+ }
2375
+ function groupCount(values) {
2376
+ const counts = new Map;
2377
+ for (const value of values) {
2378
+ const key = typeof value === "string" ? value : String(value);
2379
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2380
+ }
2381
+ return Object.fromEntries([...counts.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
2382
+ }
2383
+ function techDebtRollup(projectDir) {
2384
+ const items = [];
2385
+ let entries;
2386
+ try {
2387
+ entries = readdirSync5(projectDir, { withFileTypes: true });
2388
+ } catch {
2389
+ entries = [];
2390
+ }
2391
+ for (const project of entries) {
2392
+ if (!project.isDirectory())
2393
+ continue;
2394
+ const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
2395
+ if (!existsSync5(registerPath))
2396
+ continue;
2397
+ let register;
2398
+ try {
2399
+ register = readJson(registerPath);
2400
+ } catch {
2401
+ continue;
2402
+ }
2403
+ if (!isPlainObject5(register) || !isPlainObject5(register.entries))
2404
+ continue;
2405
+ for (const [plan, planEntries] of Object.entries(register.entries)) {
2406
+ if (!Array.isArray(planEntries))
2407
+ continue;
2408
+ for (const entry of planEntries) {
2409
+ if (!isPlainObject5(entry) || !isOpenResidual(entry))
2410
+ continue;
2411
+ items.push({ plan, entry });
2412
+ }
2413
+ }
2414
+ }
2415
+ const bySeverity = {};
2416
+ for (const severity of SEVERITY_ORDER) {
2417
+ bySeverity[severity] = items.filter(({ entry }) => normalizeSeverity(entry.severity) === severity).length;
2418
+ }
2419
+ const computed = {
2420
+ total_open: items.length,
2421
+ by_severity: bySeverity,
2422
+ by_target: groupCount(items.map(({ entry }) => entry.target ?? "unspecified")),
2423
+ by_plan: groupCount(items.map(({ plan }) => plan))
2424
+ };
2425
+ const stored = null;
2426
+ const checks = ROLLUP_FIELDS.map((field) => ({ field, status: "DRIFT" }));
2427
+ const overall = "DRIFT";
2428
+ return { computed, stored, checks, overall };
2429
+ }
2430
+ // src/migrate.ts
2431
+ import { copyFileSync, mkdirSync as mkdirSync6, readFileSync as readFileSync8, readdirSync as readdirSync6, writeFileSync as writeFileSync4 } from "node:fs";
2432
+ import { dirname as dirname7, isAbsolute as isAbsolute6, join as join10, relative as relative3, resolve as resolve8, sep as sep2 } from "node:path";
2433
+ var MIGRATE_STATUS_FILE = "status.json";
2434
+ var ARCHIVED_STATUS_V1_FILE = "archived/status.v1.json";
2435
+ var NOTES_LEDGER_FILE = "notes.jsonl";
2436
+ var DATE_RE4 = /^\d{4}-\d{2}-\d{2}$/;
2437
+ var ROOT_METADATA_LIFT_KEYS = {
2438
+ plan_parallelism: true,
2439
+ worktree_mode: true,
2440
+ push_policy: true,
2441
+ iteration_base_branch: true,
2442
+ target_branch: true,
2443
+ spec_integration_branch: true,
2444
+ control_worktree_path: true,
2445
+ integration_merge_lease: true,
2446
+ program_roadmap: true,
2447
+ updated_at: true
2448
+ };
2449
+ function isPlainObject6(value) {
2450
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2451
+ }
2452
+ function dateString(value) {
2453
+ return typeof value === "string" && DATE_RE4.test(value) ? value : undefined;
2454
+ }
2455
+ function rowIdOf(row) {
2456
+ if (typeof row.id === "string" && row.id.trim() !== "")
2457
+ return row.id;
2458
+ if (typeof row.plan_id === "string" && row.plan_id.trim() !== "")
2459
+ return row.plan_id;
2460
+ return null;
2461
+ }
2462
+ function compareIds(a, b) {
2463
+ return a < b ? -1 : a > b ? 1 : 0;
2464
+ }
2465
+ function todayString2() {
2466
+ const now = new Date;
2467
+ const month = String(now.getMonth() + 1).padStart(2, "0");
2468
+ const day = String(now.getDate()).padStart(2, "0");
2469
+ return `${now.getFullYear()}-${month}-${day}`;
2470
+ }
2471
+ function scanCompasses(harnessDir) {
2472
+ const iterationsDir = join10(harnessDir, "iterations");
2473
+ const out = [];
2474
+ let entries;
2475
+ try {
2476
+ entries = readdirSync6(iterationsDir, { withFileTypes: true });
2477
+ } catch {
2478
+ return out;
2479
+ }
2480
+ for (const entry of entries) {
2481
+ if (!entry.isDirectory())
2482
+ continue;
2483
+ const compassPath = join10(iterationsDir, entry.name, "delivery-compass.md");
2484
+ let content;
2485
+ try {
2486
+ content = readFileSync8(compassPath, "utf8");
2487
+ } catch {
2488
+ continue;
2489
+ }
2490
+ const doc = parseCompassFrontmatterText(content, compassPath);
2491
+ const status = doc.status;
2492
+ if (status !== "active" && status !== "locked" && status !== "completed") {
2493
+ throw new Error(`refusing to migrate: compass ${JSON.stringify(compassPath)} has unsupported status ${JSON.stringify(status)} (expected active | locked | completed)`);
2494
+ }
2495
+ const plans = Array.isArray(doc.plans) ? doc.plans.filter((p) => typeof p === "string" && p !== "") : [];
2496
+ out.push({
2497
+ id: typeof doc.iteration_id === "string" && doc.iteration_id !== "" ? doc.iteration_id : entry.name,
2498
+ file: compassPath,
2499
+ status,
2500
+ plans,
2501
+ startDate: dateString(doc.start_date),
2502
+ endDate: dateString(doc.end_date)
2503
+ });
2504
+ }
2505
+ out.sort((a, b) => compareIds(a.id, b.id));
2506
+ return out;
2507
+ }
2508
+ function pickDate(preferred, fallback) {
2509
+ for (const candidate of preferred) {
2510
+ if (candidate !== undefined)
2511
+ return candidate;
2512
+ }
2513
+ return fallback;
2514
+ }
2515
+ function groupRows(rows, compasses) {
2516
+ const rowById = new Map;
2517
+ for (const row of rows) {
2518
+ const id = rowIdOf(row);
2519
+ if (id !== null && !rowById.has(id))
2520
+ rowById.set(id, row);
2521
+ }
2522
+ const byPlan = new Map;
2523
+ for (const compass of compasses) {
2524
+ for (const planId of compass.plans) {
2525
+ if (!byPlan.has(planId))
2526
+ byPlan.set(planId, compass);
2527
+ }
2528
+ }
2529
+ return { byPlan, rowById };
2530
+ }
2531
+ function buildIterationSnapshot(compass, rowById, rootUpdatedAt) {
2532
+ const rows = compass.plans.map((planId) => rowById.get(planId)).filter((row) => row !== undefined);
2533
+ rows.sort((a, b) => compareIds(rowIdOf(a) ?? "", rowIdOf(b) ?? ""));
2534
+ const rowDates = rows.flatMap((row) => [
2535
+ dateString(row.created_at),
2536
+ dateString(row.updated_at),
2537
+ dateString(row.done_at)
2538
+ ]);
2539
+ const startedAt = pickDate([compass.startDate, ...rowDates], rootUpdatedAt);
2540
+ const endedAt = pickDate([compass.endDate, ...rowDates], rootUpdatedAt);
2541
+ const status = compass.status === "completed" ? "completed" : "running";
2542
+ const compactMissing = compass.plans.filter((planId) => !rowById.has(planId)).sort();
2543
+ const snapshot = {
2544
+ schema_version: 1,
2545
+ id: compass.id,
2546
+ type: "iteration",
2547
+ status,
2548
+ started_at: startedAt,
2549
+ ...status === "completed" ? { ended_at: endedAt } : {},
2550
+ updated_at: status === "completed" ? endedAt : startedAt,
2551
+ plans: rows,
2552
+ compass_ref: `iterations/${compass.id}/delivery-compass.md`
2553
+ };
2554
+ const legacyMetadata = {};
2555
+ if (compactMissing.length > 0)
2556
+ legacyMetadata.compact_missing = compactMissing;
2557
+ if (Object.keys(legacyMetadata).length > 0)
2558
+ snapshot.legacy_metadata = legacyMetadata;
2559
+ return {
2560
+ id: compass.id,
2561
+ type: "iteration",
2562
+ status,
2563
+ file: join10("workflows", compass.id, WORKFLOW_SNAPSHOT_FILE),
2564
+ source: join10("iterations", compass.id, "delivery-compass.md"),
2565
+ data: snapshot
2566
+ };
2567
+ }
2568
+ function buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes) {
2569
+ const id = rowIdOf(row) ?? "<unnamed>";
2570
+ const statusValue = row.status;
2571
+ let status;
2572
+ if (statusValue === "Done")
2573
+ status = "completed";
2574
+ else if (statusValue === "InProgress" || statusValue === "InReview")
2575
+ status = "running";
2576
+ else if (statusValue === "Todo" || statusValue === "Blocked")
2577
+ status = "paused";
2578
+ else {
2579
+ status = "paused";
2580
+ migrationNotes.push(`row ${JSON.stringify(id)} has unrecognized v1 status ${JSON.stringify(statusValue)} — snapshot status defaults to paused (row status stays verbatim)`);
2581
+ }
2582
+ const startedAt = pickDate([dateString(row.created_at), dateString(row.updated_at)], rootUpdatedAt);
2583
+ const updatedAt = pickDate([dateString(row.updated_at), dateString(row.done_at), dateString(row.created_at)], rootUpdatedAt);
2584
+ const endedAt = pickDate([dateString(row.done_at), dateString(row.updated_at)], rootUpdatedAt);
2585
+ const snapshot = {
2586
+ schema_version: 1,
2587
+ id,
2588
+ type: "plan",
2589
+ status,
2590
+ started_at: startedAt,
2591
+ ...status === "completed" ? { ended_at: endedAt } : {},
2592
+ updated_at: status === "completed" ? endedAt : updatedAt,
2593
+ plans: [row]
2594
+ };
2595
+ return {
2596
+ id,
2597
+ type: "plan",
2598
+ status,
2599
+ file: join10("workflows", id, WORKFLOW_SNAPSHOT_FILE),
2600
+ source: "status.json plans[] row",
2601
+ data: snapshot
2602
+ };
2603
+ }
2604
+ function applyRootMetadataLift(snapshot, metadata, migrationNotes, activeIterations) {
2605
+ const data = snapshot.data;
2606
+ const policy = {};
2607
+ for (const key of ["plan_parallelism", "worktree_mode", "push_policy"]) {
2608
+ if (metadata[key] !== undefined)
2609
+ policy[key] = metadata[key];
2610
+ }
2611
+ if (Object.keys(policy).length > 0)
2612
+ data.execution_policy = policy;
2613
+ const branch = {};
2614
+ const branchKeys = [
2615
+ ["base", "iteration_base_branch"],
2616
+ ["integration", "spec_integration_branch"],
2617
+ ["target", "target_branch"]
2618
+ ];
2619
+ for (const [target, source] of branchKeys) {
2620
+ const value = metadata[source];
2621
+ if (typeof value === "string" && value !== "")
2622
+ branch[target] = value;
2623
+ }
2624
+ if (Object.keys(branch).length > 0)
2625
+ data.branch = branch;
2626
+ if (typeof metadata.control_worktree_path === "string" && metadata.control_worktree_path !== "") {
2627
+ data.control_worktree_path = metadata.control_worktree_path;
2628
+ }
2629
+ if (isPlainObject6(metadata.integration_merge_lease)) {
2630
+ data.integration_merge_lease = metadata.integration_merge_lease;
2631
+ }
2632
+ const legacyMetadata = isPlainObject6(data.legacy_metadata) ? { ...data.legacy_metadata } : {};
2633
+ for (const [key, value] of Object.entries(metadata)) {
2634
+ if (key === "harness_root")
2635
+ continue;
2636
+ if (ROOT_METADATA_LIFT_KEYS[key] === true)
2637
+ continue;
2638
+ legacyMetadata[key] = value;
2639
+ }
2640
+ if (metadata.harness_root !== undefined) {
2641
+ legacyMetadata.harness_root_note = `dropped as redundant (v2 harness dir derives from status.json location): ${String(metadata.harness_root)}`;
2642
+ }
2643
+ if (Object.keys(legacyMetadata).length > 0)
2644
+ data.legacy_metadata = legacyMetadata;
2645
+ if (activeIterations > 1) {
2646
+ migrationNotes.push(`${activeIterations} active iterations present — root-metadata lifts applied to ${JSON.stringify(snapshot.id)} only`);
2647
+ }
2648
+ }
2649
+ function buildRoadmap(programRoadmap, projectId, migratedAt) {
2650
+ const title = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
2651
+ const doc = typeof programRoadmap.doc === "string" ? programRoadmap.doc : "";
2652
+ const completionVersion = typeof programRoadmap.completion_version === "string" ? programRoadmap.completion_version : "";
2653
+ const branch = typeof programRoadmap.branch === "string" ? programRoadmap.branch : "";
2654
+ const milestones = Array.isArray(programRoadmap.slices) ? programRoadmap.slices.map((slice) => String(slice)).filter((slice) => slice !== "") : [];
2655
+ const deferred = Array.isArray(programRoadmap.deferred_beyond) ? programRoadmap.deferred_beyond.map((item) => String(item)).filter((item) => item !== "") : [];
2656
+ const lines = [
2657
+ "---",
2658
+ `project_id: ${projectId}`,
2659
+ `title: ${title}`,
2660
+ "status: active",
2661
+ `created_at: ${migratedAt}`
2662
+ ];
2663
+ if (milestones.length > 0) {
2664
+ lines.push("milestones:");
2665
+ for (const milestone of milestones)
2666
+ lines.push(` - ${milestone}`);
2667
+ }
2668
+ lines.push("residuals_ref: residuals.json", "---", "", "# Roadmap", "", "## Direction");
2669
+ const provenance = [doc !== "" ? `doc: ${doc}` : "", completionVersion !== "" ? `completion_version: ${completionVersion}` : "", branch !== "" ? `branch: ${branch}` : ""].filter((part) => part !== "").join(", ");
2670
+ lines.push(`Migrated from legacy status.json \`metadata.program_roadmap\`${provenance !== "" ? ` (${provenance})` : ""}.`);
2671
+ if (programRoadmap.no_intermediate_releases !== undefined) {
2672
+ lines.push(`no_intermediate_releases: ${String(programRoadmap.no_intermediate_releases)}`);
2673
+ }
2674
+ if (deferred.length > 0) {
2675
+ lines.push("", "### Deferred beyond", ...deferred.map((item) => `- ${item}`));
2676
+ }
2677
+ lines.push("");
2678
+ return lines.join(`
2679
+ `);
2680
+ }
2681
+ function buildRegister(residualFindings, byPlan, projectId, migratedAt) {
2682
+ const entries = {};
2683
+ const planKeys = Object.keys(residualFindings).sort();
2684
+ for (const planId of planKeys) {
2685
+ const raw = residualFindings[planId];
2686
+ if (!Array.isArray(raw))
2687
+ continue;
2688
+ const open = raw.filter((entry) => isPlainObject6(entry) && isOpenResidual(entry)).sort((a, b) => {
2689
+ const aId = typeof a.id === "string" ? a.id : "";
2690
+ const bId = typeof b.id === "string" ? b.id : "";
2691
+ return compareIds(aId, bId);
2692
+ });
2693
+ if (open.length === 0)
2694
+ continue;
2695
+ const owner = byPlan.get(planId);
2696
+ entries[planId] = open.map((entry) => ({
2697
+ ...entry,
2698
+ source_plan: planId,
2699
+ registered_at: migratedAt,
2700
+ ...owner !== undefined ? { lifecycle_id: owner.id } : {}
2701
+ }));
2702
+ }
2703
+ if (Object.keys(entries).length === 0)
2704
+ return null;
2705
+ const doc = { entries };
2706
+ return {
2707
+ file: join10("projects", projectId, PROJECT_REGISTER_FILE),
2708
+ source: "status.json residual_findings",
2709
+ data: doc
2710
+ };
2711
+ }
2712
+ function collectNotesFiles(snapshots) {
2713
+ const out = [];
2714
+ for (const snapshot of snapshots) {
2715
+ const lines = [];
2716
+ let source = "";
2717
+ for (const row of snapshot.data.plans) {
2718
+ if (Array.isArray(row.notes)) {
2719
+ source = "status.json plans[].notes arrays";
2720
+ for (const note of row.notes) {
2721
+ if (typeof note !== "string")
2722
+ continue;
2723
+ lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: note }));
2724
+ }
2725
+ }
2726
+ }
2727
+ if (snapshot.type === "plan" && snapshot.status === "paused") {
2728
+ const row = snapshot.data.plans[0];
2729
+ if (row !== undefined && row.status === "Todo") {
2730
+ source = source === "" ? "status.json plans[] Todo rows (not-started note)" : source;
2731
+ 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)";
2732
+ lines.push(JSON.stringify({ kind: "note", ts: snapshot.data.updated_at, text: noteText }));
2733
+ }
2734
+ }
2735
+ if (lines.length === 0)
2736
+ continue;
2737
+ out.push({
2738
+ file: join10(dirname7(snapshot.file), NOTES_LEDGER_FILE),
2739
+ source,
2740
+ lines
2741
+ });
2742
+ }
2743
+ return out;
2744
+ }
2745
+ function migrateHarnessTree(root, opts = {}) {
2746
+ const harnessDir = resolve8(root);
2747
+ const workflowDir = resolveWorkflowDir(harnessDir, { harnessDir });
2748
+ const projectDir = resolveProjectDir(harnessDir, { harnessDir });
2749
+ const projectId = opts.projectId ?? _DEFAULT_PROJECT;
2750
+ const statusPath = join10(harnessDir, MIGRATE_STATUS_FILE);
2751
+ const legacy = readJson(statusPath);
2752
+ if (legacy.version === 2) {
2753
+ const updatedAt = typeof legacy.updated_at === "string" && legacy.updated_at !== "" ? legacy.updated_at : "1970-01-01";
2754
+ return {
2755
+ root: harnessDir,
2756
+ workflowDir,
2757
+ projectDir,
2758
+ dryRun: opts.dryRun === true,
2759
+ alreadyMigrated: true,
2760
+ message: `no-op: ${statusPath} is already at schema version 2 (migrated) — nothing to do`,
2761
+ snapshots: [],
2762
+ notesFiles: [],
2763
+ register: null,
2764
+ roadmap: null,
2765
+ rootV2: { file: MIGRATE_STATUS_FILE, data: { version: 2, updated_at: updatedAt, workflows: [] } },
2766
+ archive: { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE },
2767
+ migrationNotes: [],
2768
+ steps: []
2769
+ };
2770
+ }
2771
+ if (legacy.version !== undefined && legacy.version !== 1) {
2772
+ throw new Error(`refusing to migrate: ${statusPath} has unrecognized schema version ${JSON.stringify(legacy.version)} (expected 1)`);
2773
+ }
2774
+ if (legacy.version === undefined) {
2775
+ throw new Error(`refusing to migrate: no v1 status.json found at ${statusPath} (nothing to migrate)`);
2776
+ }
2777
+ const rows = Array.isArray(legacy.plans) ? legacy.plans.filter(isPlainObject6) : [];
2778
+ if (Array.isArray(legacy.plans)) {
2779
+ const unLiftable = [];
2780
+ const idCounts = new Map;
2781
+ for (const row of legacy.plans) {
2782
+ if (!isPlainObject6(row) || rowIdOf(row) === null) {
2783
+ unLiftable.push(row);
2784
+ continue;
2785
+ }
2786
+ const id = rowIdOf(row);
2787
+ assertSafePathComponent(id, "plan id");
2788
+ idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
2789
+ }
2790
+ if (unLiftable.length > 0) {
2791
+ 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`);
2792
+ }
2793
+ const duplicates = [...idCounts.entries()].filter(([, count]) => count > 1).map(([id]) => id);
2794
+ if (duplicates.length > 0) {
2795
+ throw new Error(`refusing to migrate: ${duplicates.length} duplicate plan id(s) (${duplicates.join(", ")}) — every v1 row must land in exactly one snapshot`);
2796
+ }
2797
+ }
2798
+ const metadata = isPlainObject6(legacy.metadata) ? legacy.metadata : {};
2799
+ const rootUpdatedAt = dateString(legacy.updated_at) ?? dateString(metadata.updated_at) ?? todayString2();
2800
+ const migratedAt = dateString(metadata.updated_at) ?? rootUpdatedAt;
2801
+ const migrationNotes = [];
2802
+ const compasses = scanCompasses(harnessDir);
2803
+ for (const compass of compasses) {
2804
+ assertSafePathComponent(compass.id, "iteration id");
2805
+ }
2806
+ assertSafePathComponent(projectId, "projectId");
2807
+ const { byPlan, rowById } = groupRows(rows, compasses);
2808
+ const snapshots = compasses.map((compass) => buildIterationSnapshot(compass, rowById, rootUpdatedAt));
2809
+ for (const row of rows) {
2810
+ const id = rowIdOf(row);
2811
+ if (id !== null && !byPlan.has(id))
2812
+ snapshots.push(buildStandaloneSnapshot(row, rootUpdatedAt, migrationNotes));
2813
+ }
2814
+ const lifecycleSources = new Map;
2815
+ for (const snapshot of snapshots) {
2816
+ const sources = lifecycleSources.get(snapshot.id) ?? [];
2817
+ sources.push(snapshot.type === "iteration" ? "iteration" : "standalone plan");
2818
+ lifecycleSources.set(snapshot.id, sources);
2819
+ }
2820
+ const projectSources = lifecycleSources.get(projectId) ?? [];
2821
+ projectSources.push("project");
2822
+ lifecycleSources.set(projectId, projectSources);
2823
+ const collisions = [...lifecycleSources.entries()].filter(([, sources]) => sources.length > 1);
2824
+ if (collisions.length > 0) {
2825
+ 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)`);
2826
+ }
2827
+ snapshots.sort((a, b) => compareIds(a.id, b.id));
2828
+ const activeIterations = snapshots.filter((snapshot) => snapshot.status === "running" && snapshot.type === "iteration");
2829
+ if (activeIterations.length > 0) {
2830
+ applyRootMetadataLift(activeIterations[0], metadata, migrationNotes, activeIterations.length);
2831
+ } else if (Object.keys(metadata).length > 0) {
2832
+ 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)");
2833
+ }
2834
+ const notesFiles = collectNotesFiles(snapshots);
2835
+ const residualFindings = isPlainObject6(legacy.residual_findings) ? legacy.residual_findings : {};
2836
+ const register = buildRegister(residualFindings, byPlan, projectId, migratedAt);
2837
+ const programRoadmap = isPlainObject6(metadata.program_roadmap) ? metadata.program_roadmap : null;
2838
+ let roadmap = null;
2839
+ if (programRoadmap) {
2840
+ const rawTitle = typeof programRoadmap.title === "string" && programRoadmap.title !== "" ? programRoadmap.title : "Program roadmap";
2841
+ const sanitizedTitle = rawTitle.replace(/[\r\n]+/g, " ").trim();
2842
+ if (sanitizedTitle !== rawTitle) {
2843
+ migrationNotes.push(`roadmap title sanitized for frontmatter (line breaks replaced with spaces): ${JSON.stringify(rawTitle)}`);
2844
+ }
2845
+ roadmap = {
2846
+ file: join10("projects", projectId, PROJECT_ROADMAP_FILE),
2847
+ source: "status.json metadata.program_roadmap",
2848
+ content: buildRoadmap({ ...programRoadmap, title: sanitizedTitle }, projectId, migratedAt)
2849
+ };
2850
+ }
2851
+ const rootV2 = {
2852
+ file: MIGRATE_STATUS_FILE,
2853
+ data: { version: 2, updated_at: migratedAt, workflows: [] }
2854
+ };
2855
+ const archive = { file: ARCHIVED_STATUS_V1_FILE, source: MIGRATE_STATUS_FILE };
2856
+ const steps = [
2857
+ { kind: "archive-status-v1", source: archive.source, destination: archive.file },
2858
+ ...snapshots.map((snapshot) => ({
2859
+ kind: "write-snapshot",
2860
+ source: snapshot.source,
2861
+ destination: snapshot.file
2862
+ })),
2863
+ ...notesFiles.map((notes) => ({
2864
+ kind: "write-notes",
2865
+ source: notes.source,
2866
+ destination: notes.file
2867
+ }))
2868
+ ];
2869
+ if (register !== null)
2870
+ steps.push({ kind: "write-register", source: register.source, destination: register.file });
2871
+ if (roadmap !== null)
2872
+ steps.push({ kind: "write-roadmap", source: roadmap.source, destination: roadmap.file });
2873
+ steps.push({ kind: "replace-root-v2", source: `${MIGRATE_STATUS_FILE} (v1)`, destination: `${MIGRATE_STATUS_FILE} (v2)` });
2874
+ return {
2875
+ root: harnessDir,
2876
+ workflowDir,
2877
+ projectDir,
2878
+ dryRun: opts.dryRun === true,
2879
+ alreadyMigrated: false,
2880
+ message: `planned migration of ${snapshots.length} lifecycles (${steps.length} steps)`,
2881
+ snapshots,
2882
+ notesFiles,
2883
+ register,
2884
+ roadmap,
2885
+ rootV2,
2886
+ archive,
2887
+ migrationNotes,
2888
+ steps
2889
+ };
2890
+ }
2891
+ async function applyMigratePlan(plan) {
2892
+ if (plan.dryRun) {
2893
+ return { applied: false, message: `dry-run: ${plan.steps.length} steps planned (source → destination), zero writes` };
2894
+ }
2895
+ const statusPath = join10(plan.root, MIGRATE_STATUS_FILE);
2896
+ const current = readJson(statusPath);
2897
+ if (current.version === 2) {
2898
+ return { applied: false, message: "no-op: status.json already at schema version 2 (migrated) — nothing to do" };
2899
+ }
2900
+ const harnessRoot = resolve8(plan.root);
2901
+ const workflowRoot = resolve8(plan.workflowDir);
2902
+ const projectRoot = resolve8(plan.projectDir);
2903
+ if (!isAbsolute6(plan.workflowDir) || !isAbsolute6(plan.projectDir)) {
2904
+ throw new Error(`refusing to apply migration: plan workflowDir/projectDir must be absolute (got ${JSON.stringify(plan.workflowDir)} / ${JSON.stringify(plan.projectDir)})`);
2905
+ }
2906
+ const workflowTargetOf = (canonicalFile) => join10(workflowRoot, relative3("workflows", canonicalFile));
2907
+ const projectTargetOf = (canonicalFile) => join10(projectRoot, relative3("projects", canonicalFile));
2908
+ const allDestinations = [
2909
+ plan.archive.file,
2910
+ ...plan.snapshots.map((snapshot) => snapshot.file),
2911
+ ...plan.notesFiles.map((notes) => notes.file),
2912
+ ...plan.register !== null ? [plan.register.file] : [],
2913
+ ...plan.roadmap !== null ? [plan.roadmap.file] : []
2914
+ ];
2915
+ for (const destination of allDestinations) {
2916
+ const resolvedDest = resolve8(join10(plan.root, destination));
2917
+ const inside = (dir) => resolvedDest === dir || resolvedDest.startsWith(`${dir}${sep2}`);
2918
+ if (!inside(harnessRoot) && !inside(workflowRoot) && !inside(projectRoot)) {
2919
+ 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)})`);
2920
+ }
2921
+ }
2922
+ mkdirSync6(join10(plan.root, dirname7(plan.archive.file)), { recursive: true });
2923
+ copyFileSync(statusPath, join10(plan.root, plan.archive.file));
2924
+ for (const snapshot of plan.snapshots) {
2925
+ await writeWorkflowSnapshot(snapshot.data, dirname7(workflowTargetOf(snapshot.file)));
2926
+ }
2927
+ for (const notes of plan.notesFiles) {
2928
+ const filePath = workflowTargetOf(notes.file);
2929
+ mkdirSync6(dirname7(filePath), { recursive: true });
2930
+ const content = notes.lines.length > 0 ? `${notes.lines.join(`
2931
+ `)}
2932
+ ` : "";
2933
+ writeFileSync4(filePath, content, "utf8");
2934
+ }
2935
+ if (plan.register !== null) {
2936
+ const gate2 = validateProjectRegister(plan.register.data);
2937
+ if (!gate2.ok) {
2938
+ throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
2939
+ }
2940
+ const filePath = projectTargetOf(plan.register.file);
2941
+ mkdirSync6(dirname7(filePath), { recursive: true });
2942
+ writeJson(filePath, plan.register.data);
2943
+ }
2944
+ if (plan.roadmap !== null) {
2945
+ const filePath = projectTargetOf(plan.roadmap.file);
2946
+ mkdirSync6(dirname7(filePath), { recursive: true });
2947
+ writeFileSync4(filePath, plan.roadmap.content, "utf8");
2948
+ }
2949
+ const rootGate = validateStatusV2(plan.rootV2.data, { harnessDir: plan.root });
2950
+ if (!rootGate.ok) {
2951
+ throw new Error(`refusing to apply migration: invalid v2 root: ${rootGate.violations.map((v) => v.message).join("; ")}`);
2952
+ }
2953
+ return withStatusWriteLock(statusPath, () => {
2954
+ const latest = readJson(statusPath);
2955
+ if (latest.version === 2) {
2956
+ return {
2957
+ applied: false,
2958
+ message: "no-op: status.json already at schema version 2 (migrated) — nothing to do"
2959
+ };
2960
+ }
2961
+ writeJson(statusPath, plan.rootV2.data);
2962
+ return {
2963
+ applied: true,
2964
+ message: `migrated ${plan.snapshots.length} lifecycles into workflows/, project layer seeded, root status.json replaced (v1 archived to ${plan.archive.file})`
2965
+ };
2966
+ });
2967
+ }
1948
2968
  // src/design-md.ts
1949
- function violation6(severity, code, message, fix) {
2969
+ function violation8(severity, code, message, fix) {
1950
2970
  return { ok: false, severity, code, message, fix };
1951
2971
  }
1952
2972
  var RAW_GROUP = "__raw";
@@ -2059,7 +3079,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2059
3079
  const violations = [];
2060
3080
  const fm = parseDesignFrontmatter(frontmatterText);
2061
3081
  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"));
3082
+ 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
3083
  return { ok: false, violations };
2064
3084
  }
2065
3085
  const groupEntries = (group) => {
@@ -2074,61 +3094,61 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2074
3094
  };
2075
3095
  for (const group of ["colors", "typography", "spacing", "rounded"]) {
2076
3096
  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`));
3097
+ 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
3098
  } 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`));
3099
+ 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
3100
  } 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`));
3101
+ 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
3102
  }
2083
3103
  }
2084
3104
  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`));
3105
+ 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
3106
  }
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`));
3107
+ 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
3108
  for (const [name, value] of groupEntries("colors")) {
2089
3109
  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"));
3110
+ 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
3111
  continue;
2092
3112
  }
2093
3113
  if (isPlaceholder(value)) {
2094
3114
  placeholder("colors", name, value);
2095
3115
  } 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"));
3116
+ 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
3117
  }
2098
3118
  }
2099
3119
  for (const [name, value] of groupEntries("typography")) {
2100
3120
  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"));
3121
+ 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
3122
  continue;
2103
3123
  }
2104
3124
  const keys = Object.keys(value);
2105
3125
  const missing = TYPOGRAPHY_PROPS.filter((p) => !keys.includes(p));
2106
3126
  const extra = keys.filter((k) => !TYPOGRAPHY_PROPS.includes(k));
2107
3127
  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"));
3128
+ 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
3129
  }
2110
3130
  for (const prop of ["fontFamily", "fontSize"]) {
2111
3131
  const v = value[prop];
2112
3132
  if (typeof v === "string" && isPlaceholder(v))
2113
3133
  placeholder("typography", name, v);
2114
3134
  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`));
3135
+ 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
3136
  }
2117
3137
  }
2118
3138
  }
2119
3139
  if (groupIsMap("spacing")) {
2120
3140
  const spacing = fm.spacing;
2121
3141
  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"));
3142
+ 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
3143
  }
2124
3144
  for (const [name, value] of Object.entries(spacing)) {
2125
3145
  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`"));
3146
+ 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
3147
  }
2128
3148
  if (typeof value === "string" && isPlaceholder(value)) {
2129
3149
  placeholder("spacing", name, value);
2130
3150
  } 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`"));
3151
+ 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
3152
  }
2133
3153
  }
2134
3154
  }
@@ -2136,12 +3156,12 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2136
3156
  if (typeof value === "string" && isPlaceholder(value)) {
2137
3157
  placeholder("rounded", name, value);
2138
3158
  } 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`"));
3159
+ 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
3160
  }
2141
3161
  }
2142
3162
  for (const [name, value] of groupEntries("components")) {
2143
3163
  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"));
3164
+ 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
3165
  continue;
2146
3166
  }
2147
3167
  for (const [prop, v] of Object.entries(value)) {
@@ -2157,7 +3177,7 @@ function validateDesignTokenFrontmatter(frontmatterText) {
2157
3177
  const [, refGroup, refKey] = ref;
2158
3178
  const resolves = REF_GROUPS.includes(refGroup) && groupIsMap(refGroup) && Object.prototype.hasOwnProperty.call(fm[refGroup], refKey);
2159
3179
  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`));
3180
+ 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
3181
  }
2162
3182
  }
2163
3183
  }
@@ -2169,7 +3189,7 @@ function assertLightDarkParity(lightFm, darkFm) {
2169
3189
  const light = parseDesignFrontmatter(lightFm);
2170
3190
  const dark = parseDesignFrontmatter(darkFm);
2171
3191
  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"));
3192
+ 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
3193
  return { ok: false, violations };
2174
3194
  }
2175
3195
  const activeKeys = (fm) => {
@@ -2187,12 +3207,12 @@ function assertLightDarkParity(lightFm, darkFm) {
2187
3207
  const darkKeys = activeKeys(dark);
2188
3208
  for (const key of lightKeys) {
2189
3209
  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"));
3210
+ 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
3211
  }
2192
3212
  }
2193
3213
  for (const key of darkKeys) {
2194
3214
  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"));
3215
+ 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
3216
  }
2197
3217
  }
2198
3218
  return { ok: violations.length === 0, violations };
@@ -2346,9 +3366,9 @@ function completenessLevel(frontmatterText, checklist) {
2346
3366
  return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
2347
3367
  }
2348
3368
  // 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) {
3369
+ import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
3370
+ import { join as join11, resolve as resolve9 } from "node:path";
3371
+ function violation9(severity, code, message, fix) {
2352
3372
  return { ok: false, severity, code, message, fix };
2353
3373
  }
2354
3374
  var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
@@ -2392,14 +3412,14 @@ function validateAuditStatusBlocks(planText) {
2392
3412
  const violations = [];
2393
3413
  const blocks = parseStatusBlocks(planText);
2394
3414
  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"));
3415
+ 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
3416
  return { ok: false, violations };
2397
3417
  }
2398
3418
  blocks.forEach((block, index) => {
2399
3419
  const label = blocks.length > 1 ? ` #${index + 1}` : "";
2400
3420
  for (const field of AUDIT_STATUS_FIELDS) {
2401
3421
  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`));
3422
+ 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
3423
  }
2404
3424
  }
2405
3425
  const check = (field, pattern, code, expected) => {
@@ -2407,7 +3427,7 @@ function validateAuditStatusBlocks(planText) {
2407
3427
  if (value === undefined)
2408
3428
  return;
2409
3429
  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}`));
3430
+ violations.push(violation9("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
2411
3431
  }
2412
3432
  };
2413
3433
  check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
@@ -2521,7 +3541,7 @@ function renderPlanFile(finding, plannedAt) {
2521
3541
  `;
2522
3542
  }
2523
3543
  function readPlanFileSummary(filePath) {
2524
- const text = readFileSync6(filePath, "utf8");
3544
+ const text = readFileSync9(filePath, "utf8");
2525
3545
  const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
2526
3546
  const blocks = parseStatusBlocks(text);
2527
3547
  return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
@@ -2560,8 +3580,8 @@ function renderIndex(params) {
2560
3580
  function scaffoldAuditPlan(outDir, findings, options = {}) {
2561
3581
  const date = options.date ?? new Date().toISOString().slice(0, 10);
2562
3582
  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));
3583
+ mkdirSync7(outDir, { recursive: true });
3584
+ const existing = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
2565
3585
  let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
2566
3586
  const written = [];
2567
3587
  const usedSlugs = new Set;
@@ -2576,13 +3596,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
2576
3596
  }
2577
3597
  usedSlugs.add(slug);
2578
3598
  const file = `${num}-${slug}.md`;
2579
- writeFileSync4(join7(outDir, file), renderPlanFile(finding, plannedAt));
3599
+ writeFileSync5(join11(outDir, file), renderPlanFile(finding, plannedAt));
2580
3600
  written.push(file);
2581
3601
  next++;
2582
3602
  }
2583
3603
  const all = [...existing, ...written].sort();
2584
3604
  const rows = all.map((file) => {
2585
- const summary = readPlanFileSummary(join7(outDir, file));
3605
+ const summary = readPlanFileSummary(join11(outDir, file));
2586
3606
  const fields = summary.fields;
2587
3607
  return {
2588
3608
  num: file.slice(0, 3),
@@ -2614,19 +3634,19 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
2614
3634
  row.dependsOn = finding.dependsOn ?? "none";
2615
3635
  }
2616
3636
  });
2617
- writeFileSync4(join7(outDir, "README.md"), renderIndex({
3637
+ writeFileSync5(join11(outDir, "README.md"), renderIndex({
2618
3638
  date,
2619
3639
  repoName: options.repoName ?? "repo",
2620
3640
  repoShortSha: options.repoShortSha ?? "unknown",
2621
3641
  rows,
2622
3642
  rejected: options.rejected ?? []
2623
3643
  }));
2624
- return { outDir: resolve7(outDir), date, files: written, nextNumber: next };
3644
+ return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
2625
3645
  }
2626
3646
  // 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) {
3647
+ import { existsSync as existsSync6, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
3648
+ import { basename as basename4, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
3649
+ function violation10(severity, code, message, fix) {
2630
3650
  return { ok: false, severity, code, message, fix };
2631
3651
  }
2632
3652
  var KNOWLEDGE_REQUIRED_FIELDS = ["module", "date", "problem_type", "category", "severity"];
@@ -2710,7 +3730,7 @@ var KNOWLEDGE_CATEGORY_MAP = {
2710
3730
  developer_experience: "developer-experience",
2711
3731
  documentation_gap: "documentation"
2712
3732
  };
2713
- var DATE_RE3 = /^\d{4}-\d{2}-\d{2}$/;
3733
+ var DATE_RE5 = /^\d{4}-\d{2}-\d{2}$/;
2714
3734
  var isMap2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2715
3735
  function parseScalar2(raw) {
2716
3736
  const trimmed = raw.trim();
@@ -2803,36 +3823,36 @@ function validateSchemaYaml(frontmatterText) {
2803
3823
  const violations = [];
2804
3824
  const doc = parseYamlLite(frontmatterText);
2805
3825
  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"));
3826
+ 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
3827
  return { ok: false, violations };
2808
3828
  }
2809
3829
  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`));
3830
+ 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
3831
  for (const field of KNOWLEDGE_REQUIRED_FIELDS) {
2812
3832
  if (!(field in doc) || doc[field] === "")
2813
3833
  missing(field);
2814
3834
  }
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`"));
3835
+ if (doc.date !== undefined && (!isStr(doc.date) || !DATE_RE5.test(doc.date))) {
3836
+ 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
3837
  }
2818
3838
  const problemType = doc.problem_type;
2819
3839
  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"));
3840
+ 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
3841
  }
2822
3842
  const problemTypeValid = isStr(problemType) && KNOWLEDGE_PROBLEM_TYPES.includes(problemType);
2823
3843
  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"));
3844
+ 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
3845
  }
2826
3846
  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"));
3847
+ 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
3848
  }
2829
3849
  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"));
3850
+ 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
3851
  }
2832
3852
  if (problemTypeValid && isStr(doc.category)) {
2833
3853
  const expected = KNOWLEDGE_CATEGORY_MAP[problemType];
2834
3854
  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})`));
3855
+ 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
3856
  }
2837
3857
  }
2838
3858
  if (problemTypeValid) {
@@ -2840,37 +3860,37 @@ function validateSchemaYaml(frontmatterText) {
2840
3860
  if (isBug) {
2841
3861
  for (const field of ["symptoms", "root_cause", "resolution_type"]) {
2842
3862
  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`));
3863
+ 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
3864
  }
2845
3865
  }
2846
3866
  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:`"));
3867
+ 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
3868
  }
2849
3869
  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"));
3870
+ 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
3871
  }
2852
3872
  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"));
3873
+ 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
3874
  }
2855
3875
  } 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:`"));
3876
+ 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
3877
  }
2858
3878
  }
2859
3879
  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"));
3880
+ 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
3881
  }
2862
3882
  if (doc.tags !== undefined) {
2863
3883
  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"));
3884
+ 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
3885
  } 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"));
3886
+ 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
3887
  }
2868
3888
  }
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`"));
3889
+ if (doc.last_updated !== undefined && (!isStr(doc.last_updated) || !DATE_RE5.test(doc.last_updated))) {
3890
+ 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
3891
  }
2872
3892
  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"));
3893
+ 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
3894
  }
2875
3895
  return { ok: violations.length === 0, violations };
2876
3896
  }
@@ -2892,7 +3912,7 @@ function referenceExists(repoRoot, docText) {
2892
3912
  if (ref === "" || seen.has(ref))
2893
3913
  continue;
2894
3914
  seen.add(ref);
2895
- if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute5(ref)) {
3915
+ if (SCHEME_RE.test(ref) || ref.startsWith("{") || ref.startsWith("#") || ref.includes("*") || ref.includes("?") || ref.startsWith("~") || isAbsolute7(ref)) {
2896
3916
  continue;
2897
3917
  }
2898
3918
  if (ref.includes("/") || REF_EXT_RE.test(ref)) {
@@ -2911,7 +3931,7 @@ function referenceExists(repoRoot, docText) {
2911
3931
  const dir = stack.pop();
2912
3932
  let entries;
2913
3933
  try {
2914
- entries = readdirSync5(dir, { withFileTypes: true });
3934
+ entries = readdirSync8(dir, { withFileTypes: true });
2915
3935
  } catch {
2916
3936
  continue;
2917
3937
  }
@@ -2920,7 +3940,7 @@ function referenceExists(repoRoot, docText) {
2920
3940
  break;
2921
3941
  if (entry.isDirectory()) {
2922
3942
  if (!WALK_SKIP_DIRS.has(entry.name))
2923
- stack.push(join8(dir, entry.name));
3943
+ stack.push(join12(dir, entry.name));
2924
3944
  } else if (!entry.isSymbolicLink()) {
2925
3945
  const base = entry.name.replace(/\.(?:ts|tsx|js|jsx|mjs|cjs)$/, "");
2926
3946
  if (moduleNames.has(base))
@@ -2932,15 +3952,15 @@ function referenceExists(repoRoot, docText) {
2932
3952
  for (const { ref, isSymbol, module } of refs) {
2933
3953
  if (!isSymbol || module === undefined) {
2934
3954
  const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
2935
- if (existsSync5(resolve8(repoRoot, candidate))) {
3955
+ if (existsSync6(resolve10(repoRoot, candidate))) {
2936
3956
  checked++;
2937
3957
  } 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"));
3958
+ 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
3959
  }
2940
3960
  } else if (foundModules.has(module)) {
2941
3961
  checked++;
2942
3962
  } 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"));
3963
+ 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
3964
  }
2945
3965
  }
2946
3966
  return { ok: violations.length === 0, violations, checked };
@@ -2952,18 +3972,18 @@ function collectKnowledgeDocs(dir) {
2952
3972
  const current = stack.pop();
2953
3973
  let entries;
2954
3974
  try {
2955
- entries = readdirSync5(current, { withFileTypes: true });
3975
+ entries = readdirSync8(current, { withFileTypes: true });
2956
3976
  } catch {
2957
3977
  continue;
2958
3978
  }
2959
3979
  for (const entry of entries) {
2960
3980
  if (entry.isSymbolicLink())
2961
3981
  continue;
2962
- const full = join8(current, entry.name);
3982
+ const full = join12(current, entry.name);
2963
3983
  if (entry.isDirectory()) {
2964
3984
  stack.push(full);
2965
3985
  } else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
2966
- docs.push(relative2(dir, full).split(sep).join("/"));
3986
+ docs.push(relative4(dir, full).split(sep3).join("/"));
2967
3987
  }
2968
3988
  }
2969
3989
  }
@@ -2979,14 +3999,14 @@ function normalizeIndexRef(cell) {
2979
3999
  }
2980
4000
  function assertIndexRows(knowledgeDir) {
2981
4001
  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"));
4002
+ const readmePath = join12(knowledgeDir, "README.md");
4003
+ if (!existsSync6(readmePath)) {
4004
+ 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
4005
  return { ok: false, violations };
2986
4006
  }
2987
4007
  const docs = collectKnowledgeDocs(knowledgeDir);
2988
4008
  const rows = new Set;
2989
- for (const line of readFileSync7(readmePath, "utf8").split(/\r?\n/)) {
4009
+ for (const line of readFileSync10(readmePath, "utf8").split(/\r?\n/)) {
2990
4010
  if (!line.trim().startsWith("|"))
2991
4011
  continue;
2992
4012
  const cells = line.split("|").map((c) => c.trim());
@@ -2998,42 +4018,42 @@ function assertIndexRows(knowledgeDir) {
2998
4018
  }
2999
4019
  for (const doc of docs) {
3000
4020
  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`));
4021
+ 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
4022
  }
3003
4023
  }
3004
4024
  return { ok: violations.length === 0, violations };
3005
4025
  }
3006
4026
  function compoundRefreshScope(harnessDir, projectRoot) {
3007
4027
  return [
3008
- join8(harnessDir, "knowledge"),
3009
- join8(harnessDir, "knowledge", "README.md"),
3010
- join8(projectRoot, "CONCEPTS.md"),
3011
- join8(harnessDir, "status.json")
4028
+ join12(harnessDir, "knowledge"),
4029
+ join12(harnessDir, "knowledge", "README.md"),
4030
+ join12(projectRoot, "CONCEPTS.md"),
4031
+ join12(harnessDir, "status.json")
3012
4032
  ];
3013
4033
  }
3014
4034
  function isFileLikeRoot(root) {
3015
4035
  return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
3016
4036
  }
3017
4037
  function scopeGuard(path, allowedRoots) {
3018
- const resolved = resolve8(path);
4038
+ const resolved = resolve10(path);
3019
4039
  for (const root of allowedRoots) {
3020
- const r = resolve8(root);
4040
+ const r = resolve10(root);
3021
4041
  if (isFileLikeRoot(r)) {
3022
4042
  if (resolved === r)
3023
4043
  return { ok: true, violations: [] };
3024
- } else if (resolved === r || resolved.startsWith(r + sep)) {
4044
+ } else if (resolved === r || resolved.startsWith(r + sep3)) {
3025
4045
  return { ok: true, violations: [] };
3026
4046
  }
3027
4047
  }
3028
4048
  return {
3029
4049
  ok: false,
3030
4050
  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")
4051
+ 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
4052
  ]
3033
4053
  };
3034
4054
  }
3035
4055
  // src/lint.ts
3036
- function violation9(severity, code, message, fix) {
4056
+ function violation11(severity, code, message, fix) {
3037
4057
  return { ok: false, severity, code, message, fix };
3038
4058
  }
3039
4059
  var COMMENT_INTRODUCER = "(?:\\/\\/|\\/\\*|#|;|--|\\s\\*)";
@@ -3076,7 +4096,7 @@ function findTemporaryMarkers(fileText) {
3076
4096
  }
3077
4097
  markers.push({ line: i + 1, text, removalPath });
3078
4098
  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"'));
4099
+ 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
4100
  }
3081
4101
  }
3082
4102
  return { ok: violations.length === 0, violations, markers };
@@ -3123,13 +4143,13 @@ function assertSddTddTriple(reportText) {
3123
4143
  break;
3124
4144
  }
3125
4145
  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'));
4146
+ 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
4147
  }
3128
4148
  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'));
4149
+ 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
4150
  }
3131
4151
  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'));
4152
+ 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
4153
  }
3134
4154
  return { ok: violations.length === 0, violations };
3135
4155
  }
@@ -3170,7 +4190,7 @@ function planQualityBar(planText) {
3170
4190
  if (token !== null) {
3171
4191
  const text = trimmed.length > 80 ? `${trimmed.slice(0, 77)}...` : trimmed;
3172
4192
  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)"));
4193
+ 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
4194
  }
3175
4195
  }
3176
4196
  return { ok: violations.length === 0, violations, findings };
@@ -3182,18 +4202,18 @@ function lintSkillFrontmatter(frontmatterText) {
3182
4202
  const violations = [];
3183
4203
  const fm = parseFrontmatter(frontmatterText);
3184
4204
  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"));
4205
+ 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
4206
  return { ok: false, violations };
3187
4207
  }
3188
4208
  const name = fm.name ?? "";
3189
4209
  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"));
4210
+ 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
4211
  } 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`"));
4212
+ 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
4213
  }
3194
4214
  const description = fm.description ?? "";
3195
4215
  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)"));
4216
+ 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
4217
  } else {
3198
4218
  const stripped = description.replace(/`[^`]*`/g, " ").replace(/'[^']*'/g, " ").replace(/"[^"]*"/g, " ");
3199
4219
  let pronoun = null;
@@ -3204,15 +4224,15 @@ function lintSkillFrontmatter(frontmatterText) {
3204
4224
  break;
3205
4225
  }
3206
4226
  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 …"'));
4227
+ 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
4228
  }
3209
4229
  const start = description.trim().replace(/^[*_#>]+/, "").replace(/^["'`]+/, "").trim();
3210
4230
  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"));
4231
+ 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
4232
  } else {
3213
4233
  const words = description.trim().split(/\s+/).filter(Boolean).length;
3214
4234
  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"));
4235
+ 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
4236
  }
3217
4237
  }
3218
4238
  }
@@ -3259,15 +4279,15 @@ function lintStrategySections(docText) {
3259
4279
  }
3260
4280
  for (const required of REQUIRED_STRATEGY_SECTIONS) {
3261
4281
  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"));
4282
+ 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
4283
  }
3264
4284
  }
3265
4285
  return { ok: violations.length === 0, violations };
3266
4286
  }
3267
4287
  // 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) {
4288
+ import { existsSync as existsSync7 } from "node:fs";
4289
+ import { join as join13 } from "node:path";
4290
+ function violation12(severity, code, message, fix) {
3271
4291
  return { ok: false, severity, code, message, fix };
3272
4292
  }
3273
4293
  var ROLE_MAPPING = [
@@ -3322,19 +4342,19 @@ function validateRoleMapping(rolesDir, options = {}) {
3322
4342
  const violations = [];
3323
4343
  const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
3324
4344
  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`));
4345
+ if (!existsSync7(join13(rolesDir, reference))) {
4346
+ 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
4347
  }
3328
4348
  }
3329
4349
  for (const { family, memberIds } of families) {
3330
4350
  const absent = memberIds.filter((id) => !referenceById.has(id));
3331
4351
  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`));
4352
+ 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
4353
  }
3334
4354
  if (absent.length === 0) {
3335
4355
  const refs = new Set(memberIds.map((id) => referenceById.get(id)));
3336
4356
  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`));
4357
+ 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
4358
  }
3339
4359
  }
3340
4360
  }
@@ -3343,12 +4363,12 @@ function validateRoleMapping(rolesDir, options = {}) {
3343
4363
  for (const row of rows) {
3344
4364
  const existing = tableByRole.get(row.roleId);
3345
4365
  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"));
4366
+ 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
4367
  } else {
3348
4368
  tableByRole.set(row.roleId, table);
3349
4369
  }
3350
4370
  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`));
4371
+ 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
4372
  }
3353
4373
  }
3354
4374
  };
@@ -3356,20 +4376,20 @@ function validateRoleMapping(rolesDir, options = {}) {
3356
4376
  checkParamRoles(qcReviewers, "QC reviewer");
3357
4377
  for (const row of devTrack) {
3358
4378
  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"'));
4379
+ 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
4380
  }
3361
4381
  }
3362
4382
  const indices = qcReviewers.map((r) => r.reviewerIndex).sort((a, b) => a - b);
3363
4383
  const unique = new Set(indices);
3364
4384
  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"));
4385
+ 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
4386
  }
3367
4387
  for (const row of qcReviewers) {
3368
4388
  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"));
4389
+ 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
4390
  }
3371
4391
  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}`));
4392
+ 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
4393
  }
3374
4394
  }
3375
4395
  return { ok: violations.length === 0, violations };
@@ -3408,11 +4428,11 @@ function lintLoadOrder(skillTexts) {
3408
4428
  continue;
3409
4429
  const section = extractLoadOrderSection(text);
3410
4430
  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`));
4431
+ 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
4432
  continue;
3413
4433
  }
3414
4434
  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"));
4435
+ 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
4436
  }
3417
4437
  }
3418
4438
  return { ok: violations.length === 0, violations };
@@ -3458,7 +4478,7 @@ function resolveSkillRoot(host, paths) {
3458
4478
  }
3459
4479
  }
3460
4480
  // src/skill-authoring.ts
3461
- function violation11(severity, code, message, fix) {
4481
+ function violation13(severity, code, message, fix) {
3462
4482
  return { ok: false, severity, code, message, fix };
3463
4483
  }
3464
4484
  var FIVE_QUESTION_SECTIONS = [
@@ -3519,7 +4539,7 @@ function lintFiveQuestion(bodyText, mode = "authoring") {
3519
4539
  const aliases = mode === "runtime" ? RUNTIME_HEADING_ALIASES[section.key] ?? [] : [];
3520
4540
  const covered = headings.some((heading) => heading.includes(label) || aliases.some((alias) => heading.includes(alias)));
3521
4541
  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}`));
4542
+ 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
4543
  }
3524
4544
  }
3525
4545
  return { ok: violations.length === 0, violations };
@@ -3528,13 +4548,19 @@ function resolveAssetPath(skillName, relPath, host) {
3528
4548
  return `skill \`${skillName}\` → ${relPath} (${resolveSkillRoot(host, { skill: skillName, rel: relPath })})`;
3529
4549
  }
3530
4550
  export {
4551
+ writeWorkflowSnapshot,
3531
4552
  writeJson,
3532
4553
  withStatusWriteLock,
3533
4554
  verifyPlanExecutionLease,
4555
+ validateWorkflowSnapshot,
4556
+ validateWorkflowEntry,
4557
+ validateStatusV2,
3534
4558
  validateStatus,
3535
4559
  validateSchemaYaml,
3536
4560
  validateRoleMapping,
4561
+ validateRoadmap,
3537
4562
  validateResidual,
4563
+ validateProjectRegister,
3538
4564
  validatePlanRow,
3539
4565
  validateIntegrationMergeLease,
3540
4566
  validateGitignore,
@@ -3543,6 +4569,7 @@ export {
3543
4569
  validateCompassFrontmatter,
3544
4570
  validateAuditStatusBlocks,
3545
4571
  validateAssignmentFields,
4572
+ unregisterWorkflow,
3546
4573
  techDebtRollup,
3547
4574
  taskReportExists,
3548
4575
  taskBrief,
@@ -3554,16 +4581,22 @@ export {
3554
4581
  scaffoldAuditPlan,
3555
4582
  sameHolderResume,
3556
4583
  reviewPackage,
4584
+ resolveWorkflowDir,
3557
4585
  resolveSpecsDir,
3558
4586
  resolveSkillRoot,
3559
4587
  resolveSddDir,
4588
+ resolveRepoEnforcement,
3560
4589
  resolveProjectRoot,
4590
+ resolveProjectDir,
3561
4591
  resolvePlanDir,
4592
+ resolveMstarcEnforcement,
4593
+ resolveKnowledgeDir,
3562
4594
  resolveIterationDir,
3563
4595
  resolveHarnessDir,
3564
4596
  resolveCompassEnforcement,
3565
4597
  resolveAssetPath,
3566
4598
  releaseLease,
4599
+ registerWorkflow,
3567
4600
  referenceExists,
3568
4601
  redactSecrets,
3569
4602
  readProgressLedger,
@@ -3572,13 +4605,16 @@ export {
3572
4605
  pushCadenceProbe,
3573
4606
  planQualityBar,
3574
4607
  planExecutionLeaseLocations,
4608
+ parseMstarc,
3575
4609
  parseEnforcementFlag,
3576
4610
  parseDesignFrontmatter,
4611
+ parseCompassFrontmatterText,
3577
4612
  parseCompassFrontmatter,
3578
4613
  parseBranchPolicyDirectOnBranch,
3579
4614
  parseAssignmentFields,
3580
4615
  parseAssignmentBranchForms,
3581
4616
  normalizeSeverity,
4617
+ migrateHarnessTree,
3582
4618
  lintStrategySections,
3583
4619
  lintSkillFrontmatter,
3584
4620
  lintLoadOrder,
@@ -3591,6 +4627,7 @@ export {
3591
4627
  findingsCleanupGate,
3592
4628
  findTemporaryMarkers,
3593
4629
  findSimplifyMarkers,
4630
+ findMstarc,
3594
4631
  findEphemeralCitations,
3595
4632
  executionModeToN,
3596
4633
  evaluatePhaseGate,
@@ -3613,15 +4650,30 @@ export {
3613
4650
  assertControlVsFeaturePath,
3614
4651
  assertBranchAlignment,
3615
4652
  assertBaseSha,
3616
- archiveResiduals,
4653
+ applyMigratePlan,
3617
4654
  applyEnforcement,
3618
4655
  antiRecursionPrecheck,
4656
+ _DEFAULT_PROJECT,
4657
+ WORKFLOW_TERMINAL_STATUSES,
4658
+ WORKFLOW_SNAPSHOT_FILE,
4659
+ WORKFLOW_LIFECYCLE_TYPES,
4660
+ WORKFLOW_LIFECYCLE_STATUSES,
3619
4661
  SddScriptError,
3620
4662
  SHARED_FAMILIES,
3621
4663
  SEVERITY_ORDER,
3622
4664
  RUNTIME_HEADING_ALIASES,
3623
4665
  ROLE_MAPPING,
4666
+ ROADMAP_STATUSES,
3624
4667
  QC_REVIEWER_PARAMS,
4668
+ PROJECT_ROADMAP_FILE,
4669
+ PROJECT_REGISTER_FILE,
4670
+ NOTES_LEDGER_FILE,
4671
+ MSTARC_WORKFLOW_DIR_KEY,
4672
+ MSTARC_SECTION,
4673
+ MSTARC_PROJECT_DIR_KEY,
4674
+ MSTARC_HARNESS_DIR_KEY,
4675
+ MSTARC_FILE,
4676
+ MIGRATE_STATUS_FILE,
3625
4677
  KNOWLEDGE_SEVERITIES,
3626
4678
  KNOWLEDGE_RESOLUTION_TYPES,
3627
4679
  KNOWLEDGE_REQUIRED_FIELDS,
@@ -3634,5 +4686,6 @@ export {
3634
4686
  AUDIT_RISKS,
3635
4687
  AUDIT_PRIORITIES,
3636
4688
  AUDIT_EFFORTS,
3637
- AUDIT_CATEGORIES
4689
+ AUDIT_CATEGORIES,
4690
+ ARCHIVED_STATUS_V1_FILE
3638
4691
  };