@indigoai-us/hq-cli 5.345.197 → 5.345.199

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/command-catalog.generated.d.ts +393 -12
  3. package/dist/command-catalog.generated.js +482 -12
  4. package/dist/commands/bot-events.d.ts +19 -0
  5. package/dist/commands/bot-events.js +46 -0
  6. package/dist/commands/bot-jobs.d.ts +2 -0
  7. package/dist/commands/bot-jobs.js +43 -2
  8. package/dist/commands/bot.d.ts +5 -0
  9. package/dist/commands/bot.js +33 -1
  10. package/dist/commands/core-resolve-company-native.d.ts +2 -1
  11. package/dist/commands/core-resolve-company-native.js +2 -24
  12. package/dist/commands/deploy-domains.d.ts +14 -2
  13. package/dist/commands/deploy-domains.js +726 -42
  14. package/dist/commands/integrations-connect.js +41 -3
  15. package/dist/commands/lanes-report.d.ts +2 -1
  16. package/dist/commands/lanes-report.js +44 -1
  17. package/dist/commands/lanes.d.ts +9 -0
  18. package/dist/commands/lanes.js +95 -9
  19. package/dist/commands/meetings.js +1 -21
  20. package/dist/commands/secrets.js +27 -6
  21. package/dist/commands/signals-run.d.ts +2 -0
  22. package/dist/commands/signals-run.js +6 -1
  23. package/dist/lanes-drain-hook.js +9 -0
  24. package/dist/lib/bot/api.d.ts +5 -0
  25. package/dist/lib/bot/claude-login.d.ts +31 -0
  26. package/dist/lib/bot/claude-login.js +160 -0
  27. package/dist/lib/bot/company-bind.d.ts +34 -0
  28. package/dist/lib/bot/company-bind.js +112 -0
  29. package/dist/lib/bot/config.js +3 -1
  30. package/dist/lib/bot/events.d.ts +18 -0
  31. package/dist/lib/bot/events.js +160 -0
  32. package/dist/lib/bot/executive-settings.d.ts +10 -0
  33. package/dist/lib/bot/executive-settings.js +43 -0
  34. package/dist/lib/bot/fleet-cron.js +4 -0
  35. package/dist/lib/bot/fleet-local.d.ts +12 -0
  36. package/dist/lib/bot/fleet-local.js +50 -20
  37. package/dist/lib/bot/job-run.js +1 -1
  38. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +8 -0
  39. package/dist/lib/bot/jobs/hot-signal-sync.js +2 -0
  40. package/dist/lib/bot/jobs/meeting-detect.d.ts +28 -0
  41. package/dist/lib/bot/jobs/meeting-detect.js +176 -0
  42. package/dist/lib/bot/jobs/meeting-rider.d.ts +53 -0
  43. package/dist/lib/bot/jobs/meeting-rider.js +284 -0
  44. package/dist/lib/bot/jobs/signal-to-lane.d.ts +32 -0
  45. package/dist/lib/bot/jobs/signal-to-lane.js +169 -0
  46. package/dist/lib/bot/local-jobs.d.ts +15 -2
  47. package/dist/lib/bot/local-jobs.js +24 -7
  48. package/dist/lib/bot/run.d.ts +7 -49
  49. package/dist/lib/bot/run.js +135 -12
  50. package/dist/lib/bot/session.d.ts +19 -1
  51. package/dist/lib/bot/session.js +75 -28
  52. package/dist/lib/bot/status.d.ts +21 -0
  53. package/dist/lib/core-utils/manifest-slugs.d.ts +2 -0
  54. package/dist/lib/core-utils/manifest-slugs.js +26 -0
  55. package/dist/lib/doctor/checks/bots.d.ts +6 -0
  56. package/dist/lib/doctor/checks/bots.js +47 -1
  57. package/dist/lib/hot-channels/adapters/meetings.d.ts +1 -0
  58. package/dist/lib/hot-channels/adapters/meetings.js +3 -1
  59. package/dist/lib/hot-channels/meeting-source-ref.d.ts +3 -0
  60. package/dist/lib/hot-channels/meeting-source-ref.js +7 -0
  61. package/dist/lib/hot-channels/schedule.js +2 -2
  62. package/dist/lib/hot-channels/signals-pass.d.ts +9 -32
  63. package/dist/lib/hot-channels/signals-pass.js +51 -17
  64. package/dist/lib/lanes/admission.d.ts +2 -0
  65. package/dist/lib/lanes/admission.js +4 -0
  66. package/dist/lib/lanes/approval-gate.js +4 -1
  67. package/dist/lib/lanes/links.d.ts +8 -1
  68. package/dist/lib/lanes/links.js +84 -3
  69. package/dist/lib/lanes/outcome.d.ts +154 -0
  70. package/dist/lib/lanes/outcome.js +151 -0
  71. package/dist/lib/lanes/reconcile.js +1 -0
  72. package/dist/lib/lanes/signal-brief-template.d.ts +7 -0
  73. package/dist/lib/lanes/signal-brief-template.js +40 -0
  74. package/dist/lib/lanes/signal-lane.d.ts +24 -0
  75. package/dist/lib/lanes/signal-lane.js +99 -0
  76. package/dist/lib/lanes/source-report.d.ts +37 -0
  77. package/dist/lib/lanes/source-report.js +66 -0
  78. package/dist/lib/lanes/spawn.d.ts +6 -0
  79. package/dist/lib/lanes/spawn.js +21 -0
  80. package/dist/lib/lanes/store.d.ts +3 -0
  81. package/dist/lib/lanes/store.js +64 -0
  82. package/dist/lib/lanes/story-ledger.d.ts +3 -0
  83. package/dist/lib/lanes/story-ledger.js +1 -0
  84. package/dist/lib/lanes/types.d.ts +137 -0
  85. package/dist/lib/lanes/types.js +17 -2
  86. package/dist/lib/lanes/watcher.js +4 -0
  87. package/dist/lib/live-meetings.d.ts +19 -0
  88. package/dist/lib/live-meetings.js +23 -0
  89. package/dist/lib/mesh/live/daemon/doctor.js +6 -7
  90. package/dist/lib/mesh/live/spool.d.ts +8 -0
  91. package/dist/lib/mesh/live/spool.js +59 -40
  92. package/dist/lib/plan-limit-nag.d.ts +30 -4
  93. package/dist/lib/plan-limit-nag.js +68 -17
  94. package/dist/lib/signals/engine.d.ts +4 -0
  95. package/dist/lib/signals/engine.js +25 -5
  96. package/dist/lib/signals/incremental.d.ts +42 -0
  97. package/dist/lib/signals/incremental.js +191 -0
  98. package/dist/lib/signals/prior-signals.d.ts +11 -0
  99. package/dist/lib/signals/prior-signals.js +44 -0
  100. package/dist/lib/signals/reply.js +6 -1
  101. package/dist/lib/signals/settings.d.ts +5 -0
  102. package/dist/lib/signals/settings.js +17 -0
  103. package/dist/lib/signals/steps/extract.d.ts +30 -2
  104. package/dist/lib/signals/steps/extract.js +153 -11
  105. package/dist/lib/signals/steps/types.d.ts +1 -0
  106. package/dist/lib/signals/write-guard.js +7 -0
  107. package/dist/secrets-env-action.d.ts +70 -0
  108. package/dist/secrets-env-action.js +220 -2
  109. package/dist/utils/cognito-callback-port.d.ts +15 -0
  110. package/dist/utils/cognito-callback-port.js +51 -0
  111. package/dist/utils/cognito-config.js +2 -3
  112. package/dist/utils/cognito-session.d.ts +7 -6
  113. package/dist/utils/cognito-session.js +14 -14
  114. package/dist/utils/interactive-caller.d.ts +15 -0
  115. package/dist/utils/interactive-caller.js +70 -0
  116. package/dist/utils/login-browser.js +3 -1
  117. package/dist/utils/resident-package-preload.js +4 -0
  118. package/package.json +9 -7
@@ -7,7 +7,8 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { botsRoot, botDir, launchdLabel } from "../../bot/paths.js";
10
- import { readBotConfig } from "../../bot/config.js";
10
+ import { HQ_FLEET_RUNTIME_ENGINE_ID, readBotConfig } from "../../bot/config.js";
11
+ import { checkClaudeCodeLogin, claudeLoginFix, describeClaudeLoginSources, } from "../../bot/claude-login.js";
11
12
  import { isPidAlive, readBotStatus } from "../../bot/status.js";
12
13
  import { nodeBinaryProblem } from "../../node-binary.js";
13
14
  import { launchdPlistProgramArguments, parseLaunchdCommand } from "../../service-manager/launchd.js";
@@ -17,6 +18,20 @@ const DEFAULT_DEPS = {
17
18
  home: () => os.homedir(),
18
19
  root: (home) => botsRoot(home ?? os.homedir()),
19
20
  };
21
+ function isClaudeFleetBot(config) {
22
+ return config.engine?.id === HQ_FLEET_RUNTIME_ENGINE_ID && config.runtime === "claude";
23
+ }
24
+ /** The last reported failure, unless a later turn worked. */
25
+ export function unresolvedLastFailure(status) {
26
+ const failure = status?.lastFailure;
27
+ if (!failure)
28
+ return null;
29
+ const failedAt = Date.parse(failure.at);
30
+ const okAt = status?.lastTurnOkAt ? Date.parse(status.lastTurnOkAt) : Number.NaN;
31
+ if (Number.isFinite(failedAt) && Number.isFinite(okAt) && okAt > failedAt)
32
+ return null;
33
+ return failure;
34
+ }
20
35
  export const botsFamily = {
21
36
  id: BOTS_FAMILY_ID,
22
37
  title: BOTS_FAMILY_TITLE,
@@ -66,6 +81,7 @@ function runChecks(deps) {
66
81
  ];
67
82
  }
68
83
  const results = [];
84
+ let claudeLogin = null;
69
85
  for (const name of names) {
70
86
  const dir = botDir(name, root);
71
87
  const config = readBotConfig(dir);
@@ -73,6 +89,36 @@ function runChecks(deps) {
73
89
  continue;
74
90
  const status = readBotStatus(dir);
75
91
  const alive = !!status && status.state === "running" && isPidAlive(status.pid);
92
+ if (isClaudeFleetBot(config)) {
93
+ claudeLogin ??= (deps.claudeLogin ?? (() => checkClaudeCodeLogin({ home })))();
94
+ if (claudeLogin.signedIn) {
95
+ results.push({
96
+ status: "PASS",
97
+ checkId: `bots.${name}.claude-login`,
98
+ target: dir,
99
+ message: `${name} can read a Claude Code login here (${claudeLogin.via}).`,
100
+ });
101
+ }
102
+ else {
103
+ results.push({
104
+ status: "FAIL",
105
+ checkId: `bots.${name}.claude-login`,
106
+ target: dir,
107
+ message: `Claude Code is not signed in on this computer, so ${name} cannot answer. ${describeClaudeLoginSources(claudeLogin)}`,
108
+ remediation: claudeLoginFix(name),
109
+ });
110
+ }
111
+ }
112
+ const lastFailure = unresolvedLastFailure(status);
113
+ if (lastFailure) {
114
+ results.push({
115
+ status: "FAIL",
116
+ checkId: `bots.${name}.last-failure`,
117
+ target: dir,
118
+ message: `${name} last failed at ${lastFailure.at} (${lastFailure.class}): ${lastFailure.cause}`,
119
+ remediation: `${lastFailure.fix} Details: hq bot status ${name}`,
120
+ });
121
+ }
76
122
  if (alive && status.lastHeartbeatOk === false) {
77
123
  const heartbeatMessage = status.lastHeartbeatReason === "identity_mismatch"
78
124
  ? `${name} heartbeat stopped: identity mismatch (configured agent ${status.lastHeartbeatConfiguredAgentUid ?? config.agentUid}; token agent ${status.lastHeartbeatTokenAgentUid ?? "unknown"}).`
@@ -42,6 +42,7 @@
42
42
  * watermark; the vault and other providers still land.
43
43
  */
44
44
  import { type PollHotChannelAdapter } from "../contract.js";
45
+ export { meetingSignalSourceRef } from "../meeting-source-ref.js";
45
46
  export declare const MEETINGS_ADAPTER_ID = "meetings";
46
47
  export declare const MEETINGS_CHANNEL = "meeting-events";
47
48
  export declare const MEETINGS_PROVIDER = "hq-vault";
@@ -49,6 +49,8 @@ import { isAuthError } from "../../../utils/auth-error.js";
49
49
  import { MAX_CATCH_UP_WINDOW_MS } from "../contract.js";
50
50
  import { assertSafeSegment, HOT_SIGNAL_ORIGIN, writeFileAtomic } from "../landing.js";
51
51
  import { SyncLockLostError } from "../sync-lock.js";
52
+ import { meetingSignalSourceRef } from "../meeting-source-ref.js";
53
+ export { meetingSignalSourceRef } from "../meeting-source-ref.js";
52
54
  import { MEETING_NOTE_PROVIDERS, MEETING_PROVIDERS, sameMeeting, } from "./meeting-providers.js";
53
55
  export const MEETINGS_ADAPTER_ID = "meetings";
54
56
  export const MEETINGS_CHANNEL = "meeting-events";
@@ -217,7 +219,7 @@ function toEvent(t) {
217
219
  title: t.title,
218
220
  body,
219
221
  payloadRef,
220
- frontmatter: { audience_source: t.audienceSource, provider: MEETINGS_VAULT_SOURCE },
222
+ frontmatter: { audience_source: t.audienceSource, provider: MEETINGS_VAULT_SOURCE, signal_source_ref: meetingSignalSourceRef(nonEmptyString(t.frontmatter.recall_bot_id) ?? t.transcriptId) },
221
223
  raw: { transcript: payloadRef, frontmatter: t.frontmatter, audience: [...t.audience], audience_source: t.audienceSource },
222
224
  };
223
225
  }
@@ -0,0 +1,3 @@
1
+ /** Independent of transcript revisions and the adapter's versioned pointer filename. */
2
+ export declare function meetingSignalSourceRef(id: string): string;
3
+ //# sourceMappingURL=meeting-source-ref.d.ts.map
@@ -0,0 +1,7 @@
1
+ import { assertSafeSegment } from "./landing.js";
2
+ /** Independent of transcript revisions and the adapter's versioned pointer filename. */
3
+ export function meetingSignalSourceRef(id) {
4
+ assertSafeSegment(id, "meeting id");
5
+ return `meeting:${id}`;
6
+ }
7
+ //# sourceMappingURL=meeting-source-ref.js.map
@@ -76,9 +76,9 @@ export function toJobView(found, now) {
76
76
  return {
77
77
  botName: found.botName,
78
78
  jobId: found.job.jobId,
79
- cron: found.job.cron,
79
+ cron: found.job.cron ?? "",
80
80
  timezone: found.job.timezone,
81
- dailyAt: dailyAtOf(found.job.cron),
81
+ dailyAt: dailyAtOf(found.job.cron ?? ""),
82
82
  nextRunAt: next ? next.toISOString() : null,
83
83
  lastRunAt: found.job.lastRunAt ?? null,
84
84
  };
@@ -1,35 +1,3 @@
1
- /**
2
- * The local signals pass (US-011). After each hot-channel adapter run, the
3
- * pass takes the adapter's channel through the ontology worklist, asks
4
- * `claude -p` for signals one document at a time, and writes those signals to
5
- * `companies/{co}/signals/`. It returns the count for the run ledger.
6
- *
7
- * Work comes from the worklist, not only from this run's landings: when the
8
- * worklist script exits 0, the pass takes the channel's unprocessed items in
9
- * the worklist's order (newest first) up to the run's document cap. Items the
10
- * cap leaves over stay unmarked, and a later run picks them up, including a
11
- * run that landed nothing (a bounded backlog drain: at most
12
- * DEFAULT_MAX_SIGNAL_DOCS claude calls per run, and no claude call at all when
13
- * the backlog is empty). A channel with no source.yaml (worklist exit 2, the
14
- * usual case for a hot channel such as `meeting-events`) has no worklist, so
15
- * the pass keeps its own backlog: landed documents it has not processed yet
16
- * are listed in `sources/_signals-pending/{channel}.json` and drained, newest
17
- * first by `when` (else `created_at`), on later runs under the same cap. A
18
- * cloud-run source (exit 4) is skipped.
19
- *
20
- * A landed document with both `payload_ref` and `raw_ref` is a pointer (the
21
- * meetings, Slack and Attio adapters land these). For it, claude gets the
22
- * pointer followed by the document `payload_ref` names, capped at
23
- * MAX_PAYLOAD_BYTES. A payload outside `sources/`, missing or unreadable falls
24
- * back to the pointer alone, and the reason is warned and kept on the signal.
25
- *
26
- * Billing: policy hq-no-headless-print-subscription-billing forbids headless
27
- * `claude -p` in general. This is its allowed case, per the owner decision of
28
- * 2026-10-21: the owner's own `claude` login, on their own machine, in a
29
- * bounded single-turn call. Cloud Claude is not the bot brain, and no model is
30
- * called from the poller itself. Do not reuse this pass for a shared, fleet or
31
- * hosted identity.
32
- */
33
1
  import type { HotChannelRunResult, WriteGuard } from "./loop.js";
34
2
  /** Ledger `error` values a failed pass records. */
35
3
  export type SignalsPassErrorCode = "claude-logged-out" | "claude-timeout" | "claude-bad-output";
@@ -128,6 +96,14 @@ interface ParsedSignal {
128
96
  type: SignalType;
129
97
  title: string;
130
98
  body: string;
99
+ update?: string;
100
+ }
101
+ export interface IndexRow {
102
+ signal_id: string;
103
+ type: string;
104
+ source_ref: string;
105
+ key: string;
106
+ written_at: string;
131
107
  }
132
108
  export declare function defaultPromptFile(): string;
133
109
  export declare const CLAUDE_LOGGED_OUT_RE: RegExp;
@@ -172,6 +148,7 @@ export declare function signalId(sourceRef: string, type: string, title: string)
172
148
  export declare function signalTypeDir(key: string, type: SignalType): string;
173
149
  /** Write `signals/@{key}/_audience.yaml` once, when the partition is first created. */
174
150
  export declare function ensureAudienceFile(vaultRoot: string, item: Pick<WorkItem, "audienceKey" | "principals" | "audienceSource">): Promise<void>;
151
+ export declare function appendSignalIndex(vaultRoot: string, day: string, rows: readonly IndexRow[]): Promise<void>;
175
152
  /**
176
153
  * Build the claude signals pass for one company. The pass keeps state for one
177
154
  * run: the auth preflight runs once, and the document cap counts across every
@@ -1,3 +1,4 @@
1
+ import { prepareSignalPublication, flushSignalPublications } from "../bot/jobs/signal-to-lane.js";
1
2
  /**
2
3
  * The local signals pass (US-011). After each hot-channel adapter run, the
3
4
  * pass takes the adapter's channel through the ontology worklist, asks
@@ -30,6 +31,7 @@
30
31
  * called from the poller itself. Do not reuse this pass for a shared, fleet or
31
32
  * hosted identity.
32
33
  */
34
+ import { acquireSyncLock } from "./sync-lock.js";
33
35
  import { spawn } from "node:child_process";
34
36
  import { createHash } from "node:crypto";
35
37
  import * as fs from "node:fs/promises";
@@ -390,7 +392,10 @@ export function parseClaudeSignals(stdout) {
390
392
  typeof s.body !== "string") {
391
393
  throw new SignalsPassError("claude-bad-output", `claude signal ${i} has the wrong shape`);
392
394
  }
393
- return { type: s.type, title: s.title.trim(), body: s.body.trim() };
395
+ if (s.update !== undefined && (typeof s.update !== "string" || !/^[a-f0-9]{64}$/.test(s.update))) {
396
+ throw new SignalsPassError("claude-bad-output", `claude signal ${i} has an invalid update id`);
397
+ }
398
+ return { type: s.type, title: s.title.trim(), body: s.body.trim(), ...(s.update ? { update: s.update } : {}) };
394
399
  });
395
400
  }
396
401
  /** `sha256(source_ref \n type \n title)`: the same signal from the same source keeps its id. */
@@ -441,25 +446,40 @@ export async function ensureAudienceFile(vaultRoot, item) {
441
446
  throw err;
442
447
  }
443
448
  }
444
- async function appendSignalIndex(vaultRoot, day, rows) {
449
+ export async function appendSignalIndex(vaultRoot, day, rows) {
445
450
  if (rows.length === 0)
446
451
  return;
447
- const file = path.join(vaultRoot, "signals", "_index", `${day}.json`);
448
- let existing = [];
452
+ assertSafeSegment(day, "index day");
453
+ let lock = await acquireSyncLock(vaultRoot, `signal-index-${day}`);
454
+ for (let attempt = 0; !lock && attempt < 100; attempt += 1) {
455
+ await new Promise((resolve) => setTimeout(resolve, 20));
456
+ lock = await acquireSyncLock(vaultRoot, `signal-index-${day}`);
457
+ }
458
+ if (!lock)
459
+ throw new Error("Signal index is busy; retry this extraction");
449
460
  try {
450
- const parsed = JSON.parse(await fs.readFile(file, "utf8"));
451
- if (Array.isArray(parsed))
461
+ const file = path.join(vaultRoot, "signals", "_index", `${day}.json`);
462
+ let existing = [];
463
+ try {
464
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
465
+ if (!Array.isArray(parsed))
466
+ throw new Error("Invalid daily signal index");
452
467
  existing = parsed;
468
+ }
469
+ catch (err) {
470
+ if (err.code !== "ENOENT")
471
+ throw err;
472
+ }
473
+ const seen = new Set(existing.map((row) => row.signal_id));
474
+ const added = rows.filter((row) => !seen.has(row.signal_id));
475
+ if (added.length === 0)
476
+ return;
477
+ await lock.assertHeld();
478
+ await writeFileAtomic(vaultRoot, file, `${JSON.stringify([...existing, ...added], null, 2)}\n`);
453
479
  }
454
- catch (err) {
455
- if (err.code !== "ENOENT")
456
- throw err;
480
+ finally {
481
+ await lock.release();
457
482
  }
458
- const seen = new Set(existing.map((row) => row.signal_id));
459
- const added = rows.filter((row) => !seen.has(row.signal_id));
460
- if (added.length === 0)
461
- return;
462
- await writeFileAtomic(vaultRoot, file, `${JSON.stringify([...existing, ...added], null, 2)}\n`);
463
483
  }
464
484
  /**
465
485
  * Build the claude signals pass for one company. The pass keeps state for one
@@ -610,17 +630,20 @@ export function createClaudeSignalsPass(options) {
610
630
  await beforeWrite();
611
631
  await ensureAudienceFile(vaultRoot, item);
612
632
  }
633
+ const pointer = splitFrontmatter(await fs.readFile(path.join(vaultRoot, item.file), "utf8")).frontmatter;
634
+ const sourceRef = typeof pointer.signal_source_ref === "string" && /^meeting:[a-zA-Z0-9._-]+$/.test(pointer.signal_source_ref)
635
+ ? pointer.signal_source_ref : item.file;
613
636
  for (const signal of signals) {
614
- const id = signalId(item.file, signal.type, signal.title);
637
+ const id = signalId(sourceRef, signal.type, signal.title);
615
638
  const key = `${signalTypeDir(item.audienceKey, signal.type)}/${id}.md`;
616
- rows.push({ signal_id: id, type: signal.type, source_ref: item.file, key, written_at: at });
639
+ rows.push({ signal_id: id, type: signal.type, source_ref: sourceRef, key, written_at: at });
617
640
  const file = path.join(vaultRoot, key);
618
641
  if (await exists(file))
619
642
  continue;
620
643
  const frontmatter = {
621
644
  signal_id: id,
622
645
  type: signal.type,
623
- source_ref: item.file,
646
+ source_ref: sourceRef,
624
647
  canonical_content: signal.title,
625
648
  audience_key: item.audienceKey,
626
649
  origin: SIGNAL_ORIGIN,
@@ -632,15 +655,26 @@ export function createClaudeSignalsPass(options) {
632
655
  frontmatter.payload_fallback = input.fallback;
633
656
  const text = `---\n${yaml.dump(frontmatter, { lineWidth: -1, noRefs: true }).trimEnd()}\n---\n## ${heading(signal.type)}\n\n${signal.body || signal.title}\n`;
634
657
  await beforeWrite();
658
+ // Cloud-extracted signals arriving by upload/garden are out of scope.
659
+ prepareSignalPublication(options.hqRoot, options.company, {
660
+ signal_id: id, type: signal.type, source_ref: sourceRef,
661
+ audience_key: item.audienceKey, origin: SIGNAL_ORIGIN,
662
+ });
635
663
  await writeFileAtomic(vaultRoot, file, text);
664
+ // Publish the index before the event consumer looks up this new signal.
665
+ await appendSignalIndex(vaultRoot, at.slice(0, 10), [rows[rows.length - 1]]);
666
+ flushSignalPublications(options.hqRoot, options.company);
636
667
  written += 1;
637
668
  }
638
669
  await beforeWrite();
639
670
  await appendSignalIndex(vaultRoot, at.slice(0, 10), rows);
671
+ flushSignalPublications(options.hqRoot, options.company);
640
672
  return written;
641
673
  };
642
674
  return async (result, guard) => {
643
675
  const beforeWrite = guard?.beforeWrite ?? (async () => undefined);
676
+ await beforeWrite();
677
+ flushSignalPublications(options.hqRoot, options.company);
644
678
  const byChannel = new Map();
645
679
  // The adapter's own channel is listed even when this run landed nothing
646
680
  // there, so items an earlier run left over the cap still get processed.
@@ -10,6 +10,8 @@ export interface AdmissionHost {
10
10
  }
11
11
  export type AdmissionCapReason = "provider_cap" | "account_cap" | "profile_cap";
12
12
  export declare const ADMISSION_LOCK_STALE_MS = 30000;
13
+ /** Serialize signal lookup and persistence separately from provider admission. */
14
+ export declare function withSignalCreationLock<T>(hqRoot: string, key: string, fn: () => Promise<T>): Promise<T>;
13
15
  export declare function withLaneAdmissionLock<T>(hqRoot: string, provider: LaneRecord["provider"], fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
14
16
  export declare function withAllLaneAdmissionLocks<T>(hqRoot: string, fn: () => Promise<T>, sleep?: (ms: number) => Promise<void>): Promise<T>;
15
17
  export declare function laneRunsOnHost(lane: LaneRecord, host: AdmissionHost): boolean;
@@ -88,6 +88,10 @@ async function withAdmissionLock(lockPath, fn, sleep) {
88
88
  }
89
89
  }
90
90
  }
91
+ /** Serialize signal lookup and persistence separately from provider admission. */
92
+ export function withSignalCreationLock(hqRoot, key, fn) {
93
+ return withAdmissionLock(path.join(hqRoot, "workspace", "lanes-runs", ".signal-locks", key + ".lock"), fn, defaultAdmissionSleep);
94
+ }
91
95
  export function withLaneAdmissionLock(hqRoot, provider, fn, sleep = defaultAdmissionSleep) {
92
96
  return withAdmissionLock(admissionLockPath(hqRoot, provider), fn, sleep);
93
97
  }
@@ -73,9 +73,12 @@ export function enforceRegistryApprovalGate(lane, envelope) {
73
73
  if (missing.length === 0) {
74
74
  return { envelope, approvedIdsToConsume: used, violation: false };
75
75
  }
76
+ // The worker's outcome block described the action the gate just refused,
77
+ // so it does not carry over onto the blocked envelope.
78
+ const { outcome: _refusedOutcome, ...envelopeWithoutOutcome } = envelope;
76
79
  return {
77
80
  envelope: {
78
- ...envelope,
81
+ ...envelopeWithoutOutcome,
79
82
  decision: "blocked",
80
83
  summary: `External action requires senior approval at checkpoint: ${missing.join(", ")}.`,
81
84
  story_status: "review",
@@ -30,10 +30,17 @@ export interface LinkResult {
30
30
  exitCode?: number;
31
31
  }
32
32
  export declare function resolveLinkSessionId(root: string, explicit: string | undefined, env?: NodeJS.ProcessEnv): string;
33
+ /** Resolve only the caller's link, never another arbitrary open conductor. */
34
+ export declare function resolveSignalConductor(root: string, env: NodeJS.ProcessEnv, explicitLink?: string, senior?: string): {
35
+ senior: string;
36
+ link?: string;
37
+ };
33
38
  /** Cheap hook guard used before remote flag lookup or queue access. */
34
39
  export declare function linkedSessionIdFromHookInput(input: string): string;
35
- export declare function isLinkedSession(root: string, sid: string): boolean;
40
+ export declare function isLinkedSession(root: string, sid: string, env?: NodeJS.ProcessEnv): boolean;
36
41
  export declare function drainLinkQueue(inbox: string): string;
42
+ /** Called by the installed lane drain hook before core's pointer-file guard. */
43
+ export declare function bindSignalLaneHook(root: string, input: string, env: NodeJS.ProcessEnv): void;
37
44
  export declare function runLaneLink(subcommand: string, options: LinkOptions): Promise<LinkResult>;
38
45
  /** Apply the default-on feature gate contract at the command boundary. */
39
46
  export declare function runLaneLinkWithFlag(subcommand: string, options: LinkOptions, enabled: boolean): Promise<LinkResult>;
@@ -2,7 +2,7 @@ import { randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { appendDropboxMessageAtPath } from "./dropbox.js";
5
- import { withLaneRecordFileLock } from "./store.js";
5
+ import { readLane, withLaneRecordFileLock } from "./store.js";
6
6
  import { LanesError } from "./errors.js";
7
7
  export const LANE_SESSION_LINKS_FLAG = "lanes.session-links";
8
8
  const ID_PATTERN = /^[A-Za-z0-9._-]+$/;
@@ -58,6 +58,33 @@ function readPointer(root, sid) {
58
58
  throw error;
59
59
  }
60
60
  }
61
+ /** Resolve only the caller's link, never another arbitrary open conductor. */
62
+ export function resolveSignalConductor(root, env, explicitLink, senior) {
63
+ if (senior === "user" && explicitLink === undefined)
64
+ return { senior: "user" };
65
+ let link = explicitLink;
66
+ if (link === undefined) {
67
+ try {
68
+ link = readPointer(root, resolveLinkSessionId(root, undefined, env))?.link;
69
+ }
70
+ catch { /* A missing caller pointer is handled below. */ }
71
+ }
72
+ try {
73
+ const dir = linkPath(root, link);
74
+ const meta = readJson(path.join(dir, "meta.json"));
75
+ if (typeof meta.conductor_session !== "string" || !safeId(meta.conductor_session))
76
+ throw new Error("invalid conductor");
77
+ const resolved = `session:${meta.conductor_session}`;
78
+ if (senior !== undefined && senior !== resolved)
79
+ throw new LanesError("signal_senior_mismatch", "Signal lane senior must match its conductor link");
80
+ return { senior: resolved, link };
81
+ }
82
+ catch (error) {
83
+ if (error instanceof LanesError)
84
+ throw error;
85
+ throw new LanesError("no_conductor_link", "No open conductor link for this session; pass --link or --senior user");
86
+ }
87
+ }
61
88
  function atomicJson(file, value) {
62
89
  fs.mkdirSync(path.dirname(file), { recursive: true });
63
90
  const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
@@ -95,7 +122,44 @@ export function linkedSessionIdFromHookInput(input) {
95
122
  return "";
96
123
  }
97
124
  }
98
- export function isLinkedSession(root, sid) {
125
+ /** A fresh provider session is unknown until its first hook. Bind that session
126
+ * from the persisted lane, rather than inheriting the creator's pointer. */
127
+ function bindSignalLaneSession(root, sid, env) {
128
+ if (!env.HQ_LANE_SIGNAL_LINK)
129
+ return;
130
+ const laneId = env.HQ_LANE_ID;
131
+ if (!safeId(sid) || !safeId(laneId) || readPointer(root, sid))
132
+ return;
133
+ let lane;
134
+ try {
135
+ lane = readLane(root, laneId);
136
+ }
137
+ catch {
138
+ process.stderr.write("hq lanes signal link: lane record unavailable\n");
139
+ return;
140
+ }
141
+ if (!lane.signal || !lane.signal_link || lane.signal_link !== env.HQ_LANE_SIGNAL_LINK)
142
+ return;
143
+ const dir = linkPath(root, lane.signal_link);
144
+ const meta = readJson(path.join(dir, "meta.json"));
145
+ if (lane.senior.kind !== "session" || lane.senior.id !== meta.conductor_session || sid === meta.conductor_session)
146
+ return;
147
+ const child = lane.lane_id;
148
+ const at = new Date().toISOString();
149
+ const childDir = path.join(dir, "children", child);
150
+ fs.mkdirSync(path.join(childDir, "inbox", "pending"), { recursive: true });
151
+ atomicJson(path.join(childDir, "meta.json"), { name: child, session_id: sid, engine: lane.provider, company: lane.company?.slug ?? "", title: lane.signal.title, state: "working", joined: at, updated: at });
152
+ fs.mkdirSync(path.dirname(pointerPath(root, sid)), { recursive: true });
153
+ fs.writeFileSync(pointerPath(root, sid), `child ${lane.signal_link} ${child}\n`, { flag: "wx", mode: 0o600 });
154
+ }
155
+ export function isLinkedSession(root, sid, env = process.env) {
156
+ try {
157
+ bindSignalLaneSession(root, sid, env);
158
+ }
159
+ catch {
160
+ process.stderr.write("hq lanes signal link: could not bind worker session\n");
161
+ return false;
162
+ }
99
163
  return Boolean(safeId(sid) && readPointer(root, sid));
100
164
  }
101
165
  function activeChild(root, link, name) {
@@ -478,6 +542,18 @@ function isChildHookPayload(payload) {
478
542
  const transcript = payload.transcript_path ?? payload.transcriptPath;
479
543
  return typeof transcript === "string" && /[/\\]subagents[/\\][^/\\]+\.jsonl$/.test(transcript);
480
544
  }
545
+ /** Called by the installed lane drain hook before core's pointer-file guard. */
546
+ export function bindSignalLaneHook(root, input, env) {
547
+ const payload = JSON.parse(input);
548
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
549
+ return;
550
+ const record = payload;
551
+ if (isChildHookPayload(record))
552
+ return;
553
+ const sid = linkedSessionIdFromHookInput(input);
554
+ if (sid)
555
+ bindSignalLaneSession(root, sid, env);
556
+ }
481
557
  export async function runLaneLink(subcommand, options) {
482
558
  const now = options.now ?? (() => new Date());
483
559
  const env = options.env ?? process.env;
@@ -498,13 +574,18 @@ export async function runLaneLink(subcommand, options) {
498
574
  childContext = isChildHookPayload(payload);
499
575
  stopHookActive = payload.stop_hook_active === true || payload.stopHookActive === true;
500
576
  }
501
- const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
577
+ const requireSid = () => {
578
+ sid ||= resolveLinkSessionId(root, options.sessionId, env);
579
+ bindSignalLaneSession(root, sid, env);
580
+ return sid;
581
+ };
502
582
  const links = rootDir(root);
503
583
  if (subcommand === "_deliver") {
504
584
  // Subagent hook events carry the parent's session_id. Delivering there would
505
585
  // drain the root's queue into the subagent, so leave the message queued.
506
586
  if (!sid || childContext || !["PostToolUse", "Stop"].includes(options.event ?? ""))
507
587
  return {};
588
+ bindSignalLaneSession(root, sid, env);
508
589
  if (options.event === "Stop" && (stopHookActive || env.HQ_STOP_DECISION_DELIVERABLE === "0"))
509
590
  return {};
510
591
  const pointer = readPointer(root, sid);
@@ -0,0 +1,154 @@
1
+ export declare const LANE_OUTCOME_STATES: readonly ["done", "needs_decision", "partial"];
2
+ export type LaneOutcomeState = (typeof LANE_OUTCOME_STATES)[number];
3
+ export interface LaneOutcomeGathered {
4
+ label: string;
5
+ text: string;
6
+ ref?: string;
7
+ }
8
+ export interface LaneOutcome {
9
+ state: LaneOutcomeState;
10
+ outcome: string;
11
+ gathered: LaneOutcomeGathered[];
12
+ next_steps: string[];
13
+ decision_id?: string;
14
+ }
15
+ /** Envelope fields the consistency rules read. */
16
+ export interface LaneOutcomeEnvelopeContext {
17
+ decision: string;
18
+ /** Number of envelope questions[] entries (0 when absent). */
19
+ questionCount: number;
20
+ }
21
+ export declare const LANE_OUTCOME_JSON_SCHEMA: {
22
+ readonly type: "object";
23
+ readonly additionalProperties: false;
24
+ readonly required: readonly ["state", "outcome", "gathered", "next_steps"];
25
+ readonly properties: {
26
+ readonly state: {
27
+ readonly enum: readonly ["done", "needs_decision", "partial"];
28
+ };
29
+ readonly outcome: {
30
+ readonly type: "string";
31
+ readonly minLength: 1;
32
+ readonly pattern: "\\S";
33
+ };
34
+ readonly gathered: {
35
+ readonly type: "array";
36
+ readonly items: {
37
+ readonly type: "object";
38
+ readonly additionalProperties: false;
39
+ readonly required: readonly ["label", "text"];
40
+ readonly properties: {
41
+ readonly label: {
42
+ readonly type: "string";
43
+ readonly minLength: 1;
44
+ readonly pattern: "\\S";
45
+ };
46
+ readonly text: {
47
+ readonly type: "string";
48
+ readonly minLength: 1;
49
+ readonly pattern: "\\S";
50
+ };
51
+ readonly ref: {
52
+ readonly type: "string";
53
+ readonly minLength: 1;
54
+ readonly pattern: "\\S";
55
+ };
56
+ };
57
+ };
58
+ };
59
+ readonly next_steps: {
60
+ readonly type: "array";
61
+ readonly items: {
62
+ readonly type: "string";
63
+ readonly minLength: 1;
64
+ readonly pattern: "\\S";
65
+ };
66
+ };
67
+ readonly decision_id: {
68
+ readonly type: "string";
69
+ readonly minLength: 1;
70
+ readonly pattern: "\\S";
71
+ };
72
+ };
73
+ readonly allOf: readonly [{
74
+ readonly if: {
75
+ readonly properties: {
76
+ readonly state: {
77
+ readonly const: "partial";
78
+ };
79
+ };
80
+ };
81
+ readonly then: {
82
+ readonly properties: {
83
+ readonly next_steps: {
84
+ readonly minItems: 1;
85
+ };
86
+ };
87
+ };
88
+ }];
89
+ readonly description: string;
90
+ };
91
+ /**
92
+ * Envelope-level rules that tie the outcome block to other envelope fields.
93
+ * Merged into `LANE_ENVELOPE_JSON_SCHEMA.allOf`.
94
+ */
95
+ export declare const LANE_OUTCOME_ENVELOPE_RULES: readonly [{
96
+ readonly if: {
97
+ readonly required: readonly ["outcome"];
98
+ readonly properties: {
99
+ readonly outcome: {
100
+ readonly properties: {
101
+ readonly state: {
102
+ readonly const: "needs_decision";
103
+ };
104
+ };
105
+ };
106
+ };
107
+ };
108
+ readonly then: {
109
+ readonly anyOf: readonly [{
110
+ readonly properties: {
111
+ readonly outcome: {
112
+ readonly required: readonly ["decision_id"];
113
+ };
114
+ };
115
+ }, {
116
+ readonly required: readonly ["questions"];
117
+ readonly properties: {
118
+ readonly questions: {
119
+ readonly minItems: 1;
120
+ };
121
+ };
122
+ }];
123
+ };
124
+ }, {
125
+ readonly if: {
126
+ readonly required: readonly ["outcome"];
127
+ readonly properties: {
128
+ readonly decision: {
129
+ readonly const: "blocked";
130
+ };
131
+ };
132
+ };
133
+ readonly then: {
134
+ readonly properties: {
135
+ readonly outcome: {
136
+ readonly properties: {
137
+ readonly state: {
138
+ readonly not: {
139
+ readonly const: "done";
140
+ };
141
+ };
142
+ };
143
+ };
144
+ };
145
+ };
146
+ }];
147
+ /**
148
+ * Structural check only: field types, enums and the partial → next_steps rule.
149
+ * Used for stored LaneRecord.outcome values, where the envelope is not at hand.
150
+ */
151
+ export declare function parseLaneOutcomeShape(raw: unknown, field?: string): LaneOutcome;
152
+ /** Full check: shape plus the rules that read the surrounding envelope. */
153
+ export declare function parseLaneOutcome(raw: unknown, context: LaneOutcomeEnvelopeContext): LaneOutcome;
154
+ //# sourceMappingURL=outcome.d.ts.map