@indigoai-us/hq-cli 5.201.3 → 5.202.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,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.202.0] — 2026-09-26
6
+
7
+ ### Added
8
+
9
+ - Behind the off-by-default checkpoint.ontology.company-brief flag, the checkpoint ontology plan writes company-brief.md from the local entities, signals and source index, with no model call; recent signals are dated through their source.
10
+
11
+ ## [5.201.4] — 2026-09-25
12
+
13
+ ### Fixed
14
+
15
+ - Claude account balancing on lane resume now respects the attached worker
16
+ profile's account pin, skips accounts pinned to a different model, and treats
17
+ `--account` naming the current account as a keep request for message/resume.
18
+
5
19
  ## [5.201.3] — 2026-09-25
6
20
 
7
21
  ### Fixed
@@ -44,6 +44,11 @@ function requireRunDir(runDir) {
44
44
  export const registryFlagReader = (flagKey, company) => readLanesRegistryFlag(flagKey, {
45
45
  company: company.cloudUid ? { slug: company.slug, uid: company.cloudUid } : null,
46
46
  });
47
+ function companyBriefSuffix(brief) {
48
+ if (!brief)
49
+ return "";
50
+ return `; company brief ${brief.status}${brief.reason ? ` (${brief.reason})` : ""}`;
51
+ }
47
52
  export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
48
53
  const runDir = requireRunDir(runDirOption);
49
54
  const outcome = await planOntologyRun({
@@ -53,11 +58,13 @@ export async function runOntologyPlan(hqRoot, runDirOption, deps = {}) {
53
58
  readFlag: deps.readFlag ?? registryFlagReader,
54
59
  now: deps.now?.(),
55
60
  });
56
- if (outcome.status === "skipped")
57
- return `ontology: skipped (${outcome.reason})`;
61
+ if (outcome.status === "skipped") {
62
+ return `ontology: skipped (${outcome.reason})${companyBriefSuffix(outcome.companyBrief)}`;
63
+ }
58
64
  const { plan } = outcome;
59
65
  return (`ontology: planned ${plan.inputs.length} input(s), ~${plan.budget.planned_tokens} tokens, ` +
60
- `${plan.pending_after_run} left for later runs; plan at ${outcome.planPath}`);
66
+ `${plan.pending_after_run} left for later runs; plan at ${outcome.planPath}` +
67
+ companyBriefSuffix(outcome.companyBrief));
61
68
  }
62
69
  export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
63
70
  const runDir = requireRunDir(runDirOption);
@@ -70,7 +77,8 @@ export function runOntologyApply(hqRoot, runDirOption, deps = {}) {
70
77
  return `ontology: ${report.status.replaceAll("_", " ")}; nothing written`;
71
78
  return (`ontology: ${report.created.length} created, ${report.updated.length} updated, ` +
72
79
  `${report.matched} matched, ${report.rejected.length} rejected, ${report.deferred.length} deferred, ` +
73
- `${report.suspect_duplicates.length} suspect duplicate(s), ${report.write_errors.length} write error(s)`);
80
+ `${report.suspect_duplicates.length} suspect duplicate(s), ${report.write_errors.length} write error(s)` +
81
+ companyBriefSuffix(report.company_brief));
74
82
  }
75
83
  function fail(error) {
76
84
  if (error instanceof OntologyUsageError || error instanceof ApplyError || error instanceof WriteBoundsError) {
@@ -4905,8 +4905,26 @@ function applyResumeProfileAndEffort(current, next, input) {
4905
4905
  }
4906
4906
  }
4907
4907
  function recordResumeSetup(ctx, lane, input) {
4908
+ if (input.cliModel !== undefined) {
4909
+ const model = input.cliModel.trim();
4910
+ if (!model)
4911
+ throw new LanesError("empty_model", "--model is empty or whitespace-only", { value: input.cliModel });
4912
+ const config = loadLanesConfig(ctx.hqRoot());
4913
+ const account = lane.account === undefined
4914
+ ? undefined
4915
+ : config.providers[lane.provider].accounts[lane.account];
4916
+ if (account === undefined) {
4917
+ throw new AccountResolutionError("unknown_account", `Lane ${lane.lane_id} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`, { provider: lane.provider, account: lane.account ?? "", enabled: [] });
4918
+ }
4919
+ validateLaneModel(lane.provider, model, account.home, {
4920
+ readFileSync: ctx.readFileSync,
4921
+ probe: ctx.probeProvider,
4922
+ env: ctx.env,
4923
+ });
4924
+ }
4908
4925
  if (input.profile === undefined &&
4909
4926
  input.effort === undefined &&
4927
+ input.cliModel === undefined &&
4910
4928
  input.meshCompany === undefined)
4911
4929
  return lane;
4912
4930
  return casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => {
@@ -4915,6 +4933,10 @@ function recordResumeSetup(ctx, lane, input) {
4915
4933
  }
4916
4934
  const next = { ...current };
4917
4935
  applyResumeProfileAndEffort(current, next, input);
4936
+ if (input.cliModel !== undefined) {
4937
+ next.model = input.cliModel.trim();
4938
+ next.model_source = "cli";
4939
+ }
4918
4940
  if (input.meshCompany !== undefined) {
4919
4941
  if (current.company === null)
4920
4942
  next.mesh_company = input.meshCompany;
@@ -5147,7 +5169,7 @@ async function prepareAccountMove(ctx, lane, input) {
5147
5169
  return { lane: moved, from, to: target, copy, queued };
5148
5170
  }, ctx.sleep);
5149
5171
  }
5150
- async function selectAutomaticClaudeResumeAccount(ctx, lane) {
5172
+ async function selectAutomaticClaudeResumeAccount(ctx, lane, workerProfile) {
5151
5173
  if (lane.provider !== "claude")
5152
5174
  return undefined;
5153
5175
  if (!await ctx.laneClaudeAccountBalanceEnabled(lane.company)) {
@@ -5171,6 +5193,16 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane) {
5171
5193
  }
5172
5194
  const hqRoot = ctx.hqRoot();
5173
5195
  const config = loadLanesConfig(hqRoot);
5196
+ const profile = workerProfile?.profile ?? (lane.worker_profile !== undefined
5197
+ ? loadWorkerProfile(hqRoot, lane.worker_profile)
5198
+ : undefined);
5199
+ const profileDefaultsApply = profile !== undefined && profile.meta.provider === lane.provider;
5200
+ const profileAccount = profileDefaultsApply ? profile?.meta.account : undefined;
5201
+ const profileAccounts = profileDefaultsApply ? profile?.meta.accounts : undefined;
5202
+ const profilePin = profileAccounts ?? (profileAccount === undefined ? undefined : [profileAccount]);
5203
+ const allowedAccounts = profilePin !== undefined && lane.admission_accounts !== undefined
5204
+ ? profilePin.filter((account) => lane.admission_accounts.includes(account))
5205
+ : profilePin ?? lane.admission_accounts;
5174
5206
  const currentConfig = lane.account === undefined
5175
5207
  ? undefined
5176
5208
  : config.providers.claude.accounts[lane.account];
@@ -5181,20 +5213,71 @@ async function selectAutomaticClaudeResumeAccount(ctx, lane) {
5181
5213
  probe: ctx.probeProvider,
5182
5214
  env: ctx.env,
5183
5215
  }).logged_in;
5216
+ const pinnedModel = workerProfile !== undefined
5217
+ ? lane.model_source === "cli" ? lane.model : undefined
5218
+ : lane.model_source === undefined || lane.model_source === "cli"
5219
+ ? lane.model
5220
+ : undefined;
5221
+ const model = resolveLaneModel({
5222
+ cliModel: pinnedModel,
5223
+ profileModel: profileDefaultsApply ? profile?.meta.model : undefined,
5224
+ accountModel: lane.account === undefined ? undefined : config.providers.claude.accounts[lane.account]?.model,
5225
+ providerModel: config.providers.claude.model,
5226
+ });
5184
5227
  const accountNeedsRepair = currentConfig?.enabled !== true || currentLogin === false;
5185
5228
  const mayRebalance = BALANCE_MOVABLE_STATES.includes(lane.state);
5186
5229
  if (!accountNeedsRepair && !mayRebalance)
5187
5230
  return undefined;
5188
5231
  const balanceHosts = await loadClaudeBalanceHostSnapshots(ctx, hqRoot, config);
5232
+ const targetHost = balanceHosts.snapshots.find((host) => host.capacity.host.kind === lane.host.kind && host.capacity.host.id === lane.host.id);
5233
+ const currentTargetAccount = lane.account === undefined
5234
+ ? undefined
5235
+ : targetHost?.capacity.providers.claude.accounts[lane.account];
5236
+ if (profilePin !== undefined &&
5237
+ lane.account !== undefined &&
5238
+ allowedAccounts?.includes(lane.account) &&
5239
+ currentConfig?.enabled === true &&
5240
+ currentTargetAccount?.enabled === true &&
5241
+ currentTargetAccount.logged_in === true &&
5242
+ (model === undefined || currentConfig.model === undefined || currentConfig.model === model))
5243
+ return undefined;
5189
5244
  const selected = selectClaudeAccount({
5190
5245
  hosts: balanceHosts.snapshots,
5191
5246
  targetHost: lane.host,
5192
5247
  config,
5193
- ...(lane.admission_accounts !== undefined ? { allowedAccounts: lane.admission_accounts } : {}),
5248
+ ...(allowedAccounts !== undefined ? { allowedAccounts } : {}),
5249
+ ...(model !== undefined ? { model } : {}),
5194
5250
  unavailableHosts: balanceHosts.unavailableHosts,
5195
5251
  });
5196
5252
  return { ...selected, selected_at: ctx.now().toISOString() };
5197
5253
  }
5254
+ function accountRequestKeepsCurrentLane(lane, rawAccount) {
5255
+ const raw = rawAccount.trim();
5256
+ const colon = raw.indexOf(":");
5257
+ const requestedProvider = colon > 0 ? raw.slice(0, colon) : undefined;
5258
+ const requestedAccount = colon > 0 ? raw.slice(colon + 1) : raw;
5259
+ return requestedAccount.length > 0 &&
5260
+ requestedAccount === lane.account &&
5261
+ (requestedProvider === undefined || requestedProvider === lane.provider);
5262
+ }
5263
+ function assertCurrentResumeAccountAvailable(ctx, lane) {
5264
+ const config = loadLanesConfig(ctx.hqRoot());
5265
+ const account = lane.account === undefined
5266
+ ? undefined
5267
+ : config.providers[lane.provider].accounts[lane.account];
5268
+ if (account === undefined || !account.enabled) {
5269
+ throw new AccountResolutionError(account === undefined ? "unknown_account" : "account_disabled", account === undefined
5270
+ ? `Lane ${lane.lane_id} records account ${JSON.stringify(lane.account)} which is not configured for ${lane.provider}`
5271
+ : `Account ${JSON.stringify(lane.account)} is disabled${account.disabled_reason ? `: ${account.disabled_reason}` : ""}`, { provider: lane.provider, account: lane.account ?? "", ...(account?.disabled_reason !== undefined ? { reason: account.disabled_reason } : {}) });
5272
+ }
5273
+ if (accountStatus(lane.provider, lane.account, config, {
5274
+ existsSync: ctx.existsSync,
5275
+ probe: ctx.probeProvider,
5276
+ env: ctx.env,
5277
+ }).logged_in !== true) {
5278
+ throw new LanesError("account_not_logged_in", `Account ${lane.account} for ${lane.provider} is not logged in (no credential file)`, { provider: lane.provider, account: lane.account, lane_id: lane.lane_id });
5279
+ }
5280
+ }
5198
5281
  async function runMessage(ctx, laneId, opts) {
5199
5282
  const hqRoot = ctx.hqRoot();
5200
5283
  const text = opts.text;
@@ -5215,16 +5298,19 @@ async function runMessage(ctx, laneId, opts) {
5215
5298
  // The final state transition repeats this check under the senior lock.
5216
5299
  await withResumeHireAdmission(ctx, lane, () => undefined);
5217
5300
  lane = laneWithRecoveredProviderSession(hqRoot, lane);
5218
- // An account move is refused on its own terms first: a running lane and a
5219
- // provider without session resume get the move's named errors, not the
5220
- // generic delivery refusal below.
5221
- if (opts.account !== undefined && lane.state === "running") {
5301
+ const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5302
+ if (keepCurrentAccount)
5303
+ assertCurrentResumeAccountAvailable(ctx, lane);
5304
+ // A real account move is refused on its own terms first: a running lane and
5305
+ // a provider without session resume get the move's named errors, not the
5306
+ // generic delivery refusal below. Naming the current account is a keep.
5307
+ if (opts.account !== undefined && !keepCurrentAccount && lane.state === "running") {
5222
5308
  throw new LanesError("lane_running", `Lane ${laneId} is running; stop it or wait for it to exit before moving its session`, {
5223
5309
  lane_id: laneId,
5224
5310
  state: lane.state,
5225
5311
  });
5226
5312
  }
5227
- if (opts.account !== undefined && !providerSupportsSessionResume(lane.provider)) {
5313
+ if (opts.account !== undefined && !keepCurrentAccount && !providerSupportsSessionResume(lane.provider)) {
5228
5314
  throw new LanesError("provider_session_not_portable", `Provider ${lane.provider} has no session resume, so a lane cannot move between its accounts`, { lane_id: laneId, provider: lane.provider });
5229
5315
  }
5230
5316
  // One decision point for how a follow-up reaches this lane (delivery.ts).
@@ -5239,6 +5325,9 @@ async function runMessage(ctx, laneId, opts) {
5239
5325
  message,
5240
5326
  });
5241
5327
  }
5328
+ if (keepCurrentAccount && opts.model !== undefined && mechanism !== "resume") {
5329
+ throw new LanesError("model_requires_resume", "--model with --account requires a message that resumes the lane", { lane_id: laneId, state: lane.state });
5330
+ }
5242
5331
  if (opts.worker !== undefined && mechanism === "dropbox") {
5243
5332
  throw new LanesError("worker_profile_requires_resume", `Lane ${laneId} is still running; --worker can only be applied when message starts a resumed provider turn`, { lane_id: laneId, state: lane.state });
5244
5333
  }
@@ -5252,12 +5341,12 @@ async function runMessage(ctx, laneId, opts) {
5252
5341
  throw new LanesError("model_requires_account", "--model on hq lanes message is only accepted together with --account", {});
5253
5342
  }
5254
5343
  const automaticAccountSelection = opts.account === undefined && mechanism === "resume"
5255
- ? await selectAutomaticClaudeResumeAccount(ctx, lane)
5344
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile)
5256
5345
  : undefined;
5257
5346
  const resumeEffort = resolveResumeEffort(ctx, lane, {
5258
5347
  cliEffort,
5259
5348
  profile: resumeProfile,
5260
- account: opts.account ?? automaticAccountSelection?.account,
5349
+ account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.account,
5261
5350
  model: opts.model,
5262
5351
  });
5263
5352
  const messageId = mintMessageId();
@@ -5280,7 +5369,7 @@ async function runMessage(ctx, laneId, opts) {
5280
5369
  let resumed = null;
5281
5370
  let move = null;
5282
5371
  let queuedOnAdmission = false;
5283
- if (opts.account !== undefined) {
5372
+ if (opts.account !== undefined && !keepCurrentAccount) {
5284
5373
  // A moved message is always a resume on the target account: a running
5285
5374
  // lane (the dropbox mechanism) is refused inside prepareAccountMove.
5286
5375
  move = await prepareAccountMove(ctx, lane, {
@@ -5321,13 +5410,14 @@ async function runMessage(ctx, laneId, opts) {
5321
5410
  }
5322
5411
  }
5323
5412
  }
5324
- if (resumeProfile !== undefined && opts.account === undefined) {
5413
+ if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
5325
5414
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
5326
5415
  }
5327
- if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined)) {
5416
+ if (move === null && (resumeProfile !== undefined || resumeEffort !== undefined || (keepCurrentAccount && opts.model !== undefined))) {
5328
5417
  lane = recordResumeSetup(ctx, lane, {
5329
5418
  ...(resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5330
5419
  ...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5420
+ ...(keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
5331
5421
  });
5332
5422
  }
5333
5423
  if (move?.queued) {
@@ -5751,20 +5841,23 @@ async function runResume(ctx, laneId, opts) {
5751
5841
  const resumeProfile = opts.worker !== undefined
5752
5842
  ? resolveResumeProfile(ctx, lane, opts.worker)
5753
5843
  : undefined;
5754
- if (resumeProfile !== undefined && opts.account === undefined) {
5844
+ const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
5845
+ if (keepCurrentAccount)
5846
+ assertCurrentResumeAccountAvailable(ctx, lane);
5847
+ if (resumeProfile !== undefined && (opts.account === undefined || keepCurrentAccount)) {
5755
5848
  validateResumeProfileModel(ctx, lane, resumeProfile.profile);
5756
5849
  }
5757
5850
  const automaticAccountSelection = opts.account === undefined
5758
- ? await selectAutomaticClaudeResumeAccount(ctx, lane)
5851
+ ? await selectAutomaticClaudeResumeAccount(ctx, lane, resumeProfile)
5759
5852
  : undefined;
5760
5853
  const resumeEffort = resolveResumeEffort(ctx, lane, {
5761
5854
  cliEffort,
5762
5855
  profile: resumeProfile,
5763
- account: opts.account ?? automaticAccountSelection?.account,
5856
+ account: keepCurrentAccount ? lane.account : opts.account ?? automaticAccountSelection?.account,
5764
5857
  model: opts.model,
5765
5858
  });
5766
5859
  let move = null;
5767
- if (opts.account !== undefined) {
5860
+ if (opts.account !== undefined && !keepCurrentAccount) {
5768
5861
  move = await prepareAccountMove(ctx, lane, {
5769
5862
  account: opts.account,
5770
5863
  cliModel: opts.model?.trim() || undefined,
@@ -5805,6 +5898,7 @@ async function runResume(ctx, laneId, opts) {
5805
5898
  lane = recordResumeSetup(ctx, lane, {
5806
5899
  ...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
5807
5900
  ...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
5901
+ ...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
5808
5902
  ...(requestedMeshCompany !== undefined
5809
5903
  ? { meshCompany: requestedMeshCompany, threadId: meshThreadId }
5810
5904
  : {}),
@@ -5,6 +5,7 @@
5
5
  * sections 4.3, 5 and 14). The model output is untrusted input: a malformed
6
6
  * candidate is rejected and counted, never repaired.
7
7
  */
8
+ import { type CompanyBriefResult } from "./company-brief.js";
8
9
  import { type WritableEntityType } from "./identity.js";
9
10
  import { type EntityIndex } from "./tree.js";
10
11
  export declare const CAPS: {
@@ -73,6 +74,8 @@ export interface ApplyReport {
73
74
  path: string;
74
75
  reason: string;
75
76
  }>;
77
+ /** Present only when the plan ran the company brief and this apply created or updated entities. */
78
+ company_brief?: CompanyBriefResult;
76
79
  duration_ms: number;
77
80
  }
78
81
  export interface ApplyOptions {
@@ -8,6 +8,7 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
+ import { writeCompanyBrief } from "./company-brief.js";
11
12
  import { entitiesRoot, resolveOntologyCompany, safeWriteFile } from "./company.js";
12
13
  import { appendBackRefs, parseEntityFile, patchEntityFile, previewSignalContent, renderNewEntity, validateEntityFrontmatter, } from "./entity-file.js";
13
14
  import { canonicalizeName, entityId, isRejectedName, isWritableEntityType, normalizeEntityName, squash, toSlug, } from "./identity.js";
@@ -438,6 +439,10 @@ export function applyOntologyRun(options) {
438
439
  report.updated.push(rel);
439
440
  }
440
441
  commitLedger(options.hqRoot, company, plan, inputs, blocked, report);
442
+ // The plan rendered the brief before these entity writes; render it again so it matches them.
443
+ if (plan.company_brief && report.created.length + report.updated.length > 0) {
444
+ report.company_brief = writeCompanyBrief(options.hqRoot, company, options.now ?? new Date());
445
+ }
441
446
  return finish();
442
447
  }
443
448
  let signalRefIndex = null;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Deterministic `company-brief.md` for one company.
3
+ *
4
+ * The cloud gardener used to write this file. The checkpoint renders the same
5
+ * shape from the local ontology with no model call. Signal dates come from
6
+ * `sources/_index`, because signal files have no `created_at`.
7
+ */
8
+ import { type OntologyCompany } from "./company.js";
9
+ export declare const COMPANY_BRIEF_FILE = "company-brief.md";
10
+ export interface CompanyBriefResult {
11
+ status: "written" | "unchanged" | "skipped" | "failed";
12
+ /** Set for skipped and failed. */
13
+ reason?: string;
14
+ }
15
+ /** `null` when fewer than three valid person, project, company, or concept entities exist. */
16
+ export declare function renderCompanyBrief(company: OntologyCompany, now: Date): string | null;
17
+ export declare function writeCompanyBrief(hqRoot: string, company: OntologyCompany, now: Date): CompanyBriefResult;
18
+ //# sourceMappingURL=company-brief.d.ts.map
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Deterministic `company-brief.md` for one company.
3
+ *
4
+ * The cloud gardener used to write this file. The checkpoint renders the same
5
+ * shape from the local ontology with no model call. Signal dates come from
6
+ * `sources/_index`, because signal files have no `created_at`.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import { safeWriteFile, WriteBoundsError } from "./company.js";
11
+ import { parseEntityFile, validateEntityFrontmatter } from "./entity-file.js";
12
+ export const COMPANY_BRIEF_FILE = "company-brief.md";
13
+ const SIGNAL_TYPES = [
14
+ { dir: "decision", heading: "Decisions" },
15
+ { dir: "risk", heading: "Risks" },
16
+ { dir: "question", heading: "Questions" },
17
+ ];
18
+ const ENTITY_TYPES = [
19
+ { dir: "person", heading: "People" },
20
+ { dir: "project", heading: "Projects" },
21
+ { dir: "company", heading: "Companies" },
22
+ { dir: "concept", heading: "Concepts" },
23
+ ];
24
+ const CHANNELS = [
25
+ { id: "meeting", label: "Meeting" },
26
+ { id: "email", label: "Email" },
27
+ { id: "slack", label: "Slack" },
28
+ { id: "slack-dm", label: "Slack DM" },
29
+ { id: "linear", label: "Linear" },
30
+ { id: "notion", label: "Notion" },
31
+ ];
32
+ const NO_SOURCES = "<!-- source channels: no sources/_index entries in last 7d -->";
33
+ const SIGNAL_PREVIEW_MAX = 160;
34
+ const SIGNAL_TOP = 5;
35
+ const ENTITY_TOP = 8;
36
+ /** Cloud budget: `floor(2000 * 0.4)` reserved tokens, minus the source block. */
37
+ const SIGNAL_TOKEN_RESERVE = Math.floor(2000 * 0.4);
38
+ const MIN_ENTITIES = 3;
39
+ function byCodeUnit(a, b) {
40
+ if (a < b)
41
+ return -1;
42
+ if (a > b)
43
+ return 1;
44
+ return 0;
45
+ }
46
+ function tokenCount(text) {
47
+ return Math.ceil(text.length / 4);
48
+ }
49
+ /** UTC dates `now`, `now − 1 day`, … from the epoch day, not the local calendar. */
50
+ function windowDates(now) {
51
+ const day = Math.floor(now.getTime() / 86_400_000);
52
+ const dates = [];
53
+ for (let i = 0; i < 7; i += 1)
54
+ dates.push(new Date((day - i) * 86_400_000).toISOString().slice(0, 10));
55
+ return dates;
56
+ }
57
+ /**
58
+ * `false` when `dir` does not exist. Throws when it resolves outside the company:
59
+ * a symlinked directory would otherwise copy another company's files into this brief.
60
+ */
61
+ function readableInside(companyDir, dir) {
62
+ let real;
63
+ try {
64
+ real = fs.realpathSync(dir);
65
+ }
66
+ catch (error) {
67
+ if (error.code === "ENOENT")
68
+ return false;
69
+ throw error;
70
+ }
71
+ const relative = path.relative(fs.realpathSync(companyDir), real);
72
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
73
+ throw new WriteBoundsError(`${path.relative(companyDir, dir)} resolves outside the company`);
74
+ }
75
+ return true;
76
+ }
77
+ function listDirectMd(companyDir, dir) {
78
+ if (!readableInside(companyDir, dir))
79
+ return [];
80
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
81
+ const names = [];
82
+ for (const entry of entries) {
83
+ // Files only. A directory whose name starts with `@` or `_` is never entered.
84
+ if (!entry.isFile() || !entry.name.endsWith(".md"))
85
+ continue;
86
+ names.push(entry.name);
87
+ }
88
+ names.sort(byCodeUnit);
89
+ return names;
90
+ }
91
+ /** `null` when the file is missing, unparsable, or not a JSON array. */
92
+ function readIndexArray(file) {
93
+ let raw;
94
+ try {
95
+ raw = fs.readFileSync(file, "utf8");
96
+ }
97
+ catch (error) {
98
+ if (error.code === "ENOENT")
99
+ return null;
100
+ throw error;
101
+ }
102
+ try {
103
+ const parsed = JSON.parse(raw);
104
+ return Array.isArray(parsed) ? parsed : null;
105
+ }
106
+ catch (error) {
107
+ if (error instanceof SyntaxError)
108
+ return null;
109
+ throw error;
110
+ }
111
+ }
112
+ function indexKey(entry) {
113
+ const keys = entry.keys;
114
+ const markdown = keys && typeof keys === "object" && !Array.isArray(keys)
115
+ ? keys.markdown
116
+ : undefined;
117
+ if (typeof markdown === "string" && markdown.length > 0)
118
+ return markdown;
119
+ const sourceRef = entry.source_ref;
120
+ const fallback = sourceRef && typeof sourceRef === "object" && !Array.isArray(sourceRef)
121
+ ? sourceRef.key
122
+ : undefined;
123
+ return typeof fallback === "string" && fallback.length > 0 ? fallback : null;
124
+ }
125
+ function mapChannel(value) {
126
+ if (value === "meeting" || value === "meetings")
127
+ return "meeting";
128
+ if (value === "email" || value === "slack" || value === "slack-dm" || value === "linear" || value === "notion") {
129
+ return value;
130
+ }
131
+ return null;
132
+ }
133
+ function loadSources(companyDir, now) {
134
+ const times = new Map();
135
+ const counts = new Map();
136
+ const dir = path.join(companyDir, "sources", "_index");
137
+ // Regular files only: a symlinked index file could point at another company's index.
138
+ const names = readableInside(companyDir, dir)
139
+ ? fs
140
+ .readdirSync(dir, { withFileTypes: true })
141
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
142
+ .map((entry) => entry.name)
143
+ .sort(byCodeUnit)
144
+ : [];
145
+ const windowFiles = new Set(windowDates(now).map((date) => `${date}.json`));
146
+ for (const name of names) {
147
+ const entries = readIndexArray(path.join(dir, name));
148
+ if (!entries)
149
+ continue;
150
+ const inWindow = windowFiles.has(name);
151
+ for (const entry of entries) {
152
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
153
+ continue;
154
+ const record = entry;
155
+ const key = indexKey(record);
156
+ if (key && typeof record.written_at === "string")
157
+ times.set(key, record.written_at);
158
+ if (!inWindow)
159
+ continue;
160
+ const channel = mapChannel(record.channel);
161
+ if (channel)
162
+ counts.set(channel, (counts.get(channel) ?? 0) + 1);
163
+ }
164
+ }
165
+ return { times, counts };
166
+ }
167
+ function loadSignals(companyDir, times) {
168
+ const items = [];
169
+ for (const type of SIGNAL_TYPES) {
170
+ const dir = path.join(companyDir, "signals", type.dir);
171
+ for (const name of listDirectMd(companyDir, dir)) {
172
+ const parsed = parseEntityFile(fs.readFileSync(path.join(dir, name), "utf8"));
173
+ if (!parsed)
174
+ continue;
175
+ const content = parsed.frontmatter.canonical_content;
176
+ if (typeof content !== "string" || content.length === 0)
177
+ continue;
178
+ const sourceRef = parsed.frontmatter.source_ref;
179
+ const time = typeof sourceRef === "string" ? times.get(sourceRef) ?? null : null;
180
+ items.push({ type: type.dir, rel: `signals/${type.dir}/${name}`, content, time });
181
+ }
182
+ }
183
+ return items;
184
+ }
185
+ function compareSignals(a, b) {
186
+ if (a.time !== null && b.time !== null) {
187
+ if (a.time !== b.time)
188
+ return a.time < b.time ? 1 : -1;
189
+ }
190
+ else if (a.time !== b.time) {
191
+ return a.time === null ? 1 : -1;
192
+ }
193
+ if (a.rel !== b.rel)
194
+ return a.rel < b.rel ? 1 : -1;
195
+ return 0;
196
+ }
197
+ function previewContent(content) {
198
+ const collapsed = content.replace(/\s+/g, " ").trim();
199
+ if (collapsed.length <= SIGNAL_PREVIEW_MAX)
200
+ return collapsed;
201
+ return `${collapsed.slice(0, SIGNAL_PREVIEW_MAX).trimEnd()}…`;
202
+ }
203
+ function renderSignalBlock(items) {
204
+ const sections = [];
205
+ for (const type of SIGNAL_TYPES) {
206
+ const rows = items.filter((item) => item.type === type.dir);
207
+ if (rows.length === 0)
208
+ continue;
209
+ const lines = rows.map((item) => {
210
+ const base = `- [${previewContent(item.content)}](${item.rel})`;
211
+ return item.time ? `${base} _(${item.time.slice(0, 10)})_` : base;
212
+ });
213
+ sections.push(`### ${type.heading}\n${lines.join("\n")}`);
214
+ }
215
+ if (sections.length === 0)
216
+ return null;
217
+ return ["## Recent Signals", ...sections].join("\n\n").trimEnd();
218
+ }
219
+ function confidenceOf(frontmatter) {
220
+ const value = frontmatter.confidence;
221
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1 ? value : 0;
222
+ }
223
+ function compareEntities(a, b) {
224
+ if (a.score !== b.score)
225
+ return b.score - a.score;
226
+ if (a.name !== b.name)
227
+ return a.name < b.name ? -1 : 1;
228
+ if (a.file !== b.file)
229
+ return a.file < b.file ? -1 : 1;
230
+ return 0;
231
+ }
232
+ function loadEntities(companyDir) {
233
+ let total = 0;
234
+ const groups = [];
235
+ for (const type of ENTITY_TYPES) {
236
+ const dir = path.join(companyDir, "ontology", "entities", type.dir);
237
+ const items = [];
238
+ for (const name of listDirectMd(companyDir, dir)) {
239
+ const parsed = parseEntityFile(fs.readFileSync(path.join(dir, name), "utf8"));
240
+ if (!parsed)
241
+ continue;
242
+ const valid = validateEntityFrontmatter(parsed.frontmatter);
243
+ if (typeof valid === "string")
244
+ continue;
245
+ items.push({
246
+ file: name,
247
+ name: valid.canonical_name,
248
+ signalCount: valid.signal_count,
249
+ score: valid.signal_count * confidenceOf(parsed.frontmatter),
250
+ });
251
+ }
252
+ total += items.length;
253
+ items.sort(compareEntities);
254
+ groups.push({
255
+ heading: type.heading,
256
+ lines: items.slice(0, ENTITY_TOP).map((item) => `- [${item.name}](ontology/entities/${type.dir}/${item.file}) (${item.signalCount} signals)`),
257
+ });
258
+ }
259
+ return { total, groups };
260
+ }
261
+ function renderEntityBlock(slug, groups) {
262
+ const parts = [
263
+ `# ${slug} company brief`,
264
+ "_Rendered by hq core checkpoint from the local ontology._",
265
+ "## Key Entities",
266
+ ];
267
+ for (const group of groups) {
268
+ if (group.lines.length === 0)
269
+ continue;
270
+ parts.push(`### ${group.heading}\n${group.lines.join("\n")}`);
271
+ }
272
+ return parts.join("\n\n");
273
+ }
274
+ function renderSourceBlock(counts) {
275
+ const total = CHANNELS.reduce((sum, channel) => sum + (counts.get(channel.id) ?? 0), 0);
276
+ if (total === 0)
277
+ return NO_SOURCES;
278
+ const rows = CHANNELS.map((channel) => `| ${channel.label} | ${counts.get(channel.id) ?? 0} |`);
279
+ return ["## Source Channels", "", "_Last 7 days_", "", "| Channel | Count |", "| --- | ---: |", ...rows].join("\n");
280
+ }
281
+ function fitSignals(ranked, budget) {
282
+ const kept = ranked.slice();
283
+ while (kept.length > 0) {
284
+ const block = renderSignalBlock(kept);
285
+ if (block === null || tokenCount(block) <= budget)
286
+ break;
287
+ kept.pop();
288
+ }
289
+ return kept;
290
+ }
291
+ /** `null` when fewer than three valid person, project, company, or concept entities exist. */
292
+ export function renderCompanyBrief(company, now) {
293
+ const entities = loadEntities(company.dir);
294
+ if (entities.total < MIN_ENTITIES)
295
+ return null;
296
+ const sources = loadSources(company.dir, now);
297
+ const signals = loadSignals(company.dir, sources.times);
298
+ const ranked = [];
299
+ for (const type of SIGNAL_TYPES) {
300
+ ranked.push(...signals.filter((item) => item.type === type.dir).sort(compareSignals).slice(0, SIGNAL_TOP));
301
+ }
302
+ const sourceBlock = renderSourceBlock(sources.counts);
303
+ const budget = Math.max(0, SIGNAL_TOKEN_RESERVE - tokenCount(sourceBlock));
304
+ const signalBlock = renderSignalBlock(fitSignals(ranked, budget));
305
+ const blocks = [renderEntityBlock(company.slug, entities.groups)];
306
+ if (signalBlock)
307
+ blocks.push(signalBlock);
308
+ blocks.push(sourceBlock);
309
+ return `${blocks.join("\n\n")}\n`;
310
+ }
311
+ export function writeCompanyBrief(hqRoot, company, now) {
312
+ try {
313
+ const content = renderCompanyBrief(company, now);
314
+ if (content === null)
315
+ return { status: "skipped", reason: "fewer than 3 entities" };
316
+ const target = path.join(company.dir, COMPANY_BRIEF_FILE);
317
+ let existing = null;
318
+ try {
319
+ // Never follow a symlinked brief for the equality check; safeWriteFile rejects it below.
320
+ if (!fs.lstatSync(target).isSymbolicLink())
321
+ existing = fs.readFileSync(target);
322
+ }
323
+ catch (error) {
324
+ if (error.code !== "ENOENT")
325
+ throw error;
326
+ }
327
+ if (existing && existing.equals(Buffer.from(content, "utf8")))
328
+ return { status: "unchanged" };
329
+ safeWriteFile(hqRoot, [company.dir], target, content);
330
+ return { status: "written" };
331
+ }
332
+ catch (error) {
333
+ const failed = error;
334
+ return { status: "failed", reason: `${failed.name}: ${failed.message}` };
335
+ }
336
+ }
337
+ //# sourceMappingURL=company-brief.js.map
@@ -7,9 +7,11 @@
7
7
  * backend model to read. The model only fills a candidates file; everything
8
8
  * that affects identity, scope and cost stays in code.
9
9
  */
10
+ import { type CompanyBriefResult } from "./company-brief.js";
10
11
  import { type OntologyCompany } from "./company.js";
11
12
  export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
12
13
  export declare const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
14
+ export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
13
15
  export declare const PLAN_FILE = "ontology-plan.json";
14
16
  export declare const CANDIDATES_FILE = "ontology-candidates.json";
15
17
  export declare const REPORT_FILE = "ontology-report.json";
@@ -66,6 +68,11 @@ export interface OntologyPlan {
66
68
  first_run: boolean;
67
69
  boundary: string | null;
68
70
  shared_inputs: boolean;
71
+ /** Present only when `checkpoint.ontology.company-brief` was on for this plan. */
72
+ company_brief?: {
73
+ status: CompanyBriefResult["status"];
74
+ reason?: string;
75
+ };
69
76
  inputs: PlanInput[];
70
77
  /** The session's summary, decisions and learnings, for the `session` input. */
71
78
  session_text: string | null;
@@ -98,10 +105,12 @@ export interface PlanOptions {
98
105
  export type PlanOutcome = {
99
106
  status: "skipped";
100
107
  reason: string;
108
+ companyBrief?: CompanyBriefResult;
101
109
  } | {
102
110
  status: "planned";
103
111
  plan: OntologyPlan;
104
112
  planPath: string;
113
+ companyBrief?: CompanyBriefResult;
105
114
  };
106
115
  export declare function ledgerPath(hqRoot: string, company: OntologyCompany): string;
107
116
  export declare function readLedger(hqRoot: string, company: OntologyCompany): Ledger | null;
@@ -10,10 +10,12 @@
10
10
  import { createHash } from "node:crypto";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
+ import { writeCompanyBrief } from "./company-brief.js";
13
14
  import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
14
15
  import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
15
16
  export const ONTOLOGY_FLAG = "checkpoint.ontology";
16
17
  export const SHARED_INPUTS_FLAG = "checkpoint.ontology.shared-inputs";
18
+ export const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
17
19
  export const PLAN_FILE = "ontology-plan.json";
18
20
  export const CANDIDATES_FILE = "ontology-candidates.json";
19
21
  export const REPORT_FILE = "ontology-report.json";
@@ -173,6 +175,10 @@ export async function planOntologyRun(options) {
173
175
  if (!(await options.readFlag(ONTOLOGY_FLAG, company)))
174
176
  return { status: "skipped", reason: "flag off" };
175
177
  const sharedInputs = await options.readFlag(SHARED_INPUTS_FLAG, company);
178
+ // Before the ledger, so a nothing-changed plan still records the brief.
179
+ const companyBrief = (await options.readFlag(COMPANY_BRIEF_FLAG, company))
180
+ ? writeCompanyBrief(hqRoot, company, options.now ?? new Date())
181
+ : undefined;
176
182
  const touched = sessionTouched(hqRoot, company, payload.files);
177
183
  const { ledger, firstRun } = refreshLedger(company, readLedger(hqRoot, company), touched, sharedInputs);
178
184
  const pending = Object.entries(ledger.entries)
@@ -214,7 +220,8 @@ export async function planOntologyRun(options) {
214
220
  }
215
221
  writeLedger(hqRoot, company, ledger);
216
222
  if (inputs.length === 0) {
217
- return { status: "skipped", reason: pending.length > 0 ? "over budget" : "nothing changed" };
223
+ const reason = pending.length > 0 ? "over budget" : "nothing changed";
224
+ return companyBrief ? { status: "skipped", reason, companyBrief } : { status: "skipped", reason };
218
225
  }
219
226
  // Session content is scoped to the session's user (design section 6, D2).
220
227
  // v1 records the existing entities it names as links in the report and
@@ -243,6 +250,13 @@ export async function planOntologyRun(options) {
243
250
  first_run: firstRun,
244
251
  boundary: ledger.boundary,
245
252
  shared_inputs: sharedInputs,
253
+ ...(companyBrief
254
+ ? {
255
+ company_brief: companyBrief.reason
256
+ ? { status: companyBrief.status, reason: companyBrief.reason }
257
+ : { status: companyBrief.status },
258
+ }
259
+ : {}),
246
260
  inputs,
247
261
  session_text: sessionText,
248
262
  pending_after_run: remaining,
@@ -258,7 +272,7 @@ export async function planOntologyRun(options) {
258
272
  fs.mkdirSync(runDir, { recursive: true });
259
273
  const planPath = path.join(runDir, PLAN_FILE);
260
274
  fs.writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
261
- return { status: "planned", plan, planPath };
275
+ return companyBrief ? { status: "planned", plan, planPath, companyBrief } : { status: "planned", plan, planPath };
262
276
  }
263
277
  const SESSION_TEXT_MAX_BYTES = 8_000;
264
278
  /** The session summary, decisions and learnings from the payload, capped. */
@@ -67,6 +67,7 @@ export declare function selectClaudeAccount(input: {
67
67
  targetHost: LaneHost;
68
68
  config: LanesConfig;
69
69
  allowedAccounts?: readonly string[];
70
+ model?: string;
70
71
  explicitAccount?: string;
71
72
  unavailableHosts?: readonly BalanceHostGap[];
72
73
  }): ClaudeAccountSelection;
@@ -91,7 +91,10 @@ function buildLoads(input) {
91
91
  const loads = Object.keys(configAccounts).sort().map((account) => {
92
92
  const hosts = input.hosts.map((host) => accountHostLoad(host, account));
93
93
  const targetLoad = hosts.find((host) => sameHost(host.host, targetHost));
94
- const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad);
94
+ const configuredModel = configAccounts[account]?.model;
95
+ const ineligible = targetIneligibilityReason(account, configAccounts[account]?.enabled === true, input.allowedAccounts, targetLoad) ?? (input.model !== undefined && configuredModel !== undefined && configuredModel !== input.model
96
+ ? "model_mismatch"
97
+ : undefined);
95
98
  if (ineligible !== undefined)
96
99
  skipped.push({ account, host: targetHost, reason: ineligible });
97
100
  const knownCounts = hosts.flatMap((host) => host.live_lanes === null ? [] : [host.live_lanes]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.201.3",
3
+ "version": "5.202.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {