@staix/agent-hub 0.23.1 → 0.25.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 (95) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +32 -5
  3. package/docs/agent-notes/adapters.md +5 -2
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +16 -2
  6. package/docs/agent-notes/tasks.md +9 -2
  7. package/docs/agent-notes/tests.md +3 -0
  8. package/docs/dogfood.md +16 -2
  9. package/docs/memory.md +1 -1
  10. package/docs/operations.md +198 -5
  11. package/docs/profiles.md +2 -0
  12. package/docs/quickstart.md +19 -4
  13. package/docs/security.md +225 -8
  14. package/docs/specs/2026-09-19-agent-hub-design.md +54 -1
  15. package/docs/specs/2026-09-19-multi-project-design.md +12 -1
  16. package/docs/specs/2026-11-01-research-campaigns-design.md +351 -0
  17. package/docs/verification/2026-10-31-0.24.0.md +63 -0
  18. package/docs/verification/2026-10-31-claude-implementer-2.1.316.md +65 -0
  19. package/docs/verification/2026-11-01-0.25.0.md +91 -0
  20. package/docs/verification/claude-shell-wrapper-native.py +1 -1
  21. package/docs/verified.json +31 -15
  22. package/package.json +4 -3
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +5120 -4710
  25. package/src/adapters/claude-channel.ts +29 -6
  26. package/src/adapters/compat.ts +3 -3
  27. package/src/adapters/definitions/native-cli-claude-setup.ts +4 -0
  28. package/src/adapters/definitions/native-cli-doctor.ts +30 -0
  29. package/src/adapters/jcode.ts +3 -0
  30. package/src/adapters/registry.ts +1 -1
  31. package/src/cli/acon-register.ts +29 -0
  32. package/src/cli/autonomy-homes.ts +33 -0
  33. package/src/cli/autonomy-offline.ts +75 -0
  34. package/src/cli/autonomy-projects.ts +103 -0
  35. package/src/cli/autonomy.ts +44 -0
  36. package/src/cli/claude-homes.ts +123 -0
  37. package/src/cli/claude-profile-hook.ts +130 -0
  38. package/src/cli/handoff-args.ts +27 -0
  39. package/src/cli/handoff-output.ts +25 -0
  40. package/src/cli/help.ts +8 -4
  41. package/src/cli/identity.ts +8 -1
  42. package/src/cli/main.ts +174 -33
  43. package/src/cli/output.ts +8 -5
  44. package/src/cli/schedule-output.ts +8 -4
  45. package/src/cli/setup-homes.ts +46 -0
  46. package/src/cli/setup.ts +1 -1
  47. package/src/cli/study-output.ts +71 -0
  48. package/src/cli/study.ts +58 -0
  49. package/src/cli/task-output.ts +1 -0
  50. package/src/hub/action-class.ts +203 -14
  51. package/src/hub/action-enforce.ts +8 -6
  52. package/src/hub/action-policy.ts +95 -9
  53. package/src/hub/autonomy-consent.ts +173 -0
  54. package/src/hub/autonomy-defaults.ts +58 -0
  55. package/src/hub/autonomy.ts +232 -0
  56. package/src/hub/board.ts +871 -2
  57. package/src/hub/channel-project.ts +61 -0
  58. package/src/hub/config-apply.ts +3 -2
  59. package/src/hub/confirmation-matrix.ts +202 -0
  60. package/src/hub/control-client.ts +10 -1
  61. package/src/hub/daemon.ts +546 -27
  62. package/src/hub/envelope.ts +2 -0
  63. package/src/hub/events.ts +4 -1
  64. package/src/hub/execution-budget.ts +12 -0
  65. package/src/hub/handoff.ts +126 -0
  66. package/src/hub/hub-tools.ts +12 -2
  67. package/src/hub/manager.ts +64 -57
  68. package/src/hub/no-writes.ts +1 -1
  69. package/src/hub/path-policy.ts +12 -8
  70. package/src/hub/project.ts +14 -5
  71. package/src/hub/provenance.ts +30 -0
  72. package/src/hub/research-campaign.ts +265 -0
  73. package/src/hub/research-experiments.ts +445 -0
  74. package/src/hub/restart.ts +26 -0
  75. package/src/hub/schedules.ts +210 -9
  76. package/src/hub/session-identity.ts +49 -0
  77. package/src/hub/settings.ts +105 -8
  78. package/src/hub/task-repository.ts +11 -6
  79. package/src/hub/tasks.ts +894 -22
  80. package/src/hub/ui.ts +15 -6
  81. package/src/memory/capture.ts +2 -2
  82. package/src/ui/index.html +5 -3
  83. package/src/ui/office-space.ts +118 -0
  84. package/src/ui/plugin.ts +293 -0
  85. package/templates/AGENTS.block.md +1 -0
  86. package/templates/presets/autonomous.json +161 -0
  87. package/templates/presets/confirmation-matrix.json +943 -0
  88. package/templates/profiles/general.json +1 -1
  89. package/templates/profiles/research.json +4 -4
  90. package/templates/profiles/writing.json +1 -1
  91. package/templates/roles/research/analyst.md +1 -0
  92. package/templates/roles/research/fact-checker.md +1 -0
  93. package/templates/roles/research/research-lead.md +1 -0
  94. package/templates/roles/research/researcher.md +1 -0
  95. package/templates/roles/research/statistician.md +1 -0
@@ -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
  });
@@ -229,7 +426,10 @@ export interface ScheduleRunResult { id: string; outcome: ScheduleOutcome; task?
229
426
  export type ScheduleState = "scheduled" | "due" | "waiting" | "held" | "paused";
230
427
  export interface ScheduleView {
231
428
  id: string;
232
- every: string;
429
+ /** `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). */
430
+ every?: string;
431
+ cron?: string;
432
+ zone?: string;
233
433
  class: string;
234
434
  queue: "backlog" | "ready";
235
435
  next: number;
@@ -247,10 +447,10 @@ export interface ScheduleView {
247
447
  export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly ScheduleRow[], now: number, open: ReadonlyMap<string, number>, held: boolean): ScheduleView[] {
248
448
  const byId = new Map(rows.map(row => [row.id, row]));
249
449
  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);
450
+ const row = byId.get(entry.id), anchor = row?.anchor ?? now, next = scheduleNext(entry, anchor), openTask = open.get(entry.id);
251
451
  const state: ScheduleState = row?.paused ? "paused" : now < next ? "scheduled" : held ? "held" : openTask !== undefined ? "waiting" : "due";
252
452
  return {
253
- id: entry.id, every: entry.every, class: entry.class, queue: entry.queue, next, state,
453
+ id: entry.id, ...(entry.cron !== undefined ? { cron: entry.cron, zone: scheduleZone(entry) } : { every: entry.every }), class: entry.class, queue: entry.queue, next, state,
254
454
  ...(row?.last_run != null ? { last: row.last_run } : {}), ...(openTask !== undefined ? { task: openTask } : row?.last_task != null ? { task: row.last_task } : {}),
255
455
  ...(row?.coalesced ? { coalesced: row.coalesced } : {}), ...(row?.last_error ? { error: row.last_error } : {}),
256
456
  };
@@ -259,7 +459,8 @@ export function scheduleViews(entries: readonly ScheduleEntry[], rows: readonly
259
459
 
260
460
  /** 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
461
  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] : []),
462
+ const when = entry.cron !== undefined ? `on the calendar "${entry.cron}" (${scheduleZone(entry)})` : `every ${entry.every}`;
463
+ return [`${SCHEDULE_MARKER}${entry.id}`, `Scheduled ${when} by the machine-local schedule "${entry.id}".`, ...(entry.detail ? ["", entry.detail] : []),
263
464
  ...(entry.sources?.length ? ["", ...entry.sources.map(id => `Source: ${id}`)] : [])].join("\n");
264
465
  }
265
466
 
@@ -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
+ }
@@ -30,7 +30,7 @@ export interface SettingDef {
30
30
  label: string;
31
31
  type: "enum" | "boolean" | "peers" | "roles" | "integer" | "number" | "lines";
32
32
  /** Written only by the named hub command, never by `settings set`, a dashboard session or a conductor (#514b). */
33
- managed?: "learn";
33
+ managed?: "learn" | "autonomy";
34
34
  min?: number; max?: number;
35
35
  /** The closed values of an enum; a route setting takes the project's own route ids instead. */
36
36
  values?: readonly string[];
@@ -70,6 +70,7 @@ export const SETTINGS: readonly SettingDef[] = [
70
70
  { key: "scoped_grants.protect_instructions", group: "Permissions", label: "Ask before writing instruction files", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["protect_instruction_files"] },
71
71
  ...SETTING_PEERS.map((peer): SettingDef => ({ key: `roster.${peer}`, group: "Roster", label: `${peer}: participates`, type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["roster", peer] })),
72
72
  { key: "action_policy.enabled", group: "Permissions", label: "Always-ask action policy", type: "boolean", store: "config", applies: "live", risk: "raises", floor: true, path: ["action_policy", "enabled"] },
73
+ { key: "action_policy.floor", group: "Permissions", label: "Floor: ordinary branch pushes and own-repository pull requests do not ask", type: "boolean", store: "config", applies: "live", risk: "raises", floor: false, path: ["action_policy", "floor"] },
73
74
  ...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] })),
74
75
  ...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] })),
75
76
  ...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] })),
@@ -156,6 +157,7 @@ export function projectSettings(instances?: KindMap, adapterCatalog?: ProjectAda
156
157
 
157
158
  /** What undo may put back: a registry key, or the one managed key `ahub learn accept` writes. Never used to set anything. The CLI passes its project scope; the daemon has its own wrapper over its project-scoped lookup. */
158
159
  export function undoableDef(key: unknown, instances?: KindMap, adapterCatalog?: ProjectAdapterCatalog): SettingDef | string {
160
+ if (key === AUTONOMY_UNDO.key) return AUTONOMY_UNDO;
159
161
  return key === LEARN_CHECKLIST.key ? LEARN_CHECKLIST : settingDef(key, instances, adapterCatalog);
160
162
  }
161
163
 
@@ -221,7 +223,7 @@ export function parseSettingText(def: SettingDef, text: string): unknown {
221
223
  export const settingText = (value: unknown): string => value === null || value === undefined ? "(not set)" : Array.isArray(value) ? value.join(",") || "(none)" : String(value);
222
224
 
223
225
  export type SettingSource = "dashboard" | "terminal" | "conductor";
224
- export type SettingWriteOptions = { preserveUndo?: boolean };
226
+ export type SettingWriteOptions = { preserveUndo?: boolean; keys?: string[] };
225
227
  /** `ordinary`: a dashboard session from `ahub ui`. `settings`: one from `ahub ui --settings`. `terminal`: `ahub settings`. */
226
228
  export type SettingAuthority = "ordinary" | "settings" | "terminal" | "conductor";
227
229
  /** Why this authority may not set this value, or undefined. Removing a stored value can raise, so it counts as one. */
@@ -236,7 +238,7 @@ export function settingRefusal(def: SettingDef | string, value: SettingValue, au
236
238
 
237
239
  const CONFIG_LOCAL = "config.local.json";
238
240
  type StoredFile = typeof CONFIG_LOCAL | typeof OVERLAY_FILE;
239
- interface UndoRecord { file: StoredFile; key: string; previous: string | null; written: string | null; at: number }
241
+ interface UndoRecord { file: StoredFile; key: string; previous: string | null; written: string | null; at: number; keys?: string[] }
240
242
  const undoFile = (stateDir: string): string => join(stateDir, "settings-undo.json");
241
243
  const digest = (text: string | null): string | null => text === null ? null : createHash("sha256").update(text).digest("hex");
242
244
  /** The file's text, or null when it is not there. Any other failure is named by its code: its own text holds absolute paths. */
@@ -275,17 +277,19 @@ function refuseTracked(cwd: string, file: StoredFile): void {
275
277
  if (tracked === undefined && configRepositoryPresent(cwd)) throw new Error(`git could not confirm that .agenthub/${file} is untracked; nothing was written`);
276
278
  }
277
279
  /** Person writes keep the one previous version; conductor writes preserve that existing receipt. */
278
- function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null, options: SettingWriteOptions = {}): void {
280
+ function store(cwd: string, stateDir: string, file: StoredFile, key: string, previous: string | null, next: string | null, options: SettingWriteOptions = {}): UndoIdentity | undefined {
279
281
  refuseTracked(cwd, file);
280
282
  replaceFile(join(cwd, ".agenthub", file), file, next);
281
283
  // A conductor write never replaces, deletes or rebases the person's undo receipt. The ordinary digest fence still applies.
282
284
  if(options.preserveUndo)return;
283
285
  // The record follows the file: a write that failed leaves the earlier record valid, because the file is as it left it.
284
- const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now() };
286
+ const record: UndoRecord = { file, key, previous, written: digest(next), at: Date.now(), ...(options.keys ? { keys: options.keys } : {}) };
285
287
  try {
286
288
  mkdirSync(stateDir, { recursive: true, mode: 0o700 });
287
289
  rmSync(undoFile(stateDir), { force: true });
288
- writeFileSync(undoFile(stateDir), JSON.stringify(record), { mode: 0o600 });
290
+ const text = JSON.stringify(record);
291
+ writeFileSync(undoFile(stateDir), text, { mode: 0o600 });
292
+ return { key, undoDigest: digest(text)! };
289
293
  } catch {
290
294
  rmSync(undoFile(stateDir), { force: true }); // this write has no undo, and an older record must not offer one
291
295
  }
@@ -350,6 +354,98 @@ export function writeConfigSetting(cwd: string, stateDir: string, def: SettingDe
350
354
  store(cwd, stateDir, CONFIG_LOCAL, def.key, before, text, options);
351
355
  return true;
352
356
  }
357
+
358
+ /** Preset writes share the ordinary settings writer and its whole-file undo receipt. Not a public settings row. */
359
+ export const AUTONOMY_UNDO: SettingDef = Object.freeze({ key: "autonomy.preset", group: "Permissions", label: "Project autonomy preset", type: "boolean", store: "config", applies: "live", risk: "raises", path: ["_autonomy"], managed: "autonomy" });
360
+ interface AutonomyOwned { key: string; previous: SettingValue; written: SettingValue }
361
+ const presetKey = (key: unknown): key is string => typeof key === "string" && key.length <= 160 && /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$/.test(key)
362
+ && !key.split(".").some(part => ["__proto__", "constructor", "prototype"].includes(part));
363
+ const presetValue = (value: unknown): value is SettingValue => value === null || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)
364
+ || typeof value === "string" && value.length <= 4096 || Array.isArray(value) && value.length <= 64 && value.every(item => typeof item === "string" && item.length <= 4096);
365
+ /** Ownership is inert machine-local metadata, co-written with the values, so undo also restores ownership. */
366
+ export function autonomyOwnership(value: unknown): AutonomyOwned[] {
367
+ if (!isObject(value) || value.version !== 1 || Object.keys(value).some(key => !["version", "owned"].includes(key)) || !Array.isArray(value.owned) || value.owned.length > 128) throw new Error("invalid autonomy ownership receipt");
368
+ const keys = new Set<string>();
369
+ for (const row of value.owned) {
370
+ if (!isObject(row) || Object.keys(row).some(key => !["key", "previous", "written"].includes(key)) || !presetKey(row.key) || !presetValue(row.previous) || !presetValue(row.written) || keys.has(row.key)) throw new Error("invalid autonomy ownership entry");
371
+ keys.add(row.key);
372
+ }
373
+ return value.owned as AutonomyOwned[];
374
+ }
375
+ function putPreset(doc: Record<string, unknown>, key: string, value: SettingValue): void {
376
+ const parts = key.split("."), trail: [Record<string, unknown>, string][] = [];
377
+ let node = doc;
378
+ for (const part of parts.slice(0, -1)) {
379
+ if (node[part] === undefined) { if (value === null) return; node[part] = {}; }
380
+ if (!isObject(node[part])) throw new Error(`${key}: parent is not an object; nothing was changed`);
381
+ trail.push([node, part]); node = node[part] as Record<string, unknown>;
382
+ }
383
+ const leaf = parts.at(-1)!;
384
+ if (value !== null) node[leaf] = structuredClone(value);
385
+ else {
386
+ delete node[leaf];
387
+ for (const [parent, part] of trail.reverse()) { if (Object.keys(parent[part] as object).length) break; delete parent[part]; }
388
+ }
389
+ }
390
+ /** Only the host-derived shipped operations enter here; requests cannot supply operations or receipt keys. */
391
+ export function writeAutonomyPreset(cwd: string, stateDir: string, enabled: boolean, operations: readonly { key: string; value: unknown }[], validate: (scratch: string, keys: string[]) => unknown, authorizeOwnership: (metadata: unknown) => boolean = () => false, forceUndo = false): { changed: boolean; keys: string[]; preserved: string[]; ownership?: unknown; undo?: UndoIdentity } {
392
+ const before = readText(join(cwd, ".agenthub", CONFIG_LOCAL));
393
+ const doc = before === null ? {} : parseObject(before, CONFIG_LOCAL);
394
+ if (before === null && !existsSync(join(cwd, ".agenthub", "config.json"))) throw new Error("run ahub init first; autonomy does not create a project's first config file");
395
+ // Ownership witnesses permit precise reapply/undo only. Neither malformed nor
396
+ // unauthenticated project metadata may block an explicit on/off operation.
397
+ let owned: AutonomyOwned[] | undefined;
398
+ if (enabled && doc._autonomy !== undefined) {
399
+ try { const parsed = autonomyOwnership(doc._autonomy); if (authorizeOwnership(doc._autonomy)) owned = parsed; } catch { /* Start a fresh receipt. */ }
400
+ }
401
+ const keys: string[] = [], preserved: string[] = [];
402
+ if (enabled) {
403
+ const receipt: AutonomyOwned[] = structuredClone(owned ?? []);
404
+ for (const { key, value } of operations) {
405
+ if (!presetKey(key) || !presetValue(value) || value === null || keys.includes(key)) throw new Error("invalid autonomy preset operation");
406
+ const previous = valueAt(doc, key.split("."));
407
+ if (previous !== undefined && !presetValue(previous)) throw new Error(`${key}: existing value cannot be retained by the preset`);
408
+ const retained = receipt.find(row => row.key === key);
409
+ if (retained && !same(previous, retained.written)) { preserved.push(key); continue; }
410
+ if (same(previous, value)) continue; // Already hand-set: never claim ownership of it.
411
+ if (retained) retained.written = structuredClone(value);
412
+ else receipt.push({ key, previous: previous ?? null, written: structuredClone(value) });
413
+ putPreset(doc, key, value); keys.push(key);
414
+ }
415
+ doc._autonomy = { version: 1, owned: receipt };
416
+ } else {
417
+ // Off removes exactly the host's fixed list, including manual values. Receipt
418
+ // contents never select keys and never restore previous values on this path.
419
+ const seen = new Set<string>();
420
+ for (const { key } of operations) {
421
+ if (!presetKey(key) || seen.has(key)) throw new Error("invalid autonomy preset operation");
422
+ seen.add(key);
423
+ if (valueAt(doc, key.split(".")) === undefined) continue;
424
+ putPreset(doc, key, null); keys.push(key);
425
+ }
426
+ delete doc._autonomy;
427
+ }
428
+ const next = `${JSON.stringify(doc, null, 2)}\n`;
429
+ if (!forceUndo && (before === null ? Object.keys(doc).length === 0 : same(JSON.parse(before), doc))) return { changed: false, keys, preserved };
430
+ checkConfigText(cwd, next, scratch => validate(scratch, keys));
431
+ // Validation may call host ports; retain the exact input file rather than overwrite an intervening hand edit.
432
+ if (readText(join(cwd, ".agenthub", CONFIG_LOCAL)) !== before) throw new Error("configuration changed during autonomy validation; nothing was written");
433
+ const undo = store(cwd, stateDir, CONFIG_LOCAL, AUTONOMY_UNDO.key, before, next, { keys });
434
+ return { changed: true, keys, preserved, ownership: doc._autonomy ?? null, undo };
435
+ }
436
+ /** The keys of the retained preset transaction, never arbitrary request text. */
437
+ /** The inert leaf is never authority by itself; the host authenticates this digest outside the project. */
438
+ export function autonomyOwnershipDigest(metadata: unknown): string | null {
439
+ if (metadata === null || metadata === undefined) return null;
440
+ const owned = autonomyOwnership(metadata).map(row => ({ key: row.key, previous: row.previous, written: row.written })).sort((a, b) => a.key.localeCompare(b.key));
441
+ return digest(JSON.stringify({ version: 1, owned }));
442
+ }
443
+ export function autonomyUndoKeys(stateDir: string): string[] {
444
+ const raw = readText(undoFile(stateDir));
445
+ const record = raw === null ? undefined : JSON.parse(raw) as UndoRecord;
446
+ if (record?.key !== AUTONOMY_UNDO.key || !Array.isArray(record.keys) || record.keys.length > 128 || record.keys.some(key => !presetKey(key))) throw new Error("invalid autonomy undo receipt");
447
+ return [...record.keys];
448
+ }
353
449
  /** Runs `validate` on a scratch project holding the shared config and this candidate for the machine-local one. */
354
450
  function checkConfigText(cwd: string, text: string | null, validate: (scratch: string) => unknown): void {
355
451
  const scratch = mkdtempSync(join(tmpdir(), "ahub-settings-"));
@@ -407,7 +503,7 @@ export function readOverlay(cwd: string): RoutingOverlay {
407
503
  * that write (a hand edit would be lost), when the loaders no longer accept that version, or when there is nothing to undo.
408
504
  */
409
505
  export interface UndoIdentity { key: string; undoDigest: string }
410
- export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing, overlay?: RoutingOverlay) => void, expected?: UndoIdentity): { key: string; file: StoredFile } {
506
+ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: string) => unknown, checkRouting?: (routing: Routing, overlay?: RoutingOverlay) => void, expected?: UndoIdentity): { key: string; file: StoredFile; ownership?: unknown } {
411
507
  const raw = readText(undoFile(stateDir));
412
508
  if (raw === null) throw new Error("nothing to undo");
413
509
  let record: UndoRecord;
@@ -426,9 +522,10 @@ export function undoSetting(cwd: string, stateDir: string, validate: (scratch: s
426
522
  }
427
523
  else checkConfigText(cwd, record.previous, validate);
428
524
  if (expected && readText(undoFile(stateDir)) !== raw) throw new Error("undo record changed since preview; preview again; nothing was undone");
525
+ const ownership = record.key === AUTONOMY_UNDO.key ? record.previous === null ? null : parseObject(record.previous, CONFIG_LOCAL)._autonomy ?? null : undefined;
429
526
  replaceFile(path, record.file, record.previous);
430
527
  rmSync(undoFile(stateDir), { force: true });
431
- return { key: String(record.key), file: record.file };
528
+ return { key: String(record.key), file: record.file, ...(ownership !== undefined ? { ownership } : {}) };
432
529
  }
433
530
  /** Whether the machine-local config file holds a value for this setting now. */
434
531
  export function storedLocally(cwd: string, def: SettingDef): boolean {
@@ -36,12 +36,8 @@ function boundedGit(dir: string, args: string[], isolated = false): string | und
36
36
  } catch { return undefined; } // Unknown identity never grants; never log remote URLs or stderr.
37
37
  }
38
38
 
39
- /** Actual project-local GitHub origin only, never task refs, a bare slug or global-config fallback. */
40
- export function githubOriginRepository(cwd: string): string | undefined {
41
- let root: string;
42
- try { root = realPath(cwd); } catch { return undefined; }
43
- const reply = boundedGit(root, ["config", "--show-scope", "--get", "remote.origin.url"], true);
44
- const remote = reply && /^(?:local|worktree)\t([^\r\n]+)\n$/.exec(reply)?.[1];
39
+ /** The project-local GitHub repository a remote URL names, or undefined: only GitHub https, http or ssh URLs and scp forms, no query, no fragment, no whitespace. */
40
+ export function githubRemoteRepository(remote: string): string | undefined {
45
41
  if (!remote || /[\s?#]/.test(remote)) return undefined;
46
42
  const scp = /^(?:[^/@:]+@)?github\.com:[^/]+\/[^/]+\/?$/i.test(remote);
47
43
  if (!scp) {
@@ -51,6 +47,15 @@ export function githubOriginRepository(cwd: string): string | undefined {
51
47
  return slug(remote);
52
48
  }
53
49
 
50
+ /** Actual project-local GitHub origin only, never task refs, a bare slug or global-config fallback. */
51
+ export function githubOriginRepository(cwd: string): string | undefined {
52
+ let root: string;
53
+ try { root = realPath(cwd); } catch { return undefined; }
54
+ const reply = boundedGit(root, ["config", "--show-scope", "--get", "remote.origin.url"], true);
55
+ const remote = reply && /^(?:local|worktree)\t([^\r\n]+)\n$/.exec(reply)?.[1];
56
+ return remote ? githubRemoteRepository(remote) : undefined;
57
+ }
58
+
54
59
  /** Repository metadata is advisory, never an execution root or a grant. Git reads are bounded, local and cached. */
55
60
  export class TaskRepositories {
56
61
  readonly project: TaskRepository;