@staix/agent-hub 0.24.0 → 0.26.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +35 -8
  3. package/docs/agent-notes/README.md +12 -0
  4. package/docs/agent-notes/adapters.md +2 -1
  5. package/docs/agent-notes/bus.md +1 -0
  6. package/docs/agent-notes/daemon.md +18 -3
  7. package/docs/agent-notes/tasks.md +12 -4
  8. package/docs/agent-notes/tests.md +3 -1
  9. package/docs/dogfood.md +16 -2
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +264 -14
  12. package/docs/profiles.md +2 -0
  13. package/docs/quickstart.md +23 -6
  14. package/docs/security.md +198 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
  16. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  17. package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
  18. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  19. package/docs/verification/2026-11-01-0.26.0.md +54 -0
  20. package/docs/verified.json +45 -17
  21. package/package.json +4 -3
  22. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  23. package/plugins/agent-hub/server.js +5294 -4797
  24. package/src/adapters/channel-peer.ts +4 -2
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/definitions/builtin-metadata.ts +6 -6
  27. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  28. package/src/adapters/definitions/qwen.ts +2 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry-types.ts +2 -0
  31. package/src/adapters/registry.ts +2 -1
  32. package/src/cli/acon-register.ts +29 -0
  33. package/src/cli/conductor-recovery.ts +3 -2
  34. package/src/cli/handoff-args.ts +27 -0
  35. package/src/cli/handoff-output.ts +25 -0
  36. package/src/cli/help.ts +11 -8
  37. package/src/cli/identity.ts +7 -1
  38. package/src/cli/main.ts +138 -30
  39. package/src/cli/output.ts +8 -5
  40. package/src/cli/recovery-package.ts +94 -4
  41. package/src/cli/reset.ts +14 -4
  42. package/src/cli/runtime-version-warning.ts +28 -0
  43. package/src/cli/schedule-output.ts +8 -4
  44. package/src/cli/start-agents-run.ts +125 -0
  45. package/src/cli/study-output.ts +71 -0
  46. package/src/cli/study.ts +58 -0
  47. package/src/cli/task-output.ts +6 -0
  48. package/src/cli/upgrade-interactive.ts +4 -4
  49. package/src/cli/upgrade-runtime.ts +105 -57
  50. package/src/cli/upgrade.ts +90 -28
  51. package/src/hub/autonomy-defaults.ts +32 -7
  52. package/src/hub/board.ts +993 -8
  53. package/src/hub/budget.ts +3 -4
  54. package/src/hub/channel-project.ts +61 -0
  55. package/src/hub/child-process.ts +4 -3
  56. package/src/hub/conductor.ts +3 -4
  57. package/src/hub/config-apply.ts +13 -2
  58. package/src/hub/config-edit-host.ts +1 -0
  59. package/src/hub/config-tier.ts +3 -0
  60. package/src/hub/config-trust.ts +10 -2
  61. package/src/hub/control-client.ts +39 -10
  62. package/src/hub/daemon.ts +326 -25
  63. package/src/hub/db-schema.ts +143 -0
  64. package/src/hub/delivery-journal.ts +3 -4
  65. package/src/hub/envelope.ts +2 -0
  66. package/src/hub/events.ts +4 -1
  67. package/src/hub/execution-budget.ts +15 -4
  68. package/src/hub/folder-trigger.ts +3 -4
  69. package/src/hub/handoff.ts +126 -0
  70. package/src/hub/hub-tools.ts +12 -2
  71. package/src/hub/invocations.ts +3 -4
  72. package/src/hub/lifecycle.ts +87 -29
  73. package/src/hub/manager.ts +110 -87
  74. package/src/hub/no-writes.ts +1 -1
  75. package/src/hub/orca-env.ts +19 -0
  76. package/src/hub/path-policy.ts +12 -8
  77. package/src/hub/project-runtime-state.ts +139 -0
  78. package/src/hub/project-runtime.ts +62 -0
  79. package/src/hub/project.ts +14 -5
  80. package/src/hub/protocol-window.ts +19 -0
  81. package/src/hub/provenance.ts +30 -0
  82. package/src/hub/recovery-store.ts +102 -12
  83. package/src/hub/registry.ts +5 -0
  84. package/src/hub/research-campaign.ts +265 -0
  85. package/src/hub/research-experiments.ts +474 -0
  86. package/src/hub/research-runner.ts +172 -0
  87. package/src/hub/restart.ts +35 -0
  88. package/src/hub/runtime-capture-prune.ts +415 -0
  89. package/src/hub/runtime-prune-health.ts +112 -0
  90. package/src/hub/schedules.ts +213 -13
  91. package/src/hub/sdlc-merge.ts +7 -0
  92. package/src/hub/sdlc-queue.ts +13 -1
  93. package/src/hub/session-identity.ts +49 -0
  94. package/src/hub/settings.ts +4 -0
  95. package/src/hub/snapshots.ts +3 -4
  96. package/src/hub/start-agents.ts +44 -0
  97. package/src/hub/task-approval.ts +23 -0
  98. package/src/hub/tasks.ts +1005 -34
  99. package/src/hub/ui.ts +15 -6
  100. package/src/hub/usage-rollup.ts +3 -4
  101. package/src/memory/capture.ts +2 -2
  102. package/src/pi/tool-receipts.ts +3 -4
  103. package/src/ui/index.html +5 -3
  104. package/src/ui/office-space.ts +118 -0
  105. package/src/ui/plugin.ts +293 -0
  106. package/templates/AGENTS.block.md +1 -0
  107. package/templates/profiles/general.json +1 -1
  108. package/templates/profiles/research.json +4 -4
  109. package/templates/profiles/writing.json +1 -1
  110. package/templates/roles/research/analyst.md +1 -0
  111. package/templates/roles/research/fact-checker.md +1 -0
  112. package/templates/roles/research/research-lead.md +1 -0
  113. package/templates/roles/research/researcher.md +1 -0
  114. package/templates/roles/research/statistician.md +1 -0
@@ -1,5 +1,5 @@
1
- import { Database } from "bun:sqlite";
2
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
1
+ import type { Database } from "./db-schema.ts";
2
+ import { openHubDatabase } from "./db-schema.ts";
3
3
  import { corpusPathProblem } from "./corpus-config.ts";
4
4
 
5
5
  // agent-hub #512 (hub task #330): machine-local schedules. Spec: "Schedules and folder triggers (#512)".
@@ -15,7 +15,12 @@ export const SCHEDULE_MARKER = "Schedule key: ";
15
15
 
16
16
  export interface ScheduleEntry {
17
17
  id: string;
18
- every: string;
18
+ /** Exactly one of `every` and `cron` (#1017). */
19
+ every?: string;
20
+ /** The five cron fields, single-spaced (`parseCron` checked them). */
21
+ cron?: string;
22
+ /** An IANA zone for `cron`; without it the machine's own zone (`scheduleZone`). */
23
+ tz?: string;
19
24
  class: string;
20
25
  title: string;
21
26
  detail?: string;
@@ -26,7 +31,7 @@ export interface ScheduleEntry {
26
31
 
27
32
  /** The source id form of `src/hub/sources.ts` (`validSourceId`), repeated here because that module reaches the file system and the converters and this one is bundled into the Claude channel server through the config schema; `test/schedules.test.ts` pins that the two agree. */
28
33
  export const SOURCE_ID = /^src:[0-9a-f]{16}$/;
29
- const ID = /^[a-z0-9][a-z0-9_-]{0,47}$/, PEER = /^[a-z][a-z0-9-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, KEYS = new Set(["id", "every", "cron", "class", "title", "detail", "sources", "owner", "queue"]);
34
+ const ID = /^[a-z0-9][a-z0-9_-]{0,47}$/, PEER = /^[a-z][a-z0-9-]{0,31}$/, CLASS = /^[a-z][a-z0-9_]{0,47}$/, KEYS = new Set(["id", "every", "cron", "tz", "class", "title", "detail", "sources", "owner", "queue"]);
30
35
  const control = /[\x00-\x1f\x7f]/;
31
36
 
32
37
  /** `<n>m|h|d` to milliseconds; a thrown reason otherwise. The 15-minute floor keeps a typo from flooding the board. */
@@ -39,6 +44,185 @@ export function parseEvery(value: unknown): number {
39
44
  return ms;
40
45
  }
41
46
 
47
+ // agent-hub #1017: calendar-anchored schedules. Spec: "Calendar-anchored schedules (#1017)". Pure: a cron expression and a zone in, instants out, on `Intl` and `Date` only (zero runtime
48
+ // dependencies). Every function takes the time it works from, so a test pins a fixed clock.
49
+
50
+ /** The five fields, each as the sorted values it allows. `domStar` and `dowStar`: the field began with `*`, which the day rule below reads. */
51
+ export interface CronSpec { minutes: number[]; hours: number[]; doms: number[]; months: number[]; dows: number[]; domStar: boolean; dowStar: boolean }
52
+ const CRON_FIELDS = [{ name: "minute", min: 0, max: 59 }, { name: "hour", min: 0, max: 23 }, { name: "day of month", min: 1, max: 31 }, { name: "month", min: 1, max: 12 }, { name: "day of week", min: 0, max: 7 }] as const;
53
+ export const MAX_CRON_LENGTH = 100;
54
+ /**
55
+ * One Gregorian cycle: the calendar, and so every day-of-month, month and weekday pattern, repeats exactly every 146097 days (400 years, a whole number of weeks). Scanning that far makes
56
+ * both questions complete: an expression that never fires has no matching day in it, and one that fires at all fires inside it from any start (a Feb 29 that is a Sunday recurs
57
+ * 28 to 40 years apart). The cost is only paid by a sparse expression: a common one stops at its first match.
58
+ */
59
+ const CRON_HORIZON_DAYS = 146_097;
60
+ const DAY_MS = 86_400_000, MIN_MS = 60_000;
61
+
62
+ function cronField(text: string, name: string, min: number, max: number): number[] {
63
+ const values = new Set<number>();
64
+ for (const part of text.split(",")) {
65
+ const match = /^(?:(\*)|(\d{1,2})(?:-(\d{1,2}))?)(?:\/(\d{1,2}))?$/.exec(part);
66
+ if (!match || (match[2] !== undefined && match[3] === undefined && match[4] !== undefined)) throw new Error(`cron ${name} must be numbers, ranges (a-b), lists (a,b) and steps (*/n or a-b/n)`);
67
+ const from = match[1] ? min : Number(match[2]), to = match[1] ? max : match[3] !== undefined ? Number(match[3]) : from, step = match[4] === undefined ? 1 : Number(match[4]);
68
+ if (from < min || to > max || from > to) throw new Error(`cron ${name} is ${min} to ${max}, and a range runs upward`);
69
+ if (step < 1) throw new Error(`cron ${name} step must be at least 1`);
70
+ for (let value = from; value <= to; value += step) values.add(name === "day of week" && value === 7 ? 0 : value);
71
+ }
72
+ return [...values].sort((a, b) => a - b);
73
+ }
74
+
75
+ /**
76
+ * The standard five fields (minute, hour, day of month, month, day of week): numbers, lists, ranges and steps; day of week 0 and 7 are Sunday. No seconds, no year, no `@` macro, no names.
77
+ * A day matches when both day fields are restricted and EITHER matches, or when one of them begins with `*` and BOTH match (the classic cron rule); month always has to match.
78
+ */
79
+ export function parseCron(expression: unknown): CronSpec {
80
+ if (typeof expression !== "string" || !expression.trim()) throw new Error("cron is five fields: minute hour day-of-month month day-of-week");
81
+ if (expression.length > MAX_CRON_LENGTH || /[^\x20-\x7e]/.test(expression)) throw new Error(`cron is at most ${MAX_CRON_LENGTH} printable characters`);
82
+ if (expression.trim().startsWith("@")) throw new Error("cron takes the five fields, not an @ macro");
83
+ const fields = expression.trim().split(/\s+/);
84
+ if (fields.length !== 5) throw new Error(`cron is exactly five fields (minute hour day-of-month month day-of-week), not ${fields.length}; seconds and a year are not supported`);
85
+ const [minutes, hours, doms, months, dows] = CRON_FIELDS.map((field, index) => cronField(fields[index]!, field.name, field.min, field.max)) as [number[], number[], number[], number[], number[]];
86
+ return { minutes, hours, doms, months, dows, domStar: fields[2]!.startsWith("*"), dowStar: fields[4]!.startsWith("*") };
87
+ }
88
+ /** The expression as stored: the same five fields, single-spaced. */
89
+ export const cronText = (expression: string): string => expression.trim().split(/\s+/).join(" ");
90
+
91
+ function dayMatches(spec: CronSpec, dayMs: number): boolean {
92
+ const date = new Date(dayMs);
93
+ if (!spec.months.includes(date.getUTCMonth() + 1)) return false;
94
+ const dom = spec.doms.includes(date.getUTCDate()), dow = spec.dows.includes(date.getUTCDay());
95
+ return spec.domStar || spec.dowStar ? dom && dow : dom || dow;
96
+ }
97
+
98
+ /**
99
+ * The smallest gap, in minutes, between two fires of the expression on the calendar itself (a wall clock with no zone), or undefined when no day of a full 400-year Gregorian cycle matches (`0 0 31 2 *`).
100
+ * Daylight saving can still move two fires closer on its one change day (a skipped time runs at the first valid minute); a schedule proposes at most one task per pass and none while its
101
+ * previous task is open, so that costs no flood.
102
+ */
103
+ export function cronMinGapMinutes(spec: CronSpec): number | undefined {
104
+ const times = spec.hours.flatMap(hour => spec.minutes.map(minute => hour * 60 + minute));
105
+ let gap = Infinity;
106
+ for (let i = 1; i < times.length; i++) gap = Math.min(gap, times[i]! - times[i - 1]!);
107
+ const start = Date.UTC(2024, 0, 1);
108
+ let any = false, consecutive = false, before = false;
109
+ for (let day = 0; day < CRON_HORIZON_DAYS; day++) {
110
+ const now = dayMatches(spec, start + day * DAY_MS);
111
+ any ||= now;
112
+ if (now && before) consecutive = true;
113
+ before = now;
114
+ }
115
+ if (!any) return undefined;
116
+ return Math.min(gap, consecutive ? 1440 - times[times.length - 1]! + times[0]! : Infinity);
117
+ }
118
+
119
+ const zoneFormats = new Map<string, Intl.DateTimeFormat>();
120
+ function zoneFormat(zone: string): Intl.DateTimeFormat {
121
+ let format = zoneFormats.get(zone);
122
+ if (!format) {
123
+ format = new Intl.DateTimeFormat("en-US", { timeZone: zone, hourCycle: "h23", year: "numeric", month: "numeric", day: "numeric", hour: "numeric", minute: "numeric", second: "numeric" });
124
+ if (zoneFormats.size >= 64) zoneFormats.clear();
125
+ zoneFormats.set(zone, format);
126
+ }
127
+ return format;
128
+ }
129
+ /** An IANA zone name the runtime knows, as it spells it (`utc` is `UTC`), or a thrown reason. */
130
+ export function checkZone(value: unknown): string {
131
+ if (typeof value !== "string" || value.length > 64 || !/^[A-Za-z][A-Za-z0-9_+-]*(?:\/[A-Za-z0-9_+-]+)*$/.test(value)) throw new Error("tz is an IANA zone name such as Asia/Seoul or America/New_York");
132
+ try { return zoneFormat(value).resolvedOptions().timeZone; } catch { throw new Error("tz is not a zone this machine knows; use an IANA name such as Asia/Seoul or America/New_York"); }
133
+ }
134
+ /** The zone a schedule's `cron` is read in: its `tz`, else this machine's own. */
135
+ export function scheduleZone(entry: Pick<ScheduleEntry, "tz">): string {
136
+ if (entry.tz) return entry.tz;
137
+ try { return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; }
138
+ }
139
+
140
+ /** The wall clock of an instant in a zone, as milliseconds on a zone-less calendar (the calendar read as UTC). */
141
+ function wallOf(zone: string, instant: number): number {
142
+ const parts: Record<string, number> = {};
143
+ for (const part of zoneFormat(zone).formatToParts(instant)) if (part.type !== "literal") parts[part.type] = Number(part.value);
144
+ return Date.UTC(parts.year!, parts.month! - 1, parts.day!, parts.hour! % 24, parts.minute!, parts.second!);
145
+ }
146
+ /**
147
+ * The instant a wall-clock minute happens in a zone. A time that happens twice (the clock went back) is its first occurrence, so a repeated time runs once; a time that never happens
148
+ * (the clock jumped forward) is the first minute that does, so a skipped time still runs.
149
+ */
150
+ function instantOf(zone: string, wall: number): number {
151
+ const offset = (instant: number) => wallOf(zone, instant) - Math.floor(instant / 1000) * 1000;
152
+ const tried = new Set<number>(), found: number[] = [];
153
+ for (const guess of [offset(wall - DAY_MS), offset(wall + DAY_MS), offset(wall)]) {
154
+ if (tried.has(guess)) continue;
155
+ tried.add(guess);
156
+ if (wallOf(zone, wall - guess) === wall) found.push(wall - guess);
157
+ }
158
+ if (found.length) return Math.min(...found);
159
+ // A gap: the first minute whose wall clock has reached the skipped time. No zone's offset moves by 30 hours, so this window holds the one change.
160
+ let low = Math.floor((wall - 30 * 3_600_000) / MIN_MS), high = Math.ceil((wall + 30 * 3_600_000) / MIN_MS);
161
+ while (high - low > 1) {
162
+ const middle = Math.floor((low + high) / 2);
163
+ if (wallOf(zone, middle * MIN_MS) >= wall) high = middle; else low = middle;
164
+ }
165
+ return high * MIN_MS;
166
+ }
167
+
168
+ /** The first wall-clock minute strictly after `wall` that the expression allows, or undefined when no day of a full cycle matches. */
169
+ function nextWall(spec: CronSpec, wall: number): number | undefined {
170
+ const from = wall - (wall % MIN_MS) + MIN_MS, firstDay = from - (from % DAY_MS), floor = (from - firstDay) / MIN_MS;
171
+ for (let day = 0; day < CRON_HORIZON_DAYS; day++) {
172
+ const dayMs = firstDay + day * DAY_MS;
173
+ if (!dayMatches(spec, dayMs)) continue;
174
+ for (const hour of spec.hours) for (const minute of spec.minutes) if (hour * 60 + minute >= (day === 0 ? floor : 0)) return dayMs + (hour * 60 + minute) * MIN_MS;
175
+ }
176
+ return undefined;
177
+ }
178
+
179
+ /**
180
+ * The next fire strictly after `after` (epoch ms), or undefined when none exists (no day of a full 400-year cycle matches). Fires are found on the zone's wall clock and then placed on the timeline by
181
+ * `instantOf`: a repeated wall time fires once, and a fire the clock skipped fires at the first valid minute.
182
+ */
183
+ export function cronNext(spec: CronSpec, zone: string, after: number): number | undefined {
184
+ let wall = wallOf(zone, after);
185
+ wall -= wall % MIN_MS;
186
+ for (let guard = 0; guard < 2000; guard++) {
187
+ const next = nextWall(spec, wall);
188
+ if (next === undefined) return undefined;
189
+ const instant = instantOf(zone, next);
190
+ if (instant > after) return instant;
191
+ wall = next;
192
+ }
193
+ return undefined;
194
+ }
195
+
196
+ /** `YYYY-MM-DD HH:mm` in a zone: how a person reads the next run of a calendar schedule. */
197
+ export function formatInZone(instant: number, zone: string): string {
198
+ const iso = new Date(wallOf(zone, instant)).toISOString();
199
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)}`;
200
+ }
201
+
202
+ /** How a view's period reads: `every 6h`, or `cron 30 7 * * 1-5 (Asia/Seoul)`. */
203
+ export const schedulePeriodText = (view: { every?: string; cron?: string; zone?: string }): string => view.cron !== undefined ? `cron ${view.cron}${view.zone ? ` (${view.zone})` : ""}` : `every ${view.every ?? "?"}`;
204
+ /** An instant as a person reads a schedule's next run: in the zone of a `cron` (`2026-11-02 07:30 Asia/Seoul`), else UTC like every other time in the CLI. */
205
+ export function scheduleWhen(view: { zone?: string }, instant: number): string {
206
+ if (view.zone) { try { return `${formatInZone(instant, view.zone)} ${view.zone}`; } catch { /* an unknown zone falls through to UTC */ } }
207
+ return `${new Date(instant).toISOString().replace("T", " ").slice(0, 16)} UTC`;
208
+ }
209
+
210
+ const parsedCrons = new Map<string, CronSpec>();
211
+ function cronSpec(expression: string): CronSpec {
212
+ let spec = parsedCrons.get(expression);
213
+ if (!spec) {
214
+ spec = parseCron(expression);
215
+ if (parsedCrons.size >= 256) parsedCrons.clear();
216
+ parsedCrons.set(expression, spec);
217
+ }
218
+ return spec;
219
+ }
220
+ /** The next time a schedule is due, given the start of its period: `every` after the anchor, or the next calendar fire of its `cron` after it. Far in the future when a cron has none. */
221
+ export function scheduleNext(entry: Pick<ScheduleEntry, "every" | "cron" | "tz">, anchor: number): number {
222
+ if (entry.cron !== undefined) return cronNext(cronSpec(entry.cron), scheduleZone(entry), anchor) ?? Number.MAX_SAFE_INTEGER;
223
+ return anchor + parseEvery(entry.every);
224
+ }
225
+
42
226
  /** The checked `schedules` list. Every refusal names the entry; nothing here reads the board, a clock or a file. */
43
227
  export function schedulesConfig(input: unknown): ScheduleEntry[] {
44
228
  if (input === undefined) return [];
@@ -54,8 +238,21 @@ export function schedulesConfig(input: unknown): ScheduleEntry[] {
54
238
  if (typeof row.id !== "string" || !ID.test(row.id)) bad("id is 1 to 48 lower-case letters, digits, - or _");
55
239
  if (seen.has(row.id as string)) bad("duplicate id");
56
240
  seen.add(row.id as string);
57
- if (row.cron !== undefined) bad("cron not supported yet; use every");
58
- try { parseEvery(row.every); } catch (error) { bad((error as Error).message); }
241
+ if (row.every !== undefined && row.cron !== undefined) bad("give every or cron, not both");
242
+ if (row.every === undefined && row.cron === undefined) bad("every must be <n>m, <n>h or <n>d, or give cron (five fields)");
243
+ if (row.tz !== undefined && row.cron === undefined) bad("tz applies to cron only");
244
+ let cron: string | undefined, tz: string | undefined;
245
+ if (row.cron !== undefined) {
246
+ try {
247
+ const spec = parseCron(row.cron), gap = cronMinGapMinutes(spec);
248
+ if (gap === undefined) bad("cron never fires: no calendar day matches it");
249
+ if (gap! < MIN_EVERY_MS / MIN_MS) bad(`cron can fire every ${gap} minute${gap === 1 ? "" : "s"}; the minimum is ${MIN_EVERY_MS / MIN_MS} minutes`);
250
+ cron = cronText(row.cron as string);
251
+ if (row.tz !== undefined) tz = checkZone(row.tz);
252
+ } catch (error) { bad((error as Error).message.replace(/^schedules[.[][^:]*: /, "")); }
253
+ } else {
254
+ try { parseEvery(row.every); } catch (error) { bad((error as Error).message); }
255
+ }
59
256
  if (typeof row.class !== "string" || !CLASS.test(row.class)) bad("class is required");
60
257
  if (typeof row.title !== "string" || !row.title.trim() || row.title.length > 300 || control.test(row.title)) bad("title is 1 to 300 printable characters on one line");
61
258
  if (row.detail !== undefined && (typeof row.detail !== "string" || row.detail.length > MAX_SCHEDULE_DETAIL || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(row.detail))) bad(`detail is text of at most ${MAX_SCHEDULE_DETAIL} characters`);
@@ -65,7 +262,7 @@ export function schedulesConfig(input: unknown): ScheduleEntry[] {
65
262
  if (!Array.isArray(row.sources) || row.sources.length > MAX_SCHEDULE_SOURCES || !row.sources.every(id => typeof id === "string" && SOURCE_ID.test(id))) bad(`sources is a list of at most ${MAX_SCHEDULE_SOURCES} source ids (src:<16 hex>)`);
66
263
  }
67
264
  out.push({
68
- id: row.id as string, every: row.every as string, class: row.class as string, title: (row.title as string).replace(/\s+/g, " ").trim(),
265
+ id: row.id as string, ...(cron !== undefined ? { cron, ...(tz !== undefined ? { tz } : {}) } : { every: row.every as string }), class: row.class as string, title: (row.title as string).replace(/\s+/g, " ").trim(),
69
266
  ...(row.detail !== undefined ? { detail: row.detail as string } : {}), ...(row.sources !== undefined ? { sources: [...(row.sources as string[])] } : {}),
70
267
  ...(row.owner !== undefined ? { owner: row.owner as string } : {}), queue: (row.queue as "backlog" | "ready" | undefined) ?? "backlog",
71
268
  });
@@ -180,8 +377,7 @@ export interface ScheduleRow {
180
377
  export class ScheduleStore {
181
378
  private readonly db: Database;
182
379
  constructor(dbPath: string) {
183
- this.db = new Database(dbPath, { create: true });
184
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
380
+ this.db = openHubDatabase(dbPath, { create: true });
185
381
  this.db.run("CREATE TABLE IF NOT EXISTS schedule_runs (id TEXT PRIMARY KEY, anchor INTEGER NOT NULL, last_run INTEGER, last_task INTEGER, coalesced INTEGER NOT NULL DEFAULT 0, last_error TEXT)");
186
382
  // #335: a person's pause. A column on the existing table, so an older database gains it and every older row reads as not paused.
187
383
  if (!(this.db.query("PRAGMA table_info(schedule_runs)").all() as { name: string }[]).some(column => column.name === "paused")) this.db.run("ALTER TABLE schedule_runs ADD COLUMN paused INTEGER NOT NULL DEFAULT 0");
@@ -229,7 +425,10 @@ export interface ScheduleRunResult { id: string; outcome: ScheduleOutcome; task?
229
425
  export type ScheduleState = "scheduled" | "due" | "waiting" | "held" | "paused";
230
426
  export interface ScheduleView {
231
427
  id: string;
232
- every: string;
428
+ /** `every` or `cron`, never both; `zone` is the zone a `cron` is read in (its `tz`, else the machine's), so a person reads the next run with it (#1017). */
429
+ every?: string;
430
+ cron?: string;
431
+ zone?: string;
233
432
  class: string;
234
433
  queue: "backlog" | "ready";
235
434
  next: number;
@@ -247,10 +446,10 @@ export interface ScheduleView {
247
446
  export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly ScheduleRow[], now: number, open: ReadonlyMap<string, number>, held: boolean): ScheduleView[] {
248
447
  const byId = new Map(rows.map(row => [row.id, row]));
249
448
  return entries.map(entry => {
250
- const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = anchor + parseEvery(entry.every), openTask = open.get(entry.id);
449
+ const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = scheduleNext(entry, anchor), openTask = open.get(entry.id);
251
450
  const state: ScheduleState = row?.paused ? "paused" : now < next ? "scheduled" : held ? "held" : openTask !== undefined ? "waiting" : "due";
252
451
  return {
253
- id: entry.id, every: entry.every, class: entry.class, queue: entry.queue, next, state,
452
+ id: entry.id, ...(entry.cron !== undefined ? { cron: entry.cron, zone: scheduleZone(entry) } : { every: entry.every }), class: entry.class, queue: entry.queue, next, state,
254
453
  ...(row?.last_run != null ? { last: row.last_run } : {}), ...(openTask !== undefined ? { task: openTask } : row?.last_task != null ? { task: row.last_task } : {}),
255
454
  ...(row?.coalesced ? { coalesced: row.coalesced } : {}), ...(row?.last_error ? { error: row.last_error } : {}),
256
455
  };
@@ -259,7 +458,8 @@ export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly
259
458
 
260
459
  /** The detail of a scheduled task: the marker line first, then the person's text and the sources it names, all fixed words around config text. */
261
460
  export function scheduledDetail(entry: ScheduleEntry): string {
262
- return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled every ${entry.every} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
461
+ const when = entry.cron !== undefined ? `on the calendar "${entry.cron}" (${scheduleZone(entry)})` : `every ${entry.every}`;
462
+ return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled ${when} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
263
463
  ...(entry.sources?.length ? ["", ...entry.sources.map(id => `Source: ${id}`)] : [])].join("\n");
264
464
  }
265
465
 
@@ -144,6 +144,12 @@ export function localGates(input: { subject: DeliveryTask; state: string; taskSt
144
144
  const { subject, config } = input, gates: string[] = [];
145
145
  if (subject.private) return ["private task: a person merges"];
146
146
  if (input.state !== "approved") gates.push(`task not approved (state ${input.state})`);
147
+ const approved = subject.receipt?.reviewedHead;
148
+ if (subject.latestHead !== undefined && subject.latestHead !== approved) gates.push("latest reported head differs from approved head");
149
+ const binding = subject.approval;
150
+ if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
151
+ || subject.latestHead !== undefined && binding.head !== subject.latestHead
152
+ || binding.latestHead !== undefined && binding.head !== binding.latestHead)) gates.push("approval is not current at the latest reported head");
147
153
  for (const id of subject.after) {
148
154
  const state = input.taskState(id);
149
155
  if (state !== "approved") gates.push(`dependency task #${id} is not approved (${state ?? "unknown"})`);
@@ -206,6 +212,7 @@ export function dispatchGates(input: Pick<MergeInput, "subject" | "taskState"> &
206
212
  if (!now) return moved;
207
213
  const receipt = now.subject.receipt;
208
214
  if (receipt?.repo !== decision.repo || receipt?.pr !== decision.pr || receipt?.reviewedHead !== decision.head) return moved;
215
+ if (now.subject.latestHead !== input.subject.latestHead || JSON.stringify(now.subject.approval) !== JSON.stringify(input.subject.approval)) return moved;
209
216
  if (now.mode !== input.mode) return ["merge mode changed during the merge check: nothing was run"];
210
217
  if (JSON.stringify(requiredChecksFor(now.config, decision.repo)) !== JSON.stringify(requiredChecksFor(input.config, decision.repo))) return ["required checks changed during the merge check: nothing was run"];
211
218
  if (!now.allowed) return ["authority or hub availability changed during the merge check: nothing was run"];
@@ -4,7 +4,14 @@ import { evalGapBlockers, evalGateView, readEvalFor, type EvalGate } from "./eva
4
4
  import { findingsForHead, findingHead, findingTally, type ReviewFindingSet, type ReviewFindingTally } from "./review-findings.ts";
5
5
  import type { CriteriaView } from "./workflow.ts";
6
6
  import { parsePrEvidence, readSdlcPr, sdlcGh, sdlcStatus, type PrEvidence, type SdlcConfig, type SdlcGh, type SdlcReceipt } from "./sdlc.ts";
7
- export interface DeliveryTask { plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] }; findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView; }
7
+ export interface DeliveryTask {
8
+ /** Latest owner-reported artifact head; omitted by older library callers. */
9
+ latestHead?: string;
10
+ /** Host-derived verdict binding, never inferred from a mutable lifecycle receipt. */
11
+ approval?: { head?: string; latestHead?: string; mismatch: boolean; current: boolean };
12
+ plan?: { paths?: string[]; revision: number; fingerprint: string; accepted: { repo: string; pr: number; head: string; revision: number; fingerprint: string; paths: string[] }[] };
13
+ findings?: readonly ReviewFindingSet[]; id: number; after: number[]; private: boolean; receipt?: SdlcReceipt; phase?: CriteriaView;
14
+ }
8
15
  export interface MergeQueueRow { evalGate?: EvalGate; testIntegrity?: (TestIntegrity & { task?: number })[]; planConformance?: { task?: number; state: string; outside: number; untouched: number; accepted: boolean }[]; findings?: ReviewFindingTally; pr: number; tasks: number[]; head?: string; private?: true; ready: boolean; blockers: string[]; after: number[]; }
9
16
  export interface MergeConflict { left: number; right: number; files: number; order: "dependency" | "person_required"; }
10
17
  export interface MergeQueue { repo: string; rows: MergeQueueRow[]; order: number[]; unordered?: number[]; conflicts: MergeConflict[]; complete: boolean; truncated: boolean; }
@@ -94,6 +101,11 @@ export async function readMergeQueue(repo: string, config: SdlcConfig, tasks: De
94
101
  const integrity = rawIntegrity ? publicTestIntegrity(rawIntegrity, readableMetadata) : undefined;
95
102
  if (integrity) (row.testIntegrity ??= []).push({ task: holder!.id, ...integrity });
96
103
  row.blockers.push(...sdlcStatus(config, holder?.receipt, read, holder?.phase).blockers);
104
+ const approved = holder?.receipt?.reviewedHead, binding = holder?.approval;
105
+ if (holder?.latestHead !== undefined && holder.latestHead !== approved) row.blockers.push("latest reported head differs from approved head");
106
+ if (binding && (!binding.current || binding.mismatch || !binding.head || binding.head !== approved
107
+ || holder?.latestHead !== undefined && binding.head !== holder.latestHead
108
+ || binding.latestHead !== undefined && binding.head !== binding.latestHead)) row.blockers.push("approval is not current at the latest reported head");
97
109
  // #481 S5: the eval check itself is a required check inside sdlcStatus; its recorded pass rate and suite are read once per PR here.
98
110
  if (!row.evalGate) {
99
111
  const gate = evalGateView(config, read, await readEvalFor(config, read, repo, bounded));
@@ -0,0 +1,49 @@
1
+ // Distinguishable sessions, not authentication (agent-hub#1036, board #606). A connection that names a peer id but is not that id's current session is bound to an id the DAEMON mints,
2
+ // `<peer>~<origin>` (for example `claude~agent-console`), instead of becoming that peer. The id has no `@`, so it never reaches the remote-room paths (`isRemotePeerId`,
3
+ // `membershipAllowed`, `remoteEnvelope`), and `PEER_ID` is not widened: only the daemon makes these ids, and it keeps them in its own Set.
4
+ //
5
+ // What such a session may do: send, and read through the tools below. It is never a WsPeer, never receives a delivery or a push, and holds none of the peer's board roles.
6
+ //
7
+ // Limit, stated plainly: this makes sessions distinguishable and traceable. It does not authenticate them. A same-user process can always act as the user; the point is that a mix-up
8
+ // between two sessions of one person is visible in the header a recipient reads and in events.jsonl.
9
+
10
+ /** The task ops a distinct session may run: reads that need no role. Everything else is refused with `sessionRefusal`. */
11
+ export const SESSION_READ_OPS: ReadonlySet<string> = new Set([
12
+ "hub_task_show", "hub_task_list", "hub_status", "hub_capabilities", "hub_study_show", "hub_backlog_list", "hub_release_list",
13
+ ]);
14
+
15
+ /** The control messages a distinct session may send besides task ops. `task` is gated again by `SESSION_READ_OPS`. */
16
+ export const SESSION_MESSAGES: ReadonlySet<string> = new Set(["send", "status", "task"]);
17
+
18
+ const LABEL = /^[a-z][a-z0-9-]{0,23}$/;
19
+
20
+ /**
21
+ * A label is the other project's name or a short session id, and it ends up in a header another agent reads. It is kept only when it already IS a plain lowercase token: nothing is cleaned up into
22
+ * one, so a path, a sentence or an instruction cannot leave a fragment of itself in a sender name. Anything else is dropped, and the hub mints a random short id instead.
23
+ */
24
+ export function sessionLabel(value: unknown): string | undefined {
25
+ return typeof value === "string" && LABEL.test(value) && !value.endsWith("-") ? value : undefined;
26
+ }
27
+
28
+ /** `taken(id)` says whether a live session already has the id. A second session from the same origin gets a short suffix, so no two live sessions share an id. */
29
+ export function mintSessionId(peer: string, label: unknown, taken: (id: string) => boolean, random: () => string): string {
30
+ const base = `${peer.slice(0, 32)}~${sessionLabel(label) ?? random()}`;
31
+ let id = base;
32
+ for (let n = 0; taken(id) && n < 8; n++) id = `${base}-${random().slice(0, 4)}`;
33
+ return id;
34
+ }
35
+
36
+ export const isSessionId = (id: unknown): id is string => typeof id === "string" && id.includes("~");
37
+
38
+ /** The one refusal a distinct session meets, so a person reading it learns which session it is and which one holds the role. */
39
+ export function sessionRefusal(id: string): string {
40
+ const peer = id.slice(0, id.indexOf("~"));
41
+ return `this session is ${id}, not this project's ${peer}: it can send and read, and holds none of ${peer}'s board roles`;
42
+ }
43
+
44
+ /** The gate the daemon applies to every message of a distinct session. Returns the refusal text, or undefined when the message may proceed. */
45
+ export function sessionGate(id: string, msg: { t?: unknown; op?: unknown }): string | undefined {
46
+ if (typeof msg.t !== "string" || !SESSION_MESSAGES.has(msg.t)) return sessionRefusal(id);
47
+ if (msg.t === "task" && !(typeof msg.op === "string" && SESSION_READ_OPS.has(msg.op))) return sessionRefusal(id);
48
+ return undefined;
49
+ }
@@ -12,6 +12,7 @@ import { CLASSES, type TaskClass } from "./board.ts";
12
12
  import { configTracked, configRepositoryPresent } from "./config-trust.ts";
13
13
  import { PERMISSION_MODES, permissionBoundary } from "./permission-mode.ts";
14
14
  import { START_MODES } from "./start-mode.ts";
15
+ import { checkStartAgents } from "./start-agents.ts";
15
16
  import { OVERLAY_FILE, overlayToml, parseOverlay, parseRouting, routingText, type Routing, type RoutingOverlay } from "./routing.ts";
16
17
 
17
18
  /**
@@ -74,6 +75,7 @@ export const SETTINGS: readonly SettingDef[] = [
74
75
  ...ACTION_CLASSES.map((kind): SettingDef => ({ key: `action_policy.budgets.${kind}`, group: "Permissions", label: `${kind}: approved session budget`, type: "integer", min: 0, max: 1000, store: "config", applies: "live", risk: "raises", floor: 0, path: ["action_policy", "budgets", kind] })),
75
76
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission.${peer}`, group: "Permissions", label: `${peer}: mode now`, type: "enum", values: PERMISSION_MODES, store: "runtime", applies: "live", risk: "raises", floor: "ask", path: [peer] })),
76
77
  ...SETTING_PEERS.filter(peer => !definitionOf(peer)?.permission.unmanaged).map((peer): SettingDef => ({ key: `permission_modes.${peer}`, group: "Permissions", label: `${peer}: mode at next peer start`, type: "enum", values: PERMISSION_MODES, store: "config", applies: "next use", risk: "raises", floor: "ask", path: ["permission_modes", peer] })),
78
+ { key: "start.agents", group: "Start", label: "Agents ahub up starts (none for an empty list)", type: "peers", store: "config", applies: "next use", risk: "raises", path: ["start", "agents"] },
77
79
  ...START_MODE_PEERS.map((peer): SettingDef => ({ key: `peers.${peer}.start_mode`, group: "Start", label: `${peer}: start mode (tui unless set)`, type: "enum", values: START_MODES, store: "config", applies: "next use", risk: "raises", floor: "tui", path: ["peers", peer, "start_mode"] })),
78
80
  ...ADAPTERS.flatMap(adapter => adapter.configuration?.settings ?? []),
79
81
  { key: "routing.stay_switch", group: "Routing", label: "Stay or switch (hub/auto and stage routes)", type: "enum", values: ["off", "shadow", "enforce"], store: "routing", applies: "live", risk: "raises", path: ["stay_switch"] },
@@ -203,6 +205,7 @@ export function checkSettingValue(def: SettingDef, raw: unknown, routes: readonl
203
205
  }
204
206
  return peers.length === 1 ? peers[0] as string : [...peers] as string[];
205
207
  }
208
+ if (def.key === "start.agents") return checkStartAgents(raw, def.key);
206
209
  if (!Array.isArray(raw) || raw.length > PEERS_MAX || raw.some((p) => typeof p !== "string" || !PEER.test(p)) || new Set(raw).size !== raw.length) throw new Error(`${def.key} takes up to ${PEERS_MAX} distinct peer ids`);
207
210
  return [...(raw as string[])];
208
211
  }
@@ -217,6 +220,7 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
217
220
  if (def.type === "number") return text.trim() && Number.isFinite(Number(text)) ? Number(text) : text;
218
221
  if (def.type === "integer") return /^(0|[1-9]\d*)$/.test(text) ? Number(text) : text;
219
222
  if (def.type === "boolean") return text === "true" ? true : text === "false" ? false : text;
223
+ if (def.key === "start.agents" && text.trim() === "none") return [];
220
224
  if (def.type === "peers" || def.type === "roles") return text.split(",").map((p) => p.trim()).filter(Boolean);
221
225
  return text;
222
226
  }
@@ -1,4 +1,5 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "./db-schema.ts";
2
+ import { openHubDatabase } from "./db-schema.ts";
2
3
  import { closeSync, constants, copyFileSync, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync, type Stats } from "node:fs";
3
4
  import { createHash } from "node:crypto";
4
5
  import { tmpdir } from "node:os";
@@ -7,7 +8,6 @@ import { childEnv } from "./child-process.ts";
7
8
  import type { Touch } from "./conflicts.ts";
8
9
  import { hubGitSync } from "./git.ts";
9
10
  import { realPath } from "./project.ts";
10
- import { HUB_DB_BUSY_TIMEOUT_MS } from "./db-timeout.ts";
11
11
 
12
12
  /**
13
13
  * Per-turn workspace snapshots (issue #33): git tree objects written through a copy of the index, so the user's index
@@ -657,8 +657,7 @@ export function submodulePaths(repo: Repo): string[] {
657
657
  export class Turns {
658
658
  private readonly db: Database;
659
659
  constructor(path: string, readonly?: boolean) {
660
- this.db = new Database(path, readonly ? { readonly: true } : { create: true });
661
- this.db.run(`PRAGMA busy_timeout = ${HUB_DB_BUSY_TIMEOUT_MS}`);
660
+ this.db = openHubDatabase(path, readonly ? { readonly: true } : { create: true });
662
661
  if (readonly) return;
663
662
  this.db.run("PRAGMA journal_mode = WAL");
664
663
  this.db.run(`CREATE TABLE IF NOT EXISTS turns (id TEXT PRIMARY KEY, peer TEXT NOT NULL, started INTEGER NOT NULL, ended INTEGER,
@@ -0,0 +1,44 @@
1
+ import { SUPPORTED_PEERS, definitionOf } from "../adapters/registry.ts";
2
+
3
+ /**
4
+ * #608 (agent-hub#1040): the agents `ahub up` asks the hub to start, `start.agents`. Pure and free of file-system
5
+ * imports: the config schema (`config-apply.ts`) reaches the Claude channel bundle. The list is read by the CLI at a
6
+ * person's `ahub up` and by the dashboard manager's start; the daemon never reads it, so a restart, a crash recovery,
7
+ * a reload or an upgrade relaunch starts nothing from it.
8
+ *
9
+ * Which kinds may be listed, which are in the default set and why a kind is refused all come from the kind's own
10
+ * `AdapterDefinition.start.upStart` (#451): nothing here names a peer.
11
+ */
12
+ const upStart = (id: string) => definitionOf(id)?.start.upStart;
13
+
14
+ /** The kinds in the default set, in the registry's order. */
15
+ export const START_AGENTS_DEFAULT: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => { const s = upStart(peer); return s?.allowed === true && s.default === true; }));
16
+ export const START_AGENTS_MAX = 8;
17
+ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
18
+
19
+ /** The kinds `ahub up` may start. A declared instance, a channel extra and an unknown id are never in it. */
20
+ export const START_AGENTS_ALLOWED: readonly string[] = Object.freeze(SUPPORTED_PEERS.filter(peer => definitionOf(peer)?.start.conductorStartable === true && upStart(peer)?.allowed === true));
21
+
22
+ /** Why `id` cannot be in the start set, or undefined. The reason never repeats the id unless it is a well-formed peer id. */
23
+ export function startAgentRefusal(id: unknown): string | undefined {
24
+ if (typeof id !== "string" || !PEER.test(id)) return "an entry is not a peer id (lowercase letters, digits and dashes)";
25
+ if (START_AGENTS_ALLOWED.includes(id)) return undefined;
26
+ const own = upStart(id);
27
+ if (own?.allowed === false) return `${id} cannot be started by ahub up: ${own.refusal}`;
28
+ if (SUPPORTED_PEERS.includes(id)) return `${id} cannot be started by ahub up: the hub does not start it; run ahub ${id} in a terminal`;
29
+ return `${id} cannot be started by ahub up: it is a declared instance, a channel extra or unknown, and only ${START_AGENTS_ALLOWED.join(", ")} are started by the hub`;
30
+ }
31
+
32
+ /** The checked list (a copy), or a thrown reason. An empty list is allowed and starts nothing. */
33
+ export function checkStartAgents(raw: unknown, key = "start.agents"): string[] {
34
+ if (!Array.isArray(raw) || raw.length > START_AGENTS_MAX) throw new Error(`${key} takes a list of up to ${START_AGENTS_MAX} peer ids (an empty list starts none)`);
35
+ for (const id of raw) { const why = startAgentRefusal(id); if (why) throw new Error(`${key}: ${why}`); }
36
+ if (new Set(raw).size !== raw.length) throw new Error(`${key} takes distinct peer ids`);
37
+ return [...raw] as string[];
38
+ }
39
+
40
+ /** A person's text for the list: commas or spaces between ids, `none` for an empty list. */
41
+ export function parseStartAgents(text: string): string[] {
42
+ const words = text.split(/[\s,]+/).filter(Boolean);
43
+ return checkStartAgents(words.length === 1 && words[0] === "none" ? [] : words);
44
+ }
@@ -0,0 +1,23 @@
1
+ import type { Task } from "./board.ts";
2
+
3
+ export interface ApprovalBinding { head?: string; latestHead?: string; mismatch: boolean; current: boolean }
4
+ const fullHead = (value: unknown): string | undefined => typeof value === "string" && /^[a-f0-9]{40}$/.test(value) ? value : undefined;
5
+
6
+ /** The last approval remains historical evidence after a new delivery or a withdrawal. Unknown legacy heads stay unknown. */
7
+ export function approvalBinding(task: Task): ApprovalBinding | undefined {
8
+ const verdict = task.history.findLast(entry => entry.event === "approved");
9
+ if (!verdict) return undefined;
10
+ const head = fullHead(verdict.reviewedHead) ?? fullHead(verdict.reviewFindings?.head)
11
+ ?? (task.state === "approved" ? fullHead(task.sdlc?.reviewedHead) : undefined);
12
+ const latestHead = fullHead(task.refs.commit ?? task.sdlc?.head ?? task.sdlc?.localHead);
13
+ const mismatch = !!head && !!latestHead && head !== latestHead;
14
+ return { ...(head ? { head } : {}), ...(latestHead ? { latestHead } : {}), mismatch,
15
+ current: task.state === "approved" && !!head && head === latestHead };
16
+ }
17
+
18
+ export function approvalText(binding: ApprovalBinding | undefined): string {
19
+ if (!binding) return "";
20
+ return `Approval bound to head ${binding.head ?? "unknown"}; latest reported head ${binding.latestHead ?? "unknown"}. ${binding.mismatch
21
+ ? "HEAD MISMATCH: earlier approval does not approve the latest reported head."
22
+ : binding.current ? "Approval is current at this head." : "Earlier approval is not current; review is required."}`;
23
+ }