@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.
- package/CHANGELOG.md +33 -0
- package/dist/command-catalog.generated.d.ts +49 -0
- package/dist/command-catalog.generated.js +60 -0
- package/dist/commands/signals-run.d.ts +1 -0
- package/dist/commands/signals-run.js +5 -0
- package/dist/commands/sources.d.ts +47 -0
- package/dist/commands/sources.js +157 -1
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +35 -2
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +1 -0
- package/dist/lib/hot-channels/signals-pass.d.ts +33 -2
- package/dist/lib/hot-channels/signals-pass.js +142 -33
- package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
- package/dist/lib/hot-channels/signals-redo.js +207 -0
- package/dist/lib/signals/engine.d.ts +6 -0
- package/dist/lib/signals/engine.js +10 -1
- package/dist/lib/signals/steps/extract.d.ts +5 -0
- package/dist/lib/signals/steps/extract.js +30 -2
- package/dist/lib/signals/steps/types.d.ts +5 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +7 -7
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The local signals pass (US-011). After
|
|
3
|
-
* pass takes the
|
|
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
|
-
|
|
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
|
-
/**
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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" ||
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|