@mstar-harness/engine 3.1.2 → 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/audit.d.ts CHANGED
@@ -97,3 +97,45 @@ export type ScaffoldAuditPlanResult = {
97
97
  * findings render in the "considered and rejected" section.
98
98
  */
99
99
  export declare function scaffoldAuditPlan(outDir: string, findings: readonly AuditFinding[], options?: ScaffoldAuditPlanOptions): ScaffoldAuditPlanResult;
100
+ /** Options for `promoteAuditPlans`. `harnessDir` is required — the snapshot
101
+ * and `status.json` live under the harness, never beside the audit dir. */
102
+ export type PromoteAuditPlansOptions = {
103
+ /** Absolute harness dir that contains `status.json` + `workflows/`. Required. */
104
+ harnessDir: string;
105
+ /** Default: basename of `outDir` (e.g. `audit-2026-08-22`). */
106
+ workflowId?: string;
107
+ };
108
+ /**
109
+ * Promote selected audit plans into the v2 workflow lifecycle as a
110
+ * `type: "plan"` workflow (mstar-audit Handoff): write the workflow
111
+ * snapshot FIRST (with one Todo PlanRow per selected file), then register
112
+ * the workflow entry — `validateStatusV2` validates the full status doc
113
+ * including the per-snapshot existence check, so the snapshot must exist
114
+ * before the registration. Plan rows are built from the README index
115
+ * `## Execution order & status` columns (Plan/Title), falling back to the
116
+ * private `readPlanFileSummary` only when the index lacks the row.
117
+ *
118
+ * Run-once semantics: a workflow id whose snapshot already exists refuses
119
+ * the promote (re-promote would drop its registered plan rows); remove
120
+ * that workflow first.
121
+ *
122
+ * The re-promote guard, the snapshot write, and the root upsert run in ONE
123
+ * atomic section under the root `withStatusWriteLock(statusPath)` — the
124
+ * same root lock `registerWorkflow` uses — so the guard is check-then-act
125
+ * safe: two concurrent same-id promotes cannot both pass it (one writes
126
+ * and registers; the other re-checks under the lock and refuses). The
127
+ * snapshot is written directly with `writeJson` under the root lock (never
128
+ * a nested `writeWorkflowSnapshot` — its own snapshot-dir lock would be a
129
+ * second serialization point; the root lock must be THE serialization
130
+ * point). The root upsert replicates `registerWorkflow` semantics inline
131
+ * via the shared `registerWorkflowEntryLocked` helper (calling
132
+ * `registerWorkflow` itself would re-enter the non-reentrant root lock).
133
+ *
134
+ * On any failure inside the lock, the partial snapshot + now-empty
135
+ * workflow dir are rolled back so a retry converges after the root
136
+ * conflict is resolved.
137
+ */
138
+ export declare function promoteAuditPlans(outDir: string, selected: readonly string[], options: PromoteAuditPlansOptions): Promise<{
139
+ workflowId: string;
140
+ snapshotPath: string;
141
+ }>;
package/dist/audit.js ADDED
@@ -0,0 +1,755 @@
1
+ // src/audit.ts
2
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
3
+ import { basename as basename2, join as join4, resolve as resolve4, sep as sep2 } from "node:path";
4
+
5
+ // src/core.ts
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { randomUUID } from "node:crypto";
8
+ import { basename, dirname, join, resolve } from "node:path";
9
+ function readJson(filePath) {
10
+ if (!existsSync(filePath))
11
+ return {};
12
+ const content = readFileSync(filePath, "utf8").trim();
13
+ if (!content)
14
+ return {};
15
+ try {
16
+ return JSON.parse(content);
17
+ } catch (error) {
18
+ throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
19
+ }
20
+ }
21
+ function writeJson(filePath, value) {
22
+ const parent = dirname(filePath);
23
+ mkdirSync(parent, { recursive: true });
24
+ const tmp = join(parent, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
25
+ try {
26
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
27
+ `, "utf8");
28
+ renameSync(tmp, filePath);
29
+ } catch (error) {
30
+ try {
31
+ unlinkSync(tmp);
32
+ } catch {}
33
+ throw error;
34
+ }
35
+ }
36
+
37
+ // src/lease.ts
38
+ import { mkdirSync as mkdirSync2, rmdirSync, statSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
39
+ import { dirname as dirname2, isAbsolute, join as join2, resolve as resolve2 } from "node:path";
40
+ import { setTimeout as sleep } from "node:timers/promises";
41
+ import { AsyncLocalStorage } from "node:async_hooks";
42
+ var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
43
+ var RFC3339_Z_RE = new RegExp(String.raw`^${DATE_PART}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`);
44
+ var DATE_ONLY_RE = new RegExp(String.raw`^${DATE_PART}$`);
45
+ var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
46
+ var LOCKDIR_HOLDER_PID = "holder.pid";
47
+ var heldLockDirs = new AsyncLocalStorage;
48
+ async function withStatusWriteLock(statusPath, fn, opts = {}) {
49
+ const lockDir = join2(dirname2(resolve2(statusPath)), STATUS_WRITE_LOCKDIR);
50
+ const held = heldLockDirs.getStore();
51
+ if (held !== undefined && held.has(lockDir)) {
52
+ 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`);
53
+ }
54
+ const timeoutMs = opts.timeoutMs ?? 30000;
55
+ const pollMs = opts.pollMs ?? 25;
56
+ const deadline = Date.now() + timeoutMs;
57
+ let acquired = null;
58
+ for (;; ) {
59
+ try {
60
+ mkdirSync2(lockDir);
61
+ const st = statSync(lockDir);
62
+ acquired = { dev: st.dev, ino: st.ino };
63
+ break;
64
+ } catch (error) {
65
+ if (error.code !== "EEXIST")
66
+ throw error;
67
+ if (Date.now() >= deadline) {
68
+ throw new Error(`${lockDir} already exists — another writer holds the status write lock; Blocked (same-host exclusive lock; status-and-residuals.md § Same-host exclusive write lock). ` + `Recovery: remove ${lockDir} if no writer is alive (holder.pid inside names the acquiring process)`);
69
+ }
70
+ await sleep(pollMs);
71
+ }
72
+ }
73
+ try {
74
+ writeFileSync2(join2(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
75
+ } catch {}
76
+ const owns = held ?? new Set;
77
+ owns.add(lockDir);
78
+ try {
79
+ return await heldLockDirs.run(owns, fn);
80
+ } finally {
81
+ owns.delete(lockDir);
82
+ try {
83
+ const current = statSync(lockDir);
84
+ if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
85
+ try {
86
+ unlinkSync2(join2(lockDir, LOCKDIR_HOLDER_PID));
87
+ } catch {}
88
+ rmdirSync(lockDir);
89
+ }
90
+ } catch {}
91
+ }
92
+ }
93
+
94
+ // src/mstarc.ts
95
+ var MSTARC_HARNESS_DIR_KEY = "harness_dir";
96
+ var MSTARC_PLAN_DIR_KEY = "plan_dir";
97
+ var MSTARC_SDD_DIR_KEY = "sdd_dir";
98
+ var MSTARC_ITERATION_DIR_KEY = "iteration_dir";
99
+ var MSTARC_KNOWLEDGE_DIR_KEY = "knowledge_dir";
100
+ var MSTARC_SPECS_DIR_KEY = "specs_dir";
101
+ var MSTARC_WORKFLOW_DIR_KEY = "workflow_dir";
102
+ var MSTARC_PROJECT_DIR_KEY = "project_dir";
103
+ var MSTARC_ENFORCEMENT_KEY = "enforcement";
104
+ var CONFIG_KEYS = {
105
+ [MSTARC_HARNESS_DIR_KEY]: "harnessDir",
106
+ [MSTARC_PLAN_DIR_KEY]: "planDir",
107
+ [MSTARC_SDD_DIR_KEY]: "sddDir",
108
+ [MSTARC_ITERATION_DIR_KEY]: "iterationDir",
109
+ [MSTARC_KNOWLEDGE_DIR_KEY]: "knowledgeDir",
110
+ [MSTARC_SPECS_DIR_KEY]: "specsDir",
111
+ [MSTARC_WORKFLOW_DIR_KEY]: "workflowDir",
112
+ [MSTARC_PROJECT_DIR_KEY]: "projectDir",
113
+ [MSTARC_ENFORCEMENT_KEY]: "enforcement"
114
+ };
115
+
116
+ // src/path.ts
117
+ function assertSafePathComponent(value, what) {
118
+ if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
119
+ throw new Error(`${what} must be a single safe path component ([A-Za-z0-9._-]+; not "", ".", "..", or containing "/" or "\\") — got ${JSON.stringify(value)}`);
120
+ }
121
+ }
122
+ var GITIGNORE_SNIPPET = `# Morning Star harness (.mstar/)
123
+ # Principle: process stays local; results are shared with the team.
124
+ # Default-ignore everything under .mstar/, then re-include the tracked results.
125
+ .mstar/**
126
+ !.mstar/AGENTS.md
127
+ !.mstar/knowledge/
128
+ !.mstar/knowledge/**
129
+ !.mstar/specs/
130
+ !.mstar/specs/**
131
+ # .mstarc — repo-local harness config (may declare [config] harness_dir=<name>)
132
+ .mstarc
133
+ `;
134
+ var GITIGNORE_SNIPPET_AGENTS = `# Morning Star harness (.agents/) — legacy
135
+ # Default-ignore everything under .agents/, then re-include the tracked results.
136
+ .agents/**
137
+ !.agents/AGENTS.md
138
+ !.agents/knowledge/
139
+ !.agents/knowledge/**
140
+ !.agents/specs/
141
+ !.agents/specs/**
142
+ `;
143
+ var GITIGNORE_PROCESS_ENTRIES = GITIGNORE_SNIPPET.split(`
144
+ `).filter((line) => line.startsWith(".mstar/") || line.startsWith("!.mstar/")).map((line) => line.trim());
145
+ var GITIGNORE_PROCESS_ENTRIES_AGENTS = GITIGNORE_SNIPPET_AGENTS.split(`
146
+ `).filter((line) => line.startsWith(".agents/") || line.startsWith("!.agents/")).map((line) => line.trim());
147
+
148
+ // src/status.ts
149
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, realpathSync } from "node:fs";
150
+ import { dirname as dirname3, join as join3, resolve as resolve3, sep } from "node:path";
151
+
152
+ // src/workflow.ts
153
+ var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
154
+ var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
155
+ var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
156
+
157
+ // src/status.ts
158
+ var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
159
+ function isPlainObject(value) {
160
+ return typeof value === "object" && value !== null && !Array.isArray(value);
161
+ }
162
+ function violation(severity, code, message, fix) {
163
+ return { ok: false, severity, code, message, fix };
164
+ }
165
+ function todayString() {
166
+ const now = new Date;
167
+ const month = String(now.getMonth() + 1).padStart(2, "0");
168
+ const day = String(now.getDate()).padStart(2, "0");
169
+ return `${now.getFullYear()}-${month}-${day}`;
170
+ }
171
+ function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
172
+ if (value === undefined) {
173
+ violations.push(violation("high", missingCode, `missing required field: ${field}`));
174
+ } else if (typeof value !== "string" || value.trim() === "") {
175
+ violations.push(violation("medium", invalidCode, `${field} must be a non-empty string`));
176
+ }
177
+ }
178
+ function isHarnessRelativePath(dir) {
179
+ if (dir.startsWith("/") || dir.startsWith("\\"))
180
+ return false;
181
+ if (/^[A-Za-z]:[\\/]/.test(dir))
182
+ return false;
183
+ return !dir.split(/[\\/]+/).includes("..");
184
+ }
185
+ function validateWorkflowEntry(entry) {
186
+ const violations = [];
187
+ if (!isPlainObject(entry)) {
188
+ return {
189
+ ok: false,
190
+ violations: [violation("high", "status.workflow.invalid", "workflow entry must be an object")]
191
+ };
192
+ }
193
+ validateNonEmptyString(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
194
+ if (entry.type === undefined) {
195
+ violations.push(violation("high", "status.workflow.missing-type", "missing required field: type"));
196
+ } else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
197
+ violations.push(violation("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
198
+ }
199
+ validateNonEmptyString(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
200
+ if (entry.dir === undefined) {
201
+ violations.push(violation("high", "status.workflow.missing-dir", "missing required field: dir"));
202
+ } else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
203
+ violations.push(violation("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
204
+ } else if (!isHarnessRelativePath(entry.dir)) {
205
+ violations.push(violation("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
206
+ }
207
+ return { ok: violations.length === 0, violations };
208
+ }
209
+ function validateStatusV2(docOrPath, opts = {}) {
210
+ let doc;
211
+ let harnessDir = opts.harnessDir;
212
+ if (typeof docOrPath === "string") {
213
+ try {
214
+ doc = readJson(docOrPath);
215
+ harnessDir = dirname3(resolve3(docOrPath));
216
+ } catch (error) {
217
+ return {
218
+ ok: false,
219
+ violations: [violation("high", "status.invalid-json", error.message)]
220
+ };
221
+ }
222
+ } else {
223
+ doc = docOrPath;
224
+ }
225
+ if (!isPlainObject(doc)) {
226
+ return { ok: false, violations: [violation("high", "status.invalid-doc", "status document must be an object")] };
227
+ }
228
+ if (doc.version !== 2) {
229
+ return {
230
+ ok: false,
231
+ violations: [
232
+ violation("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`")
233
+ ]
234
+ };
235
+ }
236
+ if (Array.isArray(doc.plans)) {
237
+ return {
238
+ ok: false,
239
+ violations: [
240
+ violation("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`")
241
+ ]
242
+ };
243
+ }
244
+ if (doc.residual_findings !== undefined) {
245
+ return {
246
+ ok: false,
247
+ violations: [
248
+ violation("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`")
249
+ ]
250
+ };
251
+ }
252
+ const violations = [];
253
+ if (doc.updated_at === undefined) {
254
+ violations.push(violation("high", "status.missing-updated-at", "missing required field: updated_at"));
255
+ } else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
256
+ violations.push(violation("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
257
+ }
258
+ if (doc.workflows === undefined) {
259
+ violations.push(violation("high", "status.missing-workflows", "missing required field: workflows"));
260
+ } else if (!Array.isArray(doc.workflows)) {
261
+ violations.push(violation("high", "status.invalid-workflows", "workflows must be an array"));
262
+ } else {
263
+ const seen = new Set;
264
+ for (const entry of doc.workflows) {
265
+ violations.push(...validateWorkflowEntry(entry).violations);
266
+ if (isPlainObject(entry) && typeof entry.id === "string") {
267
+ if (seen.has(entry.id)) {
268
+ violations.push(violation("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
269
+ }
270
+ seen.add(entry.id);
271
+ }
272
+ }
273
+ }
274
+ if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
275
+ let realHarnessDir = null;
276
+ try {
277
+ realHarnessDir = realpathSync(harnessDir);
278
+ } catch {}
279
+ for (const entry of doc.workflows) {
280
+ if (!isPlainObject(entry) || typeof entry.dir !== "string")
281
+ continue;
282
+ const relSnapshot = join3(entry.dir, WORKFLOW_SNAPSHOT_FILE);
283
+ const snapshotPath = join3(harnessDir, relSnapshot);
284
+ const label = typeof entry.id === "string" ? entry.id : relSnapshot;
285
+ let physical;
286
+ try {
287
+ physical = realpathSync(snapshotPath);
288
+ } catch {
289
+ violations.push(violation("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`));
290
+ continue;
291
+ }
292
+ if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep}`)) {
293
+ violations.push(violation("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)}`));
294
+ continue;
295
+ }
296
+ let snapshot;
297
+ try {
298
+ snapshot = readJson(snapshotPath);
299
+ } catch (error) {
300
+ violations.push(violation("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
301
+ continue;
302
+ }
303
+ if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
304
+ violations.push(violation("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`));
305
+ }
306
+ if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
307
+ violations.push(violation("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`));
308
+ }
309
+ if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
310
+ violations.push(violation("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`));
311
+ }
312
+ }
313
+ }
314
+ return { ok: violations.length === 0, violations };
315
+ }
316
+ function registerWorkflowEntryLocked(statusPath, entry) {
317
+ const harnessDir = dirname3(statusPath);
318
+ const current = readJson(statusPath);
319
+ const fresh = Object.keys(current).length === 0;
320
+ const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
321
+ if (!fresh && !Array.isArray(doc.workflows)) {
322
+ throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
323
+ }
324
+ const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
325
+ if (existing >= 0) {
326
+ doc.workflows[existing] = entry;
327
+ } else {
328
+ doc.workflows.push(entry);
329
+ }
330
+ doc.updated_at = todayString();
331
+ const gate = validateStatusV2(doc, { harnessDir });
332
+ if (!gate.ok) {
333
+ throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
334
+ }
335
+ writeJson(statusPath, doc);
336
+ return doc;
337
+ }
338
+
339
+ // src/audit.ts
340
+ function violation2(severity, code, message, fix) {
341
+ return { ok: false, severity, code, message, fix };
342
+ }
343
+ var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
344
+ var AUDIT_EFFORTS = ["XS", "S", "M", "L", "XL"];
345
+ var AUDIT_RISKS = ["LOW", "MED", "HIGH"];
346
+ var AUDIT_CATEGORIES = [
347
+ "bug",
348
+ "security",
349
+ "perf",
350
+ "tests",
351
+ "tech-debt",
352
+ "migration",
353
+ "dx",
354
+ "docs",
355
+ "direction"
356
+ ];
357
+ var AUDIT_STATUS_FIELDS = ["Priority", "Effort", "Risk", "Depends on", "Category", "Planned at"];
358
+ function parseStatusBlocks(planText) {
359
+ const blocks = [];
360
+ let current = null;
361
+ for (const line of planText.split(/\r?\n/)) {
362
+ const trimmed = line.trim();
363
+ if (trimmed === "## Status") {
364
+ current = new Map;
365
+ blocks.push({ fields: current });
366
+ continue;
367
+ }
368
+ if (current === null)
369
+ continue;
370
+ if (trimmed.startsWith("#")) {
371
+ current = null;
372
+ continue;
373
+ }
374
+ const match = /^-\s*\*\*([^*]+)\*\*:\s*(.*)$/.exec(trimmed);
375
+ if (match !== null)
376
+ current.set(match[1].trim(), match[2].trim());
377
+ }
378
+ return blocks;
379
+ }
380
+ function validateAuditStatusBlocks(planText) {
381
+ const violations = [];
382
+ const blocks = parseStatusBlocks(planText);
383
+ if (blocks.length === 0) {
384
+ violations.push(violation2("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"));
385
+ return { ok: false, violations };
386
+ }
387
+ blocks.forEach((block, index) => {
388
+ const label = blocks.length > 1 ? ` #${index + 1}` : "";
389
+ for (const field of AUDIT_STATUS_FIELDS) {
390
+ if (!block.fields.has(field)) {
391
+ violations.push(violation2("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL § Plan files)`, `add \`- **${field}**: <value>\` to the Status block`));
392
+ }
393
+ }
394
+ const check = (field, pattern, code, expected) => {
395
+ const value = block.fields.get(field);
396
+ if (value === undefined)
397
+ return;
398
+ if (!pattern.test(value)) {
399
+ violations.push(violation2("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL § Plan files)`, `fix \`- **${field}**:\` to one of: ${expected}`));
400
+ }
401
+ };
402
+ check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
403
+ check("Effort", /^(?:XS|S|M|L|XL)$/, "audit.status.invalid-effort", "XS | S | M | L | XL");
404
+ check("Risk", /^(?:LOW|MED|HIGH)$/, "audit.status.invalid-risk", "LOW | MED | HIGH");
405
+ check("Category", /^(?:bug|security|perf|tests|tech-debt|migration|dx|docs|direction)$/, "audit.status.invalid-category", "bug | security | perf | tests | tech-debt | migration | dx | docs | direction");
406
+ check("Depends on", /^(?:none|plans\/\d{3}-[\w.*-]+\.md)$/i, "audit.status.invalid-depends-on", "none or plans/NNN-*.md");
407
+ check("Planned at", /^commit \`?(?:[0-9a-f]{7,40}|unknown)\`?, \d{4}-\d{2}-\d{2}$/, "audit.status.invalid-planned-at", "commit <short SHA>, <YYYY-MM-DD> (or `commit unknown` outside a git repo)");
408
+ });
409
+ return { ok: violations.length === 0, violations };
410
+ }
411
+ var WHOLE_MATCH_PATTERNS = [
412
+ { type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
413
+ { type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
414
+ { type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
415
+ { type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
416
+ { type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
417
+ { type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
418
+ ];
419
+ var VALUE_PATTERNS = [
420
+ {
421
+ typeOf: (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[_-]+/g, "-"),
422
+ 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
423
+ }
424
+ ];
425
+ function buildLineStarts(text) {
426
+ const starts = [0];
427
+ for (let i = 0;i < text.length; i++) {
428
+ if (text[i] === `
429
+ `)
430
+ starts.push(i + 1);
431
+ }
432
+ return starts;
433
+ }
434
+ function lineAt(starts, index) {
435
+ let lo = 0;
436
+ let hi = starts.length - 1;
437
+ while (lo < hi) {
438
+ const mid = lo + hi + 1 >> 1;
439
+ if (starts[mid] <= index)
440
+ lo = mid;
441
+ else
442
+ hi = mid - 1;
443
+ }
444
+ return lo + 1;
445
+ }
446
+ function redactSecrets(text, filePath) {
447
+ const starts = buildLineStarts(text);
448
+ const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
449
+ const replacements = [];
450
+ const findings = [];
451
+ for (const pattern of WHOLE_MATCH_PATTERNS) {
452
+ for (const match of text.matchAll(pattern.re)) {
453
+ if (match.index === undefined)
454
+ continue;
455
+ replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
456
+ findings.push({ line: lineAt(starts, match.index), type: pattern.type });
457
+ }
458
+ }
459
+ for (const pattern of VALUE_PATTERNS) {
460
+ for (const match of text.matchAll(pattern.re)) {
461
+ if (match.index === undefined)
462
+ continue;
463
+ const type = pattern.typeOf(match[2]);
464
+ const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
465
+ replacements.push({ index: match.index, length: match[0].length, text: replacement });
466
+ findings.push({ line: lineAt(starts, match.index), type });
467
+ }
468
+ }
469
+ replacements.sort((a, b) => b.index - a.index);
470
+ let out = text;
471
+ for (const r of replacements)
472
+ out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
473
+ const deduped = new Map;
474
+ for (const f of findings)
475
+ deduped.set(`${f.line}:${f.type}`, f);
476
+ const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
477
+ return { text: out, findings: sorted };
478
+ }
479
+ function slugify(title) {
480
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
481
+ }
482
+ var escapeCell = (value) => value.replace(/\|/g, "\\|");
483
+ var truncate = (value, max) => value.length > max ? `${value.slice(0, max)}…` : value;
484
+ function renderPlanFile(finding, plannedAt) {
485
+ const sections = [
486
+ `# ${finding.title}`,
487
+ "",
488
+ "## Status",
489
+ `- **Priority**: ${finding.priority}`,
490
+ `- **Effort**: ${finding.effort}`,
491
+ `- **Risk**: ${finding.risk}`,
492
+ `- **Depends on**: ${finding.dependsOn ?? "none"}`,
493
+ `- **Category**: ${finding.category}`,
494
+ `- **Planned at**: commit \`${plannedAt.commit}\`, ${plannedAt.date}`,
495
+ "",
496
+ "## Impact",
497
+ finding.impact
498
+ ];
499
+ if (finding.evidence.length > 0) {
500
+ sections.push("", "## Evidence", ...finding.evidence.map((e) => `- ${e}`));
501
+ }
502
+ if (finding.fixSketch !== undefined) {
503
+ sections.push("", "## Fix sketch", finding.fixSketch);
504
+ }
505
+ if (finding.verification !== undefined) {
506
+ sections.push("", "## Verification", finding.verification);
507
+ }
508
+ return `${sections.join(`
509
+ `)}
510
+ `;
511
+ }
512
+ function readPlanFileSummary(filePath) {
513
+ const text = readFileSync3(filePath, "utf8");
514
+ const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
515
+ const blocks = parseStatusBlocks(text);
516
+ return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
517
+ }
518
+ function renderIndex(params) {
519
+ const { date, repoName, repoShortSha, rows, rejected } = params;
520
+ const findingsRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.category} | ${escapeCell(truncate(r.impact, 80))} | ${r.effort} | ${r.risk} | ${r.confidence} | ${escapeCell(truncate(r.evidence, 80))} |`).join(`
521
+ `);
522
+ const directionRows = rows.filter((r) => r.category === "direction").map((r) => `- ${escapeCell(r.title)} — ${escapeCell(truncate(r.impact, 120))}`).join(`
523
+ `);
524
+ const executionRows = rows.map((r) => `| ${r.num} | ${escapeCell(r.title)} | ${r.priority} | ${r.effort} | ${r.dependsOn} | TODO |`).join(`
525
+ `);
526
+ const rejectedRows = rejected.map((r) => `- ${escapeCell(r.title)}: ${escapeCell(r.reason)}`).join(`
527
+ `);
528
+ const sections = [
529
+ `# Audit Report — ${repoName} @ ${repoShortSha} (${date})`,
530
+ "",
531
+ "## Findings",
532
+ "",
533
+ "| # | Finding | Category | Impact | Effort | Risk | Confidence | Evidence |",
534
+ "|---|---------|----------|--------|--------|------|------------|----------|",
535
+ findingsRows
536
+ ];
537
+ if (directionRows !== "") {
538
+ sections.push("", "## Direction", "", directionRows);
539
+ }
540
+ sections.push("", "## Execution order & status", "", "| Plan | Title | Priority | Effort | Depends on | Status |", "|------|-------|----------|--------|------------|--------|", executionRows);
541
+ if (rejectedRows !== "") {
542
+ sections.push("", "## Findings considered and rejected", "", rejectedRows);
543
+ }
544
+ sections.push("", "## Red-team dispositions", "", "- <finding>: <survived / refuted / hallucination-dropped / uncovered-kept>, <one-line reason>");
545
+ return `${sections.join(`
546
+ `)}
547
+ `;
548
+ }
549
+ function scaffoldAuditPlan(outDir, findings, options = {}) {
550
+ const date = options.date ?? new Date().toISOString().slice(0, 10);
551
+ const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
552
+ mkdirSync3(outDir, { recursive: true });
553
+ const existing = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
554
+ let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
555
+ const written = [];
556
+ const usedSlugs = new Set;
557
+ for (const finding of findings) {
558
+ const num = String(next).padStart(3, "0");
559
+ let slug = slugify(finding.title);
560
+ if (usedSlugs.has(slug)) {
561
+ let n = 2;
562
+ while (usedSlugs.has(`${slug}-${n}`))
563
+ n++;
564
+ slug = `${slug}-${n}`;
565
+ }
566
+ usedSlugs.add(slug);
567
+ const file = `${num}-${slug}.md`;
568
+ writeFileSync3(join4(outDir, file), renderPlanFile(finding, plannedAt));
569
+ written.push(file);
570
+ next++;
571
+ }
572
+ const all = [...existing, ...written].sort();
573
+ const rows = all.map((file) => {
574
+ const summary = readPlanFileSummary(join4(outDir, file));
575
+ const fields = summary.fields;
576
+ return {
577
+ num: file.slice(0, 3),
578
+ title: summary.title,
579
+ category: fields.get("Category") ?? "—",
580
+ impact: "see plan file",
581
+ effort: fields.get("Effort") ?? "—",
582
+ risk: fields.get("Risk") ?? "—",
583
+ confidence: "—",
584
+ evidence: fields.get("Evidence") ?? "—",
585
+ priority: fields.get("Priority") ?? "—",
586
+ dependsOn: fields.get("Depends on") ?? "—"
587
+ };
588
+ });
589
+ const byNum = new Map(rows.map((r) => [r.num, r]));
590
+ written.forEach((file, i) => {
591
+ const finding = findings[i];
592
+ if (finding === undefined)
593
+ return;
594
+ const row = byNum.get(file.slice(0, 3));
595
+ if (row !== undefined) {
596
+ row.category = finding.category;
597
+ row.impact = finding.impact;
598
+ row.effort = finding.effort;
599
+ row.risk = finding.risk;
600
+ row.confidence = finding.confidence;
601
+ row.evidence = finding.evidence[0] ?? "";
602
+ row.priority = finding.priority;
603
+ row.dependsOn = finding.dependsOn ?? "none";
604
+ }
605
+ });
606
+ writeFileSync3(join4(outDir, "README.md"), renderIndex({
607
+ date,
608
+ repoName: options.repoName ?? "repo",
609
+ repoShortSha: options.repoShortSha ?? "unknown",
610
+ rows,
611
+ rejected: options.rejected ?? []
612
+ }));
613
+ return { outDir: resolve4(outDir), date, files: written, nextNumber: next };
614
+ }
615
+ async function promoteAuditPlans(outDir, selected, options) {
616
+ if (selected.length === 0) {
617
+ throw new Error("promoteAuditPlans: at least one plan id must be selected (--plans 001,002,…)");
618
+ }
619
+ if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
620
+ throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
621
+ }
622
+ const workflowId = options.workflowId ?? basename2(resolve4(outDir));
623
+ assertSafePathComponent(workflowId, "workflow id");
624
+ const harnessDir = resolve4(options.harnessDir);
625
+ const statusPath = join4(harnessDir, "status.json");
626
+ const workflowDir = join4(harnessDir, "workflows", workflowId);
627
+ const snapshotPath = join4(workflowDir, WORKFLOW_SNAPSHOT_FILE);
628
+ const planFiles = resolveSelectedPlanFiles(outDir, selected);
629
+ const indexRows = readExecutionOrderIndex(outDir);
630
+ const plans = planFiles.map((planFile) => {
631
+ const stem = planFile.replace(/\.md$/, "");
632
+ const num = stem.slice(0, 3);
633
+ const indexRow = indexRows.get(num);
634
+ const title = indexRow?.title ?? readPlanFileSummary(join4(outDir, planFile)).title;
635
+ return {
636
+ id: stem,
637
+ title,
638
+ file: planFileRel(outDir, planFile),
639
+ status: "Todo"
640
+ };
641
+ });
642
+ const now = new Date;
643
+ const snapshot = {
644
+ schema_version: 1,
645
+ id: workflowId,
646
+ type: "plan",
647
+ status: "running",
648
+ started_at: now.toISOString(),
649
+ updated_at: now.toISOString().slice(0, 10),
650
+ plans
651
+ };
652
+ const entry = {
653
+ id: workflowId,
654
+ type: "plan",
655
+ started_at: snapshot.started_at,
656
+ dir: `workflows/${workflowId}`
657
+ };
658
+ const entryGate = validateWorkflowEntry(entry);
659
+ if (!entryGate.ok) {
660
+ throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
661
+ }
662
+ await withStatusWriteLock(statusPath, () => {
663
+ if (existsSync3(snapshotPath)) {
664
+ 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`);
665
+ }
666
+ mkdirSync3(workflowDir, { recursive: true });
667
+ try {
668
+ writeJson(snapshotPath, snapshot);
669
+ registerWorkflowEntryLocked(statusPath, entry);
670
+ } catch (error) {
671
+ rmSync(snapshotPath, { force: true });
672
+ try {
673
+ if (readdirSync2(workflowDir).length === 0) {
674
+ rmdirSync2(workflowDir);
675
+ }
676
+ } catch {}
677
+ throw error;
678
+ }
679
+ return { workflowId, snapshotPath };
680
+ });
681
+ return { workflowId, snapshotPath };
682
+ }
683
+ function resolveSelectedPlanFiles(outDir, selected) {
684
+ const files = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
685
+ const byNum = new Map;
686
+ const byStem = new Map;
687
+ for (const file of files) {
688
+ const stem = file.replace(/\.md$/, "");
689
+ if (!byNum.has(stem.slice(0, 3))) {
690
+ byNum.set(stem.slice(0, 3), file);
691
+ }
692
+ byStem.set(stem, file);
693
+ }
694
+ const resolved = [];
695
+ const seen = new Set;
696
+ for (const id of selected) {
697
+ const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
698
+ if (file === undefined) {
699
+ throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve4(outDir)}`);
700
+ }
701
+ if (!seen.has(file)) {
702
+ seen.add(file);
703
+ resolved.push(file);
704
+ }
705
+ }
706
+ return resolved;
707
+ }
708
+ function readExecutionOrderIndex(outDir) {
709
+ const readmePath = join4(outDir, "README.md");
710
+ let text;
711
+ try {
712
+ text = readFileSync3(readmePath, "utf8");
713
+ } catch {
714
+ return new Map;
715
+ }
716
+ const rows = new Map;
717
+ const lines = text.split(`
718
+ `);
719
+ let inSection = false;
720
+ for (const line of lines) {
721
+ if (/^##\s+Execution order & status/.test(line)) {
722
+ inSection = true;
723
+ continue;
724
+ }
725
+ if (inSection && /^#/.test(line)) {
726
+ break;
727
+ }
728
+ if (!inSection)
729
+ continue;
730
+ const cells = line.split(/(?<!\\)\|/).map((c) => c.trim());
731
+ if (cells.length >= 3 && /^\d{3}$/.test(cells[1])) {
732
+ rows.set(cells[1], { title: cells[2].replace(/\\\|/g, "|") });
733
+ }
734
+ }
735
+ return rows;
736
+ }
737
+ function planFileRel(outDir, planFile) {
738
+ const resolved = resolve4(outDir);
739
+ const parts = resolved.split(sep2);
740
+ const plansIdx = parts.lastIndexOf("plans");
741
+ if (plansIdx >= 0) {
742
+ return `${parts.slice(plansIdx + 1).join(sep2)}${sep2}${planFile}`;
743
+ }
744
+ return planFile;
745
+ }
746
+ export {
747
+ validateAuditStatusBlocks,
748
+ scaffoldAuditPlan,
749
+ redactSecrets,
750
+ promoteAuditPlans,
751
+ AUDIT_RISKS,
752
+ AUDIT_PRIORITIES,
753
+ AUDIT_EFFORTS,
754
+ AUDIT_CATEGORIES
755
+ };
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
package/dist/engine.js CHANGED
@@ -1350,41 +1350,42 @@ function validateStatusV2(docOrPath, opts = {}) {
1350
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`));
1351
1351
  }
1352
1352
  if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
1353
- violations.push(violation4("medium", "status.workflow.mismatched-started-at", `workflows[] entry ${JSON.stringify(label)} started_at ${JSON.stringify(entry.started_at)} does not match its snapshot started_at ${JSON.stringify(snapshot.started_at)} — 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`));
1354
1354
  }
1355
1355
  }
1356
1356
  }
1357
1357
  return { ok: violations.length === 0, violations };
1358
1358
  }
1359
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
+ }
1360
1382
  async function registerWorkflow(root, entry) {
1361
1383
  const entryGate = validateWorkflowEntry(entry);
1362
1384
  if (!entryGate.ok) {
1363
1385
  throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
1364
1386
  }
1365
1387
  const statusPath = resolve5(root);
1366
- const harnessDir = dirname5(statusPath);
1367
- return withStatusWriteLock(statusPath, () => {
1368
- const current = readJson(statusPath);
1369
- const fresh = Object.keys(current).length === 0;
1370
- const doc = fresh ? { version: 2, updated_at: todayString(), workflows: [] } : current;
1371
- if (!fresh && !Array.isArray(doc.workflows)) {
1372
- throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
1373
- }
1374
- const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
1375
- if (existing >= 0) {
1376
- doc.workflows[existing] = entry;
1377
- } else {
1378
- doc.workflows.push(entry);
1379
- }
1380
- doc.updated_at = todayString();
1381
- const gate = validateStatusV2(doc, { harnessDir });
1382
- if (!gate.ok) {
1383
- throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
1384
- }
1385
- writeJson(statusPath, doc);
1386
- return doc;
1387
- });
1388
+ return withStatusWriteLock(statusPath, () => registerWorkflowEntryLocked(statusPath, entry));
1388
1389
  }
1389
1390
  async function unregisterWorkflow(root, id) {
1390
1391
  if (typeof id !== "string" || id.trim() === "") {
@@ -2990,9 +2991,11 @@ async function applyMigratePlan(plan) {
2990
2991
  if (!gate2.ok) {
2991
2992
  throw new Error(`refusing to apply migration: invalid project register: ${gate2.violations.map((v) => v.message).join("; ")}`);
2992
2993
  }
2993
- const filePath = projectTargetOf(plan.register.file);
2994
- mkdirSync6(dirname7(filePath), { recursive: true });
2995
- 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
+ }
2996
2999
  }
2997
3000
  if (plan.roadmap !== null) {
2998
3001
  const filePath = projectTargetOf(plan.roadmap.file);
@@ -3419,8 +3422,8 @@ function completenessLevel(frontmatterText, checklist) {
3419
3422
  return { level, items, missing, placeholders, upgradeTo, bodyUnverified };
3420
3423
  }
3421
3424
  // src/audit.ts
3422
- import { mkdirSync as mkdirSync7, readdirSync as readdirSync7, readFileSync as readFileSync9, writeFileSync as writeFileSync5 } from "node:fs";
3423
- 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";
3424
3427
  function violation9(severity, code, message, fix) {
3425
3428
  return { ok: false, severity, code, message, fix };
3426
3429
  }
@@ -3492,74 +3495,6 @@ function validateAuditStatusBlocks(planText) {
3492
3495
  });
3493
3496
  return { ok: violations.length === 0, violations };
3494
3497
  }
3495
- var WHOLE_MATCH_PATTERNS = [
3496
- { type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
3497
- { type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
3498
- { type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
3499
- { type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
3500
- { type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
3501
- { type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
3502
- ];
3503
- var VALUE_PATTERNS = [
3504
- {
3505
- typeOf: (key) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[_-]+/g, "-"),
3506
- 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
3507
- }
3508
- ];
3509
- function buildLineStarts(text) {
3510
- const starts = [0];
3511
- for (let i = 0;i < text.length; i++) {
3512
- if (text[i] === `
3513
- `)
3514
- starts.push(i + 1);
3515
- }
3516
- return starts;
3517
- }
3518
- function lineAt(starts, index) {
3519
- let lo = 0;
3520
- let hi = starts.length - 1;
3521
- while (lo < hi) {
3522
- const mid = lo + hi + 1 >> 1;
3523
- if (starts[mid] <= index)
3524
- lo = mid;
3525
- else
3526
- hi = mid - 1;
3527
- }
3528
- return lo + 1;
3529
- }
3530
- function redactSecrets(text, filePath) {
3531
- const starts = buildLineStarts(text);
3532
- const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
3533
- const replacements = [];
3534
- const findings = [];
3535
- for (const pattern of WHOLE_MATCH_PATTERNS) {
3536
- for (const match of text.matchAll(pattern.re)) {
3537
- if (match.index === undefined)
3538
- continue;
3539
- replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
3540
- findings.push({ line: lineAt(starts, match.index), type: pattern.type });
3541
- }
3542
- }
3543
- for (const pattern of VALUE_PATTERNS) {
3544
- for (const match of text.matchAll(pattern.re)) {
3545
- if (match.index === undefined)
3546
- continue;
3547
- const type = pattern.typeOf(match[2]);
3548
- const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
3549
- replacements.push({ index: match.index, length: match[0].length, text: replacement });
3550
- findings.push({ line: lineAt(starts, match.index), type });
3551
- }
3552
- }
3553
- replacements.sort((a, b) => b.index - a.index);
3554
- let out = text;
3555
- for (const r of replacements)
3556
- out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
3557
- const deduped = new Map;
3558
- for (const f of findings)
3559
- deduped.set(`${f.line}:${f.type}`, f);
3560
- const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
3561
- return { text: out, findings: sorted };
3562
- }
3563
3498
  function slugify(title) {
3564
3499
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3565
3500
  }
@@ -3696,9 +3631,140 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
3696
3631
  }));
3697
3632
  return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
3698
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
+ }
3699
3765
  // src/compound.ts
3700
- import { existsSync as existsSync7, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "node:fs";
3701
- 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";
3702
3768
  function violation10(severity, code, message, fix) {
3703
3769
  return { ok: false, severity, code, message, fix };
3704
3770
  }
@@ -4005,7 +4071,7 @@ function referenceExists(repoRoot, docText) {
4005
4071
  for (const { ref, isSymbol, module } of refs) {
4006
4072
  if (!isSymbol || module === undefined) {
4007
4073
  const candidate = ref.replace(LINE_SUFFIX_RE, "").replace(ANCHOR_RE, "");
4008
- if (existsSync7(resolve10(repoRoot, candidate))) {
4074
+ if (existsSync8(resolve10(repoRoot, candidate))) {
4009
4075
  checked++;
4010
4076
  } else {
4011
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"));
@@ -4036,7 +4102,7 @@ function collectKnowledgeDocs(dir) {
4036
4102
  if (entry.isDirectory()) {
4037
4103
  stack.push(full);
4038
4104
  } else if (entry.name.endsWith(".md") && entry.name !== "README.md" && entry.name !== "index.md") {
4039
- docs.push(relative4(dir, full).split(sep3).join("/"));
4105
+ docs.push(relative4(dir, full).split(sep4).join("/"));
4040
4106
  }
4041
4107
  }
4042
4108
  }
@@ -4053,7 +4119,7 @@ function normalizeIndexRef(cell) {
4053
4119
  function assertIndexRows(knowledgeDir) {
4054
4120
  const violations = [];
4055
4121
  const readmePath = join12(knowledgeDir, "README.md");
4056
- if (!existsSync7(readmePath)) {
4122
+ if (!existsSync8(readmePath)) {
4057
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"));
4058
4124
  return { ok: false, violations };
4059
4125
  }
@@ -4085,7 +4151,7 @@ function compoundRefreshScope(harnessDir, projectRoot) {
4085
4151
  ];
4086
4152
  }
4087
4153
  function isFileLikeRoot(root) {
4088
- return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename4(root));
4154
+ return /^[^.]*\.[A-Za-z0-9]{1,10}$/.test(basename5(root));
4089
4155
  }
4090
4156
  function scopeGuard(path, allowedRoots) {
4091
4157
  const resolved = resolve10(path);
@@ -4094,7 +4160,7 @@ function scopeGuard(path, allowedRoots) {
4094
4160
  if (isFileLikeRoot(r)) {
4095
4161
  if (resolved === r)
4096
4162
  return { ok: true, violations: [] };
4097
- } else if (resolved === r || resolved.startsWith(r + sep3)) {
4163
+ } else if (resolved === r || resolved.startsWith(r + sep4)) {
4098
4164
  return { ok: true, violations: [] };
4099
4165
  }
4100
4166
  }
@@ -4338,7 +4404,7 @@ function lintStrategySections(docText) {
4338
4404
  return { ok: violations.length === 0, violations };
4339
4405
  }
4340
4406
  // src/roles.ts
4341
- import { existsSync as existsSync8 } from "node:fs";
4407
+ import { existsSync as existsSync9 } from "node:fs";
4342
4408
  import { join as join13 } from "node:path";
4343
4409
  function violation12(severity, code, message, fix) {
4344
4410
  return { ok: false, severity, code, message, fix };
@@ -4395,7 +4461,7 @@ function validateRoleMapping(rolesDir, options = {}) {
4395
4461
  const violations = [];
4396
4462
  const referenceById = new Map(mapping.map((m) => [m.agentId, m.reference]));
4397
4463
  for (const { agentId, reference } of mapping) {
4398
- if (!existsSync8(join13(rolesDir, reference))) {
4464
+ if (!existsSync9(join13(rolesDir, reference))) {
4399
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`));
4400
4466
  }
4401
4467
  }
@@ -4651,11 +4717,11 @@ export {
4651
4717
  releaseLease,
4652
4718
  registerWorkflow,
4653
4719
  referenceExists,
4654
- redactSecrets,
4655
4720
  readProgressLedger,
4656
4721
  readJson,
4657
4722
  readHarnessVersion,
4658
4723
  pushCadenceProbe,
4724
+ promoteAuditPlans,
4659
4725
  planQualityBar,
4660
4726
  planExecutionLeaseLocations,
4661
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";
package/dist/status.d.ts CHANGED
@@ -136,6 +136,26 @@ export declare function validateStatusV2(docOrPath: StatusV2Doc | string, opts?:
136
136
  * closed on v1 input with the `mstar migrate` hint.
137
137
  */
138
138
  export declare const validateStatus: typeof validateStatusV2;
139
+ /**
140
+ * Root-file workflow upsert, to be called ONLY while the caller holds the
141
+ * root `withStatusWriteLock(statusPath)` (see `registerWorkflow` and the
142
+ * audit promote path, which call this from inside their lock — the root
143
+ * lock is the serialization point for read-check-replace-verify).
144
+ *
145
+ * Idempotent upsert by entry `id`, bumping root `updated_at`. A
146
+ * missing/empty root file is initialized from the v2 template (never a v1
147
+ * tree); a v1 root is refused with the `mstar migrate` hint (no silent
148
+ * mutation of an un-migrated tree). The final document is validated with
149
+ * `validateStatusV2` (including the removal-at-terminal snapshot invariant
150
+ * against `dirname(statusPath)`) before the write — an entry whose
151
+ * snapshot is missing or terminal is refused and nothing is written.
152
+ *
153
+ * The caller must validate the entry (`validateWorkflowEntry`) before
154
+ * acquiring the lock; this helper asserts it as a safety net (cheap —
155
+ * an invalid entry would fail `validateStatusV2` anyway, but the explicit
156
+ * gate keeps the pre-lock fail-fast contract of `registerWorkflow`).
157
+ */
158
+ export declare function registerWorkflowEntryLocked(statusPath: string, entry: WorkflowEntry): StatusV2Doc;
139
159
  /**
140
160
  * Register one active workflow entry in the v2 root file (plan Task 3).
141
161
  * Idempotent upsert by entry `id` under the root-file `withStatusWriteLock`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mstar-harness/engine",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "description": "Morning Star Harness Workflow Engine — deterministic workflow enforcement library (path, status, lease, dispatch, sdd, iteration, lint gates).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,13 +16,17 @@
16
16
  "types": "./dist/index.d.ts",
17
17
  "default": "./dist/engine.js"
18
18
  },
19
- "./package.json": "./package.json"
19
+ "./package.json": "./package.json",
20
+ "./src/audit": {
21
+ "types": "./dist/audit.d.ts",
22
+ "default": "./dist/audit.js"
23
+ }
20
24
  },
21
25
  "files": [
22
26
  "dist"
23
27
  ],
24
28
  "scripts": {
25
- "build": "rm -rf dist && bun build src/index.ts --target node --outfile dist/engine.js && bunx tsc",
29
+ "build": "rm -rf dist && bun build src/index.ts --target node --outfile dist/engine.js && bun build src/audit.ts --target node --outfile dist/audit.js && bunx tsc",
26
30
  "test": "bun test",
27
31
  "prepublishOnly": "bun run build"
28
32
  },