@mstar-harness/engine 3.9.2 → 3.9.4
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 +8 -7
- package/dist/audit.js +908 -170
- package/dist/coordination-write.d.ts +170 -0
- package/dist/coordination.d.ts +284 -0
- package/dist/engine.js +3558 -469
- package/dist/index.d.ts +6 -3
- package/dist/iteration.d.ts +1 -2
- package/dist/lease.d.ts +13 -1
- package/dist/lint.d.ts +50 -2
- package/dist/migrate.d.ts +8 -0
- package/dist/path.d.ts +17 -8
- package/dist/project.d.ts +2 -2
- package/dist/status.d.ts +21 -1
- package/dist/workflow.d.ts +52 -3
- 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 readdirSync4, readFileSync as readFileSync7, rmdirSync as rmdirSync2, writeFileSync as writeFileSync4 } from "node:fs";
|
|
4
|
+
import { basename as basename5, join as join9, resolve as resolve8, 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 readdirSync3, 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 resolve7 } 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 readdirSync2, realpathSync as realpathSync2 } from "node:fs";
|
|
630
|
+
import { dirname as dirname5, join as join7, resolve as resolve6, 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,215 @@ 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 } from "node:fs";
|
|
793
|
+
import { isAbsolute as isAbsolute5, join as join6 } 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
|
+
function stableJson(value) {
|
|
799
|
+
if (Array.isArray(value))
|
|
800
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
801
|
+
if (isPlainObject2(value)) {
|
|
802
|
+
const keys = Object.keys(value).sort();
|
|
803
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
804
|
+
}
|
|
805
|
+
return JSON.stringify(value) ?? "undefined";
|
|
806
|
+
}
|
|
807
|
+
function violation2(severity, code, message, fix) {
|
|
808
|
+
return { ok: false, severity, code, message, fix };
|
|
809
|
+
}
|
|
810
|
+
function validateNonEmptyString2(violations, value, field, missingCode, invalidCode) {
|
|
811
|
+
if (value === undefined) {
|
|
812
|
+
violations.push(violation2("high", missingCode, `missing required field: ${field}`));
|
|
813
|
+
} else if (typeof value !== "string" || value.trim() === "") {
|
|
814
|
+
violations.push(violation2("medium", invalidCode, `${field} must be a non-empty string`));
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
function validateWorktreePathValue(violations, value, field) {
|
|
818
|
+
if (typeof value !== "string" || value.trim() === "" || !isAbsolute5(value)) {
|
|
819
|
+
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)"));
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
function validateWorkflowSnapshot(doc) {
|
|
823
|
+
const violations = [];
|
|
824
|
+
if (!isPlainObject2(doc)) {
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
violations: [violation2("high", "workflow.snapshot.invalid", "workflow snapshot must be an object")]
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
if (doc.schema_version === undefined) {
|
|
831
|
+
violations.push(violation2("high", "workflow.snapshot.missing-schema-version", "missing required field: schema_version"));
|
|
832
|
+
} else if (doc.schema_version !== 1) {
|
|
833
|
+
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)`));
|
|
834
|
+
}
|
|
835
|
+
if (doc.version !== undefined) {
|
|
836
|
+
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"));
|
|
837
|
+
}
|
|
838
|
+
validateNonEmptyString2(violations, doc.id, "id", "workflow.snapshot.missing-id", "workflow.snapshot.invalid-id");
|
|
839
|
+
if (doc.type === undefined) {
|
|
840
|
+
violations.push(violation2("high", "workflow.snapshot.missing-type", "missing required field: type"));
|
|
841
|
+
} else if (typeof doc.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(doc.type)) {
|
|
842
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(doc.type)}`));
|
|
843
|
+
}
|
|
844
|
+
if (doc.status === undefined) {
|
|
845
|
+
violations.push(violation2("high", "workflow.snapshot.missing-status", "missing required field: status"));
|
|
846
|
+
} else if (typeof doc.status !== "string" || !WORKFLOW_LIFECYCLE_STATUSES.includes(doc.status)) {
|
|
847
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-status", `status must be one of ${WORKFLOW_LIFECYCLE_STATUSES.join(" | ")} — got ${JSON.stringify(doc.status)}`));
|
|
848
|
+
}
|
|
849
|
+
validateNonEmptyString2(violations, doc.started_at, "started_at", "workflow.snapshot.missing-started-at", "workflow.snapshot.invalid-started-at");
|
|
850
|
+
validateNonEmptyString2(violations, doc.updated_at, "updated_at", "workflow.snapshot.missing-updated-at", "workflow.snapshot.invalid-updated-at");
|
|
851
|
+
if (doc.ended_at !== undefined) {
|
|
852
|
+
validateNonEmptyString2(violations, doc.ended_at, "ended_at", "workflow.snapshot.missing-ended-at", "workflow.snapshot.invalid-ended-at");
|
|
853
|
+
}
|
|
854
|
+
if (doc.phase !== undefined && typeof doc.phase !== "string") {
|
|
855
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-phase", "phase must be a string (free-form phase machine label)"));
|
|
856
|
+
}
|
|
857
|
+
if (doc.plans === undefined) {
|
|
858
|
+
violations.push(violation2("high", "workflow.snapshot.missing-plans", "missing required field: plans"));
|
|
859
|
+
} else if (!Array.isArray(doc.plans)) {
|
|
860
|
+
violations.push(violation2("high", "workflow.snapshot.invalid-plans", "plans must be an array of legacy plan rows"));
|
|
861
|
+
} else {
|
|
862
|
+
for (const row of doc.plans) {
|
|
863
|
+
violations.push(...validatePlanRow(row).violations);
|
|
864
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
865
|
+
violations.push(...validateExecutionLease(row.execution_lease).violations);
|
|
866
|
+
}
|
|
867
|
+
if (isPlainObject2(row) && row.coordination !== undefined) {
|
|
868
|
+
violations.push(...validateRowCoordination(row.coordination, `plans[${String(row.id)}].coordination`));
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (doc.coordination !== undefined) {
|
|
873
|
+
violations.push(...validateSnapshotCoordination(doc.coordination));
|
|
874
|
+
}
|
|
875
|
+
if (doc.execution_policy !== undefined) {
|
|
876
|
+
if (!isPlainObject2(doc.execution_policy)) {
|
|
877
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-execution-policy", "execution_policy must be an object"));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
881
|
+
violations.push(...validateIntegrationMergeLease(doc.integration_merge_lease).violations);
|
|
882
|
+
}
|
|
883
|
+
if (doc.branch !== undefined) {
|
|
884
|
+
if (!isPlainObject2(doc.branch)) {
|
|
885
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-branch", "branch must be an object"));
|
|
886
|
+
} else {
|
|
887
|
+
for (const key of ["base", "integration", "target"]) {
|
|
888
|
+
if (doc.branch[key] !== undefined && (typeof doc.branch[key] !== "string" || doc.branch[key].trim() === "")) {
|
|
889
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-branch", `branch.${key} must be a non-empty string`));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const legacyWorktreePath = doc.control_worktree_path;
|
|
895
|
+
const canonicalWorktreePath = doc.integration_worktree_path;
|
|
896
|
+
if (legacyWorktreePath !== undefined && canonicalWorktreePath !== undefined) {
|
|
897
|
+
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"));
|
|
898
|
+
} else {
|
|
899
|
+
if (canonicalWorktreePath !== undefined) {
|
|
900
|
+
validateWorktreePathValue(violations, canonicalWorktreePath, "integration_worktree_path");
|
|
901
|
+
}
|
|
902
|
+
if (legacyWorktreePath !== undefined) {
|
|
903
|
+
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"));
|
|
904
|
+
validateWorktreePathValue(violations, legacyWorktreePath, "control_worktree_path (legacy alias)");
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (doc.legacy_metadata !== undefined && !isPlainObject2(doc.legacy_metadata)) {
|
|
908
|
+
violations.push(violation2("medium", "workflow.snapshot.invalid-legacy-metadata", "legacy_metadata must be an object"));
|
|
909
|
+
}
|
|
910
|
+
if (doc.compass_ref !== undefined) {
|
|
911
|
+
validateNonEmptyString2(violations, doc.compass_ref, "compass_ref", "workflow.snapshot.missing-compass-ref", "workflow.snapshot.invalid-compass-ref");
|
|
912
|
+
}
|
|
913
|
+
const terminal = typeof doc.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(doc.status);
|
|
914
|
+
if (terminal) {
|
|
915
|
+
if (doc.ended_at === undefined) {
|
|
916
|
+
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`));
|
|
917
|
+
}
|
|
918
|
+
if (Array.isArray(doc.plans)) {
|
|
919
|
+
for (const row of doc.plans) {
|
|
920
|
+
if (isPlainObject2(row) && row.execution_lease !== undefined) {
|
|
921
|
+
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`));
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
if (doc.integration_merge_lease !== undefined) {
|
|
926
|
+
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"));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return { ok: violations.length === 0, violations };
|
|
930
|
+
}
|
|
931
|
+
async function writeWorkflowSnapshot(snapshot, dir, opts = {}) {
|
|
932
|
+
const gate = validateWorkflowSnapshot(snapshot);
|
|
933
|
+
if (!gate.ok) {
|
|
934
|
+
const detail = gate.violations.map((v) => v.message).join("; ");
|
|
935
|
+
throw new Error(`refusing to write invalid workflow snapshot: ${detail}`);
|
|
936
|
+
}
|
|
937
|
+
if (opts.expectedVersion !== undefined && !isArtifactVersion(opts.expectedVersion)) {
|
|
938
|
+
throw new CoordinationError("coordination.invalid-input", `expectedVersion must be "absent" or sha256:<64 hex> — got ${JSON.stringify(opts.expectedVersion)}`, { expected: opts.expectedVersion });
|
|
939
|
+
}
|
|
940
|
+
if (opts.createOnly === true && opts.expectedVersion !== undefined && opts.expectedVersion !== "absent") {
|
|
941
|
+
throw new CoordinationError("coordination.invalid-input", `createOnly implies expectedVersion "absent" — got ${JSON.stringify(opts.expectedVersion)}`, { expected: opts.expectedVersion });
|
|
942
|
+
}
|
|
943
|
+
const snapshotPath = join6(dir, WORKFLOW_SNAPSHOT_FILE);
|
|
944
|
+
const store = getArtifactStore();
|
|
945
|
+
assertFsStorePath(store, { kind: "snapshot", key: snapshot.id }, snapshotPath);
|
|
946
|
+
mkdirSync3(dir, { recursive: true });
|
|
947
|
+
await withStatusWriteLock(snapshotPath, async () => {
|
|
948
|
+
const current = readArtifactBytes(snapshotPath);
|
|
949
|
+
const currentVersion = current?.version ?? "absent";
|
|
950
|
+
const required = opts.createOnly === true ? "absent" : opts.expectedVersion ?? "absent";
|
|
951
|
+
if (required !== currentVersion) {
|
|
952
|
+
const missingToken = opts.createOnly !== true && opts.expectedVersion === undefined;
|
|
953
|
+
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 });
|
|
954
|
+
}
|
|
955
|
+
const payload = current === undefined ? snapshot : mergePhaseProjection(current.payload, snapshot);
|
|
956
|
+
assertCoordinatedSnapshotWriter(current?.payload, snapshotPath, opts.sessionPath);
|
|
957
|
+
await withProtectedWrite(snapshotPath, "put", () => store.put({ kind: "snapshot", key: snapshot.id, payload }));
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
function assertCoordinatedSnapshotWriter(stored, snapshotPath, sessionPath, action = "replacement") {
|
|
961
|
+
const coordination = isPlainObject2(stored) ? stored.coordination : undefined;
|
|
962
|
+
if (coordination === undefined)
|
|
963
|
+
return;
|
|
964
|
+
const bound = isPlainObject2(coordination) && isPlainObject2(coordination.coordinator) ? coordination.coordinator.session_file : undefined;
|
|
965
|
+
if (sessionPath === undefined || typeof bound !== "string" || canonicalTarget(sessionPath) !== canonicalTarget(bound)) {
|
|
966
|
+
throw new CoordinationError("coordination.session-mismatch", `snapshot ${snapshotPath} is coordinated — ${action} requires --session <coordinator envelope>`, { path: snapshotPath, expected: bound, actual: sessionPath });
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function mergePhaseProjection(stored, incoming) {
|
|
970
|
+
if (!isPlainObject2(stored)) {
|
|
971
|
+
throw new CoordinationError("coordination.version-conflict", "stored workflow snapshot is not an object — refusing a field-scoped rewrite over it", {});
|
|
972
|
+
}
|
|
973
|
+
const allowed = ["phase", "updated_at"];
|
|
974
|
+
const keys = new Set([...Object.keys(stored), ...Object.keys(incoming)]);
|
|
975
|
+
const drifted = [...keys].filter((key) => !allowed.includes(key) && stableJson(stored[key]) !== stableJson(incoming[key]));
|
|
976
|
+
if (drifted.length > 0) {
|
|
977
|
+
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 });
|
|
978
|
+
}
|
|
979
|
+
const next = { ...stored };
|
|
980
|
+
for (const key of allowed) {
|
|
981
|
+
const value = incoming[key];
|
|
982
|
+
if (value === undefined)
|
|
983
|
+
delete next[key];
|
|
984
|
+
else
|
|
985
|
+
next[key] = value;
|
|
986
|
+
}
|
|
987
|
+
return next;
|
|
988
|
+
}
|
|
329
989
|
|
|
330
990
|
// src/status.ts
|
|
331
991
|
var DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
}
|
|
335
|
-
function violation(severity, code, message, fix) {
|
|
992
|
+
var PLAN_STATUSES = ["Todo", "InProgress", "InReview", "Blocked", "Done"];
|
|
993
|
+
function violation3(severity, code, message, fix) {
|
|
336
994
|
return { ok: false, severity, code, message, fix };
|
|
337
995
|
}
|
|
338
996
|
function todayString() {
|
|
@@ -341,12 +999,49 @@ function todayString() {
|
|
|
341
999
|
const day = String(now.getDate()).padStart(2, "0");
|
|
342
1000
|
return `${now.getFullYear()}-${month}-${day}`;
|
|
343
1001
|
}
|
|
344
|
-
function
|
|
1002
|
+
function validateNonEmptyString3(violations, value, field, missingCode, invalidCode) {
|
|
345
1003
|
if (value === undefined) {
|
|
346
|
-
violations.push(
|
|
1004
|
+
violations.push(violation3("high", missingCode, `missing required field: ${field}`));
|
|
347
1005
|
} else if (typeof value !== "string" || value.trim() === "") {
|
|
348
|
-
violations.push(
|
|
1006
|
+
violations.push(violation3("medium", invalidCode, `${field} must be a non-empty string`));
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
function validatePlanRow(row) {
|
|
1010
|
+
const violations = [];
|
|
1011
|
+
if (!isPlainObject2(row)) {
|
|
1012
|
+
return { ok: false, violations: [violation3("high", "status.plan-row.invalid", "plan row must be an object")] };
|
|
1013
|
+
}
|
|
1014
|
+
const { id, plan_id: planId, title, file, status, metadata, execution_lease } = row;
|
|
1015
|
+
if (id === undefined && planId === undefined) {
|
|
1016
|
+
violations.push(violation3("high", "status.plan-row.missing-id", "missing required field: id (or legacy plan_id)"));
|
|
1017
|
+
} else {
|
|
1018
|
+
if (id !== undefined) {
|
|
1019
|
+
validateNonEmptyString3(violations, id, "id", "status.plan-row.missing-id", "status.plan-row.invalid-id");
|
|
1020
|
+
}
|
|
1021
|
+
if (planId !== undefined) {
|
|
1022
|
+
validateNonEmptyString3(violations, planId, "plan_id", "status.plan-row.missing-plan-id", "status.plan-row.invalid-plan-id");
|
|
1023
|
+
}
|
|
1024
|
+
if (id !== undefined && planId !== undefined && id !== planId) {
|
|
1025
|
+
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)"));
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
validateNonEmptyString3(violations, title, "title", "status.plan-row.missing-title", "status.plan-row.invalid-title");
|
|
1029
|
+
validateNonEmptyString3(violations, file, "file", "status.plan-row.missing-file", "status.plan-row.invalid-file");
|
|
1030
|
+
if (status === undefined) {
|
|
1031
|
+
violations.push(violation3("high", "status.plan-row.missing-status", "missing required field: status"));
|
|
1032
|
+
} else if (typeof status !== "string" || !PLAN_STATUSES.includes(status)) {
|
|
1033
|
+
violations.push(violation3("medium", "status.plan-row.invalid-status", `status must be one of ${PLAN_STATUSES.join(" | ")} — got ${JSON.stringify(status)}`));
|
|
1034
|
+
}
|
|
1035
|
+
if (metadata !== undefined && !isPlainObject2(metadata)) {
|
|
1036
|
+
violations.push(violation3("medium", "status.plan-row.invalid-metadata", "metadata must be an object"));
|
|
1037
|
+
}
|
|
1038
|
+
if (execution_lease !== undefined && !isPlainObject2(execution_lease)) {
|
|
1039
|
+
violations.push(violation3("medium", "status.plan-row.invalid-execution-lease", "execution_lease must be an object"));
|
|
1040
|
+
}
|
|
1041
|
+
if (status === "Done" && execution_lease !== undefined) {
|
|
1042
|
+
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
1043
|
}
|
|
1044
|
+
return { ok: violations.length === 0, violations };
|
|
350
1045
|
}
|
|
351
1046
|
function isHarnessRelativePath(dir) {
|
|
352
1047
|
if (dir.startsWith("/") || dir.startsWith("\\"))
|
|
@@ -357,25 +1052,25 @@ function isHarnessRelativePath(dir) {
|
|
|
357
1052
|
}
|
|
358
1053
|
function validateWorkflowEntry(entry) {
|
|
359
1054
|
const violations = [];
|
|
360
|
-
if (!
|
|
1055
|
+
if (!isPlainObject2(entry)) {
|
|
361
1056
|
return {
|
|
362
1057
|
ok: false,
|
|
363
|
-
violations: [
|
|
1058
|
+
violations: [violation3("high", "status.workflow.invalid", "workflow entry must be an object")]
|
|
364
1059
|
};
|
|
365
1060
|
}
|
|
366
|
-
|
|
1061
|
+
validateNonEmptyString3(violations, entry.id, "id", "status.workflow.missing-id", "status.workflow.invalid-id");
|
|
367
1062
|
if (entry.type === undefined) {
|
|
368
|
-
violations.push(
|
|
1063
|
+
violations.push(violation3("high", "status.workflow.missing-type", "missing required field: type"));
|
|
369
1064
|
} else if (typeof entry.type !== "string" || !WORKFLOW_LIFECYCLE_TYPES.includes(entry.type)) {
|
|
370
|
-
violations.push(
|
|
1065
|
+
violations.push(violation3("medium", "status.workflow.invalid-type", `type must be one of ${WORKFLOW_LIFECYCLE_TYPES.join(" | ")} — got ${JSON.stringify(entry.type)}`));
|
|
371
1066
|
}
|
|
372
|
-
|
|
1067
|
+
validateNonEmptyString3(violations, entry.started_at, "started_at", "status.workflow.missing-started-at", "status.workflow.invalid-started-at");
|
|
373
1068
|
if (entry.dir === undefined) {
|
|
374
|
-
violations.push(
|
|
1069
|
+
violations.push(violation3("high", "status.workflow.missing-dir", "missing required field: dir"));
|
|
375
1070
|
} else if (typeof entry.dir !== "string" || entry.dir.trim() === "") {
|
|
376
|
-
violations.push(
|
|
1071
|
+
violations.push(violation3("medium", "status.workflow.invalid-dir", "dir must be a non-empty string"));
|
|
377
1072
|
} else if (!isHarnessRelativePath(entry.dir)) {
|
|
378
|
-
violations.push(
|
|
1073
|
+
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
1074
|
}
|
|
380
1075
|
return { ok: violations.length === 0, violations };
|
|
381
1076
|
}
|
|
@@ -385,24 +1080,24 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
385
1080
|
if (typeof docOrPath === "string") {
|
|
386
1081
|
try {
|
|
387
1082
|
doc = readJson(docOrPath);
|
|
388
|
-
harnessDir =
|
|
1083
|
+
harnessDir = dirname5(resolve6(docOrPath));
|
|
389
1084
|
} catch (error) {
|
|
390
1085
|
return {
|
|
391
1086
|
ok: false,
|
|
392
|
-
violations: [
|
|
1087
|
+
violations: [violation3("high", "status.invalid-json", error.message)]
|
|
393
1088
|
};
|
|
394
1089
|
}
|
|
395
1090
|
} else {
|
|
396
1091
|
doc = docOrPath;
|
|
397
1092
|
}
|
|
398
|
-
if (!
|
|
399
|
-
return { ok: false, violations: [
|
|
1093
|
+
if (!isPlainObject2(doc)) {
|
|
1094
|
+
return { ok: false, violations: [violation3("high", "status.invalid-doc", "status document must be an object")] };
|
|
400
1095
|
}
|
|
401
1096
|
if (doc.version !== 2) {
|
|
402
1097
|
return {
|
|
403
1098
|
ok: false,
|
|
404
1099
|
violations: [
|
|
405
|
-
|
|
1100
|
+
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
1101
|
]
|
|
407
1102
|
};
|
|
408
1103
|
}
|
|
@@ -410,7 +1105,7 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
410
1105
|
return {
|
|
411
1106
|
ok: false,
|
|
412
1107
|
violations: [
|
|
413
|
-
|
|
1108
|
+
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
1109
|
]
|
|
415
1110
|
};
|
|
416
1111
|
}
|
|
@@ -418,27 +1113,27 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
418
1113
|
return {
|
|
419
1114
|
ok: false,
|
|
420
1115
|
violations: [
|
|
421
|
-
|
|
1116
|
+
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
1117
|
]
|
|
423
1118
|
};
|
|
424
1119
|
}
|
|
425
1120
|
const violations = [];
|
|
426
1121
|
if (doc.updated_at === undefined) {
|
|
427
|
-
violations.push(
|
|
1122
|
+
violations.push(violation3("high", "status.missing-updated-at", "missing required field: updated_at"));
|
|
428
1123
|
} else if (typeof doc.updated_at !== "string" || !DATE_RE.test(doc.updated_at)) {
|
|
429
|
-
violations.push(
|
|
1124
|
+
violations.push(violation3("medium", "status.invalid-updated-at", "updated_at must be YYYY-MM-DD"));
|
|
430
1125
|
}
|
|
431
1126
|
if (doc.workflows === undefined) {
|
|
432
|
-
violations.push(
|
|
1127
|
+
violations.push(violation3("high", "status.missing-workflows", "missing required field: workflows"));
|
|
433
1128
|
} else if (!Array.isArray(doc.workflows)) {
|
|
434
|
-
violations.push(
|
|
1129
|
+
violations.push(violation3("high", "status.invalid-workflows", "workflows must be an array"));
|
|
435
1130
|
} else {
|
|
436
1131
|
const seen = new Set;
|
|
437
1132
|
for (const entry of doc.workflows) {
|
|
438
1133
|
violations.push(...validateWorkflowEntry(entry).violations);
|
|
439
|
-
if (
|
|
1134
|
+
if (isPlainObject2(entry) && typeof entry.id === "string") {
|
|
440
1135
|
if (seen.has(entry.id)) {
|
|
441
|
-
violations.push(
|
|
1136
|
+
violations.push(violation3("medium", "status.workflow.duplicate-id", `duplicate workflow id in workflows[]: ${JSON.stringify(entry.id)}`));
|
|
442
1137
|
}
|
|
443
1138
|
seen.add(entry.id);
|
|
444
1139
|
}
|
|
@@ -447,47 +1142,47 @@ function validateStatusV2(docOrPath, opts = {}) {
|
|
|
447
1142
|
if (harnessDir !== undefined && Array.isArray(doc.workflows)) {
|
|
448
1143
|
let realHarnessDir = null;
|
|
449
1144
|
try {
|
|
450
|
-
realHarnessDir =
|
|
1145
|
+
realHarnessDir = realpathSync2(harnessDir);
|
|
451
1146
|
} catch {}
|
|
452
1147
|
for (const entry of doc.workflows) {
|
|
453
|
-
if (!
|
|
1148
|
+
if (!isPlainObject2(entry) || typeof entry.dir !== "string")
|
|
454
1149
|
continue;
|
|
455
|
-
const relSnapshot =
|
|
456
|
-
const snapshotPath =
|
|
1150
|
+
const relSnapshot = join7(entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1151
|
+
const snapshotPath = join7(harnessDir, relSnapshot);
|
|
457
1152
|
const label = typeof entry.id === "string" ? entry.id : relSnapshot;
|
|
458
1153
|
let physical;
|
|
459
1154
|
try {
|
|
460
|
-
physical =
|
|
1155
|
+
physical = realpathSync2(snapshotPath);
|
|
461
1156
|
} catch {
|
|
462
|
-
violations.push(
|
|
1157
|
+
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
1158
|
continue;
|
|
464
1159
|
}
|
|
465
|
-
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${
|
|
466
|
-
violations.push(
|
|
1160
|
+
if (realHarnessDir !== null && physical !== realHarnessDir && !physical.startsWith(`${realHarnessDir}${sep2}`)) {
|
|
1161
|
+
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
1162
|
continue;
|
|
468
1163
|
}
|
|
469
1164
|
let snapshot;
|
|
470
1165
|
try {
|
|
471
1166
|
snapshot = readJson(snapshotPath);
|
|
472
1167
|
} catch (error) {
|
|
473
|
-
violations.push(
|
|
1168
|
+
violations.push(violation3("high", "status.workflow.snapshot-invalid", `snapshot at ${JSON.stringify(relSnapshot)} is not valid JSON: ${error.message}`));
|
|
474
1169
|
continue;
|
|
475
1170
|
}
|
|
476
1171
|
if (typeof snapshot.status === "string" && WORKFLOW_TERMINAL_STATUSES.includes(snapshot.status)) {
|
|
477
|
-
violations.push(
|
|
1172
|
+
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
1173
|
}
|
|
479
1174
|
if (typeof entry.type === "string" && typeof snapshot.type === "string" && entry.type !== snapshot.type) {
|
|
480
|
-
violations.push(
|
|
1175
|
+
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
1176
|
}
|
|
482
1177
|
if (typeof entry.started_at === "string" && typeof snapshot.started_at === "string" && entry.started_at !== snapshot.started_at) {
|
|
483
|
-
violations.push(
|
|
1178
|
+
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
1179
|
}
|
|
485
1180
|
}
|
|
486
1181
|
}
|
|
487
1182
|
return { ok: violations.length === 0, violations };
|
|
488
1183
|
}
|
|
489
1184
|
async function registerWorkflowEntryLocked(statusPath, entry) {
|
|
490
|
-
const harnessDir =
|
|
1185
|
+
const harnessDir = dirname5(statusPath);
|
|
491
1186
|
const store = getArtifactStore();
|
|
492
1187
|
assertFsStorePath(store, { kind: "status", key: "root" }, statusPath);
|
|
493
1188
|
const current = readJson(statusPath);
|
|
@@ -497,41 +1192,72 @@ async function registerWorkflowEntryLocked(statusPath, entry) {
|
|
|
497
1192
|
throw new Error("refusing to modify status.json: workflows must be an array — a v1 root must be migrated first (run `mstar migrate`)");
|
|
498
1193
|
}
|
|
499
1194
|
const existing = doc.workflows.findIndex((wf) => wf.id === entry.id);
|
|
500
|
-
|
|
501
|
-
doc.
|
|
502
|
-
|
|
1195
|
+
const commit = async () => {
|
|
1196
|
+
doc.updated_at = todayString();
|
|
1197
|
+
const gate = validateStatusV2(doc, { harnessDir });
|
|
1198
|
+
if (!gate.ok) {
|
|
1199
|
+
throw new Error(`refusing to write invalid status.json: ${gate.violations.map((v) => v.message).join("; ")}`);
|
|
1200
|
+
}
|
|
1201
|
+
await withProtectedWrite(statusPath, "put", () => store.put({ kind: "status", key: "root", payload: doc }));
|
|
1202
|
+
return doc;
|
|
1203
|
+
};
|
|
1204
|
+
if (existing < 0) {
|
|
503
1205
|
doc.workflows.push(entry);
|
|
1206
|
+
return commit();
|
|
1207
|
+
}
|
|
1208
|
+
const prior = doc.workflows[existing];
|
|
1209
|
+
return withRegisteredSnapshotLock(harnessDir, prior, async (snapshot) => {
|
|
1210
|
+
const priorCoordination = snapshot?.coordination;
|
|
1211
|
+
if (isPlainObject2(priorCoordination) && isPlainObject2(priorCoordination.coordinator)) {
|
|
1212
|
+
const drifted = ["dir", "type", "started_at"].filter((field) => prior[field] !== entry[field]);
|
|
1213
|
+
if (drifted.length > 0) {
|
|
1214
|
+
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] });
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
doc.workflows[existing] = entry;
|
|
1218
|
+
return commit();
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
async function withRegisteredSnapshotLock(harnessDir, entry, fn) {
|
|
1222
|
+
if (typeof entry.dir !== "string")
|
|
1223
|
+
return fn(undefined);
|
|
1224
|
+
const snapshotPath = join7(harnessDir, entry.dir, WORKFLOW_SNAPSHOT_FILE);
|
|
1225
|
+
if (!existsSync5(snapshotPath))
|
|
1226
|
+
return fn(undefined);
|
|
1227
|
+
return withStatusWriteLock(snapshotPath, () => fn(readRegisteredSnapshot(snapshotPath)));
|
|
1228
|
+
}
|
|
1229
|
+
function readRegisteredSnapshot(snapshotPath) {
|
|
1230
|
+
if (!existsSync5(snapshotPath))
|
|
1231
|
+
return;
|
|
1232
|
+
try {
|
|
1233
|
+
const parsed = JSON.parse(readFileSync5(snapshotPath, "utf8"));
|
|
1234
|
+
return isPlainObject2(parsed) ? parsed : undefined;
|
|
1235
|
+
} catch {
|
|
1236
|
+
return;
|
|
504
1237
|
}
|
|
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
1238
|
}
|
|
513
1239
|
|
|
514
1240
|
// src/path.ts
|
|
515
1241
|
function resolveHarnessDir(startDir = process.cwd(), opts = {}) {
|
|
516
|
-
const start =
|
|
1242
|
+
const start = resolve7(startDir);
|
|
517
1243
|
const explicit = opts.harnessDir ?? process.env.MSTAR_HARNESS_DIR;
|
|
518
1244
|
if (explicit)
|
|
519
|
-
return
|
|
520
|
-
const boundary =
|
|
1245
|
+
return resolve7(start, explicit);
|
|
1246
|
+
const boundary = resolve7(start, opts.workspaceRoot ?? defaultWorkspaceRoot(start));
|
|
521
1247
|
const rc = loadMstarc(start, boundary);
|
|
522
1248
|
if (rc !== null && rc.config.harnessDir)
|
|
523
|
-
return
|
|
1249
|
+
return resolve7(rc.dir, rc.config.harnessDir);
|
|
524
1250
|
let dir = start;
|
|
525
1251
|
for (;; ) {
|
|
526
1252
|
if (!isAtOrBelow2(dir, boundary))
|
|
527
1253
|
return null;
|
|
528
|
-
for (const candidate of [
|
|
1254
|
+
for (const candidate of [join8(dir, ".mstar"), join8(dir, ".agents"), join8(dir, ".plans"), join8(dir, "plans")]) {
|
|
529
1255
|
if (isDirectory(candidate))
|
|
530
1256
|
return candidate;
|
|
531
1257
|
}
|
|
532
1258
|
if (dir === boundary)
|
|
533
1259
|
return null;
|
|
534
|
-
const parent =
|
|
1260
|
+
const parent = dirname6(dir);
|
|
535
1261
|
if (parent === dir)
|
|
536
1262
|
return null;
|
|
537
1263
|
dir = parent;
|
|
@@ -549,21 +1275,21 @@ function defaultWorkspaceRoot(startDir) {
|
|
|
549
1275
|
let boundary = startDir;
|
|
550
1276
|
for (const segment of cdup.split(/[\\/]/)) {
|
|
551
1277
|
if (segment && segment !== ".")
|
|
552
|
-
boundary =
|
|
1278
|
+
boundary = dirname6(boundary);
|
|
553
1279
|
}
|
|
554
|
-
return
|
|
1280
|
+
return resolve7(boundary);
|
|
555
1281
|
} catch {}
|
|
556
1282
|
return startDir;
|
|
557
1283
|
}
|
|
558
1284
|
function isAtOrBelow2(dir, root) {
|
|
559
1285
|
const rel = relative2(root, dir);
|
|
560
|
-
return rel === "" || !rel.startsWith("..") && !
|
|
1286
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute6(rel);
|
|
561
1287
|
}
|
|
562
1288
|
function mstarcDirOverride(harnessDir, key) {
|
|
563
|
-
const dir =
|
|
564
|
-
const rc = loadMstarc(dir,
|
|
1289
|
+
const dir = resolve7(harnessDir);
|
|
1290
|
+
const rc = loadMstarc(dir, dirname6(dir));
|
|
565
1291
|
const declared = rc?.config[key];
|
|
566
|
-
return declared ?
|
|
1292
|
+
return declared ? resolve7(rc.dir, declared) : null;
|
|
567
1293
|
}
|
|
568
1294
|
function assertSafePathComponent(value, what) {
|
|
569
1295
|
if (value === "" || value === "." || value === ".." || !/^[A-Za-z0-9._-]+$/.test(value)) {
|
|
@@ -573,10 +1299,10 @@ function assertSafePathComponent(value, what) {
|
|
|
573
1299
|
function resolveHarnessSubdir(startDir, opts, key, fallback) {
|
|
574
1300
|
const harness = resolveHarnessDir(startDir, opts);
|
|
575
1301
|
if (harness === null) {
|
|
576
|
-
throw new Error(`harness dir not found from ${
|
|
1302
|
+
throw new Error(`harness dir not found from ${resolve7(startDir)} — cannot resolve the ${fallback} dir (run \`mstar harness scaffold\`, pass opts.harnessDir, or set MSTAR_HARNESS_DIR)`);
|
|
577
1303
|
}
|
|
578
1304
|
const declared = mstarcDirOverride(harness, key);
|
|
579
|
-
return declared !== null ? declared :
|
|
1305
|
+
return declared !== null ? declared : join8(resolve7(harness), fallback);
|
|
580
1306
|
}
|
|
581
1307
|
function resolveWorkflowDir(startDir = process.cwd(), opts = {}) {
|
|
582
1308
|
return resolveHarnessSubdir(startDir, opts, "workflowDir", "workflows");
|
|
@@ -618,7 +1344,7 @@ function isDirectory(dir) {
|
|
|
618
1344
|
}
|
|
619
1345
|
|
|
620
1346
|
// src/audit.ts
|
|
621
|
-
function
|
|
1347
|
+
function violation4(severity, code, message, fix) {
|
|
622
1348
|
return { ok: false, severity, code, message, fix };
|
|
623
1349
|
}
|
|
624
1350
|
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
@@ -663,14 +1389,14 @@ function validateAuditStatusBlocks(planText) {
|
|
|
663
1389
|
const violations = [];
|
|
664
1390
|
const blocks = parseStatusBlocks(planText);
|
|
665
1391
|
if (blocks.length === 0) {
|
|
666
|
-
violations.push(
|
|
1392
|
+
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
1393
|
return { ok: false, violations };
|
|
668
1394
|
}
|
|
669
1395
|
blocks.forEach((block, index) => {
|
|
670
1396
|
const label = blocks.length > 1 ? ` #${index + 1}` : "";
|
|
671
1397
|
for (const field of AUDIT_STATUS_FIELDS) {
|
|
672
1398
|
if (!block.fields.has(field)) {
|
|
673
|
-
violations.push(
|
|
1399
|
+
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
1400
|
}
|
|
675
1401
|
}
|
|
676
1402
|
const check = (field, pattern, code, expected) => {
|
|
@@ -678,7 +1404,7 @@ function validateAuditStatusBlocks(planText) {
|
|
|
678
1404
|
if (value === undefined)
|
|
679
1405
|
return;
|
|
680
1406
|
if (!pattern.test(value)) {
|
|
681
|
-
violations.push(
|
|
1407
|
+
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
1408
|
}
|
|
683
1409
|
};
|
|
684
1410
|
check("Priority", /^P[123]$/, "audit.status.invalid-priority", "P1 | P2 | P3");
|
|
@@ -913,12 +1639,12 @@ function scanSecrets(files) {
|
|
|
913
1639
|
for (const file of files) {
|
|
914
1640
|
let text;
|
|
915
1641
|
try {
|
|
916
|
-
text =
|
|
1642
|
+
text = readFileSync7(file, "utf8");
|
|
917
1643
|
} catch {
|
|
918
1644
|
unreadableFiles++;
|
|
919
1645
|
continue;
|
|
920
1646
|
}
|
|
921
|
-
const base =
|
|
1647
|
+
const base = basename5(file);
|
|
922
1648
|
for (const entry of NEVER_COMMIT_FILENAMES) {
|
|
923
1649
|
if (entry.re.test(base))
|
|
924
1650
|
findings.push({ file, line: 1, type: entry.type });
|
|
@@ -975,7 +1701,7 @@ function rootLockfiles(root) {
|
|
|
975
1701
|
return [];
|
|
976
1702
|
}
|
|
977
1703
|
const names = new Set(LOCKFILE_NAMES);
|
|
978
|
-
const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) =>
|
|
1704
|
+
const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) => join9(root, entry.name));
|
|
979
1705
|
if (present.length === 0)
|
|
980
1706
|
return [];
|
|
981
1707
|
try {
|
|
@@ -984,7 +1710,7 @@ function rootLockfiles(root) {
|
|
|
984
1710
|
encoding: "utf8",
|
|
985
1711
|
stdio: ["ignore", "pipe", "ignore"]
|
|
986
1712
|
}).split("\x00").filter((f) => f !== ""));
|
|
987
|
-
return present.filter((p) => tracked.has(
|
|
1713
|
+
return present.filter((p) => tracked.has(basename5(p)));
|
|
988
1714
|
} catch {
|
|
989
1715
|
return present;
|
|
990
1716
|
}
|
|
@@ -995,12 +1721,12 @@ function supplyChainChecks(repoRoot) {
|
|
|
995
1721
|
const lockfiles = rootLockfiles(repoRoot);
|
|
996
1722
|
if (lockfiles.length === 0) {
|
|
997
1723
|
findings.push({ kind: "lockfile-missing", file: repoRoot });
|
|
998
|
-
violations.push(
|
|
1724
|
+
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
1725
|
} else if (lockfiles.length > 1) {
|
|
1000
1726
|
findings.push({ kind: "lockfile-duplicate", file: lockfiles.map((f) => f.replace(`${repoRoot}/`, "")).join(", ") });
|
|
1001
|
-
violations.push(
|
|
1727
|
+
violations.push(violation4("medium", "audit.supply.lockfile-duplicate", `multiple lockfiles at ${repoRoot}: ${lockfiles.join(", ")}`, "keep exactly one lockfile per package manager"));
|
|
1002
1728
|
}
|
|
1003
|
-
const workflowsDir =
|
|
1729
|
+
const workflowsDir = join9(repoRoot, ".github", "workflows");
|
|
1004
1730
|
let wfEntries = [];
|
|
1005
1731
|
try {
|
|
1006
1732
|
wfEntries = readdirSync4(workflowsDir, { withFileTypes: true });
|
|
@@ -1010,11 +1736,11 @@ function supplyChainChecks(repoRoot) {
|
|
|
1010
1736
|
for (const entry of wfEntries) {
|
|
1011
1737
|
if (!entry.isFile() || !/\.(?:ya?ml)$/.test(entry.name))
|
|
1012
1738
|
continue;
|
|
1013
|
-
const wfPath =
|
|
1739
|
+
const wfPath = join9(workflowsDir, entry.name);
|
|
1014
1740
|
const relPath = `.github/workflows/${entry.name}`;
|
|
1015
1741
|
let text;
|
|
1016
1742
|
try {
|
|
1017
|
-
text =
|
|
1743
|
+
text = readFileSync7(wfPath, "utf8");
|
|
1018
1744
|
} catch {
|
|
1019
1745
|
continue;
|
|
1020
1746
|
}
|
|
@@ -1054,12 +1780,12 @@ function supplyChainChecks(repoRoot) {
|
|
|
1054
1780
|
const versionLike = /^v\d+(?:\.\d+)*$/.test(ref);
|
|
1055
1781
|
if (!shaLike && !versionLike) {
|
|
1056
1782
|
findings.push({ kind: "action-unpinned", file: relPath, line: i + 1 });
|
|
1057
|
-
violations.push(
|
|
1783
|
+
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
1784
|
}
|
|
1059
1785
|
}
|
|
1060
1786
|
if (hasPrt && prtHeadSteps.has(i)) {
|
|
1061
1787
|
findings.push({ kind: "pull_request_target-head", file: relPath, line: i + 1 });
|
|
1062
|
-
violations.push(
|
|
1788
|
+
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
1789
|
}
|
|
1064
1790
|
}
|
|
1065
1791
|
}
|
|
@@ -1112,7 +1838,7 @@ function redactFinding(finding) {
|
|
|
1112
1838
|
};
|
|
1113
1839
|
}
|
|
1114
1840
|
function readPlanFileSummary(filePath) {
|
|
1115
|
-
const text =
|
|
1841
|
+
const text = readFileSync7(filePath, "utf8");
|
|
1116
1842
|
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
1117
1843
|
const blocks = parseStatusBlocks(text);
|
|
1118
1844
|
return { title: title.trim(), fields: blocks.length > 0 ? blocks[0].fields : new Map };
|
|
@@ -1166,9 +1892,9 @@ function renderIndex(params) {
|
|
|
1166
1892
|
function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
1167
1893
|
const date = options.date ?? new Date().toISOString().slice(0, 10);
|
|
1168
1894
|
const plannedAt = options.plannedAt ?? { commit: options.repoShortSha ?? "unknown", date };
|
|
1169
|
-
|
|
1170
|
-
const existingReadme =
|
|
1171
|
-
const carried =
|
|
1895
|
+
mkdirSync5(outDir, { recursive: true });
|
|
1896
|
+
const existingReadme = join9(outDir, "README.md");
|
|
1897
|
+
const carried = existsSync7(existingReadme) ? extractSecurityDispositionSections(readFileSync7(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
|
|
1172
1898
|
const existing = readdirSync4(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
1173
1899
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
1174
1900
|
const redactedFindings = findings.map(redactFinding);
|
|
@@ -1185,13 +1911,13 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1185
1911
|
}
|
|
1186
1912
|
usedSlugs.add(slug);
|
|
1187
1913
|
const file = `${num}-${slug}.md`;
|
|
1188
|
-
writeFileSync4(
|
|
1914
|
+
writeFileSync4(join9(outDir, file), renderPlanFile(finding, plannedAt));
|
|
1189
1915
|
written.push(file);
|
|
1190
1916
|
next++;
|
|
1191
1917
|
}
|
|
1192
1918
|
const all = [...existing, ...written].sort();
|
|
1193
1919
|
const rows = all.map((file) => {
|
|
1194
|
-
const summary = readPlanFileSummary(
|
|
1920
|
+
const summary = readPlanFileSummary(join9(outDir, file));
|
|
1195
1921
|
const fields = summary.fields;
|
|
1196
1922
|
return {
|
|
1197
1923
|
num: file.slice(0, 3),
|
|
@@ -1225,7 +1951,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1225
1951
|
});
|
|
1226
1952
|
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
1953
|
const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(redactText(hc.text))}`) : carried.hardeningChecked;
|
|
1228
|
-
writeFileSync4(
|
|
1954
|
+
writeFileSync4(join9(outDir, "README.md"), renderIndex({
|
|
1229
1955
|
date,
|
|
1230
1956
|
repoName: options.repoName ?? "repo",
|
|
1231
1957
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
@@ -1234,7 +1960,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
1234
1960
|
needsVerification: needsVerificationLines,
|
|
1235
1961
|
hardeningChecked: hardeningCheckedLines
|
|
1236
1962
|
}));
|
|
1237
|
-
return { outDir:
|
|
1963
|
+
return { outDir: resolve8(outDir), date, files: written, nextNumber: next };
|
|
1238
1964
|
}
|
|
1239
1965
|
async function promoteAuditPlans(outDir, selected, options) {
|
|
1240
1966
|
if (selected.length === 0) {
|
|
@@ -1243,19 +1969,20 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1243
1969
|
if (typeof options.harnessDir !== "string" || options.harnessDir.trim() === "") {
|
|
1244
1970
|
throw new Error("promoteAuditPlans: options.harnessDir is required (must contain status.json + workflows/)");
|
|
1245
1971
|
}
|
|
1246
|
-
const workflowId = options.workflowId ??
|
|
1972
|
+
const workflowId = options.workflowId ?? basename5(resolve8(outDir));
|
|
1247
1973
|
assertSafePathComponent(workflowId, "workflow id");
|
|
1248
|
-
const harnessDir =
|
|
1249
|
-
const statusPath =
|
|
1250
|
-
const workflowDir =
|
|
1251
|
-
const snapshotPath =
|
|
1974
|
+
const harnessDir = resolve8(options.harnessDir);
|
|
1975
|
+
const statusPath = join9(harnessDir, "status.json");
|
|
1976
|
+
const workflowDir = join9(harnessDir, "workflows", workflowId);
|
|
1977
|
+
const snapshotPath = join9(workflowDir, WORKFLOW_SNAPSHOT_FILE);
|
|
1978
|
+
const store = getArtifactStore();
|
|
1252
1979
|
const planFiles = resolveSelectedPlanFiles(outDir, selected);
|
|
1253
1980
|
const indexRows = readExecutionOrderIndex(outDir);
|
|
1254
1981
|
const plans = planFiles.map((planFile) => {
|
|
1255
1982
|
const stem = planFile.replace(/\.md$/, "");
|
|
1256
1983
|
const num = stem.slice(0, 3);
|
|
1257
1984
|
const indexRow = indexRows.get(num);
|
|
1258
|
-
const title = indexRow?.title ?? readPlanFileSummary(
|
|
1985
|
+
const title = indexRow?.title ?? readPlanFileSummary(join9(outDir, planFile)).title;
|
|
1259
1986
|
return {
|
|
1260
1987
|
id: stem,
|
|
1261
1988
|
title,
|
|
@@ -1284,15 +2011,26 @@ async function promoteAuditPlans(outDir, selected, options) {
|
|
|
1284
2011
|
throw new Error(`refusing to register invalid workflow entry: ${entryGate.violations.map((v) => v.message).join("; ")}`);
|
|
1285
2012
|
}
|
|
1286
2013
|
await withStatusWriteLock(statusPath, async () => {
|
|
1287
|
-
if (
|
|
2014
|
+
if (existsSync7(snapshotPath)) {
|
|
1288
2015
|
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
2016
|
}
|
|
1290
|
-
|
|
2017
|
+
let createdVersion;
|
|
1291
2018
|
try {
|
|
1292
|
-
|
|
2019
|
+
await writeWorkflowSnapshot(snapshot, workflowDir, { createOnly: true });
|
|
2020
|
+
createdVersion = readArtifactBytes(snapshotPath)?.version;
|
|
1293
2021
|
await registerWorkflowEntryLocked(statusPath, entry);
|
|
1294
2022
|
} catch (error) {
|
|
1295
|
-
|
|
2023
|
+
if (createdVersion !== undefined) {
|
|
2024
|
+
await withStatusWriteLock(snapshotPath, async () => {
|
|
2025
|
+
const current = readArtifactBytes(snapshotPath);
|
|
2026
|
+
if (current === undefined || current.version !== createdVersion)
|
|
2027
|
+
return;
|
|
2028
|
+
const remove = store.delete?.bind(store);
|
|
2029
|
+
if (remove !== undefined) {
|
|
2030
|
+
await withProtectedWrite(snapshotPath, "delete", () => remove({ kind: "snapshot", key: workflowId }));
|
|
2031
|
+
}
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
1296
2034
|
try {
|
|
1297
2035
|
if (readdirSync4(workflowDir).length === 0) {
|
|
1298
2036
|
rmdirSync2(workflowDir);
|
|
@@ -1320,7 +2058,7 @@ function resolveSelectedPlanFiles(outDir, selected) {
|
|
|
1320
2058
|
for (const id of selected) {
|
|
1321
2059
|
const file = byNum.get(id) ?? byStem.get(id) ?? byStem.get(id.replace(/\.md$/, ""));
|
|
1322
2060
|
if (file === undefined) {
|
|
1323
|
-
throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${
|
|
2061
|
+
throw new Error(`promoteAuditPlans: selected plan ${JSON.stringify(id)} does not match any NNN-*.md file in ${resolve8(outDir)}`);
|
|
1324
2062
|
}
|
|
1325
2063
|
if (!seen.has(file)) {
|
|
1326
2064
|
seen.add(file);
|
|
@@ -1330,10 +2068,10 @@ function resolveSelectedPlanFiles(outDir, selected) {
|
|
|
1330
2068
|
return resolved;
|
|
1331
2069
|
}
|
|
1332
2070
|
function readExecutionOrderIndex(outDir) {
|
|
1333
|
-
const readmePath =
|
|
2071
|
+
const readmePath = join9(outDir, "README.md");
|
|
1334
2072
|
let text;
|
|
1335
2073
|
try {
|
|
1336
|
-
text =
|
|
2074
|
+
text = readFileSync7(readmePath, "utf8");
|
|
1337
2075
|
} catch {
|
|
1338
2076
|
return new Map;
|
|
1339
2077
|
}
|
|
@@ -1359,11 +2097,11 @@ function readExecutionOrderIndex(outDir) {
|
|
|
1359
2097
|
return rows;
|
|
1360
2098
|
}
|
|
1361
2099
|
function planFileRel(outDir, planFile) {
|
|
1362
|
-
const resolved =
|
|
1363
|
-
const parts = resolved.split(
|
|
2100
|
+
const resolved = resolve8(outDir);
|
|
2101
|
+
const parts = resolved.split(sep3);
|
|
1364
2102
|
const plansIdx = parts.lastIndexOf("plans");
|
|
1365
2103
|
if (plansIdx >= 0) {
|
|
1366
|
-
return `${parts.slice(plansIdx + 1).join(
|
|
2104
|
+
return `${parts.slice(plansIdx + 1).join(sep3)}${sep3}${planFile}`;
|
|
1367
2105
|
}
|
|
1368
2106
|
return planFile;
|
|
1369
2107
|
}
|