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

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 +25 -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 +536 -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 +52 -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 +421 -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,272 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readdir } from "node:fs/promises";
3
+ import { readTaskPoolState } from "../pool/run-store.js";
4
+ import { dispatchClaimedExecution, } from "./dispatcher.js";
5
+ import { acquireExecutionLease, countActiveLeases, readExecutionLease, } from "./lease.js";
6
+ import { withSchedulerMutationLock } from "./lock.js";
7
+ import { transitionSchedule } from "./lifecycle.js";
8
+ import { getLeasesDir } from "./paths.js";
9
+ import { recoverStaleSchedulerState } from "./recovery.js";
10
+ import { listExecutionsForSchedule, listSchedules, readAdmission, recoverAllPendingJournals, writeExecution, } from "./store.js";
11
+ import { compareDueSchedules, evaluateOnceDue } from "./trigger.js";
12
+ import { SCHEDULER_ERROR_CODES, SchedulerError, } from "./types.js";
13
+ /**
14
+ * One OS-driven tick: recover journals, claim due schedules under short lock,
15
+ * then dispatch outside the lock.
16
+ */
17
+ export async function schedulerTick(options) {
18
+ const now = options.now ?? new Date();
19
+ const maxClaims = options.maxClaims ?? 1;
20
+ if (!Number.isInteger(maxClaims) || maxClaims < 1) {
21
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `maxClaims must be a positive integer, got ${maxClaims}`);
22
+ }
23
+ const waiting = [];
24
+ const skipped = [];
25
+ const recovery = [];
26
+ const claimedMeta = [];
27
+ await recoverAllPendingJournals(options.controlRepoRoot);
28
+ if (options.recoverStale !== false) {
29
+ const recovered = await recoverStaleSchedulerState({
30
+ controlRepoRoot: options.controlRepoRoot,
31
+ now,
32
+ hostname: options.hostname,
33
+ });
34
+ for (const item of recovered) {
35
+ recovery.push({
36
+ scheduleId: item.scheduleId,
37
+ action: item.action,
38
+ detail: item.detail,
39
+ });
40
+ }
41
+ }
42
+ await withSchedulerMutationLock({
43
+ controlRepoRoot: options.controlRepoRoot,
44
+ hostname: options.hostname,
45
+ now: () => now,
46
+ }, async () => {
47
+ const schedules = await listSchedules(options.controlRepoRoot);
48
+ const candidates = schedules
49
+ .filter((schedule) => schedule.status === "scheduled" || schedule.status === "waiting")
50
+ .filter((schedule) => schedule.trigger?.type === "once")
51
+ .map((schedule) => ({
52
+ schedule,
53
+ executeAtUtc: schedule.trigger.executeAtUtc,
54
+ scheduleId: schedule.id,
55
+ }))
56
+ .sort((a, b) => compareDueSchedules({ executeAtUtc: a.executeAtUtc, scheduleId: a.scheduleId }, { executeAtUtc: b.executeAtUtc, scheduleId: b.scheduleId }));
57
+ const activeLeaseCount = await countCurrentActiveLeases(options.controlRepoRoot, now);
58
+ const concurrencyLimits = candidates.map((item) => item.schedule.policySnapshot?.maxConcurrency ?? 1);
59
+ const maxConcurrency = concurrencyLimits.length > 0
60
+ ? Math.max(1, Math.min(...concurrencyLimits))
61
+ : 1;
62
+ let remainingCapacity = Math.max(0, maxConcurrency - activeLeaseCount);
63
+ remainingCapacity = Math.min(remainingCapacity, maxClaims);
64
+ for (const item of candidates) {
65
+ if (remainingCapacity <= 0) {
66
+ skipped.push({
67
+ scheduleId: item.scheduleId,
68
+ reason: "max-concurrency",
69
+ });
70
+ continue;
71
+ }
72
+ const decision = evaluateDue(item.schedule, now);
73
+ if (decision.kind === "waiting") {
74
+ if (item.schedule.status === "scheduled" ||
75
+ item.schedule.waitingReason !== decision.reason) {
76
+ await transitionSchedule({
77
+ controlRepoRoot: options.controlRepoRoot,
78
+ scheduleId: item.scheduleId,
79
+ toStatus: "waiting",
80
+ event: "waiting",
81
+ reason: decision.reason,
82
+ reasonCode: decision.reason,
83
+ patch: {
84
+ waitingReason: decision.reason,
85
+ nextAttemptAt: decision.nextAttemptAt,
86
+ },
87
+ alreadyLocked: true,
88
+ now,
89
+ });
90
+ }
91
+ waiting.push({
92
+ scheduleId: item.scheduleId,
93
+ reason: decision.reason,
94
+ ...(decision.nextAttemptAt
95
+ ? { nextAttemptAt: decision.nextAttemptAt }
96
+ : {}),
97
+ });
98
+ continue;
99
+ }
100
+ if (decision.kind === "human_required") {
101
+ await transitionSchedule({
102
+ controlRepoRoot: options.controlRepoRoot,
103
+ scheduleId: item.scheduleId,
104
+ toStatus: "human_required",
105
+ event: "human_required",
106
+ reason: decision.reason,
107
+ reasonCode: decision.reason,
108
+ alreadyLocked: true,
109
+ now,
110
+ });
111
+ skipped.push({
112
+ scheduleId: item.scheduleId,
113
+ reason: decision.reason,
114
+ });
115
+ continue;
116
+ }
117
+ // eligible
118
+ const existingLease = await readExecutionLease(options.controlRepoRoot, item.scheduleId);
119
+ if (existingLease) {
120
+ skipped.push({
121
+ scheduleId: item.scheduleId,
122
+ reason: "lease-held",
123
+ });
124
+ continue;
125
+ }
126
+ const admission = await readAdmission(options.controlRepoRoot, item.scheduleId);
127
+ if (!admission?.workspace ||
128
+ !admission.gateApproval?.token ||
129
+ !admission.gateApproval.approvedAt) {
130
+ await transitionSchedule({
131
+ controlRepoRoot: options.controlRepoRoot,
132
+ scheduleId: item.scheduleId,
133
+ toStatus: "human_required",
134
+ event: "human_required",
135
+ reason: "approved admission incomplete at claim",
136
+ reasonCode: "admission-incomplete",
137
+ alreadyLocked: true,
138
+ now,
139
+ });
140
+ skipped.push({
141
+ scheduleId: item.scheduleId,
142
+ reason: "admission-incomplete",
143
+ });
144
+ continue;
145
+ }
146
+ const poolState = await readTaskPoolState(options.controlRepoRoot, {
147
+ featureId: item.schedule.featureId,
148
+ taskId: item.schedule.taskId,
149
+ });
150
+ if (poolState?.status !== "Queued" ||
151
+ poolState.nightScheduleId !== item.scheduleId) {
152
+ await transitionSchedule({
153
+ controlRepoRoot: options.controlRepoRoot,
154
+ scheduleId: item.scheduleId,
155
+ toStatus: "human_required",
156
+ event: "human_required",
157
+ reason: "Task Pool reservation is missing or owned by another run",
158
+ reasonCode: "reservation-mismatch",
159
+ alreadyLocked: true,
160
+ now,
161
+ });
162
+ skipped.push({
163
+ scheduleId: item.scheduleId,
164
+ reason: "reservation-mismatch",
165
+ });
166
+ continue;
167
+ }
168
+ const priorExecutions = await listExecutionsForSchedule(options.controlRepoRoot, item.scheduleId);
169
+ const attemptNumber = priorExecutions.length + 1;
170
+ const executionId = `exec-${item.scheduleId}-${randomBytes(2).toString("hex")}`;
171
+ await transitionSchedule({
172
+ controlRepoRoot: options.controlRepoRoot,
173
+ scheduleId: item.scheduleId,
174
+ toStatus: "dispatching",
175
+ event: "execution_claimed",
176
+ patch: {
177
+ currentExecutionId: executionId,
178
+ waitingReason: null,
179
+ nextAttemptAt: null,
180
+ },
181
+ alreadyLocked: true,
182
+ now,
183
+ });
184
+ const lease = await acquireExecutionLease({
185
+ controlRepoRoot: options.controlRepoRoot,
186
+ scheduleId: item.scheduleId,
187
+ executionId,
188
+ hostname: options.hostname,
189
+ workspacePath: admission.workspace.path,
190
+ now,
191
+ });
192
+ await writeExecution(options.controlRepoRoot, {
193
+ schemaVersion: 1,
194
+ executionId,
195
+ scheduleId: item.scheduleId,
196
+ attempt: attemptNumber,
197
+ status: "claimed",
198
+ claimedAt: now.toISOString(),
199
+ startedAt: null,
200
+ finishedAt: null,
201
+ lease: {
202
+ ownerPid: lease.ownerPid,
203
+ hostname: lease.hostname,
204
+ token: lease.token,
205
+ heartbeatAt: lease.heartbeatAt,
206
+ },
207
+ workspacePath: admission.workspace.path,
208
+ failure: null,
209
+ });
210
+ claimedMeta.push({
211
+ scheduleId: item.scheduleId,
212
+ executionId,
213
+ lease,
214
+ });
215
+ remainingCapacity -= 1;
216
+ }
217
+ });
218
+ const dispatch = options.dispatch ?? dispatchClaimedExecution;
219
+ const dispatched = await Promise.all(claimedMeta.map((claim) => dispatch({
220
+ controlRepoRoot: options.controlRepoRoot,
221
+ scheduleId: claim.scheduleId,
222
+ executionId: claim.executionId,
223
+ lease: claim.lease,
224
+ client: options.client,
225
+ now: new Date(),
226
+ })));
227
+ return {
228
+ schemaVersion: 1,
229
+ at: now.toISOString(),
230
+ claimed: claimedMeta.map((item) => item.scheduleId),
231
+ dispatched,
232
+ waiting,
233
+ skipped,
234
+ recovery,
235
+ };
236
+ }
237
+ function evaluateDue(schedule, now) {
238
+ const trigger = schedule.trigger;
239
+ if (!trigger || trigger.type !== "once") {
240
+ return { kind: "human_required", reason: "missing-once-trigger" };
241
+ }
242
+ const policy = schedule.policySnapshot;
243
+ return evaluateOnceDue({
244
+ nowUtc: now,
245
+ executeAtUtc: trigger.executeAtUtc,
246
+ ...(trigger.latestStartAtUtc
247
+ ? { latestStartAtUtc: trigger.latestStartAtUtc }
248
+ : {}),
249
+ timezone: trigger.displayTimezone ?? policy?.timezone ?? "Asia/Shanghai",
250
+ allowedHours: policy?.allowedHours ?? ["00:00-08:00"],
251
+ misfirePolicy: trigger.misfirePolicy ?? policy?.misfirePolicy ?? "wait-next-window",
252
+ });
253
+ }
254
+ async function countCurrentActiveLeases(controlRepoRoot, now) {
255
+ const leases = [];
256
+ try {
257
+ const dir = getLeasesDir(controlRepoRoot);
258
+ const names = await readdir(dir);
259
+ for (const name of names) {
260
+ if (!name.endsWith(".json"))
261
+ continue;
262
+ const scheduleId = name.slice(0, -5);
263
+ const lease = await readExecutionLease(controlRepoRoot, scheduleId);
264
+ if (lease)
265
+ leases.push(lease);
266
+ }
267
+ }
268
+ catch {
269
+ return 0;
270
+ }
271
+ return countActiveLeases(leases, now);
272
+ }
@@ -0,0 +1,275 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { appendFile, mkdir, readdir, readFile, unlink, writeFile, } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { writeJsonAtomic } from "../../infrastructure/harness/atomic-write.js";
5
+ import { getAdmissionPath, getAdmissionsDir, getExecutionPath, getExecutionsDir, getLedgerPath, getSchedulePath, getSchedulesDir, getSchedulerRoot, getTransactionPath, getTransactionsDir, relativeSchedulerPath, } from "./paths.js";
6
+ import { ACTIVE_SCHEDULE_STATUSES, executionRecordSchema, ledgerEventSchema, scheduleRecordSchema, SCHEDULER_ERROR_CODES, SchedulerError, taskAdmissionSchema, transitionJournalSchema, } from "./types.js";
7
+ export async function ensureSchedulerDirs(controlRepoRoot) {
8
+ const root = getSchedulerRoot(controlRepoRoot);
9
+ await mkdir(root, { recursive: true });
10
+ await Promise.all([
11
+ getSchedulesDir(controlRepoRoot),
12
+ getAdmissionsDir(controlRepoRoot),
13
+ getExecutionsDir(controlRepoRoot),
14
+ getTransactionsDir(controlRepoRoot),
15
+ path.join(root, "leases"),
16
+ path.join(root, "evidence"),
17
+ path.join(root, "reports"),
18
+ ].map((dir) => mkdir(dir, { recursive: true })));
19
+ }
20
+ export function createScheduleId(now = new Date()) {
21
+ const stamp = now
22
+ .toISOString()
23
+ .replace(/[-:]/g, "")
24
+ .replace(/\.\d{3}Z$/, "Z");
25
+ // sched-20260806T180000Z-7f3a
26
+ const compact = stamp.replace("Z", "").slice(0, 15);
27
+ const suffix = randomBytes(2).toString("hex");
28
+ return `sched-${compact}-${suffix}`;
29
+ }
30
+ export function createTransitionId(now = new Date()) {
31
+ return `tr-${now.getTime().toString(36)}-${randomBytes(3).toString("hex")}`;
32
+ }
33
+ export function createEventId(now = new Date()) {
34
+ return `evt-${now.getTime().toString(36)}-${randomBytes(3).toString("hex")}`;
35
+ }
36
+ export async function readSchedule(controlRepoRoot, scheduleId) {
37
+ const filePath = getSchedulePath(controlRepoRoot, scheduleId);
38
+ try {
39
+ const raw = await readFile(filePath, "utf-8");
40
+ return scheduleRecordSchema.parse(JSON.parse(raw));
41
+ }
42
+ catch (error) {
43
+ if (isNotFound(error))
44
+ return undefined;
45
+ if (error instanceof SchedulerError)
46
+ throw error;
47
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `invalid schedule snapshot ${scheduleId}: ${error instanceof Error ? error.message : String(error)}`);
48
+ }
49
+ }
50
+ export async function writeScheduleSnapshot(controlRepoRoot, schedule) {
51
+ const parsed = scheduleRecordSchema.parse(schedule);
52
+ await ensureSchedulerDirs(controlRepoRoot);
53
+ await writeJsonAtomic(getSchedulePath(controlRepoRoot, parsed.id), parsed, {
54
+ repoRoot: controlRepoRoot,
55
+ });
56
+ }
57
+ export async function listSchedules(controlRepoRoot) {
58
+ const dir = getSchedulesDir(controlRepoRoot);
59
+ let entries;
60
+ try {
61
+ entries = await readdir(dir);
62
+ }
63
+ catch (error) {
64
+ if (isNotFound(error))
65
+ return [];
66
+ throw error;
67
+ }
68
+ const records = [];
69
+ for (const name of entries) {
70
+ if (!name.endsWith(".json"))
71
+ continue;
72
+ const id = name.slice(0, -5);
73
+ const record = await readSchedule(controlRepoRoot, id);
74
+ if (record)
75
+ records.push(record);
76
+ }
77
+ return records.sort((a, b) => a.id.localeCompare(b.id));
78
+ }
79
+ export async function listActiveSchedulesForTask(controlRepoRoot, featureId, taskId) {
80
+ const all = await listSchedules(controlRepoRoot);
81
+ return all.filter((schedule) => schedule.featureId === featureId &&
82
+ schedule.taskId === taskId &&
83
+ ACTIVE_SCHEDULE_STATUSES.has(schedule.status));
84
+ }
85
+ export async function readAdmission(controlRepoRoot, scheduleId) {
86
+ const filePath = getAdmissionPath(controlRepoRoot, scheduleId);
87
+ try {
88
+ const raw = await readFile(filePath, "utf-8");
89
+ return taskAdmissionSchema.parse(JSON.parse(raw));
90
+ }
91
+ catch (error) {
92
+ if (isNotFound(error))
93
+ return undefined;
94
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `invalid admission ${scheduleId}: ${error instanceof Error ? error.message : String(error)}`);
95
+ }
96
+ }
97
+ export async function writeAdmission(controlRepoRoot, admission) {
98
+ const parsed = taskAdmissionSchema.parse(admission);
99
+ await ensureSchedulerDirs(controlRepoRoot);
100
+ const absolute = getAdmissionPath(controlRepoRoot, parsed.scheduleId);
101
+ await writeJsonAtomic(absolute, parsed, { repoRoot: controlRepoRoot });
102
+ return relativeSchedulerPath(controlRepoRoot, absolute);
103
+ }
104
+ export async function readExecution(controlRepoRoot, executionId) {
105
+ const filePath = getExecutionPath(controlRepoRoot, executionId);
106
+ try {
107
+ const raw = await readFile(filePath, "utf-8");
108
+ return executionRecordSchema.parse(JSON.parse(raw));
109
+ }
110
+ catch (error) {
111
+ if (isNotFound(error))
112
+ return undefined;
113
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `invalid execution ${executionId}: ${error instanceof Error ? error.message : String(error)}`);
114
+ }
115
+ }
116
+ export async function writeExecution(controlRepoRoot, execution) {
117
+ const parsed = executionRecordSchema.parse(execution);
118
+ await ensureSchedulerDirs(controlRepoRoot);
119
+ await writeJsonAtomic(getExecutionPath(controlRepoRoot, parsed.executionId), parsed, { repoRoot: controlRepoRoot });
120
+ }
121
+ export async function listExecutions(controlRepoRoot) {
122
+ const dir = getExecutionsDir(controlRepoRoot);
123
+ let entries;
124
+ try {
125
+ entries = await readdir(dir);
126
+ }
127
+ catch (error) {
128
+ if (isNotFound(error))
129
+ return [];
130
+ throw error;
131
+ }
132
+ const records = [];
133
+ for (const name of entries) {
134
+ if (!name.endsWith(".json"))
135
+ continue;
136
+ const id = name.slice(0, -5);
137
+ const record = await readExecution(controlRepoRoot, id);
138
+ if (record)
139
+ records.push(record);
140
+ }
141
+ return records.sort((a, b) => a.executionId.localeCompare(b.executionId));
142
+ }
143
+ export async function listExecutionsForSchedule(controlRepoRoot, scheduleId) {
144
+ const all = await listExecutions(controlRepoRoot);
145
+ return all.filter((item) => item.scheduleId === scheduleId);
146
+ }
147
+ export async function appendLedgerEvent(controlRepoRoot, event) {
148
+ const parsed = ledgerEventSchema.parse(event);
149
+ await ensureSchedulerDirs(controlRepoRoot);
150
+ const ledgerPath = getLedgerPath(controlRepoRoot);
151
+ // Idempotent by eventId: skip if already present.
152
+ const existing = await readLedgerEvents(controlRepoRoot);
153
+ if (existing.some((item) => item.eventId === parsed.eventId)) {
154
+ return;
155
+ }
156
+ await appendFile(ledgerPath, `${JSON.stringify(parsed)}\n`, "utf-8");
157
+ }
158
+ export async function readLedgerEvents(controlRepoRoot, filter) {
159
+ const ledgerPath = getLedgerPath(controlRepoRoot);
160
+ let raw;
161
+ try {
162
+ raw = await readFile(ledgerPath, "utf-8");
163
+ }
164
+ catch (error) {
165
+ if (isNotFound(error))
166
+ return [];
167
+ throw error;
168
+ }
169
+ const events = [];
170
+ for (const [index, line] of raw.split(/\r?\n/).filter(Boolean).entries()) {
171
+ try {
172
+ const parsed = ledgerEventSchema.safeParse(JSON.parse(line));
173
+ if (!parsed.success)
174
+ continue;
175
+ if (filter?.scheduleId && parsed.data.scheduleId !== filter.scheduleId) {
176
+ continue;
177
+ }
178
+ if (filter?.toStatus && parsed.data.toStatus !== filter.toStatus) {
179
+ continue;
180
+ }
181
+ events.push(parsed.data);
182
+ }
183
+ catch {
184
+ // skip corrupt line; doctor reports separately
185
+ void index;
186
+ }
187
+ }
188
+ return events;
189
+ }
190
+ export async function writeTransitionJournal(controlRepoRoot, journal) {
191
+ const parsed = transitionJournalSchema.parse(journal);
192
+ await ensureSchedulerDirs(controlRepoRoot);
193
+ await writeJsonAtomic(getTransactionPath(controlRepoRoot, parsed.transitionId), parsed, { repoRoot: controlRepoRoot });
194
+ }
195
+ export async function readTransitionJournal(controlRepoRoot, transitionId) {
196
+ const filePath = getTransactionPath(controlRepoRoot, transitionId);
197
+ try {
198
+ const raw = await readFile(filePath, "utf-8");
199
+ return transitionJournalSchema.parse(JSON.parse(raw));
200
+ }
201
+ catch (error) {
202
+ if (isNotFound(error))
203
+ return undefined;
204
+ throw new SchedulerError(SCHEDULER_ERROR_CODES.INVALID_RECORD, `invalid transition journal ${transitionId}: ${error instanceof Error ? error.message : String(error)}`);
205
+ }
206
+ }
207
+ export async function listPendingJournals(controlRepoRoot) {
208
+ const dir = getTransactionsDir(controlRepoRoot);
209
+ let entries;
210
+ try {
211
+ entries = await readdir(dir);
212
+ }
213
+ catch (error) {
214
+ if (isNotFound(error))
215
+ return [];
216
+ throw error;
217
+ }
218
+ const journals = [];
219
+ for (const name of entries) {
220
+ if (!name.endsWith(".json"))
221
+ continue;
222
+ const id = name.slice(0, -5);
223
+ const journal = await readTransitionJournal(controlRepoRoot, id);
224
+ if (journal && journal.status === "pending") {
225
+ journals.push(journal);
226
+ }
227
+ }
228
+ return journals;
229
+ }
230
+ export async function deleteTransitionJournal(controlRepoRoot, transitionId) {
231
+ const filePath = getTransactionPath(controlRepoRoot, transitionId);
232
+ try {
233
+ await unlink(filePath);
234
+ }
235
+ catch (error) {
236
+ if (!isNotFound(error))
237
+ throw error;
238
+ }
239
+ }
240
+ /**
241
+ * Recover a pending journal: rewrite schedule snapshot + append ledger (idempotent),
242
+ * then mark committed and delete journal.
243
+ */
244
+ export async function recoverTransitionJournal(controlRepoRoot, journal) {
245
+ const parsed = transitionJournalSchema.parse(journal);
246
+ await writeScheduleSnapshot(controlRepoRoot, parsed.scheduleSnapshot);
247
+ await appendLedgerEvent(controlRepoRoot, parsed.ledgerEvent);
248
+ const committed = {
249
+ ...parsed,
250
+ status: "committed",
251
+ committedAt: new Date().toISOString(),
252
+ };
253
+ await writeTransitionJournal(controlRepoRoot, committed);
254
+ await deleteTransitionJournal(controlRepoRoot, parsed.transitionId);
255
+ return parsed.scheduleSnapshot;
256
+ }
257
+ export async function recoverAllPendingJournals(controlRepoRoot) {
258
+ const pending = await listPendingJournals(controlRepoRoot);
259
+ const recovered = [];
260
+ for (const journal of pending) {
261
+ recovered.push(await recoverTransitionJournal(controlRepoRoot, journal));
262
+ }
263
+ return recovered;
264
+ }
265
+ /** Low-level helper for tests that need to simulate partial crash mid-write. */
266
+ export async function writeRawFile(filePath, content) {
267
+ await mkdir(path.dirname(filePath), { recursive: true });
268
+ await writeFile(filePath, content, "utf-8");
269
+ }
270
+ function isNotFound(error) {
271
+ return Boolean(error &&
272
+ typeof error === "object" &&
273
+ "code" in error &&
274
+ error.code === "ENOENT");
275
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Validate a task-card / work item ref against tracked policy.
3
+ * Public package keeps generic workItemRef; company repos set required=true.
4
+ */
5
+ export function validateTaskCard(workItemRef, policy) {
6
+ const required = policy?.required ?? false;
7
+ const label = policy?.label ?? "Task Card";
8
+ const patternSource = policy?.pattern ?? "^[A-Z][A-Z0-9]+-[A-Z0-9]+#[0-9]+$";
9
+ const template = policy?.commitMessageTemplate ?? "{summary}\n\n{workItemRef}";
10
+ if (!template.includes("{workItemRef}")) {
11
+ return {
12
+ ok: false,
13
+ reason: "traceability.commitMessageTemplate must include {workItemRef}",
14
+ };
15
+ }
16
+ if (!workItemRef || workItemRef.trim().length === 0) {
17
+ if (required) {
18
+ return {
19
+ ok: false,
20
+ reason: `${label} is required (--task-card)`,
21
+ };
22
+ }
23
+ return { ok: true, workItemRef: "" };
24
+ }
25
+ let re;
26
+ try {
27
+ re = new RegExp(patternSource);
28
+ }
29
+ catch {
30
+ return {
31
+ ok: false,
32
+ reason: `invalid traceability.pattern: ${patternSource}`,
33
+ };
34
+ }
35
+ if (!re.test(workItemRef.trim())) {
36
+ return {
37
+ ok: false,
38
+ reason: `${label} does not match pattern ${patternSource}: ${workItemRef}`,
39
+ };
40
+ }
41
+ return { ok: true, workItemRef: workItemRef.trim() };
42
+ }
43
+ export function formatCommitMessage(input) {
44
+ const template = input.template ?? "{summary}\n\n{workItemRef}";
45
+ const summary = input.summary.replace(/\r?\n/g, " ").trim().slice(0, 72);
46
+ let body = template
47
+ .replaceAll("{summary}", summary)
48
+ .replaceAll("{workItemRef}", input.workItemRef);
49
+ if (input.trailers) {
50
+ const lines = Object.entries(input.trailers).map(([key, value]) => `${key}: ${value}`);
51
+ body = `${body.trimEnd()}\n${lines.join("\n")}`;
52
+ }
53
+ return `${body.trimEnd()}\n`;
54
+ }