@indigoai-us/hq-cli 5.345.193 → 5.345.194

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.
@@ -1,9 +1,22 @@
1
1
  /**
2
- * The local signals pass (US-011). After a hot-channel run lands sources, the
3
- * pass takes the run's landed documents through the ontology worklist, asks
2
+ * The local signals pass (US-011). After each hot-channel adapter run, the
3
+ * pass takes the adapter's channel through the ontology worklist, asks
4
4
  * `claude -p` for signals one document at a time, and writes those signals to
5
5
  * `companies/{co}/signals/`. It returns the count for the run ledger.
6
6
  *
7
+ * Work comes from the worklist, not only from this run's landings: when the
8
+ * worklist script exits 0, the pass takes the channel's unprocessed items in
9
+ * the worklist's order (newest first) up to the run's document cap. Items the
10
+ * cap leaves over stay unmarked, and a later run picks them up, including a
11
+ * run that landed nothing (a bounded backlog drain: at most
12
+ * DEFAULT_MAX_SIGNAL_DOCS claude calls per run, and no claude call at all when
13
+ * the backlog is empty). A channel with no source.yaml (worklist exit 2, the
14
+ * usual case for a hot channel such as `meeting-events`) has no worklist, so
15
+ * the pass keeps its own backlog: landed documents it has not processed yet
16
+ * are listed in `sources/_signals-pending/{channel}.json` and drained, newest
17
+ * first by `when` (else `created_at`), on later runs under the same cap. A
18
+ * cloud-run source (exit 4) is skipped.
19
+ *
7
20
  * A landed document with both `payload_ref` and `raw_ref` is a pointer (the
8
21
  * meetings, Slack and Attio adapters land these). For it, claude gets the
9
22
  * pointer followed by the document `payload_ref` names, capped at
@@ -80,8 +93,6 @@ export const WORKLIST_TIMEOUT_MS = 60_000;
80
93
  export const DEFAULT_MAX_SIGNAL_DOCS = 20;
81
94
  /** Payload bytes sent to claude per document (200 KB). A longer payload is cut with a marker. */
82
95
  export const MAX_PAYLOAD_BYTES = 200 * 1024;
83
- /** Items the worklist lists per channel; landed documents must fall inside it. */
84
- const WORKLIST_LIMIT = 500;
85
96
  /** Output kept per child stream. A longer reply is bad output. */
86
97
  const MAX_OUTPUT_BYTES = 4 * 1024 * 1024;
87
98
  /** Signals kept per document. */
@@ -173,7 +184,7 @@ export function defaultPromptFile() {
173
184
  return path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts", "signals-pass.md");
174
185
  }
175
186
  export const CLAUDE_LOGGED_OUT_RE = /not logged in|log ?in|\/login|invalid api key|authenticat|oauth token/i;
176
- function channelOf(key) {
187
+ export function channelOf(key) {
177
188
  const parts = key.split("/");
178
189
  if (parts.length !== 3 || parts[0] !== "sources" || !parts[2].endsWith(".md"))
179
190
  return null;
@@ -186,7 +197,69 @@ function channelOf(key) {
186
197
  }
187
198
  return parts[1];
188
199
  }
189
- function splitFrontmatter(text) {
200
+ /**
201
+ * Vault-relative file that lists a channel's landed documents the pass has not
202
+ * processed yet. Only channels with no worklist (no source.yaml) use it.
203
+ */
204
+ export function pendingSignalsKey(channel) {
205
+ assertSafeSegment(channel, "channel");
206
+ return `sources/_signals-pending/${channel}.json`;
207
+ }
208
+ /** The channel's pending landed keys; a missing or unreadable file is an empty backlog. */
209
+ export async function readPendingSignals(vaultRoot, channel) {
210
+ let parsed;
211
+ try {
212
+ parsed = JSON.parse(await fs.readFile(path.join(vaultRoot, pendingSignalsKey(channel)), "utf8"));
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ if (!Array.isArray(parsed))
218
+ return [];
219
+ return parsed.filter((key) => typeof key === "string" && channelOf(key) === channel);
220
+ }
221
+ /** Replace the channel's pending list; an empty list removes the file. */
222
+ export async function writePendingSignals(vaultRoot, channel, keys, beforeWrite = async () => undefined) {
223
+ const file = path.join(vaultRoot, pendingSignalsKey(channel));
224
+ assertVaultPath(vaultRoot, file);
225
+ await beforeWrite();
226
+ if (keys.length === 0) {
227
+ await fs.rm(file, { force: true });
228
+ return;
229
+ }
230
+ await writeFileAtomic(vaultRoot, file, `${JSON.stringify([...new Set(keys)], null, 2)}\n`);
231
+ }
232
+ /** Add landed keys to the channel's pending list (kept once each). */
233
+ export async function queuePendingSignals(vaultRoot, channel, keys, beforeWrite) {
234
+ for (const key of keys) {
235
+ if (channelOf(key) !== channel)
236
+ throw new Error(`${key} is not a document in sources/${channel}/`);
237
+ }
238
+ await writePendingSignals(vaultRoot, channel, [...(await readPendingSignals(vaultRoot, channel)), ...keys], beforeWrite);
239
+ }
240
+ /** When a landed document happened: `when`, else `created_at`; unknown sorts last. */
241
+ export function landedDocTime(frontmatter) {
242
+ for (const field of ["when", "created_at"]) {
243
+ const value = frontmatter[field];
244
+ const text = value instanceof Date ? value.toISOString() : typeof value === "string" ? value : undefined;
245
+ if (text === undefined)
246
+ continue;
247
+ const ms = Date.parse(text);
248
+ if (!Number.isNaN(ms))
249
+ return ms;
250
+ }
251
+ return Number.NEGATIVE_INFINITY;
252
+ }
253
+ function isSafeChannel(channel) {
254
+ try {
255
+ assertSafeSegment(channel, "channel");
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ export function splitFrontmatter(text) {
190
263
  const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text);
191
264
  if (!match)
192
265
  return { frontmatter: {}, body: text };
@@ -433,15 +506,19 @@ export function createClaudeSignalsPass(options) {
433
506
  cwd: options.hqRoot,
434
507
  env: { ...env, HQ_ROOT: options.hqRoot },
435
508
  });
436
- /** Work items for the landed documents of one channel. */
437
- const itemsFor = async (channel, files) => {
438
- const wanted = new Set(files);
439
- const run = await runWorklist(["--company", options.company, "--channel", channel, "--limit", String(WORKLIST_LIMIT)]);
509
+ /**
510
+ * Work items for one channel. With a worklist: its unprocessed items in its
511
+ * order (at most `limit`). Without one: this run's landed `files` plus the
512
+ * channel's pending backlog, newest first, all of them (`pending` is set and
513
+ * the caller applies the cap).
514
+ */
515
+ const itemsFor = async (channel, files, limit) => {
516
+ const run = await runWorklist(["--company", options.company, "--channel", channel, "--limit", String(limit)]);
440
517
  if (run.timedOut)
441
518
  throw new Error(`ontology worklist timed out for channel ${channel}`);
442
519
  // Exit 4: the source is assigned to the cloud agent. Its documents are not this pass's to process.
443
520
  if (run.code === 4)
444
- return [];
521
+ return { items: [], pending: false };
445
522
  if (run.code === 0) {
446
523
  const items = [];
447
524
  for (const line of run.stdout.split("\n")) {
@@ -454,7 +531,7 @@ export function createClaudeSignalsPass(options) {
454
531
  catch {
455
532
  continue;
456
533
  }
457
- if (typeof row.file !== "string" || !wanted.has(row.file) || row.skip !== undefined)
534
+ if (typeof row.file !== "string" || channelOf(row.file) !== channel || row.skip !== undefined)
458
535
  continue;
459
536
  if (typeof row.audience_key !== "string" || !Array.isArray(row.audience))
460
537
  continue;
@@ -467,30 +544,46 @@ export function createClaudeSignalsPass(options) {
467
544
  fromWorklist: true,
468
545
  });
469
546
  }
470
- return items;
547
+ return { items, pending: false };
471
548
  }
472
549
  // Any other exit (usually 2: the channel has no source.yaml yet, which is
473
550
  // normal for a hot channel) falls back to the landed document's own
474
- // audience frontmatter, which landing always writes.
475
- const items = [];
476
- for (const file of files) {
477
- const { frontmatter } = splitFrontmatter(await fs.readFile(path.join(vaultRoot, file), "utf8"));
551
+ // audience frontmatter, which landing always writes. The pass's own
552
+ // pending backlog stands in for the worklist.
553
+ const candidates = [...new Set([...files, ...(await readPendingSignals(vaultRoot, channel))])];
554
+ const dated = [];
555
+ for (const file of candidates) {
556
+ let text;
557
+ try {
558
+ text = await fs.readFile(path.join(vaultRoot, file), "utf8");
559
+ }
560
+ catch (err) {
561
+ // A pending document that is gone has nothing left to process.
562
+ if (isMissing(err))
563
+ continue;
564
+ throw err;
565
+ }
566
+ const { frontmatter } = splitFrontmatter(text);
478
567
  const audience = Array.isArray(frontmatter.audience)
479
568
  ? frontmatter.audience.filter((a) => typeof a === "string" && a.trim().length > 0)
480
569
  : [];
481
570
  // No audience: skipped, never widened to company.
482
571
  if (audience.length === 0)
483
572
  continue;
484
- items.push({
485
- file,
486
- channel,
487
- principals: audience,
488
- audienceKey: audienceKey(audience),
489
- audienceSource: typeof frontmatter.audience_source === "string" ? frontmatter.audience_source : "frontmatter",
490
- fromWorklist: false,
573
+ dated.push({
574
+ item: {
575
+ file,
576
+ channel,
577
+ principals: audience,
578
+ audienceKey: audienceKey(audience),
579
+ audienceSource: typeof frontmatter.audience_source === "string" ? frontmatter.audience_source : "frontmatter",
580
+ fromWorklist: false,
581
+ },
582
+ at: landedDocTime(frontmatter),
491
583
  });
492
584
  }
493
- return items;
585
+ dated.sort((a, b) => b.at - a.at || (a.item.file < b.item.file ? -1 : a.item.file > b.item.file ? 1 : 0));
586
+ return { items: dated.map((d) => d.item), pending: true };
494
587
  };
495
588
  const extract = async (item, input) => {
496
589
  prompt ??= fs.readFile(promptFile, "utf8");
@@ -549,23 +642,35 @@ export function createClaudeSignalsPass(options) {
549
642
  return async (result, guard) => {
550
643
  const beforeWrite = guard?.beforeWrite ?? (async () => undefined);
551
644
  const byChannel = new Map();
645
+ // The adapter's own channel is listed even when this run landed nothing
646
+ // there, so items an earlier run left over the cap still get processed.
647
+ if (result.channel !== undefined && isSafeChannel(result.channel))
648
+ byChannel.set(result.channel, []);
552
649
  for (const key of result.landedKeys ?? []) {
553
650
  const channel = channelOf(key);
554
651
  if (!channel)
555
652
  continue;
556
653
  byChannel.set(channel, [...(byChannel.get(channel) ?? []), key]);
557
654
  }
558
- // Nothing landed: there is no pass to run, so claude is not called.
559
- if (byChannel.size === 0)
560
- return 0;
561
- preflight ??= checkLogin();
562
- await preflight;
563
655
  let total = 0;
564
656
  for (const [channel, files] of byChannel) {
565
- for (const item of await itemsFor(channel, files)) {
566
- // Documents over the cap stay unmarked in the worklist for a later pass.
657
+ // With the cap spent and nothing landed here, there is nothing to list:
658
+ // the channel's backlog stays where it is (worklist or pending file).
659
+ if (docsSent >= maxDocs && files.length === 0)
660
+ continue;
661
+ const work = await itemsFor(channel, files, Math.max(0, maxDocs - docsSent));
662
+ // No worklist: every candidate is pending until it is processed, so
663
+ // documents over the cap (or left by a failed call) wait for a later run.
664
+ let pending = work.pending ? work.items.map((item) => item.file) : undefined;
665
+ if (pending !== undefined)
666
+ await writePendingSignals(vaultRoot, channel, pending, beforeWrite);
667
+ for (const item of work.items) {
668
+ // Documents over the cap stay in the worklist or the pending file for a later run.
567
669
  if (docsSent >= maxDocs)
568
- return total;
670
+ break;
671
+ // Log in once per run, and only when there is a document to send.
672
+ preflight ??= checkLogin();
673
+ await preflight;
569
674
  docsSent += 1;
570
675
  const input = await buildSignalsInput(vaultRoot, item.file);
571
676
  if (input.fallback !== undefined) {
@@ -575,6 +680,10 @@ export function createClaudeSignalsPass(options) {
575
680
  if (item.fromWorklist) {
576
681
  await runWorklist(["--company", options.company, "--channel", channel, "--mark-done", item.file]);
577
682
  }
683
+ else if (pending !== undefined) {
684
+ pending = pending.filter((file) => file !== item.file);
685
+ await writePendingSignals(vaultRoot, channel, pending, beforeWrite);
686
+ }
578
687
  }
579
688
  }
580
689
  return total;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Queue landed hot-channel documents for the local signals pass again, and
3
+ * run the pass now (`hq sources signals`). Used when a run's documents never
4
+ * got signals: for example, the 20-document cap of an older build, or a pass
5
+ * that failed. A sync cursor never offers those documents again, so this is
6
+ * the way to reach them.
7
+ *
8
+ * Where a document is queued depends on its channel:
9
+ * - no source.yaml (the usual hot channel, such as `meeting-events`): the
10
+ * pass's own pending file, `sources/_signals-pending/{channel}.json`;
11
+ * - a source.yaml: the ontology worklist's `--redo`, which drops the item
12
+ * from the worklist's ledgers so the next listing includes it.
13
+ */
14
+ import type { HotChannelRunResult } from "./loop.js";
15
+ /** Bad input (key, channel or date): the command exits 2. */
16
+ export declare class SignalsRedoUsageError extends Error {
17
+ constructor(message: string);
18
+ }
19
+ export interface SignalsRedoSelection {
20
+ /** Channels with no source.yaml: keys for the pass's pending file. */
21
+ pending: Map<string, string[]>;
22
+ /** Channels with a source.yaml: keys for the worklist's `--redo`. */
23
+ worklist: Map<string, string[]>;
24
+ notices: string[];
25
+ }
26
+ export interface SelectSignalsRedoInput {
27
+ vaultRoot: string;
28
+ /** Landed keys (`sources/{channel}/{id}.md`) to process again, signals or not. */
29
+ keys?: readonly string[];
30
+ /** Landed documents whose `when` (else `created_at`) is at or after this, with no signals yet. */
31
+ since?: Date;
32
+ /** Limit `since` to one channel. */
33
+ channel?: string;
34
+ }
35
+ /**
36
+ * Parse `--since`: a bare `YYYY-MM-DD` is local midnight, so a day's meetings
37
+ * are the ones in the machine's own time zone. Anything else must be an ISO
38
+ * date-time.
39
+ */
40
+ export declare function parseSince(value: string): Date;
41
+ /** Every `source_ref` in the company's signal index (`signals/_index/*.json`). */
42
+ export declare function signalledSourceRefs(vaultRoot: string): Promise<Set<string>>;
43
+ /** Which landed documents to process again, grouped by how each is queued. */
44
+ export declare function selectSignalsRedo(input: SelectSignalsRedoInput): Promise<SignalsRedoSelection>;
45
+ /**
46
+ * Queue the selection: pending keys into the pass's pending file, worklist
47
+ * keys through `ontology-source-worklist.sh --redo`. Throws when the worklist
48
+ * refuses (an older script has no `--redo`).
49
+ */
50
+ export declare function queueSignalsRedo(input: {
51
+ hqRoot: string;
52
+ company: string;
53
+ selection: SignalsRedoSelection;
54
+ beforeWrite?: () => Promise<void>;
55
+ worklistScript?: string;
56
+ env?: NodeJS.ProcessEnv;
57
+ }): Promise<void>;
58
+ /** A run result with nothing landed, so the signals pass works through one channel's backlog. */
59
+ export declare function backlogRunResult(channel: string): HotChannelRunResult;
60
+ //# sourceMappingURL=signals-redo.d.ts.map
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Queue landed hot-channel documents for the local signals pass again, and
3
+ * run the pass now (`hq sources signals`). Used when a run's documents never
4
+ * got signals: for example, the 20-document cap of an older build, or a pass
5
+ * that failed. A sync cursor never offers those documents again, so this is
6
+ * the way to reach them.
7
+ *
8
+ * Where a document is queued depends on its channel:
9
+ * - no source.yaml (the usual hot channel, such as `meeting-events`): the
10
+ * pass's own pending file, `sources/_signals-pending/{channel}.json`;
11
+ * - a source.yaml: the ontology worklist's `--redo`, which drops the item
12
+ * from the worklist's ledgers so the next listing includes it.
13
+ */
14
+ import * as fs from "node:fs/promises";
15
+ import * as path from "node:path";
16
+ import { assertSafeSegment } from "./landing.js";
17
+ import { channelOf, landedDocTime, queuePendingSignals, runBounded, splitFrontmatter, WORKLIST_TIMEOUT_MS, } from "./signals-pass.js";
18
+ /** Bad input (key, channel or date): the command exits 2. */
19
+ export class SignalsRedoUsageError extends Error {
20
+ constructor(message) {
21
+ super(message);
22
+ this.name = "SignalsRedoUsageError";
23
+ }
24
+ }
25
+ /**
26
+ * Parse `--since`: a bare `YYYY-MM-DD` is local midnight, so a day's meetings
27
+ * are the ones in the machine's own time zone. Anything else must be an ISO
28
+ * date-time.
29
+ */
30
+ export function parseSince(value) {
31
+ const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
32
+ const date = day ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3])) : new Date(Date.parse(value));
33
+ if (Number.isNaN(date.getTime()))
34
+ throw new SignalsRedoUsageError(`--since must be YYYY-MM-DD or an ISO date-time: ${value}`);
35
+ return date;
36
+ }
37
+ async function hasSourceYaml(vaultRoot, channel) {
38
+ try {
39
+ return (await fs.stat(path.join(vaultRoot, "sources", channel, "source.yaml"))).isFile();
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function isFile(file) {
46
+ try {
47
+ return (await fs.stat(file)).isFile();
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ /** Every `source_ref` in the company's signal index (`signals/_index/*.json`). */
54
+ export async function signalledSourceRefs(vaultRoot) {
55
+ const dir = path.join(vaultRoot, "signals", "_index");
56
+ const refs = new Set();
57
+ let names;
58
+ try {
59
+ names = await fs.readdir(dir);
60
+ }
61
+ catch {
62
+ return refs;
63
+ }
64
+ for (const name of names) {
65
+ if (!name.endsWith(".json"))
66
+ continue;
67
+ try {
68
+ const rows = JSON.parse(await fs.readFile(path.join(dir, name), "utf8"));
69
+ if (!Array.isArray(rows))
70
+ continue;
71
+ for (const row of rows) {
72
+ const ref = row?.source_ref;
73
+ if (typeof ref === "string")
74
+ refs.add(ref);
75
+ }
76
+ }
77
+ catch {
78
+ // An unreadable index day says nothing about which sources have signals.
79
+ }
80
+ }
81
+ return refs;
82
+ }
83
+ function add(map, channel, key) {
84
+ const list = map.get(channel) ?? [];
85
+ if (!list.includes(key))
86
+ list.push(key);
87
+ map.set(channel, list);
88
+ }
89
+ /** Which landed documents to process again, grouped by how each is queued. */
90
+ export async function selectSignalsRedo(input) {
91
+ const { vaultRoot } = input;
92
+ const selection = { pending: new Map(), worklist: new Map(), notices: [] };
93
+ if (input.channel !== undefined) {
94
+ try {
95
+ assertSafeSegment(input.channel, "channel");
96
+ }
97
+ catch {
98
+ throw new SignalsRedoUsageError(`not a channel name: ${input.channel}`);
99
+ }
100
+ }
101
+ const worklistChannel = new Map();
102
+ const usesWorklist = async (channel) => {
103
+ if (!worklistChannel.has(channel))
104
+ worklistChannel.set(channel, await hasSourceYaml(vaultRoot, channel));
105
+ return worklistChannel.get(channel) === true;
106
+ };
107
+ for (const key of input.keys ?? []) {
108
+ const channel = channelOf(key);
109
+ if (!channel || channel.startsWith("_"))
110
+ throw new SignalsRedoUsageError(`--key must be sources/<channel>/<id>.md: ${key}`);
111
+ if (!(await isFile(path.join(vaultRoot, key))))
112
+ throw new SignalsRedoUsageError(`no landed document at ${key}`);
113
+ add((await usesWorklist(channel)) ? selection.worklist : selection.pending, channel, key);
114
+ }
115
+ if (input.since !== undefined) {
116
+ const since = input.since.getTime();
117
+ const signalled = await signalledSourceRefs(vaultRoot);
118
+ let channels;
119
+ if (input.channel !== undefined) {
120
+ channels = [input.channel];
121
+ }
122
+ else {
123
+ try {
124
+ const entries = await fs.readdir(path.join(vaultRoot, "sources"), { withFileTypes: true });
125
+ channels = entries
126
+ .filter((e) => e.isDirectory() && !e.name.startsWith("_") && !e.name.startsWith("."))
127
+ .map((e) => e.name)
128
+ .sort();
129
+ }
130
+ catch {
131
+ channels = [];
132
+ }
133
+ }
134
+ const withWorklist = [];
135
+ for (const channel of channels) {
136
+ if (await usesWorklist(channel)) {
137
+ // The worklist already lists its channel's unprocessed items, and the pass drains them.
138
+ withWorklist.push(channel);
139
+ continue;
140
+ }
141
+ let names;
142
+ try {
143
+ names = (await fs.readdir(path.join(vaultRoot, "sources", channel))).filter((n) => n.endsWith(".md")).sort();
144
+ }
145
+ catch {
146
+ continue;
147
+ }
148
+ for (const name of names) {
149
+ const key = `sources/${channel}/${name}`;
150
+ if (channelOf(key) !== channel || signalled.has(key))
151
+ continue;
152
+ let text;
153
+ try {
154
+ text = await fs.readFile(path.join(vaultRoot, key), "utf8");
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (landedDocTime(splitFrontmatter(text).frontmatter) >= since)
160
+ add(selection.pending, channel, key);
161
+ }
162
+ }
163
+ if (withWorklist.length > 0) {
164
+ selection.notices.push(`Skipped for --since (their worklist lists unprocessed items itself; use --key to redo one): ${withWorklist.join(", ")}`);
165
+ }
166
+ }
167
+ return selection;
168
+ }
169
+ /**
170
+ * Queue the selection: pending keys into the pass's pending file, worklist
171
+ * keys through `ontology-source-worklist.sh --redo`. Throws when the worklist
172
+ * refuses (an older script has no `--redo`).
173
+ */
174
+ export async function queueSignalsRedo(input) {
175
+ const vaultRoot = path.join(input.hqRoot, "companies", input.company);
176
+ for (const [channel, keys] of input.selection.pending) {
177
+ await queuePendingSignals(vaultRoot, channel, keys, input.beforeWrite);
178
+ }
179
+ const worklist = input.worklistScript ?? path.join(input.hqRoot, "core", "scripts", "ontology-source-worklist.sh");
180
+ for (const [channel, keys] of input.selection.worklist) {
181
+ await input.beforeWrite?.();
182
+ const run = await runBounded("bash", [worklist, "--company", input.company, "--channel", channel, ...keys.flatMap((key) => ["--redo", key])], { timeoutMs: WORKLIST_TIMEOUT_MS, cwd: input.hqRoot, env: { ...(input.env ?? process.env), HQ_ROOT: input.hqRoot } });
183
+ if (run.timedOut || run.code !== 0) {
184
+ const why = run.timedOut ? "timed out" : run.stderr.trim().slice(0, 200) || `exit ${String(run.code)}`;
185
+ throw new Error(`ontology worklist --redo failed for sources/${channel}: ${why}`);
186
+ }
187
+ }
188
+ }
189
+ /** A run result with nothing landed, so the signals pass works through one channel's backlog. */
190
+ export function backlogRunResult(channel) {
191
+ return {
192
+ adapterId: "signals-redo",
193
+ provider: "signals-redo",
194
+ channel,
195
+ connectionId: null,
196
+ transport: null,
197
+ landed: 0,
198
+ duplicates: 0,
199
+ stale: 0,
200
+ rejected: 0,
201
+ pages: 0,
202
+ landedKeys: [],
203
+ cursorBefore: null,
204
+ cursorAfter: null,
205
+ };
206
+ }
207
+ //# sourceMappingURL=signals-redo.js.map
@@ -161,6 +161,12 @@ export interface RunSignalsOptions {
161
161
  signalsAlert?: SignalsRunAlertSenders;
162
162
  /** Lock liveness check; defaults to process.kill(pid, 0). */
163
163
  isPidAlive?: (pid: number) => boolean;
164
+ /**
165
+ * Source items (`sources/<channel>/<file>.md`) to process again: the extract
166
+ * step re-queues each with the worklist's `--redo` before listing its
167
+ * channel. Needs `company`.
168
+ */
169
+ redo?: readonly string[];
164
170
  }
165
171
  /** Bad input from the caller (company slug, missing company): exit 2. */
166
172
  export declare class SignalsUsageError extends Error {
@@ -18,7 +18,7 @@ import { guardedWrite, WriteGuardError } from "./write-guard.js";
18
18
  import { acquireCompanyLock, withSummaryLock } from "./run-lock.js";
19
19
  import { gardenStep } from "./steps/garden.js";
20
20
  import { adaptersStep } from "./steps/adapters.js";
21
- import { extractStep } from "./steps/extract.js";
21
+ import { extractStep, redoItemChannel } from "./steps/extract.js";
22
22
  import { uploadStep } from "./steps/upload.js";
23
23
  import { raiseSignalsRunAlerts } from "./failure-alert.js";
24
24
  export const SIGNALS_TRIGGERS = ["job", "chat", "manual", "brief"];
@@ -255,6 +255,14 @@ export async function runSignals(opts) {
255
255
  if (opts.company !== undefined && !fs.existsSync(path.join(hqRoot, "companies", opts.company))) {
256
256
  throw new SignalsUsageError(`no company directory: companies/${opts.company}`);
257
257
  }
258
+ const redo = opts.redo ?? [];
259
+ if (redo.length > 0) {
260
+ if (opts.company === undefined)
261
+ throw new SignalsUsageError("--redo needs --company");
262
+ const bad = redo.find((f) => redoItemChannel(f) === null);
263
+ if (bad !== undefined)
264
+ throw new SignalsUsageError(`--redo item must be sources/<channel>/<file>.md: ${bad}`);
265
+ }
258
266
  const companies = opts.company !== undefined ? [opts.company] : listCompanies(hqRoot);
259
267
  const start = now();
260
268
  const date = localDate(start);
@@ -397,6 +405,7 @@ export async function runSignals(opts) {
397
405
  ...(opts.uploadDeps ? { uploadDeps: opts.uploadDeps } : {}),
398
406
  ...(opts.isFlagOn ? { isFlagOn: opts.isFlagOn } : {}),
399
407
  ...(opts.registeredAdapterIds ? { registeredAdapterIds: opts.registeredAdapterIds } : {}),
408
+ ...(redo.length > 0 ? { redo } : {}),
400
409
  };
401
410
  let stepRec;
402
411
  const planner = stepImpls[name].dryRun;
@@ -31,6 +31,11 @@ interface WorkItem {
31
31
  }
32
32
  /** First personal bot under ~/.hq/bots (by name) with a readable config. */
33
33
  export declare function defaultResolveBotRuntime(root?: string): BotRuntimeId | null;
34
+ /**
35
+ * The channel of a `--redo` item, which must be written `sources/<channel>/<file>.md`,
36
+ * or null when it is not in that form.
37
+ */
38
+ export declare function redoItemChannel(item: string): string | null;
34
39
  /** Declared source channels with run: local (or any run: when forced). */
35
40
  export declare function listSourceChannels(hqRoot: string, company: string, force?: boolean): SourceChannel[];
36
41
  /** Shell single-quote a value so a generated script carries it as data. */
@@ -104,6 +104,15 @@ function yamlField(text, key) {
104
104
  return "";
105
105
  return m[1].replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "");
106
106
  }
107
+ const REDO_ITEM_RE = /^sources\/([a-zA-Z0-9][a-zA-Z0-9._-]*)\/([^/.][^/]*\.md)$/;
108
+ /**
109
+ * The channel of a `--redo` item, which must be written `sources/<channel>/<file>.md`,
110
+ * or null when it is not in that form.
111
+ */
112
+ export function redoItemChannel(item) {
113
+ const m = REDO_ITEM_RE.exec(item);
114
+ return m && !m[2].includes("\0") ? m[1] : null;
115
+ }
107
116
  /** Declared source channels with run: local (or any run: when forced). */
108
117
  export function listSourceChannels(hqRoot, company, force = false) {
109
118
  const dir = path.join(hqRoot, "companies", company, "sources");
@@ -573,8 +582,11 @@ export function createExtractStep(options = {}) {
573
582
  async run(ctx) {
574
583
  const empty = { modelTurns: 0, estimatedSpendUsd: 0 };
575
584
  const channels = listSourceChannels(ctx.hqRoot, ctx.company, options.force === true);
576
- if (channels.length === 0)
585
+ // A --redo item whose channel this step does not run cannot be re-queued here.
586
+ const unrunRedo = (ctx.redo ?? []).filter((f) => !channels.some((c) => c.channel === redoItemChannel(f)));
587
+ if (channels.length === 0 && unrunRedo.length === 0) {
577
588
  return { status: "skipped", itemsIn: 0, itemsOut: 0, skippedReason: "no-sources", ...empty };
589
+ }
578
590
  const runtime = (options.resolveBotRuntime ?? defaultResolveBotRuntime)() ?? options.runtime ?? "claude";
579
591
  if (runtime === "grok") {
580
592
  return {
@@ -609,7 +621,12 @@ export function createExtractStep(options = {}) {
609
621
  let processedAny = false;
610
622
  let budgetStop = false;
611
623
  let errorKind;
612
- const failures = [];
624
+ const failures = unrunRedo.map((file) => ({
625
+ file,
626
+ error: "redo: not a source channel this run processes (needs a source.yaml with run: local, or --force for run: cloud)",
627
+ }));
628
+ if (unrunRedo.length > 0)
629
+ errorKind = "other";
613
630
  const skippedItems = [];
614
631
  const sourcesReal = fs.realpathSync(path.join(ctx.hqRoot, "companies", ctx.company, "sources"));
615
632
  outer: for (const ch of channels) {
@@ -650,6 +667,17 @@ export function createExtractStep(options = {}) {
650
667
  continue;
651
668
  }
652
669
  }
670
+ // --redo: drop the named items from the worklist's ledgers so this listing includes them again.
671
+ const redo = (ctx.redo ?? []).filter((f) => redoItemChannel(f) === ch.channel);
672
+ if (redo.length > 0) {
673
+ const rd = ctx.spawn("bash", [worklist, ...base, ...redo.flatMap((f) => ["--redo", f])], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
674
+ if (rd.error || rd.status !== 0) {
675
+ failures.push({ file: `sources/${ch.channel}`, error: `redo failed: ${(rd.error?.message ?? rd.stderr.trim()).slice(0, 200) || `exit ${String(rd.status)}`}` });
676
+ if (!errorKind)
677
+ errorKind = "other";
678
+ continue;
679
+ }
680
+ }
653
681
  const wl = ctx.spawn("bash", [worklist, ...base, "--limit", String(limit)], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
654
682
  if (wl.error || wl.status !== 0) {
655
683
  failures.push({ file: `sources/${ch.channel}`, error: `worklist failed: ${(wl.error?.message ?? wl.stderr.trim()).slice(0, 200) || `exit ${String(wl.status)}`}` });
@@ -56,6 +56,11 @@ export interface StepContext {
56
56
  * evaluator with the signed-in session.
57
57
  */
58
58
  isFlagOn?: AdapterFlagReader;
59
+ /**
60
+ * Source items (`sources/<channel>/<file>.md`) the extract step re-queues
61
+ * with the worklist's `--redo` before it lists that channel.
62
+ */
63
+ redo?: readonly string[];
59
64
  }
60
65
  export interface StepResult {
61
66
  status: StepStatus;