@indigoai-us/hq-cli 5.235.0 → 5.237.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.
@@ -29,6 +29,8 @@ export declare function normalizeBotCompanies(values: readonly string[] | undefi
29
29
  export declare function effectiveBotKind(config: Pick<BotConfig, "kind" | "workerId">): BotKind;
30
30
  /** The companies a bot belongs to (empty for a personal bot: its owner's memberships apply). */
31
31
  export declare function effectiveBotCompanies(config: Pick<BotConfig, "kind" | "companies" | "workerId">): string[];
32
+ /** Longest display name (Slack's own limit for a bot's display name). */
33
+ export declare const BOT_DISPLAY_NAME_MAX_CHARS = 35;
32
34
  /** Longest `intro` (first-start hello DM) a bot may store. */
33
35
  export declare const BOT_INTRO_MAX_CHARS = 500;
34
36
  /** Longest `kickoff` (first-start model turn prompt) a bot may store. */
@@ -61,6 +63,11 @@ export declare function validateBotEffort(runtime: BotRuntimeId, value: string):
61
63
  * it cannot be a model id.
62
64
  */
63
65
  export declare function validateBotModel(value: string): string | undefined;
66
+ /**
67
+ * Trim and check a display name: the human name people see in HQ (the DM list,
68
+ * the bot's own "You are …"). The handle (`name`) stays the CLI's key.
69
+ */
70
+ export declare function validateBotDisplayName(text: string): string;
64
71
  /** Trim and bound the intro text; throws an expected error when unusable. */
65
72
  export declare function validateBotIntro(text: string): string;
66
73
  /** Trim and bound the kickoff prompt; throws an expected error when unusable. */
@@ -112,6 +119,11 @@ export interface BotConfig {
112
119
  * the synced tree (~/.hq/bots/<name>/memory).
113
120
  */
114
121
  memoryDir?: string;
122
+ /**
123
+ * The human name people see (e.g. "Maya"). Sent to HQ as the bot's name at
124
+ * create; `name` stays the handle every hq bot command uses. Unset = `name`.
125
+ */
126
+ displayName?: string;
115
127
  /**
116
128
  * Verbatim hello the bot DMs its owner when it first comes online (and on
117
129
  * `hq bot intro`). Unset = the default introduction.
@@ -64,6 +64,8 @@ export function effectiveBotCompanies(config) {
64
64
  }
65
65
  return listed;
66
66
  }
67
+ /** Longest display name (Slack's own limit for a bot's display name). */
68
+ export const BOT_DISPLAY_NAME_MAX_CHARS = 35;
67
69
  /** Longest `intro` (first-start hello DM) a bot may store. */
68
70
  export const BOT_INTRO_MAX_CHARS = 500;
69
71
  /** Longest `kickoff` (first-start model turn prompt) a bot may store. */
@@ -120,6 +122,29 @@ export function validateBotModel(value) {
120
122
  }
121
123
  return v;
122
124
  }
125
+ /**
126
+ * Trim and check a display name: the human name people see in HQ (the DM list,
127
+ * the bot's own "You are …"). The handle (`name`) stays the CLI's key.
128
+ */
129
+ export function validateBotDisplayName(text) {
130
+ const trimmed = (text ?? "").replace(/\s+/g, " ").trim();
131
+ if (!trimmed) {
132
+ throw Object.assign(new Error("--display-name needs some text (the name people see, e.g. Maya)."), {
133
+ expected: true,
134
+ });
135
+ }
136
+ if (trimmed.length > BOT_DISPLAY_NAME_MAX_CHARS) {
137
+ throw Object.assign(new Error(`--display-name is too long (${trimmed.length} chars); keep it under ${BOT_DISPLAY_NAME_MAX_CHARS}.`), {
138
+ expected: true,
139
+ });
140
+ }
141
+ if (!/^[\p{L}\p{M}][\p{L}\p{M} .'-]*$/u.test(trimmed)) {
142
+ throw Object.assign(new Error("--display-name can use letters, spaces, apostrophes, periods and hyphens."), {
143
+ expected: true,
144
+ });
145
+ }
146
+ return trimmed;
147
+ }
123
148
  /** Trim and bound the intro text; throws an expected error when unusable. */
124
149
  export function validateBotIntro(text) {
125
150
  const trimmed = (text ?? "").replace(/\r\n/g, "\n").trim();
@@ -34,13 +34,14 @@ export interface BotLocalConfig {
34
34
  autoApprove?: boolean;
35
35
  /** Only the memory *kind* travels — never an absolute path from another Mac. */
36
36
  memory?: BotMemoryMode;
37
+ displayName?: string;
37
38
  intro?: string;
38
39
  kickoff?: string;
39
40
  }
40
41
  /** Walks a value and throws when any key looks like it could carry a secret. */
41
42
  export declare function assertNoSecretKeys(value: unknown, path?: string): void;
42
43
  /** The bot.json fields a localConfig is built from — nothing else is read. */
43
- export type BotLocalConfigSource = Pick<BotConfig, "runtime" | "workerSource" | "workerId" | "companySlug" | "model" | "effort" | "kind" | "companies" | "autoApprove" | "memoryDir" | "intro" | "kickoff">;
44
+ export type BotLocalConfigSource = Pick<BotConfig, "runtime" | "workerSource" | "workerId" | "companySlug" | "model" | "effort" | "kind" | "companies" | "autoApprove" | "memoryDir" | "displayName" | "intro" | "kickoff">;
44
45
  /**
45
46
  * The non-secret settings of a bot, for the cloud to keep. Absolute paths
46
47
  * (hqRoot, memoryDir, workerDir) are deliberately left out: they belong to the
@@ -51,6 +51,7 @@ export function buildBotLocalConfig(config) {
51
51
  ...(kind === "company" ? { companies } : {}),
52
52
  ...(config.autoApprove === false ? { autoApprove: false } : {}),
53
53
  memory: botMemoryMode(config),
54
+ ...(config.displayName ? { displayName: config.displayName } : {}),
54
55
  ...(config.intro ? { intro: config.intro } : {}),
55
56
  ...(config.kickoff ? { kickoff: config.kickoff } : {}),
56
57
  };
@@ -107,6 +108,9 @@ export function parseBotLocalConfig(value) {
107
108
  const memory = str("memory");
108
109
  if (isBotMemoryMode(memory))
109
110
  out.memory = memory;
111
+ const displayName = str("displayName");
112
+ if (displayName)
113
+ out.displayName = displayName;
110
114
  const intro = str("intro");
111
115
  if (intro)
112
116
  out.intro = intro;
@@ -27,6 +27,9 @@ export declare const PROGRESS_COALESCE_MS = 2000;
27
27
  export declare const PROGRESS_MIN_GAP_MS = 4000;
28
28
  export declare const WORKING_NOTICE_MS = 60000;
29
29
  export declare const WORKING_NOTICE_TEXT = "Working on it \u2014 I'll reply here when it's done.";
30
+ /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
+ export declare const SETUP_WORKING_NOTICE_MS = 30000;
32
+ export declare const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
30
33
  export interface ProgressPosterOptions {
31
34
  /** Post one message; resolve true when it was delivered. */
32
35
  send: (body: string) => Promise<boolean>;
@@ -49,6 +52,14 @@ export interface ProgressPosterOptions {
49
52
  * (when the turn is not silent); `abandon` posts nothing.
50
53
  */
51
54
  buffer?: boolean;
55
+ /**
56
+ * Never post a message the model wrote right before a tool call. Those are
57
+ * the model's notes to itself ("Now write the worker."), not replies: the
58
+ * runtime stream shows them as a text block followed by a tool_use. A
59
+ * message is held until the next event says which it was: a tool call drops
60
+ * it, anything else (another message, the end of the turn) keeps it.
61
+ */
62
+ dropPreToolMessages?: boolean;
52
63
  }
53
64
  /** Whitespace-insensitive form used to recognise repeated text. */
54
65
  export declare function normalizeForCompare(text: string): string;
@@ -65,6 +76,9 @@ export declare class ProgressPoster {
65
76
  private readonly workingNoticeMs;
66
77
  private readonly workingNoticeText;
67
78
  private readonly buffer;
79
+ private readonly dropPreTool;
80
+ /** dropPreToolMessages: messages waiting to learn whether a tool call follows. */
81
+ private candidates;
68
82
  private pending;
69
83
  /** In-between questions, held until the turn ends (see the header). */
70
84
  private heldQuestions;
@@ -77,11 +91,22 @@ export declare class ProgressPoster {
77
91
  private finished;
78
92
  private noticeSent;
79
93
  private delivered;
94
+ /**
95
+ * Posts handed to the send chain this turn, counted when queued rather than
96
+ * when delivered. The "(no reply)" fallback checks this: a post still in
97
+ * flight when the turn ends has not bumped `delivered` yet, and counting only
98
+ * deliveries posted the placeholder right after a real message.
99
+ */
100
+ private queued;
80
101
  constructor(opts: ProgressPosterOptions);
81
102
  /** Arm the one-time "working on it" note. Call when the turn starts. */
82
103
  start(): void;
83
104
  /** One finished assistant message from the model. */
84
105
  message(text: string): void;
106
+ /** The model called a tool: whatever it wrote just before was a note to itself. */
107
+ tool(): void;
108
+ private promoteCandidates;
109
+ private accept;
85
110
  /** Posts that actually reached the chat (progress, notice and final). */
86
111
  get postedCount(): number;
87
112
  /** True once any message from the model was said this turn (not counting the notice). */
@@ -27,6 +27,9 @@ export const PROGRESS_COALESCE_MS = 2_000;
27
27
  export const PROGRESS_MIN_GAP_MS = 4_000;
28
28
  export const WORKING_NOTICE_MS = 60_000;
29
29
  export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's done.";
30
+ /** The setup bot's heads-up, sent once when a turn runs past SETUP_WORKING_NOTICE_MS. */
31
+ export const SETUP_WORKING_NOTICE_MS = 30_000;
32
+ export const SETUP_WORKING_NOTICE_TEXT = "This one takes a minute. I'll reply here as soon as it's done.";
30
33
  /** Whitespace-insensitive form used to recognise repeated text. */
31
34
  export function normalizeForCompare(text) {
32
35
  return text.replace(/\s+/g, "");
@@ -46,6 +49,9 @@ export class ProgressPoster {
46
49
  workingNoticeMs;
47
50
  workingNoticeText;
48
51
  buffer;
52
+ dropPreTool;
53
+ /** dropPreToolMessages: messages waiting to learn whether a tool call follows. */
54
+ candidates = [];
49
55
  pending = [];
50
56
  /** In-between questions, held until the turn ends (see the header). */
51
57
  heldQuestions = [];
@@ -58,6 +64,13 @@ export class ProgressPoster {
58
64
  finished = false;
59
65
  noticeSent = false;
60
66
  delivered = 0;
67
+ /**
68
+ * Posts handed to the send chain this turn, counted when queued rather than
69
+ * when delivered. The "(no reply)" fallback checks this: a post still in
70
+ * flight when the turn ends has not bumped `delivered` yet, and counting only
71
+ * deliveries posted the placeholder right after a real message.
72
+ */
73
+ queued = 0;
61
74
  constructor(opts) {
62
75
  this.send = opts.send;
63
76
  this.sendFinalFn = opts.sendFinal;
@@ -69,6 +82,7 @@ export class ProgressPoster {
69
82
  this.workingNoticeMs = opts.workingNoticeMs ?? WORKING_NOTICE_MS;
70
83
  this.workingNoticeText = opts.workingNoticeText ?? WORKING_NOTICE_TEXT;
71
84
  this.buffer = opts.buffer ?? false;
85
+ this.dropPreTool = opts.dropPreToolMessages ?? false;
72
86
  }
73
87
  /** Arm the one-time "working on it" note. Call when the turn starts. */
74
88
  start() {
@@ -93,6 +107,27 @@ export class ProgressPoster {
93
107
  // silence signal, not content, and must never be posted as progress.
94
108
  if (containsNoReplySentinel(t))
95
109
  return;
110
+ if (this.dropPreTool) {
111
+ // The previous message was not followed by a tool call: it was real.
112
+ this.promoteCandidates();
113
+ this.candidates.push(t);
114
+ return;
115
+ }
116
+ this.accept(t);
117
+ }
118
+ /** The model called a tool: whatever it wrote just before was a note to itself. */
119
+ tool() {
120
+ if (this.finished || !this.dropPreTool)
121
+ return;
122
+ this.candidates = [];
123
+ }
124
+ promoteCandidates() {
125
+ const kept = this.candidates;
126
+ this.candidates = [];
127
+ for (const t of kept)
128
+ this.accept(t);
129
+ }
130
+ accept(t) {
96
131
  this.cancelNotice();
97
132
  this.said.push(t);
98
133
  // An unmentioned room turn holds every in-between message: nothing is posted
@@ -124,6 +159,8 @@ export class ProgressPoster {
124
159
  async finish(finalText) {
125
160
  if (this.finished)
126
161
  return this.chain;
162
+ // A message the turn ended on (no tool after it) is real.
163
+ this.promoteCandidates();
127
164
  this.finished = true;
128
165
  this.cancelNotice();
129
166
  this.cancelFlush();
@@ -161,7 +198,7 @@ export class ProgressPoster {
161
198
  const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
162
199
  if (!shouldAddFinal && held.length > 0)
163
200
  this.pending.push(...held);
164
- if (this.pending.length === 0 && this.delivered === 0 && !shouldAddFinal)
201
+ if (this.pending.length === 0 && this.queued === 0 && !shouldAddFinal)
165
202
  this.pending.push("(no reply)");
166
203
  if (this.sendFinalFn && shouldAddFinal) {
167
204
  // Flush any remaining progress messages first, then the final reply separately.
@@ -229,6 +266,7 @@ export class ProgressPoster {
229
266
  // path built this body.
230
267
  if (containsNoReplySentinel(body))
231
268
  return;
269
+ this.queued += 1;
232
270
  this.lastPostAt = this.now();
233
271
  this.chain = this.chain.then(async () => {
234
272
  try {
@@ -77,6 +77,8 @@ export declare function normalizeInstructions(raw: unknown): string | undefined;
77
77
  export declare function readPromptSources(hqRoot: string, workerDir: string, io?: PromptFs, opts?: ReadPromptSourcesOptions): PromptSources;
78
78
  export interface BuildSystemPromptInput {
79
79
  botName: string;
80
+ /** The human name people call the bot (e.g. "Maya"); unset = botName. */
81
+ displayName?: string;
80
82
  agentUid: string;
81
83
  ownerUid: string;
82
84
  hqRoot: string;
@@ -229,6 +229,16 @@ export function readPromptSources(hqRoot, workerDir, io = defaultPromptFs, opts
229
229
  }
230
230
  return { workerYaml, persona: read("persona.md"), memory };
231
231
  }
232
+ /**
233
+ * How the bot names itself: its display name when it has one ("Maya", with the
234
+ * handle kept for commands), else its handle.
235
+ */
236
+ function introName(input) {
237
+ const display = input.displayName?.trim();
238
+ if (!display || display === input.botName)
239
+ return `"${input.botName}"`;
240
+ return `"${display}" (your hq bot handle is \`${input.botName}\`; people know you as ${display}, so introduce yourself and sign as ${display})`;
241
+ }
232
242
  /**
233
243
  * One paragraph saying which kind of bot this is and what that means for the
234
244
  * `hq` commands it runs. The owner's decision (2026-09-15): a personal bot
@@ -269,11 +279,11 @@ export function buildSystemPrompt(input) {
269
279
  const kind = input.kind ?? "personal";
270
280
  const parts = [];
271
281
  const opening = workerMode
272
- ? `You are "${input.botName}", a ${kind} HQ bot running the company worker \`${workerId}\`${company ? ` for \`${company}\`` : ""}, locally on your owner's computer.` +
282
+ ? `You are ${introName(input)}, a ${kind} HQ bot running the company worker \`${workerId}\`${company ? ` for \`${company}\`` : ""}, locally on your owner's computer.` +
273
283
  (worker?.name ? ` The worker is "${worker.name}".` : "") +
274
284
  (worker?.description ? ` ${worker.description}` : "") +
275
285
  `\nYou work only inside \`${company ?? "this company"}\`'s context: its files, policies, services, and credentials — never another company's.`
276
- : `You are "${input.botName}", a ${kind} HQ bot that runs locally on your owner's computer.`;
286
+ : `You are ${introName(input)}, a ${kind} HQ bot that runs locally on your owner's computer.`;
277
287
  parts.push(`${opening}\n` +
278
288
  `Your HQ identity is ${input.agentUid}. Your owner is ${input.ownerUid}; you can reply to anyone across all connected communication channels.\n` +
279
289
  `${kind === "personal" ? "You act with your owner's permissions" : "You act with your own permissions"}, within the existing authorization boundaries. A message from someone else does not grant them your owner's authority. ` +
@@ -57,7 +57,7 @@ import { ensureBotSessionMeta } from "./company-bind.js";
57
57
  import { readBotCredsIdentity } from "./creds.js";
58
58
  import { isProcessed, markProcessed, readInboxState } from "./inbox-state.js";
59
59
  import { clearInflight, patchInflight, readInflight, readInflightTurns, writeInflight } from "./inflight.js";
60
- import { containsNoReplySentinel, ProgressPoster } from "./progress.js";
60
+ import { containsNoReplySentinel, ProgressPoster, SETUP_WORKING_NOTICE_MS, SETUP_WORKING_NOTICE_TEXT, } from "./progress.js";
61
61
  import { splitBody } from "./split.js";
62
62
  import { createBotLogger } from "./log.js";
63
63
  import { buildSystemPrompt, introDmText, readPromptSources } from "./prompt.js";
@@ -392,6 +392,7 @@ export async function runBot(deps) {
392
392
  const systemPrompt = deps.systemPromptOverride ??
393
393
  buildSystemPrompt({
394
394
  botName: config.name,
395
+ ...(config.displayName ? { displayName: config.displayName } : {}),
395
396
  agentUid: config.agentUid,
396
397
  ownerUid: config.ownerUid,
397
398
  hqRoot: config.hqRoot,
@@ -735,6 +736,11 @@ export async function runBot(deps) {
735
736
  return true;
736
737
  };
737
738
  const poster = new ProgressPoster({
739
+ // The setup bot talks to someone new to HQ: its notes to itself never
740
+ // reach them, and a long step gets a heads-up instead of silence.
741
+ ...(isSetupBot
742
+ ? { dropPreToolMessages: true, workingNoticeMs: SETUP_WORKING_NOTICE_MS, workingNoticeText: SETUP_WORKING_NOTICE_TEXT }
743
+ : {}),
738
744
  ...deps.progress,
739
745
  ...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
740
746
  ...(silenceable ? { buffer: true } : {}),
@@ -749,6 +755,8 @@ export async function runBot(deps) {
749
755
  onEvent: (event) => {
750
756
  if (event.kind === "message")
751
757
  poster.message(event.text);
758
+ else if (event.kind === "tool")
759
+ poster.tool();
752
760
  },
753
761
  signal: controller.signal,
754
762
  ...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
@@ -1450,7 +1458,7 @@ export async function runBot(deps) {
1450
1458
  tReceived,
1451
1459
  send,
1452
1460
  sendFinal,
1453
- workingNotice: !isSetupBot,
1461
+ // The setup bot's own notice (SETUP_WORKING_NOTICE_TEXT) is set where the poster is built.
1454
1462
  silenceable: target.kind === "room" && !target.mentioned,
1455
1463
  ...(readDirs.length ? { readDirs } : {}),
1456
1464
  ...(target.kind === "room"
@@ -52,6 +52,9 @@ export declare function tryReadLane(hqRoot: string, laneId: string, deps?: Store
52
52
  export declare function readLane(hqRoot: string, laneId: string, deps?: StoreDeps): LaneRecord;
53
53
  export declare function tryReadQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord | null;
54
54
  export declare function readQuestion(hqRoot: string, questionId: string, deps?: StoreDeps): QuestionRecord;
55
+ /** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
56
+ export declare function isLaneStoreVersionNewer(storedVersion: string, runningVersion: string): boolean;
57
+ export declare const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
55
58
  export declare function createLane(hqRoot: string, init: Omit<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at"> & Partial<Pick<LaneRecord, "schema_version" | "lane_id" | "version" | "created_at" | "updated_at">>, deps?: StoreDeps): LaneRecord;
56
59
  /** Hierarchy lock: separate from record writes so callers can CAS-write while holding it. */
57
60
  export declare function withLaneHireLocks<T>(hqRoot: string, laneIds: readonly string[], fn: () => T, deps?: StoreDeps): T;
@@ -10,6 +10,8 @@ import { randomBytes } from "node:crypto";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import semver from "semver";
14
+ import { CLI_VERSION } from "../../cli-version.js";
13
15
  import { generateUlid } from "../mesh/live/ulid.js";
14
16
  import { resolveLanesHqRoot } from "./hq-root.js";
15
17
  import { LaneNotFoundError, LaneSchemaError, LaneVersionConflict, LanesError, isLaneVersionConflict, } from "./errors.js";
@@ -391,6 +393,26 @@ export function parseLaneRecord(raw, filePath) {
391
393
  const schema_version = requireString(raw.schema_version, "schema_version");
392
394
  parseMajorSchema(schema_version, "hq-lane", 1);
393
395
  requireString(raw.lane_id, "lane_id");
396
+ if (raw.written_by_version !== undefined) {
397
+ const writtenByVersion = requireString(raw.written_by_version, "written_by_version");
398
+ if (!semver.valid(writtenByVersion)) {
399
+ throw new LaneSchemaError("written_by_version must be a valid semver version", {
400
+ path: filePath,
401
+ field: "written_by_version",
402
+ value: writtenByVersion,
403
+ });
404
+ }
405
+ }
406
+ if (raw.owner_written_by_version !== undefined) {
407
+ const ownerWrittenByVersion = requireString(raw.owner_written_by_version, "owner_written_by_version");
408
+ if (!semver.valid(ownerWrittenByVersion)) {
409
+ throw new LaneSchemaError("owner_written_by_version must be a valid semver version", {
410
+ path: filePath,
411
+ field: "owner_written_by_version",
412
+ value: ownerWrittenByVersion,
413
+ });
414
+ }
415
+ }
394
416
  requireInt(raw.version, "version");
395
417
  requireInt(raw.depth, "depth");
396
418
  requireInt(raw.priority, "priority");
@@ -950,6 +972,52 @@ export function readQuestion(hqRoot, questionId, deps = defaultStoreDeps()) {
950
972
  throw new LaneNotFoundError("question", questionId);
951
973
  return record;
952
974
  }
975
+ /** Semver precedence includes prerelease identifiers (for example, rc.2 > rc.1). */
976
+ export function isLaneStoreVersionNewer(storedVersion, runningVersion) {
977
+ if (!semver.valid(storedVersion)) {
978
+ throw new LanesError("lane_store_version_invalid", "Lane store writer version is not valid semver", {
979
+ stored_version: storedVersion,
980
+ });
981
+ }
982
+ if (!semver.valid(runningVersion)) {
983
+ throw new LanesError("lane_store_version_invalid", "Running hq-cli version is not valid semver", {
984
+ running_version: runningVersion,
985
+ });
986
+ }
987
+ return semver.gt(storedVersion, runningVersion);
988
+ }
989
+ const OLDER_LANE_STORE_WRITE_OVERRIDE = "HQ_LANES_ALLOW_OLDER_WRITE";
990
+ export const LANE_STORE_UPGRADE_COMMAND = "npm install -g @indigoai-us/hq-cli@latest";
991
+ const warnedBackgroundWriterLaneIds = new Set();
992
+ function assertLaneStoreWriteAllowed(lane, laneId, writer) {
993
+ const storedVersion = lane.written_by_version;
994
+ if (storedVersion === undefined || !isLaneStoreVersionNewer(storedVersion, CLI_VERSION))
995
+ return;
996
+ const warning = `Lane ${laneId} was last written by hq-cli ${storedVersion}, newer than running hq-cli ${CLI_VERSION}. Upgrade with \`${LANE_STORE_UPGRADE_COMMAND}\` before writing.`;
997
+ if (writer === "command" || writer === "creator") {
998
+ if (process.env[OLDER_LANE_STORE_WRITE_OVERRIDE] === "1") {
999
+ console.warn(`${warning} Proceeding because ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1 was set for this command.`);
1000
+ return;
1001
+ }
1002
+ throw new LanesError("lane_store_newer_cli", `${warning} For a one-command override, set ${OLDER_LANE_STORE_WRITE_OVERRIDE}=1.`, {
1003
+ lane_id: laneId,
1004
+ stored_version: storedVersion,
1005
+ running_version: CLI_VERSION,
1006
+ upgrade_command: LANE_STORE_UPGRADE_COMMAND,
1007
+ override: `${OLDER_LANE_STORE_WRITE_OVERRIDE}=1`,
1008
+ });
1009
+ }
1010
+ if (!warnedBackgroundWriterLaneIds.has(laneId)) {
1011
+ warnedBackgroundWriterLaneIds.add(laneId);
1012
+ console.warn(`${warning} Continuing background ${writer} writes and preserving the newer stamp.`);
1013
+ }
1014
+ }
1015
+ function newestLaneStoreVersion(storedVersion) {
1016
+ if (storedVersion !== undefined && isLaneStoreVersionNewer(storedVersion, CLI_VERSION)) {
1017
+ return storedVersion;
1018
+ }
1019
+ return CLI_VERSION;
1020
+ }
953
1021
  function recordsEquivalentAsideFromClock(current, updated) {
954
1022
  if (updated === current)
955
1023
  return true;
@@ -961,7 +1029,7 @@ function recordsEquivalentAsideFromClock(current, updated) {
961
1029
  };
962
1030
  return JSON.stringify(strip(current)) === JSON.stringify(strip(updated));
963
1031
  }
964
- function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
1032
+ function casWrite(filePath, expectedVersion, parse, merge, updater, deps, options = {}) {
965
1033
  return withRecordLock(filePath, deps, () => {
966
1034
  const current = readMergedRecord(filePath, parse, merge, deps);
967
1035
  if (current === null) {
@@ -980,9 +1048,11 @@ function casWrite(filePath, expectedVersion, parse, merge, updater, deps) {
980
1048
  if (recordsEquivalentAsideFromClock(current, updated)) {
981
1049
  return current;
982
1050
  }
1051
+ options.beforeWrite?.(current);
983
1052
  const next = {
984
1053
  ...current,
985
1054
  ...updated,
1055
+ ...options.stamp?.(current),
986
1056
  version: current.version + 1,
987
1057
  updated_at: deps.now().toISOString(),
988
1058
  };
@@ -996,6 +1066,7 @@ export function createLane(hqRoot, init, deps = defaultStoreDeps()) {
996
1066
  const filePath = laneRecordPath(hqRoot, lane_id);
997
1067
  const record = {
998
1068
  ...init,
1069
+ written_by_version: CLI_VERSION,
999
1070
  hire_grant: init.hire_grant ?? 0,
1000
1071
  schema_version: init.schema_version ?? LANE_SCHEMA_VERSION,
1001
1072
  lane_id,
@@ -1060,7 +1131,10 @@ export function updateLane(hqRoot, laneId, expectedVersion, writer, updater, dep
1060
1131
  }
1061
1132
  }
1062
1133
  return updated;
1063
- }, deps);
1134
+ }, deps, {
1135
+ beforeWrite: (current) => assertLaneStoreWriteAllowed(current, laneId, writer),
1136
+ stamp: (current) => ({ written_by_version: newestLaneStoreVersion(current.written_by_version) }),
1137
+ });
1064
1138
  if (laneStoryLedgerEnabled() && before && laneLedgerRelevantChange(before, updated)) {
1065
1139
  return syncStoryLedgerForLane(hqRoot, updated, deps);
1066
1140
  }
@@ -1182,7 +1256,12 @@ export function syncLaneRecordFromOwner(hqRoot, laneId, outpostId, rawOwnerLane,
1182
1256
  current.outpost_command_id !== undefined
1183
1257
  ? { ...ownerLane, outpost_command_id: current.outpost_command_id }
1184
1258
  : ownerLane;
1185
- const synced = mergeLaneRecords(ownerSnapshot, [current]);
1259
+ const merged = mergeLaneRecords(ownerSnapshot, [current]);
1260
+ const synced = {
1261
+ ...merged,
1262
+ written_by_version: newestLaneStoreVersion(current.written_by_version),
1263
+ owner_written_by_version: ownerLane.written_by_version,
1264
+ };
1186
1265
  parseLaneRecord(synced, filePath);
1187
1266
  atomicWriteJson(filePath, synced, deps);
1188
1267
  return synced;
@@ -318,6 +318,10 @@ export interface LaneStory {
318
318
  export interface LaneRecord {
319
319
  schema_version: typeof LANE_SCHEMA_VERSION | string;
320
320
  lane_id: string;
321
+ /** CLI release that most recently wrote this lane record. Absent on legacy rows. */
322
+ written_by_version?: string;
323
+ /** CLI release that most recently wrote the owner-host copy synced to primary. */
324
+ owner_written_by_version?: string;
321
325
  /** `null` is a core/personal lane (spec §6.2 / §10). Not an empty slug. */
322
326
  company: LaneCompanyRef | null;
323
327
  /** Explicit Work Mesh attribution for a personal lane; never inferred from cwd. */
@@ -216,7 +216,7 @@ export const REQUIRED_LANE_RECORD_FIELDS = [
216
216
  export const LANE_WRITERS = ["creator", "command", "watcher", "reconcile", "spawn", "story-ledger"];
217
217
  /** Field ownership at the durable-record mutation boundary (spec §10.1). */
218
218
  export const LANE_FIELD_WRITERS = {
219
- schema_version: ["creator"], lane_id: ["creator"], company: ["creator"],
219
+ schema_version: ["creator"], lane_id: ["creator"], written_by_version: [], owner_written_by_version: [], company: ["creator"],
220
220
  project_id: ["command"], story_id: ["command"], no_story_reason: ["creator", "command"], ledger_sync_error: ["story-ledger"], pending_question_ids: ["story-ledger"], stories: ["creator", "command", "watcher", "reconcile", "spawn"], thread_id: ["creator", "command", "spawn"], mesh_company: ["creator", "command"],
221
221
  depth: ["command"], senior: ["command"], senior_history: ["command"],
222
222
  hire_grant: ["creator", "command"],
@@ -1,28 +1,19 @@
1
1
  /**
2
2
  * Node network defaults for HQ CLI entry points.
3
3
  *
4
- * The compatibility timeout remains the default. On a canary where IPv6 is
5
- * advertised but unusable, the default-off hq-flags gate can also prefer IPv4
6
- * and disable automatic family racing before the CLI issues its first request.
4
+ * The compatibility timeout remains the default for HQ CLI entry points.
7
5
  *
8
6
  * Keep this module safe to import immediately after the Node version preflight.
9
- * The hq-flags reader loads dynamically so an unsupported runtime exits through
10
- * the preflight rather than failing during module linking.
11
7
  */
12
8
  export declare const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
13
9
  export interface NodeNetworkCompatibilityOptions {
14
10
  nodeOptions?: string;
15
11
  execArgv?: readonly string[];
16
- forceIpv4?: boolean;
17
12
  /**
18
13
  * Inject the net timeout setter (tests). Pass null to simulate a runtime
19
14
  * that lacks setDefaultAutoSelectFamilyAttemptTimeout.
20
15
  */
21
16
  setAttemptTimeout?: ((milliseconds: number) => void) | null;
22
- /** Inject Node's automatic family selection setter (tests). */
23
- setAutoSelectFamily?: ((enabled: boolean) => void) | null;
24
- /** Inject Node's DNS result-order setter (tests). */
25
- setDefaultResultOrder?: ((order: "ipv4first") => void) | null;
26
17
  }
27
18
  export declare function configureNodeNetworkCompatibility(options?: NodeNetworkCompatibilityOptions): boolean;
28
19
  //# sourceMappingURL=node-network-compat.d.ts.map