@indigoai-us/hq-cli 5.345.191 → 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 +431 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +145 -19
- package/dist/command-catalog.generated.js +184 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +4 -3
- package/dist/commands/bot.js +265 -140
- package/dist/commands/core-jobs-schedule.js +10 -0
- package/dist/commands/daemon.js +37 -0
- package/dist/commands/files.d.ts +41 -6
- package/dist/commands/files.js +108 -27
- package/dist/commands/pack-start-bot.d.ts +4 -6
- package/dist/commands/pack-start-bot.js +4 -8
- package/dist/commands/signals-run.d.ts +46 -0
- package/dist/commands/signals-run.js +180 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +58 -0
- package/dist/commands/sources.js +294 -26
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -2
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.js +35 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/api.d.ts +3 -2
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-cron.d.ts +121 -0
- package/dist/lib/bot/fleet-cron.js +319 -0
- package/dist/lib/bot/fleet-local.d.ts +93 -1
- package/dist/lib/bot/fleet-local.js +546 -34
- package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
- package/dist/lib/bot/fleet-owner-companies.js +30 -0
- package/dist/lib/bot/fleet-watch.d.ts +25 -0
- package/dist/lib/bot/fleet-watch.js +51 -0
- package/dist/lib/bot/index.d.ts +1 -1
- package/dist/lib/bot/index.js +1 -1
- package/dist/lib/bot/job-run.d.ts +5 -4
- package/dist/lib/bot/job-run.js +5 -4
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
- package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
- package/dist/lib/bot/local-jobs.d.ts +13 -2
- package/dist/lib/bot/local-jobs.js +17 -4
- package/dist/lib/bot/progress.d.ts +14 -0
- package/dist/lib/bot/progress.js +47 -1
- package/dist/lib/bot/run.d.ts +55 -2
- package/dist/lib/bot/run.js +192 -10
- package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
- package/dist/lib/bot/setup-tool-offer.js +16 -0
- package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
- package/dist/lib/bot/signals-engine-flag.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +29 -9
- package/dist/lib/daemon/legacy.d.ts +6 -0
- package/dist/lib/daemon/legacy.js +16 -10
- package/dist/lib/daemon/run.d.ts +1 -0
- package/dist/lib/daemon/run.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.d.ts +13 -1
- package/dist/lib/flag-registry.js +46 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
- package/dist/lib/hot-channels/adapters/attio.js +367 -29
- package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
- package/dist/lib/hot-channels/adapters/gmail.js +59 -2
- package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
- package/dist/lib/hot-channels/adapters/linear.js +71 -1
- package/dist/lib/hot-channels/adapters/meetings.js +4 -1
- package/dist/lib/hot-channels/adapters/slack.js +7 -3
- package/dist/lib/hot-channels/alert.js +1 -1
- package/dist/lib/hot-channels/contract.d.ts +11 -0
- package/dist/lib/hot-channels/contract.js +1 -0
- package/dist/lib/hot-channels/cursors.js +1 -1
- package/dist/lib/hot-channels/landing.d.ts +6 -2
- package/dist/lib/hot-channels/landing.js +13 -6
- package/dist/lib/hot-channels/ledger.js +2 -0
- package/dist/lib/hot-channels/loop.d.ts +6 -0
- package/dist/lib/hot-channels/loop.js +19 -4
- package/dist/lib/hot-channels/registry.js +9 -1
- package/dist/lib/hot-channels/run.d.ts +18 -0
- package/dist/lib/hot-channels/run.js +15 -4
- package/dist/lib/hot-channels/signals-pass.d.ts +34 -2
- package/dist/lib/hot-channels/signals-pass.js +149 -38
- package/dist/lib/hot-channels/signals-redo.d.ts +60 -0
- package/dist/lib/hot-channels/signals-redo.js +207 -0
- package/dist/lib/hot-channels/sync-lock.js +22 -6
- package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
- package/dist/lib/mesh/live/daemon/install.js +117 -30
- package/dist/lib/signals/adapters-catalog.d.ts +77 -0
- package/dist/lib/signals/adapters-catalog.js +194 -0
- package/dist/lib/signals/engine.d.ts +202 -0
- package/dist/lib/signals/engine.js +504 -0
- package/dist/lib/signals/failure-alert.d.ts +89 -0
- package/dist/lib/signals/failure-alert.js +175 -0
- package/dist/lib/signals/local-date.d.ts +3 -0
- package/dist/lib/signals/local-date.js +6 -0
- package/dist/lib/signals/projection.d.ts +42 -0
- package/dist/lib/signals/projection.js +67 -0
- package/dist/lib/signals/reply.d.ts +112 -0
- package/dist/lib/signals/reply.js +368 -0
- package/dist/lib/signals/run-lock.d.ts +36 -0
- package/dist/lib/signals/run-lock.js +148 -0
- package/dist/lib/signals/run-record.d.ts +24 -0
- package/dist/lib/signals/run-record.js +82 -0
- package/dist/lib/signals/sanitize.d.ts +105 -0
- package/dist/lib/signals/sanitize.js +439 -0
- package/dist/lib/signals/settings.d.ts +10 -0
- package/dist/lib/signals/settings.js +48 -0
- package/dist/lib/signals/steps/adapters.d.ts +14 -0
- package/dist/lib/signals/steps/adapters.js +156 -0
- package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
- package/dist/lib/signals/steps/entity-similarity.js +47 -0
- package/dist/lib/signals/steps/extract.d.ts +118 -0
- package/dist/lib/signals/steps/extract.js +793 -0
- package/dist/lib/signals/steps/garden.d.ts +24 -0
- package/dist/lib/signals/steps/garden.js +250 -0
- package/dist/lib/signals/steps/types.d.ts +117 -0
- package/dist/lib/signals/steps/types.js +4 -0
- package/dist/lib/signals/steps/upload.d.ts +43 -0
- package/dist/lib/signals/steps/upload.js +324 -0
- package/dist/lib/signals/write-guard.d.ts +63 -0
- package/dist/lib/signals/write-guard.js +287 -0
- package/dist/utils/resident-package-preload.js +16 -0
- package/dist/utils/vault-api.d.ts +1 -0
- package/dist/utils/vault-api.js +1 -1
- package/package.json +9 -9
- package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
- package/dist/lib/bot/fleet-default-flag.js +0 -127
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract step: run the ontology worker's process-source skill on each
|
|
3
|
+
* unprocessed item of every local-run source channel, one model turn per
|
|
4
|
+
* item, under the main local bot's coding-tool login.
|
|
5
|
+
*
|
|
6
|
+
* Flow per channel: ontology-source-worklist.sh (run by the step, never by
|
|
7
|
+
* the model) -> one turn per item that writes candidates only through a
|
|
8
|
+
* per-item wrapper of ontology-candidate.sh -> validate the new candidates ->
|
|
9
|
+
* --mark-done only when the turn succeeded.
|
|
10
|
+
*
|
|
11
|
+
* Isolation. A turn reads an untrusted transcript, so the runtime flags, not
|
|
12
|
+
* HQ's settings, decide what the model can do:
|
|
13
|
+
* - cwd is a fresh temp dir outside HQ holding the wrapper. The wrapper pins
|
|
14
|
+
* --company, --audience and --source-ref to the item and refuses
|
|
15
|
+
* --body-file, so a candidate can only carry text the model writes.
|
|
16
|
+
* - Reads are limited to the item's channel folder in ctx.company.
|
|
17
|
+
* - After the turn every company's _candidates is diffed, and only the new
|
|
18
|
+
* files carrying this item's source_ref are touched. One outside ctx.company
|
|
19
|
+
* is deleted and the item fails (unless that company has its own source item
|
|
20
|
+
* of the same name); one in ctx.company that fails validation is deleted.
|
|
21
|
+
* New files from other writers (other companies' bots, other sources,
|
|
22
|
+
* garden's moves) are left alone.
|
|
23
|
+
* - Worklist items whose path or audience could break out of the prompt or
|
|
24
|
+
* the channel folder are refused before any turn.
|
|
25
|
+
*
|
|
26
|
+
* Runtime flags, checked 2026-11-01 against `claude --help` (2.1.316) and
|
|
27
|
+
* `codex exec --help` (0.153.4) on Corey's Mac:
|
|
28
|
+
* claude -p <prompt> --output-format stream-json --verbose
|
|
29
|
+
* --permission-mode dontAsk --permission-prompts none --restricted
|
|
30
|
+
* --tools Read,Bash --strict-mcp-config --mcp-config {"mcpServers":{}}
|
|
31
|
+
* --disable-slash-commands --no-session-persistence
|
|
32
|
+
* --max-turns <n> --max-budget-usd <x> --add-dir <channel dir>
|
|
33
|
+
* --allowedTools Read(//<channel dir>/**) "Bash(bash <wrapper> *)"
|
|
34
|
+
* dontAsk denies every tool call no allow rule covers. --restricted ignores
|
|
35
|
+
* the user, project and local settings files (so no HQ or user allow rules,
|
|
36
|
+
* hooks or plugins) and confines file tools to the working directories.
|
|
37
|
+
* --tools leaves only Read and Bash in the tool set; --strict-mcp-config with
|
|
38
|
+
* an empty --mcp-config loads no MCP server. (`--bare` is not used: it needs
|
|
39
|
+
* an API key and skips the login.) An older claude without --restricted
|
|
40
|
+
* exits on the unknown flag, so the item fails and is retried.
|
|
41
|
+
* codex -a never exec --json --skip-git-repo-check --ephemeral
|
|
42
|
+
* --ignore-user-config --ignore-rules -C <turn dir>
|
|
43
|
+
* -c default_permissions="hq_signals_extract"
|
|
44
|
+
* -c permissions.hq_signals_extract.filesystem={...}
|
|
45
|
+
* -c permissions.hq_signals_extract.network.enabled=false <prompt>
|
|
46
|
+
* The permission profile reads only the platform minimum, the codex install,
|
|
47
|
+
* the turn dir, core/scripts, the company's knowledge preferences and the
|
|
48
|
+
* item's channel folder, and writes only the company's _candidates folders.
|
|
49
|
+
* --ignore-user-config drops ~/.codex/config.toml (its MCP servers);
|
|
50
|
+
* --ignore-rules drops execpolicy allow rules. Codex has no turn cap flag;
|
|
51
|
+
* the per-item timeout is its only limit, and it reports no cost.
|
|
52
|
+
* grok: src/lib/bot/runtime/grok.ts has no restricted tool or sandbox
|
|
53
|
+
* mode, so the step refuses it.
|
|
54
|
+
*/
|
|
55
|
+
import * as fs from "node:fs";
|
|
56
|
+
import * as os from "node:os";
|
|
57
|
+
import * as path from "node:path";
|
|
58
|
+
import { readBotConfig, effectiveBotKind, isBotRuntimeId } from "../../bot/config.js";
|
|
59
|
+
import { botsRoot } from "../../bot/paths.js";
|
|
60
|
+
import { CLAUDE_LOGGED_OUT_RE } from "../../hot-channels/signals-pass.js";
|
|
61
|
+
import { assertAllowedPath, assertNoEscapingLinks, guardedRemove, realRelPath } from "../write-guard.js";
|
|
62
|
+
export const EXTRACT_SIGNAL_TYPES = [
|
|
63
|
+
"action_item",
|
|
64
|
+
"commitment",
|
|
65
|
+
"decision",
|
|
66
|
+
"risk",
|
|
67
|
+
"question",
|
|
68
|
+
"key_point",
|
|
69
|
+
"participant_contribution",
|
|
70
|
+
"summary",
|
|
71
|
+
];
|
|
72
|
+
const ENTITY_TYPES = ["person", "project", "company", "concept"];
|
|
73
|
+
export const DEFAULT_WORKLIST_LIMIT = 20;
|
|
74
|
+
export const DEFAULT_TURN_TIMEOUT_MS = 10 * 60_000;
|
|
75
|
+
const SCRIPT_TIMEOUT_MS = 60_000;
|
|
76
|
+
export const CODEX_PERMISSION_PROFILE = "hq_signals_extract";
|
|
77
|
+
export const CANDIDATE_WRAPPER_NAME = "write-candidate.sh";
|
|
78
|
+
/** The only tools a claude turn may use: Read inside the channel folder and the pinned wrapper. */
|
|
79
|
+
export function claudeAllowedTools(channelDir, wrapperPath) {
|
|
80
|
+
// A rule path that starts with `//` is absolute.
|
|
81
|
+
return [`Read(/${channelDir}/**)`, `Bash(bash ${wrapperPath} *)`];
|
|
82
|
+
}
|
|
83
|
+
/** First personal bot under ~/.hq/bots (by name) with a readable config. */
|
|
84
|
+
export function defaultResolveBotRuntime(root = botsRoot()) {
|
|
85
|
+
if (!fs.existsSync(root))
|
|
86
|
+
return null;
|
|
87
|
+
const names = fs.readdirSync(root).sort();
|
|
88
|
+
const configs = names
|
|
89
|
+
.map((n) => {
|
|
90
|
+
try {
|
|
91
|
+
return readBotConfig(path.join(root, n));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
.filter((c) => c !== null);
|
|
98
|
+
const main = configs.find((c) => effectiveBotKind(c) === "personal") ?? null;
|
|
99
|
+
return main && isBotRuntimeId(main.runtime) ? main.runtime : null;
|
|
100
|
+
}
|
|
101
|
+
function yamlField(text, key) {
|
|
102
|
+
const m = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(text);
|
|
103
|
+
if (!m)
|
|
104
|
+
return "";
|
|
105
|
+
return m[1].replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "");
|
|
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
|
+
}
|
|
116
|
+
/** Declared source channels with run: local (or any run: when forced). */
|
|
117
|
+
export function listSourceChannels(hqRoot, company, force = false) {
|
|
118
|
+
const dir = path.join(hqRoot, "companies", company, "sources");
|
|
119
|
+
if (!fs.existsSync(dir))
|
|
120
|
+
return [];
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
123
|
+
if (!e.isDirectory())
|
|
124
|
+
continue;
|
|
125
|
+
const y = path.join(dir, e.name, "source.yaml");
|
|
126
|
+
if (!fs.existsSync(y))
|
|
127
|
+
continue;
|
|
128
|
+
const run = yamlField(fs.readFileSync(y, "utf8"), "run");
|
|
129
|
+
if (run === "local")
|
|
130
|
+
out.push({ channel: e.name, force: false });
|
|
131
|
+
else if (force)
|
|
132
|
+
out.push({ channel: e.name, force: true });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** Shell single-quote a value so a generated script carries it as data. */
|
|
137
|
+
export function shellQuote(value) {
|
|
138
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
139
|
+
}
|
|
140
|
+
/** True when text has a control character, a double quote, $, a backtick or a backslash. */
|
|
141
|
+
function hasUnsafeText(text) {
|
|
142
|
+
for (const ch of text) {
|
|
143
|
+
const code = ch.charCodeAt(0);
|
|
144
|
+
if (code < 0x20 || code === 0x7f || ch === '"' || ch === "`" || ch === "$" || ch === "\\")
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const AUDIENCE_ENTRY = /^[A-Za-z0-9._%+@-]+$/;
|
|
150
|
+
/**
|
|
151
|
+
* Refuse a worklist item whose path or audience could break out of the prompt
|
|
152
|
+
* or the channel folder. Returns the reason, or null when the item is safe.
|
|
153
|
+
*/
|
|
154
|
+
export function unsafeItemReason(item, channel) {
|
|
155
|
+
if (typeof item.file !== "string" || item.file === "")
|
|
156
|
+
return "no file";
|
|
157
|
+
const prefix = `sources/${channel}/`;
|
|
158
|
+
const name = item.file.slice(prefix.length);
|
|
159
|
+
if (!item.file.startsWith(prefix) || name === "" || name.includes("/") || name === "." || name === "..") {
|
|
160
|
+
return `file is not directly in sources/${channel}`;
|
|
161
|
+
}
|
|
162
|
+
if (hasUnsafeText(item.file))
|
|
163
|
+
return "file name has quote, $, backtick, backslash or control characters";
|
|
164
|
+
if (item.skip !== undefined)
|
|
165
|
+
return null;
|
|
166
|
+
if (!Array.isArray(item.audience) || item.audience.length === 0)
|
|
167
|
+
return "no audience";
|
|
168
|
+
for (const a of item.audience) {
|
|
169
|
+
if (typeof a !== "string" || !AUDIENCE_ENTRY.test(a))
|
|
170
|
+
return "audience entry is not an email, id or company";
|
|
171
|
+
}
|
|
172
|
+
if (item.audience_key !== undefined && !/^[A-Za-z0-9_-]+$/.test(item.audience_key))
|
|
173
|
+
return "bad audience_key";
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
export function buildExtractPrompt(input) {
|
|
177
|
+
const { item } = input;
|
|
178
|
+
const audience = (item.audience ?? []).join(",");
|
|
179
|
+
const ref = `${input.channel}:${path.basename(item.file)}`;
|
|
180
|
+
return [
|
|
181
|
+
"You are running the ontology worker's process-source skill unattended for ONE source item.",
|
|
182
|
+
"Follow the skill below, but only step 5 (Extract) for this single item. Do not build a worklist, do not mark the item done, do not run garden.",
|
|
183
|
+
"",
|
|
184
|
+
`COMPANY: ${input.company}`,
|
|
185
|
+
`CHANNEL: ${input.channel}`,
|
|
186
|
+
`ITEM: ${input.itemPath}`,
|
|
187
|
+
`AUDIENCE: ${audience}`,
|
|
188
|
+
`AUDIENCE_KEY: ${item.audience_key ?? ""}`,
|
|
189
|
+
`SOURCE_REF: ${ref}`,
|
|
190
|
+
"",
|
|
191
|
+
"Rules:",
|
|
192
|
+
input.runtime === "codex"
|
|
193
|
+
? "- Read the item with a shell command such as cat. Only files in the item's channel folder are readable."
|
|
194
|
+
: "- Read the item with the Read tool. Only files in the item's channel folder are readable.",
|
|
195
|
+
`- Write candidates ONLY with: bash ${input.wrapperPath} --kind signal|entity --type <type> --body "<text>"`,
|
|
196
|
+
"- That command already fixes the company, audience and source ref for this item. Do not pass --company, --audience, --source-ref or --body-file; it refuses them.",
|
|
197
|
+
`- Signal types (exactly these): ${EXTRACT_SIGNAL_TYPES.join(", ")}.`,
|
|
198
|
+
`- Entity types: ${ENTITY_TYPES.join(", ")}.`,
|
|
199
|
+
"- Every signal body must end with a citation: a one-sentence quote from the item or its timestamp, for example: (cite: \"we ship Friday\") or (cite: 00:12:31).",
|
|
200
|
+
"- No other command can run, and no other file can be written.",
|
|
201
|
+
"- When finished, reply with one line: the number of candidates written.",
|
|
202
|
+
"",
|
|
203
|
+
"--- process-source skill ---",
|
|
204
|
+
input.skillText.trim(),
|
|
205
|
+
].join("\n");
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The per-item candidate command. It pins --company, --audience and
|
|
209
|
+
* --source-ref (a repeated flag must match the pinned value), refuses
|
|
210
|
+
* --body-file and any other flag, and calls ontology-candidate.sh write.
|
|
211
|
+
*/
|
|
212
|
+
export function buildCandidateWrapper(input) {
|
|
213
|
+
const script = path.join(input.hqRoot, "core", "scripts", "ontology-candidate.sh");
|
|
214
|
+
return [
|
|
215
|
+
"#!/usr/bin/env bash",
|
|
216
|
+
"# Generated by hq signals run (extract step) for one source item.",
|
|
217
|
+
"set -euo pipefail",
|
|
218
|
+
`PIN_COMPANY=${shellQuote(input.company)}`,
|
|
219
|
+
`PIN_AUDIENCE=${shellQuote(input.audience)}`,
|
|
220
|
+
`PIN_REF=${shellQuote(input.sourceRef)}`,
|
|
221
|
+
`HQ=${shellQuote(input.hqRoot)}`,
|
|
222
|
+
`SCRIPT=${shellQuote(script)}`,
|
|
223
|
+
'die() { echo "write-candidate: $*" >&2; exit 2; }',
|
|
224
|
+
'pinned() { [ "$2" = "$3" ] || die "$1 is fixed to $3 for this item"; }',
|
|
225
|
+
'[ "${1:-}" = "write" ] && shift',
|
|
226
|
+
'kind="" type="" body=""',
|
|
227
|
+
'while [ $# -gt 0 ]; do',
|
|
228
|
+
' [ $# -ge 2 ] || die "$1 needs a value"',
|
|
229
|
+
' case "$1" in',
|
|
230
|
+
' --kind) kind="$2" ;;',
|
|
231
|
+
' --type) type="$2" ;;',
|
|
232
|
+
' --body) body="$2" ;;',
|
|
233
|
+
' --company) pinned --company "$2" "$PIN_COMPANY" ;;',
|
|
234
|
+
' --audience) pinned --audience "$2" "$PIN_AUDIENCE" ;;',
|
|
235
|
+
' --source-ref) pinned --source-ref "$2" "$PIN_REF" ;;',
|
|
236
|
+
' --body-file) die "--body-file is not allowed; pass the text with --body" ;;',
|
|
237
|
+
' *) die "unknown flag $1 (allowed: --kind, --type, --body)" ;;',
|
|
238
|
+
' esac',
|
|
239
|
+
' shift 2',
|
|
240
|
+
'done',
|
|
241
|
+
'case "$kind" in signal|entity) ;; *) die "--kind must be signal or entity" ;; esac',
|
|
242
|
+
'[ -n "$type" ] || die "--type is required"',
|
|
243
|
+
'[ -n "$body" ] || die "--body is required"',
|
|
244
|
+
// Re-checked on every call, right before the write: the turn can create a
|
|
245
|
+
// link after the step's preflight, so a link anywhere on the candidate
|
|
246
|
+
// path (or a store folder that resolves elsewhere) stops the write.
|
|
247
|
+
'REAL_HQ="$(cd "$HQ" && pwd -P)"',
|
|
248
|
+
'for store in signals ontology; do',
|
|
249
|
+
' rel="companies/$PIN_COMPANY/$store/_candidates"',
|
|
250
|
+
' for p in "companies" "companies/$PIN_COMPANY" "companies/$PIN_COMPANY/$store" "$rel"; do',
|
|
251
|
+
' [ -L "$HQ/$p" ] && die "refusing to write: $p is a link"',
|
|
252
|
+
' done',
|
|
253
|
+
' if [ -d "$HQ/$rel" ]; then',
|
|
254
|
+
' [ "$(cd "$HQ/$rel" && pwd -P)" = "$REAL_HQ/$rel" ] || die "refusing to write: $rel resolves outside $PIN_COMPANY"',
|
|
255
|
+
' [ -z "$(find "$HQ/$rel" -type l -print -quit)" ] || die "refusing to write: a link sits under $rel"',
|
|
256
|
+
' fi',
|
|
257
|
+
'done',
|
|
258
|
+
'HQ_ROOT="$HQ" exec bash "$SCRIPT" write --company "$PIN_COMPANY" --kind "$kind" --type "$type" --audience "$PIN_AUDIENCE" --source-ref "$PIN_REF" --body "$body"',
|
|
259
|
+
"",
|
|
260
|
+
].join("\n");
|
|
261
|
+
}
|
|
262
|
+
/** Parse a stream-json transcript (or a codex --json event stream). */
|
|
263
|
+
export function parseTurnOutput(stdout) {
|
|
264
|
+
let result = null;
|
|
265
|
+
let codexTurns = 0;
|
|
266
|
+
let codexDone = false;
|
|
267
|
+
for (const line of stdout.split("\n")) {
|
|
268
|
+
const t = line.trim();
|
|
269
|
+
if (!t.startsWith("{"))
|
|
270
|
+
continue;
|
|
271
|
+
let ev;
|
|
272
|
+
try {
|
|
273
|
+
ev = JSON.parse(t);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (!ev || typeof ev !== "object")
|
|
279
|
+
continue;
|
|
280
|
+
const o = ev;
|
|
281
|
+
if (o.type === "result")
|
|
282
|
+
result = o;
|
|
283
|
+
if (o.type === "turn.completed") {
|
|
284
|
+
codexTurns += 1;
|
|
285
|
+
codexDone = true;
|
|
286
|
+
}
|
|
287
|
+
if (o.type === "turn.failed")
|
|
288
|
+
codexTurns += 1;
|
|
289
|
+
}
|
|
290
|
+
if (result) {
|
|
291
|
+
const turns = typeof result.num_turns === "number" ? result.num_turns : 1;
|
|
292
|
+
const cost = typeof result.total_cost_usd === "number" ? result.total_cost_usd : typeof result.cost_usd === "number" ? result.cost_usd : 0;
|
|
293
|
+
return { result, turns, costUsd: cost };
|
|
294
|
+
}
|
|
295
|
+
if (codexTurns > 0) {
|
|
296
|
+
return { result: codexDone ? { type: "result", is_error: false } : { type: "result", is_error: true }, turns: codexTurns, costUsd: 0 };
|
|
297
|
+
}
|
|
298
|
+
return { result: null, turns: 0, costUsd: 0 };
|
|
299
|
+
}
|
|
300
|
+
function walkMd(dir, out) {
|
|
301
|
+
let entries;
|
|
302
|
+
try {
|
|
303
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Missing, or removed by a concurrent garden between listing and reading.
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
for (const e of entries) {
|
|
310
|
+
const p = path.join(dir, e.name);
|
|
311
|
+
if (e.isDirectory())
|
|
312
|
+
walkMd(p, out);
|
|
313
|
+
else if (e.name.endsWith(".md"))
|
|
314
|
+
out.add(p);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Every candidate file under every company's signals/ontology _candidates. */
|
|
318
|
+
function allCandidateFiles(hqRoot) {
|
|
319
|
+
const out = new Set();
|
|
320
|
+
const companies = path.join(hqRoot, "companies");
|
|
321
|
+
if (!fs.existsSync(companies))
|
|
322
|
+
return out;
|
|
323
|
+
for (const e of fs.readdirSync(companies, { withFileTypes: true })) {
|
|
324
|
+
if (!e.isDirectory())
|
|
325
|
+
continue;
|
|
326
|
+
for (const store of ["signals", "ontology"])
|
|
327
|
+
walkMd(path.join(companies, e.name, store, "_candidates"), out);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
function frontmatter(text) {
|
|
332
|
+
const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
|
|
333
|
+
if (!m)
|
|
334
|
+
return null;
|
|
335
|
+
const fm = {};
|
|
336
|
+
for (const line of m[1].split("\n")) {
|
|
337
|
+
const kv = /^([a-z_]+):\s*(.*)$/.exec(line);
|
|
338
|
+
if (kv)
|
|
339
|
+
fm[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
340
|
+
}
|
|
341
|
+
return fm;
|
|
342
|
+
}
|
|
343
|
+
/** True when companies/<company>/sources/<channel>/<file> exists for a `<channel>:<file>` ref. */
|
|
344
|
+
function companyHasSourceItem(hqRoot, company, sourceRef) {
|
|
345
|
+
const i = sourceRef.indexOf(":");
|
|
346
|
+
if (i <= 0)
|
|
347
|
+
return false;
|
|
348
|
+
const channel = sourceRef.slice(0, i);
|
|
349
|
+
const name = sourceRef.slice(i + 1);
|
|
350
|
+
if (!name || channel.includes("/") || name.includes("/") || channel === ".." || name === "..")
|
|
351
|
+
return false;
|
|
352
|
+
return fs.existsSync(path.join(hqRoot, "companies", company, "sources", channel, name));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Clean up the new candidate files a turn produced.
|
|
356
|
+
*
|
|
357
|
+
* With `scope`, only this item's files are touched: a file is this item's when
|
|
358
|
+
* its source_ref is the wrapper-pinned `scope.sourceRef`. Every other new file
|
|
359
|
+
* (another company's garden or extract, a /meeting-notes session, another
|
|
360
|
+
* source, garden moving a candidate into _done/) is left as it is and not
|
|
361
|
+
* counted.
|
|
362
|
+
* - In companies/<company>/: this item's file is kept when it conforms and
|
|
363
|
+
* deleted when it does not.
|
|
364
|
+
* - In another company: this item's file is deleted and listed under `foreign`,
|
|
365
|
+
* unless that company has its own source item with the same channel and file
|
|
366
|
+
* name (then the file is that company's candidate, not a leak).
|
|
367
|
+
* Every delete goes through the company-scoped write guard.
|
|
368
|
+
*/
|
|
369
|
+
export function validateNewCandidates(files, expectedAudienceKey, scope) {
|
|
370
|
+
let kept = 0;
|
|
371
|
+
let rejected = 0;
|
|
372
|
+
const foreign = [];
|
|
373
|
+
for (const f of files) {
|
|
374
|
+
let text;
|
|
375
|
+
try {
|
|
376
|
+
text = fs.readFileSync(f, "utf8");
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
// Gone already (a concurrent garden moved it); nothing to clean up.
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const fm = frontmatter(text);
|
|
383
|
+
// The path the file really lives at, every link followed. Ownership is
|
|
384
|
+
// decided on it: a file written through acme's linked type folder into
|
|
385
|
+
// beta's tree is beta's file, not acme's.
|
|
386
|
+
const realRel = realRelPath(scope.hqRoot, f);
|
|
387
|
+
if (fm?.source_ref !== scope.sourceRef)
|
|
388
|
+
continue;
|
|
389
|
+
const m = realRel === null ? null : /^companies\/([^/]+)\//.exec(realRel);
|
|
390
|
+
const fileCompany = m ? m[1] : null;
|
|
391
|
+
if (fileCompany !== scope.company) {
|
|
392
|
+
if (fileCompany !== null && companyHasSourceItem(scope.hqRoot, fileCompany, scope.sourceRef))
|
|
393
|
+
continue;
|
|
394
|
+
// Delete it in the company that really holds it, through the guard.
|
|
395
|
+
// A file that resolves outside every company folder is not deleted
|
|
396
|
+
// (fail closed); it is still reported and the item still fails.
|
|
397
|
+
if (realRel !== null && fileCompany !== null) {
|
|
398
|
+
try {
|
|
399
|
+
guardedRemove(scope.hqRoot, realRel, { company: fileCompany });
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
// Refused by the guard: leave it and report it below.
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
foreign.push(path.relative(scope.hqRoot, f));
|
|
406
|
+
rejected += 1;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const typeOk = fm !== null &&
|
|
410
|
+
((fm.kind === "signal" && EXTRACT_SIGNAL_TYPES.includes(fm.type)) ||
|
|
411
|
+
(fm.kind === "entity" && ENTITY_TYPES.includes(fm.type)));
|
|
412
|
+
const ok = fm !== null &&
|
|
413
|
+
typeOk &&
|
|
414
|
+
Boolean(fm.source_ref) &&
|
|
415
|
+
Boolean(fm.audience_key) &&
|
|
416
|
+
(expectedAudienceKey === undefined || fm.audience_key === expectedAudienceKey);
|
|
417
|
+
if (ok)
|
|
418
|
+
kept += 1;
|
|
419
|
+
else {
|
|
420
|
+
if (realRel !== null) {
|
|
421
|
+
guardedRemove(scope.hqRoot, realRel, { company: scope.company });
|
|
422
|
+
}
|
|
423
|
+
rejected += 1;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { kept, rejected, foreign };
|
|
427
|
+
}
|
|
428
|
+
function classifyFailure(text, fallback) {
|
|
429
|
+
if (CLAUDE_LOGGED_OUT_RE.test(text))
|
|
430
|
+
return { error: "coding tool is not logged in", errorKind: "signed-out" };
|
|
431
|
+
return { error: fallback, errorKind: "other" };
|
|
432
|
+
}
|
|
433
|
+
/** Absolute path of an executable as PATH resolves it, or null. */
|
|
434
|
+
function resolveOnPath(bin, envPath) {
|
|
435
|
+
if (bin.includes("/"))
|
|
436
|
+
return fs.existsSync(bin) ? path.resolve(bin) : null;
|
|
437
|
+
for (const dir of (envPath ?? "").split(path.delimiter)) {
|
|
438
|
+
if (!dir)
|
|
439
|
+
continue;
|
|
440
|
+
const p = path.join(dir, bin);
|
|
441
|
+
try {
|
|
442
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
443
|
+
return p;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// not here
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Install prefixes the sandboxed turn must read: codex's own (its sandbox
|
|
453
|
+
* helper re-executes it) and bash's (Homebrew bash loads its libraries from the
|
|
454
|
+
* prefix). For /opt/homebrew/bin/bash that is /opt/homebrew. A prefix that is
|
|
455
|
+
* /, the home directory, or overlaps the HQ root is never added.
|
|
456
|
+
*/
|
|
457
|
+
function toolPrefixRoots(bins, envPath, hqRoot) {
|
|
458
|
+
const hq = fs.realpathSync(hqRoot);
|
|
459
|
+
const home = os.homedir();
|
|
460
|
+
const overlaps = (a, b) => a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
|
|
461
|
+
const roots = [];
|
|
462
|
+
for (const bin of bins) {
|
|
463
|
+
const found = resolveOnPath(bin, envPath);
|
|
464
|
+
if (!found)
|
|
465
|
+
continue;
|
|
466
|
+
const forms = [found];
|
|
467
|
+
try {
|
|
468
|
+
forms.push(fs.realpathSync(found));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// dangling link: the spawn fails on its own
|
|
472
|
+
}
|
|
473
|
+
for (const f of forms) {
|
|
474
|
+
const prefix = path.dirname(path.dirname(f));
|
|
475
|
+
if (prefix === path.parse(prefix).root || prefix === home || overlaps(prefix, hq))
|
|
476
|
+
continue;
|
|
477
|
+
roots.push(prefix);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return [...new Set(roots)];
|
|
481
|
+
}
|
|
482
|
+
/** TOML inline table for the codex permission profile's filesystem entries. */
|
|
483
|
+
export function codexFilesystemProfile(scope, codexBin, envPath) {
|
|
484
|
+
const entries = [[":minimal", "read"]];
|
|
485
|
+
for (const r of toolPrefixRoots([codexBin, "bash"], envPath, scope.hqRoot))
|
|
486
|
+
entries.push([r, "read"]);
|
|
487
|
+
entries.push([scope.turnDir, "read"]);
|
|
488
|
+
entries.push([path.join(fs.realpathSync(scope.hqRoot), "core", "scripts"), "read"]);
|
|
489
|
+
const prefs = path.join(fs.realpathSync(scope.hqRoot), "companies", scope.company, "settings", "knowledge");
|
|
490
|
+
if (fs.existsSync(prefs))
|
|
491
|
+
entries.push([prefs, "read"]);
|
|
492
|
+
const personalPrefs = path.join(fs.realpathSync(scope.hqRoot), "personal", "settings", "knowledge-preferences.yaml");
|
|
493
|
+
if (fs.existsSync(personalPrefs))
|
|
494
|
+
entries.push([personalPrefs, "read"]);
|
|
495
|
+
entries.push([scope.channelDir, "read"]);
|
|
496
|
+
for (const d of scope.candidateDirs)
|
|
497
|
+
entries.push([d, "write"]);
|
|
498
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}=${JSON.stringify(v)}`).join(",")}}`;
|
|
499
|
+
}
|
|
500
|
+
export function claudeTurnArgs(prompt, scope, maxTurns, budgetUsd) {
|
|
501
|
+
return [
|
|
502
|
+
"-p",
|
|
503
|
+
prompt,
|
|
504
|
+
"--output-format",
|
|
505
|
+
"stream-json",
|
|
506
|
+
"--verbose",
|
|
507
|
+
"--permission-mode",
|
|
508
|
+
"dontAsk",
|
|
509
|
+
"--permission-prompts",
|
|
510
|
+
"none",
|
|
511
|
+
"--restricted",
|
|
512
|
+
"--tools",
|
|
513
|
+
"Read,Bash",
|
|
514
|
+
"--strict-mcp-config",
|
|
515
|
+
"--mcp-config",
|
|
516
|
+
'{"mcpServers":{}}',
|
|
517
|
+
"--disable-slash-commands",
|
|
518
|
+
"--no-session-persistence",
|
|
519
|
+
"--max-turns",
|
|
520
|
+
String(maxTurns),
|
|
521
|
+
"--max-budget-usd",
|
|
522
|
+
budgetUsd.toFixed(4),
|
|
523
|
+
"--add-dir",
|
|
524
|
+
scope.channelDir,
|
|
525
|
+
"--allowedTools",
|
|
526
|
+
...claudeAllowedTools(scope.channelDir, scope.wrapperPath),
|
|
527
|
+
];
|
|
528
|
+
}
|
|
529
|
+
export function codexTurnArgs(prompt, scope, codexBin, envPath) {
|
|
530
|
+
const profile = CODEX_PERMISSION_PROFILE;
|
|
531
|
+
return [
|
|
532
|
+
"-a",
|
|
533
|
+
"never",
|
|
534
|
+
"exec",
|
|
535
|
+
"--json",
|
|
536
|
+
"--skip-git-repo-check",
|
|
537
|
+
"--ephemeral",
|
|
538
|
+
"--ignore-user-config",
|
|
539
|
+
"--ignore-rules",
|
|
540
|
+
"-C",
|
|
541
|
+
scope.turnDir,
|
|
542
|
+
"-c",
|
|
543
|
+
`default_permissions=${JSON.stringify(profile)}`,
|
|
544
|
+
"-c",
|
|
545
|
+
`permissions.${profile}.filesystem=${codexFilesystemProfile(scope, codexBin, envPath)}`,
|
|
546
|
+
"-c",
|
|
547
|
+
`permissions.${profile}.network.enabled=false`,
|
|
548
|
+
prompt,
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
function runTurn(ctx, runtime, prompt, scope, budgetUsd, opts) {
|
|
552
|
+
const cwd = scope.turnDir;
|
|
553
|
+
const cmd = runtime === "claude" ? opts.claudeBin : opts.codexBin;
|
|
554
|
+
const args = runtime === "claude"
|
|
555
|
+
? claudeTurnArgs(prompt, scope, ctx.maxTurns, budgetUsd)
|
|
556
|
+
: codexTurnArgs(prompt, scope, opts.codexBin, process.env.PATH);
|
|
557
|
+
const res = ctx.spawn(cmd, args, { cwd, env: { ...process.env, HQ_ROOT: ctx.hqRoot }, timeoutMs: opts.turnTimeoutMs });
|
|
558
|
+
const parsed = parseTurnOutput(res.stdout);
|
|
559
|
+
const all = `${res.stderr}\n${res.stdout}`;
|
|
560
|
+
if (res.error) {
|
|
561
|
+
const timedOut = /ETIMEDOUT|timed out/i.test(res.error.message) || res.error.code === "ETIMEDOUT";
|
|
562
|
+
if (timedOut)
|
|
563
|
+
return { ok: false, error: `${runtime} timed out after ${opts.turnTimeoutMs}ms`, errorKind: "transient", turns: parsed.turns, costUsd: parsed.costUsd };
|
|
564
|
+
return { ok: false, error: `${runtime} could not start: ${res.error.message}`.slice(0, 300), errorKind: "other", turns: 0, costUsd: 0 };
|
|
565
|
+
}
|
|
566
|
+
if (res.status !== 0) {
|
|
567
|
+
const c = classifyFailure(all, `${runtime} exited ${String(res.status)}: ${res.stderr.trim().slice(0, 200)}`);
|
|
568
|
+
return { ok: false, ...c, turns: parsed.turns, costUsd: parsed.costUsd };
|
|
569
|
+
}
|
|
570
|
+
if (!parsed.result)
|
|
571
|
+
return { ok: false, error: `${runtime} printed no result event`, errorKind: "other", turns: 0, costUsd: 0 };
|
|
572
|
+
if (parsed.result.is_error === true || (typeof parsed.result.subtype === "string" && parsed.result.subtype !== "success")) {
|
|
573
|
+
const text = typeof parsed.result.result === "string" ? parsed.result.result : String(parsed.result.subtype ?? "");
|
|
574
|
+
const c = classifyFailure(text, `${runtime} turn failed: ${text.slice(0, 200) || "error result"}`);
|
|
575
|
+
return { ok: false, ...c, turns: parsed.turns, costUsd: parsed.costUsd };
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, turns: parsed.turns, costUsd: parsed.costUsd };
|
|
578
|
+
}
|
|
579
|
+
export function createExtractStep(options = {}) {
|
|
580
|
+
return {
|
|
581
|
+
name: "extract",
|
|
582
|
+
async run(ctx) {
|
|
583
|
+
const empty = { modelTurns: 0, estimatedSpendUsd: 0 };
|
|
584
|
+
const channels = listSourceChannels(ctx.hqRoot, ctx.company, options.force === true);
|
|
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) {
|
|
588
|
+
return { status: "skipped", itemsIn: 0, itemsOut: 0, skippedReason: "no-sources", ...empty };
|
|
589
|
+
}
|
|
590
|
+
const runtime = (options.resolveBotRuntime ?? defaultResolveBotRuntime)() ?? options.runtime ?? "claude";
|
|
591
|
+
if (runtime === "grok") {
|
|
592
|
+
return {
|
|
593
|
+
status: "failed",
|
|
594
|
+
itemsIn: 0,
|
|
595
|
+
itemsOut: 0,
|
|
596
|
+
error: "grok has no restricted tool mode; extract needs claude or codex",
|
|
597
|
+
errorKind: "other",
|
|
598
|
+
...empty,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
const scripts = path.join(ctx.hqRoot, "core", "scripts");
|
|
602
|
+
const worklist = path.join(scripts, "ontology-source-worklist.sh");
|
|
603
|
+
const skillPath = path.join(ctx.hqRoot, "core", "workers", "public", "ontology", "skills", "process-source.md");
|
|
604
|
+
if (!fs.existsSync(worklist) || !fs.existsSync(skillPath)) {
|
|
605
|
+
return { status: "skipped", itemsIn: 0, itemsOut: 0, skippedReason: "ontology-worker-missing", ...empty };
|
|
606
|
+
}
|
|
607
|
+
const skillText = fs.readFileSync(skillPath, "utf8");
|
|
608
|
+
const turnOpts = {
|
|
609
|
+
claudeBin: options.claudeBin ?? "claude",
|
|
610
|
+
codexBin: options.codexBin ?? "codex",
|
|
611
|
+
turnTimeoutMs: options.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
612
|
+
};
|
|
613
|
+
const env = { ...process.env, HQ_ROOT: ctx.hqRoot };
|
|
614
|
+
const limit = options.worklistLimit ?? DEFAULT_WORKLIST_LIMIT;
|
|
615
|
+
let itemsIn = 0;
|
|
616
|
+
let itemsOut = 0;
|
|
617
|
+
let modelTurns = 0;
|
|
618
|
+
let spent = 0;
|
|
619
|
+
let lastCost = 0;
|
|
620
|
+
let rejected = 0;
|
|
621
|
+
let processedAny = false;
|
|
622
|
+
let budgetStop = false;
|
|
623
|
+
let errorKind;
|
|
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";
|
|
630
|
+
const skippedItems = [];
|
|
631
|
+
const sourcesReal = fs.realpathSync(path.join(ctx.hqRoot, "companies", ctx.company, "sources"));
|
|
632
|
+
outer: for (const ch of channels) {
|
|
633
|
+
const base = ["--company", ctx.company, "--channel", ch.channel, ...(ch.force ? ["--force"] : [])];
|
|
634
|
+
// The channel folder is the only place a turn may read. A symlink out of
|
|
635
|
+
// this company's sources/ would widen that, so refuse it.
|
|
636
|
+
const channelDir = fs.realpathSync(path.join(ctx.hqRoot, "companies", ctx.company, "sources", ch.channel));
|
|
637
|
+
if (!channelDir.startsWith(sourcesReal + path.sep)) {
|
|
638
|
+
failures.push({ file: `sources/${ch.channel}`, error: "channel folder resolves outside this company's sources" });
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
let candidateDirs = [];
|
|
642
|
+
// The candidate script writes into _candidates/<folder>/ with plain
|
|
643
|
+
// fs. A linked folder there that leads into another company's tree
|
|
644
|
+
// would land this item's candidates in that company, so refuse the
|
|
645
|
+
// channel before any turn runs (every runtime, not only codex).
|
|
646
|
+
try {
|
|
647
|
+
for (const store of ["signals", "ontology"]) {
|
|
648
|
+
assertNoEscapingLinks(ctx.hqRoot, `companies/${ctx.company}/${store}/_candidates`, { company: ctx.company });
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (e) {
|
|
652
|
+
failures.push({ file: `sources/${ch.channel}`, error: `candidate folder refused: ${e.message}`.slice(0, 300) });
|
|
653
|
+
if (!errorKind)
|
|
654
|
+
errorKind = "other";
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (runtime === "codex") {
|
|
658
|
+
try {
|
|
659
|
+
candidateDirs = ["signals", "ontology"].map((store) => {
|
|
660
|
+
const dir = assertAllowedPath(ctx.hqRoot, `companies/${ctx.company}/${store}/_candidates`, { company: ctx.company });
|
|
661
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
662
|
+
return fs.realpathSync(dir);
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
failures.push({ file: `sources/${ch.channel}`, error: `candidate folder refused: ${e.message}`.slice(0, 300) });
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
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
|
+
}
|
|
681
|
+
const wl = ctx.spawn("bash", [worklist, ...base, "--limit", String(limit)], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
|
|
682
|
+
if (wl.error || wl.status !== 0) {
|
|
683
|
+
failures.push({ file: `sources/${ch.channel}`, error: `worklist failed: ${(wl.error?.message ?? wl.stderr.trim()).slice(0, 200) || `exit ${String(wl.status)}`}` });
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const items = [];
|
|
687
|
+
for (const line of wl.stdout.split("\n")) {
|
|
688
|
+
if (!line.trim())
|
|
689
|
+
continue;
|
|
690
|
+
try {
|
|
691
|
+
items.push(JSON.parse(line));
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
failures.push({ file: `sources/${ch.channel}`, error: "worklist printed a non-JSON line" });
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
for (const item of items) {
|
|
698
|
+
itemsIn += 1;
|
|
699
|
+
if (item.skip) {
|
|
700
|
+
skippedItems.push({ file: String(item.file), reason: String(item.skip) });
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
const unsafe = unsafeItemReason(item, ch.channel);
|
|
704
|
+
if (unsafe) {
|
|
705
|
+
failures.push({ file: String(item.file), error: `unsafe worklist item: ${unsafe}` });
|
|
706
|
+
if (!errorKind)
|
|
707
|
+
errorKind = "other";
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const remaining = ctx.maxBudgetUsd - spent;
|
|
711
|
+
if (spent > 0 && (spent >= ctx.maxBudgetUsd || remaining < lastCost)) {
|
|
712
|
+
budgetStop = true;
|
|
713
|
+
break outer;
|
|
714
|
+
}
|
|
715
|
+
const sourceRef = `${ch.channel}:${path.basename(item.file)}`;
|
|
716
|
+
const turnDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "hq-signals-extract-")));
|
|
717
|
+
const wrapperPath = path.join(turnDir, CANDIDATE_WRAPPER_NAME);
|
|
718
|
+
const before = allCandidateFiles(ctx.hqRoot);
|
|
719
|
+
let turn;
|
|
720
|
+
try {
|
|
721
|
+
fs.writeFileSync(wrapperPath, buildCandidateWrapper({ hqRoot: ctx.hqRoot, company: ctx.company, audience: (item.audience ?? []).join(","), sourceRef }), { mode: 0o700 });
|
|
722
|
+
const scope = { hqRoot: ctx.hqRoot, company: ctx.company, channelDir, turnDir, wrapperPath, candidateDirs };
|
|
723
|
+
const prompt = buildExtractPrompt({
|
|
724
|
+
skillText,
|
|
725
|
+
company: ctx.company,
|
|
726
|
+
channel: ch.channel,
|
|
727
|
+
item,
|
|
728
|
+
itemPath: path.join(channelDir, path.basename(item.file)),
|
|
729
|
+
wrapperPath,
|
|
730
|
+
runtime: runtime === "codex" ? "codex" : "claude",
|
|
731
|
+
});
|
|
732
|
+
turn = runTurn(ctx, runtime, prompt, scope, Math.max(0, remaining), turnOpts);
|
|
733
|
+
}
|
|
734
|
+
finally {
|
|
735
|
+
fs.rmSync(turnDir, { recursive: true, force: true });
|
|
736
|
+
}
|
|
737
|
+
modelTurns += turn.turns;
|
|
738
|
+
spent += turn.costUsd;
|
|
739
|
+
lastCost = turn.costUsd;
|
|
740
|
+
const created = [...allCandidateFiles(ctx.hqRoot)].filter((f) => !before.has(f));
|
|
741
|
+
const v = validateNewCandidates(created, item.audience_key, { hqRoot: ctx.hqRoot, company: ctx.company, sourceRef });
|
|
742
|
+
rejected += v.rejected;
|
|
743
|
+
if (v.foreign.length > 0) {
|
|
744
|
+
// Isolation breach: the files are already deleted; the item stays unprocessed.
|
|
745
|
+
failures.push({ file: item.file, error: `candidate written outside ${ctx.company} (deleted): ${v.foreign.join(", ")}`.slice(0, 300) });
|
|
746
|
+
if (!errorKind)
|
|
747
|
+
errorKind = "other";
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (!turn.ok) {
|
|
751
|
+
failures.push({ file: item.file, error: turn.error ?? "turn failed" });
|
|
752
|
+
if (turn.errorKind === "signed-out" || !errorKind)
|
|
753
|
+
errorKind = turn.errorKind;
|
|
754
|
+
if (turn.errorKind === "signed-out")
|
|
755
|
+
break outer;
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
itemsOut += v.kept;
|
|
759
|
+
const md = ctx.spawn("bash", [worklist, ...base, "--mark-done", item.file], { cwd: ctx.hqRoot, env, timeoutMs: SCRIPT_TIMEOUT_MS });
|
|
760
|
+
if (md.error || md.status !== 0) {
|
|
761
|
+
failures.push({ file: item.file, error: `mark-done failed: ${md.stderr.trim().slice(0, 200)}` });
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
processedAny = true;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const result = {
|
|
768
|
+
status: "ok",
|
|
769
|
+
itemsIn,
|
|
770
|
+
itemsOut,
|
|
771
|
+
modelTurns,
|
|
772
|
+
estimatedSpendUsd: Math.round(spent * 1e6) / 1e6,
|
|
773
|
+
...(failures.length ? { failures } : {}),
|
|
774
|
+
...(skippedItems.length ? { skippedItems } : {}),
|
|
775
|
+
...(rejected ? { rejectedCandidates: rejected } : {}),
|
|
776
|
+
};
|
|
777
|
+
if (budgetStop) {
|
|
778
|
+
result.status = "partial";
|
|
779
|
+
result.error = "budget-exceeded";
|
|
780
|
+
}
|
|
781
|
+
else if (failures.length > 0) {
|
|
782
|
+
result.status = processedAny ? "partial" : "failed";
|
|
783
|
+
result.error = failures[0].error;
|
|
784
|
+
result.errorKind = errorKind ?? "other";
|
|
785
|
+
}
|
|
786
|
+
if (budgetStop && failures.length > 0)
|
|
787
|
+
result.errorKind = errorKind ?? "other";
|
|
788
|
+
return result;
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
export const extractStep = createExtractStep();
|
|
793
|
+
//# sourceMappingURL=extract.js.map
|