@indigoai-us/hq-cli 5.184.0 → 5.185.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.185.0] — 2026-09-25
6
+
7
+ ### Fixed
8
+
9
+ - Local bots no longer post their no-reply signal, narration, blank messages, or raw model errors into rooms. On a room message that does not mention the bot, in-between messages and the working notice are held until the turn ends, and nothing is posted when the reply is the `{"action":"no_reply"}` signal or empty. Any model failure on such a turn (not only a Grok `reasoning_only` empty response, but every model error) is now silent instead of leaking "I didn't finish that — <model> stopped with an error: <raw excerpt>" into the room. When the turn does produce content, the held in-between messages are posted alongside the final answer, so a bot that answers mid-turn and ends with a short sign-off (claude/codex) no longer loses the answer; a Grok final that just concatenates the in-between messages still posts once with no duplication. The signal is never posted as progress anywhere. DMs and @-mentions still stream and still get a reply; a `reasoning_only` failure there now says "the model returned no answer" instead of the raw internal error, and other failures there keep the existing "did not finish" message. The room prompt also tells the model to produce no output rather than explain that it is staying silent.
10
+
5
11
  ## [5.184.0] — 2026-09-25
6
12
 
7
13
  ### Fixed
@@ -13,6 +19,8 @@
13
19
 
14
20
  - `hq doctor --json` includes stable sync `reasonCode` enums. Client-health reports add `checkId: reasonCode` pairs only when the company-scoped `client-health-sync-reason-codes` hq-flags gate is enabled. `hq doctor --fix --json` reports refresh-sync outcomes without raw error details.
15
21
 
22
+ - Repair known stale QMD collection paths behind the default-off `qmd.collection-path-repair` hq-flags canary while preserving collection metadata.
23
+
16
24
  ## [5.183.0] — 2026-09-25
17
25
 
18
26
  ### Added
@@ -42,9 +42,18 @@ export interface ProgressPosterOptions {
42
42
  minGapMs?: number;
43
43
  workingNoticeMs?: number;
44
44
  workingNoticeText?: string;
45
+ /**
46
+ * Unmentioned room turn: hold every in-between message and the working
47
+ * notice, and post nothing until the turn ends. `finish` then posts the
48
+ * in-between messages followed by the final answer when it adds something
49
+ * (when the turn is not silent); `abandon` posts nothing.
50
+ */
51
+ buffer?: boolean;
45
52
  }
46
53
  /** Whitespace-insensitive form used to recognise repeated text. */
47
54
  export declare function normalizeForCompare(text: string): string;
55
+ /** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
56
+ export declare function containsNoReplySentinel(text: string): boolean;
48
57
  export declare class ProgressPoster {
49
58
  private readonly send;
50
59
  private readonly sendFinalFn?;
@@ -55,6 +64,7 @@ export declare class ProgressPoster {
55
64
  private readonly minGapMs;
56
65
  private readonly workingNoticeMs;
57
66
  private readonly workingNoticeText;
67
+ private readonly buffer;
58
68
  private pending;
59
69
  /** In-between questions, held until the turn ends (see the header). */
60
70
  private heldQuestions;
@@ -31,6 +31,10 @@ export const WORKING_NOTICE_TEXT = "Working on it — I'll reply here when it's
31
31
  export function normalizeForCompare(text) {
32
32
  return text.replace(/\s+/g, "");
33
33
  }
34
+ /** Matches {"action":"no_reply"} anywhere in a model reply, with or without preamble text. */
35
+ export function containsNoReplySentinel(text) {
36
+ return /\{\s*"action"\s*:\s*"no_reply"\s*\}/.test(text);
37
+ }
34
38
  export class ProgressPoster {
35
39
  send;
36
40
  sendFinalFn;
@@ -41,6 +45,7 @@ export class ProgressPoster {
41
45
  minGapMs;
42
46
  workingNoticeMs;
43
47
  workingNoticeText;
48
+ buffer;
44
49
  pending = [];
45
50
  /** In-between questions, held until the turn ends (see the header). */
46
51
  heldQuestions = [];
@@ -63,9 +68,12 @@ export class ProgressPoster {
63
68
  this.minGapMs = opts.minGapMs ?? PROGRESS_MIN_GAP_MS;
64
69
  this.workingNoticeMs = opts.workingNoticeMs ?? WORKING_NOTICE_MS;
65
70
  this.workingNoticeText = opts.workingNoticeText ?? WORKING_NOTICE_TEXT;
71
+ this.buffer = opts.buffer ?? false;
66
72
  }
67
73
  /** Arm the one-time "working on it" note. Call when the turn starts. */
68
74
  start() {
75
+ if (this.buffer)
76
+ return;
69
77
  if (this.finished || this.noticeTimer !== null || this.workingNoticeMs <= 0)
70
78
  return;
71
79
  this.noticeTimer = this.setTimer(() => {
@@ -81,8 +89,16 @@ export class ProgressPoster {
81
89
  const t = text.trim();
82
90
  if (this.finished || !t)
83
91
  return;
92
+ // Never treat the no_reply sentinel as something the model "said": it is a
93
+ // silence signal, not content, and must never be posted as progress.
94
+ if (containsNoReplySentinel(t))
95
+ return;
84
96
  this.cancelNotice();
85
97
  this.said.push(t);
98
+ // An unmentioned room turn holds every in-between message: nothing is posted
99
+ // until the turn ends, and then only the final answer.
100
+ if (this.buffer)
101
+ return;
86
102
  if (t.includes("?")) {
87
103
  this.heldQuestions.push(t);
88
104
  return;
@@ -112,6 +128,34 @@ export class ProgressPoster {
112
128
  this.cancelNotice();
113
129
  this.cancelFlush();
114
130
  const final = finalText.trim();
131
+ // Buffered (unmentioned room): nothing was posted during the turn. Post the
132
+ // collected in-between messages (the sentinel is already excluded from
133
+ // `said`), then the final answer when it adds something the in-between
134
+ // messages did not already say — mirroring the streaming path's
135
+ // repeatsSaid logic. claude/codex answer mid-turn and end with a short
136
+ // sign-off, so dropping the in-between messages would lose the real answer;
137
+ // Grok's final is every message concatenated, so it repeats and is skipped.
138
+ // A silent turn (run.ts decides via sentinel/empty) never reaches here.
139
+ if (this.buffer) {
140
+ this.pending = [];
141
+ this.heldQuestions = [];
142
+ for (const s of this.said) {
143
+ if (!containsNoReplySentinel(s))
144
+ this.pending.push(s);
145
+ }
146
+ if (final && !containsNoReplySentinel(final) && !this.repeatsSaid(final)) {
147
+ if (this.sendFinalFn) {
148
+ // Keep the audience split: collected messages go through send, the
149
+ // final reply through sendFinal.
150
+ this.flushNow();
151
+ this.enqueueWith(final, this.sendFinalFn);
152
+ return this.chain;
153
+ }
154
+ this.pending.push(final);
155
+ }
156
+ this.flushNow();
157
+ return this.chain;
158
+ }
115
159
  const held = this.heldQuestions;
116
160
  this.heldQuestions = [];
117
161
  const shouldAddFinal = Boolean(final && !this.repeatsSaid(final));
@@ -141,6 +185,13 @@ export class ProgressPoster {
141
185
  this.finished = true;
142
186
  this.cancelNotice();
143
187
  this.cancelFlush();
188
+ // Buffered turns never post salvage: nothing streamed, so there is nothing
189
+ // to salvage, and the caller decides whether to post anything.
190
+ if (this.buffer) {
191
+ this.pending = [];
192
+ this.heldQuestions = [];
193
+ return this.chain;
194
+ }
144
195
  this.pending.push(...this.heldQuestions);
145
196
  this.heldQuestions = [];
146
197
  this.flushNow();
@@ -174,6 +225,10 @@ export class ProgressPoster {
174
225
  this.enqueueWith(body, this.send);
175
226
  }
176
227
  enqueueWith(body, sendFn) {
228
+ // Defence in depth: never let the silence sentinel reach the chat, whatever
229
+ // path built this body.
230
+ if (containsNoReplySentinel(body))
231
+ return;
177
232
  this.lastPostAt = this.now();
178
233
  this.chain = this.chain.then(async () => {
179
234
  try {
@@ -288,7 +288,9 @@ export function buildSystemPrompt(input) {
288
288
  `Your owner can also add you to group chats and channels. A message that starts with a \`[#channel · N members · from Name]\` line ` +
289
289
  `comes from a room with other people in it (a "Recent messages:" block may follow for context; the last part is the message you are answering). ` +
290
290
  `Answer for the room: reply to the person who asked, keep it short, and never repeat your private memory, your owner's files, ` +
291
- `or anything from your DMs to other people.`);
291
+ `or anything from your DMs to other people. ` +
292
+ `If the message is not addressed to you, is routine automated output, or you have nothing meaningful to add, produce zero output (completely empty response). ` +
293
+ `Do not narrate your reasoning, do not print JSON, do not explain that you are staying silent. Just produce nothing.`);
292
294
  // Only company bots: the cloud's jobs routes refuse a personal bot's per_
293
295
  // company claim today, so telling a personal bot it can schedule work makes
294
296
  // it promise its owner something that then fails. Drop this condition when
@@ -47,7 +47,7 @@
47
47
  import { type OwnerContext } from "./owner-context.js";
48
48
  import { type BotApi, type InboxItem } from "./api.js";
49
49
  import type { BotConfig, BotKind } from "./config.js";
50
- import { type ProgressPosterOptions } from "./progress.js";
50
+ import { containsNoReplySentinel, type ProgressPosterOptions } from "./progress.js";
51
51
  import { type BotLogger } from "./log.js";
52
52
  import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "./runtime/index.js";
53
53
  import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
@@ -188,6 +188,9 @@ export declare function turnEnv(base: NodeJS.ProcessEnv, kind: BotKind, agentUid
188
188
  export declare const SETUP_WORKER_ID = "setup";
189
189
  /** The one message a person gets when a turn on their message did not finish. */
190
190
  export declare function didNotFinishText(reason: string, progressPosted: boolean): string;
191
+ export { containsNoReplySentinel };
192
+ /** Grok emits is_error with this message when the model produces only reasoning tokens. */
193
+ export declare function isReasoningOnlyError(excerpt: string): boolean;
191
194
  /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
192
195
  export declare const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
193
196
  export declare const RESTARTED_REASON = "I was restarted while I was working on it";
@@ -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 { ProgressPoster } from "./progress.js";
60
+ import { containsNoReplySentinel, ProgressPoster } 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";
@@ -178,6 +178,11 @@ export function didNotFinishText(reason, progressPosted) {
178
178
  const r = reason.trim().replace(/[.\s]+$/, "");
179
179
  return `I didn't finish that — ${r}.${progressPosted ? " The messages above show how far I got." : ""}`;
180
180
  }
181
+ export { containsNoReplySentinel };
182
+ /** Grok emits is_error with this message when the model produces only reasoning tokens. */
183
+ export function isReasoningOnlyError(excerpt) {
184
+ return excerpt.includes("reasoning_only");
185
+ }
181
186
  /** Owner stop, `hq bot stop` or `hq bot restart`: all arrive as the same signal. */
182
187
  export const STOPPED_BY_OWNER_REASON = "I was stopped before I was done";
183
188
  export const RESTARTED_REASON = "I was restarted while I was working on it";
@@ -646,6 +651,7 @@ export async function runBot(deps) {
646
651
  writeInflight(dir, { v: 1, ...args.marker, startedAt: now().toISOString() });
647
652
  let running = true;
648
653
  let posted = 0;
654
+ const silenceable = args.silenceable === true;
649
655
  const makeSendFn = (fn) => async (body) => {
650
656
  try {
651
657
  await fn(body);
@@ -662,7 +668,8 @@ export async function runBot(deps) {
662
668
  };
663
669
  const poster = new ProgressPoster({
664
670
  ...deps.progress,
665
- ...(args.workingNotice === false ? { workingNoticeMs: 0 } : {}),
671
+ ...(args.workingNotice === false || silenceable ? { workingNoticeMs: 0 } : {}),
672
+ ...(silenceable ? { buffer: true } : {}),
666
673
  send: makeSendFn(args.send),
667
674
  ...(args.sendFinal ? { sendFinal: makeSendFn(args.sendFinal) } : {}),
668
675
  });
@@ -679,8 +686,21 @@ export async function runBot(deps) {
679
686
  ...(args.readDirs?.length ? { readDirs: args.readDirs } : {}),
680
687
  });
681
688
  running = false;
682
- await poster.finish(turn.text);
683
- return { outcome: { ok: true, reply: turn.text.trim() || "(no reply)" }, poster };
689
+ const sentinelOrEmpty = containsNoReplySentinel(turn.text) || !turn.text.trim();
690
+ // Unmentioned room turn: a no_reply sentinel (bare, fenced, or with
691
+ // preamble) or an empty/whitespace reply means stay quiet. Abandon the
692
+ // poster (it posts nothing) and return a silent outcome; the caller acks
693
+ // without posting anything.
694
+ if (silenceable && sentinelOrEmpty) {
695
+ await poster.abandon();
696
+ return { outcome: { ok: true, reply: "", silent: true }, poster };
697
+ }
698
+ // DM or @-mentioned room: the person asked directly and must get a reply.
699
+ // Strip the silence sentinel so its raw JSON never posts, then let the
700
+ // poster produce its "(no reply)" fallback when there is nothing else.
701
+ const finalText = sentinelOrEmpty ? "" : turn.text;
702
+ await poster.finish(finalText);
703
+ return { outcome: { ok: true, reply: finalText.trim() }, poster };
684
704
  }
685
705
  catch (err) {
686
706
  running = false;
@@ -984,6 +1004,7 @@ export async function runBot(deps) {
984
1004
  });
985
1005
  await clearThinking();
986
1006
  if (outcome.ok) {
1007
+ // The kickoff never runs silenceable, so outcome.silent is never set here.
987
1008
  if (poster.postedCount === 0) {
988
1009
  patchInflight(dir, KICKOFF_MESSAGE_ID, saved(outcome.reply));
989
1010
  log("warn", "kickoff reply failed to send; it is posted on the next start");
@@ -1334,12 +1355,23 @@ export async function runBot(deps) {
1334
1355
  send,
1335
1356
  sendFinal,
1336
1357
  workingNotice: !isSetupBot,
1358
+ silenceable: target.kind === "room" && !target.mentioned,
1337
1359
  ...(readDirs.length ? { readDirs } : {}),
1338
1360
  ...(target.kind === "room"
1339
1361
  ? { afterProgressPost: () => api.postAgentStatus(target.channelId, ROOM_THINKING_STATUS, target.rootEventId) }
1340
1362
  : {}),
1341
1363
  });
1342
1364
  if (outcome.ok) {
1365
+ // A no_reply sentinel or intentionally empty reply: ack silently, post nothing.
1366
+ if (outcome.silent) {
1367
+ markProcessed(dir, id, now().toISOString());
1368
+ await api.ackInbox(config.agentUid, id);
1369
+ clearInflight(dir, id);
1370
+ attempts.delete(id);
1371
+ await clearThinking();
1372
+ log("info", `no_reply sentinel or empty reply for ${id} (${where}); acked silently`);
1373
+ return;
1374
+ }
1343
1375
  // Known gap (#614, not changed here): a progress post counts, so a final answer that fails after one is not resent.
1344
1376
  if (poster.postedCount === 0) {
1345
1377
  // The work finished but nothing reached the chat: keep the answer —
@@ -1400,8 +1432,31 @@ export async function runBot(deps) {
1400
1432
  // The model started: it may already have done things, so never re-run.
1401
1433
  if (!outcome.aborted)
1402
1434
  recordTurnFailure(outcome.error, sessionScope);
1435
+ // reasoning_only means the model produced only reasoning tokens and no text.
1436
+ const reasoningOnly = !outcome.aborted && isReasoningOnlyError(outcome.excerpt);
1437
+ const silenceableTurn = target.kind === "room" && !target.mentioned;
1438
+ // Unmentioned room turn that posted nothing and was not stopped by the
1439
+ // owner: any model failure (reasoning_only or otherwise) is a silent
1440
+ // no-reply. The failure is already recorded above via recordTurnFailure,
1441
+ // so we just ack quietly and never leak the raw error into the room.
1442
+ if (!outcome.aborted && poster.postedCount === 0 && silenceableTurn) {
1443
+ markProcessed(dir, id, now().toISOString());
1444
+ await api.ackInbox(config.agentUid, id);
1445
+ clearInflight(dir, id);
1446
+ attempts.delete(id);
1447
+ await clearThinking();
1448
+ log("info", `unmentioned room turn ${id} failed with no post (${where}); acked silently`);
1449
+ if (failures.exceeded(now().getTime())) {
1450
+ await finish("failed", 0, `${FAILURE_LIMIT} model failures in 10 minutes; last: ${outcome.excerpt}`);
1451
+ }
1452
+ return;
1453
+ }
1403
1454
  try {
1404
- await send(didNotFinishText(failureReason(outcome), poster.postedCount > 0));
1455
+ // DM or @-mentioned room reasoning_only: the person asked directly, so
1456
+ // still say we did not finish — but with a plain reason, never the raw
1457
+ // internal error string.
1458
+ const reason = reasoningOnly ? "the model returned no answer" : failureReason(outcome);
1459
+ await send(didNotFinishText(reason, poster.postedCount > 0));
1405
1460
  }
1406
1461
  catch (err) {
1407
1462
  // Leave it un-acked with the marker: the next poll says it did not finish.
@@ -0,0 +1,4 @@
1
+ export declare const QMD_COLLECTION_PATH_REPAIR_FLAG = "qmd.collection-path-repair";
2
+ /** Keep collection-path repair off unless the hq-flags registry enables a canary. */
3
+ export declare function qmdCollectionPathRepairEnabled(): boolean;
4
+ //# sourceMappingURL=collection-path-repair-flag.d.ts.map
@@ -0,0 +1,7 @@
1
+ import { resolveProcessFlagGate } from '../flag-registry.js';
2
+ export const QMD_COLLECTION_PATH_REPAIR_FLAG = 'qmd.collection-path-repair';
3
+ /** Keep collection-path repair off unless the hq-flags registry enables a canary. */
4
+ export function qmdCollectionPathRepairEnabled() {
5
+ return resolveProcessFlagGate(QMD_COLLECTION_PATH_REPAIR_FLAG, { fallback: false }, () => false);
6
+ }
7
+ //# sourceMappingURL=collection-path-repair-flag.js.map
@@ -537,10 +537,14 @@ export declare function deriveCollections(hqRoot: string): SearchCollection[];
537
537
  export type ReconcileCollectionsOptions = {
538
538
  bin?: string;
539
539
  runner?: QmdProcessRunner;
540
+ /** Test seam for the default-off collection-path repair rollout. */
541
+ repairPathDrift?: boolean;
540
542
  };
541
543
  /**
542
544
  * Register expected collections that qmd does not yet know about, returning the
543
545
  * collections this call actually created (a `collection add` that exited 0).
546
+ * When the default-off hq-flags rollout is enabled, it also replaces a registered
547
+ * collection whose path matches a known prior HQ-managed layout.
544
548
  *
545
549
  * A same-name idempotent duplicate — qmd already owns the exact requested name
546
550
  * in its YAML config (`Collection '<name>' already exists.`), while the
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as os from 'node:os';
4
4
  import * as path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { dump, load } from 'js-yaml';
6
7
  import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
7
8
  import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
8
9
  import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
@@ -12,6 +13,8 @@ import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-erro
12
13
  import { markExpectedUserError } from '../../utils/expected-cli-error.js';
13
14
  import { redactErrorText } from '../../utils/redact-error-text.js';
14
15
  import { planCommandSpawn } from '../../utils/windows-spawn.js';
16
+ import { resolveQmdConfigPath } from './max-doc-bytes.js';
17
+ import { qmdCollectionPathRepairEnabled } from './collection-path-repair-flag.js';
15
18
  /** The `path` implementation for a (possibly injected) platform. */
16
19
  function pathFor(platform) {
17
20
  return platform === 'win32' ? path.win32 : path.posix;
@@ -1350,9 +1353,90 @@ export function deriveCollections(hqRoot) {
1350
1353
  }
1351
1354
  return collections;
1352
1355
  }
1356
+ function isRecord(value) {
1357
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
1358
+ }
1359
+ function qmdConfigPath(hqRoot) {
1360
+ const env = {
1361
+ ...process.env,
1362
+ HOME: process.env.HOME ?? process.env.USERPROFILE ?? os.homedir(),
1363
+ };
1364
+ return resolveQmdConfigPath({ env, startDir: hqRoot });
1365
+ }
1366
+ /** Read qmd's configured collection metadata from the same config file its CLI uses. */
1367
+ function configuredCollections(hqRoot) {
1368
+ const configPath = qmdConfigPath(hqRoot);
1369
+ if (!configPath || !fs.existsSync(configPath))
1370
+ return new Map();
1371
+ const document = load(fs.readFileSync(configPath, 'utf8'));
1372
+ if (!isRecord(document) || !isRecord(document.collections))
1373
+ return new Map();
1374
+ return new Map(Object.entries(document.collections).flatMap(([name, config]) => isRecord(config) && typeof config.path === 'string' ? [[name, config]] : []));
1375
+ }
1376
+ /** Restore existing collection options after qmd's add command rewrites the entry. */
1377
+ function restoreCollectionMetadata(hqRoot, collection, previousConfig) {
1378
+ const configPath = qmdConfigPath(hqRoot);
1379
+ if (!configPath || !fs.existsSync(configPath)) {
1380
+ throw new Error(`QMD config disappeared while repairing collection '${collection.name}'`);
1381
+ }
1382
+ const document = load(fs.readFileSync(configPath, 'utf8'));
1383
+ if (!isRecord(document) || !isRecord(document.collections)) {
1384
+ throw new Error(`QMD config has no collections map while repairing '${collection.name}'`);
1385
+ }
1386
+ const currentConfig = document.collections[collection.name];
1387
+ document.collections[collection.name] = {
1388
+ ...(isRecord(currentConfig) ? currentConfig : {}),
1389
+ ...previousConfig,
1390
+ path: collection.path,
1391
+ };
1392
+ fs.writeFileSync(configPath, dump(document, { lineWidth: 0 }));
1393
+ }
1394
+ function resolvedPath(value) {
1395
+ const absolutePath = path.resolve(value);
1396
+ try {
1397
+ return fs.realpathSync(absolutePath);
1398
+ }
1399
+ catch {
1400
+ return absolutePath;
1401
+ }
1402
+ }
1403
+ function comparablePath(value) {
1404
+ return process.platform === 'win32' ? value.toLowerCase() : value;
1405
+ }
1406
+ /** Repair a known HQ-managed path, including the former company knowledge mirror layout. */
1407
+ function isPreviousHqManagedPath(hqRoot, collection, configuredPath) {
1408
+ const root = resolvedPath(hqRoot);
1409
+ const expectedPath = resolvedPath(collection.path);
1410
+ const managedRelativePath = path.relative(root, expectedPath);
1411
+ if (!managedRelativePath ||
1412
+ path.isAbsolute(managedRelativePath) ||
1413
+ managedRelativePath === '..' ||
1414
+ managedRelativePath.startsWith(`..${path.sep}`)) {
1415
+ return false;
1416
+ }
1417
+ const actualPath = resolvedPath(path.resolve(hqRoot, configuredPath));
1418
+ const suffix = `${path.sep}${managedRelativePath}`;
1419
+ const actualForComparison = comparablePath(actualPath);
1420
+ const suffixForComparison = comparablePath(suffix);
1421
+ const prefixLength = actualForComparison.length - suffixForComparison.length;
1422
+ if (prefixLength > path.parse(actualPath).root.length && actualForComparison.endsWith(suffixForComparison)) {
1423
+ return true;
1424
+ }
1425
+ const expectedParts = managedRelativePath.split(path.sep);
1426
+ if (expectedParts.length !== 3 || expectedParts[0] !== 'companies' || expectedParts[2] !== 'knowledge') {
1427
+ return false;
1428
+ }
1429
+ const slug = expectedParts[1];
1430
+ if (!slug)
1431
+ return false;
1432
+ const formerMirrorPath = resolvedPath(path.join(root, 'repos', 'private', `knowledge-${slug}`));
1433
+ return comparablePath(actualPath) === comparablePath(formerMirrorPath);
1434
+ }
1353
1435
  /**
1354
1436
  * Register expected collections that qmd does not yet know about, returning the
1355
1437
  * collections this call actually created (a `collection add` that exited 0).
1438
+ * When the default-off hq-flags rollout is enabled, it also replaces a registered
1439
+ * collection whose path matches a known prior HQ-managed layout.
1356
1440
  *
1357
1441
  * A same-name idempotent duplicate — qmd already owns the exact requested name
1358
1442
  * in its YAML config (`Collection '<name>' already exists.`), while the
@@ -1368,11 +1452,29 @@ export function deriveCollections(hqRoot) {
1368
1452
  export function reconcileCollections(hqRoot, options = {}) {
1369
1453
  const runOptions = { bin: options.bin, runner: options.runner, cwd: hqRoot };
1370
1454
  const registered = listRegisteredCollections(hqRoot, runOptions);
1371
- const missing = deriveCollections(hqRoot).filter((collection) => !registered.has(collection.name));
1455
+ const expected = deriveCollections(hqRoot);
1456
+ const repairPathDrift = options.repairPathDrift ?? qmdCollectionPathRepairEnabled();
1457
+ const configured = repairPathDrift ? configuredCollections(hqRoot) : new Map();
1458
+ const stale = repairPathDrift
1459
+ ? expected.filter((collection) => {
1460
+ const previousConfig = configured.get(collection.name);
1461
+ return previousConfig !== undefined
1462
+ && comparablePath(resolvedPath(path.resolve(hqRoot, previousConfig.path))) !== comparablePath(resolvedPath(collection.path))
1463
+ && isPreviousHqManagedPath(hqRoot, collection, previousConfig.path);
1464
+ })
1465
+ : [];
1466
+ for (const collection of stale) {
1467
+ runQmd(['collection', 'remove', collection.name], runOptions);
1468
+ registered.delete(collection.name);
1469
+ }
1470
+ const missing = expected.filter((collection) => !registered.has(collection.name));
1372
1471
  const created = [];
1373
1472
  for (const collection of missing) {
1374
1473
  try {
1375
1474
  runQmd(['collection', 'add', collection.path, '--name', collection.name, '--mask', collection.mask], runOptions);
1475
+ const previousConfig = configured.get(collection.name);
1476
+ if (previousConfig)
1477
+ restoreCollectionMetadata(hqRoot, collection, previousConfig);
1376
1478
  created.push(collection);
1377
1479
  }
1378
1480
  catch (error) {
@@ -1381,6 +1483,9 @@ export function reconcileCollections(hqRoot, options = {}) {
1381
1483
  // different collection — stays fatal.
1382
1484
  if (!(error instanceof QmdCollectionExistsError) || error.collectionName !== collection.name)
1383
1485
  throw error;
1486
+ const previousConfig = configured.get(collection.name);
1487
+ if (previousConfig)
1488
+ restoreCollectionMetadata(hqRoot, collection, previousConfig);
1384
1489
  }
1385
1490
  runQmd(['context', 'add', `qmd://${collection.name}`, collection.context], runOptions);
1386
1491
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.184.0",
3
+ "version": "5.185.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {