@indigoai-us/hq-cli 5.217.0 → 5.219.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.
@@ -13,6 +13,7 @@ import * as path from "node:path";
13
13
  import { readLocalOverride } from "@indigoai-us/hq-flags-client";
14
14
  import { Option } from "commander";
15
15
  import { registerOutpostPrunerCommand } from "./outpost-pruner.js";
16
+ import { registerLaneChecksCommand } from "./lanes-checks.js";
16
17
  import { ensureCognitoToken } from "../utils/cognito-session.js";
17
18
  import { resolveLanesHqRoot } from "../lib/lanes/hq-root.js";
18
19
  import { isSsmRunCommandProcess } from "../lib/lanes/ssm-command-context.js";
@@ -33,6 +34,7 @@ import { vaultApiFetch } from "../utils/vault-api.js";
33
34
  import { resolveActiveMembershipCompany } from "../lib/mesh/api.js";
34
35
  import { openMeshTransport, requireToken } from "../lib/mesh/client.js";
35
36
  import { CompanySelectionError } from "../utils/company-selection-error.js";
37
+ import { assertNoSecretText } from "../lib/lanes/text-scan.js";
36
38
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshEventIdentity, } from "../lib/lanes/mesh-events.js";
37
39
  import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
38
40
  import { effectiveAccountHome } from "../lib/lanes/account-home.js";
@@ -40,7 +42,7 @@ import { AccountResolutionError, compareLaneQueueOrder, coveredLaneIds, DEFAULT_
40
42
  import { activeLaneStories, laneStories, laneStory, storyWarning, } from "../lib/lanes/stories.js";
41
43
  import { resolveLaneKeepAlive } from "../lib/lanes/profile.js";
42
44
  import { keepAliveExclusion, loadRetiredLaneIds, } from "../lib/lanes/keep-alive.js";
43
- import { questionBodyAbsPath } from "../lib/lanes/questions.js";
45
+ import { questionBodyAbsPath, resolveQuestionCompany, } from "../lib/lanes/questions.js";
44
46
  import { compactionAfterUsage, } from "../lib/lanes/compaction.js";
45
47
  import { assertUniqueAccountHomes, providerCompactAfterMs } from "../lib/lanes/config.js";
46
48
  import { parseCliEffort, resolveLaneEffort, } from "../lib/lanes/effort.js";
@@ -1579,6 +1581,40 @@ function sameQuestionCompany(a, b) {
1579
1581
  const bSlug = b.slug.trim();
1580
1582
  return aSlug !== "" && aSlug === bSlug;
1581
1583
  }
1584
+ function effectiveLaneQuestionCompany(lane) {
1585
+ return lane.company ?? lane.mesh_company ?? null;
1586
+ }
1587
+ function questionCompanyForLane(question, lane) {
1588
+ return question.company ?? effectiveLaneQuestionCompany(lane);
1589
+ }
1590
+ async function resolveActiveQuestionCompany(ctx) {
1591
+ try {
1592
+ return await ctx.resolveCompany();
1593
+ }
1594
+ catch (err) {
1595
+ if (!(err instanceof CompanySelectionError))
1596
+ throw err;
1597
+ throw new LanesError("question_company_unavailable", "Cannot access lane questions because the active HQ company could not be resolved", {});
1598
+ }
1599
+ }
1600
+ function assertQuestionCompanyMatchesLane(question, lane, code) {
1601
+ const laneCompany = effectiveLaneQuestionCompany(lane);
1602
+ if (question.company != null && laneCompany !== null && !sameQuestionCompany(question.company, laneCompany)) {
1603
+ throw new LanesError(code, `Question ${question.question_id} does not match lane ${lane.lane_id}'s company`, { question_id: question.question_id, lane_id: lane.lane_id });
1604
+ }
1605
+ }
1606
+ function assertQuestionCompanyAccess(question, lane, requestedCompany) {
1607
+ assertQuestionCompanyMatchesLane(question, lane, "question_company_mismatch");
1608
+ const questionCompany = questionCompanyForLane(question, lane);
1609
+ if (questionCompany !== null && !sameQuestionCompany(questionCompany, requestedCompany)) {
1610
+ throw new LanesError("question_company_mismatch", `Question ${question.question_id} does not belong to the requested or active company`, { question_id: question.question_id, lane_id: lane.lane_id });
1611
+ }
1612
+ }
1613
+ function assertQuestionCompanyResolvedForAnswer(question, lane) {
1614
+ if (questionCompanyForLane(question, lane) !== null)
1615
+ return;
1616
+ throw new LanesError("question_company_unavailable", `Cannot answer legacy question ${question.question_id}: its company cannot be resolved from the question or lane`, { question_id: question.question_id, lane_id: lane.lane_id });
1617
+ }
1582
1618
  function questionCompanyRefMatches(recordCompany, filter) {
1583
1619
  return recordCompany !== null &&
1584
1620
  recordCompany !== undefined &&
@@ -3543,9 +3579,11 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
3543
3579
  });
3544
3580
  }
3545
3581
  const ownerAnswer = ownerQuestion.answer;
3582
+ const ownerLaneCompany = effectiveLaneQuestionCompany(lane);
3546
3583
  if (!isQuestionAnswer(ownerAnswer) ||
3547
3584
  ownerQuestion.lane_id !== lane.lane_id ||
3548
- !sameQuestionCompany(ownerQuestion.company, lane.company)) {
3585
+ (ownerQuestion.company !== null && ownerLaneCompany !== null &&
3586
+ !sameQuestionCompany(ownerQuestion.company, ownerLaneCompany))) {
3549
3587
  throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} did not return the confirmed answer for question ${questionId}`, {
3550
3588
  lane_id: lane.lane_id,
3551
3589
  question_id: questionId,
@@ -3557,14 +3595,31 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
3557
3595
  if (current.lane_id !== lane.lane_id) {
3558
3596
  throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} belongs to a different lane than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
3559
3597
  }
3560
- if (!sameQuestionCompany(current.company, lane.company)) {
3598
+ if (current.company !== null &&
3599
+ effectiveLaneQuestionCompany(lane) !== null &&
3600
+ !sameQuestionCompany(current.company, effectiveLaneQuestionCompany(lane))) {
3561
3601
  throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} belongs to a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
3562
3602
  }
3603
+ if (current.company !== null &&
3604
+ ownerQuestion.company !== null &&
3605
+ !sameQuestionCompany(current.company, ownerQuestion.company)) {
3606
+ throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} has a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
3607
+ }
3563
3608
  if (current.status === "pending") {
3564
- return { ...current, status: "answered", answer: ownerAnswer };
3609
+ return {
3610
+ ...current,
3611
+ company: current.company ?? ownerQuestion.company,
3612
+ status: "answered",
3613
+ answer: ownerAnswer,
3614
+ delivered_at: ownerQuestion.delivered_at ?? current.delivered_at ?? null,
3615
+ };
3565
3616
  }
3566
3617
  if (current.status === "answered" && sameQuestionAnswer(current.answer, ownerAnswer)) {
3567
- return current;
3618
+ const deliveredAt = ownerQuestion.delivered_at ?? current.delivered_at ?? null;
3619
+ const company = current.company ?? ownerQuestion.company;
3620
+ return deliveredAt === current.delivered_at && company === current.company
3621
+ ? current
3622
+ : { ...current, company, delivered_at: deliveredAt };
3568
3623
  }
3569
3624
  throw new LanesError("outpost_question_sync_conflict", `Primary question ${questionId} is ${current.status} or has a different answer than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
3570
3625
  });
@@ -3576,7 +3631,9 @@ async function dispatchOutpostQuestionAnswer(ctx, lane, questionId, options) {
3576
3631
  if (!isLanesError(err) || err.error !== "question_not_found")
3577
3632
  throw err;
3578
3633
  const primaryLane = readLane(ctx.hqRoot(), lane.lane_id);
3579
- if (!sameQuestionCompany(primaryLane.company, lane.company)) {
3634
+ const primaryCompany = effectiveLaneQuestionCompany(primaryLane);
3635
+ const ownerCompany = effectiveLaneQuestionCompany(lane);
3636
+ if (primaryCompany !== null && ownerCompany !== null && !sameQuestionCompany(primaryCompany, ownerCompany)) {
3580
3637
  throw new LanesError("outpost_question_sync_conflict", `Primary lane ${lane.lane_id} belongs to a different company than Outpost ${outpostId} confirmed`, { lane_id: lane.lane_id, question_id: questionId, outpost_id: outpostId });
3581
3638
  }
3582
3639
  try {
@@ -7035,6 +7092,10 @@ async function runAsk(ctx, opts) {
7035
7092
  }
7036
7093
  const lane = readLane(hqRoot, laneId);
7037
7094
  assertLaneRunsHereForOperatorMutation(ctx, lane, "ask");
7095
+ const activeCompany = lane.company || lane.mesh_company
7096
+ ? undefined
7097
+ : await resolveActiveQuestionCompany(ctx);
7098
+ const questionCompany = resolveQuestionCompany(lane, activeCompany);
7038
7099
  const optionList = Array.isArray(opts.option)
7039
7100
  ? opts.option
7040
7101
  : opts.option
@@ -7059,6 +7120,7 @@ async function runAsk(ctx, opts) {
7059
7120
  body,
7060
7121
  raisedVia: "cli",
7061
7122
  at,
7123
+ activeCompany: questionCompany,
7062
7124
  questionId,
7063
7125
  });
7064
7126
  questionId = withBody.question_id;
@@ -7084,6 +7146,8 @@ async function runAsk(ctx, opts) {
7084
7146
  return;
7085
7147
  }
7086
7148
  catch (err) {
7149
+ if (isLanesError(err) && err.error === "secret_text_refused")
7150
+ throw err;
7087
7151
  lastErr = err;
7088
7152
  process.stderr.write(`hq lanes ask: attempt ${i + 1}/${attempts} failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
7089
7153
  if (i === attempts - 1)
@@ -7113,14 +7177,15 @@ async function runAsk(ctx, opts) {
7113
7177
  });
7114
7178
  }
7115
7179
  const OUTPOST_QUESTION_LOOKUP_CONCURRENCY = 4;
7116
- function groupQuestionLanesByCompany(lanes) {
7180
+ function groupQuestionLanesByCompany(lanes, fallbackCompany) {
7117
7181
  const groups = [];
7118
7182
  for (const lane of lanes) {
7119
- const existing = groups.find((group) => sameQuestionCompany(group.company, lane.company));
7183
+ const company = effectiveLaneQuestionCompany(lane) ?? fallbackCompany ?? null;
7184
+ const existing = groups.find((group) => sameQuestionCompany(group.company, company));
7120
7185
  if (existing)
7121
7186
  existing.lanes.push(lane);
7122
7187
  else
7123
- groups.push({ company: lane.company, lanes: [lane] });
7188
+ groups.push({ company, lanes: [lane] });
7124
7189
  }
7125
7190
  return groups;
7126
7191
  }
@@ -7265,7 +7330,7 @@ async function readOutpostQuestionDetail(ctx, owner) {
7265
7330
  }
7266
7331
  if (question.question_id !== owner.question.question_id ||
7267
7332
  question.lane_id !== owner.lane.lane_id ||
7268
- !sameQuestionCompany(question.company, owner.lane.company)) {
7333
+ !sameQuestionCompany(question.company, effectiveLaneQuestionCompany(owner.lane))) {
7269
7334
  throw new LanesError("outpost_question_company_mismatch", `Outpost ${outpostId} returned question ${owner.question.question_id} for a different lane or company`, { outpost_id: outpostId, question_id: owner.question.question_id });
7270
7335
  }
7271
7336
  const audience = parseOutpostQuestionAudience(response.audience, outpostId, 0);
@@ -7274,7 +7339,9 @@ async function readOutpostQuestionDetail(ctx, owner) {
7274
7339
  }
7275
7340
  async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
7276
7341
  const hostedLanes = loadAllLanes(hqRoot).filter((lane) => lane.host.kind === "outpost" &&
7277
- (!companyFilter || questionCompanyRefMatches(lane.company, companyFilter)));
7342
+ (!companyFilter ||
7343
+ effectiveLaneQuestionCompany(lane) === null ||
7344
+ questionCompanyRefMatches(effectiveLaneQuestionCompany(lane), companyFilter)));
7278
7345
  const lanesByOutpost = new Map();
7279
7346
  for (const lane of hostedLanes) {
7280
7347
  if (lane.host.kind !== "outpost")
@@ -7287,7 +7354,7 @@ async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
7287
7354
  const errors = [];
7288
7355
  const eligibleGroups = [];
7289
7356
  for (const [outpostId, lanes] of [...lanesByOutpost.entries()].sort(([a], [b]) => a.localeCompare(b))) {
7290
- for (const group of groupQuestionLanesByCompany(lanes)) {
7357
+ for (const group of groupQuestionLanesByCompany(lanes, companyFilter)) {
7291
7358
  if (group.company === null) {
7292
7359
  if (await ctx.laneOutpostQuestionVisibilityEnabled(null)) {
7293
7360
  errors.push({
@@ -7346,7 +7413,7 @@ async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
7346
7413
  const lane = localLanesById.get(question.lane_id);
7347
7414
  if (!lane)
7348
7415
  continue;
7349
- if (!sameQuestionCompany(question.company, lane.company)) {
7416
+ if (!sameQuestionCompany(question.company, effectiveLaneQuestionCompany(lane))) {
7350
7417
  throw new LanesError("outpost_question_company_mismatch", `Outpost ${outpostId} question ${question.question_id} does not match its primary lane company`, { outpost_id: outpostId, question_id: question.question_id });
7351
7418
  }
7352
7419
  if (seenQuestionIds.has(question.question_id)) {
@@ -7379,14 +7446,42 @@ async function loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, companyFilter) {
7379
7446
  errors: [...new Map(errors.map((entry) => [entry.host, entry])).values()],
7380
7447
  };
7381
7448
  }
7449
+ function syncPrimaryQuestionDelivery(hqRoot, owner) {
7450
+ const deliveredAt = owner.question.delivered_at;
7451
+ if (!deliveredAt)
7452
+ return;
7453
+ try {
7454
+ casUpdateQuestion(hqRoot, owner.question.question_id, (current) => {
7455
+ if (current.lane_id !== owner.lane.lane_id) {
7456
+ throw new LanesError("outpost_question_sync_conflict", `Primary question ${current.question_id} belongs to a different lane than its Outpost owner`, { lane_id: owner.lane.lane_id, question_id: current.question_id });
7457
+ }
7458
+ if (current.company !== null &&
7459
+ owner.question.company !== null &&
7460
+ !sameQuestionCompany(current.company, owner.question.company)) {
7461
+ throw new LanesError("outpost_question_sync_conflict", `Primary question ${current.question_id} belongs to a different company than its Outpost owner`, { lane_id: owner.lane.lane_id, question_id: current.question_id });
7462
+ }
7463
+ const company = current.company ?? owner.question.company;
7464
+ const nextDeliveredAt = current.delivered_at ?? deliveredAt;
7465
+ return company === current.company && nextDeliveredAt === current.delivered_at
7466
+ ? current
7467
+ : { ...current, company, delivered_at: nextDeliveredAt };
7468
+ });
7469
+ }
7470
+ catch (err) {
7471
+ if (isLanesError(err) && err.error === "question_not_found")
7472
+ return;
7473
+ throw err;
7474
+ }
7475
+ }
7382
7476
  async function runQuestionsList(ctx, opts) {
7383
7477
  const hqRoot = ctx.hqRoot();
7384
7478
  const status = opts.status ? parseQuestionStatus(opts.status) : undefined;
7385
7479
  const forFilter = opts.for ? parseForFilter(opts.for, ctx) : undefined;
7386
- let company;
7387
- if (opts.company)
7388
- company = await ctx.resolveCompany(opts.company);
7389
- const localLanes = opts.allHosts ? new Map(loadAllLanes(hqRoot).map((lane) => [lane.lane_id, lane])) : undefined;
7480
+ const company = opts.company
7481
+ ? await ctx.resolveCompany(opts.company)
7482
+ : await resolveActiveQuestionCompany(ctx);
7483
+ const localLanes = new Map(loadAllLanes(hqRoot).map((lane) => [lane.lane_id, lane]));
7484
+ const targeted = opts.from !== undefined || opts.project !== undefined || opts.story !== undefined;
7390
7485
  const localQuestions = loadAllQuestions(hqRoot)
7391
7486
  .filter((q) => {
7392
7487
  if (status && q.status !== status)
@@ -7397,16 +7492,15 @@ async function runQuestionsList(ctx, opts) {
7397
7492
  return false;
7398
7493
  if (opts.story && q.story_id !== opts.story)
7399
7494
  return false;
7400
- if (company && !(opts.allHosts
7401
- ? questionCompanyRefMatches(q.company, company)
7402
- : companyRefMatches(q.company, company))) {
7403
- return false;
7404
- }
7405
- if (company && localLanes) {
7406
- const lane = localLanes.get(q.lane_id);
7407
- if (!lane || !questionCompanyRefMatches(lane.company, company) || !sameQuestionCompany(q.company, lane.company)) {
7408
- return false;
7495
+ const lane = localLanes.get(q.lane_id);
7496
+ if (lane)
7497
+ assertQuestionCompanyMatchesLane(q, lane, "question_company_mismatch");
7498
+ const questionCompany = lane ? questionCompanyForLane(q, lane) : q.company;
7499
+ if (questionCompany !== null && !sameQuestionCompany(questionCompany, company)) {
7500
+ if (targeted) {
7501
+ throw new LanesError("question_company_mismatch", `Question ${q.question_id} does not belong to the requested or active company`, { question_id: q.question_id, lane_id: q.lane_id });
7409
7502
  }
7503
+ return false;
7410
7504
  }
7411
7505
  if (forFilter) {
7412
7506
  const audience = resolveAudience(hqRoot, q, ctx);
@@ -7418,6 +7512,10 @@ async function runQuestionsList(ctx, opts) {
7418
7512
  const outpostLoad = opts.allHosts
7419
7513
  ? await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot, company)
7420
7514
  : { questions: [], errors: [] };
7515
+ for (const owner of outpostLoad.questions) {
7516
+ assertQuestionCompanyAccess(owner.question, owner.lane, company);
7517
+ syncPrimaryQuestionDelivery(hqRoot, owner);
7518
+ }
7421
7519
  const remoteQuestionIds = new Set(outpostLoad.questions.map(({ question }) => question.question_id));
7422
7520
  const questions = [
7423
7521
  ...localQuestions
@@ -7430,7 +7528,7 @@ async function runQuestionsList(ctx, opts) {
7430
7528
  };
7431
7529
  if (!opts.allHosts)
7432
7530
  return row;
7433
- const lane = localLanes?.get(q.lane_id);
7531
+ const lane = localLanes.get(q.lane_id);
7434
7532
  const host = lane?.host.kind === "outpost" ? `outpost:${lane.host.id}` : "local";
7435
7533
  return { ...row, host };
7436
7534
  }),
@@ -7444,11 +7542,7 @@ async function runQuestionsList(ctx, opts) {
7444
7542
  return false;
7445
7543
  if (opts.story && q.story_id !== opts.story)
7446
7544
  return false;
7447
- if (company && (!questionCompanyRefMatches(q.company, company) ||
7448
- !questionCompanyRefMatches(lane.company, company) ||
7449
- !sameQuestionCompany(q.company, lane.company))) {
7450
- return false;
7451
- }
7545
+ assertQuestionCompanyAccess(q, lane, company);
7452
7546
  if (forFilter) {
7453
7547
  if (!audienceMatches(audience, forFilter))
7454
7548
  return false;
@@ -7518,7 +7612,15 @@ async function runQuestionsShow(ctx, questionId) {
7518
7612
  }
7519
7613
  async function runQuestionsAnswer(ctx, questionId, opts) {
7520
7614
  const hqRoot = ctx.hqRoot();
7521
- const company = opts.company ? await ctx.resolveCompany(opts.company) : undefined;
7615
+ if (opts.choice !== undefined)
7616
+ assertNoSecretText("answer choice", opts.choice);
7617
+ if (opts.freeform !== undefined)
7618
+ assertNoSecretText("freeform answer", opts.freeform);
7619
+ if (opts.notes !== undefined)
7620
+ assertNoSecretText("answer notes", opts.notes);
7621
+ const company = opts.company
7622
+ ? await ctx.resolveCompany(opts.company)
7623
+ : await resolveActiveQuestionCompany(ctx);
7522
7624
  let question;
7523
7625
  try {
7524
7626
  question = readQuestion(hqRoot, questionId);
@@ -7535,6 +7637,17 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
7535
7637
  }
7536
7638
  const matches = lookup.questions.filter((entry) => entry.question.question_id === questionId);
7537
7639
  if (matches.length === 0) {
7640
+ const allCompanyLookup = await loadPrimaryOwnedOutpostQuestions(ctx, hqRoot);
7641
+ const otherCompanyMatch = allCompanyLookup.questions.find((entry) => entry.question.question_id === questionId);
7642
+ if (otherCompanyMatch) {
7643
+ assertQuestionCompanyAccess(otherCompanyMatch.question, otherCompanyMatch.lane, company);
7644
+ }
7645
+ if (allCompanyLookup.errors.length > 0) {
7646
+ throw new LanesError("outpost_question_lookup_unavailable", `Could not verify whether Outposts own question ${questionId}`, {
7647
+ question_id: questionId,
7648
+ hosts: allCompanyLookup.errors.map(({ host, error }) => ({ host, error })),
7649
+ });
7650
+ }
7538
7651
  throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
7539
7652
  }
7540
7653
  if (matches.length > 1) {
@@ -7544,29 +7657,24 @@ async function runQuestionsAnswer(ctx, questionId, opts) {
7544
7657
  if (!owner) {
7545
7658
  throw new LanesError("question_not_found", `Question ${questionId} not found on this primary or its Outposts`, { question_id: questionId });
7546
7659
  }
7547
- if (!sameQuestionCompany(owner.question.company, owner.lane.company)) {
7548
- throw new LanesError("outpost_question_company_mismatch", `Question ${questionId} does not match its primary lane company`, { question_id: questionId, lane_id: owner.lane.lane_id });
7549
- }
7660
+ assertQuestionCompanyAccess(owner.question, owner.lane, company);
7661
+ assertQuestionCompanyResolvedForAnswer(owner.question, owner.lane);
7550
7662
  throwIfQuestionNotPending(owner.question);
7551
7663
  await yieldEventLoop();
7552
7664
  await dispatchOutpostQuestionAnswer(ctx, owner.lane, questionId, {
7553
7665
  ...opts,
7554
- company: questionListCompanyArg(owner.lane.company),
7666
+ company: questionListCompanyArg(effectiveLaneQuestionCompany(owner.lane)),
7555
7667
  });
7556
7668
  return;
7557
7669
  }
7558
7670
  await yieldEventLoop();
7559
7671
  const askingLane = readLane(hqRoot, laneIdForAnswerDelivery(question));
7560
- if (company &&
7561
- (!questionCompanyRefMatches(question.company, company) ||
7562
- !questionCompanyRefMatches(askingLane.company, company) ||
7563
- !sameQuestionCompany(question.company, askingLane.company))) {
7564
- throw new LanesError("question_company_mismatch", `Question ${questionId} does not belong to the requested company`, { question_id: questionId, lane_id: askingLane.lane_id });
7565
- }
7672
+ assertQuestionCompanyAccess(question, askingLane, company);
7673
+ assertQuestionCompanyResolvedForAnswer(question, askingLane);
7566
7674
  if (askingLane.host.kind === "outpost" && !laneIsHostedHere(ctx, askingLane)) {
7567
7675
  await dispatchOutpostQuestionAnswer(ctx, askingLane, questionId, {
7568
7676
  ...opts,
7569
- ...(askingLane.company ? { company: questionListCompanyArg(askingLane.company) } : {}),
7677
+ company: questionListCompanyArg(effectiveLaneQuestionCompany(askingLane)),
7570
7678
  });
7571
7679
  return;
7572
7680
  }
@@ -8303,6 +8411,7 @@ async function runReconcile(ctx, opts) {
8303
8411
  throttleDeps: ctx.throttleDeps,
8304
8412
  livenessDeps: livenessDepsFromCtx(ctx),
8305
8413
  presenceConnected: ctx.presenceConnected,
8414
+ resolveQuestionActiveCompany: () => resolveActiveQuestionCompany(ctx),
8306
8415
  mesh: ctx.mesh,
8307
8416
  env: ctx.env,
8308
8417
  });
@@ -8383,6 +8492,13 @@ export function registerLanesCommand(parent, deps = {}) {
8383
8492
  now: ctx.now,
8384
8493
  actor: ctx.trackerActor,
8385
8494
  }, wrap);
8495
+ registerLaneChecksCommand(lanes, {
8496
+ hqRoot: ctx.hqRoot,
8497
+ resolveCompany: ctx.resolveCompany,
8498
+ trackerEnabled: ctx.laneTrackerEnabled,
8499
+ trackerMeshEnabled: ctx.laneTrackerMeshEnabled,
8500
+ now: ctx.now,
8501
+ }, wrap);
8386
8502
  registerOutpostPrunerCommand(lanes);
8387
8503
  const noStoryOption = new Option("--no-story <reason>", "Create a lane without a story and record why");
8388
8504
  noStoryOption.attributeName = () => "noStory";
@@ -7,7 +7,7 @@
7
7
  * that resolve to the same home are refused (identity-keyed-cap rule).
8
8
  */
9
9
  import * as fs from "node:fs";
10
- import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig } from "./types.js";
10
+ import { type AccountConfig, type LaneProvider, type LanesConfig, type LivenessWindows, type ProviderConfig, type TrackerChecksConfig } from "./types.js";
11
11
  export declare const LANES_CONFIG_REL: string;
12
12
  export declare const DEFAULT_MAX_QUEUED = 3;
13
13
  export declare const DEFAULT_CPU_BUSY_PAUSE = 0.85;
@@ -39,6 +39,7 @@ export declare const DEFAULT_TURN_START_LOST_MS: number;
39
39
  export declare const DEFAULT_SILENT_LOST_MS: number;
40
40
  /** Unanswered session questions move to the next audience after 10 minutes. */
41
41
  export declare const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS: number;
42
+ export declare const DEFAULT_TRACKER_CHECKS: TrackerChecksConfig;
42
43
  export declare const DEFAULT_LIVENESS_WINDOWS: LivenessWindows;
43
44
  export declare const DEFAULT_PROVIDER_MAX_RUNNING: Record<LaneProvider, number>;
44
45
  export declare const DEFAULT_ACCOUNT_HOMES: Record<LaneProvider, string>;
@@ -43,6 +43,15 @@ export const DEFAULT_TURN_START_LOST_MS = 2 * 60 * 60 * 1000;
43
43
  export const DEFAULT_SILENT_LOST_MS = 12 * 60 * 60 * 1000;
44
44
  /** Unanswered session questions move to the next audience after 10 minutes. */
45
45
  export const DEFAULT_SESSION_QUESTION_TIMEOUT_SECS = 10 * 60;
46
+ export const DEFAULT_TRACKER_CHECKS = {
47
+ owner_question_surface_ms: 10 * 60 * 1000,
48
+ open_question_age_ms: 2 * 60 * 60 * 1000,
49
+ status_stale_ms: 90 * 60 * 1000,
50
+ task_list_stale_ms: 120 * 60 * 1000,
51
+ blocker_max_age_ms: 24 * 60 * 60 * 1000,
52
+ kpi_value_max_age_ms: 24 * 60 * 60 * 1000,
53
+ directive_ack_timeout_ms: 30 * 60 * 1000,
54
+ };
46
55
  export const DEFAULT_LIVENESS_WINDOWS = {
47
56
  live_within_ms: DEFAULT_LIVE_WITHIN_MS,
48
57
  turn_start_lost_ms: DEFAULT_TURN_START_LOST_MS,
@@ -362,6 +371,25 @@ function parseLiveness(raw) {
362
371
  silent_lost_ms: positiveInt(obj.silent_lost_ms, "liveness.silent_lost_ms", DEFAULT_SILENT_LOST_MS),
363
372
  };
364
373
  }
374
+ function parseTrackerChecks(raw) {
375
+ const tracker = raw === undefined ? {} : raw;
376
+ if (!isPlainObject(tracker)) {
377
+ throw new LanesConfigError("invalid_config", "tracker must be an object");
378
+ }
379
+ const checks = tracker.checks === undefined ? {} : tracker.checks;
380
+ if (!isPlainObject(checks)) {
381
+ throw new LanesConfigError("invalid_config", "tracker.checks must be an object");
382
+ }
383
+ return {
384
+ owner_question_surface_ms: positiveInt(checks.owner_question_surface_ms, "tracker.checks.owner_question_surface_ms", DEFAULT_TRACKER_CHECKS.owner_question_surface_ms),
385
+ open_question_age_ms: positiveInt(checks.open_question_age_ms, "tracker.checks.open_question_age_ms", DEFAULT_TRACKER_CHECKS.open_question_age_ms),
386
+ status_stale_ms: positiveInt(checks.status_stale_ms, "tracker.checks.status_stale_ms", DEFAULT_TRACKER_CHECKS.status_stale_ms),
387
+ task_list_stale_ms: positiveInt(checks.task_list_stale_ms, "tracker.checks.task_list_stale_ms", DEFAULT_TRACKER_CHECKS.task_list_stale_ms),
388
+ blocker_max_age_ms: positiveInt(checks.blocker_max_age_ms, "tracker.checks.blocker_max_age_ms", DEFAULT_TRACKER_CHECKS.blocker_max_age_ms),
389
+ kpi_value_max_age_ms: positiveInt(checks.kpi_value_max_age_ms, "tracker.checks.kpi_value_max_age_ms", DEFAULT_TRACKER_CHECKS.kpi_value_max_age_ms),
390
+ directive_ack_timeout_ms: positiveInt(checks.directive_ack_timeout_ms, "tracker.checks.directive_ack_timeout_ms", DEFAULT_TRACKER_CHECKS.directive_ack_timeout_ms),
391
+ };
392
+ }
365
393
  export function documentedDefaultConfig(deps = defaultDeps()) {
366
394
  const providers = {};
367
395
  for (const provider of LANE_PROVIDERS) {
@@ -384,6 +412,7 @@ export function documentedDefaultConfig(deps = defaultDeps()) {
384
412
  min_available_mem_mb: DEFAULT_MIN_AVAILABLE_MEM_MB,
385
413
  },
386
414
  liveness: { ...DEFAULT_LIVENESS_WINDOWS },
415
+ tracker: { checks: { ...DEFAULT_TRACKER_CHECKS } },
387
416
  session_question_timeout_secs: DEFAULT_SESSION_QUESTION_TIMEOUT_SECS,
388
417
  };
389
418
  }
@@ -425,6 +454,7 @@ export function parseLanesConfig(raw, deps = defaultDeps()) {
425
454
  providers,
426
455
  throttle: parseThrottle(raw.throttle),
427
456
  liveness: parseLiveness(raw.liveness),
457
+ tracker: { checks: parseTrackerChecks(raw.tracker) },
428
458
  session_question_timeout_secs: positiveInt(raw.session_question_timeout_secs, "session_question_timeout_secs", DEFAULT_SESSION_QUESTION_TIMEOUT_SECS),
429
459
  };
430
460
  }
@@ -3,7 +3,7 @@
3
3
  * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
4
  * drift on hop, origin, raised_via, body store, and routing_history.
5
5
  */
6
- import { type LaneEnvelope, type LaneRecord, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
6
+ import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionRecord, type RaisedVia } from "./types.js";
7
7
  export interface QuestionBody {
8
8
  question: string;
9
9
  context: string;
@@ -11,8 +11,12 @@ export interface QuestionBody {
11
11
  recommended?: string;
12
12
  notes?: string;
13
13
  }
14
+ export declare function assertQuestionBodySecretSafe(body: QuestionBody): void;
15
+ /** Errors that make an envelope ask unsafe or impossible to persist. */
16
+ export declare function questionRecordRefusalCode(err: unknown): "secret_text_refused" | "question_company_unavailable" | null;
14
17
  export declare function parseQuestionBodyStore(value: unknown): QuestionBodyStore;
15
- export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company">): QuestionBodyStore;
18
+ export declare function bodyStoreForLane(lane: Pick<LaneRecord, "company" | "mesh_company">, resolvedCompany?: LaneCompanyRef): QuestionBodyStore;
19
+ export declare function resolveQuestionCompany(lane: Pick<LaneRecord, "lane_id" | "company" | "mesh_company">, activeCompany?: LaneCompanyRef | null): LaneCompanyRef;
16
20
  export declare function questionBodyRef(questionId: string): string;
17
21
  export declare function sha256Text(text: string): string;
18
22
  /** Tenant-store path for a question body (spec §6.2 / §10). Never the personal vault for company prose. */
@@ -28,6 +32,7 @@ export interface RaiseLaneQuestionInput {
28
32
  body: QuestionBody;
29
33
  raisedVia: RaisedVia;
30
34
  at: string;
35
+ activeCompany?: LaneCompanyRef | null;
31
36
  questionId?: string;
32
37
  }
33
38
  /**
@@ -43,15 +48,15 @@ export declare function raiseLaneQuestion(input: RaiseLaneQuestionInput): Questi
43
48
  */
44
49
  export declare function classifyQuestionKind(options: readonly string[]): QuestionKind | undefined;
45
50
  /** The store a lane's question bodies live in, derived from the lane and never the reverse. */
46
- export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company">, questionId: string): string;
51
+ export declare function questionBodyPathForLane(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, questionId: string): string;
47
52
  /**
48
53
  * Free-text notes the operator attached with `questions answer --notes`.
49
54
  * Read from the lane's own store (spec §6.5): the company store for a company
50
55
  * lane, the personal store otherwise. Returns undefined when the answer has
51
- * no notes_ref or the body carries no notes; a body the host cannot read is
52
- * reported and skipped rather than failing the resume.
56
+ * no notes_ref or the body carries no notes. An unavailable or malformed body
57
+ * fails the resume so the lane cannot continue without its answer notes.
53
58
  */
54
- export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company">, question: Pick<QuestionRecord, "question_id" | "answer">): string | undefined;
59
+ export declare function readAnswerNotes(hqRoot: string, lane: Pick<LaneRecord, "company" | "mesh_company">, question: Pick<QuestionRecord, "question_id" | "company" | "body" | "answer">): string | undefined;
55
60
  export interface AnsweredQuestionEntry {
56
61
  question_id: string;
57
62
  choice: string | undefined;
@@ -66,10 +71,11 @@ export interface AnsweredQuestionEntry {
66
71
  * directory on the executing host.
67
72
  */
68
73
  export declare function answeredQuestionEntries(hqRoot: string, lane: Pick<LaneRecord, "lane_id" | "company">, questions: QuestionRecord[]): AnsweredQuestionEntry[];
74
+ export declare function markAnsweredQuestionsDelivered(hqRoot: string, laneId: string, questionIds: readonly string[], deliveredAt: string): QuestionRecord[];
69
75
  export declare function envelopeQuestionId(laneId: string, envelopeRef: string, index: number): string;
70
76
  /**
71
77
  * Spec §6.1: each envelope questions[] entry becomes one queue record, via
72
78
  * raiseLaneQuestion (the same function the CLI ask path uses).
73
79
  */
74
- export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[]): QuestionRecord[];
80
+ export declare function raiseEnvelopeQuestions(hqRoot: string, lane: LaneRecord, envelope: LaneEnvelope, envelopeRef: string, at: string, questionIndexes?: readonly number[], activeCompany?: LaneCompanyRef | null): QuestionRecord[];
75
81
  //# sourceMappingURL=questions.d.ts.map