@gilbertgt/dsh-plan-orchestrator 1.0.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.
@@ -0,0 +1,577 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { dirname, join, posix, resolve, sep } from "node:path";
5
+ import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
6
+ import { homedir } from "node:os";
7
+ //#region src/contract/plan-artifact.ts
8
+ const TOP = /* @__PURE__ */ new Set([
9
+ "planModeVersion",
10
+ "summary",
11
+ "complexity",
12
+ "decisionLocks",
13
+ "tasks",
14
+ "validationStrategy",
15
+ "validationCommands",
16
+ "risks",
17
+ "outOfScope"
18
+ ]);
19
+ const TASK = /* @__PURE__ */ new Set([
20
+ "id",
21
+ "title",
22
+ "objective",
23
+ "read",
24
+ "modify",
25
+ "decisionLocks",
26
+ "requiredChanges",
27
+ "acceptanceCriteria",
28
+ "validation",
29
+ "dependsOn",
30
+ "parallelSafe"
31
+ ]);
32
+ const VCMD = /* @__PURE__ */ new Set([
33
+ "id",
34
+ "taskIds",
35
+ "command",
36
+ "timeoutMs"
37
+ ]);
38
+ const glob = /[*?\[\]{}!]/;
39
+ const drive = /^[A-Za-z]:[\\/]/;
40
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
41
+ const SAFE_VALIDATION_TOKEN = /^[A-Za-z0-9_./:@+=,-]+$/;
42
+ const MANAGERS = /* @__PURE__ */ new Set([
43
+ "npm",
44
+ "pnpm",
45
+ "yarn",
46
+ "bun"
47
+ ]);
48
+ const stringArray = (v, field) => {
49
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) throw new Error(`${field} must be a string array`);
50
+ return [...v];
51
+ };
52
+ function strictKeys(value, allowed, where) {
53
+ const unknown = Object.keys(value).filter((k) => !allowed.has(k));
54
+ if (unknown.length) throw new Error(`${where} has unknown field(s): ${unknown.join(", ")}`);
55
+ }
56
+ /**
57
+ * Validation is intentionally not an arbitrary shell surface. The Planner may
58
+ * select an existing project package script, but it cannot inject redirection,
59
+ * pipes, command substitution, inline interpreters, download-and-execute tools,
60
+ * or an arbitrary executable into the host validation phase.
61
+ */
62
+ function parseValidationCommand(command) {
63
+ if (typeof command !== "string" || !command.trim() || command.includes("\0") || bytes(command) > 2048) throw new Error("validation command invalid");
64
+ const tokens = command.trim().split(/\s+/);
65
+ if (tokens.some((token) => !SAFE_VALIDATION_TOKEN.test(token))) throw new Error("validation command may contain only conservative package-script tokens");
66
+ const manager = tokens.shift().toLowerCase().replace(/\.(?:cmd|exe)$/i, "");
67
+ if (!MANAGERS.has(manager)) throw new Error("validation command must use npm, pnpm, yarn, or bun package scripts");
68
+ const action = tokens.shift();
69
+ let script;
70
+ if (action === "test") {
71
+ if (manager === "bun") throw new Error("bun validation must use bun run <script>; bun test is a direct runner, not a package script");
72
+ script = "test";
73
+ } else if (action === "run") script = tokens.shift();
74
+ if (!script || !/^[A-Za-z0-9_.:@/-]{1,128}$/.test(script) || script === "." || script === ".." || script.includes("../")) throw new Error("validation command must select one existing package script");
75
+ let args = [];
76
+ if (tokens.length) {
77
+ if (tokens[0] !== "--") throw new Error("validation command arguments must follow --");
78
+ args = tokens.slice(1);
79
+ if (args.some((arg) => arg === "--" || !SAFE_VALIDATION_TOKEN.test(arg))) throw new Error("validation command arguments invalid");
80
+ }
81
+ return {
82
+ manager,
83
+ script,
84
+ args
85
+ };
86
+ }
87
+ function normalizeOwnedPath(raw) {
88
+ if (!raw || raw.includes("\0") || raw.includes("\\")) throw new Error(`invalid owned path: ${JSON.stringify(raw)}`);
89
+ if (raw.startsWith("/") || drive.test(raw) || glob.test(raw) || raw.endsWith("/")) throw new Error(`owned path must be an exact repository-relative file: ${raw}`);
90
+ const normalized = posix.normalize(raw);
91
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) throw new Error(`owned path escapes repository: ${raw}`);
92
+ if (normalized !== raw) throw new Error(`owned path must be canonical: ${raw}`);
93
+ return normalized;
94
+ }
95
+ function schedulerPathIdentity(path, platform = process.platform) {
96
+ return platform === "win32" || platform === "darwin" ? path.toLocaleLowerCase("en-US") : path;
97
+ }
98
+ function validatePlanArtifact(input, requireExplicitOwnership = true) {
99
+ if (!input || typeof input !== "object" || Array.isArray(input)) throw new Error("PlanArtifact must be a JSON object");
100
+ const x = input;
101
+ strictKeys(x, TOP, "PlanArtifact");
102
+ if (x.planModeVersion !== 1) throw new Error("planModeVersion must equal 1");
103
+ if (typeof x.summary !== "string" || !x.summary.trim()) throw new Error("summary is required");
104
+ if (![
105
+ "small",
106
+ "medium",
107
+ "large"
108
+ ].includes(String(x.complexity))) throw new Error("invalid complexity");
109
+ const decisionLocks = stringArray(x.decisionLocks, "decisionLocks"), validationStrategy = stringArray(x.validationStrategy, "validationStrategy");
110
+ const risks = stringArray(x.risks, "risks"), outOfScope = stringArray(x.outOfScope, "outOfScope");
111
+ if (!Array.isArray(x.tasks) || x.tasks.length === 0) throw new Error("tasks must be non-empty");
112
+ const ids = /* @__PURE__ */ new Set();
113
+ const tasks = x.tasks.map((raw, i) => {
114
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`tasks[${i}] must be an object`);
115
+ const t = raw;
116
+ strictKeys(t, TASK, `tasks[${i}]`);
117
+ for (const key of [
118
+ "id",
119
+ "title",
120
+ "objective"
121
+ ]) if (typeof t[key] !== "string" || !t[key].trim()) throw new Error(`tasks[${i}].${key} is required`);
122
+ if (ids.has(t.id)) throw new Error(`duplicate task id: ${t.id}`);
123
+ ids.add(t.id);
124
+ const read = stringArray(t.read, `tasks[${i}].read`).map(normalizeOwnedPath);
125
+ const modify = stringArray(t.modify, `tasks[${i}].modify`).map(normalizeOwnedPath);
126
+ if (requireExplicitOwnership && modify.length === 0) throw new Error(`tasks[${i}].modify must be non-empty`);
127
+ if (new Set(modify).size !== modify.length) throw new Error(`tasks[${i}].modify contains duplicates`);
128
+ const acceptanceCriteria = stringArray(t.acceptanceCriteria, `tasks[${i}].acceptanceCriteria`);
129
+ if (!acceptanceCriteria.length) throw new Error(`tasks[${i}].acceptanceCriteria must be non-empty`);
130
+ if (typeof t.parallelSafe !== "boolean") throw new Error(`tasks[${i}].parallelSafe must be boolean`);
131
+ return {
132
+ id: t.id,
133
+ title: t.title,
134
+ objective: t.objective,
135
+ read,
136
+ modify,
137
+ decisionLocks: stringArray(t.decisionLocks, `tasks[${i}].decisionLocks`),
138
+ requiredChanges: stringArray(t.requiredChanges, `tasks[${i}].requiredChanges`),
139
+ acceptanceCriteria,
140
+ validation: stringArray(t.validation, `tasks[${i}].validation`),
141
+ dependsOn: stringArray(t.dependsOn, `tasks[${i}].dependsOn`),
142
+ parallelSafe: t.parallelSafe
143
+ };
144
+ });
145
+ for (const t of tasks) for (const dep of t.dependsOn) if (!ids.has(dep) || dep === t.id) throw new Error(`task ${t.id} has invalid dependency ${dep}`);
146
+ assertAcyclic(tasks);
147
+ if (!Array.isArray(x.validationCommands) || x.validationCommands.length > 4) throw new Error("validationCommands may contain at most 4 commands");
148
+ const commandIds = /* @__PURE__ */ new Set();
149
+ const validationCommands = x.validationCommands.map((raw, i) => {
150
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`validationCommands[${i}] must be object`);
151
+ const c = raw;
152
+ strictKeys(c, VCMD, `validationCommands[${i}]`);
153
+ if (typeof c.id !== "string" || !c.id.trim() || commandIds.has(c.id)) throw new Error(`invalid/duplicate validation command id`);
154
+ commandIds.add(c.id);
155
+ const taskIds = stringArray(c.taskIds, `validationCommands[${i}].taskIds`);
156
+ if (!taskIds.length || taskIds.some((id) => !ids.has(id))) throw new Error(`validationCommands[${i}] references unknown task`);
157
+ if (typeof c.command !== "string" || !c.command.trim() || c.command.includes("\0") || bytes(c.command) > 2048) throw new Error(`validationCommands[${i}].command invalid`);
158
+ try {
159
+ parseValidationCommand(c.command);
160
+ } catch (error) {
161
+ throw new Error(`validationCommands[${i}].command unsafe: ${error.message}`);
162
+ }
163
+ if (!Number.isSafeInteger(c.timeoutMs) || c.timeoutMs < 1 || c.timeoutMs > 6e5) throw new Error(`validationCommands[${i}].timeoutMs invalid`);
164
+ return {
165
+ id: c.id,
166
+ taskIds,
167
+ command: c.command,
168
+ timeoutMs: c.timeoutMs
169
+ };
170
+ });
171
+ return {
172
+ planModeVersion: 1,
173
+ summary: x.summary,
174
+ complexity: x.complexity,
175
+ decisionLocks,
176
+ tasks,
177
+ validationStrategy,
178
+ validationCommands,
179
+ risks,
180
+ outOfScope
181
+ };
182
+ }
183
+ function assertAcyclic(tasks) {
184
+ const byId = new Map(tasks.map((t) => [t.id, t]));
185
+ const state = /* @__PURE__ */ new Map();
186
+ const visit = (id) => {
187
+ const s = state.get(id) ?? 0;
188
+ if (s === 1) throw new Error(`dependency cycle includes ${id}`);
189
+ if (s === 2) return;
190
+ state.set(id, 1);
191
+ for (const d of byId.get(id).dependsOn) visit(d);
192
+ state.set(id, 2);
193
+ };
194
+ for (const t of tasks) visit(t.id);
195
+ }
196
+ function extractPlanArtifact(markdown, requireExplicitOwnership = true) {
197
+ const fences = [...markdown.matchAll(/```json\s*\n([\s\S]*?)\n```/gi)];
198
+ if (fences.length !== 1) throw new Error(`plan markdown must contain exactly one JSON candidate fence; found ${fences.length}`);
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(fences[0][1]);
202
+ } catch (e) {
203
+ throw new Error(`invalid PlanArtifact JSON: ${e.message}`);
204
+ }
205
+ const artifact = validatePlanArtifact(parsed, requireExplicitOwnership);
206
+ return {
207
+ artifact,
208
+ hash: createHash("sha256").update(JSON.stringify(artifact)).digest("hex")
209
+ };
210
+ }
211
+ //#endregion
212
+ //#region src/git/repository.ts
213
+ const execFileP = promisify(execFile);
214
+ async function git(cwd, args, opts = {}) {
215
+ const r = await execFileP("git", args, {
216
+ cwd,
217
+ encoding: "buffer",
218
+ maxBuffer: opts.maxBuffer ?? 32 * 1024 * 1024,
219
+ windowsHide: true
220
+ });
221
+ return {
222
+ stdout: r.stdout,
223
+ stderr: r.stderr
224
+ };
225
+ }
226
+ function decodeUtf8Strict(buf) {
227
+ return new TextDecoder("utf-8", { fatal: true }).decode(buf);
228
+ }
229
+ function splitNul(buf) {
230
+ const text = decodeUtf8Strict(buf);
231
+ if (!text) return [];
232
+ const parts = text.split("\0");
233
+ if (parts.at(-1) === "") parts.pop();
234
+ return parts;
235
+ }
236
+ async function repoRoot(cwd) {
237
+ return decodeUtf8Strict((await git(cwd, ["rev-parse", "--show-toplevel"])).stdout).trim();
238
+ }
239
+ async function fullHead(cwd) {
240
+ const h = decodeUtf8Strict((await git(cwd, [
241
+ "rev-parse",
242
+ "--verify",
243
+ "HEAD"
244
+ ])).stdout).trim();
245
+ if (!/^[0-9a-f]{40}$/.test(h)) throw new Error("HEAD is not a full SHA");
246
+ return h;
247
+ }
248
+ function assertRepoPath(root, path) {
249
+ const b = resolve(root), t = resolve(b, path);
250
+ if (t !== b && !t.startsWith(b + sep)) throw new Error(`path escapes repository: ${path}`);
251
+ return t;
252
+ }
253
+ function inside(root, target) {
254
+ return target === root || target.startsWith(root + sep);
255
+ }
256
+ async function nearestExisting(path) {
257
+ let current = path;
258
+ while (true) try {
259
+ await lstat(current);
260
+ return current;
261
+ } catch (e) {
262
+ if (e?.code !== "ENOENT") throw e;
263
+ const parent = dirname(current);
264
+ if (parent === current) throw new Error(`no existing ancestor for ${path}`);
265
+ current = parent;
266
+ }
267
+ }
268
+ /** Resolve existing symlink-bearing ancestors so an apparently in-repo target cannot escape through a junction/symlink. */
269
+ async function assertRepoPathConfined(root, path) {
270
+ const lexical = assertRepoPath(root, path);
271
+ if (!inside(await realpath(resolve(root)), await realpath(await nearestExisting(lexical)))) throw new Error(`path escapes repository through symlink/junction: ${path}`);
272
+ return lexical;
273
+ }
274
+ async function assertRepoPathsConfined(root, paths) {
275
+ for (const path of paths) await assertRepoPathConfined(root, path);
276
+ }
277
+ async function changedPaths(cwd) {
278
+ const tracked = splitNul((await git(cwd, [
279
+ "diff",
280
+ "--name-only",
281
+ "-z",
282
+ "HEAD",
283
+ "--"
284
+ ])).stdout);
285
+ const staged = splitNul((await git(cwd, [
286
+ "diff",
287
+ "--cached",
288
+ "--name-only",
289
+ "-z",
290
+ "HEAD",
291
+ "--"
292
+ ])).stdout);
293
+ const untracked = splitNul((await git(cwd, [
294
+ "ls-files",
295
+ "--others",
296
+ "--exclude-standard",
297
+ "-z"
298
+ ])).stdout);
299
+ return [.../* @__PURE__ */ new Set([
300
+ ...tracked,
301
+ ...staged,
302
+ ...untracked
303
+ ])];
304
+ }
305
+ //#endregion
306
+ //#region src/recovery/store.ts
307
+ const stateRoot = () => resolve(process.env.DSH_HOME ? join(process.env.DSH_HOME, "state", "plan-orchestrator") : join(homedir(), ".dsh", "state", "plan-orchestrator"));
308
+ function confined(root, ...parts) {
309
+ const base = resolve(root), target = resolve(base, ...parts);
310
+ if (target !== base && !target.startsWith(base + sep)) throw new Error("artifact path escapes plugin root");
311
+ return target;
312
+ }
313
+ async function atomicJson(path, value) {
314
+ await mkdir(dirname(path), { recursive: true });
315
+ const tmp = `${path}.${randomUUID()}.tmp`;
316
+ const body = JSON.stringify(value, null, 2) + "\n";
317
+ await writeFile(tmp, body, {
318
+ encoding: "utf8",
319
+ mode: 384
320
+ });
321
+ await rename(tmp, path);
322
+ return createHash("sha256").update(body).digest("hex");
323
+ }
324
+ async function readJson(path) {
325
+ return JSON.parse(await readFile(path, "utf8"));
326
+ }
327
+ var RunStore = class {
328
+ root;
329
+ constructor(root = stateRoot()) {
330
+ this.root = root;
331
+ }
332
+ sessionDir(sessionId) {
333
+ return confined(this.root, "sessions", safe(sessionId));
334
+ }
335
+ runDir(sessionId, runId) {
336
+ return confined(this.root, "sessions", safe(sessionId), safe(runId));
337
+ }
338
+ manifestPath(sessionId, runId) {
339
+ return join(this.runDir(sessionId, runId), "manifest.json");
340
+ }
341
+ async writeManifest(m) {
342
+ m.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
343
+ return atomicJson(this.manifestPath(m.sessionId, m.runId), m);
344
+ }
345
+ async readManifest(sessionId, runId) {
346
+ return readJson(this.manifestPath(sessionId, runId));
347
+ }
348
+ async listSessionManifests(sessionId) {
349
+ const dir = this.sessionDir(sessionId);
350
+ let names = [];
351
+ try {
352
+ names = await readdir(dir);
353
+ } catch (e) {
354
+ if (e?.code === "ENOENT") return [];
355
+ throw e;
356
+ }
357
+ const out = [];
358
+ for (const name of names) try {
359
+ const p = join(dir, name, "manifest.json");
360
+ if ((await stat(p)).isFile()) out.push(await readJson(p));
361
+ } catch {}
362
+ return out.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
363
+ }
364
+ async removeRun(sessionId, runId) {
365
+ return rm(this.runDir(sessionId, runId), {
366
+ recursive: true,
367
+ force: true
368
+ });
369
+ }
370
+ };
371
+ function safe(v) {
372
+ if (!/^[A-Za-z0-9._-]{1,200}$/.test(v)) return createHash("sha256").update(v).digest("hex");
373
+ return v;
374
+ }
375
+ //#endregion
376
+ //#region src/git/ownership.ts
377
+ function assertOwnedPaths(changed, allowed, platform = process.platform) {
378
+ const set = new Set(allowed.map((p) => schedulerPathIdentity(p, platform)));
379
+ const outside = changed.filter((p) => !set.has(schedulerPathIdentity(p, platform)));
380
+ if (outside.length) throw new Error(`ownership violation: ${outside.join(", ")}`);
381
+ return true;
382
+ }
383
+ function disjointOwnership(a, b, platform = process.platform) {
384
+ const set = new Set(a.map((p) => schedulerPathIdentity(p, platform)));
385
+ return !b.some((p) => set.has(schedulerPathIdentity(p, platform)));
386
+ }
387
+ //#endregion
388
+ //#region src/validation/receipts.ts
389
+ const hashBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
390
+ function verifyReceiptHash(receipt, stdout, stderr) {
391
+ return hashBytes(stdout) === receipt.stdout.sha256 && hashBytes(stderr) === receipt.stderr.sha256;
392
+ }
393
+ //#endregion
394
+ //#region src/validation/review-handoff.ts
395
+ function receiptIndex(receipts) {
396
+ return receipts.map((receipt) => ({
397
+ commandId: receipt.commandId,
398
+ command: receipt.command,
399
+ status: receipt.status,
400
+ exitCode: receipt.exitCode,
401
+ start: receipt.start,
402
+ end: receipt.end,
403
+ timeoutMs: receipt.timeoutMs,
404
+ boundHead: receipt.boundHead,
405
+ ownershipFingerprint: receipt.ownershipFingerprint,
406
+ stdout: {
407
+ sha256: receipt.stdout.sha256,
408
+ bytes: receipt.stdout.bytes,
409
+ truncated: receipt.stdout.truncated
410
+ },
411
+ stderr: {
412
+ sha256: receipt.stderr.sha256,
413
+ bytes: receipt.stderr.bytes,
414
+ truncated: receipt.stderr.truncated
415
+ },
416
+ complete: receipt.complete
417
+ }));
418
+ }
419
+ async function assertTrustedReceipts(receipts, expectedHead, expectedOwnershipFingerprint) {
420
+ for (const receipt of receipts) {
421
+ if (receipt.boundHead !== expectedHead) throw new Error(`stale validation receipt ${receipt.commandId}: HEAD ${receipt.boundHead}`);
422
+ if (expectedOwnershipFingerprint !== void 0 && receipt.ownershipFingerprint !== expectedOwnershipFingerprint) throw new Error(`stale validation receipt ${receipt.commandId}: ownership fingerprint changed`);
423
+ if (receipt.status !== "PASS" || !receipt.complete || receipt.stdout.truncated || receipt.stderr.truncated) throw new Error(`validation receipt ${receipt.commandId} is not a complete PASS`);
424
+ if (receipt.stdout.bytes > 16 * 1024 * 1024 || receipt.stderr.bytes > 16 * 1024 * 1024) throw new Error(`validation receipt ${receipt.commandId} exceeds hard stream cap`);
425
+ const [stdout, stderr] = await Promise.all([readFile(receipt.stdout.path), readFile(receipt.stderr.path)]);
426
+ if (stdout.length !== receipt.stdout.bytes || stderr.length !== receipt.stderr.bytes || !verifyReceiptHash(receipt, stdout, stderr)) throw new Error(`tampered validation receipt ${receipt.commandId}`);
427
+ }
428
+ return true;
429
+ }
430
+ //#endregion
431
+ //#region src/external/github.ts
432
+ const exec = promisify(execFile);
433
+ async function ghRaw(cwd, args) {
434
+ const r = await exec("gh", args, {
435
+ cwd,
436
+ encoding: "utf8",
437
+ windowsHide: true,
438
+ maxBuffer: 8 * 1024 * 1024
439
+ });
440
+ return String(r.stdout ?? "");
441
+ }
442
+ async function gh(cwd, args) {
443
+ const raw = await ghRaw(cwd, args);
444
+ return JSON.parse(raw || "null");
445
+ }
446
+ async function ghRepository(cwd) {
447
+ await exec("gh", ["auth", "status"], {
448
+ cwd,
449
+ encoding: "utf8",
450
+ windowsHide: true
451
+ });
452
+ return gh(cwd, [
453
+ "repo",
454
+ "view",
455
+ "--json",
456
+ "nameWithOwner,defaultBranchRef"
457
+ ]);
458
+ }
459
+ async function ghPreflight(cwd, repository) {
460
+ const actual = await ghRepository(cwd);
461
+ if (actual?.nameWithOwner !== repository) throw new Error(`repository mismatch: ${actual?.nameWithOwner} != ${repository}`);
462
+ return actual;
463
+ }
464
+ async function fetchIssue(cwd, issue) {
465
+ return gh(cwd, [
466
+ "issue",
467
+ "view",
468
+ String(issue),
469
+ "--json",
470
+ "number,state,url,body,author,comments"
471
+ ]);
472
+ }
473
+ async function ghRepositoryPermission(cwd, repository, login) {
474
+ if (!/^[A-Za-z0-9-]{1,100}$/.test(login)) return void 0;
475
+ if (!/^[-A-Za-z0-9_.]+\/[-A-Za-z0-9_.]+$/.test(repository)) return void 0;
476
+ try {
477
+ const permission = (await ghRaw(cwd, [
478
+ "api",
479
+ `repos/${repository}/collaborators/${login}/permission`,
480
+ "--jq",
481
+ ".permission"
482
+ ])).trim();
483
+ return [
484
+ "admin",
485
+ "maintain",
486
+ "write",
487
+ "triage",
488
+ "read"
489
+ ].includes(permission) ? permission : void 0;
490
+ } catch {
491
+ return;
492
+ }
493
+ }
494
+ function canAuthorExternalControl(permission) {
495
+ return permission === "admin" || permission === "maintain" || permission === "write";
496
+ }
497
+ async function trustedIssueTexts(cwd, repository, issue, permissionLookup = ghRepositoryPermission) {
498
+ const sources = [{
499
+ body: String(issue?.body ?? ""),
500
+ login: String(issue?.author?.login ?? "")
501
+ }, ...(issue?.comments ?? []).map((comment) => ({
502
+ body: String(comment?.body ?? ""),
503
+ login: String(comment?.author?.login ?? "")
504
+ }))];
505
+ const cache = /* @__PURE__ */ new Map();
506
+ const trusted = [];
507
+ for (const source of sources) {
508
+ if (!source.login || !source.body) continue;
509
+ let allowed = cache.get(source.login);
510
+ if (allowed === void 0) {
511
+ allowed = canAuthorExternalControl(await permissionLookup(cwd, repository, source.login));
512
+ cache.set(source.login, allowed);
513
+ }
514
+ if (allowed) trusted.push(source.body);
515
+ }
516
+ return trusted;
517
+ }
518
+ async function ensureBaseCommit(cwd, sha) {
519
+ await git(cwd, [
520
+ "cat-file",
521
+ "-e",
522
+ `${sha}^{commit}`
523
+ ]);
524
+ return sha;
525
+ }
526
+ async function branchExists(cwd, branch) {
527
+ try {
528
+ await git(cwd, [
529
+ "show-ref",
530
+ "--verify",
531
+ "--quiet",
532
+ `refs/heads/${branch}`
533
+ ]);
534
+ return true;
535
+ } catch {
536
+ return false;
537
+ }
538
+ }
539
+ async function prepareIssueBranch(cwd, branch, baseCommit) {
540
+ const root = await repoRoot(cwd), dirty = await changedPaths(root);
541
+ if (dirty.length) throw new Error(`external issue mode requires a clean working tree; dirty: ${dirty.join(", ")}`);
542
+ await ensureBaseCommit(root, baseCommit);
543
+ if (await branchExists(root, branch)) {
544
+ await git(root, ["switch", branch]);
545
+ if (await fullHead(root) !== baseCommit) throw new Error(`existing ${branch} is not at trusted baseCommit; continuation requires remote completion/recovery evidence`);
546
+ } else await git(root, [
547
+ "switch",
548
+ "-c",
549
+ branch,
550
+ baseCommit
551
+ ]);
552
+ return root;
553
+ }
554
+ async function remotePr(cwd, pr) {
555
+ return gh(cwd, [
556
+ "pr",
557
+ "view",
558
+ String(pr),
559
+ "--json",
560
+ "number,state,url,headRefOid,headRefName"
561
+ ]);
562
+ }
563
+ async function openPrForBranch(cwd, branch) {
564
+ const rows = await gh(cwd, [
565
+ "pr",
566
+ "list",
567
+ "--head",
568
+ branch,
569
+ "--state",
570
+ "open",
571
+ "--json",
572
+ "number,state,url,headRefOid,headRefName"
573
+ ]);
574
+ return Array.isArray(rows) ? rows[0] : void 0;
575
+ }
576
+ //#endregion
577
+ export { validatePlanArtifact as A, git as C, normalizeOwnedPath as D, extractPlanArtifact as E, parseValidationCommand as O, fullHead as S, splitNul as T, readJson as _, openPrForBranch as a, changedPaths as b, trustedIssueTexts as c, hashBytes as d, assertOwnedPaths as f, confined as g, atomicJson as h, ghRepository as i, schedulerPathIdentity as k, assertTrustedReceipts as l, RunStore as m, ghPreflight as n, prepareIssueBranch as o, disjointOwnership as p, ghRaw as r, remotePr as s, fetchIssue as t, receiptIndex as u, stateRoot as v, repoRoot as w, decodeUtf8Strict as x, assertRepoPathsConfined as y };