@indigoai-us/hq-cli 5.252.0 → 5.254.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,7 +2,32 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.254.0] — 2026-09-28
6
+
7
+ ### Added
8
+
9
+ - The `lanes.manager-status-gate` flag checks a manager lane's declared
10
+ status file after each provider turn. It queues at most one corrective
11
+ resume if the file was not updated. An invalid or out-of-boundary mapping
12
+ disables the gate for that lane and lets the lane start.
13
+
14
+ ### Fixed
15
+
16
+ - The end-of-session work report is no longer dropped when the background
17
+ - `hq db sql --remote` is hidden because remote SQL is not available in this
18
+ CLI build. Existing scripts get a clear error, and local SQL remains
19
+ available without `--remote`.
20
+
21
+ ## [5.253.0] — 2026-09-27
22
+
23
+ ### Added
24
+
25
+ - `hq publish --handle <handle>` publishes a pack under one of the creator handles your
26
+ account owns, for accounts that own more than one. The server refuses a handle you do
27
+ not own with a plain message. `--as` is unchanged and cannot be combined with `--handle`.
28
+
5
29
  ## [5.252.0] — 2026-09-27
30
+
6
31
  ### Added
7
32
 
8
33
  - `hq report publish` takes a snapshot of the finished page and publishes it as the report's preview image, using a browser already installed on the computer. A page that names its own preview image keeps it. If no browser is found the report publishes without a preview.
@@ -418,6 +418,9 @@ export declare const COMMAND_CATALOG: readonly [{
418
418
  }, {
419
419
  readonly flags: "--on-behalf <handle>";
420
420
  readonly description: "Alias for --as.";
421
+ }, {
422
+ readonly flags: "--handle <handle>";
423
+ readonly description: "Publish as one of your own claimed creator handles (for accounts that own more than one). Defaults to your first claimed handle.";
421
424
  }];
422
425
  readonly subcommands: readonly [];
423
426
  }, {
@@ -1267,7 +1270,7 @@ export declare const COMMAND_CATALOG: readonly [{
1267
1270
  readonly subcommands: readonly [];
1268
1271
  }, {
1269
1272
  readonly name: "sql";
1270
- readonly description: "Run SQL against the company vault DB (local default; --remote for secrets-injected remote; read-only by default)";
1273
+ readonly description: "Run SQL against the company vault DB (local; read-only by default)";
1271
1274
  readonly aliases: readonly [];
1272
1275
  readonly hidden: false;
1273
1276
  readonly usage: "[options] [sql...]";
@@ -1284,7 +1287,7 @@ export declare const COMMAND_CATALOG: readonly [{
1284
1287
  readonly description: "Allow write/DDL statements (discouraged for ad-hoc DDL)";
1285
1288
  }, {
1286
1289
  readonly flags: "--remote";
1287
- readonly description: "Use remote tier via vault-bound credentials (not auto-replicated with local)";
1290
+ readonly description: "Remote SQL is not available in this CLI build yet; local queries work without --remote";
1288
1291
  }, {
1289
1292
  readonly flags: "--db-path <path>";
1290
1293
  readonly description: "Dangerous: open a non-canonical DB path (denied unless --allow-cross-company-path)";
@@ -530,6 +530,10 @@ export const COMMAND_CATALOG = [
530
530
  {
531
531
  "flags": "--on-behalf <handle>",
532
532
  "description": "Alias for --as."
533
+ },
534
+ {
535
+ "flags": "--handle <handle>",
536
+ "description": "Publish as one of your own claimed creator handles (for accounts that own more than one). Defaults to your first claimed handle."
533
537
  }
534
538
  ],
535
539
  "subcommands": []
@@ -1615,7 +1619,7 @@ export const COMMAND_CATALOG = [
1615
1619
  },
1616
1620
  {
1617
1621
  "name": "sql",
1618
- "description": "Run SQL against the company vault DB (local default; --remote for secrets-injected remote; read-only by default)",
1622
+ "description": "Run SQL against the company vault DB (local; read-only by default)",
1619
1623
  "aliases": [],
1620
1624
  "hidden": false,
1621
1625
  "usage": "[options] [sql...]",
@@ -1637,7 +1641,7 @@ export const COMMAND_CATALOG = [
1637
1641
  },
1638
1642
  {
1639
1643
  "flags": "--remote",
1640
- "description": "Use remote tier via vault-bound credentials (not auto-replicated with local)"
1644
+ "description": "Remote SQL is not available in this CLI build yet; local queries work without --remote"
1641
1645
  },
1642
1646
  {
1643
1647
  "flags": "--db-path <path>",
@@ -4,9 +4,9 @@
4
4
  * Default is read-only. Prefer `hq db migrate` for schema changes.
5
5
  * Never prints remote connection strings.
6
6
  */
7
+ import { Option } from "commander";
7
8
  import chalk from "chalk";
8
9
  import fs from "node:fs";
9
- import { runRemoteSql } from "../lib/db/remote-sql.js";
10
10
  import { formatSqlResult, runLocalSql } from "../lib/db/sql.js";
11
11
  function readSqlArg(sqlArgs, stdinFlag) {
12
12
  if (stdinFlag || sqlArgs.length === 0) {
@@ -19,10 +19,10 @@ function readSqlArg(sqlArgs, stdinFlag) {
19
19
  }
20
20
  export function registerDbSqlCommand(db) {
21
21
  db.command("sql")
22
- .description("Run SQL against the company vault DB (local default; --remote for secrets-injected remote; read-only by default)")
22
+ .description("Run SQL against the company vault DB (local; read-only by default)")
23
23
  .requiredOption("--company <slug>", "Company slug (tenant scope; required)")
24
24
  .option("--write", "Allow write/DDL statements (discouraged for ad-hoc DDL)", false)
25
- .option("--remote", "Use remote tier via vault-bound credentials (not auto-replicated with local)", false)
25
+ .addOption(new Option("--remote", "Remote SQL is not available in this CLI build yet; local queries work without --remote").hideHelp())
26
26
  .option("--db-path <path>", "Dangerous: open a non-canonical DB path (denied unless --allow-cross-company-path)")
27
27
  .option("--allow-cross-company-path", "Dangerous: permit --db-path outside this company's canonical local DB", false)
28
28
  .option("--format <fmt>", "Output format: jsonl | table", "jsonl")
@@ -37,27 +37,13 @@ export function registerDbSqlCommand(db) {
37
37
  process.exitCode = 1;
38
38
  return;
39
39
  }
40
- const sql = readSqlArg(sqlParts, !!opts.stdin).trim();
41
- const format = opts.format === "table" ? "table" : "jsonl";
42
40
  if (opts.remote) {
43
- // Local and remote are not auto-replicated (v1 limitation).
44
- const remoteResult = await runRemoteSql({
45
- company,
46
- sql,
47
- getConnectionConfig: async () => {
48
- throw new Error("no remote binding");
49
- },
50
- });
51
- console.log(formatSqlResult({
52
- columns: remoteResult.rows[0]
53
- ? Object.keys(remoteResult.rows[0])
54
- : [],
55
- rows: remoteResult.rows,
56
- changes: 0,
57
- readonly: true,
58
- }, format));
41
+ console.error(chalk.red("Error: Remote SQL is not available in this CLI build yet. Local queries work without --remote."));
42
+ process.exitCode = 1;
59
43
  return;
60
44
  }
45
+ const sql = readSqlArg(sqlParts, !!opts.stdin).trim();
46
+ const format = opts.format === "table" ? "table" : "jsonl";
61
47
  const result = runLocalSql({
62
48
  company,
63
49
  sql,
@@ -9,6 +9,7 @@ import { createLocalTrackerStore } from "../lib/lanes/tracker-store.js";
9
9
  import { sessionLiveness } from "../lib/lanes/liveness.js";
10
10
  import { findOrphanLanes, parseOrphanMinAge } from "../lib/lanes/orphans.js";
11
11
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
12
+ import { checkCompanyPath } from "../lib/lanes/checked-company-path.js";
12
13
  import { LANES_TRACKER_FLAG } from "./lanes-tracker.js";
13
14
  function fail(error, message, details = {}) {
14
15
  throw new LanesError(error, message, details);
@@ -56,37 +57,6 @@ function readJsonObject(file, missing = "error") {
56
57
  return fail("tracker_checks_input_invalid", "Checks input must be a JSON object", { path: file });
57
58
  return parsed;
58
59
  }
59
- function isWithin(root, candidate) {
60
- const relative = path.relative(root, candidate);
61
- return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
62
- }
63
- function checkCompanyPath(root, company, file, detailsPath) {
64
- if (path.isAbsolute(file))
65
- return fail("tracker_checks_mapping_invalid", "Mapped path must be relative to the HQ root", { path: detailsPath });
66
- const absolute = path.resolve(root, file);
67
- const rootReal = fs.realpathSync(root);
68
- if (!isWithin(rootReal, absolute))
69
- return fail("tracker_checks_mapping_invalid", "Mapped path escapes the HQ root", { path: detailsPath });
70
- const relative = path.relative(rootReal, absolute).split(path.sep);
71
- if (relative[0] === "companies" && relative[1] !== company) {
72
- return fail("tracker_checks_mapping_invalid", "Mapped path crosses the active company boundary", { path: detailsPath });
73
- }
74
- try {
75
- const real = fs.realpathSync(absolute);
76
- if (!isWithin(rootReal, real))
77
- return fail("tracker_checks_mapping_invalid", "Mapped path resolves outside the HQ root", { path: detailsPath });
78
- const realRelative = path.relative(rootReal, real).split(path.sep);
79
- if (realRelative[0] === "companies" && realRelative[1] !== company) {
80
- return fail("tracker_checks_mapping_invalid", "Mapped path resolves into another company", { path: detailsPath });
81
- }
82
- }
83
- catch (err) {
84
- if (err.code !== "ENOENT") {
85
- return fail("tracker_checks_mapping_unreadable", `Mapped path could not be checked: ${err instanceof Error ? err.message : String(err)}`, { path: absolute });
86
- }
87
- }
88
- return absolute;
89
- }
90
60
  function projectFromPrdReference(root, company, raw, registryPath) {
91
61
  if (typeof raw !== "string" || raw.trim() === "") {
92
62
  return fail("tracker_checks_mapping_invalid", "Team mapping needs a PRD path", { path: registryPath });
@@ -10,6 +10,7 @@ import { isPidAlive, runWatcherLoop, WATCHER_OUT_MAX_BYTES, watcherOutLogPath, w
10
10
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
11
11
  import { laneRunDir, probePidRecord, readPidRecord, readProcStarttime, spawnLaneWorker } from "../lib/lanes/spawn.js";
12
12
  import { keepAliveExclusion, LANE_SUPERSESSION_LINEAGE_AWARE_FLAG, loadRetiredLaneIds } from "../lib/lanes/keep-alive.js";
13
+ import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
13
14
  import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
14
15
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
15
16
  export function createLaneMessageHandlers(deps) {
@@ -285,6 +286,7 @@ export function createLaneMessageHandlers(deps) {
285
286
  markAccepted();
286
287
  }
287
288
  else {
289
+ const managerStatusGateEnabled = await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env });
288
290
  const dispatched = await dispatchLaneDelivery(lane, {
289
291
  dropbox: () => {
290
292
  // Live (or about-to-be-live) process: the PreToolUse drain delivers it.
@@ -341,6 +343,7 @@ export function createLaneMessageHandlers(deps) {
341
343
  }
342
344
  const started = spawnLaneWorker(hqRoot, lane, {
343
345
  env: ctx.env,
346
+ managerStatusGateEnabled,
344
347
  modelDeps: {
345
348
  readFileSync: ctx.readFileSync,
346
349
  probe: ctx.probeProvider,
@@ -5,6 +5,7 @@ import { parseCliEffort } from "../lib/lanes/effort.js";
5
5
  import { parseOutpostHireGrantContext } from "../lib/lanes/hire-grant-dispatch.js";
6
6
  import { assertNoSecretText } from "../lib/lanes/text-scan.js";
7
7
  import { laneStories, laneStory, storyWarning } from "../lib/lanes/stories.js";
8
+ import { managerStatusGateEnabledForLane } from "../lib/lanes/manager-status-gate.js";
8
9
  import { spawnLaneWorker } from "../lib/lanes/spawn.js";
9
10
  import { accountMoveSummary, accountRequestKeepsCurrentLane, assertCurrentResumeAccountAvailable, hasKeepAliveProgress, recordedResumeEffort, resetKeepAliveProgress, resolveResumeEffort } from "./lanes-shared.js";
10
11
  export function createLaneResumeHandlers(deps) {
@@ -281,6 +282,7 @@ export function createLaneResumeHandlers(deps) {
281
282
  resumed = lane;
282
283
  }
283
284
  else {
285
+ const managerStatusGateEnabled = await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env });
284
286
  resumed = await withLaneAdmissionLock(hqRoot, lane.provider, async () => {
285
287
  const admission = await assessLaneAdmission(ctx, lane, { queueAt: at });
286
288
  if (admission.action === "refuse" && admission.error)
@@ -327,6 +329,7 @@ export function createLaneResumeHandlers(deps) {
327
329
  }
328
330
  return spawnLaneWorker(hqRoot, lane, {
329
331
  env: ctx.env,
332
+ managerStatusGateEnabled,
330
333
  modelDeps: {
331
334
  readFileSync: ctx.readFileSync,
332
335
  probe: ctx.probeProvider,
@@ -58,7 +58,7 @@ export interface LanesCommandDeps {
58
58
  /** Test seam for worker PID identity checks. */
59
59
  readStarttime?: (pid: number) => number | null;
60
60
  /** Test seams for synchronous dead-worker classification and repair. */
61
- watcherLoopDeps?: Pick<WatcherLoopDeps, "sleep" | "spawnRepair">;
61
+ watcherLoopDeps?: Pick<WatcherLoopDeps, "sleep" | "spawnRepair" | "readManagerStatusGateFlag">;
62
62
  stopSleepMs?: (ms: number) => void;
63
63
  livenessDeps?: LivenessDeps;
64
64
  presenceConnected?: boolean;
@@ -57,6 +57,7 @@ import { LanesError } from "../lib/lanes/errors.js";
57
57
  import { LANE_WAIT_INVALID_INPUT_EXIT_CODE, LANE_WAIT_TIMEOUT_EXIT_CODE, runLaneWait, } from "../lib/lanes/wait.js";
58
58
  import { loadQuotaState, quotaBlockFor } from "../lib/lanes/quota.js";
59
59
  import { SESSION_MOVE_REASON } from "../lib/lanes/move-account.js";
60
+ import { managerStatusGateEnabledForLane, resolveManagerStatusGateForLane, } from "../lib/lanes/manager-status-gate.js";
60
61
  import { ensureSessionHome } from "../lib/lanes/session-relink.js";
61
62
  import { defaultProbe, validateProfileLaneModel, validateLaneModel, } from "../lib/lanes/models.js";
62
63
  import { parseCliModel, refuseOutpostSelfTarget, resolveLaneModel, FOREGROUND_ONLY_RULE, STANDING_AUTHORIZATION, spawnLaneWorker, laneRunDir, readSpawnManifest, stopLaneProcesses, } from "../lib/lanes/spawn.js";
@@ -3072,6 +3073,7 @@ async function runCreate(ctx, opts) {
3072
3073
  if (admission.action === "start") {
3073
3074
  started = spawnLaneWorker(hqRoot, lane, {
3074
3075
  env: ctx.env,
3076
+ managerStatusGateEnabled: await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env }),
3075
3077
  modelDeps: {
3076
3078
  readFileSync: ctx.readFileSync,
3077
3079
  probe: ctx.probeProvider,
@@ -4043,6 +4045,7 @@ async function runPickup(ctx, opts) {
4043
4045
  try {
4044
4046
  return spawnLaneWorker(hqRoot, current, {
4045
4047
  env: ctx.env,
4048
+ managerStatusGateEnabled: await managerStatusGateEnabledForLane(hqRoot, current, { env: ctx.env }),
4046
4049
  modelDeps: {
4047
4050
  readFileSync: ctx.readFileSync,
4048
4051
  probe: ctx.probeProvider,
@@ -4724,6 +4727,8 @@ async function runShow(ctx, laneId) {
4724
4727
  : storedLane;
4725
4728
  const askDeliveryFailed = readJsonFile(askDeliveryFailedPath(hqRoot, laneId));
4726
4729
  const keepAliveState = resolveLaneKeepAlive(hqRoot, storedLane);
4730
+ const managerStatusGate = await resolveManagerStatusGateForLane(hqRoot, storedLane, { env: ctx.env });
4731
+ const managerStatusTarget = managerStatusGate.target;
4727
4732
  printJson({
4728
4733
  ok: true,
4729
4734
  action: "show",
@@ -4762,6 +4767,22 @@ async function runShow(ctx, laneId) {
4762
4767
  next_eligible_at: lane.keep_alive_next_eligible_at ?? null,
4763
4768
  exhausted_at: lane.keep_alive_exhausted_at ?? null,
4764
4769
  },
4770
+ manager_status_gate: managerStatusGate.error !== undefined
4771
+ ? {
4772
+ status_path: null,
4773
+ skipped: null,
4774
+ consecutive_misses: storedLane.status_gate?.consecutive_misses ?? 0,
4775
+ error: managerStatusGate.error,
4776
+ }
4777
+ : managerStatusTarget?.manager
4778
+ ? {
4779
+ status_path: managerStatusTarget.statusPath ?? null,
4780
+ skipped: managerStatusTarget.skipped === "no_juniors_row"
4781
+ ? "no_declared_status_file"
4782
+ : null,
4783
+ consecutive_misses: storedLane.status_gate?.consecutive_misses ?? 0,
4784
+ }
4785
+ : null,
4765
4786
  last_message_at: lane.last_message_at ?? null,
4766
4787
  last_compaction: lane.last_compaction
4767
4788
  ? { ...lane.last_compaction, after: compactionAfterUsage(storedLane) }
@@ -5221,6 +5242,7 @@ async function runAdmit(ctx, laneId, opts) {
5221
5242
  }
5222
5243
  const started = spawnLaneWorker(hqRoot, lane, {
5223
5244
  env: ctx.env,
5245
+ managerStatusGateEnabled: await managerStatusGateEnabledForLane(hqRoot, lane, { env: ctx.env }),
5224
5246
  modelDeps: {
5225
5247
  readFileSync: ctx.readFileSync,
5226
5248
  probe: ctx.probeProvider,
@@ -130,7 +130,7 @@ export declare function unwrapListing(body: Record<string, unknown>): ListingRes
130
130
  * locally-resolved author handle — falling back gracefully when the server
131
131
  * omits either field.
132
132
  */
133
- export declare function buildListingNotice(status: number, body: Record<string, unknown>, packName: string, packVersion: string): string;
133
+ export declare function buildListingNotice(status: number, body: Record<string, unknown>, packName: string, packVersion: string, selectedHandle?: string): string;
134
134
  /**
135
135
  * The exact JSON body the deployed `POST /v1/listings` handler expects. Field
136
136
  * shapes mirror the server contract: `type` ∈ {skill,worker}, `contributes` is
@@ -151,6 +151,12 @@ export interface ListingRequest {
151
151
  * caller is staff (`@getindigo.ai`) and the server has the capability enabled.
152
152
  */
153
153
  publishAsHandle?: string;
154
+ /**
155
+ * OPTIONAL explicit choice among the caller's OWN claimed creator handles
156
+ * (`hq publish --handle <h>`). The server refuses a handle the caller does
157
+ * not own (403 CREATOR_HANDLE_NOT_OWNED) instead of falling back.
158
+ */
159
+ ownedCreatorHandle?: string;
154
160
  /** Base64-encoded gzip pack tarball (pack.tar.gz bytes). */
155
161
  tarball: string;
156
162
  }
@@ -196,6 +202,7 @@ export interface PublishResult {
196
202
  */
197
203
  export declare function runPublish(targetPath: string, deps: PublishDeps, opts?: {
198
204
  publishAsHandle?: string;
205
+ creatorHandle?: string;
199
206
  }): Promise<PublishResult>;
200
207
  export declare function registerPublishCommand(program: Command): void;
201
208
  //# sourceMappingURL=publish.d.ts.map
@@ -231,7 +231,7 @@ export function unwrapListing(body) {
231
231
  * locally-resolved author handle — falling back gracefully when the server
232
232
  * omits either field.
233
233
  */
234
- export function buildListingNotice(status, body, packName, packVersion) {
234
+ export function buildListingNotice(status, body, packName, packVersion, selectedHandle) {
235
235
  if (status === 409) {
236
236
  throw new Error(`${packName}@${packVersion} is already published (duplicate version). ` +
237
237
  `Bump the version in package.yaml and re-run \`hq publish\`.`);
@@ -246,6 +246,10 @@ export function buildListingNotice(status, body, packName, packVersion) {
246
246
  }
247
247
  if (status === 401 || status === 403) {
248
248
  const code = typeof body.code === 'string' ? body.code : '';
249
+ if (code === 'CREATOR_HANDLE_NOT_OWNED') {
250
+ const h = selectedHandle ?? '(unknown)';
251
+ throw new Error(`You do not own the creator handle ${h}. Claim it first with hq creators apply --handle ${h}.`);
252
+ }
249
253
  if (code === 'NOT_VERIFIED_CREATOR') {
250
254
  throw new Error('Only verified creators can publish to the marketplace. ' +
251
255
  'Run `hq creators apply --reason "..."` to request verified-creator access; ' +
@@ -287,6 +291,12 @@ export async function runPublish(targetPath, deps, opts = {}) {
287
291
  const publishAsHandle = typeof opts.publishAsHandle === 'string' && opts.publishAsHandle.length > 0
288
292
  ? opts.publishAsHandle
289
293
  : undefined;
294
+ const selectedHandle = typeof opts.creatorHandle === 'string' && opts.creatorHandle.trim().length > 0
295
+ ? opts.creatorHandle.trim().replace(/^@/, '')
296
+ : undefined;
297
+ if (publishAsHandle && selectedHandle) {
298
+ throw new Error('Use either --as or --handle, not both. Nothing was uploaded.');
299
+ }
290
300
  // Resolve a payload dir. We stamp/validate against a temp copy so we never
291
301
  // mutate the user's working tree's package.yaml.
292
302
  const absTarget = path.resolve(process.cwd(), targetPath);
@@ -335,12 +345,13 @@ export async function runPublish(targetPath, deps, opts = {}) {
335
345
  // server is authoritative for attribution either way). Normal: our
336
346
  // resolved handle is a backwards-compat fallback under the server's
337
347
  // claimed-handle lookup.
338
- creatorHandle: publishAsHandle ?? author.handle,
348
+ creatorHandle: publishAsHandle ?? selectedHandle ?? author.handle,
339
349
  ...(publishAsHandle ? { publishAsHandle } : {}),
350
+ ...(selectedHandle ? { ownedCreatorHandle: selectedHandle } : {}),
340
351
  tarball: Buffer.from(tarballBytes).toString('base64'),
341
352
  };
342
353
  const { status, body } = await deps.upload({ token, listing });
343
- const notice = buildListingNotice(status, body, pkg.name, pkg.version);
354
+ const notice = buildListingNotice(status, body, pkg.name, pkg.version, selectedHandle);
344
355
  // Unwrap the `{ listing }` success envelope the server sends so attribution
345
356
  // reads the server's resolved handle (fall back to top-level for compat).
346
357
  const serverHandle = unwrapListing(body).creatorHandle?.trim();
@@ -362,6 +373,8 @@ export function registerPublishCommand(program) {
362
373
  .option('--as <handle>', 'Publish on behalf of a seeded, unclaimed creator by handle (HQ staff only). ' +
363
374
  'The listing is attributed to that creator instead of you.')
364
375
  .option('--on-behalf <handle>', 'Alias for --as.')
376
+ .option('--handle <handle>', 'Publish as one of your own claimed creator handles (for accounts that own more than one). ' +
377
+ 'Defaults to your first claimed handle.')
365
378
  .action(async (targetPath, options) => {
366
379
  try {
367
380
  const publishAsHandle = options.as ?? options.onBehalf;
@@ -390,7 +403,7 @@ export function registerPublishCommand(program) {
390
403
  },
391
404
  hqRoot,
392
405
  hqVersion: readHqVersion(hqRoot),
393
- }, { publishAsHandle });
406
+ }, { publishAsHandle, creatorHandle: options.handle });
394
407
  console.log(chalk.green(result.notice));
395
408
  // Only print the locally-resolved author as a fallback when the server
396
409
  // did NOT return its own resolved handle (already in the notice).
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Remote SQL via secrets-exec style injection (US-010).
2
+ * Remote SQL helper with injected credentials and executor (US-010).
3
3
  *
4
4
  * Connection string never appears in argv, logs, or stdout.
5
- * Local remains default when --remote is absent (handled by command layer).
5
+ * The CLI command does not currently wire this helper to a remote executor.
6
6
  */
7
7
  export interface RemoteSqlOptions {
8
8
  company: string;
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Remote SQL via secrets-exec style injection (US-010).
2
+ * Remote SQL helper with injected credentials and executor (US-010).
3
3
  *
4
4
  * Connection string never appears in argv, logs, or stdout.
5
- * Local remains default when --remote is absent (handled by command layer).
5
+ * The CLI command does not currently wire this helper to a remote executor.
6
6
  */
7
7
  function assertNoLeak(label, value) {
8
8
  const s = typeof value === "string" ? value : JSON.stringify(value);
@@ -23,7 +23,7 @@ export async function runRemoteSql(opts) {
23
23
  }
24
24
  catch (e) {
25
25
  const msg = e instanceof Error ? e.message : "no remote binding";
26
- throw new Error(`remote SQL unavailable: ${msg}. Run \`hq db provision --company ${opts.company}\` first.`);
26
+ throw new Error(`remote SQL unavailable: ${msg}`);
27
27
  }
28
28
  assertNoLeak("connection config", config);
29
29
  if (!opts.execute) {
@@ -0,0 +1,3 @@
1
+ /** The shared HQ/company path validation used by lane tracker mappings. */
2
+ export declare function checkCompanyPath(root: string, company: string, file: string, detailsPath: string): string;
3
+ //# sourceMappingURL=checked-company-path.d.ts.map
@@ -0,0 +1,39 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { LanesError } from "./errors.js";
4
+ function fail(error, message, details) {
5
+ throw new LanesError(error, message, details);
6
+ }
7
+ function isWithin(root, candidate) {
8
+ const relative = path.relative(root, candidate);
9
+ return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
10
+ }
11
+ /** The shared HQ/company path validation used by lane tracker mappings. */
12
+ export function checkCompanyPath(root, company, file, detailsPath) {
13
+ if (path.isAbsolute(file))
14
+ return fail("tracker_checks_mapping_invalid", "Mapped path must be relative to the HQ root", { path: detailsPath });
15
+ const absolute = path.resolve(root, file);
16
+ const rootReal = fs.realpathSync(root);
17
+ if (!isWithin(rootReal, absolute))
18
+ return fail("tracker_checks_mapping_invalid", "Mapped path escapes the HQ root", { path: detailsPath });
19
+ const relative = path.relative(rootReal, absolute).split(path.sep);
20
+ if (relative[0] === "companies" && relative[1] !== company) {
21
+ return fail("tracker_checks_mapping_invalid", "Mapped path crosses the active company boundary", { path: detailsPath });
22
+ }
23
+ try {
24
+ const real = fs.realpathSync(absolute);
25
+ if (!isWithin(rootReal, real))
26
+ return fail("tracker_checks_mapping_invalid", "Mapped path resolves outside the HQ root", { path: detailsPath });
27
+ const realRelative = path.relative(rootReal, real).split(path.sep);
28
+ if (realRelative[0] === "companies" && realRelative[1] !== company) {
29
+ return fail("tracker_checks_mapping_invalid", "Mapped path resolves into another company", { path: detailsPath });
30
+ }
31
+ }
32
+ catch (err) {
33
+ if (err.code !== "ENOENT") {
34
+ return fail("tracker_checks_mapping_unreadable", `Mapped path could not be checked: ${err instanceof Error ? err.message : String(err)}`, { path: absolute });
35
+ }
36
+ }
37
+ return absolute;
38
+ }
39
+ //# sourceMappingURL=checked-company-path.js.map
@@ -1,4 +1,4 @@
1
- import type { LaneRecord, QuestionRecord } from "./types.js";
1
+ import type { LaneRecord, ManagerStatusGateHistoryEntry, QuestionRecord } from "./types.js";
2
2
  export declare const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
3
3
  export declare const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
4
4
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
@@ -7,6 +7,8 @@ export declare const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
7
7
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
8
8
  export declare const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS: number;
9
9
  export declare const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS: number;
10
+ /** Persist a manager status miss and queue its one bounded follow-up turn. */
11
+ export declare function queueManagerStatusGateResume(hqRoot: string, laneId: string, entry: Omit<ManagerStatusGateHistoryEntry, "event" | "resume_queued" | "resume_skipped_reason">, resumeText: string): LaneRecord;
10
12
  export declare const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
11
13
  export declare const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
12
14
  export type KeepAliveBlockedReason = NonNullable<LaneRecord["keep_alive_blocked_reason"]>;
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { LanesError } from "./errors.js";
4
4
  import { dropboxInboxHasMessages } from "./dropbox.js";
5
- import { listQuestionIds, readQuestion } from "./store.js";
5
+ import { appendCappedHistory, listQuestionIds, readQuestion, retryUpdateLane } from "./store.js";
6
6
  export const LEAD_LANE_KEEP_ALIVE_FLAG = "lanes.lead-keep-alive";
7
7
  export const LEAD_LANE_KEEP_ALIVE_DEFAULT_ENABLED = false;
8
8
  /** Missing or unreadable registry values retain the legacy supersession behavior. */
@@ -11,6 +11,61 @@ export const LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED = false;
11
11
  export const LEAD_LANE_KEEP_ALIVE_MAX_RESUMES = 24;
12
12
  export const LEAD_LANE_KEEP_ALIVE_BASE_BACKOFF_MS = 5 * 60 * 1000;
13
13
  export const LEAD_LANE_KEEP_ALIVE_MAX_BACKOFF_MS = 60 * 60 * 1000;
14
+ /** Persist a manager status miss and queue its one bounded follow-up turn. */
15
+ export function queueManagerStatusGateResume(hqRoot, laneId, entry, resumeText) {
16
+ return retryUpdateLane(hqRoot, laneId, "watcher", (current) => {
17
+ const gate = current.status_gate;
18
+ if (gate === undefined || gate.turn_id !== entry.turn_id)
19
+ return current;
20
+ const history = current.status_gate_history ?? [];
21
+ if (history.some((prior) => prior.event === "status_missed" && prior.turn_id === entry.turn_id)) {
22
+ return current;
23
+ }
24
+ const consecutiveMisses = gate.consecutive_misses + 1;
25
+ let resumeSkippedReason;
26
+ if (current.state === "blocked")
27
+ resumeSkippedReason = "blocked";
28
+ else if (current.state === "awaiting_input")
29
+ resumeSkippedReason = "open_question";
30
+ else if (consecutiveMisses > 1)
31
+ resumeSkippedReason = "miss_limit";
32
+ else if (!current.provider_session_id)
33
+ resumeSkippedReason = "no_provider_session";
34
+ const resumeQueued = resumeSkippedReason === undefined &&
35
+ (current.state === "done" || current.state === "interrupted" || current.state === "failed");
36
+ const missed = {
37
+ event: "status_missed",
38
+ ...entry,
39
+ ...(resumeQueued ? { resume_queued: true } : {}),
40
+ ...(resumeSkippedReason !== undefined ? { resume_skipped_reason: resumeSkippedReason } : {}),
41
+ };
42
+ const next = {
43
+ ...current,
44
+ status_gate: { ...gate, consecutive_misses: consecutiveMisses },
45
+ status_gate_history: appendCappedHistory(history, missed),
46
+ };
47
+ if (!resumeQueued || !current.provider_session_id)
48
+ return next;
49
+ const resumedFrom = current.state;
50
+ return {
51
+ ...next,
52
+ state: "queued",
53
+ queued_reason: undefined,
54
+ queued_at: entry.at,
55
+ admission_reserved_at: undefined,
56
+ terminal_reason: undefined,
57
+ terminal_details: undefined,
58
+ pending_resume: {
59
+ session_id: current.provider_session_id,
60
+ trigger: "resume",
61
+ resumed_from: resumedFrom,
62
+ text: resumeText,
63
+ compact: true,
64
+ at: entry.at,
65
+ },
66
+ };
67
+ });
68
+ }
14
69
  export const LEAD_LANE_KEEP_ALIVE_RESUME_TEXT = "Automatic keep-alive resume: your last turn ended with decision done and story_complete false. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
15
70
  export const LEAD_LANE_KEEP_ALIVE_INTERRUPTED_RESUME_TEXT = "Automatic keep-alive resume: your previous turn was interrupted by a provider process exit. Check your last state before continuing. Continue the open work from where you stopped. End with done only when story_complete is true; if you are waiting, use hq lanes ask or stay in the loop.";
16
71
  /**
@@ -0,0 +1,34 @@
1
+ import { readLanesRegistryFlag } from "./registry-flag.js";
2
+ import type { LaneRecord, ManagerStatusGate } from "./types.js";
3
+ export declare const MANAGER_STATUS_GATE_FLAG = "lanes.manager-status-gate";
4
+ export interface ManagerStatusTarget {
5
+ manager: boolean;
6
+ identified_by?: "story_id" | "worker_profile" | "juniors";
7
+ statusPath?: string;
8
+ skipped?: "no_juniors_row";
9
+ }
10
+ export interface StatusSnapshot {
11
+ mtime: number | null;
12
+ updatedAt: string | null;
13
+ }
14
+ export interface ManagerStatusGateResolution {
15
+ enabled: boolean;
16
+ target?: ManagerStatusTarget;
17
+ error?: string;
18
+ }
19
+ /** Resolve manager identity and the single declared status file, if present. */
20
+ export declare function resolveManagerStatusTarget(root: string, lane: LaneRecord): ManagerStatusTarget;
21
+ export declare function resolveManagerStatusGateForLane(root: string, lane: LaneRecord, options?: {
22
+ env?: NodeJS.ProcessEnv;
23
+ readFlag?: typeof readLanesRegistryFlag;
24
+ onError?: (error: unknown) => void;
25
+ }): Promise<ManagerStatusGateResolution>;
26
+ export declare function managerStatusGateEnabledForLane(root: string, lane: LaneRecord, options?: {
27
+ env?: NodeJS.ProcessEnv;
28
+ readFlag?: typeof readLanesRegistryFlag;
29
+ onError?: (error: unknown) => void;
30
+ }): Promise<boolean>;
31
+ export declare function readStatusSnapshot(filePath: string): StatusSnapshot;
32
+ export declare function managerStatusGateHeartbeat(root: string, lane: LaneRecord, turnId: string, turnStartedAt: string): ManagerStatusGate | undefined;
33
+ export declare function managerStatusWasWritten(gate: ManagerStatusGate, snapshot: StatusSnapshot): boolean;
34
+ //# sourceMappingURL=manager-status-gate.d.ts.map
@@ -0,0 +1,168 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { checkCompanyPath } from "./checked-company-path.js";
4
+ import { LanesError } from "./errors.js";
5
+ import { readLanesRegistryFlag } from "./registry-flag.js";
6
+ export const MANAGER_STATUS_GATE_FLAG = "lanes.manager-status-gate";
7
+ function isObject(value) {
8
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+ function invalidMapping(message, filePath, details = {}) {
11
+ throw new LanesError("manager_status_mapping_invalid", message, { path: filePath, ...details });
12
+ }
13
+ function readJuniors(root) {
14
+ const filePath = path.join(root, "workspace", "lane-briefs", "juniors.json");
15
+ let raw;
16
+ try {
17
+ const stat = fs.lstatSync(filePath);
18
+ if (!stat.isFile() || stat.isSymbolicLink())
19
+ invalidMapping("juniors.json must be a regular file", filePath);
20
+ raw = fs.readFileSync(filePath, "utf8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return { filePath, rows: [] };
25
+ if (err instanceof LanesError)
26
+ throw err;
27
+ throw new LanesError("manager_status_mapping_unreadable", `Cannot read manager mapping ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
28
+ }
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(raw);
32
+ }
33
+ catch {
34
+ invalidMapping("juniors.json contains invalid JSON", filePath);
35
+ }
36
+ if (!isObject(parsed) || !Array.isArray(parsed.juniors))
37
+ invalidMapping("juniors.json must contain a juniors array", filePath);
38
+ const rows = [];
39
+ for (const [index, row] of parsed.juniors.entries()) {
40
+ if (!isObject(row))
41
+ invalidMapping(`juniors[${index}] must be an object`, filePath, { index });
42
+ rows.push(row);
43
+ }
44
+ return { filePath, rows };
45
+ }
46
+ function matchesLane(rowLane, laneId) {
47
+ const row = rowLane.trim();
48
+ const bareLaneId = laneId.split("_", 1)[0] ?? "";
49
+ return row === laneId || row === bareLaneId;
50
+ }
51
+ function managerStatusGateErrorCode(err) {
52
+ return err instanceof LanesError ? err.error : "manager_status_gate_unavailable";
53
+ }
54
+ function logManagerStatusGateFailure(lane, err) {
55
+ const code = managerStatusGateErrorCode(err);
56
+ const message = err instanceof Error ? err.message : String(err);
57
+ process.stderr.write(`hq lanes: manager status gate disabled for ${lane.lane_id}: ${code}: ${message}\n`);
58
+ }
59
+ /** Resolve manager identity and the single declared status file, if present. */
60
+ export function resolveManagerStatusTarget(root, lane) {
61
+ const directIdentity = lane.story_id === "MANAGER"
62
+ ? "story_id"
63
+ : lane.worker_profile?.endsWith("-manager") === true
64
+ ? "worker_profile"
65
+ : undefined;
66
+ const registry = readJuniors(root);
67
+ const matches = [];
68
+ for (const [index, row] of registry.rows.entries()) {
69
+ if (typeof row.lane !== "string")
70
+ invalidMapping(`juniors[${index}].lane must be a string`, registry.filePath, { index });
71
+ if (matchesLane(row.lane, lane.lane_id))
72
+ matches.push(row);
73
+ }
74
+ if (matches.length > 1)
75
+ invalidMapping("More than one juniors row maps to this manager lane", registry.filePath, { lane_id: lane.lane_id });
76
+ const row = matches[0];
77
+ if (row === undefined) {
78
+ return directIdentity
79
+ ? { manager: true, identified_by: directIdentity, skipped: "no_juniors_row" }
80
+ : { manager: false };
81
+ }
82
+ for (const field of ["team", "lane", "host", "status", "prd"]) {
83
+ if (typeof row[field] !== "string" || row[field].trim() === "") {
84
+ invalidMapping(`Matched juniors row needs a non-empty ${field}`, registry.filePath, { lane_id: lane.lane_id, field });
85
+ }
86
+ }
87
+ const company = lane.company?.slug ?? lane.mesh_company?.slug ?? "";
88
+ const statusPath = checkCompanyPath(root, company, row.status, registry.filePath);
89
+ return {
90
+ manager: true,
91
+ identified_by: directIdentity ?? "juniors",
92
+ statusPath,
93
+ };
94
+ }
95
+ export async function resolveManagerStatusGateForLane(root, lane, options = {}) {
96
+ const reportError = options.onError ?? ((err) => logManagerStatusGateFailure(lane, err));
97
+ let enabled;
98
+ try {
99
+ enabled = await (options.readFlag ?? readLanesRegistryFlag)(MANAGER_STATUS_GATE_FLAG, {
100
+ company: lane.company ?? lane.mesh_company ?? undefined,
101
+ env: options.env,
102
+ });
103
+ }
104
+ catch (err) {
105
+ reportError(err);
106
+ return { enabled: false, error: managerStatusGateErrorCode(err) };
107
+ }
108
+ if (!enabled)
109
+ return { enabled: false };
110
+ try {
111
+ const target = resolveManagerStatusTarget(root, lane);
112
+ return { enabled: target.manager && target.statusPath !== undefined, target };
113
+ }
114
+ catch (err) {
115
+ reportError(err);
116
+ return { enabled: false, error: managerStatusGateErrorCode(err) };
117
+ }
118
+ }
119
+ export async function managerStatusGateEnabledForLane(root, lane, options = {}) {
120
+ return (await resolveManagerStatusGateForLane(root, lane, options)).enabled;
121
+ }
122
+ export function readStatusSnapshot(filePath) {
123
+ let stat;
124
+ try {
125
+ stat = fs.statSync(filePath);
126
+ }
127
+ catch (err) {
128
+ if (err.code === "ENOENT")
129
+ return { mtime: null, updatedAt: null };
130
+ throw new LanesError("manager_status_unreadable", `Cannot stat manager status ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
131
+ }
132
+ if (!stat.isFile())
133
+ throw new LanesError("manager_status_invalid", `Manager status path is not a file: ${filePath}`, { path: filePath });
134
+ let updatedAt = null;
135
+ try {
136
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
137
+ if (isObject(parsed) && typeof parsed.updated_at === "string")
138
+ updatedAt = parsed.updated_at;
139
+ }
140
+ catch (err) {
141
+ if (!(err instanceof SyntaxError)) {
142
+ if (err.code === "ENOENT")
143
+ return { mtime: null, updatedAt: null };
144
+ throw new LanesError("manager_status_unreadable", `Cannot read manager status ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
145
+ }
146
+ }
147
+ return { mtime: stat.mtimeMs, updatedAt };
148
+ }
149
+ export function managerStatusGateHeartbeat(root, lane, turnId, turnStartedAt) {
150
+ const target = resolveManagerStatusTarget(root, lane);
151
+ if (!target.manager || target.statusPath === undefined)
152
+ return undefined;
153
+ const snapshot = readStatusSnapshot(target.statusPath);
154
+ return {
155
+ turn_started_at: turnStartedAt,
156
+ status_mtime_at_start: snapshot.mtime,
157
+ status_updated_at_at_start: snapshot.updatedAt,
158
+ status_path: target.statusPath,
159
+ turn_id: turnId,
160
+ consecutive_misses: lane.status_gate?.consecutive_misses ?? 0,
161
+ };
162
+ }
163
+ export function managerStatusWasWritten(gate, snapshot) {
164
+ const startedAt = Date.parse(gate.turn_started_at);
165
+ return ((snapshot.mtime !== null && Number.isFinite(startedAt) && snapshot.mtime > startedAt) ||
166
+ snapshot.updatedAt !== gate.status_updated_at_at_start);
167
+ }
168
+ //# sourceMappingURL=manager-status-gate.js.map
@@ -79,6 +79,8 @@ export interface ReconcileDeps {
79
79
  env?: NodeJS.ProcessEnv;
80
80
  /** Flag reader seam used to assert rollout reads happen before admission locks. */
81
81
  readRegistryFlag?: typeof readLanesRegistryFlag;
82
+ /** Pre-read manager gate decisions, scoped to each lane for this pass. */
83
+ managerStatusGateByLane?: ReadonlyMap<string, boolean>;
82
84
  /** Active-company fallback for questions raised from an unscoped lane. */
83
85
  resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
84
86
  /** Resume queued messages after this pass wins a local dead-worker CAS. */
@@ -29,6 +29,7 @@ import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuesti
29
29
  import { SILENCE_MS, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
30
30
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
31
31
  import { laneStories, storyStatusForEnvelope } from "./stories.js";
32
+ import { resolveManagerStatusGateForLane } from "./manager-status-gate.js";
32
33
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
33
34
  import { PROVIDER_REJECTED_REASON, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
34
35
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
@@ -44,11 +45,43 @@ export const PICKUP_VALIDATION_MAX_AGE_MS = 15 * 60 * 1000;
44
45
  export const PICKUP_VALIDATION_TIMEOUT_REASON = "pickup_validation_timeout";
45
46
  export const RECONCILE_UNIT_NAME = "hq-lanes-reconcile.service";
46
47
  export const RECONCILE_TIMER_NAME = "hq-lanes-reconcile.timer";
47
- function spawnDepsForHost(deps) {
48
+ function spawnDepsForHost(deps, lane) {
48
49
  return {
49
50
  ...(deps.spawnLaneDeps ?? {}),
50
51
  hostname: deps.host.hostname,
51
52
  admissionHost: deps.host,
53
+ ...(lane !== undefined && deps.managerStatusGateByLane?.has(lane.lane_id)
54
+ ? { managerStatusGateEnabled: deps.managerStatusGateByLane.get(lane.lane_id) === true }
55
+ : {}),
56
+ };
57
+ }
58
+ async function prepareManagerStatusGateStarts(root, lanes, deps, summary) {
59
+ const enabledByLane = new Map();
60
+ let gateFailureRecorded = false;
61
+ if (!deps.dryRun) {
62
+ await Promise.all(lanes.filter((lane) => laneRunsHere(lane, deps.host)).map(async (lane) => {
63
+ const resolution = await resolveManagerStatusGateForLane(root, lane, {
64
+ env: deps.env,
65
+ readFlag: deps.readRegistryFlag,
66
+ onError: (err) => {
67
+ if (gateFailureRecorded)
68
+ return;
69
+ gateFailureRecorded = true;
70
+ const code = err instanceof LanesError ? err.error : "manager_status_gate_unavailable";
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ summary.skipped.push({
73
+ kind: "lane",
74
+ id: lane.lane_id,
75
+ why: `manager status gate disabled for this reconcile pass: ${code}: ${message}`,
76
+ });
77
+ },
78
+ });
79
+ enabledByLane.set(lane.lane_id, resolution.enabled);
80
+ }));
81
+ }
82
+ return {
83
+ ...deps,
84
+ managerStatusGateByLane: enabledByLane,
52
85
  };
53
86
  }
54
87
  function logSkip(skip) {
@@ -694,8 +727,9 @@ export async function runPassBody(hqRoot, deps) {
694
727
  : QUESTION_AGE_MS;
695
728
  if (deps.afterLoad)
696
729
  await deps.afterLoad();
730
+ const startDeps = await prepareManagerStatusGateStarts(hqRoot, lanes, deps, summary);
697
731
  markOverdueLanes(hqRoot, lanes, summary, deps, now);
698
- await heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now);
732
+ await heartbeatAndDeadHosts(hqRoot, lanes, summary, startDeps, now);
699
733
  for (const question of questions) {
700
734
  if (question.status !== "pending")
701
735
  continue;
@@ -838,10 +872,10 @@ export async function runPassBody(hqRoot, deps) {
838
872
  await withAllLaneAdmissionLocks(hqRoot, async () => {
839
873
  const freshLanes = loadLaneRecords(hqRoot, summary);
840
874
  const freshQuestions = loadQuestionRecords(hqRoot, summary);
841
- resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, deps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
875
+ resumeKeepAlive(hqRoot, freshLanes, freshQuestions, summary, startDeps, now, keepAliveFlagByCompany, supersessionLineageAwareByCompany);
842
876
  const drainLanes = loadLaneRecords(hqRoot, summary);
843
- await admitQueued(hqRoot, drainLanes, summary, deps, now);
844
- await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, deps);
877
+ await admitQueued(hqRoot, drainLanes, summary, startDeps, now);
878
+ await resumeAwaiting(hqRoot, drainLanes, freshQuestions, summary, startDeps);
845
879
  });
846
880
  if (!deps.dryRun && summary.errors.length === 0) {
847
881
  try {
@@ -1024,10 +1058,12 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1024
1058
  classification.reason === "account_model_unavailable")) {
1025
1059
  recordAccountQuota(hqRoot, classified.provider, classified.account, quota);
1026
1060
  }
1027
- if (!deps.dryRun && accountRetryReason && classified.account) {
1061
+ if (!deps.dryRun &&
1062
+ accountRetryReason &&
1063
+ classified.account) {
1028
1064
  try {
1029
1065
  const recovered = await retryLaneOnAlternateAccount(hqRoot, classified, accountRetryReason, "reconcile", {
1030
- ...spawnDepsForHost(deps),
1066
+ ...spawnDepsForHost(deps, classified),
1031
1067
  });
1032
1068
  summary.dead_host.push({
1033
1069
  id: lane.lane_id,
@@ -1308,7 +1344,7 @@ async function admitQueued(hqRoot, lanes, summary, deps, now) {
1308
1344
  nowMs,
1309
1345
  });
1310
1346
  const spawnFn = deps.spawn ??
1311
- ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps)));
1347
+ ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps, lane)));
1312
1348
  const running = lanes.filter((lane) => (lane.state === "running" ||
1313
1349
  hasFreshAdmissionReservation(lane, now.getTime()) ||
1314
1350
  hasActivePendingResume(lane, now.getTime())) &&
@@ -1977,7 +2013,7 @@ async function resumeAwaiting(hqRoot, lanes, questions, summary, deps, onlyLaneI
1977
2013
  const throttle = await decideAdmission(hqRoot, config.throttle, throttleDeps);
1978
2014
  throttleDecision = throttle.decision;
1979
2015
  }
1980
- const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps)));
2016
+ const spawnFn = deps.spawn ?? ((root, lane) => spawnLaneWorker(root, lane, spawnDepsForHost(deps, lane)));
1981
2017
  const nowMs = deps.now().getTime();
1982
2018
  const fallbackAnswerAt = new Date(nowMs).toISOString();
1983
2019
  const answerAtByLane = new Map();
@@ -86,6 +86,8 @@ export interface SpawnLaneDeps {
86
86
  runCompaction?: RunCompactionFn;
87
87
  /** Finds a session transcript under an account home. Injected in tests. */
88
88
  locateTranscript?: (home: string, sessionId: string) => string | null;
89
+ /** Pre-read by the async command/reconcile boundary before this sync spawn. */
90
+ managerStatusGateEnabled?: boolean;
89
91
  }
90
92
  export interface ProviderLaunch {
91
93
  command: string;
@@ -254,6 +256,7 @@ export declare function refuseOutpostSelfTarget(hqRoot: string, lane: LaneRecord
254
256
  export declare function armLaneWatcher(hqRoot: string, laneId: string, deps?: SpawnLaneDeps): number;
255
257
  export interface SpawnManifestFile {
256
258
  envelope_path?: string;
259
+ turn_id?: string;
257
260
  resume?: {
258
261
  session_id: string;
259
262
  } | null;
@@ -27,6 +27,7 @@ import { ignoredWorkerProfileFields, loadWorkerProfile, loadWorkerProfileWithReg
27
27
  import { registryWorkerRecord } from "./registry-persona.js";
28
28
  import { resolveMachineIdentity, } from "./machine-identity.js";
29
29
  import { appendCappedHistory, listLaneIds, readLane, retryUpdateLane } from "./store.js";
30
+ import { managerStatusGateHeartbeat } from "./manager-status-gate.js";
30
31
  import { copySessionFiles, planSessionCopy } from "./move-account.js";
31
32
  import { ensureSessionHome } from "./session-relink.js";
32
33
  import { MESSAGE_REPLAY_STATE_NAME } from "./message-replay.js";
@@ -1476,6 +1477,7 @@ function writeSpawnManifest(runDir, input) {
1476
1477
  watcher_pid: input.watcherPid,
1477
1478
  detached: true,
1478
1479
  envelope_path: input.envelopePath,
1480
+ ...(input.turnId !== undefined ? { turn_id: input.turnId } : {}),
1479
1481
  resume: input.resume,
1480
1482
  worker_out_offset: input.workerOutOffset,
1481
1483
  ...(input.fastMode ? { fast_mode: true } : {}),
@@ -1552,6 +1554,8 @@ export function readSpawnManifest(runDir) {
1552
1554
  const out = {};
1553
1555
  if (typeof rec.envelope_path === "string")
1554
1556
  out.envelope_path = rec.envelope_path;
1557
+ if (typeof rec.turn_id === "string")
1558
+ out.turn_id = rec.turn_id;
1555
1559
  if (typeof rec.provider_home_at_spawn === "string") {
1556
1560
  out.provider_home_at_spawn = rec.provider_home_at_spawn;
1557
1561
  }
@@ -1982,6 +1986,9 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
1982
1986
  // Claim running before spawning. Two callers that both observe queued must
1983
1987
  // not both start processes; the CAS loser returns without spawn.
1984
1988
  const nowIso = (deps.now ?? (() => new Date()))().toISOString();
1989
+ const statusGateHeartbeat = deps.managerStatusGateEnabled === true
1990
+ ? managerStatusGateHeartbeat(hqRoot, existing, laneTurnId, nowIso)
1991
+ : undefined;
1985
1992
  let claimed = false;
1986
1993
  const claimedRecord = retryUpdateLane(hqRoot, lane.lane_id, "spawn", (current) => {
1987
1994
  claimed = false;
@@ -2033,6 +2040,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2033
2040
  }
2034
2041
  : { model_source: "none" }),
2035
2042
  effort_source: effort.source,
2043
+ status_gate: statusGateHeartbeat,
2036
2044
  last_host_report_at: nowIso,
2037
2045
  ...(profile
2038
2046
  ? {
@@ -2167,6 +2175,7 @@ export function spawnLaneWorker(hqRoot, lane, deps = {}, launch = {}) {
2167
2175
  workerPid,
2168
2176
  watcherPid,
2169
2177
  envelopePath,
2178
+ turnId: laneTurnId,
2170
2179
  resume: resume ? { session_id: resume.session_id } : null,
2171
2180
  workerOutOffset,
2172
2181
  fastMode,
@@ -334,6 +334,23 @@ export interface LastEnvelopeRef {
334
334
  artifacts?: unknown[];
335
335
  blocked_reason?: string;
336
336
  }
337
+ export interface ManagerStatusGate {
338
+ turn_started_at: string;
339
+ status_mtime_at_start: number | null;
340
+ status_updated_at_at_start: string | null;
341
+ status_path: string;
342
+ turn_id: string;
343
+ consecutive_misses: number;
344
+ }
345
+ export interface ManagerStatusGateHistoryEntry {
346
+ event: "status_missed";
347
+ turn_id: string;
348
+ path: string;
349
+ at: string;
350
+ identified_by: "story_id" | "worker_profile" | "juniors";
351
+ resume_queued?: boolean;
352
+ resume_skipped_reason?: "open_question" | "blocked" | "miss_limit" | "no_provider_session";
353
+ }
337
354
  export interface LaneStory {
338
355
  project_id: string;
339
356
  story_id: string;
@@ -473,6 +490,10 @@ export interface LaneRecord {
473
490
  fast_mode?: boolean;
474
491
  /** Time of the last operator message accepted for this lane. Drives compaction. */
475
492
  last_message_at?: string;
493
+ /** Turn-start baseline used by the optional manager status gate. */
494
+ status_gate?: ManagerStatusGate;
495
+ /** Bounded audit history of manager turns that missed their declared status artifact. */
496
+ status_gate_history?: ManagerStatusGateHistoryEntry[];
476
497
  /** Last automatic compaction of a resumed session (compaction.ts). */
477
498
  last_compaction?: LaneCompactionRecord;
478
499
  /**
@@ -228,7 +228,7 @@ export const LANE_FIELD_WRITERS = {
228
228
  provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], failed_resume_history: ["command"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
229
229
  version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
230
230
  last_envelope: ["watcher", "reconcile"], model: ["creator", "spawn", "command"], model_source: ["creator", "spawn", "command"], effort: ["creator", "spawn", "command"], effort_source: ["creator", "spawn", "command"], fast_mode: ["spawn"],
231
- last_message_at: ["command"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
231
+ last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
232
232
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
233
233
  admitted_reason: ["command", "reconcile"], terminal_reason: ["command", "watcher", "spawn", "reconcile"], stop_history: ["command"], interrupted_at: ["watcher", "reconcile"], terminal_details: ["command", "spawn", "watcher", "reconcile"],
234
234
  quota_reset_at: ["watcher", "reconcile"], model_quota_marker: ["watcher", "reconcile"], outpost_command_id: ["command"],
@@ -36,6 +36,7 @@ import { type QuotaSignal } from "./quota.js";
36
36
  import { type ProviderRejection } from "./provider-rejection.js";
37
37
  import type { AdmissionHost } from "./admission.js";
38
38
  import { type LaneMesh } from "./mesh-events.js";
39
+ import { readLanesRegistryFlag } from "./registry-flag.js";
39
40
  import { type EnvelopeRepairSpawnInput, type SpawnLaneDeps } from "./spawn.js";
40
41
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
41
42
  export declare const SILENCE_MS: number;
@@ -226,6 +227,8 @@ export interface WatcherLoopDeps {
226
227
  resolveQuestionActiveCompany?: () => Promise<LaneCompanyRef>;
227
228
  /** Resume an atomically queued inbox after this watcher wins terminal CAS. */
228
229
  recoverQueuedMessages?: (lane: LaneRecord) => Promise<void>;
230
+ /** Test seam; production reads the company-scoped hq-flags row. */
231
+ readManagerStatusGateFlag?: typeof readLanesRegistryFlag;
229
232
  }
230
233
  /**
231
234
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
@@ -47,6 +47,9 @@ import { loadLanesConfig, providerProgressEventMinIntervalMs, providerQuotaDefau
47
47
  import { resolveAdmissionHost as resolveProcessAdmissionHost } from "./admission-host.js";
48
48
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneProgressEvent, emitLaneTerminalStateEvent, laneStoryStatusSuppressionKey, progressEventDue, } from "./mesh-events.js";
49
49
  import { envelopeQuestionId } from "./questions.js";
50
+ import { readLanesRegistryFlag } from "./registry-flag.js";
51
+ import { MANAGER_STATUS_GATE_FLAG, managerStatusWasWritten, readStatusSnapshot, resolveManagerStatusTarget, managerStatusGateEnabledForLane, } from "./manager-status-gate.js";
52
+ import { queueManagerStatusGateResume } from "./keep-alive.js";
50
53
  import { laneRunDir, missingPidProbe, probePidRecord, readPidRecord, readProcStarttime, readSpawnManifest, envelopeRepairMode, spawnEnvelopeRepair, writePidRecord, retryLaneOnAlternateAccount, } from "./spawn.js";
51
54
  /** Spec §5.7: no signal for 20 minutes while the process is still alive. */
52
55
  export const SILENCE_MS = 20 * 60 * 1000;
@@ -639,6 +642,75 @@ export function missingExpectedEnvelopeFile(hqRoot, laneId, envelopePath) {
639
642
  missing: true,
640
643
  };
641
644
  }
645
+ async function settleManagerStatusGate(hqRoot, lane, manifestTurnId, at, deps, logDecision) {
646
+ const readFlag = deps.readManagerStatusGateFlag ?? readLanesRegistryFlag;
647
+ let enabled = false;
648
+ try {
649
+ enabled = await readFlag(MANAGER_STATUS_GATE_FLAG, {
650
+ company: lane.company ?? lane.mesh_company ?? undefined,
651
+ });
652
+ }
653
+ catch (err) {
654
+ process.stderr.write(`hq lanes watcher: manager status gate flag read failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
655
+ logDecision({ event: "status_gate_flag_failed", at, error: err instanceof Error ? err.name : "UnknownError" });
656
+ }
657
+ if (!enabled)
658
+ return lane;
659
+ let target;
660
+ try {
661
+ target = resolveManagerStatusTarget(hqRoot, lane);
662
+ }
663
+ catch (err) {
664
+ process.stderr.write(`hq lanes watcher: manager status mapping failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
665
+ logDecision({ event: "status_gate_mapping_failed", at, error: err instanceof Error ? err.message : String(err) });
666
+ return lane;
667
+ }
668
+ const gate = lane.status_gate;
669
+ if (!target.manager || target.statusPath === undefined || gate === undefined)
670
+ return lane;
671
+ if (manifestTurnId !== undefined && manifestTurnId !== gate.turn_id ||
672
+ target.statusPath !== gate.status_path) {
673
+ logDecision({ event: "status_gate_baseline_mismatch", at, turn_id: gate.turn_id });
674
+ return lane;
675
+ }
676
+ let written;
677
+ try {
678
+ written = managerStatusWasWritten(gate, readStatusSnapshot(gate.status_path));
679
+ }
680
+ catch (err) {
681
+ process.stderr.write(`hq lanes watcher: manager status read failed for ${lane.lane_id}: ${err instanceof Error ? err.message : String(err)}\n`);
682
+ logDecision({ event: "status_gate_status_read_failed", at, error: err instanceof Error ? err.message : String(err) });
683
+ written = false;
684
+ }
685
+ if (written) {
686
+ return retryUpdateLane(hqRoot, lane.lane_id, "watcher", (current) => {
687
+ if (current.status_gate?.turn_id !== gate.turn_id || current.status_gate.consecutive_misses === 0)
688
+ return current;
689
+ return { ...current, status_gate: { ...current.status_gate, consecutive_misses: 0 } };
690
+ });
691
+ }
692
+ const resumeText = `Your turn ended without updating ${gate.status_path}. Write your status now: update updated_at, kpis and current_work, then end the turn.`;
693
+ const updated = queueManagerStatusGateResume(hqRoot, lane.lane_id, {
694
+ turn_id: gate.turn_id,
695
+ path: gate.status_path,
696
+ at,
697
+ identified_by: target.identified_by ?? "juniors",
698
+ }, resumeText);
699
+ const miss = updated.status_gate_history?.find((entry) => entry.event === "status_missed" && entry.turn_id === gate.turn_id);
700
+ if (miss) {
701
+ logDecision({
702
+ event: "status_missed",
703
+ at,
704
+ turn_id: gate.turn_id,
705
+ path: gate.status_path,
706
+ consecutive_misses: updated.status_gate?.consecutive_misses ?? 0,
707
+ resume_queued: miss.resume_queued === true,
708
+ resume_skipped_reason: miss.resume_skipped_reason,
709
+ state: updated.state,
710
+ });
711
+ }
712
+ return updated;
713
+ }
642
714
  /**
643
715
  * Wrap one mesh call. A failed emit is logged as `mesh_event_failed` with
644
716
  * the event name and the error message, and never propagates: the lane's
@@ -820,10 +892,17 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
820
892
  }
821
893
  const progressIntervalMs = deps.progressEventMinIntervalMs ?? readProgressIntervalMs(hqRoot, readLaneSoft(hqRoot, laneId));
822
894
  const spawnRepair = deps.spawnRepair ?? spawnEnvelopeRepair;
823
- const retryAccount = deps.retryAccount ?? ((root, lane, reason) => retryLaneOnAlternateAccount(root, lane, reason, "watcher", {
824
- ...deps.retrySpawnDeps,
825
- admissionHost: (deps.resolveAdmissionHost ?? resolveProcessAdmissionHost)(),
826
- }));
895
+ const retryAccount = deps.retryAccount ?? (async (root, lane, reason) => {
896
+ const managerStatusGateEnabled = await managerStatusGateEnabledForLane(root, lane, {
897
+ env: deps.retrySpawnDeps?.env,
898
+ readFlag: deps.readManagerStatusGateFlag,
899
+ });
900
+ return retryLaneOnAlternateAccount(root, lane, reason, "watcher", {
901
+ ...deps.retrySpawnDeps,
902
+ managerStatusGateEnabled,
903
+ admissionHost: (deps.resolveAdmissionHost ?? resolveProcessAdmissionHost)(),
904
+ });
905
+ });
827
906
  let lastSignalMs = now();
828
907
  let lastSeenMtime = 0;
829
908
  let lastAlive = null;
@@ -1168,7 +1247,7 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1168
1247
  quota,
1169
1248
  providerRejection,
1170
1249
  });
1171
- const updated = terminalWrite.record;
1250
+ let updated = terminalWrite.record;
1172
1251
  const effectiveEnvelope = terminalWrite.envelope ?? envelope;
1173
1252
  const effectiveClassification = terminalWrite.classification;
1174
1253
  logDecision({
@@ -1201,7 +1280,14 @@ async function runWatcherLoopOwned(hqRoot, laneId, deps) {
1201
1280
  await emitMeshSoft(`lane_${updated.state}`, logDecision, tick.emit.at, (signal) => emitLaneTerminalStateEvent(mesh, updated, updated.state, updated.terminal_reason ?? effectiveClassification.reason, signal), meshRequestTimeoutMs);
1202
1281
  }
1203
1282
  }
1204
- if (terminalWrite.changed && deps.recoverQueuedMessages) {
1283
+ if (terminalWrite.changed) {
1284
+ updated = await settleManagerStatusGate(hqRoot, updated, manifest.turn_id, tick.emit.at, deps, logDecision);
1285
+ }
1286
+ const managerStatusFollowupQueued = manifest.turn_id !== undefined &&
1287
+ updated.status_gate_history?.some((entry) => entry.event === "status_missed" &&
1288
+ entry.turn_id === manifest.turn_id &&
1289
+ entry.resume_queued === true) === true;
1290
+ if (terminalWrite.changed && deps.recoverQueuedMessages && !managerStatusFollowupQueued) {
1205
1291
  try {
1206
1292
  if (dropboxInboxHasMessages(hqRoot, laneId)) {
1207
1293
  await deps.recoverQueuedMessages(updated);
@@ -722,7 +722,7 @@ export class TranscriptWatcher {
722
722
  }
723
723
  if (track.pendingTurn &&
724
724
  nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
725
- if (this.emitKind(track, "turn_end", nowMs)) {
725
+ if (this.emitKind(track, "turn_end", nowMs, transcriptTurnAtMs(track, nowMs))) {
726
726
  track.pendingTurn = false;
727
727
  result.turns += 1;
728
728
  }
@@ -824,7 +824,7 @@ export class TranscriptWatcher {
824
824
  }
825
825
  if (track.pendingTurn &&
826
826
  nowMs - track.lastChangeAtMs >= TRANSCRIPT_TURN_QUIET_MS) {
827
- if (this.emitKind(track, "turn_end", nowMs)) {
827
+ if (this.emitKind(track, "turn_end", nowMs, transcriptTurnAtMs(track, nowMs))) {
828
828
  track.pendingTurn = false;
829
829
  result.turns += 1;
830
830
  }
@@ -916,7 +916,9 @@ export class TranscriptWatcher {
916
916
  }
917
917
  return env;
918
918
  }
919
- emitKind(track, kind, nowMs) {
919
+ emitKind(track, kind, nowMs,
920
+ /** Event time when it differs from detection time (turn_end). */
921
+ atMs = nowMs) {
920
922
  let seq;
921
923
  try {
922
924
  seq = reserveSessionSeq(track.sessionId, 1, this.seqEnv());
@@ -925,13 +927,18 @@ export class TranscriptWatcher {
925
927
  return false;
926
928
  }
927
929
  track.seq = seq;
930
+ // `at` must not go backwards against seq: a transcript time older than the
931
+ // last event this watcher sent for the session is clamped to that event.
932
+ const eventAtMs = track.lastSentAtMs !== undefined && atMs < track.lastSentAtMs
933
+ ? track.lastSentAtMs
934
+ : atMs;
928
935
  const opts = {
929
936
  kind,
930
937
  sessionId: track.sessionId,
931
938
  harness: track.harness,
932
939
  adapterVersion: TRANSCRIPT_ADAPTER_VERSION,
933
940
  source: TRANSCRIPT_EVENT_SOURCE,
934
- at: new Date(nowMs).toISOString(),
941
+ at: new Date(eventAtMs).toISOString(),
935
942
  seq,
936
943
  cwd: track.cwd,
937
944
  hqRoot: this.deps.hqRoot,
@@ -942,6 +949,7 @@ export class TranscriptWatcher {
942
949
  };
943
950
  try {
944
951
  this.enqueueFn(opts);
952
+ track.lastSentAtMs = eventAtMs;
945
953
  return true;
946
954
  }
947
955
  catch {
@@ -949,6 +957,19 @@ export class TranscriptWatcher {
949
957
  }
950
958
  }
951
959
  }
960
+ /**
961
+ * A transcript turn ended when the transcript last changed (file mtime), not
962
+ * when a backed-off or restarted poll noticed it. Never later than now. A
963
+ * catch-up turn_end sent in the same tick as session_end (restart adoption of
964
+ * an already quiet transcript) therefore carries the earlier transcript time,
965
+ * not the session_end time.
966
+ */
967
+ function transcriptTurnAtMs(track, nowMs) {
968
+ const mtimeMs = track.lastMtimeMs;
969
+ if (!Number.isFinite(mtimeMs) || mtimeMs <= 0)
970
+ return nowMs;
971
+ return Math.min(mtimeMs, nowMs);
972
+ }
952
973
  /**
953
974
  * Adaptive interval: busy ticks double up to MAX; quiet ticks return to base.
954
975
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.252.0",
3
+ "version": "5.254.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {