@mstar-harness/engine 3.9.3 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit.d.ts +23 -7
- package/dist/audit.js +1015 -177
- package/dist/coordination-write.d.ts +170 -0
- package/dist/coordination.d.ts +369 -0
- package/dist/dispatch.d.ts +15 -1
- package/dist/engine.js +4730 -549
- package/dist/index.d.ts +4 -2
- package/dist/iteration.d.ts +33 -1
- package/dist/lease.d.ts +13 -1
- package/dist/migrate.d.ts +48 -1
- package/dist/path.d.ts +15 -6
- package/dist/sdd.d.ts +9 -1
- package/dist/status.d.ts +21 -1
- package/dist/workflow.d.ts +376 -5
- package/package.json +1 -1
package/dist/audit.js
CHANGED
|
@@ -1,53 +1,90 @@
|
|
|
1
1
|
// src/audit.ts
|
|
2
2
|
import { execFileSync as execFileSync2 } from "node:child_process";
|
|
3
|
-
import { existsSync as
|
|
4
|
-
import { basename as
|
|
5
|
-
|
|
6
|
-
// src/core.ts
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { randomUUID } from "node:crypto";
|
|
9
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
10
|
-
function readJson(filePath) {
|
|
11
|
-
if (!existsSync(filePath))
|
|
12
|
-
return {};
|
|
13
|
-
const content = readFileSync(filePath, "utf8").trim();
|
|
14
|
-
if (!content)
|
|
15
|
-
return {};
|
|
16
|
-
try {
|
|
17
|
-
return JSON.parse(content);
|
|
18
|
-
} catch (error) {
|
|
19
|
-
throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
function writeJson(filePath, value) {
|
|
23
|
-
const parent = dirname(filePath);
|
|
24
|
-
mkdirSync(parent, { recursive: true });
|
|
25
|
-
const tmp = join(parent, `.${basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
26
|
-
try {
|
|
27
|
-
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
|
|
28
|
-
`, "utf8");
|
|
29
|
-
renameSync(tmp, filePath);
|
|
30
|
-
} catch (error) {
|
|
31
|
-
try {
|
|
32
|
-
unlinkSync(tmp);
|
|
33
|
-
} catch {}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
3
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync as readdirSync5, readFileSync as readFileSync7, rmdirSync as rmdirSync3, writeFileSync as writeFileSync4 } from "node:fs";
|
|
4
|
+
import { basename as basename5, join as join9, resolve as resolve9, sep as sep3 } from "node:path";
|
|
37
5
|
|
|
38
6
|
// src/lease.ts
|
|
39
|
-
import { mkdirSync
|
|
40
|
-
import { dirname
|
|
7
|
+
import { mkdirSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
41
9
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
42
10
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
11
|
+
function isPlainObject(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function violation(severity, code, message, fix) {
|
|
15
|
+
return { ok: false, severity, code, message, fix };
|
|
16
|
+
}
|
|
17
|
+
function validateNonEmptyString(violations, value, field, missingCode, invalidCode) {
|
|
18
|
+
if (value === undefined) {
|
|
19
|
+
violations.push(violation("high", missingCode, `missing required field: ${field}`));
|
|
20
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
21
|
+
violations.push(violation("medium", invalidCode, `${field} must be a non-empty string`));
|
|
22
|
+
}
|
|
23
|
+
}
|
|
43
24
|
var DATE_PART = String.raw`\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])`;
|
|
44
25
|
var RFC3339_Z_RE = new RegExp(String.raw`^${DATE_PART}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$`);
|
|
45
26
|
var DATE_ONLY_RE = new RegExp(String.raw`^${DATE_PART}$`);
|
|
27
|
+
function isValidClaimedAt(value) {
|
|
28
|
+
return typeof value === "string" && (RFC3339_Z_RE.test(value) || DATE_ONLY_RE.test(value));
|
|
29
|
+
}
|
|
30
|
+
function validateExecutionLease(lease) {
|
|
31
|
+
const violations = [];
|
|
32
|
+
if (!isPlainObject(lease)) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
violations: [
|
|
36
|
+
violation("high", "lease.execution-lease.invalid", "execution_lease must be an object — null and tombstone objects are invalid; writers delete the key on release")
|
|
37
|
+
]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
validateNonEmptyString(violations, lease.holder, "holder", "lease.execution-lease.missing-holder", "lease.execution-lease.invalid-holder");
|
|
41
|
+
if (lease.claimed_at === undefined) {
|
|
42
|
+
violations.push(violation("high", "lease.execution-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
43
|
+
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
44
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T02:30:00Z) or a YYYY-MM-DD date"));
|
|
45
|
+
}
|
|
46
|
+
if (lease.worktree_path === undefined) {
|
|
47
|
+
violations.push(violation("high", "lease.execution-lease.missing-worktree-path", "missing required field: worktree_path"));
|
|
48
|
+
} else if (typeof lease.worktree_path !== "string" || lease.worktree_path.trim() === "") {
|
|
49
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be a non-empty string"));
|
|
50
|
+
} else if (!isAbsolute(lease.worktree_path)) {
|
|
51
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-worktree-path", "worktree_path must be an absolute path — it identifies the dedicated feature-worktree root (a Git checkout distinct from the main worktree and the integration worktree)"));
|
|
52
|
+
}
|
|
53
|
+
validateNonEmptyString(violations, lease.working_branch, "working_branch", "lease.execution-lease.missing-working-branch", "lease.execution-lease.invalid-working-branch");
|
|
54
|
+
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
55
|
+
violations.push(violation("medium", "lease.execution-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
56
|
+
}
|
|
57
|
+
return { ok: violations.length === 0, violations };
|
|
58
|
+
}
|
|
59
|
+
function validateIntegrationMergeLease(lease) {
|
|
60
|
+
const violations = [];
|
|
61
|
+
if (!isPlainObject(lease)) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
violations: [
|
|
65
|
+
violation("high", "lease.merge-lease.invalid", "integration_merge_lease must be an object — absent means unclaimed; null and tombstone objects are invalid; writers delete the key on release")
|
|
66
|
+
]
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
validateNonEmptyString(violations, lease.holder, "holder", "lease.merge-lease.missing-holder", "lease.merge-lease.invalid-holder");
|
|
70
|
+
if (lease.claimed_at === undefined) {
|
|
71
|
+
violations.push(violation("high", "lease.merge-lease.missing-claimed-at", "missing required field: claimed_at"));
|
|
72
|
+
} else if (!isValidClaimedAt(lease.claimed_at)) {
|
|
73
|
+
violations.push(violation("medium", "lease.merge-lease.invalid-claimed-at", "claimed_at must be an RFC 3339 UTC timestamp with explicit Z (e.g. 2026-07-22T04:00:00Z) or a YYYY-MM-DD date"));
|
|
74
|
+
}
|
|
75
|
+
validateNonEmptyString(violations, lease.plan_id, "plan_id", "lease.merge-lease.missing-plan-id", "lease.merge-lease.invalid-plan-id");
|
|
76
|
+
validateNonEmptyString(violations, lease.source_branch, "source_branch", "lease.merge-lease.missing-source-branch", "lease.merge-lease.invalid-source-branch");
|
|
77
|
+
validateNonEmptyString(violations, lease.target_branch, "target_branch", "lease.merge-lease.missing-target-branch", "lease.merge-lease.invalid-target-branch");
|
|
78
|
+
if (lease.session_label !== undefined && typeof lease.session_label !== "string") {
|
|
79
|
+
violations.push(violation("medium", "lease.merge-lease.invalid-session-label", "session_label must be a string (display only — never used for ownership comparison)"));
|
|
80
|
+
}
|
|
81
|
+
return { ok: violations.length === 0, violations };
|
|
82
|
+
}
|
|
46
83
|
var STATUS_WRITE_LOCKDIR = ".status-write.lockdir";
|
|
47
84
|
var LOCKDIR_HOLDER_PID = "holder.pid";
|
|
48
85
|
var heldLockDirs = new AsyncLocalStorage;
|
|
49
86
|
async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
50
|
-
const lockDir =
|
|
87
|
+
const lockDir = join(dirname(resolve(statusPath)), STATUS_WRITE_LOCKDIR);
|
|
51
88
|
const held = heldLockDirs.getStore();
|
|
52
89
|
if (held !== undefined && held.has(lockDir)) {
|
|
53
90
|
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`);
|
|
@@ -58,7 +95,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
58
95
|
let acquired = null;
|
|
59
96
|
for (;; ) {
|
|
60
97
|
try {
|
|
61
|
-
|
|
98
|
+
mkdirSync(lockDir);
|
|
62
99
|
const st = statSync(lockDir);
|
|
63
100
|
acquired = { dev: st.dev, ino: st.ino };
|
|
64
101
|
break;
|
|
@@ -72,7 +109,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
72
109
|
}
|
|
73
110
|
}
|
|
74
111
|
try {
|
|
75
|
-
|
|
112
|
+
writeFileSync(join(lockDir, LOCKDIR_HOLDER_PID), String(process.pid), "utf8");
|
|
76
113
|
} catch {}
|
|
77
114
|
const owns = held ?? new Set;
|
|
78
115
|
owns.add(lockDir);
|
|
@@ -84,7 +121,7 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
84
121
|
const current = statSync(lockDir);
|
|
85
122
|
if (acquired !== null && current.dev === acquired.dev && current.ino === acquired.ino) {
|
|
86
123
|
try {
|
|
87
|
-
|
|
124
|
+
unlinkSync(join(lockDir, LOCKDIR_HOLDER_PID));
|
|
88
125
|
} catch {}
|
|
89
126
|
rmdirSync(lockDir);
|
|
90
127
|
}
|
|
@@ -92,14 +129,384 @@ async function withStatusWriteLock(statusPath, fn, opts = {}) {
|
|
|
92
129
|
}
|
|
93
130
|
}
|
|
94
131
|
|
|
132
|
+
// src/coordination-write.ts
|
|
133
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
|
|
134
|
+
import { createHash } from "node:crypto";
|
|
135
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
136
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, resolve as resolve2 } from "node:path";
|
|
137
|
+
class CoordinationError extends Error {
|
|
138
|
+
code;
|
|
139
|
+
details;
|
|
140
|
+
constructor(code, message, details = {}) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.name = "CoordinationError";
|
|
143
|
+
this.code = code;
|
|
144
|
+
this.details = details;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function isPlainObject2(value) {
|
|
148
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
149
|
+
}
|
|
150
|
+
function isNonEmptyString(value) {
|
|
151
|
+
return typeof value === "string" && value.trim() !== "";
|
|
152
|
+
}
|
|
153
|
+
function artifactVersion(bytes) {
|
|
154
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
155
|
+
}
|
|
156
|
+
function readArtifactBytes(filePath) {
|
|
157
|
+
if (!existsSync(filePath))
|
|
158
|
+
return;
|
|
159
|
+
const bytes = readFileSync(filePath);
|
|
160
|
+
const version = artifactVersion(bytes);
|
|
161
|
+
let payload;
|
|
162
|
+
try {
|
|
163
|
+
payload = JSON.parse(bytes.toString("utf8"));
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new CoordinationError("coordination.store", `Invalid JSON in ${filePath}: ${error.message}`, {
|
|
166
|
+
path: filePath
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return { payload, version };
|
|
170
|
+
}
|
|
171
|
+
function canonicalTarget(target) {
|
|
172
|
+
const abs = resolve2(target);
|
|
173
|
+
let dir = abs;
|
|
174
|
+
const tail = [];
|
|
175
|
+
for (;; ) {
|
|
176
|
+
if (existsSync(dir)) {
|
|
177
|
+
try {
|
|
178
|
+
return join2(realpathSync(dir), ...tail);
|
|
179
|
+
} catch {
|
|
180
|
+
return abs;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const parent = dirname2(dir);
|
|
184
|
+
if (parent === dir)
|
|
185
|
+
return abs;
|
|
186
|
+
tail.unshift(basename(dir));
|
|
187
|
+
dir = parent;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
var writeAuthorizations = new AsyncLocalStorage2;
|
|
191
|
+
async function withProtectedWrite(target, operation, fn) {
|
|
192
|
+
const inherited = writeAuthorizations.getStore() ?? [];
|
|
193
|
+
return writeAuthorizations.run([...inherited, { target: canonicalTarget(target), operation }], async () => fn());
|
|
194
|
+
}
|
|
195
|
+
function isWriteAuthorized(target, operation) {
|
|
196
|
+
const active = writeAuthorizations.getStore();
|
|
197
|
+
if (active === undefined || active.length === 0)
|
|
198
|
+
return false;
|
|
199
|
+
const canonical = canonicalTarget(target);
|
|
200
|
+
return active.some((entry) => entry.target === canonical && entry.operation === operation);
|
|
201
|
+
}
|
|
202
|
+
function assertProtectedWriteAuthorized(target, operation, kind) {
|
|
203
|
+
const canonical = canonicalTarget(target);
|
|
204
|
+
if (isWriteAuthorized(canonical, operation))
|
|
205
|
+
return;
|
|
206
|
+
throw new CoordinationError("coordination.direct-write-refused", `${canonical} is a protected coordination document (${kind}) — a raw store.${operation} is refused; use the coordination API (bind/prepare/progress/residual/handoff/accept/return/complete) or the locked writer`, { path: canonical, operation, kind });
|
|
207
|
+
}
|
|
208
|
+
var HANDOFF_STATES = [
|
|
209
|
+
"submitted",
|
|
210
|
+
"accepted",
|
|
211
|
+
"returned",
|
|
212
|
+
"integrating",
|
|
213
|
+
"merged",
|
|
214
|
+
"completed"
|
|
215
|
+
];
|
|
216
|
+
var PLAN_PROGRESS_STATUSES = ["InProgress", "InReview", "Blocked"];
|
|
217
|
+
var SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
218
|
+
var GIT_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
219
|
+
var HASH_RE = /^sha256:[0-9a-f]{64}$/;
|
|
220
|
+
function invalid(code, message) {
|
|
221
|
+
return { ok: false, severity: "high", code, message };
|
|
222
|
+
}
|
|
223
|
+
function validateBinding(value, what) {
|
|
224
|
+
if (!isPlainObject2(value))
|
|
225
|
+
return [invalid("coordination.row.binding-shape", `${what} must be an object`)];
|
|
226
|
+
const violations = [];
|
|
227
|
+
const extra = Object.keys(value).filter((key) => !["session_id", "session_file", "bound_at"].includes(key));
|
|
228
|
+
if (extra.length > 0) {
|
|
229
|
+
violations.push(invalid("coordination.row.binding-field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
230
|
+
}
|
|
231
|
+
if (!isNonEmptyString(value.session_id)) {
|
|
232
|
+
violations.push(invalid("coordination.row.binding-field", `${what}.session_id must be a non-empty string`));
|
|
233
|
+
}
|
|
234
|
+
if (!isNonEmptyString(value.session_file) || !isAbsolute2(String(value.session_file))) {
|
|
235
|
+
violations.push(invalid("coordination.row.binding-field", `${what}.session_file must be an absolute path`));
|
|
236
|
+
}
|
|
237
|
+
if (!isNonEmptyString(value.bound_at)) {
|
|
238
|
+
violations.push(invalid("coordination.row.binding-field", `${what}.bound_at must be a timestamp`));
|
|
239
|
+
}
|
|
240
|
+
return violations;
|
|
241
|
+
}
|
|
242
|
+
function validateEvidenceRef(value, what) {
|
|
243
|
+
if (!isPlainObject2(value))
|
|
244
|
+
return [invalid("coordination.row.evidence-shape", `${what} must be a hash-pinned reference`)];
|
|
245
|
+
const violations = [];
|
|
246
|
+
const extra = Object.keys(value).filter((key) => !["path", "sha256"].includes(key));
|
|
247
|
+
if (extra.length > 0) {
|
|
248
|
+
violations.push(invalid("coordination.row.evidence-field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
249
|
+
}
|
|
250
|
+
if (!isNonEmptyString(value.path) || !isAbsolute2(String(value.path))) {
|
|
251
|
+
violations.push(invalid("coordination.row.evidence-field", `${what}.path must be an absolute path`));
|
|
252
|
+
}
|
|
253
|
+
if (!isNonEmptyString(value.sha256) || !SHA256_HEX.test(String(value.sha256))) {
|
|
254
|
+
violations.push(invalid("coordination.row.evidence-field", `${what}.sha256 must be 64 lowercase hex`));
|
|
255
|
+
}
|
|
256
|
+
return violations;
|
|
257
|
+
}
|
|
258
|
+
function validatePlanProgress(value, what = "coordination.progress") {
|
|
259
|
+
if (!isPlainObject2(value))
|
|
260
|
+
return [invalid("coordination.row.progress-shape", `${what} must be an object`)];
|
|
261
|
+
const violations = [];
|
|
262
|
+
const extra = Object.keys(value).filter((key) => !["status", "summary", "evidence_paths", "track_branches"].includes(key));
|
|
263
|
+
if (extra.length > 0) {
|
|
264
|
+
violations.push(invalid("coordination.row.progress-field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
265
|
+
}
|
|
266
|
+
if (!PLAN_PROGRESS_STATUSES.includes(value.status)) {
|
|
267
|
+
violations.push(invalid("coordination.row.progress-field", `${what}.status must be one of ${PLAN_PROGRESS_STATUSES.join(", ")}`));
|
|
268
|
+
}
|
|
269
|
+
if (!isNonEmptyString(value.summary)) {
|
|
270
|
+
violations.push(invalid("coordination.row.progress-field", `${what}.summary must be a non-empty string`));
|
|
271
|
+
}
|
|
272
|
+
if (!Array.isArray(value.evidence_paths) || !value.evidence_paths.every(isNonEmptyString)) {
|
|
273
|
+
violations.push(invalid("coordination.row.progress-field", `${what}.evidence_paths must be an array of paths`));
|
|
274
|
+
}
|
|
275
|
+
if (value.track_branches !== undefined) {
|
|
276
|
+
if (!Array.isArray(value.track_branches) || !value.track_branches.every(isNonEmptyString)) {
|
|
277
|
+
violations.push(invalid("coordination.row.progress-field", `${what}.track_branches must be an array of branch names`));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return violations;
|
|
281
|
+
}
|
|
282
|
+
function validatePlanHandoff(value, what = "coordination.handoff") {
|
|
283
|
+
if (!isPlainObject2(value))
|
|
284
|
+
return [invalid("coordination.row.handoff-shape", `${what} must be an object`)];
|
|
285
|
+
const allowed = [
|
|
286
|
+
"id",
|
|
287
|
+
"attempt",
|
|
288
|
+
"state",
|
|
289
|
+
"submitted_by",
|
|
290
|
+
"submitted_at",
|
|
291
|
+
"source_branch",
|
|
292
|
+
"source_sha",
|
|
293
|
+
"worktree_path",
|
|
294
|
+
"review_base",
|
|
295
|
+
"review_head",
|
|
296
|
+
"qc",
|
|
297
|
+
"qa",
|
|
298
|
+
"accepted_by",
|
|
299
|
+
"accepted_at",
|
|
300
|
+
"returned_at",
|
|
301
|
+
"return_reason",
|
|
302
|
+
"integration",
|
|
303
|
+
"completed_at"
|
|
304
|
+
];
|
|
305
|
+
const violations = [];
|
|
306
|
+
const extra = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
307
|
+
if (extra.length > 0) {
|
|
308
|
+
violations.push(invalid("coordination.row.handoff-field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
309
|
+
}
|
|
310
|
+
const required = [
|
|
311
|
+
"id",
|
|
312
|
+
"attempt",
|
|
313
|
+
"state",
|
|
314
|
+
"submitted_by",
|
|
315
|
+
"submitted_at",
|
|
316
|
+
"source_branch",
|
|
317
|
+
"source_sha",
|
|
318
|
+
"worktree_path",
|
|
319
|
+
"review_base",
|
|
320
|
+
"review_head"
|
|
321
|
+
];
|
|
322
|
+
for (const key of required) {
|
|
323
|
+
if (value[key] === undefined) {
|
|
324
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.${key} is required`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!Number.isInteger(value.attempt) || value.attempt < 1) {
|
|
328
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.attempt must be a positive integer`));
|
|
329
|
+
}
|
|
330
|
+
if (!HANDOFF_STATES.includes(value.state)) {
|
|
331
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.state must be one of ${HANDOFF_STATES.join(", ")}`));
|
|
332
|
+
}
|
|
333
|
+
if (value.id !== undefined && !isNonEmptyString(value.id)) {
|
|
334
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.id must be a non-empty string`));
|
|
335
|
+
}
|
|
336
|
+
for (const key of ["source_sha", "review_base", "review_head"]) {
|
|
337
|
+
if (value[key] !== undefined && !GIT_SHA.test(String(value[key]))) {
|
|
338
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.${key} must be a 40-hex git object id`));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
for (const key of ["submitted_at", "accepted_at", "returned_at", "completed_at"]) {
|
|
342
|
+
if (value[key] !== undefined && !isNonEmptyString(value[key])) {
|
|
343
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.${key} must be a timestamp`));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (value.worktree_path !== undefined && (!isNonEmptyString(value.worktree_path) || !isAbsolute2(String(value.worktree_path)))) {
|
|
347
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.worktree_path must be an absolute path`));
|
|
348
|
+
}
|
|
349
|
+
if (value.qc !== undefined) {
|
|
350
|
+
if (!isPlainObject2(value.qc)) {
|
|
351
|
+
violations.push(invalid("coordination.row.handoff-shape", `${what}.qc must be an object`));
|
|
352
|
+
} else {
|
|
353
|
+
const qc = value.qc;
|
|
354
|
+
const qcExtra = Object.keys(qc).filter((key) => !["decision", "reports", "consolidated"].includes(key));
|
|
355
|
+
if (qcExtra.length > 0) {
|
|
356
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qc has unexpected key(s): ${qcExtra.join(", ")}`));
|
|
357
|
+
}
|
|
358
|
+
if (!isNonEmptyString(qc.decision)) {
|
|
359
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qc.decision must be a non-empty string`));
|
|
360
|
+
}
|
|
361
|
+
if (!Array.isArray(qc.reports) || qc.reports.length === 0) {
|
|
362
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qc.reports must be a non-empty array`));
|
|
363
|
+
} else {
|
|
364
|
+
qc.reports.forEach((ref, index) => {
|
|
365
|
+
violations.push(...validateEvidenceRef(ref, `${what}.qc.reports[${index}]`));
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
violations.push(...validateEvidenceRef(qc.consolidated, `${what}.qc.consolidated`));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (value.qa !== undefined) {
|
|
372
|
+
if (!isPlainObject2(value.qa)) {
|
|
373
|
+
violations.push(invalid("coordination.row.handoff-shape", `${what}.qa must be an object`));
|
|
374
|
+
} else {
|
|
375
|
+
const qa = value.qa;
|
|
376
|
+
const qaExtra = Object.keys(qa).filter((key) => !["gate", "decision", "report"].includes(key));
|
|
377
|
+
if (qaExtra.length > 0) {
|
|
378
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qa has unexpected key(s): ${qaExtra.join(", ")}`));
|
|
379
|
+
}
|
|
380
|
+
if (!isNonEmptyString(qa.gate)) {
|
|
381
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qa.gate must be a non-empty string`));
|
|
382
|
+
}
|
|
383
|
+
if (!isNonEmptyString(qa.decision)) {
|
|
384
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.qa.decision must be a non-empty string`));
|
|
385
|
+
}
|
|
386
|
+
violations.push(...validateEvidenceRef(qa.report, `${what}.qa.report`));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (value.integration !== undefined) {
|
|
390
|
+
if (!isPlainObject2(value.integration)) {
|
|
391
|
+
violations.push(invalid("coordination.row.handoff-shape", `${what}.integration must be an object`));
|
|
392
|
+
} else {
|
|
393
|
+
const integration = value.integration;
|
|
394
|
+
const integrationAllowed = [
|
|
395
|
+
"target_branch",
|
|
396
|
+
"worktree_path",
|
|
397
|
+
"base_sha",
|
|
398
|
+
"started_at",
|
|
399
|
+
"result_sha",
|
|
400
|
+
"verified_at"
|
|
401
|
+
];
|
|
402
|
+
const integrationExtra = Object.keys(integration).filter((key) => !integrationAllowed.includes(key));
|
|
403
|
+
if (integrationExtra.length > 0) {
|
|
404
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.integration has unexpected key(s): ${integrationExtra.join(", ")}`));
|
|
405
|
+
}
|
|
406
|
+
for (const key of ["target_branch", "worktree_path", "base_sha", "started_at"]) {
|
|
407
|
+
if (!isNonEmptyString(integration[key])) {
|
|
408
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.integration.${key} is required`));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const key of ["base_sha", "result_sha"]) {
|
|
412
|
+
if (integration[key] !== undefined && !GIT_SHA.test(String(integration[key]))) {
|
|
413
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.integration.${key} must be a 40-hex git object id`));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (integration.result_sha !== undefined && integration.verified_at === undefined) {
|
|
417
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.integration.result_sha requires verified_at`));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if ((value.state === "integrating" || value.state === "merged" || value.state === "completed") && value.integration === undefined) {
|
|
422
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.state ${String(value.state)} requires integration`));
|
|
423
|
+
}
|
|
424
|
+
return violations;
|
|
425
|
+
}
|
|
426
|
+
function validatePreparedCoordination(value, what = "coordination.prepared") {
|
|
427
|
+
if (!isPlainObject2(value))
|
|
428
|
+
return [invalid("coordination.row.prepared-shape", `${what} must be an object`)];
|
|
429
|
+
const allowed = [
|
|
430
|
+
"assignment_path",
|
|
431
|
+
"assignment_sha256",
|
|
432
|
+
"plan_sha256",
|
|
433
|
+
"qa_gate",
|
|
434
|
+
"findings_cleanup",
|
|
435
|
+
"prepared_by",
|
|
436
|
+
"prepared_at"
|
|
437
|
+
];
|
|
438
|
+
const violations = [];
|
|
439
|
+
const extra = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
440
|
+
if (extra.length > 0) {
|
|
441
|
+
violations.push(invalid("coordination.row.prepared-field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
442
|
+
}
|
|
443
|
+
for (const key of allowed) {
|
|
444
|
+
if (!isNonEmptyString(value[key])) {
|
|
445
|
+
violations.push(invalid("coordination.row.prepared-field", `${what}.${key} is required`));
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (value.assignment_path !== undefined && !isAbsolute2(String(value.assignment_path))) {
|
|
449
|
+
violations.push(invalid("coordination.row.prepared-field", `${what}.assignment_path must be absolute`));
|
|
450
|
+
}
|
|
451
|
+
for (const key of ["assignment_sha256", "plan_sha256"]) {
|
|
452
|
+
if (value[key] !== undefined && !SHA256_HEX.test(String(value[key]))) {
|
|
453
|
+
violations.push(invalid("coordination.row.prepared-field", `${what}.${key} must be 64 lowercase hex`));
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return violations;
|
|
457
|
+
}
|
|
458
|
+
function validateRowCoordination(value, what = "coordination") {
|
|
459
|
+
if (!isPlainObject2(value))
|
|
460
|
+
return [invalid("coordination.row.shape", `${what} must be an object`)];
|
|
461
|
+
const allowed = ["revision", "prepared", "session", "progress", "handoff"];
|
|
462
|
+
const violations = [];
|
|
463
|
+
const extra = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
464
|
+
if (extra.length > 0) {
|
|
465
|
+
violations.push(invalid("coordination.row.field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
466
|
+
}
|
|
467
|
+
if (!Number.isInteger(value.revision) || value.revision < 0) {
|
|
468
|
+
violations.push(invalid("coordination.row.revision", `${what}.revision must be a non-negative integer`));
|
|
469
|
+
}
|
|
470
|
+
if (value.prepared !== undefined)
|
|
471
|
+
violations.push(...validatePreparedCoordination(value.prepared, `${what}.prepared`));
|
|
472
|
+
if (value.session !== undefined)
|
|
473
|
+
violations.push(...validateBinding(value.session, `${what}.session`));
|
|
474
|
+
if (value.progress !== undefined)
|
|
475
|
+
violations.push(...validatePlanProgress(value.progress, `${what}.progress`));
|
|
476
|
+
if (value.handoff !== undefined)
|
|
477
|
+
violations.push(...validatePlanHandoff(value.handoff, `${what}.handoff`));
|
|
478
|
+
if (value.handoff !== undefined && value.session === undefined) {
|
|
479
|
+
violations.push(invalid("coordination.row.handoff-field", `${what}.handoff requires a bound plan session`));
|
|
480
|
+
}
|
|
481
|
+
return violations;
|
|
482
|
+
}
|
|
483
|
+
function validateSnapshotCoordination(value, what = "coordination") {
|
|
484
|
+
if (!isPlainObject2(value))
|
|
485
|
+
return [invalid("coordination.snapshot.shape", `${what} must be an object`)];
|
|
486
|
+
const violations = [];
|
|
487
|
+
const extra = Object.keys(value).filter((key) => key !== "coordinator");
|
|
488
|
+
if (extra.length > 0) {
|
|
489
|
+
violations.push(invalid("coordination.snapshot.field", `${what} has unexpected key(s): ${extra.join(", ")}`));
|
|
490
|
+
}
|
|
491
|
+
if (value.coordinator === undefined) {
|
|
492
|
+
violations.push(invalid("coordination.snapshot.field", `${what}.coordinator is required`));
|
|
493
|
+
} else {
|
|
494
|
+
violations.push(...validateBinding(value.coordinator, `${what}.coordinator`));
|
|
495
|
+
}
|
|
496
|
+
return violations;
|
|
497
|
+
}
|
|
498
|
+
function isArtifactVersion(value) {
|
|
499
|
+
return typeof value === "string" && (value === "absent" || HASH_RE.test(value));
|
|
500
|
+
}
|
|
501
|
+
|
|
95
502
|
// src/path.ts
|
|
96
|
-
import { existsSync as
|
|
503
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readdirSync as readdirSync4, readFileSync as readFileSync6, realpathSync as realpathSync3, statSync as statSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
97
504
|
import { execFileSync } from "node:child_process";
|
|
98
|
-
import { basename as
|
|
505
|
+
import { basename as basename4, dirname as dirname6, isAbsolute as isAbsolute6, join as join8, relative as relative2, resolve as resolve8 } from "node:path";
|
|
99
506
|
|
|
100
507
|
// src/mstarc.ts
|
|
101
508
|
import { readFileSync as readFileSync2, statSync as statSync2 } from "node:fs";
|
|
102
|
-
import { dirname as dirname3, isAbsolute as
|
|
509
|
+
import { dirname as dirname3, isAbsolute as isAbsolute3, join as join3, relative, resolve as resolve3 } from "node:path";
|
|
103
510
|
var MSTARC_FILE = ".mstarc";
|
|
104
511
|
var MSTARC_SECTION = "config";
|
|
105
512
|
var MSTARC_HARNESS_DIR_KEY = "harness_dir";
|
|
@@ -183,21 +590,53 @@ function loadMstarc(startDir, boundary) {
|
|
|
183
590
|
}
|
|
184
591
|
function isAtOrBelow(dir, root) {
|
|
185
592
|
const rel = relative(root, dir);
|
|
186
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
593
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute3(rel);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// src/core.ts
|
|
597
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, unlinkSync as unlinkSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
598
|
+
import { randomUUID } from "node:crypto";
|
|
599
|
+
import { basename as basename2, dirname as dirname4, join as join4, resolve as resolve4 } from "node:path";
|
|
600
|
+
function readJson(filePath) {
|
|
601
|
+
if (!existsSync2(filePath))
|
|
602
|
+
return {};
|
|
603
|
+
const content = readFileSync3(filePath, "utf8").trim();
|
|
604
|
+
if (!content)
|
|
605
|
+
return {};
|
|
606
|
+
try {
|
|
607
|
+
return JSON.parse(content);
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw new Error(`Invalid JSON in ${filePath}: ${error.message}`);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
function writeJson(filePath, value) {
|
|
613
|
+
const parent = dirname4(filePath);
|
|
614
|
+
mkdirSync2(parent, { recursive: true });
|
|
615
|
+
const tmp = join4(parent, `.${basename2(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
616
|
+
try {
|
|
617
|
+
writeFileSync2(tmp, `${JSON.stringify(value, null, 2)}
|
|
618
|
+
`, "utf8");
|
|
619
|
+
renameSync(tmp, filePath);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
try {
|
|
622
|
+
unlinkSync2(tmp);
|
|
623
|
+
} catch {}
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
187
626
|
}
|
|
188
627
|
|
|
189
628
|
// src/status.ts
|
|
190
|
-
import { existsSync as
|
|
191
|
-
import { dirname as
|
|
629
|
+
import { existsSync as existsSync5, readFileSync as readFileSync5, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
|
|
630
|
+
import { dirname as dirname5, join as join7, resolve as resolve7, sep as sep2 } from "node:path";
|
|
192
631
|
|
|
193
632
|
// src/store.ts
|
|
194
|
-
import { existsSync as
|
|
195
|
-
import { isAbsolute as
|
|
633
|
+
import { existsSync as existsSync3, readdirSync, unlinkSync as unlinkSync3 } from "node:fs";
|
|
634
|
+
import { basename as basename3, isAbsolute as isAbsolute4, join as join5, resolve as resolve5, sep } from "node:path";
|
|
196
635
|
var PLAN_SHAPED_KEY_RE = /^[0-9]{8}-[a-z0-9-]+$/;
|
|
197
636
|
function resolveArtifactPath(harnessRoot, ref) {
|
|
198
637
|
const { kind, key } = ref;
|
|
199
638
|
if (kind === "json") {
|
|
200
|
-
if (!
|
|
639
|
+
if (!isAbsolute4(key)) {
|
|
201
640
|
throw new Error(`ArtifactStore json key must be an absolute path — got ${JSON.stringify(key)}`);
|
|
202
641
|
}
|
|
203
642
|
if (key.split(/[\\/]+/).includes("..")) {
|
|
@@ -210,18 +649,18 @@ function resolveArtifactPath(harnessRoot, ref) {
|
|
|
210
649
|
if (key !== "root") {
|
|
211
650
|
throw new Error(`ArtifactStore status key must be "root" — got ${JSON.stringify(key)}`);
|
|
212
651
|
}
|
|
213
|
-
return
|
|
652
|
+
return join5(harnessRoot, "status.json");
|
|
214
653
|
}
|
|
215
654
|
if (kind === "snapshot") {
|
|
216
|
-
return
|
|
655
|
+
return join5(resolveWorkflowDir(harnessRoot, { harnessDir: harnessRoot }), key, "snapshot.json");
|
|
217
656
|
}
|
|
218
657
|
if (kind === "residuals") {
|
|
219
|
-
return
|
|
658
|
+
return join5(resolveProjectDir(harnessRoot, { harnessDir: harnessRoot }), key, "residuals.json");
|
|
220
659
|
}
|
|
221
660
|
if (PLAN_SHAPED_KEY_RE.test(key)) {
|
|
222
|
-
return
|
|
661
|
+
return join5(harnessRoot, "sdd", key, "review", "report.json");
|
|
223
662
|
}
|
|
224
|
-
return
|
|
663
|
+
return join5(harnessRoot, "sdd", "_reviews", `${key}.json`);
|
|
225
664
|
}
|
|
226
665
|
function listDirNames(dir) {
|
|
227
666
|
return readDirEntries(dir).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
@@ -239,6 +678,26 @@ function readDirEntries(dir) {
|
|
|
239
678
|
throw error;
|
|
240
679
|
}
|
|
241
680
|
}
|
|
681
|
+
function protectedKindOf(root, ref, filePath) {
|
|
682
|
+
if (ref.kind === "status")
|
|
683
|
+
return "root";
|
|
684
|
+
if (ref.kind === "snapshot")
|
|
685
|
+
return "snapshot";
|
|
686
|
+
if (ref.kind === "residuals")
|
|
687
|
+
return "register";
|
|
688
|
+
if (ref.kind !== "json")
|
|
689
|
+
return null;
|
|
690
|
+
const canonical = canonicalTarget(filePath);
|
|
691
|
+
if (canonical === canonicalTarget(resolveArtifactPath(root, { kind: "status", key: "root" })))
|
|
692
|
+
return "root";
|
|
693
|
+
const workflowDir = canonicalTarget(resolveWorkflowDir(root, { harnessDir: root }));
|
|
694
|
+
if (basename3(canonical) === "snapshot.json" && canonical.startsWith(`${workflowDir}${sep}`))
|
|
695
|
+
return "snapshot";
|
|
696
|
+
const projectDir = canonicalTarget(resolveProjectDir(root, { harnessDir: root }));
|
|
697
|
+
if (basename3(canonical) === "residuals.json" && canonical.startsWith(`${projectDir}${sep}`))
|
|
698
|
+
return "register";
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
242
701
|
function tryResolveGetPath(root, kind, key) {
|
|
243
702
|
try {
|
|
244
703
|
return resolveArtifactPath(root, { kind, key });
|
|
@@ -247,24 +706,31 @@ function tryResolveGetPath(root, kind, key) {
|
|
|
247
706
|
}
|
|
248
707
|
}
|
|
249
708
|
function createFsStore(harnessRoot) {
|
|
250
|
-
const root =
|
|
709
|
+
const root = resolve5(harnessRoot);
|
|
251
710
|
return {
|
|
252
711
|
root,
|
|
253
712
|
async put(doc) {
|
|
254
713
|
if (doc.schema !== undefined) {
|
|
255
714
|
throw new Error("FsStore does not persist schema ids — omit --schema or inject a store module that persists it");
|
|
256
715
|
}
|
|
257
|
-
|
|
716
|
+
const filePath = resolveArtifactPath(root, doc);
|
|
717
|
+
const protectedKind = protectedKindOf(root, doc, filePath);
|
|
718
|
+
if (protectedKind !== null)
|
|
719
|
+
assertProtectedWriteAuthorized(filePath, "put", protectedKind);
|
|
720
|
+
writeJson(filePath, doc.payload);
|
|
258
721
|
},
|
|
259
722
|
async get(ref) {
|
|
260
723
|
const filePath = resolveArtifactPath(root, ref);
|
|
261
|
-
if (!
|
|
724
|
+
if (!existsSync3(filePath))
|
|
262
725
|
return;
|
|
263
726
|
return readJson(filePath);
|
|
264
727
|
},
|
|
265
728
|
async delete(ref) {
|
|
266
729
|
const filePath = resolveArtifactPath(root, ref);
|
|
267
|
-
|
|
730
|
+
const protectedKind = protectedKindOf(root, ref, filePath);
|
|
731
|
+
if (protectedKind !== null)
|
|
732
|
+
assertProtectedWriteAuthorized(filePath, "delete", protectedKind);
|
|
733
|
+
if (existsSync3(filePath))
|
|
268
734
|
unlinkSync3(filePath);
|
|
269
735
|
},
|
|
270
736
|
async list(kind) {
|
|
@@ -273,18 +739,18 @@ function createFsStore(harnessRoot) {
|
|
|
273
739
|
}
|
|
274
740
|
const keys = [];
|
|
275
741
|
if (kind === "status") {
|
|
276
|
-
if (
|
|
742
|
+
if (existsSync3(resolveArtifactPath(root, { kind, key: "root" })))
|
|
277
743
|
keys.push("root");
|
|
278
744
|
} else if (kind === "snapshot" || kind === "residuals") {
|
|
279
745
|
const baseDir = kind === "snapshot" ? resolveWorkflowDir(root, { harnessDir: root }) : resolveProjectDir(root, { harnessDir: root });
|
|
280
746
|
for (const name of listDirNames(baseDir)) {
|
|
281
747
|
const getPath = tryResolveGetPath(root, kind, name);
|
|
282
|
-
if (getPath !== undefined &&
|
|
748
|
+
if (getPath !== undefined && existsSync3(getPath))
|
|
283
749
|
keys.push(name);
|
|
284
750
|
}
|
|
285
751
|
} else {
|
|
286
|
-
const sddDir =
|
|
287
|
-
for (const key of listJsonKeys(
|
|
752
|
+
const sddDir = join5(root, "sdd");
|
|
753
|
+
for (const key of listJsonKeys(join5(sddDir, "_reviews"))) {
|
|
288
754
|
if (!PLAN_SHAPED_KEY_RE.test(key) && tryResolveGetPath(root, kind, key) !== undefined) {
|
|
289
755
|
keys.push(key);
|
|
290
756
|
}
|
|
@@ -292,7 +758,7 @@ function createFsStore(harnessRoot) {
|
|
|
292
758
|
for (const name of listDirNames(sddDir)) {
|
|
293
759
|
if (PLAN_SHAPED_KEY_RE.test(name)) {
|
|
294
760
|
const getPath = tryResolveGetPath(root, kind, name);
|
|
295
|
-
if (getPath !== undefined &&
|
|
761
|
+
if (getPath !== undefined && existsSync3(getPath))
|
|
296
762
|
keys.push(name);
|
|
297
763
|
}
|
|
298
764
|
}
|
|
@@ -307,7 +773,7 @@ function getArtifactStore() {
|
|
|
307
773
|
return injectedStore;
|
|
308
774
|
const root = resolveHarnessDir(process.cwd());
|
|
309
775
|
if (root === null) {
|
|
310
|
-
throw new Error(`harness dir not found from ${
|
|
776
|
+
throw new Error(`harness dir not found from ${resolve5(process.cwd())} — cannot create the default FsStore (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
|
|
311
777
|
}
|
|
312
778
|
return createFsStore(root);
|
|
313
779
|
}
|
|
@@ -316,23 +782,302 @@ function assertFsStorePath(store, ref, expectedPath) {
|
|
|
316
782
|
if (typeof root !== "string")
|
|
317
783
|
return;
|
|
318
784
|
const storePath = resolveArtifactPath(root, ref);
|
|
319
|
-
const expected =
|
|
785
|
+
const expected = resolve5(expectedPath);
|
|
320
786
|
if (storePath !== expected) {
|
|
321
787
|
throw new Error(`routed writer path mismatch: the active FsStore resolves ${ref.kind}/${JSON.stringify(ref.key)} to ${JSON.stringify(storePath)} but the caller's target is ${JSON.stringify(expected)} — call setArtifactStore(createFsStore(<root>)) first when the write target differs from the active store's root`);
|
|
322
788
|
}
|
|
323
789
|
}
|
|
324
790
|
|
|
325
791
|
// src/workflow.ts
|
|
792
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, rmdirSync as rmdirSync2 } from "node:fs";
|
|
793
|
+
import { isAbsolute as isAbsolute5, join as join6, resolve as resolve6 } from "node:path";
|
|
326
794
|
var WORKFLOW_SNAPSHOT_FILE = "snapshot.json";
|
|
795
|
+
var WORKFLOW_LIFECYCLE_STATUSES = ["running", "paused", "completed", "failed", "stopped"];
|
|
327
796
|
var WORKFLOW_TERMINAL_STATUSES = ["completed", "failed", "stopped"];
|
|
328
797
|
var WORKFLOW_LIFECYCLE_TYPES = ["plan", "iteration"];
|
|
798
|
+
var WORKFLOW_DELIVERY_KINDS = ["development", "verification/report-only"];
|
|
799
|
+
var WORKFLOW_COMPOUND_OUTCOMES = ["created", "updated", "skipped"];
|
|
800
|
+
function stableJson(value) {
|
|
801
|
+
if (Array.isArray(value))
|
|
802
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
803
|
+
if (isPlainObject2(value)) {
|
|
804
|
+
const keys = Object.keys(value).sort();
|
|
805
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
806
|
+
}
|
|
807
|
+
return JSON.stringify(value) ?? "undefined";
|
|
808
|
+
}
|
|
809
|
+
function violation2(severity, code, message, fix) {
|
|
810
|
+
return { ok: false, severity, code, message, fix };
|
|
811
|
+
}
|
|
812
|
+
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
813
|
+
if (value === undefined) {
|
|
814
|
+
violations.push(violation2("high", missingCode, `missing required field: ${field}`));
|
|
815
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
816
|
+
violations.push(violation2("medium", invalidCode, `${field} must be a non-empty string`));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
function validateWorktreePathValue(violations, value, field) {
|
|
820
|
+
if (typeof value !== "string" || value.trim() === "" || !isAbsolute5(value)) {
|
|
821
|
+
violations.push(violation2("high", "workflow.snapshot.invalid-integration-worktree-path", `${field} must be a non-empty absolute path — got ${JSON.stringify(value)}`, "record the absolute integration checkout path (integration_worktree_path)"));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
function deliveryEvidenceViolations(value, what) {
|
|
825
|
+
const violations = [];
|
|
826
|
+
const invalid2 = (message) => {
|
|
827
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-delivery-evidence", `${what}: ${message}`));
|
|
828
|
+
};
|
|
829
|
+
if (!isPlainObject2(value)) {
|
|
830
|
+
invalid2("must be an object");
|
|
831
|
+
return violations;
|
|
832
|
+
}
|
|
833
|
+
const members = ["compound", "pr", "merge", "completion"];
|
|
834
|
+
const unknownMembers = Object.keys(value).filter((key) => !members.includes(key));
|
|
835
|
+
if (unknownMembers.length > 0)
|
|
836
|
+
invalid2(`unknown member(s) ${unknownMembers.join(", ")} — expected ${members.join(" | ")}`);
|
|
837
|
+
const compound = value.compound;
|
|
838
|
+
if (compound !== undefined) {
|
|
839
|
+
if (!isPlainObject2(compound))
|
|
840
|
+
invalid2("compound must be an object");
|
|
841
|
+
else {
|
|
842
|
+
const unknown = Object.keys(compound).filter((key) => key !== "outcome" && key !== "reason");
|
|
843
|
+
if (unknown.length > 0)
|
|
844
|
+
invalid2(`compound has unknown key(s) ${unknown.join(", ")}`);
|
|
845
|
+
if (typeof compound.outcome !== "string" || !WORKFLOW_COMPOUND_OUTCOMES.includes(compound.outcome)) {
|
|
846
|
+
invalid2(`compound.outcome must be one of ${WORKFLOW_COMPOUND_OUTCOMES.join(" | ")} — got ${JSON.stringify(compound.outcome)}`);
|
|
847
|
+
} else if (compound.outcome === "skipped" && (typeof compound.reason !== "string" || compound.reason.trim() === "")) {
|
|
848
|
+
invalid2("compound reason is required when the disposition outcome is 'skipped' (contract §4c)");
|
|
849
|
+
} else if (compound.reason !== undefined && (typeof compound.reason !== "string" || compound.reason.trim() === "")) {
|
|
850
|
+
invalid2("compound.reason must be a non-empty string when given");
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const stringMembers = {
|
|
855
|
+
pr: ["repo", "head", "target"],
|
|
856
|
+
merge: ["provider", "evidence"],
|
|
857
|
+
completion: ["policy", "evidence"]
|
|
858
|
+
};
|
|
859
|
+
for (const member of ["pr", "merge", "completion"]) {
|
|
860
|
+
const block = value[member];
|
|
861
|
+
if (block === undefined)
|
|
862
|
+
continue;
|
|
863
|
+
if (!isPlainObject2(block)) {
|
|
864
|
+
invalid2(`${member} must be an object`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
const fields = stringMembers[member];
|
|
868
|
+
const unknown = Object.keys(block).filter((key) => !fields.includes(key));
|
|
869
|
+
if (unknown.length > 0)
|
|
870
|
+
invalid2(`${member} has unknown key(s) ${unknown.join(", ")}`);
|
|
871
|
+
for (const field of fields) {
|
|
872
|
+
if (typeof block[field] !== "string" || block[field].trim() === "") {
|
|
873
|
+
invalid2(`${member}.${field} must be a non-empty string`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return violations;
|
|
878
|
+
}
|
|
879
|
+
function validateWorkflowSnapshot(doc) {
|
|
880
|
+
const violations = [];
|
|
881
|
+
if (!isPlainObject2(doc)) {
|
|
882
|
+
return {
|
|
883
|
+
ok: false,
|
|
884
|
+
violations: [violation2("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
if (doc.schema_version === undefined) {
|
|
888
|
+
violations.push(violation2("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
|
|
889
|
+
} else if (doc.schema_version !== 1) {
|
|
890
|
+
violations.push(violation2("high", "workflow.snapshot.invalid-schema-version", `schema_version must be 1 — got ${JSON.stringify(doc.schema_version)} (version is reserved for the root file discriminator)`));
|
|
891
|
+
}
|
|
892
|
+
if (doc.version !== undefined) {
|
|
893
|
+
violations.push(violation2("medium", "workflow.snapshot.reserved-version", `top-level version is reserved for the root status.json discriminator — snapshots use schema_version; remove the version key (got ${JSON.stringify(doc.version)})`, "remove the version key from the snapshot"));
|
|
894
|
+
}
|
|
895
|
+
validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
|
|
896
|
+
if (doc.type === undefined) {
|
|
897
|
+
violations.push(violation2("high", "workflow.snapshot.missing-type", "missing required field: type"));
|
|
898
|
+
} else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
|
|
899
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
|
|
900
|
+
}
|
|
901
|
+
if (doc.status === undefined) {
|
|
902
|
+
violations.push(violation2("high", "workflow.snapshot.missing-status", "missing required field: status"));
|
|
903
|
+
} else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
|
|
904
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
905
|
+
}
|
|
906
|
+
validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
|
|
907
|
+
validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
|
|
908
|
+
if (doc.ended_at !== undefined) {
|
|
909
|
+
validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
|
|
910
|
+
}
|
|
911
|
+
if (doc.phase !== undefined && typeof doc.phase !== "string") {
|
|
912
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
|
|
913
|
+
}
|
|
914
|
+
if (doc.plans === undefined) {
|
|
915
|
+
violations.push(violation2("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
|
|
916
|
+
} else if (!Array.isArray(doc.plans)) {
|
|
917
|
+
violations.push(violation2("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
|
|
918
|
+
} else {
|
|
919
|
+
for (const row of doc.plans) {
|
|
920
|
+
violations.push(...validatePlanRow(row).violations);
|
|
921
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
922
|
+
violations.push(...validateExecutionLease(row.execution_lease).violations);
|
|
923
|
+
}
|
|
924
|
+
if (isPlainObject2(row) && row.coordination !== undefined) {
|
|
925
|
+
violations.push(...validateRowCoordination(row.coordination, `plans[${String(row.id)}].coordination`));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (doc.coordination !== undefined) {
|
|
930
|
+
violations.push(...validateSnapshotCoordination(doc.coordination));
|
|
931
|
+
}
|
|
932
|
+
if (doc.execution_policy !== undefined) {
|
|
933
|
+
if (!isPlainObject2(doc.execution_policy)) {
|
|
934
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
938
|
+
violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
|
|
939
|
+
}
|
|
940
|
+
if (doc.branch !== undefined) {
|
|
941
|
+
if (!isPlainObject2(doc.branch)) {
|
|
942
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
|
|
943
|
+
} else {
|
|
944
|
+
for (const key of ["base", "source", "integration", "target"]) {
|
|
945
|
+
if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
|
|
946
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const legacyWorktreePath = doc.control_worktree_path;
|
|
952
|
+
const canonicalWorktreePath = doc.integration_worktree_path;
|
|
953
|
+
if (legacyWorktreePath !== undefined && canonicalWorktreePath !== undefined) {
|
|
954
|
+
violations.push(violation2("high", "workflow.snapshot.conflicting-worktree-paths", "both integration_worktree_path and the legacy control_worktree_path key are present — the canonical snapshot carries only integration_worktree_path (refused even when the values are equal)", "remove the legacy control_worktree_path key"));
|
|
955
|
+
} else {
|
|
956
|
+
if (canonicalWorktreePath !== undefined) {
|
|
957
|
+
validateWorktreePathValue(violations, canonicalWorktreePath, "integration_worktree_path");
|
|
958
|
+
}
|
|
959
|
+
if (legacyWorktreePath !== undefined) {
|
|
960
|
+
violations.push(violation2("medium", "workflow.snapshot.legacy-control-worktree-path", "legacy control_worktree_path is present — the canonical reader normalizes it to integration_worktree_path in memory; migrate on the next authorized write (writers emit only the canonical key)", "rename control_worktree_path to integration_worktree_path on the next authorized write"));
|
|
961
|
+
validateWorktreePathValue(violations, legacyWorktreePath, "control_worktree_path (legacy alias)");
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
if (doc.legacy_metadata !== undefined && !isPlainObject2(doc.legacy_metadata)) {
|
|
965
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
|
|
966
|
+
}
|
|
967
|
+
if (doc.compass_ref !== undefined) {
|
|
968
|
+
validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
|
|
969
|
+
}
|
|
970
|
+
if (doc.delivery_kind !== undefined) {
|
|
971
|
+
if (typeof doc.delivery_kind !== "string" || !WORKFLOW_DELIVERY_KINDS.includes(doc.delivery_kind)) {
|
|
972
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-delivery-kind", `delivery_kind must be one of ${WORKFLOW_DELIVERY_KINDS.join(" | ")} — got ${JSON.stringify(doc.delivery_kind)}`));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
if (doc.project !== undefined) {
|
|
976
|
+
validateNonEmptyString2(violations, doc.project, "project", "workflow.snapshot.missing-project", "workflow.snapshot.invalid-project");
|
|
977
|
+
}
|
|
978
|
+
if (doc.completion_policy !== undefined) {
|
|
979
|
+
validateNonEmptyString2(violations, doc.completion_policy, "completion_policy", "workflow.snapshot.missing-completion-policy", "workflow.snapshot.invalid-completion-policy");
|
|
980
|
+
}
|
|
981
|
+
if (doc.delivery !== undefined) {
|
|
982
|
+
violations.push(...deliveryEvidenceViolations(doc.delivery, "delivery"));
|
|
983
|
+
}
|
|
984
|
+
const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
|
|
985
|
+
if (terminal) {
|
|
986
|
+
if (doc.ended_at === undefined) {
|
|
987
|
+
violations.push(violation2("high", "workflow.snapshot.missing-ended-at", `terminal status ${JSON.stringify(doc.status)} requires ended_at — a terminal snapshot must record when the lifecycle ended`));
|
|
988
|
+
}
|
|
989
|
+
if (Array.isArray(doc.plans)) {
|
|
990
|
+
for (const row of doc.plans) {
|
|
991
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
992
|
+
violations.push(violation2("high", "workflow.snapshot.terminal-dangling-execution-lease", `terminal snapshot must not carry a row execution_lease (dangling lease) — release every lease before the lifecycle ends`));
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
997
|
+
violations.push(violation2("high", "workflow.snapshot.terminal-dangling-merge-lease", "terminal snapshot must not carry integration_merge_lease (dangling lease) — release the merge lease before the lifecycle ends"));
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
return { ok: violations.length === 0, violations };
|
|
1001
|
+
}
|
|
1002
|
+
async function writeWorkflowSnapshot(snapshot, dir, opts = {}) {
|
|
1003
|
+
const gate = validateWorkflowSnapshot(snapshot);
|
|
1004
|
+
if (!gate.ok) {
|
|
1005
|
+
const detail = gate.violations.map((v) => v.message).join("; ");
|
|
1006
|
+
throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
|
|
1007
|
+
}
|
|
1008
|
+
if (opts.expectedVersion !== undefined && !isArtifactVersion(opts.expectedVersion)) {
|
|
1009
|
+
throw new CoordinationError("coordination.invalid-input", `expectedVersion must be "absent" or sha256:<64 hex> — got ${JSON.stringify(opts.expectedVersion)}`, { expected: opts.expectedVersion });
|
|
1010
|
+
}
|
|
1011
|
+
if (opts.createOnly === true && opts.expectedVersion !== undefined && opts.expectedVersion !== "absent") {
|
|
1012
|
+
throw new CoordinationError("coordination.invalid-input", `createOnly implies expectedVersion "absent" — got ${JSON.stringify(opts.expectedVersion)}`, { expected: opts.expectedVersion });
|
|
1013
|
+
}
|
|
1014
|
+
const snapshotPath = join6(dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1015
|
+
const store = getArtifactStore();
|
|
1016
|
+
assertFsStorePath(store, { kind: "snapshot", key: snapshot.id }, snapshotPath);
|
|
1017
|
+
mkdirSync3(dir, { recursive: true });
|
|
1018
|
+
await withStatusWriteLock(snapshotPath, async () => {
|
|
1019
|
+
const current = readArtifactBytes(snapshotPath);
|
|
1020
|
+
const currentVersion = current?.version ?? "absent";
|
|
1021
|
+
const required = opts.createOnly === true ? "absent" : opts.expectedVersion ?? "absent";
|
|
1022
|
+
if (required !== currentVersion) {
|
|
1023
|
+
const missingToken = opts.createOnly !== true && opts.expectedVersion === undefined;
|
|
1024
|
+
throw new CoordinationError(missingToken ? "coordination.expected-version-required" : "coordination.version-conflict", missingToken ? `snapshot ${snapshotPath} already exists — replace it with an explicit expectedVersion (its current version is ${currentVersion}) or write a new snapshot` : `snapshot ${snapshotPath} is at ${currentVersion}, writer required ${required}`, { path: snapshotPath, expected: required, actual: currentVersion });
|
|
1025
|
+
}
|
|
1026
|
+
const payload = current === undefined ? snapshot : mergePhaseProjection(current.payload, snapshot);
|
|
1027
|
+
assertCoordinatedSnapshotWriter(current?.payload, snapshotPath, opts.sessionPath);
|
|
1028
|
+
await withProtectedWrite(snapshotPath, "put", () => store.put({ kind: "snapshot", key: snapshot.id, payload }));
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
function assertCoordinatedSnapshotWriter(stored, snapshotPath, sessionPath, action = "replacement") {
|
|
1032
|
+
const coordination = isPlainObject2(stored) ? stored.coordination : undefined;
|
|
1033
|
+
if (coordination === undefined)
|
|
1034
|
+
return;
|
|
1035
|
+
const bound = isPlainObject2(coordination) && isPlainObject2(coordination.coordinator) ? coordination.coordinator.session_file : undefined;
|
|
1036
|
+
if (sessionPath === undefined || typeof bound !== "string" || canonicalTarget(sessionPath) !== canonicalTarget(bound)) {
|
|
1037
|
+
throw new CoordinationError("coordination.session-mismatch", `snapshot ${snapshotPath} is coordinated — ${action} requires --session <coordinator envelope>`, { path: snapshotPath, expected: bound, actual: sessionPath });
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function mergePhaseProjection(stored, incoming) {
|
|
1041
|
+
if (!isPlainObject2(stored)) {
|
|
1042
|
+
throw new CoordinationError("coordination.version-conflict", "stored workflow snapshot is not an object — refusing a field-scoped rewrite over it", {});
|
|
1043
|
+
}
|
|
1044
|
+
const allowed = ["phase", "updated_at"];
|
|
1045
|
+
const keys = new Set([...Object.keys(stored), ...Object.keys(incoming)]);
|
|
1046
|
+
const drifted = [...keys].filter((key) => !allowed.includes(key) && stableJson(stored[key]) !== stableJson(incoming[key]));
|
|
1047
|
+
if (drifted.length > 0) {
|
|
1048
|
+
throw new CoordinationError("coordination.direct-write-refused", `refusing snapshot write: field(s) ${drifted.join(", ")} differ from disk — this writer may only change ${allowed.join(", ")}`, { fields: drifted, allowed });
|
|
1049
|
+
}
|
|
1050
|
+
const next = { ...stored };
|
|
1051
|
+
for (const key of allowed) {
|
|
1052
|
+
const value = incoming[key];
|
|
1053
|
+
if (value === undefined)
|
|
1054
|
+
delete next[key];
|
|
1055
|
+
else
|
|
1056
|
+
next[key] = value;
|
|
1057
|
+
}
|
|
1058
|
+
return next;
|
|
1059
|
+
}
|
|
1060
|
+
function nonEmptyString(value) {
|
|
1061
|
+
return typeof value === "string" && value.trim() !== "";
|
|
1062
|
+
}
|
|
1063
|
+
function assertDeliveryRegistrationCoherence(kind, evidence, what) {
|
|
1064
|
+
if (kind === "development" && (!nonEmptyString(evidence.branchSource) || !nonEmptyString(evidence.branchTarget))) {
|
|
1065
|
+
throw new Error(`${what}: a development workflow requires its delivery source and target branches (--branch-source/--branch-target) — missing branch fields are incomplete registration, not an exempt workflow (contract §1/§4a)`);
|
|
1066
|
+
}
|
|
1067
|
+
if (kind === "verification/report-only" && !nonEmptyString(evidence.completionPolicy)) {
|
|
1068
|
+
throw new Error(`${what}: a verification/report-only workflow requires the completion policy (--completion-policy) naming the evidence that completes it (contract §1)`);
|
|
1069
|
+
}
|
|
1070
|
+
for (const [field, value] of Object.entries({ branchSource: evidence.branchSource, branchTarget: evidence.branchTarget, completionPolicy: evidence.completionPolicy })) {
|
|
1071
|
+
if (value !== undefined && !nonEmptyString(value)) {
|
|
1072
|
+
throw new Error(`${what}: ${field} must be a non-empty string when given`);
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
329
1076
|
|
|
330
1077
|
// src/status.ts
|
|
331
1078
|
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
function violation(severity, code, message, fix) {
|
|
1079
|
+
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
1080
|
+
function violation3(severity, code, message, fix) {
|
|
336
1081
|
return { ok: false, severity, code, message, fix };
|
|
337
1082
|
}
|
|
338
1083
|
function todayString() {
|
|
@@ -341,12 +1086,49 @@ function todayString() {
|
|
|
341
1086
|
const day = String(now.getDate()).padStart(2, "0");
|
|
342
1087
|
return `${now.getFullYear()}-${month}-${day}`;
|
|
343
1088
|
}
|
|
344
|
-
function
|
|
1089
|
+
function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
|
|
345
1090
|
if (value === undefined) {
|
|
346
|
-
violations.push(
|
|
1091
|
+
violations.push(violation3("high", missingCode, `missing required field: ${field}`));
|
|
347
1092
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
348
|
-
violations.push(
|
|
1093
|
+
violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function validatePlanRow(row) {
|
|
1097
|
+
const violations = [];
|
|
1098
|
+
if (!isPlainObject2(row)) {
|
|
1099
|
+
return { ok: false, violations: [violation3("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
1100
|
+
}
|
|
1101
|
+
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
1102
|
+
if (id === undefined && planId === undefined) {
|
|
1103
|
+
violations.push(violation3("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
1104
|
+
} else {
|
|
1105
|
+
if (id !== undefined) {
|
|
1106
|
+
validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
1107
|
+
}
|
|
1108
|
+
if (planId !== undefined) {
|
|
1109
|
+
validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
1110
|
+
}
|
|
1111
|
+
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
1112
|
+
violations.push(violation3("medium", "status.plan-row.dual-id", "row has both id and plan_id with different values — write one canonical key (prefer id)"));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
1116
|
+
validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
1117
|
+
if (status === undefined) {
|
|
1118
|
+
violations.push(violation3("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
1119
|
+
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
1120
|
+
violations.push(violation3("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
1121
|
+
}
|
|
1122
|
+
if (metadata !== undefined && !isPlainObject2(metadata)) {
|
|
1123
|
+
violations.push(violation3("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
1124
|
+
}
|
|
1125
|
+
if (execution_lease !== undefined && !isPlainObject2(execution_lease)) {
|
|
1126
|
+
violations.push(violation3("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
1127
|
+
}
|
|
1128
|
+
if (status === "Done" && execution_lease !== undefined) {
|
|
1129
|
+
violations.push(violation3("medium", "status.plan-row.done-with-lease", 'plan status Done must not carry an execution_lease — the Done authority deletes the lease in the same complete-file update as status: "Done" (status-and-residuals.md § Hold, release, and override)', 'delete plans[].execution_lease in the same update that sets status: "Done"'));
|
|
349
1130
|
}
|
|
1131
|
+
return { ok: violations.length === 0, violations };
|
|
350
1132
|
}
|
|
351
1133
|
function isHarnessRelativePath(dir) {
|
|
352
1134
|
if (dir.startsWith("/") || dir.startsWith("\\"))
|
|
@@ -357,25 +1139,25 @@ function isHarnessRelativePath(dir) {
|
|
|
357
1139
|
}
|
|
358
1140
|
function validateWorkflowEntry(entry) {
|
|
359
1141
|
const violations = [];
|
|
360
|
-
if (!
|
|
1142
|
+
if (!isPlainObject2(entry)) {
|
|
361
1143
|
return {
|
|
362
1144
|
ok: false,
|
|
363
|
-
violations: [
|
|
1145
|
+
violations: [violation3("high", "status.workflow.invalid", "workflow entry must be an object")]
|
|
364
1146
|
};
|
|
365
1147
|
}
|
|
366
|
-
|
|
1148
|
+
validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
|
|
367
1149
|
if (entry.type === undefined) {
|
|
368
|
-
violations.push(
|
|
1150
|
+
violations.push(violation3("high", "status.workflow.missing-type", "missing required field: type"));
|
|
369
1151
|
} else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
|
|
370
|
-
violations.push(
|
|
1152
|
+
violations.push(violation3("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
|
|
371
1153
|
}
|
|
372
|
-
|
|
1154
|
+
validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
|
|
373
1155
|
if (entry.dir === undefined) {
|
|
374
|
-
violations.push(
|
|
1156
|
+
violations.push(violation3("high", "status.workflow.missing-dir", "missing required field: dir"));
|
|
375
1157
|
} else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
|
|
376
|
-
violations.push(
|
|
1158
|
+
violations.push(violation3("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
|
|
377
1159
|
} else if (!isHarnessRelativePath(entry.dir)) {
|
|
378
|
-
violations.push(
|
|
1160
|
+
violations.push(violation3("medium", "status.workflow.invalid-dir", `dir must be a harness-relative path (no absolute paths, no ".." segments) — got ${JSON.stringify(entry.dir)}`));
|
|
379
1161
|
}
|
|
380
1162
|
return { ok: violations.length === 0, violations };
|
|
381
1163
|
}
|
|
@@ -385,24 +1167,24 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
385
1167
|
if (typeof docOrPath === "string") {
|
|
386
1168
|
try {
|
|
387
1169
|
doc = readJson(docOrPath);
|
|
388
|
-
harnessDir =
|
|
1170
|
+
harnessDir = dirname5(resolve7(docOrPath));
|
|
389
1171
|
} catch (error) {
|
|
390
1172
|
return {
|
|
391
1173
|
ok: false,
|
|
392
|
-
violations: [
|
|
1174
|
+
violations: [violation3("high", "status.invalid-json", error.message)]
|
|
393
1175
|
};
|
|
394
1176
|
}
|
|
395
1177
|
} else {
|
|
396
1178
|
doc = docOrPath;
|
|
397
1179
|
}
|
|
398
|
-
if (!
|
|
399
|
-
return { ok: false, violations: [
|
|
1180
|
+
if (!isPlainObject2(doc)) {
|
|
1181
|
+
return { ok: false, violations: [violation3("high", "status.invalid-doc", "status document must be an object")] };
|
|
400
1182
|
}
|
|
401
1183
|
if (doc.version !== 2) {
|
|
402
1184
|
return {
|
|
403
1185
|
ok: false,
|
|
404
1186
|
violations: [
|
|
405
|
-
|
|
1187
|
+
violation3("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`")
|
|
406
1188
|
]
|
|
407
1189
|
};
|
|
408
1190
|
}
|
|
@@ -410,7 +1192,7 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
410
1192
|
return {
|
|
411
1193
|
ok: false,
|
|
412
1194
|
violations: [
|
|
413
|
-
|
|
1195
|
+
violation3("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`")
|
|
414
1196
|
]
|
|
415
1197
|
};
|
|
416
1198
|
}
|
|
@@ -418,27 +1200,27 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
418
1200
|
return {
|
|
419
1201
|
ok: false,
|
|
420
1202
|
violations: [
|
|
421
|
-
|
|
1203
|
+
violation3("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`")
|
|
422
1204
|
]
|
|
423
1205
|
};
|
|
424
1206
|
}
|
|
425
1207
|
const violations = [];
|
|
426
1208
|
if (doc.updated_at === undefined) {
|
|
427
|
-
violations.push(
|
|
1209
|
+
violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
428
1210
|
} else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
|
|
429
|
-
violations.push(
|
|
1211
|
+
violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
430
1212
|
}
|
|
431
1213
|
if (doc.workflows === undefined) {
|
|
432
|
-
violations.push(
|
|
1214
|
+
violations.push(violation3("high", "status.missing-workflows", "missing required field: workflows"));
|
|
433
1215
|
} else if (!Array.isArray(doc.workflows)) {
|
|
434
|
-
violations.push(
|
|
1216
|
+
violations.push(violation3("high", "status.invalid-workflows", "workflows must be an array"));
|
|
435
1217
|
} else {
|
|
436
1218
|
const seen = new Set;
|
|
437
1219
|
for (const entry of doc.workflows) {
|
|
438
1220
|
violations.push(...validateWorkflowEntry(entry).violations);
|
|
439
|
-
if (
|
|
1221
|
+
if (isPlainObject2(entry) && typeof entry.id === "string") {
|
|
440
1222
|
if (seen.has(entry.id)) {
|
|
441
|
-
violations.push(
|
|
1223
|
+
violations.push(violation3("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
|
|
442
1224
|
}
|
|
443
1225
|
seen.add(entry.id);
|
|
444
1226
|
}
|
|
@@ -447,47 +1229,47 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
447
1229
|
if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
|
|
448
1230
|
let realHarnessDir = null;
|
|
449
1231
|
try {
|
|
450
|
-
realHarnessDir =
|
|
1232
|
+
realHarnessDir = realpathSync2(harnessDir);
|
|
451
1233
|
} catch {}
|
|
452
1234
|
for (const entry of doc.workflows) {
|
|
453
|
-
if (!
|
|
1235
|
+
if (!isPlainObject2(entry) || typeof entry.dir !== "string")
|
|
454
1236
|
continue;
|
|
455
|
-
const relSnapshot =
|
|
456
|
-
const snapshotPath =
|
|
1237
|
+
const relSnapshot = join7(entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1238
|
+
const snapshotPath = join7(harnessDir, relSnapshot);
|
|
457
1239
|
const label = typeof entry.id === "string" ? entry.id : relSnapshot;
|
|
458
1240
|
let physical;
|
|
459
1241
|
try {
|
|
460
|
-
physical =
|
|
1242
|
+
physical = realpathSync2(snapshotPath);
|
|
461
1243
|
} catch {
|
|
462
|
-
violations.push(
|
|
1244
|
+
violations.push(violation3("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`));
|
|
463
1245
|
continue;
|
|
464
1246
|
}
|
|
465
|
-
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${
|
|
466
|
-
violations.push(
|
|
1247
|
+
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep2}`)) {
|
|
1248
|
+
violations.push(violation3("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)}`));
|
|
467
1249
|
continue;
|
|
468
1250
|
}
|
|
469
1251
|
let snapshot;
|
|
470
1252
|
try {
|
|
471
1253
|
snapshot = readJson(snapshotPath);
|
|
472
1254
|
} catch (error) {
|
|
473
|
-
violations.push(
|
|
1255
|
+
violations.push(violation3("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
|
|
474
1256
|
continue;
|
|
475
1257
|
}
|
|
476
1258
|
if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
477
|
-
violations.push(
|
|
1259
|
+
violations.push(violation3("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`));
|
|
478
1260
|
}
|
|
479
1261
|
if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
|
|
480
|
-
violations.push(
|
|
1262
|
+
violations.push(violation3("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`));
|
|
481
1263
|
}
|
|
482
1264
|
if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
|
|
483
|
-
violations.push(
|
|
1265
|
+
violations.push(violation3("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`));
|
|
484
1266
|
}
|
|
485
1267
|
}
|
|
486
1268
|
}
|
|
487
1269
|
return { ok: violations.length === 0, violations };
|
|
488
1270
|
}
|
|
489
1271
|
async function registerWorkflowEntryLocked(statusPath, entry) {
|
|
490
|
-
const harnessDir =
|
|
1272
|
+
const harnessDir = dirname5(statusPath);
|
|
491
1273
|
const store = getArtifactStore();
|
|
492
1274
|
assertFsStorePath(store, { kind: "status", key: "root" }, statusPath);
|
|
493
1275
|
const current = readJson(statusPath);
|
|
@@ -497,41 +1279,72 @@ async function registerWorkflowEntryLocked(statusPath, entry) {
|
|
|
497
1279
|
throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
|
|
498
1280
|
}
|
|
499
1281
|
const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
|
|
500
|
-
|
|
501
|
-
doc.
|
|
502
|
-
|
|
1282
|
+
const commit = async () => {
|
|
1283
|
+
doc.updated_at = todayString();
|
|
1284
|
+
const gate = validateStatusV2(doc, { harnessDir });
|
|
1285
|
+
if (!gate.ok) {
|
|
1286
|
+
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
1287
|
+
}
|
|
1288
|
+
await withProtectedWrite(statusPath, "put", () => store.put({ kind: "status", key: "root", payload: doc }));
|
|
1289
|
+
return doc;
|
|
1290
|
+
};
|
|
1291
|
+
if (existing < 0) {
|
|
503
1292
|
doc.workflows.push(entry);
|
|
1293
|
+
return commit();
|
|
1294
|
+
}
|
|
1295
|
+
const prior = doc.workflows[existing];
|
|
1296
|
+
return withRegisteredSnapshotLock(harnessDir, prior, async (snapshot) => {
|
|
1297
|
+
const priorCoordination = snapshot?.coordination;
|
|
1298
|
+
if (isPlainObject2(priorCoordination) && isPlainObject2(priorCoordination.coordinator)) {
|
|
1299
|
+
const drifted = ["dir", "type", "started_at"].filter((field) => prior[field] !== entry[field]);
|
|
1300
|
+
if (drifted.length > 0) {
|
|
1301
|
+
throw new CoordinationError("coordination.invalid-transition", `refusing to re-register workflow ${JSON.stringify(entry.id)}: it is coordinated and its ${drifted.join("/")} must not change`, { workflow_id: entry.id, fields: [...drifted] });
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
doc.workflows[existing] = entry;
|
|
1305
|
+
return commit();
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
async function withRegisteredSnapshotLock(harnessDir, entry, fn) {
|
|
1309
|
+
if (typeof entry.dir !== "string")
|
|
1310
|
+
return fn(undefined);
|
|
1311
|
+
const snapshotPath = join7(harnessDir, entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1312
|
+
if (!existsSync5(snapshotPath))
|
|
1313
|
+
return fn(undefined);
|
|
1314
|
+
return withStatusWriteLock(snapshotPath, () => fn(readRegisteredSnapshot(snapshotPath)));
|
|
1315
|
+
}
|
|
1316
|
+
function readRegisteredSnapshot(snapshotPath) {
|
|
1317
|
+
if (!existsSync5(snapshotPath))
|
|
1318
|
+
return;
|
|
1319
|
+
try {
|
|
1320
|
+
const parsed = JSON.parse(readFileSync5(snapshotPath, "utf8"));
|
|
1321
|
+
return isPlainObject2(parsed) ? parsed : undefined;
|
|
1322
|
+
} catch {
|
|
1323
|
+
return;
|
|
504
1324
|
}
|
|
505
|
-
doc.updated_at = todayString();
|
|
506
|
-
const gate = validateStatusV2(doc, { harnessDir });
|
|
507
|
-
if (!gate.ok) {
|
|
508
|
-
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
509
|
-
}
|
|
510
|
-
await store.put({ kind: "status", key: "root", payload: doc });
|
|
511
|
-
return doc;
|
|
512
1325
|
}
|
|
513
1326
|
|
|
514
1327
|
// src/path.ts
|
|
515
1328
|
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
516
|
-
const start =
|
|
1329
|
+
const start = resolve8(startDir);
|
|
517
1330
|
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
518
1331
|
if (explicit)
|
|
519
|
-
return
|
|
520
|
-
const boundary =
|
|
1332
|
+
return resolve8(start, explicit);
|
|
1333
|
+
const boundary = resolve8(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
|
|
521
1334
|
const rc = loadMstarc(start, boundary);
|
|
522
1335
|
if (rc !== null && rc.config.harnessDir)
|
|
523
|
-
return
|
|
1336
|
+
return resolve8(rc.dir, rc.config.harnessDir);
|
|
524
1337
|
let dir = start;
|
|
525
1338
|
for (;; ) {
|
|
526
1339
|
if (!isAtOrBelow2(dir, boundary))
|
|
527
1340
|
return null;
|
|
528
|
-
for (const candidate of [
|
|
1341
|
+
for (const candidate of [join8(dir, ".mstar"), join8(dir, ".agents"), join8(dir, ".plans"), join8(dir, "plans")]) {
|
|
529
1342
|
if (isDirectory(candidate))
|
|
530
1343
|
return candidate;
|
|
531
1344
|
}
|
|
532
1345
|
if (dir === boundary)
|
|
533
1346
|
return null;
|
|
534
|
-
const parent =
|
|
1347
|
+
const parent = dirname6(dir);
|
|
535
1348
|
if (parent === dir)
|
|
536
1349
|
return null;
|
|
537
1350
|
dir = parent;
|
|
@@ -549,21 +1362,21 @@ function defaultWorkspaceRoot(startDir) {
|
|
|
549
1362
|
let boundary = startDir;
|
|
550
1363
|
for (const segment of cdup.split(/[\\/]/)) {
|
|
551
1364
|
if (segment && segment !== ".")
|
|
552
|
-
boundary =
|
|
1365
|
+
boundary = dirname6(boundary);
|
|
553
1366
|
}
|
|
554
|
-
return
|
|
1367
|
+
return resolve8(boundary);
|
|
555
1368
|
} catch {}
|
|
556
1369
|
return startDir;
|
|
557
1370
|
}
|
|
558
1371
|
function isAtOrBelow2(dir, root) {
|
|
559
1372
|
const rel = relative2(root, dir);
|
|
560
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
1373
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute6(rel);
|
|
561
1374
|
}
|
|
562
1375
|
function mstarcDirOverride(harnessDir, key) {
|
|
563
|
-
const dir =
|
|
564
|
-
const rc = loadMstarc(dir,
|
|
1376
|
+
const dir = resolve8(harnessDir);
|
|
1377
|
+
const rc = loadMstarc(dir, dirname6(dir));
|
|
565
1378
|
const declared = rc?.config[key];
|
|
566
|
-
return declared ?
|
|
1379
|
+
return declared ? resolve8(rc.dir, declared) : null;
|
|
567
1380
|
}
|
|
568
1381
|
function assertSafePathComponent(value, what) {
|
|
569
1382
|
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
@@ -573,10 +1386,10 @@ function assertSafePathComponent(value, what) {
|
|
|
573
1386
|
function resolveHarnessSubdir(startDir, opts, key, fallback) {
|
|
574
1387
|
const harness = resolveHarnessDir(startDir, opts);
|
|
575
1388
|
if (harness === null) {
|
|
576
|
-
throw new Error(`harness dir not found from ${
|
|
1389
|
+
throw new Error(`harness dir not found from ${resolve8(startDir)} — cannot resolve the ${fallback} dir (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
|
|
577
1390
|
}
|
|
578
1391
|
const declared = mstarcDirOverride(harness, key);
|
|
579
|
-
return declared !== null ? declared :
|
|
1392
|
+
return declared !== null ? declared : join8(resolve8(harness), fallback);
|
|
580
1393
|
}
|
|
581
1394
|
function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
|
|
582
1395
|
return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
|
|
@@ -618,7 +1431,7 @@ function isDirectory(dir) {
|
|
|
618
1431
|
}
|
|
619
1432
|
|
|
620
1433
|
// src/audit.ts
|
|
621
|
-
function
|
|
1434
|
+
function violation4(severity, code, message, fix) {
|
|
622
1435
|
return { ok: false, severity, code, message, fix };
|
|
623
1436
|
}
|
|
624
1437
|
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
@@ -663,14 +1476,14 @@ function validateAuditStatusBlocks(planText) {
|
|
|
663
1476
|
const violations = [];
|
|
664
1477
|
const blocks = parseStatusBlocks(planText);
|
|
665
1478
|
if (blocks.length === 0) {
|
|
666
|
-
violations.push(
|
|
1479
|
+
violations.push(violation4("medium", "audit.status.missing-block", "no `## Status` block found — audit plan files carry the Status block fields (mstar-audit SKILL.md § Plan output)", "add a `## Status` block with Priority, Effort, Risk, Depends on, Category, Planned at"));
|
|
667
1480
|
return { ok: false, violations };
|
|
668
1481
|
}
|
|
669
1482
|
blocks.forEach((block, index) => {
|
|
670
1483
|
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
671
1484
|
for (const field of AUDIT_STATUS_FIELDS) {
|
|
672
1485
|
if (!block.fields.has(field)) {
|
|
673
|
-
violations.push(
|
|
1486
|
+
violations.push(violation4("medium", "audit.status.missing-field", `Status block${label} missing required field "${field}" (mstar-audit SKILL.md § Plan output)`, `add \`- **${field}**: <value>\` to the Status block`));
|
|
674
1487
|
}
|
|
675
1488
|
}
|
|
676
1489
|
const check = (field, pattern, code, expected) => {
|
|
@@ -678,7 +1491,7 @@ function validateAuditStatusBlocks(planText) {
|
|
|
678
1491
|
if (value === undefined)
|
|
679
1492
|
return;
|
|
680
1493
|
if (!pattern.test(value)) {
|
|
681
|
-
violations.push(
|
|
1494
|
+
violations.push(violation4("medium", code, `Status block${label} "${field}" = "${value}" — expected ${expected} (mstar-audit SKILL.md § Plan output)`, `fix \`- **${field}**:\` to one of: ${expected}`));
|
|
682
1495
|
}
|
|
683
1496
|
};
|
|
684
1497
|
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
@@ -913,12 +1726,12 @@ function scanSecrets(files) {
|
|
|
913
1726
|
for (const file of files) {
|
|
914
1727
|
let text;
|
|
915
1728
|
try {
|
|
916
|
-
text =
|
|
1729
|
+
text = readFileSync7(file, "utf8");
|
|
917
1730
|
} catch {
|
|
918
1731
|
unreadableFiles++;
|
|
919
1732
|
continue;
|
|
920
1733
|
}
|
|
921
|
-
const base =
|
|
1734
|
+
const base = basename5(file);
|
|
922
1735
|
for (const entry of NEVER_COMMIT_FILENAMES) {
|
|
923
1736
|
if (entry.re.test(base))
|
|
924
1737
|
findings.push({ file, line: 1, type: entry.type });
|
|
@@ -970,12 +1783,12 @@ var LOCKFILE_NAMES = [
|
|
|
970
1783
|
function rootLockfiles(root) {
|
|
971
1784
|
let entries;
|
|
972
1785
|
try {
|
|
973
|
-
entries =
|
|
1786
|
+
entries = readdirSync5(root, { withFileTypes: true });
|
|
974
1787
|
} catch {
|
|
975
1788
|
return [];
|
|
976
1789
|
}
|
|
977
1790
|
const names = new Set(LOCKFILE_NAMES);
|
|
978
|
-
const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) =>
|
|
1791
|
+
const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) => join9(root, entry.name));
|
|
979
1792
|
if (present.length === 0)
|
|
980
1793
|
return [];
|
|
981
1794
|
try {
|
|
@@ -984,7 +1797,7 @@ function rootLockfiles(root) {
|
|
|
984
1797
|
encoding: "utf8",
|
|
985
1798
|
stdio: ["ignore", "pipe", "ignore"]
|
|
986
1799
|
}).split("\x00").filter((f) => f !== ""));
|
|
987
|
-
return present.filter((p) => tracked.has(
|
|
1800
|
+
return present.filter((p) => tracked.has(basename5(p)));
|
|
988
1801
|
} catch {
|
|
989
1802
|
return present;
|
|
990
1803
|
}
|
|
@@ -995,26 +1808,26 @@ function supplyChainChecks(repoRoot) {
|
|
|
995
1808
|
const lockfiles = rootLockfiles(repoRoot);
|
|
996
1809
|
if (lockfiles.length === 0) {
|
|
997
1810
|
findings.push({ kind: "lockfile-missing", file: repoRoot });
|
|
998
|
-
violations.push(
|
|
1811
|
+
violations.push(violation4("medium", "audit.supply.lockfile-missing", `no recognized lockfile at ${repoRoot}`, "commit a lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, …)"));
|
|
999
1812
|
} else if (lockfiles.length > 1) {
|
|
1000
1813
|
findings.push({ kind: "lockfile-duplicate", file: lockfiles.map((f) => f.replace(`${repoRoot}/`, "")).join(", ") });
|
|
1001
|
-
violations.push(
|
|
1814
|
+
violations.push(violation4("medium", "audit.supply.lockfile-duplicate", `multiple lockfiles at ${repoRoot}: ${lockfiles.join(", ")}`, "keep exactly one lockfile per package manager"));
|
|
1002
1815
|
}
|
|
1003
|
-
const workflowsDir =
|
|
1816
|
+
const workflowsDir = join9(repoRoot, ".github", "workflows");
|
|
1004
1817
|
let wfEntries = [];
|
|
1005
1818
|
try {
|
|
1006
|
-
wfEntries =
|
|
1819
|
+
wfEntries = readdirSync5(workflowsDir, { withFileTypes: true });
|
|
1007
1820
|
} catch {
|
|
1008
1821
|
wfEntries = [];
|
|
1009
1822
|
}
|
|
1010
1823
|
for (const entry of wfEntries) {
|
|
1011
1824
|
if (!entry.isFile() || !/\.(?:ya?ml)$/.test(entry.name))
|
|
1012
1825
|
continue;
|
|
1013
|
-
const wfPath =
|
|
1826
|
+
const wfPath = join9(workflowsDir, entry.name);
|
|
1014
1827
|
const relPath = `.github/workflows/${entry.name}`;
|
|
1015
1828
|
let text;
|
|
1016
1829
|
try {
|
|
1017
|
-
text =
|
|
1830
|
+
text = readFileSync7(wfPath, "utf8");
|
|
1018
1831
|
} catch {
|
|
1019
1832
|
continue;
|
|
1020
1833
|
}
|
|
@@ -1054,12 +1867,12 @@ function supplyChainChecks(repoRoot) {
|
|
|
1054
1867
|
const versionLike = /^v\d+(?:\.\d+)*$/.test(ref);
|
|
1055
1868
|
if (!shaLike && !versionLike) {
|
|
1056
1869
|
findings.push({ kind: "action-unpinned", file: relPath, line: i + 1 });
|
|
1057
|
-
violations.push(
|
|
1870
|
+
violations.push(violation4("high", "audit.supply.action-unpinned", `${relPath}:${i + 1} uses \`${uses[1]}@${ref}\` — mutable ref`, "pin the action to a full commit SHA"));
|
|
1058
1871
|
}
|
|
1059
1872
|
}
|
|
1060
1873
|
if (hasPrt && prtHeadSteps.has(i)) {
|
|
1061
1874
|
findings.push({ kind: "pull_request_target-head", file: relPath, line: i + 1 });
|
|
1062
|
-
violations.push(
|
|
1875
|
+
violations.push(violation4("high", "audit.supply.pull_request_target-head", `${relPath}:${i + 1} checks out the PR head under pull_request_target`, "check out the base ref or use a pull_request trigger for untrusted code"));
|
|
1063
1876
|
}
|
|
1064
1877
|
}
|
|
1065
1878
|
}
|
|
@@ -1112,7 +1925,7 @@ function redactFinding(finding) {
|
|
|
1112
1925
|
};
|
|
1113
1926
|
}
|
|
1114
1927
|
function readPlanFileSummary(filePath) {
|
|
1115
|
-
const text =
|
|
1928
|
+
const text = readFileSync7(filePath, "utf8");
|
|
1116
1929
|
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
1117
1930
|
const blocks = parseStatusBlocks(text);
|
|
1118
1931
|
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
@@ -1166,10 +1979,10 @@ function renderIndex(params) {
|
|
|
1166
1979
|
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
1167
1980
|
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
1168
1981
|
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
1169
|
-
|
|
1170
|
-
const existingReadme =
|
|
1171
|
-
const carried =
|
|
1172
|
-
const existing =
|
|
1982
|
+
mkdirSync5(outDir, { recursive: true });
|
|
1983
|
+
const existingReadme = join9(outDir, "README.md");
|
|
1984
|
+
const carried = existsSync7(existingReadme) ? extractSecurityDispositionSections(readFileSync7(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
|
|
1985
|
+
const existing = readdirSync5(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
1173
1986
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
1174
1987
|
const redactedFindings = findings.map(redactFinding);
|
|
1175
1988
|
const written = [];
|
|
@@ -1185,13 +1998,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1185
1998
|
}
|
|
1186
1999
|
usedSlugs.add(slug);
|
|
1187
2000
|
const file = `${num}-${slug}.md`;
|
|
1188
|
-
writeFileSync4(
|
|
2001
|
+
writeFileSync4(join9(outDir, file), renderPlanFile(finding, plannedAt));
|
|
1189
2002
|
written.push(file);
|
|
1190
2003
|
next++;
|
|
1191
2004
|
}
|
|
1192
2005
|
const all = [...existing, ...written].sort();
|
|
1193
2006
|
const rows = all.map((file) => {
|
|
1194
|
-
const summary = readPlanFileSummary(
|
|
2007
|
+
const summary = readPlanFileSummary(join9(outDir, file));
|
|
1195
2008
|
const fields = summary.fields;
|
|
1196
2009
|
return {
|
|
1197
2010
|
num: file.slice(0, 3),
|
|
@@ -1225,7 +2038,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1225
2038
|
});
|
|
1226
2039
|
const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(redactText(nv.lead))}: ${escapeCell(redactText(nv.how))}${nv.evidence ? ` (${escapeCell(redactText(nv.evidence))})` : ""}`) : carried.needsVerification;
|
|
1227
2040
|
const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(redactText(hc.text))}`) : carried.hardeningChecked;
|
|
1228
|
-
writeFileSync4(
|
|
2041
|
+
writeFileSync4(join9(outDir, "README.md"), renderIndex({
|
|
1229
2042
|
date,
|
|
1230
2043
|
repoName: options.repoName ?? "repo",
|
|
1231
2044
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
@@ -1234,7 +2047,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1234
2047
|
needsVerification: needsVerificationLines,
|
|
1235
2048
|
hardeningChecked: hardeningCheckedLines
|
|
1236
2049
|
}));
|
|
1237
|
-
return { outDir:
|
|
2050
|
+
return { outDir: resolve9(outDir), date, files: written, nextNumber: next };
|
|
1238
2051
|
}
|
|
1239
2052
|
async function promoteAuditPlans(outDir, selected, options) {
|
|
1240
2053
|
if (selected.length === 0) {
|
|
@@ -1243,19 +2056,24 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1243
2056
|
if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
|
|
1244
2057
|
throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
|
|
1245
2058
|
}
|
|
1246
|
-
|
|
2059
|
+
if (typeof options.deliveryKind !== "string" || !WORKFLOW_DELIVERY_KINDS.includes(options.deliveryKind)) {
|
|
2060
|
+
throw new Error(`promoteAuditPlans: options.deliveryKind must be one of ${WORKFLOW_DELIVERY_KINDS.join(" | ")} — got ${JSON.stringify(options.deliveryKind)} (a promoted plan workflow declares its delivery kind at registration; it is never inferred and never defaulted)`);
|
|
2061
|
+
}
|
|
2062
|
+
assertDeliveryRegistrationCoherence(options.deliveryKind, options, "promoteAuditPlans");
|
|
2063
|
+
const workflowId = options.workflowId ?? basename5(resolve9(outDir));
|
|
1247
2064
|
assertSafePathComponent(workflowId, "workflow id");
|
|
1248
|
-
const harnessDir =
|
|
1249
|
-
const statusPath =
|
|
1250
|
-
const workflowDir =
|
|
1251
|
-
const snapshotPath =
|
|
2065
|
+
const harnessDir = resolve9(options.harnessDir);
|
|
2066
|
+
const statusPath = join9(harnessDir, "status.json");
|
|
2067
|
+
const workflowDir = join9(harnessDir, "workflows", workflowId);
|
|
2068
|
+
const snapshotPath = join9(workflowDir, WORKFLOW_SNAPSHOT_FILE);
|
|
2069
|
+
const store = getArtifactStore();
|
|
1252
2070
|
const planFiles = resolveSelectedPlanFiles(outDir, selected);
|
|
1253
2071
|
const indexRows = readExecutionOrderIndex(outDir);
|
|
1254
2072
|
const plans = planFiles.map((planFile) => {
|
|
1255
2073
|
const stem = planFile.replace(/\.md$/, "");
|
|
1256
2074
|
const num = stem.slice(0, 3);
|
|
1257
2075
|
const indexRow = indexRows.get(num);
|
|
1258
|
-
const title = indexRow?.title ?? readPlanFileSummary(
|
|
2076
|
+
const title = indexRow?.title ?? readPlanFileSummary(join9(outDir, planFile)).title;
|
|
1259
2077
|
return {
|
|
1260
2078
|
id: stem,
|
|
1261
2079
|
title,
|
|
@@ -1271,8 +2089,17 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1271
2089
|
status: "running",
|
|
1272
2090
|
started_at: now.toISOString(),
|
|
1273
2091
|
updated_at: now.toISOString().slice(0, 10),
|
|
1274
|
-
plans
|
|
2092
|
+
plans,
|
|
2093
|
+
delivery_kind: options.deliveryKind
|
|
1275
2094
|
};
|
|
2095
|
+
if (options.branchSource !== undefined || options.branchTarget !== undefined) {
|
|
2096
|
+
snapshot.branch = {
|
|
2097
|
+
...options.branchSource !== undefined ? { source: options.branchSource } : {},
|
|
2098
|
+
...options.branchTarget !== undefined ? { target: options.branchTarget } : {}
|
|
2099
|
+
};
|
|
2100
|
+
}
|
|
2101
|
+
if (options.completionPolicy !== undefined)
|
|
2102
|
+
snapshot.completion_policy = options.completionPolicy;
|
|
1276
2103
|
const entry = {
|
|
1277
2104
|
id: workflowId,
|
|
1278
2105
|
type: "plan",
|
|
@@ -1284,18 +2111,29 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1284
2111
|
throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
|
|
1285
2112
|
}
|
|
1286
2113
|
await withStatusWriteLock(statusPath, async () => {
|
|
1287
|
-
if (
|
|
2114
|
+
if (existsSync7(snapshotPath)) {
|
|
1288
2115
|
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`);
|
|
1289
2116
|
}
|
|
1290
|
-
|
|
2117
|
+
let createdVersion;
|
|
1291
2118
|
try {
|
|
1292
|
-
|
|
2119
|
+
await writeWorkflowSnapshot(snapshot, workflowDir, { createOnly: true });
|
|
2120
|
+
createdVersion = readArtifactBytes(snapshotPath)?.version;
|
|
1293
2121
|
await registerWorkflowEntryLocked(statusPath, entry);
|
|
1294
2122
|
} catch (error) {
|
|
1295
|
-
|
|
2123
|
+
if (createdVersion !== undefined) {
|
|
2124
|
+
await withStatusWriteLock(snapshotPath, async () => {
|
|
2125
|
+
const current = readArtifactBytes(snapshotPath);
|
|
2126
|
+
if (current === undefined || current.version !== createdVersion)
|
|
2127
|
+
return;
|
|
2128
|
+
const remove = store.delete?.bind(store);
|
|
2129
|
+
if (remove !== undefined) {
|
|
2130
|
+
await withProtectedWrite(snapshotPath, "delete", () => remove({ kind: "snapshot", key: workflowId }));
|
|
2131
|
+
}
|
|
2132
|
+
});
|
|
2133
|
+
}
|
|
1296
2134
|
try {
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
2135
|
+
if (readdirSync5(workflowDir).length === 0) {
|
|
2136
|
+
rmdirSync3(workflowDir);
|
|
1299
2137
|
}
|
|
1300
2138
|
} catch {}
|
|
1301
2139
|
throw error;
|
|
@@ -1305,7 +2143,7 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1305
2143
|
return { workflowId, snapshotPath };
|
|
1306
2144
|
}
|
|
1307
2145
|
function resolveSelectedPlanFiles(outDir, selected) {
|
|
1308
|
-
const files =
|
|
2146
|
+
const files = readdirSync5(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f)).sort();
|
|
1309
2147
|
const byNum = new Map;
|
|
1310
2148
|
const byStem = new Map;
|
|
1311
2149
|
for (const file of files) {
|
|
@@ -1320,7 +2158,7 @@ function resolveSelectedPlanFiles(outDir, selected) {
|
|
|
1320
2158
|
for (const id of selected) {
|
|
1321
2159
|
const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
|
|
1322
2160
|
if (file === undefined) {
|
|
1323
|
-
throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${
|
|
2161
|
+
throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve9(outDir)}`);
|
|
1324
2162
|
}
|
|
1325
2163
|
if (!seen.has(file)) {
|
|
1326
2164
|
seen.add(file);
|
|
@@ -1330,10 +2168,10 @@ function resolveSelectedPlanFiles(outDir, selected) {
|
|
|
1330
2168
|
return resolved;
|
|
1331
2169
|
}
|
|
1332
2170
|
function readExecutionOrderIndex(outDir) {
|
|
1333
|
-
const readmePath =
|
|
2171
|
+
const readmePath = join9(outDir, "README.md");
|
|
1334
2172
|
let text;
|
|
1335
2173
|
try {
|
|
1336
|
-
text =
|
|
2174
|
+
text = readFileSync7(readmePath, "utf8");
|
|
1337
2175
|
} catch {
|
|
1338
2176
|
return new Map;
|
|
1339
2177
|
}
|
|
@@ -1359,11 +2197,11 @@ function readExecutionOrderIndex(outDir) {
|
|
|
1359
2197
|
return rows;
|
|
1360
2198
|
}
|
|
1361
2199
|
function planFileRel(outDir, planFile) {
|
|
1362
|
-
const resolved =
|
|
1363
|
-
const parts = resolved.split(
|
|
2200
|
+
const resolved = resolve9(outDir);
|
|
2201
|
+
const parts = resolved.split(sep3);
|
|
1364
2202
|
const plansIdx = parts.lastIndexOf("plans");
|
|
1365
2203
|
if (plansIdx >= 0) {
|
|
1366
|
-
return `${parts.slice(plansIdx + 1).join(
|
|
2204
|
+
return `${parts.slice(plansIdx + 1).join(sep3)}${sep3}${planFile}`;
|
|
1367
2205
|
}
|
|
1368
2206
|
return planFile;
|
|
1369
2207
|
}
|