@indigoai-us/hq-cli 5.288.0 → 5.290.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The setup bot's instant "Explain HQ first" answer.
3
+ *
4
+ * The setup bot's first question is whether to explain HQ or jump straight in,
5
+ * with "Explain HQ first" as a suggested reply. The explanation is fixed text,
6
+ * so a click on that button does not need a model turn: the runner sends the
7
+ * text from the setup worker's `skills/explain-hq.md` at once, and the bot's
8
+ * next turn starts with a note saying the explanation already went out.
9
+ *
10
+ * The text has one home, the worker file. The model reads the same file when
11
+ * someone asks for the explanation in their own words, so the two paths never
12
+ * drift. Anything unexpected (file missing or malformed, the question not
13
+ * pending, any other message) falls back to the normal model turn.
14
+ */
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ /** The suggested reply that triggers the instant answer. Exact match only. */
18
+ export const EXPLAIN_HQ_SUGGESTION = "Explain HQ first";
19
+ /** Where the explanation lives, relative to the setup worker folder. */
20
+ export const EXPLAIN_HQ_FILE = path.join("skills", "explain-hq.md");
21
+ /** The setup bot's progress note in its memory folder (setup worker.yaml). */
22
+ export const SETUP_PROGRESS_FILE = "setup-progress.md";
23
+ /** Runner-owned state, in the bot's own folder (never the model's memory). */
24
+ export const SETUP_INTRO_STATE_FILE = "setup-intro.json";
25
+ const EXPLANATION_HEADING = /^##[ \t]+The explanation[ \t]*$/m;
26
+ const CLOSING_HEADING = /^##[ \t]+After the explanation, when the app sends it[ \t]*$/m;
27
+ /**
28
+ * Put in front of the next message from the person, so the model knows the
29
+ * explanation went out without it. It never reaches the person.
30
+ */
31
+ export const SETUP_EXPLAINED_NOTE = `[Note from the HQ app: the person clicked "${EXPLAIN_HQ_SUGGESTION}", and the app already sent them the explanation ` +
32
+ `from skills/explain-hq.md word for word, ending by asking whether to start the setup. The explain-or-jump question is answered. ` +
33
+ "Write `Intro: explained` in your progress note, do not explain HQ again, and carry on with the first unfinished setup step. " +
34
+ "Their reply to that message follows.]";
35
+ /** The text between a heading and the next `## ` heading (or the end). */
36
+ function sectionAfter(body, heading) {
37
+ const match = heading.exec(body);
38
+ if (!match)
39
+ return null;
40
+ const rest = body.slice(match.index + match[0].length);
41
+ const next = /^##[ \t]/m.exec(rest);
42
+ return (next ? rest.slice(0, next.index) : rest).trim();
43
+ }
44
+ /** True when the closing carries exactly one well-formed suggestions block. */
45
+ function hasSuggestionsBlock(closing) {
46
+ const fence = /^```hq-block\n(.+)\n```$/m.exec(closing);
47
+ if (!fence)
48
+ return false;
49
+ try {
50
+ const parsed = JSON.parse(fence[1]);
51
+ const block = parsed.blocks?.[0];
52
+ return (parsed.v === 1 &&
53
+ block?.kind === "suggestions" &&
54
+ Array.isArray(block.items) &&
55
+ block.items.length >= 2 &&
56
+ block.items.every((item) => typeof item === "string" && item.trim().length > 0));
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /** Parse the worker file. Null when it is not the shape the app can send. */
63
+ export function parseSetupExplainer(body) {
64
+ const text = body.replace(/\r\n/g, "\n");
65
+ const explanation = sectionAfter(text, EXPLANATION_HEADING);
66
+ const closing = sectionAfter(text, CLOSING_HEADING);
67
+ if (!explanation || !closing)
68
+ return null;
69
+ // The closing must end with the block (nothing after it) so the app shows the buttons.
70
+ if (!closing.endsWith("```") || !hasSuggestionsBlock(closing))
71
+ return null;
72
+ return { explanation, closing, reply: `${explanation}\n\n${closing}` };
73
+ }
74
+ /** Read the explanation from the setup worker folder the bot runs. */
75
+ export function loadSetupExplainer(hqRoot, workerDir) {
76
+ try {
77
+ return parseSetupExplainer(fs.readFileSync(path.resolve(hqRoot, workerDir, EXPLAIN_HQ_FILE), "utf8"));
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** What the model's progress note says about the opening question. */
84
+ export function readSetupProgressIntro(memoryDir) {
85
+ let note;
86
+ try {
87
+ note = fs.readFileSync(path.join(memoryDir, SETUP_PROGRESS_FILE), "utf8");
88
+ }
89
+ catch {
90
+ return null;
91
+ }
92
+ if (/^Status:[ \t]*finished/im.test(note))
93
+ return "finished";
94
+ if (/^Intro:[ \t]*(explained|skipped)\b/im.test(note))
95
+ return "answered";
96
+ if (/^Intro:[ \t]*asked\b/im.test(note))
97
+ return "asked";
98
+ return null;
99
+ }
100
+ export function readSetupIntroState(dir) {
101
+ try {
102
+ const parsed = JSON.parse(fs.readFileSync(path.join(dir, SETUP_INTRO_STATE_FILE), "utf8"));
103
+ return parsed && parsed.v === 1 ? parsed : { v: 1 };
104
+ }
105
+ catch {
106
+ return { v: 1 };
107
+ }
108
+ }
109
+ export function patchSetupIntroState(dir, patch) {
110
+ const next = { ...readSetupIntroState(dir), ...patch, v: 1 };
111
+ for (const key of Object.keys(next)) {
112
+ if (next[key] === undefined)
113
+ delete next[key];
114
+ }
115
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
116
+ const dest = path.join(dir, SETUP_INTRO_STATE_FILE);
117
+ const tmp = `${dest}.tmp.${process.pid}`;
118
+ fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
119
+ fs.renameSync(tmp, dest);
120
+ return next;
121
+ }
122
+ /** A bot reply that asks the explain-or-jump question (the words or the button). */
123
+ export function replyOffersExplain(reply) {
124
+ return reply.includes(`"${EXPLAIN_HQ_SUGGESTION}"`) || /explain HQ to you/i.test(reply);
125
+ }
126
+ /**
127
+ * The instant answer is due only when ALL of these hold: the message is exactly
128
+ * the button text with nothing attached, the question is pending (the bot's
129
+ * last reply offered it, or its note says it asked), and the explanation has
130
+ * not gone out yet (neither by the app nor, per the note, by the model).
131
+ */
132
+ export function setupExplainDue(input) {
133
+ if (input.text !== EXPLAIN_HQ_SUGGESTION || input.attachments > 0)
134
+ return false;
135
+ if (input.state.explainedAt)
136
+ return false;
137
+ if (input.progress === "answered" || input.progress === "finished")
138
+ return false;
139
+ return Boolean(input.state.offeredAt) || input.progress === "asked";
140
+ }
141
+ //# sourceMappingURL=setup-explain.js.map
@@ -66,6 +66,27 @@ export type PatchResult = {
66
66
  * change, the file is returned unchanged so a rerun writes nothing.
67
67
  */
68
68
  export declare function patchEntityFile(content: string, patch: EntityPatch): PatchResult;
69
+ /** A namespaced record in a source system, as the cloud schema's `external_refs` holds it. */
70
+ export interface ExternalRef {
71
+ system: string;
72
+ namespace: string;
73
+ objectType?: string;
74
+ recordId: string;
75
+ }
76
+ export interface IdentityPatch {
77
+ externalIds: Record<string, string>;
78
+ webDomains: string[];
79
+ /** When set, the whole `external_refs` list; when absent, the key is left alone. */
80
+ externalRefs?: ExternalRef[];
81
+ lastUpdated: string;
82
+ }
83
+ /**
84
+ * Set `external_ids` and `web_domains` on an existing, valid entity file.
85
+ * A key whose value is already equal is left as it is; an absent key is
86
+ * appended at the end of the front matter. Nothing else changes except
87
+ * `last_updated`. When neither key changes the file is returned unchanged.
88
+ */
89
+ export declare function setIdentityKeys(content: string, patch: IdentityPatch): PatchResult;
69
90
  export interface SignalBackRef {
70
91
  signalType: string;
71
92
  preview: string;
@@ -206,6 +206,106 @@ function checkPatched(original, next, aliases, signalCount, lastUpdated) {
206
206
  return "patch_last_updated";
207
207
  return null;
208
208
  }
209
+ function renderExternalIds(ids) {
210
+ const entries = Object.entries(ids);
211
+ if (entries.length === 0)
212
+ return "external_ids: {}";
213
+ return ["external_ids:", ...entries.map(([key, value]) => ` ${key}: ${quote(value)}`)].join("\n");
214
+ }
215
+ function renderExternalRefs(refs) {
216
+ if (refs.length === 0)
217
+ return "external_refs: []";
218
+ const lines = ["external_refs:"];
219
+ for (const ref of refs) {
220
+ lines.push(` - system: ${quote(ref.system)}`, ` namespace: ${quote(ref.namespace)}`);
221
+ if (ref.objectType !== undefined)
222
+ lines.push(` objectType: ${quote(ref.objectType)}`);
223
+ lines.push(` recordId: ${quote(ref.recordId)}`);
224
+ }
225
+ return lines.join("\n");
226
+ }
227
+ function renderWebDomains(domains) {
228
+ if (domains.length === 0)
229
+ return "web_domains: []";
230
+ return ["web_domains:", ...domains.map((domain) => ` - ${quote(domain)}`)].join("\n");
231
+ }
232
+ /**
233
+ * Set `external_ids` and `web_domains` on an existing, valid entity file.
234
+ * A key whose value is already equal is left as it is; an absent key is
235
+ * appended at the end of the front matter. Nothing else changes except
236
+ * `last_updated`. When neither key changes the file is returned unchanged.
237
+ */
238
+ export function setIdentityKeys(content, patch) {
239
+ const match = FRONTMATTER_RE.exec(content);
240
+ const parsed = parseEntityFile(content);
241
+ if (!match || !parsed)
242
+ return { ok: false, reason: "unparseable" };
243
+ const before = validateEntityFrontmatter(parsed.frontmatter);
244
+ if (typeof before === "string")
245
+ return { ok: false, reason: `invalid_existing:${before}` };
246
+ for (const key of Object.keys(patch.externalIds)) {
247
+ if (!/^[A-Za-z0-9_-]+$/.test(key))
248
+ return { ok: false, reason: "external_ids_key" };
249
+ }
250
+ const currentIds = (parsed.frontmatter.external_ids ?? {});
251
+ const currentDomains = (parsed.frontmatter.web_domains ?? []);
252
+ const currentRefs = parsed.frontmatter.external_refs ?? [];
253
+ const idsChanged = !isDeepStrictEqual(currentIds, patch.externalIds);
254
+ const domainsChanged = !isDeepStrictEqual(currentDomains, patch.webDomains);
255
+ const refsChanged = patch.externalRefs !== undefined && !isDeepStrictEqual(currentRefs, patch.externalRefs);
256
+ if (!idsChanged && !domainsChanged && !refsChanged)
257
+ return { ok: true, content, changed: false };
258
+ const newline = content.includes("\r\n") ? "\r\n" : "\n";
259
+ let lines = match[1].split(/\r?\n/);
260
+ if (idsChanged) {
261
+ const rendered = renderExternalIds(patch.externalIds);
262
+ lines = replaceTopLevelKey(lines, "external_ids", rendered) ?? [...lines, ...rendered.split("\n")];
263
+ }
264
+ if (domainsChanged) {
265
+ const rendered = renderWebDomains(patch.webDomains);
266
+ lines = replaceTopLevelKey(lines, "web_domains", rendered) ?? [...lines, ...rendered.split("\n")];
267
+ }
268
+ if (refsChanged) {
269
+ const rendered = renderExternalRefs(patch.externalRefs);
270
+ lines = replaceTopLevelKey(lines, "external_refs", rendered) ?? [...lines, ...rendered.split("\n")];
271
+ }
272
+ const stamped = replaceTopLevelKey(lines, "last_updated", `last_updated: ${patch.lastUpdated}`);
273
+ if (!stamped)
274
+ return { ok: false, reason: "missing_owned_key" };
275
+ lines = stamped;
276
+ const head = content.slice(0, content.indexOf(match[1]));
277
+ const tail = content.slice(content.indexOf(match[1]) + match[1].length);
278
+ const next = `${head}${lines.join(newline)}${tail}`;
279
+ const reparsed = parseEntityFile(next);
280
+ if (!reparsed)
281
+ return { ok: false, reason: "patch_unparseable" };
282
+ if (typeof validateEntityFrontmatter(reparsed.frontmatter) === "string")
283
+ return { ok: false, reason: "patch_invalid" };
284
+ const keys = new Set([...Object.keys(parsed.frontmatter), ...Object.keys(reparsed.frontmatter)]);
285
+ for (const key of keys) {
286
+ if (key === "external_ids" || key === "web_domains" || key === "last_updated")
287
+ continue;
288
+ if (key === "external_refs" && patch.externalRefs !== undefined)
289
+ continue;
290
+ if (!isDeepStrictEqual(parsed.frontmatter[key], reparsed.frontmatter[key])) {
291
+ return { ok: false, reason: `patch_changed_key:${key}` };
292
+ }
293
+ }
294
+ if (!isDeepStrictEqual(reparsed.frontmatter.external_ids ?? {}, patch.externalIds)) {
295
+ return { ok: false, reason: "patch_external_ids" };
296
+ }
297
+ if (!isDeepStrictEqual(reparsed.frontmatter.web_domains ?? [], patch.webDomains)) {
298
+ return { ok: false, reason: "patch_web_domains" };
299
+ }
300
+ if (refsChanged && !isDeepStrictEqual(reparsed.frontmatter.external_refs ?? [], patch.externalRefs)) {
301
+ return { ok: false, reason: "patch_external_refs" };
302
+ }
303
+ if (reparsed.frontmatter.last_updated !== patch.lastUpdated)
304
+ return { ok: false, reason: "patch_last_updated" };
305
+ if (reparsed.body !== parsed.body)
306
+ return { ok: false, reason: "patch_body" };
307
+ return { ok: true, content: next, changed: true };
308
+ }
209
309
  /** hq-pro `previewSignalContent`: first 80 characters, one line. */
210
310
  export function previewSignalContent(canonicalContent) {
211
311
  const collapsed = canonicalContent.replace(/\s+/g, " ").trim();
@@ -26,7 +26,11 @@
26
26
  * merge cannot rewrite (a copy, a knowledge file, a signal) is held.
27
27
  *
28
28
  * A signal whose `entity_refs` names a copy holds that copy: signals are
29
- * server-owned, so the ref cannot be re-pointed from here.
29
+ * server-owned, so the ref cannot be re-pointed from here. With `rewriteRefs`
30
+ * (flag `checkpoint.ontology.merge-rewrite-refs`) signal refs, invoice and
31
+ * bill links, source and knowledge links are re-pointed at the survivor, the
32
+ * survivor receives the copy's `external_ids` and `web_domains`, and only a
33
+ * link from another copy or an external id conflict holds a pair.
30
34
  *
31
35
  * With `pairKind: "variants"` (flag `checkpoint.ontology.merge-variants`) the
32
36
  * pairs are two-file duplicate groups whose canonical names differ only in
@@ -42,6 +46,12 @@ export declare const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
42
46
  * names differ only in spacing, case or punctuation (CO-65).
43
47
  */
44
48
  export declare const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
49
+ /**
50
+ * `merge-copies --rewrite-refs`: re-point signal `entity_refs`, invoice and
51
+ * bill links, source and knowledge links from a copy to its survivor, and
52
+ * carry the copy's `external_ids` and `web_domains` to the survivor.
53
+ */
54
+ export declare const MERGE_REWRITE_REFS_FLAG = "checkpoint.ontology.merge-rewrite-refs";
45
55
  export type CopySkipReason = "no_original" | "original_invalid" | "identity_differs" | "not_in_duplicate_group" | "group_size" | "type_differs" | "names_differ" | "numbered_copy" | "not_listed";
46
56
  export type PairKind = "numbered" | "variants";
47
57
  export interface CopySkip {
@@ -55,10 +65,22 @@ export interface CopyPair {
55
65
  /** Company-relative path of the original it merges into. */
56
66
  original: string;
57
67
  }
68
+ /**
69
+ * How to settle one provider's `external_ids` conflict: `primary` stays in
70
+ * `external_ids`, and the pair's other id moves to `external_refs` under
71
+ * `namespace` (the provider account or workspace) and `object_type`.
72
+ */
73
+ export interface ExternalIdResolution {
74
+ primary: string;
75
+ namespace: string;
76
+ object_type?: string;
77
+ }
58
78
  /** One entry of a `--only` list: an exact copy -> original pair. */
59
79
  export interface ListedPair {
60
80
  copy: string;
61
81
  original: string;
82
+ /** Provider -> resolution, applied only when `rewriteRefs` is on and the ids conflict. */
83
+ resolve_external_ids?: Record<string, ExternalIdResolution>;
62
84
  }
63
85
  export interface CopyPlan extends CopyPair {
64
86
  add_aliases: string[];
@@ -75,8 +97,14 @@ export interface CopyPlan extends CopyPair {
75
97
  /** Signal files whose `entity_refs` name the copy. */
76
98
  signal_refs: string[];
77
99
  /** Why the copy cannot be merged or deleted from here. */
78
- held?: "signal_refs" | "linked_from_unwritable" | "merge_invalid";
100
+ held?: "signal_refs" | "linked_from_unwritable" | "merge_invalid" | "external_id_conflict";
79
101
  detail?: string;
102
+ /** Keys added to the survivor. Set only when `rewriteRefs` is on. */
103
+ add_external_ids?: string[];
104
+ /** Domains added to the survivor. Set only when `rewriteRefs` is on. */
105
+ add_web_domains?: string[];
106
+ /** `system:namespace:recordId` refs added to the survivor's `external_refs`. Set only when `rewriteRefs` is on. */
107
+ add_external_refs?: string[];
80
108
  /** Body lines of the copy the merged original lacks; a copy with any is never deleted. */
81
109
  unmerged_body_lines: number;
82
110
  }
@@ -105,6 +133,8 @@ export interface MergeCopiesReport {
105
133
  }[];
106
134
  /** Listed entries the finder did not produce. Absent unless `--only` was set. */
107
135
  only_unmatched?: ListedPair[];
136
+ /** Signal, source and knowledge files re-pointed at a survivor. Absent unless `rewriteRefs` was set. */
137
+ rewritten?: string[];
108
138
  /** Entity-file links whose target file does not exist. */
109
139
  broken_links_before: number;
110
140
  broken_links_after: number;
@@ -123,6 +153,8 @@ export interface MergeCopiesOptions {
123
153
  limit?: number;
124
154
  /** Handle only these exact pairs; every other found pair is skipped as `not_listed`. */
125
155
  only?: ListedPair[];
156
+ /** Re-point refs and links held today, and carry ids and domains (flag merge-rewrite-refs). */
157
+ rewriteRefs?: boolean;
126
158
  now: Date;
127
159
  deleteVaultKey?: DeleteVaultKey;
128
160
  }