@mstar-harness/engine 3.1.1 → 3.1.3

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/core.d.ts CHANGED
@@ -83,7 +83,7 @@ export declare function readJson(filePath: string): Record<string, unknown>;
83
83
  * stored state; revisit if the harness moves to a filesystem without
84
84
  * rename-atomicity guarantees.
85
85
  */
86
- export declare function writeJson(filePath: string, value: Record<string, unknown>): void;
86
+ export declare function writeJson<T>(filePath: string, value: T): void;
87
87
  /**
88
88
  * Resolve the project root by walking up from `startDir` (default: cwd) to
89
89
  * the nearest ancestor containing `package.json` or `bun.lock`. Falls back
@@ -173,8 +173,10 @@ export declare function assertTriIdentity(reviewerRoles: readonly string[]): Gat
173
173
  export type ComposeDispatchGateOptions = {
174
174
  /**
175
175
  * Host role-binding field (omp task entry `agent` / opencode `subagent` /
176
- * cursor `subagent_type`); the anti-recursion precheck runs only when
177
- * non-empty.
176
+ * cursor `subagent_type` / dsh `dispatchBinding`). The anti-recursion
177
+ * precheck runs for EVERY Assignment-shaped text: an empty or omitted
178
+ * binding fails closed (`dispatch.anti-recursion.empty-binding`) — the
179
+ * host cannot prove the dispatching agent is not recursing.
178
180
  */
179
181
  agent?: string;
180
182
  /**
@@ -204,7 +206,10 @@ export type ComposeDispatchGateResult = GateResult & {
204
206
  * Assignment-shaped passes silently (`shaped: false`).
205
207
  * 2. `validateAssignmentFields` with `writable: false` when `opts.writable
206
208
  * === false` (read-only roles), else the writable default.
207
- * 3. Anti-recursion precheck when `opts.agent` is non-empty.
209
+ * 3. Anti-recursion precheck runs for every Assignment-shaped text; an
210
+ * empty/omitted host binding fails closed (`dispatch.anti-recursion.
211
+ * empty-binding`), a binding equal to `Execute as` is the existing
212
+ * `dispatch.anti-recursion.self-type` critical.
208
213
  * 4. Default-branch gate for writable text: the branch comes from the
209
214
  * Assignment's own branch forms (create-form name / Working branch /
210
215
  * Branch policy branch), else `$MSTAR_WORKING_BRANCH`; a well-formed
@@ -221,7 +226,11 @@ export declare function composeDispatchGate(text: string, opts?: ComposeDispatch
221
226
  * Anti-recursion precheck (NEVER red line, mstar-dispatch-gates § 承接方反递归
222
227
  * 红线): a leaf executor MUST NOT invoke a Task/subagent whose role-binding
223
228
  * field (`subagent_type` / `agent` / `subagent`) equals its own `Execute as`.
224
- * Comparison is case-insensitive after trim; an empty binding is not a
225
- * self-recursion (its presence is the field gate's job, not this precheck).
229
+ * Comparison is case-insensitive after trim. An EMPTY binding fails closed
230
+ * (`dispatch.anti-recursion.empty-binding`, critical): the host cannot
231
+ * report which agent is calling, so anti-recursion cannot be proven — the
232
+ * dispatch must not proceed as if the NEVER red line held. An empty
233
+ * `executeAs` with a set binding stays ok (field presence is
234
+ * `validateAssignmentFields`' job, not this precheck).
226
235
  */
227
236
  export declare function antiRecursionPrecheck(subagentType: string, executeAs: string): GateResult;
package/dist/engine.js CHANGED
@@ -170,7 +170,7 @@ function isAtOrBelow(dir, root) {
170
170
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
171
171
  }
172
172
  // src/path.ts
173
- import { mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
173
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync3, realpathSync, statSync as statSync2 } from "node:fs";
174
174
  import { execFileSync } from "node:child_process";
175
175
  import { basename as basename2, dirname as dirname3, isAbsolute as isAbsolute2, join as join3, relative as relative2, resolve as resolve3 } from "node:path";
176
176
  function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
@@ -425,6 +425,23 @@ function assertPlanWritingPath(planPath, harnessDir) {
425
425
  fix: `write the plan under ${planDir}`
426
426
  };
427
427
  }
428
+ if (existsSync2(planAbs)) {
429
+ try {
430
+ const canonicalPlan = realpathSync(planAbs);
431
+ const canonicalPlanDir = existsSync2(planDir) ? realpathSync(planDir) : resolve3(planDir);
432
+ const canonicalRel = relative2(canonicalPlanDir, canonicalPlan);
433
+ const canonicalInside = canonicalRel === "" || !canonicalRel.startsWith("..") && !isAbsolute2(canonicalRel);
434
+ if (!canonicalInside) {
435
+ return {
436
+ ok: false,
437
+ severity: "high",
438
+ code: "plan-path.symlink-escape",
439
+ message: `plan file ${planAbs} resolves to ${canonicalPlan}, outside {PLAN_DIR} (${canonicalPlanDir})`,
440
+ fix: `write the plan under ${planDir}`
441
+ };
442
+ }
443
+ } catch {}
444
+ }
428
445
  return {
429
446
  ok: true,
430
447
  severity: "low",
@@ -455,7 +472,7 @@ function hasFiles(dir) {
455
472
  }
456
473
  }
457
474
  // src/status.ts
458
- import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync } from "node:fs";
475
+ import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, realpathSync as realpathSync2 } from "node:fs";
459
476
  import { dirname as dirname5, join as join6, resolve as resolve5, sep } from "node:path";
460
477
 
461
478
  // src/lease.ts
@@ -910,10 +927,8 @@ function composeDispatchGate(text, opts = {}) {
910
927
  const violations = [];
911
928
  const writable = opts.writable !== false;
912
929
  violations.push(...validateAssignmentFields(text, { writable }).violations);
913
- const agent = (opts.agent ?? "").trim();
914
- if (agent !== "") {
915
- violations.push(...antiRecursionPrecheck(agent, parseAssignmentFields(text).executeAs ?? "").violations);
916
- }
930
+ const agent = opts.agent ?? "";
931
+ violations.push(...antiRecursionPrecheck(agent, parseAssignmentFields(text).executeAs ?? "").violations);
917
932
  if (writable) {
918
933
  const forms = parseAssignmentBranchForms(text);
919
934
  const branch = forms.createForm?.name ?? forms.workingBranch ?? forms.directOn?.branch ?? process.env.MSTAR_WORKING_BRANCH;
@@ -932,7 +947,15 @@ function composeDispatchGate(text, opts = {}) {
932
947
  function antiRecursionPrecheck(subagentType, executeAs) {
933
948
  const binding = subagentType.trim().toLowerCase();
934
949
  const role = executeAs.trim().toLowerCase();
935
- if (binding !== "" && binding === role) {
950
+ if (binding === "") {
951
+ return {
952
+ ok: false,
953
+ violations: [
954
+ violation2("critical", "dispatch.anti-recursion.empty-binding", `empty host role binding — the host cannot report which agent is calling, so anti-recursion cannot be proven (a dispatch could silently recurse)`, "set the host role-binding field (omp task entry `agent` / opencode `subagent` / cursor `subagent_type` / dsh `dispatchBinding`) before dispatching")
955
+ ]
956
+ };
957
+ }
958
+ if (binding === role) {
936
959
  return {
937
960
  ok: false,
938
961
  violations: [
@@ -1294,7 +1317,7 @@ function validateStatusV2(docOrPath, opts = {}) {
1294
1317
  if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
1295
1318
  let realHarnessDir = null;
1296
1319
  try {
1297
- realHarnessDir = realpathSync(harnessDir);
1320
+ realHarnessDir = realpathSync2(harnessDir);
1298
1321
  } catch {}
1299
1322
  for (const entry of doc.workflows) {
1300
1323
  if (!isPlainObject3(entry) || typeof entry.dir !== "string")
@@ -1304,7 +1327,7 @@ function validateStatusV2(docOrPath, opts = {}) {
1304
1327
  const label = typeof entry.id === "string" ? entry.id : relSnapshot;
1305
1328
  let physical;
1306
1329
  try {
1307
- physical = realpathSync(snapshotPath);
1330
+ physical = realpathSync2(snapshotPath);
1308
1331
  } catch {
1309
1332
  violations.push(violation4("high", "status.workflow.snapshot-missing", `workflows[] lists ${JSON.stringify(label)} but its snapshot does not exist at ${JSON.stringify(relSnapshot)} — the root holds active lifecycles only; unregister the id when its snapshot is removed`));
1310
1333
  continue;
@@ -1327,41 +1350,42 @@ function validateStatusV2(docOrPath, opts = {}) {
1327
1350
  violations.push(violation4("medium", "status.workflow.mismatched-type", `workflows[] entry ${JSON.stringify(label)} type ${JSON.stringify(entry.type)} does not match its snapshot type ${JSON.stringify(snapshot.type)} — the root entry mirrors the snapshot; align them`));
1328
1351
  }
1329
1352
  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`));
1353
+ violations.push(violation4("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — workflow ${JSON.stringify(label)} collided with another writer (e.g. a concurrent/re-run \`audit promote\` with the same workflow id rewrote the snapshot); the root entry mirrors the snapshot align them or remove the colliding workflow`));
1331
1354
  }
1332
1355
  }
1333
1356
  }
1334
1357
  return { ok: violations.length === 0, violations };
1335
1358
  }
1336
1359
  var validateStatus = validateStatusV2;
1360
+ function registerWorkflowEntryLocked(statusPath, entry) {
1361
+ const harnessDir = dirname5(statusPath);
1362
+ const current = readJson(statusPath);
1363
+ const fresh = Object.keys(current).length === 0;
1364
+ const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
1365
+ if (!fresh && !Array.isArray(doc.workflows)) {
1366
+ throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
1367
+ }
1368
+ const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
1369
+ if (existing >= 0) {
1370
+ doc.workflows[existing] = entry;
1371
+ } else {
1372
+ doc.workflows.push(entry);
1373
+ }
1374
+ doc.updated_at = todayString();
1375
+ const gate = validateStatusV2(doc, { harnessDir });
1376
+ if (!gate.ok) {
1377
+ throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
1378
+ }
1379
+ writeJson(statusPath, doc);
1380
+ return doc;
1381
+ }
1337
1382
  async function registerWorkflow(root, entry) {
1338
1383
  const entryGate = validateWorkflowEntry(entry);
1339
1384
  if (!entryGate.ok) {
1340
1385
  throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
1341
1386
  }
1342
1387
  const statusPath = resolve5(root);
1343
- const harnessDir = dirname5(statusPath);
1344
- return withStatusWriteLock(statusPath, () => {
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("; ")}`);
1361
- }
1362
- writeJson(statusPath, doc);
1363
- return doc;
1364
- });
1388
+ return withStatusWriteLock(statusPath, () => registerWorkflowEntryLocked(statusPath, entry));
1365
1389
  }
1366
1390
  async function unregisterWorkflow(root, id) {
1367
1391
  if (typeof id !== "string" || id.trim() === "") {
@@ -1394,7 +1418,7 @@ async function unregisterWorkflow(root, id) {
1394
1418
  }
1395
1419
  function resolveCompassEnforcement(harnessDir) {
1396
1420
  const iterationsDir = resolveIterationDir(harnessDir);
1397
- if (!existsSync2(iterationsDir))
1421
+ if (!existsSync3(iterationsDir))
1398
1422
  return { hard: false, source: "none" };
1399
1423
  let entries;
1400
1424
  try {
@@ -1406,7 +1430,7 @@ function resolveCompassEnforcement(harnessDir) {
1406
1430
  if (!entry.isDirectory())
1407
1431
  continue;
1408
1432
  const compassPath = join6(iterationsDir, entry.name, "delivery-compass.md");
1409
- if (!existsSync2(compassPath))
1433
+ if (!existsSync3(compassPath))
1410
1434
  continue;
1411
1435
  let content;
1412
1436
  try {
@@ -1442,7 +1466,7 @@ function resolveRepoEnforcement(harnessDir) {
1442
1466
  }
1443
1467
  // src/worktree.ts
1444
1468
  import { execFileSync as execFileSync2 } from "node:child_process";
1445
- import { existsSync as existsSync3 } from "node:fs";
1469
+ import { existsSync as existsSync4 } from "node:fs";
1446
1470
  import { isAbsolute as isAbsolute4, resolve as resolve6 } from "node:path";
1447
1471
  var DEFAULT_PROBE_TIMEOUT_MS = 1e4;
1448
1472
  function probeTimeoutMs() {
@@ -1497,7 +1521,7 @@ function l1PreDispatchCheck(input, opts = {}) {
1497
1521
  if (controlWorktreePath !== "" && leaseWorktreePath !== "" && resolve6(controlWorktreePath) === resolve6(leaseWorktreePath)) {
1498
1522
  violations.push(violation5("critical", "worktree.l1.lease-equals-control", `execution_lease.worktree_path "${leaseWorktreePath}" equals metadata.control_worktree_path — the feature worktree MUST differ from the control worktree (L1 isolation; product edits never land in the control checkout)`, "use a distinct feature worktree for the plan (git worktree add <path> <branch>) and update the lease"));
1499
1523
  }
1500
- if (leaseWorktreePath !== "" && !existsSync3(leaseWorktreePath)) {
1524
+ if (leaseWorktreePath !== "" && !existsSync4(leaseWorktreePath)) {
1501
1525
  violations.push(violation5("high", "worktree.l1.feature-missing", `feature worktree directory "${leaseWorktreePath}" does not exist for plan "${planId}"`, `create it before dispatch: git worktree add ${leaseWorktreePath} <working-branch>`));
1502
1526
  } else if (leaseWorktreePath !== "" && leaseWorkingBranch !== "") {
1503
1527
  const probe = probeBranch(leaseWorktreePath, opts);
@@ -1531,7 +1555,7 @@ function l2PreDispatchCheck(input, opts = {}) {
1531
1555
  return;
1532
1556
  }
1533
1557
  seenPaths.add(normalized);
1534
- if (!existsSync3(track.worktreePath)) {
1558
+ if (!existsSync4(track.worktreePath)) {
1535
1559
  violations.push(violation5("high", "worktree.l2.track-missing", `track worktree directory "${track.worktreePath}" does not exist`, `create it before dispatch: git worktree add ${track.worktreePath} ${track.workingBranch}`));
1536
1560
  return;
1537
1561
  }
@@ -1594,7 +1618,7 @@ function singleReviewSnapshot(assignments) {
1594
1618
  }
1595
1619
  // src/sdd.ts
1596
1620
  import { execFileSync as execFileSync3 } from "node:child_process";
1597
- import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync2, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
1621
+ import { mkdirSync as mkdirSync5, readdirSync as readdirSync3, readFileSync as readFileSync5, realpathSync as realpathSync3, statSync as statSync4, writeFileSync as writeFileSync3 } from "node:fs";
1598
1622
  import { basename as basename3, dirname as dirname6, isAbsolute as isAbsolute5, join as join7, resolve as resolve7 } from "node:path";
1599
1623
  class SddScriptError extends Error {
1600
1624
  exitCode;
@@ -1671,8 +1695,8 @@ function isLinkedWorktree(root) {
1671
1695
  if (gitDir.includes("/.git/worktrees/") || gitDir.includes("/worktrees/"))
1672
1696
  return true;
1673
1697
  try {
1674
- const gdParent = realpathSync2(dirname6(gitDir));
1675
- const cmAbs = realpathSync2(common);
1698
+ const gdParent = realpathSync3(dirname6(gitDir));
1699
+ const cmAbs = realpathSync3(common);
1676
1700
  return join7(gdParent, basename3(gitDir)) !== cmAbs && gitDir !== cmAbs;
1677
1701
  } catch {
1678
1702
  return false;
@@ -1690,10 +1714,10 @@ function sddWorkspace(planId, opts = {}) {
1690
1714
  if (!isDirectory2(controlRoot)) {
1691
1715
  throw new SddScriptError(`mstar sdd workspace: CONTROL_ROOT / MSTAR_CONTROL_ROOT is not a directory: ${controlRoot}`, 1);
1692
1716
  }
1693
- root = realpathSync2(controlRoot);
1717
+ root = realpathSync3(controlRoot);
1694
1718
  } else {
1695
1719
  const topLevel = gitOut(cwd, ["rev-parse", "--show-toplevel"]);
1696
- root = realpathSync2(topLevel ?? cwd);
1720
+ root = realpathSync3(topLevel ?? cwd);
1697
1721
  }
1698
1722
  if (!controlRoot && isLinkedWorktree(root)) {
1699
1723
  throw new SddScriptError(`mstar sdd workspace: linked worktree at ${root} has no {HARNESS_DIR}/status.json (default gitignore).
@@ -1727,7 +1751,7 @@ function sddWorkspace(planId, opts = {}) {
1727
1751
  mkdirSync5(sddDir, { recursive: true });
1728
1752
  writeFileSync3(join7(sddDir, ".gitignore"), `*
1729
1753
  `);
1730
- return realpathSync2(sddDir);
1754
+ return realpathSync3(sddDir);
1731
1755
  }
1732
1756
  function taskBrief(planFile, taskN, outFile, opts = {}) {
1733
1757
  if (!planFile || !Number.isInteger(taskN) || taskN < 1) {
@@ -1879,7 +1903,7 @@ function implementerSessionStickyRules(input) {
1879
1903
  return { resume: true, reason: `sticky resume OK: host_agent_id ${session.host_agent_id}, next task ${nextTask}` };
1880
1904
  }
1881
1905
  // src/iteration.ts
1882
- import { existsSync as existsSync4, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "node:fs";
1906
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "node:fs";
1883
1907
  import { join as join8 } from "node:path";
1884
1908
  var COMPASS_STATUSES = ["active", "locked", "completed"];
1885
1909
  var DATE_RE2 = /^\d{4}-\d{2}-\d{2}$/;
@@ -2108,7 +2132,7 @@ function pushCadenceProbe(ciRunning, reviewWaveActive) {
2108
2132
  return { ok: violations.length === 0, violations };
2109
2133
  }
2110
2134
  function assertIndexRowObligations(iterationsDir) {
2111
- if (!existsSync4(iterationsDir)) {
2135
+ if (!existsSync5(iterationsDir)) {
2112
2136
  return {
2113
2137
  ok: false,
2114
2138
  violations: [
@@ -2116,9 +2140,9 @@ function assertIndexRowObligations(iterationsDir) {
2116
2140
  ]
2117
2141
  };
2118
2142
  }
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();
2143
+ const iterationIds = readdirSync4(iterationsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()).filter((entry) => existsSync5(join8(iterationsDir, entry.name, COMPASS_FILE))).map((entry) => entry.name).sort();
2120
2144
  const readmePath = join8(iterationsDir, INDEX_README);
2121
- if (!existsSync4(readmePath)) {
2145
+ if (!existsSync5(readmePath)) {
2122
2146
  return {
2123
2147
  ok: false,
2124
2148
  violations: [
@@ -2209,7 +2233,7 @@ function parseFlowArray(raw, filePath) {
2209
2233
  return items;
2210
2234
  }
2211
2235
  // src/project.ts
2212
- import { existsSync as existsSync5, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
2236
+ import { existsSync as existsSync6, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "node:fs";
2213
2237
  import { join as join9 } from "node:path";
2214
2238
  var PROJECT_ROADMAP_FILE = "roadmap.md";
2215
2239
  var PROJECT_REFERENCES_DIR = "references";
@@ -2393,7 +2417,7 @@ function techDebtRollup(projectDir) {
2393
2417
  if (!project.isDirectory())
2394
2418
  continue;
2395
2419
  const registerPath = join9(projectDir, project.name, PROJECT_REGISTER_FILE);
2396
- if (!existsSync5(registerPath))
2420
+ if (!existsSync6(registerPath))
2397
2421
  continue;
2398
2422
  let register;
2399
2423
  try {
@@ -2967,9 +2991,11 @@ async function applyMigratePlan(plan) {
2967
2991
  if (!gate2.ok) {
2968
2992
  throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
2969
2993
  }
2970
- const filePath = projectTargetOf(plan.register.file);
2971
- mkdirSync6(dirname7(filePath), { recursive: true });
2972
- writeJson(filePath, plan.register.data);
2994
+ if (Object.keys(plan.register.data.entries ?? {}).length > 0) {
2995
+ const filePath = projectTargetOf(plan.register.file);
2996
+ mkdirSync6(dirname7(filePath), { recursive: true });
2997
+ writeJson(filePath, plan.register.data);
2998
+ }
2973
2999
  }
2974
3000
  if (plan.roadmap !== null) {
2975
3001
  const filePath = projectTargetOf(plan.roadmap.file);
@@ -3396,8 +3422,8 @@ function completenessLevel(frontmatterText, checklist) {
3396
3422
  return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
3397
3423
  }
3398
3424
  // src/audit.ts
3399
- import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
3400
- import { join as join11, resolve as resolve9 } from "node:path";
3425
+ import { existsSync as existsSync7, mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync5 } from "node:fs";
3426
+ import { basename as basename4, join as join11, resolve as resolve9, sep as sep3 } from "node:path";
3401
3427
  function violation9(severity, code, message, fix) {
3402
3428
  return { ok: false, severity, code, message, fix };
3403
3429
  }
@@ -3469,74 +3495,6 @@ function validateAuditStatusBlocks(planText) {
3469
3495
  });
3470
3496
  return { ok: violations.length === 0, violations };
3471
3497
  }
3472
- var WHOLE_MATCH_PATTERNS = [
3473
- { type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
3474
- { type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
3475
- { type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
3476
- { type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
3477
- { type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
3478
- { type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
3479
- ];
3480
- var VALUE_PATTERNS = [
3481
- {
3482
- typeOf: (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[_-]+/g, "-"),
3483
- re: /(["']?)\b(password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|secret|token)\b(["']?)(\s*[:=]\s*)("[^"\n]{8,}"|'[^'\n]{8,}'|[A-Za-z0-9_./+\-=]{16,})/gi
3484
- }
3485
- ];
3486
- function buildLineStarts(text) {
3487
- const starts = [0];
3488
- for (let i = 0;i < text.length; i++) {
3489
- if (text[i] === `
3490
- `)
3491
- starts.push(i + 1);
3492
- }
3493
- return starts;
3494
- }
3495
- function lineAt(starts, index) {
3496
- let lo = 0;
3497
- let hi = starts.length - 1;
3498
- while (lo < hi) {
3499
- const mid = lo + hi + 1 >> 1;
3500
- if (starts[mid] <= index)
3501
- lo = mid;
3502
- else
3503
- hi = mid - 1;
3504
- }
3505
- return lo + 1;
3506
- }
3507
- function redactSecrets(text, filePath) {
3508
- const starts = buildLineStarts(text);
3509
- const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
3510
- const replacements = [];
3511
- const findings = [];
3512
- for (const pattern of WHOLE_MATCH_PATTERNS) {
3513
- for (const match of text.matchAll(pattern.re)) {
3514
- if (match.index === undefined)
3515
- continue;
3516
- replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
3517
- findings.push({ line: lineAt(starts, match.index), type: pattern.type });
3518
- }
3519
- }
3520
- for (const pattern of VALUE_PATTERNS) {
3521
- for (const match of text.matchAll(pattern.re)) {
3522
- if (match.index === undefined)
3523
- continue;
3524
- const type = pattern.typeOf(match[2]);
3525
- const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
3526
- replacements.push({ index: match.index, length: match[0].length, text: replacement });
3527
- findings.push({ line: lineAt(starts, match.index), type });
3528
- }
3529
- }
3530
- replacements.sort((a, b) => b.index - a.index);
3531
- let out = text;
3532
- for (const r of replacements)
3533
- out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
3534
- const deduped = new Map;
3535
- for (const f of findings)
3536
- deduped.set(`${f.line}:${f.type}`, f);
3537
- const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
3538
- return { text: out, findings: sorted };
3539
- }
3540
3498
  function slugify(title) {
3541
3499
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3542
3500
  }
@@ -3673,9 +3631,140 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
3673
3631
  }));
3674
3632
  return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
3675
3633
  }
3634
+ async function promoteAuditPlans(outDir, selected, options) {
3635
+ if (selected.length === 0) {
3636
+ throw new Error("promoteAuditPlans: at least one plan id must be selected (--plans 001,002,…)");
3637
+ }
3638
+ if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
3639
+ throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
3640
+ }
3641
+ const workflowId = options.workflowId ?? basename4(resolve9(outDir));
3642
+ assertSafePathComponent(workflowId, "workflow id");
3643
+ const harnessDir = resolve9(options.harnessDir);
3644
+ const statusPath = join11(harnessDir, "status.json");
3645
+ const workflowDir = join11(harnessDir, "workflows", workflowId);
3646
+ const snapshotPath = join11(workflowDir, WORKFLOW_SNAPSHOT_FILE);
3647
+ const planFiles = resolveSelectedPlanFiles(outDir, selected);
3648
+ const indexRows = readExecutionOrderIndex(outDir);
3649
+ const plans = planFiles.map((planFile) => {
3650
+ const stem = planFile.replace(/\.md$/, "");
3651
+ const num = stem.slice(0, 3);
3652
+ const indexRow = indexRows.get(num);
3653
+ const title = indexRow?.title ?? readPlanFileSummary(join11(outDir, planFile)).title;
3654
+ return {
3655
+ id: stem,
3656
+ title,
3657
+ file: planFileRel(outDir, planFile),
3658
+ status: "Todo"
3659
+ };
3660
+ });
3661
+ const now = new Date;
3662
+ const snapshot = {
3663
+ schema_version: 1,
3664
+ id: workflowId,
3665
+ type: "plan",
3666
+ status: "running",
3667
+ started_at: now.toISOString(),
3668
+ updated_at: now.toISOString().slice(0, 10),
3669
+ plans
3670
+ };
3671
+ const entry = {
3672
+ id: workflowId,
3673
+ type: "plan",
3674
+ started_at: snapshot.started_at,
3675
+ dir: `workflows/${workflowId}`
3676
+ };
3677
+ const entryGate = validateWorkflowEntry(entry);
3678
+ if (!entryGate.ok) {
3679
+ throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
3680
+ }
3681
+ await withStatusWriteLock(statusPath, () => {
3682
+ if (existsSync7(snapshotPath)) {
3683
+ throw new Error(`refusing to promote audit plans: workflow ${JSON.stringify(workflowId)} already exists ` + `(snapshot at ${snapshotPath}) — re-promote would drop its registered plan rows; ` + `remove that workflow before promoting again`);
3684
+ }
3685
+ mkdirSync7(workflowDir, { recursive: true });
3686
+ try {
3687
+ writeJson(snapshotPath, snapshot);
3688
+ registerWorkflowEntryLocked(statusPath, entry);
3689
+ } catch (error) {
3690
+ rmSync(snapshotPath, { force: true });
3691
+ try {
3692
+ if (readdirSync7(workflowDir).length === 0) {
3693
+ rmdirSync2(workflowDir);
3694
+ }
3695
+ } catch {}
3696
+ throw error;
3697
+ }
3698
+ return { workflowId, snapshotPath };
3699
+ });
3700
+ return { workflowId, snapshotPath };
3701
+ }
3702
+ function resolveSelectedPlanFiles(outDir, selected) {
3703
+ const files = readdirSync7(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
3704
+ const byNum = new Map;
3705
+ const byStem = new Map;
3706
+ for (const file of files) {
3707
+ const stem = file.replace(/\.md$/, "");
3708
+ if (!byNum.has(stem.slice(0, 3))) {
3709
+ byNum.set(stem.slice(0, 3), file);
3710
+ }
3711
+ byStem.set(stem, file);
3712
+ }
3713
+ const resolved = [];
3714
+ const seen = new Set;
3715
+ for (const id of selected) {
3716
+ const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
3717
+ if (file === undefined) {
3718
+ throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve9(outDir)}`);
3719
+ }
3720
+ if (!seen.has(file)) {
3721
+ seen.add(file);
3722
+ resolved.push(file);
3723
+ }
3724
+ }
3725
+ return resolved;
3726
+ }
3727
+ function readExecutionOrderIndex(outDir) {
3728
+ const readmePath = join11(outDir, "README.md");
3729
+ let text;
3730
+ try {
3731
+ text = readFileSync9(readmePath, "utf8");
3732
+ } catch {
3733
+ return new Map;
3734
+ }
3735
+ const rows = new Map;
3736
+ const lines = text.split(`
3737
+ `);
3738
+ let inSection = false;
3739
+ for (const line of lines) {
3740
+ if (/^##\s+Execution order & status/.test(line)) {
3741
+ inSection = true;
3742
+ continue;
3743
+ }
3744
+ if (inSection && /^#/.test(line)) {
3745
+ break;
3746
+ }
3747
+ if (!inSection)
3748
+ continue;
3749
+ const cells = line.split(/(?<!\\)\|/).map((c) => c.trim());
3750
+ if (cells.length >= 3 && /^\d{3}$/.test(cells[1])) {
3751
+ rows.set(cells[1], { title: cells[2].replace(/\\\|/g, "|") });
3752
+ }
3753
+ }
3754
+ return rows;
3755
+ }
3756
+ function planFileRel(outDir, planFile) {
3757
+ const resolved = resolve9(outDir);
3758
+ const parts = resolved.split(sep3);
3759
+ const plansIdx = parts.lastIndexOf("plans");
3760
+ if (plansIdx >= 0) {
3761
+ return `${parts.slice(plansIdx + 1).join(sep3)}${sep3}${planFile}`;
3762
+ }
3763
+ return planFile;
3764
+ }
3676
3765
  // src/compound.ts
3677
- import { existsSync as existsSync6, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
3678
- import { basename as basename4, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
3766
+ import { existsSync as existsSync8, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
3767
+ import { basename as basename5, isAbsolute as isAbsolute7, join as join12, relative as relative4, resolve as resolve10, sep as sep4 } from "node:path";
3679
3768
  function violation10(severity, code, message, fix) {
3680
3769
  return { ok: false, severity, code, message, fix };
3681
3770
  }
@@ -3982,7 +4071,7 @@ function referenceExists(repoRoot, docText) {
3982
4071
  for (const { ref, isSymbol, module } of refs) {
3983
4072
  if (!isSymbol || module === undefined) {
3984
4073
  const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
3985
- if (existsSync6(resolve10(repoRoot, candidate))) {
4074
+ if (existsSync8(resolve10(repoRoot, candidate))) {
3986
4075
  checked++;
3987
4076
  } else {
3988
4077
  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"));
@@ -4013,7 +4102,7 @@ function collectKnowledgeDocs(dir) {
4013
4102
  if (entry.isDirectory()) {
4014
4103
  stack.push(full);
4015
4104
  } else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
4016
- docs.push(relative4(dir, full).split(sep3).join("/"));
4105
+ docs.push(relative4(dir, full).split(sep4).join("/"));
4017
4106
  }
4018
4107
  }
4019
4108
  }
@@ -4030,7 +4119,7 @@ function normalizeIndexRef(cell) {
4030
4119
  function assertIndexRows(knowledgeDir) {
4031
4120
  const violations = [];
4032
4121
  const readmePath = join12(knowledgeDir, "README.md");
4033
- if (!existsSync6(readmePath)) {
4122
+ if (!existsSync8(readmePath)) {
4034
4123
  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"));
4035
4124
  return { ok: false, violations };
4036
4125
  }
@@ -4062,7 +4151,7 @@ function compoundRefreshScope(harnessDir, projectRoot) {
4062
4151
  ];
4063
4152
  }
4064
4153
  function isFileLikeRoot(root) {
4065
- return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
4154
+ return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename5(root));
4066
4155
  }
4067
4156
  function scopeGuard(path, allowedRoots) {
4068
4157
  const resolved = resolve10(path);
@@ -4071,7 +4160,7 @@ function scopeGuard(path, allowedRoots) {
4071
4160
  if (isFileLikeRoot(r)) {
4072
4161
  if (resolved === r)
4073
4162
  return { ok: true, violations: [] };
4074
- } else if (resolved === r || resolved.startsWith(r + sep3)) {
4163
+ } else if (resolved === r || resolved.startsWith(r + sep4)) {
4075
4164
  return { ok: true, violations: [] };
4076
4165
  }
4077
4166
  }
@@ -4315,7 +4404,7 @@ function lintStrategySections(docText) {
4315
4404
  return { ok: violations.length === 0, violations };
4316
4405
  }
4317
4406
  // src/roles.ts
4318
- import { existsSync as existsSync7 } from "node:fs";
4407
+ import { existsSync as existsSync9 } from "node:fs";
4319
4408
  import { join as join13 } from "node:path";
4320
4409
  function violation12(severity, code, message, fix) {
4321
4410
  return { ok: false, severity, code, message, fix };
@@ -4372,7 +4461,7 @@ function validateRoleMapping(rolesDir, options = {}) {
4372
4461
  const violations = [];
4373
4462
  const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
4374
4463
  for (const { agentId, reference } of mapping) {
4375
- if (!existsSync7(join13(rolesDir, reference))) {
4464
+ if (!existsSync9(join13(rolesDir, reference))) {
4376
4465
  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`));
4377
4466
  }
4378
4467
  }
@@ -4628,11 +4717,11 @@ export {
4628
4717
  releaseLease,
4629
4718
  registerWorkflow,
4630
4719
  referenceExists,
4631
- redactSecrets,
4632
4720
  readProgressLedger,
4633
4721
  readJson,
4634
4722
  readHarnessVersion,
4635
4723
  pushCadenceProbe,
4724
+ promoteAuditPlans,
4636
4725
  planQualityBar,
4637
4726
  planExecutionLeaseLocations,
4638
4727
  parseMstarc,
package/dist/index.d.ts CHANGED
@@ -46,8 +46,8 @@ export type { MigrateNotesFile, MigrateOptions, MigratePlan, MigrateRegister, Mi
46
46
  export { ARCHIVED_STATUS_V1_FILE, MIGRATE_STATUS_FILE, NOTES_LEDGER_FILE, applyMigratePlan, migrateHarnessTree, } from "./migrate.js";
47
47
  export type { CompletenessItem, CompletenessLevel, CompletenessPlaceholder, CompletenessResult, DesignFrontmatter, } from "./design-md.js";
48
48
  export { assertLightDarkParity, completenessLevel, parseDesignFrontmatter, validateDesignTokenFrontmatter, } from "./design-md.js";
49
- export type { AuditCategory, AuditEffort, AuditFinding, AuditPriority, AuditRisk, RedactResult, ScaffoldAuditPlanOptions, ScaffoldAuditPlanResult, SecretFinding, } from "./audit.js";
50
- export { AUDIT_CATEGORIES, AUDIT_EFFORTS, AUDIT_PRIORITIES, AUDIT_RISKS, redactSecrets, scaffoldAuditPlan, validateAuditStatusBlocks, } from "./audit.js";
49
+ export type { AuditCategory, AuditEffort, AuditFinding, AuditPriority, AuditRisk, PromoteAuditPlansOptions, RedactResult, ScaffoldAuditPlanOptions, ScaffoldAuditPlanResult, SecretFinding, } from "./audit.js";
50
+ export { AUDIT_CATEGORIES, AUDIT_EFFORTS, AUDIT_PRIORITIES, AUDIT_RISKS, promoteAuditPlans, scaffoldAuditPlan, validateAuditStatusBlocks, } from "./audit.js";
51
51
  export type { ReferenceCheckResult } from "./compound.js";
52
52
  export { KNOWLEDGE_BUG_PROBLEM_TYPES, KNOWLEDGE_CATEGORY_MAP, KNOWLEDGE_KNOWLEDGE_PROBLEM_TYPES, KNOWLEDGE_PROBLEM_TYPES, KNOWLEDGE_REQUIRED_FIELDS, KNOWLEDGE_RESOLUTION_TYPES, KNOWLEDGE_SEVERITIES, assertIndexRows, compoundRefreshScope, referenceExists, scopeGuard, validateSchemaYaml, } from "./compound.js";
53
53
  export type { EphemeralCitation, PlanQualityFinding, PlanQualityResult, SimplifyMarker, TemporaryMarker, TemporaryMarkerResult, } from "./lint.js";