@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 +42 -0
- package/dist/audit.js +755 -0
- package/dist/core.d.ts +1 -1
- package/dist/engine.js +172 -106
- package/dist/index.d.ts +2 -2
- package/dist/status.d.ts +20 -0
- package/package.json +7 -3
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
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
|
|
3701
|
-
import { basename as
|
|
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 (
|
|
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(
|
|
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 (!
|
|
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(
|
|
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 +
|
|
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
|
|
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 (!
|
|
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,
|
|
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.
|
|
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
|
},
|