@tea-agent/loop-agent 0.28.2-beta.1 → 0.28.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +37 -0
  3. package/README.md +11 -1
  4. package/dist/cli/command-definitions.js +2 -1
  5. package/dist/commands/client-recovery.js +111 -8
  6. package/dist/commands/dag-init-hybrid.js +1 -1
  7. package/dist/commands/init-upgrade.js +2479 -0
  8. package/dist/commands/init.js +120 -9
  9. package/dist/governance/manifest-types.js +65 -0
  10. package/dist/shared/operator/capabilities.js +350 -2
  11. package/dist/task/worktree.js +256 -39
  12. package/dist/worker/cli.js +22 -12
  13. package/dist/worker/console/chat/workspace-landing.js +16 -6
  14. package/dist/worker/console/observe-health-match.js +2 -0
  15. package/dist/worker/console/observe-link.js +4 -0
  16. package/dist/worker/console/operator-actions.js +183 -4
  17. package/dist/worker/console/operator-selection.js +13 -0
  18. package/dist/worker/console/static/assets/index-BfRgtLF4.js +29 -0
  19. package/dist/worker/console/static/index.html +1 -1
  20. package/dist/worker/observe/health.js +1 -0
  21. package/dist/worker/observe/night-jobs.js +104 -0
  22. package/dist/worker/observe/routes.js +48 -0
  23. package/dist/worker/observe/static/app.js +3 -0
  24. package/dist/worker/observe/static/constants.js +1 -0
  25. package/dist/worker/observe/static/index.html +47 -0
  26. package/dist/worker/observe/static/router.js +10 -0
  27. package/dist/worker/observe/static/shell-chrome.js +1 -0
  28. package/dist/worker/observe/static/views/night.js +201 -0
  29. package/dist/worker/report/morning-report.js +56 -16
  30. package/dist/worker/run-task/execute-prepared-task.js +153 -0
  31. package/dist/worker/runner/single-task-attempt.js +147 -0
  32. package/dist/worker/scheduler/admission.js +538 -0
  33. package/dist/worker/scheduler/auto-followup.js +99 -0
  34. package/dist/worker/scheduler/cli.js +539 -0
  35. package/dist/worker/scheduler/dispatcher.js +503 -0
  36. package/dist/worker/scheduler/doctor.js +346 -0
  37. package/dist/worker/scheduler/evidence.js +170 -0
  38. package/dist/worker/scheduler/git-base.js +90 -0
  39. package/dist/worker/scheduler/index.js +23 -0
  40. package/dist/worker/scheduler/lease.js +114 -0
  41. package/dist/worker/scheduler/lifecycle.js +348 -0
  42. package/dist/worker/scheduler/lock.js +80 -0
  43. package/dist/worker/scheduler/morning-window.js +161 -0
  44. package/dist/worker/scheduler/night-git-finalizer.js +88 -0
  45. package/dist/worker/scheduler/night-harvest.js +412 -0
  46. package/dist/worker/scheduler/paths.js +84 -0
  47. package/dist/worker/scheduler/prepared-attempt-recovery.js +471 -0
  48. package/dist/worker/scheduler/recovery.js +277 -0
  49. package/dist/worker/scheduler/reservation.js +146 -0
  50. package/dist/worker/scheduler/retry.js +199 -0
  51. package/dist/worker/scheduler/scheduler-loop.js +272 -0
  52. package/dist/worker/scheduler/store.js +275 -0
  53. package/dist/worker/scheduler/traceability.js +54 -0
  54. package/dist/worker/scheduler/trigger.js +258 -0
  55. package/dist/worker/scheduler/types.js +369 -0
  56. package/dist/worker/scheduler/workspace-adapter.js +91 -0
  57. package/dist/workflows/dag/frontend-implementation-contract.js +2 -102
  58. package/docs/architecture/runtime-boundaries.md +9 -0
  59. package/docs/init-surface.manifest.json +9 -2
  60. package/docs/templates/harness.schema.json +107 -0
  61. package/docs/templates/init-managed-agents.md +18 -8
  62. package/harness.json +22 -0
  63. package/package.json +1 -1
  64. package/skills/loop-agent/SKILL.md +28 -36
  65. package/skills/loop-agent/references/command-reference.md +40 -16
  66. package/skills/loop-agent/references/hybrid-dag.md +1 -1
  67. package/dist/worker/console/static/assets/index-CNO7n6qB.js +0 -29
@@ -0,0 +1,346 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { listTaskPoolStates } from "../pool/run-store.js";
4
+ import { getLeasesDir, getMutationLockPath, getSchedulerRoot, } from "./paths.js";
5
+ import { classifyLeaseLiveness, classifyLeaseRecovery } from "./recovery.js";
6
+ import { verifyEvidenceArchive } from "./evidence.js";
7
+ import { readExecutionLease } from "./lease.js";
8
+ import { listPendingJournals, listSchedules, readAdmission, readExecution, readLedgerEvents, } from "./store.js";
9
+ import { ACTIVE_SCHEDULE_STATUSES, isTerminalScheduleStatus, } from "./types.js";
10
+ /**
11
+ * Read-only consistency check for Night Scheduler facts.
12
+ * Does not mutate; use lifecycle recover / explicit apply commands for repair.
13
+ */
14
+ export async function diagnoseScheduler(controlRepoRoot, options) {
15
+ const now = options?.now ?? new Date();
16
+ const findings = [];
17
+ let schedules = [];
18
+ try {
19
+ schedules = await listSchedules(controlRepoRoot);
20
+ }
21
+ catch (error) {
22
+ findings.push({
23
+ code: "schedules-unreadable",
24
+ severity: "error",
25
+ message: `cannot list schedules: ${error instanceof Error ? error.message : String(error)}`,
26
+ });
27
+ }
28
+ const pending = await listPendingJournals(controlRepoRoot);
29
+ for (const journal of pending) {
30
+ findings.push({
31
+ code: "pending-transition-journal",
32
+ severity: "error",
33
+ scheduleId: journal.scheduleId,
34
+ message: `pending transition journal ${journal.transitionId} (${journal.fromStatus} → ${journal.toStatus}); run any mutation or doctor recover path`,
35
+ });
36
+ }
37
+ // Duplicate active schedule per task
38
+ const activeByTask = new Map();
39
+ for (const schedule of schedules) {
40
+ if (!ACTIVE_SCHEDULE_STATUSES.has(schedule.status))
41
+ continue;
42
+ const key = `${schedule.featureId}/${schedule.taskId}`;
43
+ const list = activeByTask.get(key) ?? [];
44
+ list.push(schedule);
45
+ activeByTask.set(key, list);
46
+ }
47
+ for (const [key, list] of activeByTask) {
48
+ if (list.length > 1) {
49
+ findings.push({
50
+ code: "duplicate-active-schedule",
51
+ severity: "error",
52
+ featureId: list[0]?.featureId,
53
+ taskId: list[0]?.taskId,
54
+ message: `multiple active schedules for ${key}: ${list.map((item) => item.id).join(", ")}`,
55
+ });
56
+ }
57
+ }
58
+ // Per-schedule structural checks
59
+ for (const schedule of schedules) {
60
+ if ((schedule.status === "scheduled" ||
61
+ schedule.status === "waiting" ||
62
+ schedule.status === "dispatching" ||
63
+ schedule.status === "running") &&
64
+ !schedule.trigger) {
65
+ findings.push({
66
+ code: "missing-trigger",
67
+ severity: "error",
68
+ scheduleId: schedule.id,
69
+ featureId: schedule.featureId,
70
+ taskId: schedule.taskId,
71
+ message: `status ${schedule.status} requires frozen once trigger`,
72
+ });
73
+ }
74
+ if (schedule.status === "scheduled" ||
75
+ schedule.status === "waiting" ||
76
+ schedule.status === "dispatching" ||
77
+ schedule.status === "running" ||
78
+ schedule.status === "succeeded") {
79
+ if (!schedule.admissionRef) {
80
+ findings.push({
81
+ code: "missing-admission-ref",
82
+ severity: "error",
83
+ scheduleId: schedule.id,
84
+ message: `status ${schedule.status} requires admissionRef (Phase 2 fills this; Phase 1 flags premature scheduled)`,
85
+ });
86
+ }
87
+ else {
88
+ const admission = await readAdmission(controlRepoRoot, schedule.id);
89
+ if (!admission) {
90
+ findings.push({
91
+ code: "admission-missing",
92
+ severity: "error",
93
+ scheduleId: schedule.id,
94
+ message: `admissionRef present but admission file missing for ${schedule.id}`,
95
+ });
96
+ }
97
+ }
98
+ }
99
+ if (isTerminalScheduleStatus(schedule.status) && schedule.currentExecutionId) {
100
+ const evidence = await verifyEvidenceArchive({
101
+ controlRepoRoot,
102
+ executionId: schedule.currentExecutionId,
103
+ });
104
+ if (!evidence.ok) {
105
+ findings.push({
106
+ code: "evidence-integrity-failed",
107
+ severity: "error",
108
+ scheduleId: schedule.id,
109
+ featureId: schedule.featureId,
110
+ taskId: schedule.taskId,
111
+ message: `evidence archive ${schedule.currentExecutionId} is invalid: ${evidence.reason}`,
112
+ });
113
+ }
114
+ }
115
+ if (schedule.traceability?.requiredOnCreate &&
116
+ !schedule.traceability.workItemRef &&
117
+ !isTerminalScheduleStatus(schedule.status)) {
118
+ findings.push({
119
+ code: "missing-task-card",
120
+ severity: "warning",
121
+ scheduleId: schedule.id,
122
+ message: "traceability.requiredOnCreate but workItemRef is empty",
123
+ });
124
+ }
125
+ if (schedule.status === "scheduled" &&
126
+ schedule.reservation?.taskPoolStatus !== "Queued") {
127
+ findings.push({
128
+ code: "missing-reservation",
129
+ severity: "warning",
130
+ scheduleId: schedule.id,
131
+ message: "scheduled record lacks Task Pool Queued reservation projection",
132
+ });
133
+ }
134
+ }
135
+ // Schedule ↔ Task Pool reservation consistency
136
+ let poolStates = [];
137
+ try {
138
+ poolStates = await listTaskPoolStates(controlRepoRoot);
139
+ }
140
+ catch (error) {
141
+ findings.push({
142
+ code: "task-pool-unreadable",
143
+ severity: "warning",
144
+ message: `cannot list Task Pool states: ${error instanceof Error ? error.message : String(error)}`,
145
+ });
146
+ }
147
+ const scheduleById = new Map(schedules.map((item) => [item.id, item]));
148
+ for (const state of poolStates) {
149
+ if (!state.nightScheduleId)
150
+ continue;
151
+ const schedule = scheduleById.get(state.nightScheduleId);
152
+ if (!schedule) {
153
+ findings.push({
154
+ code: "orphan-task-pool-reservation",
155
+ severity: "error",
156
+ featureId: state.featureId,
157
+ taskId: state.taskId,
158
+ scheduleId: state.nightScheduleId,
159
+ message: `Task Pool state references missing schedule ${state.nightScheduleId}`,
160
+ });
161
+ continue;
162
+ }
163
+ if (schedule.featureId !== state.featureId ||
164
+ schedule.taskId !== state.taskId) {
165
+ findings.push({
166
+ code: "reservation-identity-mismatch",
167
+ severity: "error",
168
+ scheduleId: schedule.id,
169
+ featureId: state.featureId,
170
+ taskId: state.taskId,
171
+ message: `Task Pool reservation identity ${state.featureId}/${state.taskId} does not match schedule ${schedule.featureId}/${schedule.taskId}`,
172
+ });
173
+ }
174
+ if (ACTIVE_SCHEDULE_STATUSES.has(schedule.status) &&
175
+ state.status !== "Queued" &&
176
+ state.status !== "Running" &&
177
+ state.status !== "Done" &&
178
+ state.status !== "Failed") {
179
+ findings.push({
180
+ code: "reservation-status-mismatch",
181
+ severity: "warning",
182
+ scheduleId: schedule.id,
183
+ featureId: state.featureId,
184
+ taskId: state.taskId,
185
+ message: `active schedule ${schedule.id} but Task Pool status is ${state.status}`,
186
+ });
187
+ }
188
+ }
189
+ for (const schedule of schedules) {
190
+ if (schedule.status !== "scheduled" &&
191
+ schedule.status !== "waiting" &&
192
+ schedule.status !== "dispatching") {
193
+ continue;
194
+ }
195
+ const bound = poolStates.find((state) => state.featureId === schedule.featureId &&
196
+ state.taskId === schedule.taskId &&
197
+ state.nightScheduleId === schedule.id);
198
+ if (!bound) {
199
+ findings.push({
200
+ code: "schedule-missing-pool-projection",
201
+ severity: "warning",
202
+ scheduleId: schedule.id,
203
+ featureId: schedule.featureId,
204
+ taskId: schedule.taskId,
205
+ message: `schedule ${schedule.id} has no Task Pool state bound via nightScheduleId`,
206
+ });
207
+ }
208
+ }
209
+ // Stale mutation lock
210
+ try {
211
+ const lockOwnerPath = path.join(getMutationLockPath(controlRepoRoot), "owner.json");
212
+ const raw = await readFile(lockOwnerPath, "utf-8");
213
+ const owner = JSON.parse(raw);
214
+ findings.push({
215
+ code: "mutation-lock-present",
216
+ severity: "warning",
217
+ message: `mutation lock present (pid=${owner.pid ?? "?"}, acquiredAt=${owner.acquiredAt ?? "?"})`,
218
+ });
219
+ }
220
+ catch {
221
+ // no lock — ok
222
+ }
223
+ // Lease recovery classification (Phase 4)
224
+ try {
225
+ const leaseDir = getLeasesDir(controlRepoRoot);
226
+ const leases = await readdir(leaseDir);
227
+ for (const name of leases) {
228
+ if (!name.endsWith(".json"))
229
+ continue;
230
+ const scheduleId = name.slice(0, -5);
231
+ const schedule = scheduleById.get(scheduleId);
232
+ const lease = await readExecutionLease(controlRepoRoot, scheduleId);
233
+ if (!schedule) {
234
+ findings.push({
235
+ code: "orphan-lease",
236
+ severity: "warning",
237
+ scheduleId,
238
+ message: `lease file exists without schedule snapshot ${scheduleId}`,
239
+ });
240
+ continue;
241
+ }
242
+ let execution = schedule.currentExecutionId
243
+ ? await readExecution(controlRepoRoot, schedule.currentExecutionId)
244
+ : undefined;
245
+ if (!execution && lease?.executionId) {
246
+ execution = await readExecution(controlRepoRoot, lease.executionId);
247
+ }
248
+ const classification = classifyLeaseRecovery({
249
+ schedule,
250
+ ...(lease ? { lease } : {}),
251
+ ...(execution ? { execution } : {}),
252
+ now,
253
+ });
254
+ const liveness = classifyLeaseLiveness(lease, now);
255
+ if (classification.kind === "termination-unconfirmed") {
256
+ findings.push({
257
+ code: "termination-unconfirmed",
258
+ severity: "error",
259
+ scheduleId,
260
+ featureId: schedule.featureId,
261
+ taskId: schedule.taskId,
262
+ message: `${classification.reason} (liveness=${liveness}); do not start a second writer`,
263
+ });
264
+ }
265
+ else if (classification.kind === "safe-reclaim") {
266
+ findings.push({
267
+ code: "orphan-claim-reclaimable",
268
+ severity: "warning",
269
+ scheduleId,
270
+ message: `${classification.reason}; next tick will reclaim`,
271
+ });
272
+ }
273
+ else if (isTerminalScheduleStatus(schedule.status) ||
274
+ classification.kind === "already-terminal") {
275
+ findings.push({
276
+ code: "orphan-lease",
277
+ severity: "warning",
278
+ scheduleId,
279
+ message: `lease remains on terminal/recoverable schedule ${scheduleId}`,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ // leases dir may not exist yet
286
+ }
287
+ // Ledger readability sample
288
+ try {
289
+ await readLedgerEvents(controlRepoRoot);
290
+ }
291
+ catch (error) {
292
+ findings.push({
293
+ code: "ledger-unreadable",
294
+ severity: "error",
295
+ message: `ledger unreadable: ${error instanceof Error ? error.message : String(error)}`,
296
+ });
297
+ }
298
+ // Root existence info
299
+ try {
300
+ await readdir(getSchedulerRoot(controlRepoRoot));
301
+ }
302
+ catch {
303
+ findings.push({
304
+ code: "scheduler-root-missing",
305
+ severity: "info",
306
+ message: "scheduler root does not exist yet (no schedules submitted)",
307
+ });
308
+ }
309
+ const activeCount = schedules.filter((item) => ACTIVE_SCHEDULE_STATUSES.has(item.status)).length;
310
+ const errors = findings.filter((item) => item.severity === "error");
311
+ return {
312
+ schemaVersion: 1,
313
+ generatedAt: now.toISOString(),
314
+ controlRepoRoot,
315
+ scheduleCount: schedules.length,
316
+ activeCount,
317
+ pendingJournalCount: pending.length,
318
+ findings,
319
+ ok: errors.length === 0,
320
+ };
321
+ }
322
+ export function formatSchedulerDoctorHuman(report) {
323
+ const lines = [
324
+ `Night Scheduler doctor`,
325
+ `root: ${report.controlRepoRoot}`,
326
+ `schedules: ${report.scheduleCount} (active ${report.activeCount})`,
327
+ `pending journals: ${report.pendingJournalCount}`,
328
+ `status: ${report.ok ? "ok" : "issues found"}`,
329
+ "",
330
+ ];
331
+ if (report.findings.length === 0) {
332
+ lines.push("No findings.");
333
+ }
334
+ else {
335
+ for (const finding of report.findings) {
336
+ const where = finding.scheduleId
337
+ ? ` [${finding.scheduleId}]`
338
+ : finding.featureId
339
+ ? ` [${finding.featureId}/${finding.taskId ?? "?"}]`
340
+ : "";
341
+ lines.push(`- (${finding.severity}) ${finding.code}${where}: ${finding.message}`);
342
+ }
343
+ }
344
+ lines.push("");
345
+ return `${lines.join("\n")}\n`;
346
+ }
@@ -0,0 +1,170 @@
1
+ import { createHash } from "node:crypto";
2
+ import { copyFile, mkdir, readFile, readdir, stat, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { getEvidenceDir } from "./paths.js";
6
+ /**
7
+ * Copy terminal evidence into control-root archive so worktree can be deleted later.
8
+ * Missing optional sources are skipped; manifest always written.
9
+ */
10
+ export async function archiveExecutionEvidence(input) {
11
+ const now = input.now ?? new Date();
12
+ const archiveRoot = path.join(getEvidenceDir(input.controlRepoRoot), input.executionId);
13
+ await mkdir(archiveRoot, { recursive: true });
14
+ const files = [];
15
+ const writeJson = async (relativePath, value) => {
16
+ const target = resolveArchiveTarget(archiveRoot, relativePath);
17
+ await mkdir(path.dirname(target), { recursive: true });
18
+ const text = `${JSON.stringify(value, null, 2)}\n`;
19
+ await writeFile(target, text, "utf-8");
20
+ files.push({
21
+ relativePath,
22
+ sha256: `sha256:${createHash("sha256").update(text, "utf-8").digest("hex")}`,
23
+ bytes: Buffer.byteLength(text, "utf-8"),
24
+ });
25
+ };
26
+ await writeJson("schedule.json", input.schedule);
27
+ await writeJson("execution.json", input.execution);
28
+ if (input.admission) {
29
+ await writeJson("admission.json", input.admission);
30
+ }
31
+ const candidates = [
32
+ ...(input.extraFiles ?? []),
33
+ ];
34
+ if (input.workspaceRoot && input.admission?.harnessTaskId) {
35
+ const taskDir = path.join(input.workspaceRoot, ".harness", "tasks", input.admission.harnessTaskId);
36
+ candidates.push({
37
+ absolutePath: path.join(taskDir, "artifacts", "worker-run-record.json"),
38
+ relativePath: "workspace/worker-run-record.json",
39
+ }, {
40
+ absolutePath: path.join(taskDir, "dag.json"),
41
+ relativePath: "workspace/dag.json",
42
+ }, {
43
+ absolutePath: path.join(taskDir, "artifacts", "admission-dag.json"),
44
+ relativePath: "workspace/admission-dag.json",
45
+ });
46
+ if (input.admission.dag?.path) {
47
+ const dagAbs = path.isAbsolute(input.admission.dag.path)
48
+ ? input.admission.dag.path
49
+ : path.join(input.workspaceRoot, input.admission.dag.path);
50
+ candidates.push({
51
+ absolutePath: dagAbs,
52
+ relativePath: "workspace/frozen-dag.json",
53
+ });
54
+ }
55
+ }
56
+ for (const candidate of candidates) {
57
+ const target = resolveArchiveTarget(archiveRoot, candidate.relativePath);
58
+ try {
59
+ const st = await stat(candidate.absolutePath);
60
+ if (!st.isFile())
61
+ continue;
62
+ await mkdir(path.dirname(target), { recursive: true });
63
+ await copyFile(candidate.absolutePath, target);
64
+ const raw = await readFile(target);
65
+ files.push({
66
+ relativePath: candidate.relativePath,
67
+ sha256: `sha256:${createHash("sha256").update(raw).digest("hex")}`,
68
+ bytes: raw.byteLength,
69
+ });
70
+ }
71
+ catch {
72
+ // optional
73
+ }
74
+ }
75
+ // Best-effort: copy only the latest completed DAG run bound to this task.
76
+ if (input.workspaceRoot && input.admission?.harnessTaskId) {
77
+ try {
78
+ const dagRuns = path.join(input.workspaceRoot, ".harness", "dag-runs", "completed");
79
+ const entries = (await readdir(dagRuns)).sort().reverse();
80
+ for (const name of entries) {
81
+ const runPath = path.join(dagRuns, name, "run.json");
82
+ try {
83
+ const runRaw = await readFile(runPath, "utf-8");
84
+ const run = JSON.parse(runRaw);
85
+ const boundTaskId = run.taskContractBinding?.taskId ?? run.sourceBinding?.taskId;
86
+ if (boundTaskId !== input.admission.harnessTaskId)
87
+ continue;
88
+ for (const fileName of ["run.json", "state.json"]) {
89
+ const source = path.join(dagRuns, name, fileName);
90
+ const relativePath = `workspace/dag-runs/${name}/${fileName}`;
91
+ const target = resolveArchiveTarget(archiveRoot, relativePath);
92
+ await mkdir(path.dirname(target), { recursive: true });
93
+ await copyFile(source, target);
94
+ const raw = await readFile(target);
95
+ files.push({
96
+ relativePath,
97
+ sha256: `sha256:${createHash("sha256").update(raw).digest("hex")}`,
98
+ bytes: raw.byteLength,
99
+ });
100
+ }
101
+ break;
102
+ }
103
+ catch {
104
+ // Not a readable/matching completed run.
105
+ }
106
+ }
107
+ }
108
+ catch {
109
+ // no completed runs
110
+ }
111
+ }
112
+ const manifest = {
113
+ schemaVersion: 1,
114
+ executionId: input.executionId,
115
+ scheduleId: input.schedule.id,
116
+ createdAt: now.toISOString(),
117
+ files,
118
+ archiveRoot: path
119
+ .relative(input.controlRepoRoot, archiveRoot)
120
+ .split(path.sep)
121
+ .join("/"),
122
+ };
123
+ await writeJsonAtomic(path.join(archiveRoot, "manifest.json"), manifest, {
124
+ repoRoot: input.controlRepoRoot,
125
+ });
126
+ return manifest;
127
+ }
128
+ export async function verifyEvidenceArchive(input) {
129
+ const archiveRoot = path.join(getEvidenceDir(input.controlRepoRoot), input.executionId);
130
+ try {
131
+ const raw = await readFile(path.join(archiveRoot, "manifest.json"), "utf-8");
132
+ const manifest = JSON.parse(raw);
133
+ if (manifest?.schemaVersion !== 1 ||
134
+ manifest.executionId !== input.executionId ||
135
+ !Array.isArray(manifest.files)) {
136
+ return { ok: false, reason: "manifest-shape-invalid" };
137
+ }
138
+ for (const file of manifest.files) {
139
+ const target = resolveArchiveTarget(archiveRoot, file.relativePath);
140
+ const bytes = await readFile(target);
141
+ const actual = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
142
+ if (bytes.byteLength !== file.bytes || actual !== file.sha256) {
143
+ return {
144
+ ok: false,
145
+ reason: `file-integrity-mismatch:${file.relativePath}`,
146
+ };
147
+ }
148
+ }
149
+ return { ok: true, manifest };
150
+ }
151
+ catch (error) {
152
+ return {
153
+ ok: false,
154
+ reason: error instanceof Error ? error.message : String(error),
155
+ };
156
+ }
157
+ }
158
+ function resolveArchiveTarget(archiveRoot, relativePath) {
159
+ if (!relativePath || path.isAbsolute(relativePath)) {
160
+ throw new Error(`unsafe evidence relativePath: ${relativePath}`);
161
+ }
162
+ const target = path.resolve(archiveRoot, relativePath);
163
+ const relative = path.relative(path.resolve(archiveRoot), target);
164
+ if (relative.length === 0 ||
165
+ relative.startsWith("..") ||
166
+ path.isAbsolute(relative)) {
167
+ throw new Error(`evidence path escapes archive root: ${relativePath}`);
168
+ }
169
+ return target;
170
+ }
@@ -0,0 +1,90 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Scheduler/Task Pool runtime under `.harness` and night worktrees are expected
4
+ * control facts. They must not block admission/harvest of source commits.
5
+ */
6
+ export function isSchedulerRuntimeGitPath(filePath) {
7
+ const normalized = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
8
+ return (normalized === ".harness" ||
9
+ normalized === ".worktrees" ||
10
+ normalized.startsWith(".harness/") ||
11
+ normalized.startsWith(".worktrees/"));
12
+ }
13
+ /**
14
+ * Parse `git status --porcelain=v1` and return source-path dirty lines only.
15
+ * XY markers occupy the first two columns; path starts at index 3.
16
+ */
17
+ export function filterSourceDirtyPorcelain(statusPorcelain) {
18
+ return statusPorcelain
19
+ .split("\n")
20
+ .map((line) => line.trimEnd())
21
+ .filter(Boolean)
22
+ .filter((line) => {
23
+ // Rename lines: `R old -> new` — treat either side as dirty source.
24
+ const body = line.slice(3).trim();
25
+ if (body.includes(" -> ")) {
26
+ const [from, to] = body.split(" -> ").map((part) => part.trim());
27
+ return ((from ? !isSchedulerRuntimeGitPath(from) : false) ||
28
+ (to ? !isSchedulerRuntimeGitPath(to) : false));
29
+ }
30
+ return !isSchedulerRuntimeGitPath(body);
31
+ });
32
+ }
33
+ /**
34
+ * Freeze the control repo HEAD as admission base.
35
+ * MVP requires a named branch and a clean *source* working tree.
36
+ * Runtime facts under `.harness/**` / `.worktrees/**` are ignored (ADR 0009).
37
+ */
38
+ export async function freezeCleanBase(controlRepoRoot) {
39
+ const branch = (await runGit(controlRepoRoot, ["rev-parse", "--abbrev-ref", "HEAD"])).trim();
40
+ const isDetached = branch === "HEAD";
41
+ const baseCommit = (await runGit(controlRepoRoot, ["rev-parse", "HEAD"])).trim();
42
+ const statusPorcelain = (await runGit(controlRepoRoot, [
43
+ "status",
44
+ "--porcelain=v1",
45
+ "--untracked-files=all",
46
+ ])).trimEnd();
47
+ const dirtySourceLines = filterSourceDirtyPorcelain(statusPorcelain);
48
+ const isClean = dirtySourceLines.length === 0;
49
+ if (isDetached) {
50
+ throw new Error("admission requires a named base branch (detached HEAD is not allowed)");
51
+ }
52
+ if (!isClean) {
53
+ const sample = dirtySourceLines
54
+ .slice(0, 5)
55
+ .map((line) => line.slice(3).trim())
56
+ .join(", ");
57
+ throw new Error(`admission requires a clean Git working tree on the base branch${sample ? ` (dirty source: ${sample})` : ""}`);
58
+ }
59
+ return {
60
+ baseBranch: branch,
61
+ baseCommit,
62
+ isClean,
63
+ isDetached,
64
+ statusPorcelain,
65
+ dirtySourceLines,
66
+ };
67
+ }
68
+ export async function runGit(cwd, args) {
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn("git", ["-C", cwd, ...args], {
71
+ stdio: ["ignore", "pipe", "pipe"],
72
+ });
73
+ const stdoutChunks = [];
74
+ const stderrChunks = [];
75
+ child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
76
+ child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
77
+ child.on("error", (err) => {
78
+ reject(new Error(`git ${args.join(" ")} spawn error: ${err.message}`));
79
+ });
80
+ child.on("close", (code) => {
81
+ const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
82
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
83
+ if (code !== 0) {
84
+ reject(new Error(`git ${args.join(" ")} failed (exit ${code}): ${stderr.trim()}`));
85
+ return;
86
+ }
87
+ resolve(stdout);
88
+ });
89
+ });
90
+ }
@@ -0,0 +1,23 @@
1
+ export * from "./types.js";
2
+ export * from "./paths.js";
3
+ export * from "./lock.js";
4
+ export * from "./trigger.js";
5
+ export * from "./store.js";
6
+ export * from "./lifecycle.js";
7
+ export * from "./doctor.js";
8
+ export * from "./traceability.js";
9
+ export * from "./reservation.js";
10
+ export * from "./workspace-adapter.js";
11
+ export * from "./admission.js";
12
+ export * from "./lease.js";
13
+ export * from "./evidence.js";
14
+ export * from "./night-git-finalizer.js";
15
+ export * from "./dispatcher.js";
16
+ export * from "./scheduler-loop.js";
17
+ export * from "./night-harvest.js";
18
+ export * from "./morning-window.js";
19
+ export * from "./recovery.js";
20
+ export * from "./retry.js";
21
+ export * from "./prepared-attempt-recovery.js";
22
+ export * from "./auto-followup.js";
23
+ export { registerSchedulerCommands } from "./cli.js";