@indigoai-us/hq-cli 5.185.1 → 5.187.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.187.0] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - `hq mesh context prd-sync` sends only stories whose Board status changed (a first snapshot of already-done stories is not re-emitted). Quiet `session_end` summaries read Claude Code `result` lines and Codex/Grok `item.completed` agent messages, not only `type=assistant` turns.
10
+
11
+ ## [5.186.0] — 2026-09-25
12
+
13
+ ### Changed
14
+
15
+ - A local bot's scheduled jobs now live with the bot. `hq bot jobs` stores them in the bot's folder (`jobs.json`) and the running bot fires them itself, through the same path as a cloud-fired job. No cloud setting is involved. A run missed while the computer slept or the bot was stopped runs once as a catch-up; a job never overlaps itself. The 25-job cap per bot is unchanged.
16
+
5
17
  ## [5.185.1] — 2026-09-25
6
18
 
7
19
  ### Fixed
@@ -18,9 +18,9 @@ import chalk from "chalk";
18
18
  import { execFileSync } from "node:child_process";
19
19
  import * as fs from "node:fs";
20
20
  import * as path from "node:path";
21
- import { BotApi, BotApiError } from "../lib/bot/api.js";
21
+ import { localJobsClient } from "../lib/bot/local-jobs.js";
22
+ import { BotApiError } from "../lib/bot/api.js";
22
23
  import { effectiveBotKind } from "../lib/bot/config.js";
23
- import { identityMismatchMessage, tokenAgentUid } from "../lib/heartbeat-identity.js";
24
24
  // ─── Schedule translation ────────────────────────────────────────────────────
25
25
  /** Mirrors hq-pro-agents `MIN_RECURRING_INTERVAL_SECONDS`. */
26
26
  export const MIN_RECURRING_INTERVAL_SECONDS = 5 * 60;
@@ -365,16 +365,13 @@ export async function runBotJobs(nameArg, opts, deps) {
365
365
  const kind = effectiveBotKind(config);
366
366
  const out = deps.out ?? ((line) => console.log(line));
367
367
  const ctx = { botName: name, kind };
368
+ // The schedule lives with the bot: this bot runs on this computer, so its
369
+ // jobs live in its folder and it fires them itself.
368
370
  let client;
369
371
  const jobsApi = async () => {
370
372
  if (client)
371
373
  return client;
372
- const token = await deps.botTokenSupplier(dir)();
373
- const tokenUid = tokenAgentUid(token);
374
- if (tokenUid && tokenUid !== config.agentUid) {
375
- refuse(identityMismatchMessage("bot jobs", config.agentUid, tokenUid));
376
- }
377
- client = deps.apiFor ? deps.apiFor(dir) : new BotApi({ token: async () => token });
374
+ client = deps.apiFor ? deps.apiFor(dir) : localJobsClient(dir);
378
375
  return client;
379
376
  };
380
377
  if (opts.add !== undefined && opts.rm !== undefined) {
@@ -420,9 +417,8 @@ export async function runBotJobs(nameArg, opts, deps) {
420
417
  }
421
418
  const words = ("human" in schedule && schedule.human) || describeSchedule(created);
422
419
  out(chalk.green(`Scheduled ${created.jobId}: ${name} will ${body.prompt.replace(/\s+/g, " ").slice(0, 120)} — ${words}.`));
423
- out(chalk.gray("If this computer is asleep or off when a run is due, it is picked up as a catch-up when the computer " +
424
- "wakes, as long as it wakes within about an hour. After a longer outage that run is skipped and the " +
425
- "job carries on at its next scheduled time."));
420
+ out(chalk.gray(`It runs on this computer while ${name} is running. If a run is due while the computer is asleep or ` +
421
+ `${name} is stopped, it runs once, as a catch-up, the next time ${name} is running.`));
426
422
  return;
427
423
  }
428
424
  let jobs;
@@ -1,3 +1,4 @@
1
+ import { withLocalJobs } from "../lib/bot/local-jobs.js";
1
2
  import { OwnerContextCache, lookupOwnerContext } from "../lib/bot/owner-context.js";
2
3
  import { hasActivePromotion } from "../lib/bot/promotion-receipt.js";
3
4
  import { readInflightTurns } from "../lib/bot/inflight.js";
@@ -866,7 +867,8 @@ async function runBotResident(nameArg) {
866
867
  if (healNodePathFromJob(name, dir, config))
867
868
  return;
868
869
  const token = botTokenSupplier(dir);
869
- const api = new BotApi({ token });
870
+ // The bot's own schedule lives in its folder and fires from this loop.
871
+ const api = withLocalJobs(new BotApi({ token }), dir);
870
872
  const owner = new OwnerContextCache(() => lookupOwnerContext(config.ownerUid), Date.now, undefined, config.ownerUid);
871
873
  const handle = await runBot({
872
874
  dir,
@@ -14,7 +14,7 @@ import chalk from "chalk";
14
14
  import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
15
15
  import * as readline from "node:readline/promises";
16
16
  import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
17
- import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, formatStoryStatusChanges, mergeProjectStories, } from "../lib/mesh/project-board.js";
17
+ import { closeSupersededProjectThread, ensureMissingBoardStories, ensureProjectBoard, formatStoryStatusChanges, mergeProjectStories, prdSyncEventsThatChangeBoard, } from "../lib/mesh/project-board.js";
18
18
  import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
19
19
  import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
20
20
  import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
@@ -603,7 +603,25 @@ async function runPrdSync(opts) {
603
603
  ...(bound?.projectId ? { project: bound.projectId } : {}),
604
604
  touchedPaths: [resolvePath(file)],
605
605
  };
606
+ let toEmit = plan.events;
606
607
  if (bound?.companyUid && bound.projectId) {
608
+ try {
609
+ const live = await getProjectView(token, bound.companyUid, bound.projectId);
610
+ toEmit = prdSyncEventsThatChangeBoard(plan.events, live.stories);
611
+ }
612
+ catch {
613
+ // Board unread — emit the snapshot diff; the server drops same-status.
614
+ }
615
+ if (toEmit.length === 0) {
616
+ plan.commit();
617
+ if (opts.json) {
618
+ console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: 0 }));
619
+ }
620
+ else {
621
+ console.log("Work mesh: prd-sync unchanged");
622
+ }
623
+ return;
624
+ }
607
625
  try {
608
626
  const source = parseProjectPlan(readFileSync(file, "utf8"), file);
609
627
  await ensureMissingBoardStories({
@@ -617,7 +635,7 @@ async function runPrdSync(opts) {
617
635
  description: source.description ?? "",
618
636
  stories: source.stories,
619
637
  repos: source.repos ?? [],
620
- requiredIds: plan.events.map((event) => event.taskId),
638
+ requiredIds: toEmit.map((event) => event.taskId),
621
639
  });
622
640
  }
623
641
  catch (err) {
@@ -625,9 +643,12 @@ async function runPrdSync(opts) {
625
643
  console.error(`work-mesh: prd-sync ensure failed; emitting anyway (${reason.slice(0, 160)})`);
626
644
  }
627
645
  }
646
+ const alreadyOnBoard = plan.events
647
+ .filter((event) => !toEmit.some((row) => row.taskId === event.taskId))
648
+ .map((event) => event.taskId);
628
649
  const meshRoot = workMeshHomeRoot();
629
- const seqFirst = reserveSessionSeq(sessionId, plan.events.length);
630
- const events = plan.events.map((event, index) => buildMeshEmitEvent({
650
+ const seqFirst = reserveSessionSeq(sessionId, toEmit.length);
651
+ const events = toEmit.map((event, index) => buildMeshEmitEvent({
631
652
  eventId: generateUlid(Date.now() + index),
632
653
  kind: "task_status",
633
654
  sessionId,
@@ -659,7 +680,7 @@ async function runPrdSync(opts) {
659
680
  blocked.add(id);
660
681
  }
661
682
  const advanced = events.flatMap((event) => (event.eventId && event.taskId && !blocked.has(event.eventId) ? [event.taskId] : []));
662
- plan.commit(advanced);
683
+ plan.commit([...alreadyOnBoard, ...advanced]);
663
684
  if (plan.taskId && advanced.includes(plan.taskId)) {
664
685
  updateSessionState(sessionId, root, (current) => {
665
686
  if (!current)
@@ -673,10 +694,10 @@ async function runPrdSync(opts) {
673
694
  });
674
695
  }
675
696
  if (opts.json) {
676
- console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: plan.events.length, taskId: plan.taskId }));
697
+ console.log(JSON.stringify({ ok: true, action: "context.prd-sync", changed: toEmit.length, taskId: plan.taskId }));
677
698
  return;
678
699
  }
679
- console.log(`Work mesh: prd-sync emitted ${plan.events.length} task_status`);
700
+ console.log(`Work mesh: prd-sync emitted ${toEmit.length} task_status`);
680
701
  }
681
702
  function noteCallingSessionBound(projectId, company) {
682
703
  try {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Scheduled jobs for a bot that runs on this computer.
3
+ *
4
+ * The schedule lives with the bot. A local bot keeps its jobs in `jobs.json`
5
+ * in its own folder and fires them itself, from the same loop that checks its
6
+ * inbox. A due job becomes an ordinary `channel: "job"` inbox item, so it runs
7
+ * through exactly the path a cloud-fired job does (own session, "scheduled
8
+ * job" header in the owner's DM, catch-up label when late). Only the three
9
+ * calls that talk to the cloud about a run are answered here instead:
10
+ * pulling due runs, acking them, and recording how they finished.
11
+ *
12
+ * Rules:
13
+ * - Jobs fire only while the bot is running. After a sleep or an outage, a job
14
+ * runs ONCE for its newest missed time, labelled a catch-up; older missed
15
+ * times are not replayed.
16
+ * - A job never overlaps itself. A new fire waits until the previous run of
17
+ * the same job has finished (or has been silent for an hour).
18
+ * - A fire is recorded before it is handed over, so a crash never runs the
19
+ * same due time twice.
20
+ */
21
+ import { type BotApi, type BotJobRecord, type InboxItem, type JobRunCompletion } from "./api.js";
22
+ export declare const LOCAL_JOBS_FILE = "jobs.json";
23
+ export declare const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
24
+ export interface LocalJob {
25
+ jobId: string;
26
+ prompt: string;
27
+ /** Standard 5-field cron, evaluated in `timezone`. */
28
+ cron: string;
29
+ timezone: string;
30
+ context?: Record<string, unknown>;
31
+ createdAt: string;
32
+ /** The newest due time already handed to the bot. */
33
+ lastFiredAt?: string;
34
+ /** The run in progress, if any. */
35
+ inFlight?: {
36
+ messageId: string;
37
+ since: string;
38
+ };
39
+ lastRunAt?: string;
40
+ lastStatus?: string;
41
+ runCount?: number;
42
+ }
43
+ export declare function localJobsPath(dir: string): string;
44
+ export declare function readLocalJobs(dir: string): LocalJob[];
45
+ export declare function writeLocalJobs(dir: string, jobs: LocalJob[]): void;
46
+ /**
47
+ * Run a read-change-write of `jobs.json` under an exclusive lock. The running
48
+ * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
49
+ * processes; without the lock one can write back a stale copy and silently
50
+ * undo the other's change.
51
+ */
52
+ export declare function withJobsLock<T>(dir: string, fn: () => T): T;
53
+ export interface CronMatcher {
54
+ minutes: Set<number>;
55
+ hours: Set<number>;
56
+ days: Set<number>;
57
+ months: Set<number>;
58
+ weekdays: Set<number>;
59
+ dayRestricted: boolean;
60
+ weekdayRestricted: boolean;
61
+ }
62
+ /**
63
+ * The job's schedule as a standard 5-field cron. Jobs are stored in the same
64
+ * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
65
+ * unused day field), so the same `hq bot jobs` input works for either home.
66
+ */
67
+ export declare function toStandardCron(expr: string): string | null;
68
+ /** Parse a standard 5-field cron, or null when it is not one. */
69
+ export declare function parseCron(expr: string): CronMatcher | null;
70
+ export declare function cronMatches(cron: CronMatcher, date: Date, timeZone: string): boolean;
71
+ /**
72
+ * The newest time this job was due, at or before `now` and after the last one
73
+ * already fired. Null when nothing new is due.
74
+ */
75
+ /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
76
+ export declare function rateIntervalMs(expr: string): number | null;
77
+ export declare function isRunnableSchedule(expr: string): boolean;
78
+ export declare function latestDueFire(job: LocalJob, now: Date): Date | null;
79
+ /** The next time this job is due after `now`, within a week; null otherwise. */
80
+ export declare function nextDueFire(job: LocalJob, now: Date): Date | null;
81
+ export declare function toJobRecord(job: LocalJob, now?: Date): BotJobRecord;
82
+ export declare function addLocalJob(dir: string, input: {
83
+ prompt: string;
84
+ cron: string;
85
+ timezone: string;
86
+ context?: Record<string, unknown>;
87
+ }, now?: Date): LocalJob;
88
+ /** Remove a job. Returns false when the bot has no job with that id. */
89
+ export declare function removeLocalJob(dir: string, jobId: string): boolean;
90
+ /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
91
+ export declare function resetLocalJobChecks(): void;
92
+ export declare function takeDueLocalJobRuns(dir: string, now?: Date): InboxItem[];
93
+ export declare function isLocalJobMessage(messageId: string): boolean;
94
+ /** Record how a local run finished and release the job for its next fire. */
95
+ export declare function finishLocalJobRun(dir: string, jobId: string, messageId: string, completion: Pick<JobRunCompletion, "status">, now?: Date): void;
96
+ /**
97
+ * The bot's cloud client, with its scheduled runs answered on this computer:
98
+ * due local jobs join the inbox, and their ack and completion are recorded
99
+ * locally instead of being sent to the cloud.
100
+ */
101
+ export declare function withLocalJobs(api: BotApi, dir: string, clock?: () => Date): BotApi;
102
+ /** The most jobs one bot may hold (the same cap the cloud uses). */
103
+ export declare const MAX_LOCAL_JOBS = 25;
104
+ /**
105
+ * `hq bot jobs` against the bot's own folder: the same three calls the cloud
106
+ * client offers, answered from `jobs.json`.
107
+ */
108
+ export declare function localJobsClient(dir: string, clock?: () => Date): Pick<BotApi, "listJobs" | "createJob" | "cancelJob">;
109
+ //# sourceMappingURL=local-jobs.d.ts.map
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Scheduled jobs for a bot that runs on this computer.
3
+ *
4
+ * The schedule lives with the bot. A local bot keeps its jobs in `jobs.json`
5
+ * in its own folder and fires them itself, from the same loop that checks its
6
+ * inbox. A due job becomes an ordinary `channel: "job"` inbox item, so it runs
7
+ * through exactly the path a cloud-fired job does (own session, "scheduled
8
+ * job" header in the owner's DM, catch-up label when late). Only the three
9
+ * calls that talk to the cloud about a run are answered here instead:
10
+ * pulling due runs, acking them, and recording how they finished.
11
+ *
12
+ * Rules:
13
+ * - Jobs fire only while the bot is running. After a sleep or an outage, a job
14
+ * runs ONCE for its newest missed time, labelled a catch-up; older missed
15
+ * times are not replayed.
16
+ * - A job never overlaps itself. A new fire waits until the previous run of
17
+ * the same job has finished (or has been silent for an hour).
18
+ * - A fire is recorded before it is handed over, so a crash never runs the
19
+ * same due time twice.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { randomUUID } from "node:crypto";
24
+ import { BotApiError } from "./api.js";
25
+ export const LOCAL_JOBS_FILE = "jobs.json";
26
+ export const LOCAL_JOB_MESSAGE_PREFIX = "localjob-";
27
+ /** How far back a missed fire is still looked for (one week). */
28
+ const LOOKBACK_MINUTES = 7 * 24 * 60;
29
+ /** A run that never reported back releases its job after this long. */
30
+ const IN_FLIGHT_RELEASE_MS = 60 * 60 * 1000;
31
+ /** A fire is "late" (a catch-up) once this long past its due time. */
32
+ const STALE_AFTER_MS = 10 * 60 * 1000;
33
+ export function localJobsPath(dir) {
34
+ return path.join(dir, LOCAL_JOBS_FILE);
35
+ }
36
+ export function readLocalJobs(dir) {
37
+ try {
38
+ const parsed = JSON.parse(fs.readFileSync(localJobsPath(dir), "utf8"));
39
+ return Array.isArray(parsed.jobs) ? parsed.jobs.filter((job) => !!job && typeof job.jobId === "string") : [];
40
+ }
41
+ catch {
42
+ return [];
43
+ }
44
+ }
45
+ export function writeLocalJobs(dir, jobs) {
46
+ const file = localJobsPath(dir);
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, jobs }, null, 2));
49
+ fs.renameSync(tmp, file);
50
+ }
51
+ // ─── Lock ────────────────────────────────────────────────────────────────────
52
+ /** A lock older than this belongs to a process that died holding it. */
53
+ const LOCK_STALE_MS = 30_000;
54
+ const LOCK_WAIT_MS = 5_000;
55
+ function sleepSync(ms) {
56
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
57
+ }
58
+ /**
59
+ * Run a read-change-write of `jobs.json` under an exclusive lock. The running
60
+ * bot (firing, finishing) and `hq bot jobs` (add, remove) are separate
61
+ * processes; without the lock one can write back a stale copy and silently
62
+ * undo the other's change.
63
+ */
64
+ export function withJobsLock(dir, fn) {
65
+ const lock = `${localJobsPath(dir)}.lock`;
66
+ const deadline = Date.now() + LOCK_WAIT_MS;
67
+ for (;;) {
68
+ try {
69
+ fs.writeFileSync(lock, String(process.pid), { flag: "wx" });
70
+ break;
71
+ }
72
+ catch (err) {
73
+ if (err.code !== "EEXIST")
74
+ throw err;
75
+ try {
76
+ if (Date.now() - fs.statSync(lock).mtimeMs > LOCK_STALE_MS) {
77
+ fs.rmSync(lock, { force: true });
78
+ continue;
79
+ }
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ if (Date.now() > deadline)
85
+ throw new Error(`The bot's job list is busy (${lock}). Try again in a moment.`);
86
+ sleepSync(25);
87
+ }
88
+ }
89
+ try {
90
+ return fn();
91
+ }
92
+ finally {
93
+ fs.rmSync(lock, { force: true });
94
+ }
95
+ }
96
+ // ─── Cron ────────────────────────────────────────────────────────────────────
97
+ const FIELD_RANGES = [
98
+ [0, 59], // minute
99
+ [0, 23], // hour
100
+ [1, 31], // day of month
101
+ [1, 12], // month
102
+ [0, 7], // day of week (0 and 7 are Sunday)
103
+ ];
104
+ function parseField(field, [min, max]) {
105
+ const out = new Set();
106
+ for (const part of field.split(",")) {
107
+ const [rangePart, stepPart] = part.split("/");
108
+ const step = stepPart === undefined ? 1 : Number(stepPart);
109
+ if (!Number.isInteger(step) || step < 1)
110
+ return null;
111
+ let lo;
112
+ let hi;
113
+ if (rangePart === "*") {
114
+ lo = min;
115
+ hi = max;
116
+ }
117
+ else if (/^\d+-\d+$/.test(rangePart ?? "")) {
118
+ [lo, hi] = rangePart.split("-").map(Number);
119
+ }
120
+ else if (/^\d+$/.test(rangePart ?? "")) {
121
+ lo = Number(rangePart);
122
+ hi = stepPart === undefined ? lo : max;
123
+ }
124
+ else {
125
+ return null;
126
+ }
127
+ if (lo < min || hi > max || lo > hi)
128
+ return null;
129
+ for (let v = lo; v <= hi; v += step)
130
+ out.add(v);
131
+ }
132
+ return out;
133
+ }
134
+ const DAY_NAMES = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
135
+ const MONTH_NAMES = {
136
+ JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6, JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
137
+ };
138
+ function replaceNames(field, names) {
139
+ return field.replace(/[A-Za-z]{3}/g, (name) => {
140
+ const value = names[name.toUpperCase()];
141
+ return value === undefined ? name : String(value);
142
+ });
143
+ }
144
+ /**
145
+ * The job's schedule as a standard 5-field cron. Jobs are stored in the same
146
+ * form the cloud uses (`cron(m h dom mon dow year)`, Sunday = 1, `?` for the
147
+ * unused day field), so the same `hq bot jobs` input works for either home.
148
+ */
149
+ export function toStandardCron(expr) {
150
+ const trimmed = expr.trim();
151
+ const wrapped = /^cron\((.*)\)$/.exec(trimmed);
152
+ if (!wrapped)
153
+ return trimmed.split(/\s+/).length === 5 ? trimmed : null;
154
+ const fields = wrapped[1].trim().split(/\s+/);
155
+ if (fields.length !== 6)
156
+ return null;
157
+ const [minute, hour, dom, month, dow] = fields;
158
+ const standardDow = dow === "?" || dow === "*"
159
+ ? "*"
160
+ : replaceNames(dow, { SUN: 1, MON: 2, TUE: 3, WED: 4, THU: 5, FRI: 6, SAT: 7 }).replace(/\d+/g, (n) => String(Number(n) - 1));
161
+ return [minute, hour, dom === "?" ? "*" : dom, month, standardDow].join(" ");
162
+ }
163
+ /** Parse a standard 5-field cron, or null when it is not one. */
164
+ export function parseCron(expr) {
165
+ const standard = toStandardCron(expr);
166
+ if (!standard)
167
+ return null;
168
+ const fields = standard.split(/\s+/).map((field, i) => i === 3 ? replaceNames(field, MONTH_NAMES) : i === 4 ? replaceNames(field, DAY_NAMES) : field);
169
+ if (fields.length !== 5)
170
+ return null;
171
+ const sets = fields.map((field, i) => parseField(field, FIELD_RANGES[i]));
172
+ if (sets.some((set) => set === null))
173
+ return null;
174
+ const [minutes, hours, days, months, weekdays] = sets;
175
+ if (weekdays.has(7))
176
+ weekdays.add(0);
177
+ return {
178
+ minutes: minutes,
179
+ hours: hours,
180
+ days: days,
181
+ months: months,
182
+ weekdays: weekdays,
183
+ dayRestricted: fields[2] !== "*",
184
+ weekdayRestricted: fields[4] !== "*",
185
+ };
186
+ }
187
+ const WEEKDAY = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
188
+ const formatters = new Map();
189
+ function zonedParts(date, timeZone) {
190
+ let format = formatters.get(timeZone);
191
+ if (!format) {
192
+ format = new Intl.DateTimeFormat("en-US", {
193
+ timeZone,
194
+ hour12: false,
195
+ minute: "numeric",
196
+ hour: "numeric",
197
+ day: "numeric",
198
+ month: "numeric",
199
+ weekday: "short",
200
+ });
201
+ formatters.set(timeZone, format);
202
+ }
203
+ const parts = format.formatToParts(date);
204
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? "";
205
+ return {
206
+ minute: Number(get("minute")),
207
+ hour: Number(get("hour")) % 24,
208
+ day: Number(get("day")),
209
+ month: Number(get("month")),
210
+ weekday: WEEKDAY[get("weekday")] ?? -1,
211
+ };
212
+ }
213
+ export function cronMatches(cron, date, timeZone) {
214
+ const t = zonedParts(date, timeZone);
215
+ if (!cron.minutes.has(t.minute) || !cron.hours.has(t.hour) || !cron.months.has(t.month))
216
+ return false;
217
+ const dayOk = cron.days.has(t.day);
218
+ const weekdayOk = cron.weekdays.has(t.weekday);
219
+ // Standard cron: when both day fields are restricted, either may match.
220
+ if (cron.dayRestricted && cron.weekdayRestricted)
221
+ return dayOk || weekdayOk;
222
+ return dayOk && weekdayOk;
223
+ }
224
+ /**
225
+ * The newest time this job was due, at or before `now` and after the last one
226
+ * already fired. Null when nothing new is due.
227
+ */
228
+ /** `rate(5 minutes)` / `rate(1 hour)` / `rate(2 days)` as milliseconds. */
229
+ export function rateIntervalMs(expr) {
230
+ const match = /^rate\((\d+)\s+(minute|minutes|hour|hours|day|days)\)$/.exec(expr.trim());
231
+ if (!match)
232
+ return null;
233
+ const n = Number(match[1]);
234
+ const unit = match[2].startsWith("minute") ? 60_000 : match[2].startsWith("hour") ? 3_600_000 : 86_400_000;
235
+ return n > 0 ? n * unit : null;
236
+ }
237
+ export function isRunnableSchedule(expr) {
238
+ return rateIntervalMs(expr) !== null || parseCron(expr) !== null;
239
+ }
240
+ export function latestDueFire(job, now) {
241
+ const interval = rateIntervalMs(job.cron);
242
+ if (interval) {
243
+ const origin = Date.parse(job.createdAt);
244
+ const steps = Math.floor((now.getTime() - origin) / interval);
245
+ if (steps < 1)
246
+ return null;
247
+ const due = origin + steps * interval;
248
+ return job.lastFiredAt && Date.parse(job.lastFiredAt) >= due ? null : new Date(due);
249
+ }
250
+ const cron = parseCron(job.cron);
251
+ if (!cron)
252
+ return null;
253
+ const start = new Date(now);
254
+ start.setUTCSeconds(0, 0);
255
+ const floor = Math.max(job.lastFiredAt ? Date.parse(job.lastFiredAt) : Date.parse(job.createdAt), start.getTime() - LOOKBACK_MINUTES * 60_000);
256
+ for (let t = start.getTime(); t > floor; t -= 60_000) {
257
+ const at = new Date(t);
258
+ if (cronMatches(cron, at, job.timezone))
259
+ return at;
260
+ }
261
+ return null;
262
+ }
263
+ /** The next time this job is due after `now`, within a week; null otherwise. */
264
+ export function nextDueFire(job, now) {
265
+ const interval = rateIntervalMs(job.cron);
266
+ if (interval) {
267
+ const origin = Date.parse(job.createdAt);
268
+ const steps = Math.max(0, Math.floor((now.getTime() - origin) / interval)) + 1;
269
+ return new Date(origin + steps * interval);
270
+ }
271
+ const cron = parseCron(job.cron);
272
+ if (!cron)
273
+ return null;
274
+ const start = new Date(now);
275
+ start.setUTCSeconds(0, 0);
276
+ for (let i = 1; i <= LOOKBACK_MINUTES; i += 1) {
277
+ const at = new Date(start.getTime() + i * 60_000);
278
+ if (cronMatches(cron, at, job.timezone))
279
+ return at;
280
+ }
281
+ return null;
282
+ }
283
+ // ─── The job list (what `hq bot jobs` reads and writes) ──────────────────────
284
+ export function toJobRecord(job, now = new Date()) {
285
+ const next = nextDueFire(job, now);
286
+ return {
287
+ jobId: job.jobId,
288
+ prompt: job.prompt,
289
+ status: "active",
290
+ schedule: { kind: "recurring", cron: job.cron, timezone: job.timezone },
291
+ ...(job.context ? { context: job.context } : {}),
292
+ createdAt: job.createdAt,
293
+ ...(next ? { nextRunAt: next.toISOString() } : {}),
294
+ ...(job.lastRunAt ? { lastRunAt: job.lastRunAt } : {}),
295
+ ...(job.lastStatus ? { lastStatus: job.lastStatus } : {}),
296
+ ...(job.runCount ? { runCount: job.runCount } : {}),
297
+ };
298
+ }
299
+ export function addLocalJob(dir, input, now = new Date()) {
300
+ if (!isRunnableSchedule(input.cron))
301
+ throw new Error(`"${input.cron}" is not a schedule this bot can run.`);
302
+ const job = {
303
+ jobId: `job_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
304
+ prompt: input.prompt,
305
+ cron: input.cron,
306
+ timezone: input.timezone,
307
+ ...(input.context ? { context: input.context } : {}),
308
+ createdAt: now.toISOString(),
309
+ };
310
+ withJobsLock(dir, () => writeLocalJobs(dir, [...readLocalJobs(dir), job]));
311
+ return job;
312
+ }
313
+ /** Remove a job. Returns false when the bot has no job with that id. */
314
+ export function removeLocalJob(dir, jobId) {
315
+ return withJobsLock(dir, () => {
316
+ const jobs = readLocalJobs(dir);
317
+ const next = jobs.filter((job) => job.jobId !== jobId);
318
+ if (next.length === jobs.length)
319
+ return false;
320
+ writeLocalJobs(dir, next);
321
+ return true;
322
+ });
323
+ }
324
+ // ─── Firing ──────────────────────────────────────────────────────────────────
325
+ /**
326
+ * Hand over every job that is due now. Each fire is recorded (lastFiredAt and
327
+ * inFlight) before its inbox item is returned.
328
+ */
329
+ const lastCheckedMinute = new Map();
330
+ /** Forget the once-per-minute check (tests, and a bot restarted in-process). */
331
+ export function resetLocalJobChecks() {
332
+ lastCheckedMinute.clear();
333
+ }
334
+ export function takeDueLocalJobRuns(dir, now = new Date()) {
335
+ // Schedules have minute resolution and the inbox is polled every few
336
+ // seconds: look at the jobs once per minute.
337
+ const minute = Math.floor(now.getTime() / 60_000);
338
+ if (lastCheckedMinute.get(dir) === minute)
339
+ return [];
340
+ lastCheckedMinute.set(dir, minute);
341
+ return withJobsLock(dir, () => {
342
+ const jobs = readLocalJobs(dir);
343
+ if (jobs.length === 0)
344
+ return [];
345
+ const items = [];
346
+ let changed = false;
347
+ const next = jobs.map((job) => {
348
+ if (job.inFlight && now.getTime() - Date.parse(job.inFlight.since) < IN_FLIGHT_RELEASE_MS)
349
+ return job;
350
+ const due = latestDueFire(job, now);
351
+ if (!due) {
352
+ if (job.inFlight) {
353
+ changed = true;
354
+ return { ...job, inFlight: undefined };
355
+ }
356
+ return job;
357
+ }
358
+ const firedAt = due.toISOString();
359
+ const messageId = `${LOCAL_JOB_MESSAGE_PREFIX}${job.jobId}-${due.getTime()}`;
360
+ items.push({
361
+ messageId,
362
+ channel: "job",
363
+ jobId: job.jobId,
364
+ prompt: job.prompt,
365
+ ...(job.context ? { context: job.context } : {}),
366
+ firedAt,
367
+ staleAfter: new Date(due.getTime() + STALE_AFTER_MS).toISOString(),
368
+ receivedAt: now.toISOString(),
369
+ });
370
+ changed = true;
371
+ return { ...job, lastFiredAt: firedAt, inFlight: { messageId, since: now.toISOString() } };
372
+ });
373
+ if (changed)
374
+ writeLocalJobs(dir, next);
375
+ return items;
376
+ });
377
+ }
378
+ export function isLocalJobMessage(messageId) {
379
+ return messageId.startsWith(LOCAL_JOB_MESSAGE_PREFIX);
380
+ }
381
+ /** Record how a local run finished and release the job for its next fire. */
382
+ export function finishLocalJobRun(dir, jobId, messageId, completion, now = new Date()) {
383
+ withJobsLock(dir, () => {
384
+ const jobs = readLocalJobs(dir);
385
+ let changed = false;
386
+ const next = jobs.map((job) => {
387
+ if (job.jobId !== jobId)
388
+ return job;
389
+ changed = true;
390
+ const releases = !job.inFlight || job.inFlight.messageId === messageId;
391
+ return {
392
+ ...job,
393
+ ...(releases ? { inFlight: undefined } : {}),
394
+ lastRunAt: now.toISOString(),
395
+ lastStatus: completion.status,
396
+ runCount: (job.runCount ?? 0) + (completion.status === "succeeded" || completion.status === "failed" ? 1 : 0),
397
+ };
398
+ });
399
+ if (changed)
400
+ writeLocalJobs(dir, next);
401
+ });
402
+ }
403
+ /**
404
+ * The bot's cloud client, with its scheduled runs answered on this computer:
405
+ * due local jobs join the inbox, and their ack and completion are recorded
406
+ * locally instead of being sent to the cloud.
407
+ */
408
+ export function withLocalJobs(api, dir, clock = () => new Date()) {
409
+ const local = Object.create(api);
410
+ local.pullInbox = async (agentUid) => {
411
+ // Cloud first: if that call fails, nothing local has been taken yet, so
412
+ // no due run is recorded as fired and then dropped.
413
+ const cloud = await api.pullInbox(agentUid);
414
+ return [...cloud, ...takeDueLocalJobRuns(dir, clock())];
415
+ };
416
+ local.ackInbox = async (agentUid, messageId) => {
417
+ if (isLocalJobMessage(messageId))
418
+ return;
419
+ await api.ackInbox(agentUid, messageId);
420
+ };
421
+ local.completeJobRun = async (agentUid, jobId, messageId, completion) => {
422
+ if (isLocalJobMessage(messageId)) {
423
+ finishLocalJobRun(dir, jobId, messageId, completion, clock());
424
+ return;
425
+ }
426
+ await api.completeJobRun(agentUid, jobId, messageId, completion);
427
+ };
428
+ return local;
429
+ }
430
+ /** The most jobs one bot may hold (the same cap the cloud uses). */
431
+ export const MAX_LOCAL_JOBS = 25;
432
+ /**
433
+ * `hq bot jobs` against the bot's own folder: the same three calls the cloud
434
+ * client offers, answered from `jobs.json`.
435
+ */
436
+ export function localJobsClient(dir, clock = () => new Date()) {
437
+ return {
438
+ async listJobs() {
439
+ const now = clock();
440
+ return readLocalJobs(dir).map((job) => toJobRecord(job, now));
441
+ },
442
+ async createJob(_agentUid, input) {
443
+ if (input.schedule.kind !== "recurring") {
444
+ throw new BotApiError(400, "local jobs", "JOB_VALIDATION", "A local bot runs recurring jobs only.");
445
+ }
446
+ if (readLocalJobs(dir).length >= MAX_LOCAL_JOBS) {
447
+ throw new BotApiError(409, "local jobs", "JOB_QUOTA_EXCEEDED", `at most ${MAX_LOCAL_JOBS} jobs`);
448
+ }
449
+ const job = addLocalJob(dir, { prompt: input.prompt, cron: input.schedule.cron, timezone: input.schedule.timezone, ...(input.context ? { context: input.context } : {}) }, clock());
450
+ return toJobRecord(job, clock());
451
+ },
452
+ async cancelJob(_agentUid, jobId) {
453
+ if (!removeLocalJob(dir, jobId)) {
454
+ throw new BotApiError(404, "local jobs", "JOB_NOT_FOUND", `Job '${jobId}' not found`);
455
+ }
456
+ },
457
+ };
458
+ }
459
+ //# sourceMappingURL=local-jobs.js.map
@@ -75,14 +75,27 @@ export function extractLastAssistantText(jsonl) {
75
75
  const type = typeof obj.type === "string" ? obj.type : "";
76
76
  const role = typeof obj.role === "string" ? obj.role : "";
77
77
  const payload = obj.payload;
78
+ const item = obj.item;
79
+ const message = obj.message;
78
80
  let text = "";
79
81
  if (type === "assistant" || role === "assistant") {
80
82
  text = textFromUnknown(obj.message ?? obj.content);
81
83
  }
84
+ else if (message && (message.role === "assistant" || message.type === "assistant")) {
85
+ text = textFromUnknown(message.content ?? message.text ?? message);
86
+ }
82
87
  else if (payload &&
83
88
  (payload.type === "agent_message" || payload.type === "assistant")) {
84
89
  text = textFromUnknown(payload.message ?? payload.content ?? payload.text);
85
90
  }
91
+ else if (type === "item.completed" &&
92
+ item &&
93
+ item.type === "agent_message") {
94
+ text = textFromUnknown(item.text ?? item.content ?? item.message);
95
+ }
96
+ else if (type === "result" && typeof obj.result === "string") {
97
+ text = obj.result;
98
+ }
86
99
  const trimmed = text.trim();
87
100
  if (trimmed)
88
101
  last = trimmed;
@@ -4,6 +4,15 @@ export declare function mergeProjectStories(current: MeshProjectStoryInput[], pl
4
4
  resetStatus?: boolean;
5
5
  }): MeshProjectStoryInput[];
6
6
  export declare function boardStoryStatus(story: MeshProjectStoryInput): string;
7
+ /**
8
+ * Keep only prd-sync task_status events whose Board status actually differs.
9
+ * Missing Board rows stay (ensure will add them). Same-status replays of
10
+ * already-done stories must not be sent again (US-051b).
11
+ */
12
+ export declare function prdSyncEventsThatChangeBoard<T extends {
13
+ taskId: string;
14
+ status: string;
15
+ }>(events: readonly T[], boardStories: readonly MeshProjectStoryInput[]): T[];
7
16
  /** One-line summary of live→next status diffs for existing story ids. */
8
17
  export declare function formatStoryStatusChanges(current: MeshProjectStoryInput[], next: MeshProjectStoryInput[]): string | undefined;
9
18
  /**
@@ -34,6 +34,15 @@ export function boardStoryStatus(story) {
34
34
  return story.status;
35
35
  return story.passes === true ? "done" : "queued";
36
36
  }
37
+ /**
38
+ * Keep only prd-sync task_status events whose Board status actually differs.
39
+ * Missing Board rows stay (ensure will add them). Same-status replays of
40
+ * already-done stories must not be sent again (US-051b).
41
+ */
42
+ export function prdSyncEventsThatChangeBoard(events, boardStories) {
43
+ const byId = new Map(boardStories.map((story) => [story.id, boardStoryStatus(story)]));
44
+ return events.filter((event) => byId.get(event.taskId) !== event.status);
45
+ }
37
46
  /** One-line summary of live→next status diffs for existing story ids. */
38
47
  export function formatStoryStatusChanges(current, next) {
39
48
  const currentById = new Map(current.map((story) => [story.id, story]));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.185.1",
3
+ "version": "5.187.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {