@indigoai-us/hq-cli 5.299.0 → 5.301.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/assets/scaffold/core/scripts/token-usage-report.sh +9 -0
  3. package/dist/command-catalog.generated.d.ts +32 -6
  4. package/dist/command-catalog.generated.js +38 -6
  5. package/dist/commands/__fixtures__/access-vault.d.ts +2 -0
  6. package/dist/commands/__fixtures__/access-vault.js +1 -1
  7. package/dist/commands/access.d.ts +2 -1
  8. package/dist/commands/access.js +58 -49
  9. package/dist/commands/cloud-provision.js +51 -2
  10. package/dist/commands/cloud.js +12 -7
  11. package/dist/commands/core-company-index-scope-flag.d.ts +3 -0
  12. package/dist/commands/core-company-index-scope-flag.js +6 -0
  13. package/dist/commands/core-job-native-utils.d.ts +6 -0
  14. package/dist/commands/core-job-native-utils.js +80 -0
  15. package/dist/commands/core-job-native.d.ts +2 -4
  16. package/dist/commands/core-job-native.js +6 -10
  17. package/dist/commands/core-job-notify-native.d.ts +36 -0
  18. package/dist/commands/core-job-notify-native.js +359 -0
  19. package/dist/commands/core-job-remediate-native.d.ts +26 -0
  20. package/dist/commands/core-job-remediate-native.js +374 -0
  21. package/dist/commands/core.js +26 -8
  22. package/dist/commands/files.d.ts +1 -0
  23. package/dist/commands/files.js +90 -14
  24. package/dist/commands/integrations-api.d.ts +6 -6
  25. package/dist/commands/integrations-api.js +4 -13
  26. package/dist/commands/integrations-manage.js +1 -4
  27. package/dist/commands/lanes-message.js +78 -37
  28. package/dist/commands/lanes-resume.js +247 -210
  29. package/dist/commands/lanes-shared.d.ts +5 -1
  30. package/dist/commands/lanes.d.ts +4 -0
  31. package/dist/commands/lanes.js +7 -4
  32. package/dist/commands/meetings.js +35 -16
  33. package/dist/commands/safe-extract.d.ts +4 -4
  34. package/dist/commands/safe-extract.js +6 -7
  35. package/dist/lib/core-utils/token-usage-report.js +17 -3
  36. package/dist/lib/index-render/companies.d.ts +4 -1
  37. package/dist/lib/index-render/companies.js +66 -3
  38. package/dist/lib/index-render/index.d.ts +1 -0
  39. package/dist/lib/index-render/index.js +2 -1
  40. package/dist/lib/index-render/shared.d.ts +1 -1
  41. package/dist/lib/index-render/shared.js +4 -2
  42. package/dist/lib/lanes/reconcile.js +22 -4
  43. package/dist/lib/lanes/story-switch.d.ts +21 -0
  44. package/dist/lib/lanes/story-switch.js +75 -0
  45. package/dist/lib/lanes/types.d.ts +25 -0
  46. package/dist/lib/lanes/types.js +1 -1
  47. package/dist/lib/lanes/watcher.js +15 -4
  48. package/dist/lib/mesh/live/daemon/mqtt-events.d.ts +7 -0
  49. package/dist/lib/mesh/live/daemon/mqtt-events.js +2 -0
  50. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.d.ts +2 -0
  51. package/dist/lib/mesh/live/daemon/mqtt-events.test-d.js +10 -0
  52. package/dist/lib/mesh/live/daemon/presence.d.ts +2 -2
  53. package/dist/lib/mesh/live/daemon/work-subscribe.d.ts +2 -2
  54. package/dist/utils/self-update.js +51 -4
  55. package/dist/utils/staged-npm-install.d.ts +16 -0
  56. package/dist/utils/staged-npm-install.js +264 -0
  57. package/dist/utils/update-command-supervisor.cjs +53 -1
  58. package/dist/utils/version-gate.d.ts +8 -0
  59. package/dist/utils/version-gate.js +82 -15
  60. package/package.json +2 -2
  61. package/dist/lib/install-path-compatibility-flag.d.ts +0 -9
  62. package/dist/lib/install-path-compatibility-flag.js +0 -12
@@ -3,6 +3,25 @@ import { readFile } from "node:fs/promises";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
4
  import { vaultApiFetch, getCompanyUid, resolveCallerPersonUid, } from "../utils/vault-api.js";
5
5
  import { isPlanGateError, planGateErrorFromPayload, } from "../utils/plan-gate-error.js";
6
+ // Two minutes gives a large transcript time to arrive over a slow connection,
7
+ // while keeping the interactive command from waiting indefinitely.
8
+ const MEETING_DOCUMENT_DOWNLOAD_TIMEOUT_MS = 120_000;
9
+ async function downloadMeetingDocument(url, readBody) {
10
+ const signal = AbortSignal.timeout(MEETING_DOCUMENT_DOWNLOAD_TIMEOUT_MS);
11
+ try {
12
+ const response = await fetch(url, { signal });
13
+ if (!response.ok)
14
+ return { response, ok: false };
15
+ return { response, ok: true, body: await readBody(response) };
16
+ }
17
+ catch (err) {
18
+ if ((signal.aborted && signal.reason instanceof DOMException && signal.reason.name === "TimeoutError") ||
19
+ (err instanceof DOMException && err.name === "TimeoutError")) {
20
+ throw new Error(`Meeting document download timed out after ${MEETING_DOCUMENT_DOWNLOAD_TIMEOUT_MS / 1000} seconds. Check your network connection and try again.`);
21
+ }
22
+ throw err;
23
+ }
24
+ }
6
25
  function formatDuration(seconds) {
7
26
  const h = Math.floor(seconds / 3600);
8
27
  const m = Math.floor((seconds % 3600) / 60);
@@ -495,12 +514,12 @@ export function registerMeetingsCommand(program) {
495
514
  console.error(chalk.red("No document available for this meeting."));
496
515
  process.exit(1);
497
516
  }
498
- const docRes = await fetch(documentUrl);
499
- if (!docRes.ok) {
500
- console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
517
+ const download = await downloadMeetingDocument(documentUrl, (response) => response.text());
518
+ if (!download.ok) {
519
+ console.error(chalk.red(`Failed to download meeting document (${download.response.status})`));
501
520
  process.exit(1);
502
521
  }
503
- const markdown = await docRes.text();
522
+ const markdown = download.body;
504
523
  if (meetings.opts().json) {
505
524
  console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
506
525
  return;
@@ -513,12 +532,12 @@ export function registerMeetingsCommand(program) {
513
532
  console.error(chalk.red("No document URL available for this meeting."));
514
533
  process.exit(1);
515
534
  }
516
- const docRes = await fetch(detail.documentUrl);
517
- if (!docRes.ok) {
518
- console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
535
+ const download = await downloadMeetingDocument(detail.documentUrl, (response) => response.json());
536
+ if (!download.ok) {
537
+ console.error(chalk.red(`Failed to download meeting document (${download.response.status})`));
519
538
  process.exit(1);
520
539
  }
521
- const doc = (await docRes.json());
540
+ const doc = download.body;
522
541
  if (meetings.opts().json) {
523
542
  console.log(JSON.stringify(doc.transcript, null, 2));
524
543
  return;
@@ -573,12 +592,12 @@ export function registerMeetingsCommand(program) {
573
592
  console.log(chalk.yellow("No notes available for this meeting."));
574
593
  return;
575
594
  }
576
- const docRes = await fetch(documentUrl);
577
- if (!docRes.ok) {
578
- console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
595
+ const download = await downloadMeetingDocument(documentUrl, (response) => response.text());
596
+ if (!download.ok) {
597
+ console.error(chalk.red(`Failed to download meeting document (${download.response.status})`));
579
598
  process.exit(1);
580
599
  }
581
- const markdown = await docRes.text();
600
+ const markdown = download.body;
582
601
  if (meetings.opts().json) {
583
602
  console.log(JSON.stringify({ meetingId: detail.meetingId, sourceShape: "markdown", markdown }, null, 2));
584
603
  return;
@@ -591,12 +610,12 @@ export function registerMeetingsCommand(program) {
591
610
  console.error(chalk.red("No document URL available for this meeting."));
592
611
  process.exit(1);
593
612
  }
594
- const docRes = await fetch(detail.documentUrl);
595
- if (!docRes.ok) {
596
- console.error(chalk.red(`Failed to download meeting document (${docRes.status})`));
613
+ const download = await downloadMeetingDocument(detail.documentUrl, (response) => response.json());
614
+ if (!download.ok) {
615
+ console.error(chalk.red(`Failed to download meeting document (${download.response.status})`));
597
616
  process.exit(1);
598
617
  }
599
- const doc = (await docRes.json());
618
+ const doc = download.body;
600
619
  if (meetings.opts().json) {
601
620
  console.log(JSON.stringify(doc.notes, null, 2));
602
621
  return;
@@ -111,10 +111,10 @@ export declare function assertLinkTargetContained(packDir: string, entryAbs: str
111
111
  /**
112
112
  * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
113
113
  * argv form, never a shell string, keeps hostile filenames out of shell syntax.
114
- * When the default-off compatibility flag is enabled, Windows drive paths use
115
- * a basename relative to the archive directory. GNU tar cannot parse `C:\\...`
116
- * as remote `host:path`, and Windows' bundled bsdtar accepts the same relative
117
- * argv without GNU-only `--force-local`.
114
+ * Windows drive paths use a basename relative to the archive directory by
115
+ * default. GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows'
116
+ * bundled bsdtar accepts the same relative argv without GNU-only `--force-local`.
117
+ * Callers can still explicitly opt out with `useRelativeWindowsDrivePath: false`.
118
118
  */
119
119
  export declare function listTarball(tarballPath: string, opts?: Pick<SafeExtractOptions, 'platform' | 'useRelativeWindowsDrivePath' | 'execFileSync'>): TarEntry[];
120
120
  /**
@@ -35,7 +35,6 @@
35
35
  import { execFileSync } from 'child_process';
36
36
  import * as fs from 'fs';
37
37
  import * as path from 'path';
38
- import { installPathCompatibilityEnabled } from '../lib/install-path-compatibility-flag.js';
39
38
  export const DEFAULT_CAPS = {
40
39
  // A content pack is docs/skills/policies/small scripts. 256 MiB is already
41
40
  // generous; anything larger is almost certainly a bomb or a mistake.
@@ -218,14 +217,14 @@ function tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath)
218
217
  /**
219
218
  * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
220
219
  * argv form, never a shell string, keeps hostile filenames out of shell syntax.
221
- * When the default-off compatibility flag is enabled, Windows drive paths use
222
- * a basename relative to the archive directory. GNU tar cannot parse `C:\\...`
223
- * as remote `host:path`, and Windows' bundled bsdtar accepts the same relative
224
- * argv without GNU-only `--force-local`.
220
+ * Windows drive paths use a basename relative to the archive directory by
221
+ * default. GNU tar cannot parse `C:\\...` as remote `host:path`, and Windows'
222
+ * bundled bsdtar accepts the same relative argv without GNU-only `--force-local`.
223
+ * Callers can still explicitly opt out with `useRelativeWindowsDrivePath: false`.
225
224
  */
226
225
  export function listTarball(tarballPath, opts = {}) {
227
226
  const platform = opts.platform ?? process.platform;
228
- const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? installPathCompatibilityEnabled();
227
+ const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? true;
229
228
  const location = tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath);
230
229
  const out = (opts.execFileSync ?? execFileSync)('tar', ['-tvf', location.archiveArg], {
231
230
  encoding: 'utf-8',
@@ -330,7 +329,7 @@ export function safeExtractTarball(tarballPath, finalDir, opts = {}) {
330
329
  }
331
330
  // Resolve once so listing and extraction use the same archive location.
332
331
  const platform = opts.platform ?? process.platform;
333
- const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? installPathCompatibilityEnabled();
332
+ const useRelativeWindowsDrivePath = opts.useRelativeWindowsDrivePath ?? true;
334
333
  const exec = opts.execFileSync ?? execFileSync;
335
334
  const location = tarballArgvLocation(tarballPath, platform, useRelativeWindowsDrivePath);
336
335
  // 1. Pre-flight against headers (no bytes written yet).
@@ -120,7 +120,7 @@ function comparison(projectDirs, before, after, minHours, json, stdout) {
120
120
  /** Print token usage totals from Claude session JSONL files. */
121
121
  export function tokenUsageReport(args = [], options = {}) {
122
122
  const { stdout, stderr } = ioFor(options);
123
- let lastDays = 7, since = "", json = false, before = "", after = "", minHours = 0;
123
+ let lastDays = 7, since = "", json = false, before = "", after = "", minHours = 0, projectDir = options.projectDir, projectDirSeen = false;
124
124
  for (let index = 0; index < args.length;) {
125
125
  const arg = args[index];
126
126
  if (arg === "--last") {
@@ -149,8 +149,22 @@ export function tokenUsageReport(args = [], options = {}) {
149
149
  minHours = Number.parseFloat(args[index + 1] ?? "");
150
150
  index += 2;
151
151
  }
152
+ else if (arg === "--project-dir") {
153
+ const value = args[index + 1];
154
+ if (!value || value.startsWith("--")) {
155
+ line(stderr, "--project-dir requires a path");
156
+ return 1;
157
+ }
158
+ if (projectDirSeen) {
159
+ line(stderr, "--project-dir may be specified only once");
160
+ return 1;
161
+ }
162
+ projectDir = value;
163
+ projectDirSeen = true;
164
+ index += 2;
165
+ }
152
166
  else if (arg === "-h" || arg === "--help") {
153
- stdout("token-usage-report.sh — Daily effective-token totals + top burners from Claude Code session JSONLs.\nUsage:\n scripts/token-usage-report.sh # last 7 days, table\n scripts/token-usage-report.sh --last 14 # last 14 days\n scripts/token-usage-report.sh --since 2026-05-01\n scripts/token-usage-report.sh --json # machine-readable\nEffective tokens weighting (matches weekly rate-limit volume):\n input + 5×output + 1.25×cache_creation + 0.1×cache_read\n--- Compare-windows mode (US-011) ---\nFormat: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD\n");
167
+ stdout("token-usage-report.sh — Daily effective-token totals + top burners from Claude Code session JSONLs.\nUsage:\n scripts/token-usage-report.sh # last 7 days, table\n scripts/token-usage-report.sh --last 14 # last 14 days\n scripts/token-usage-report.sh --since 2026-05-01\n scripts/token-usage-report.sh --json # machine-readable\n scripts/token-usage-report.sh --project-dir <path> # use one Claude activity folder\nEffective tokens weighting (matches weekly rate-limit volume):\n input + 5×output + 1.25×cache_creation + 0.1×cache_read\n--- Compare-windows mode (US-011) ---\nFormat: --before YYYY-MM-DD:YYYY-MM-DD --after YYYY-MM-DD:YYYY-MM-DD\n");
154
168
  return 0;
155
169
  }
156
170
  else {
@@ -158,7 +172,7 @@ export function tokenUsageReport(args = [], options = {}) {
158
172
  return 1;
159
173
  }
160
174
  }
161
- const projectDirs = resolveProjectDirs(options).filter((dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory());
175
+ const projectDirs = resolveProjectDirs({ ...options, projectDir }).filter((dir) => fs.existsSync(dir) && fs.statSync(dir).isDirectory());
162
176
  if (before && after) {
163
177
  comparison(projectDirs, before, after, minHours, json, stdout);
164
178
  return 0;
@@ -1,3 +1,6 @@
1
1
  import { RenderContext, RenderResult } from "./shared.js";
2
- export declare function renderCompanies(context: RenderContext): RenderResult;
2
+ export declare class CompanyIndexUserError extends Error {
3
+ constructor(message: string);
4
+ }
5
+ export declare function renderCompanies(context: RenderContext, args?: string[]): RenderResult;
3
6
  //# sourceMappingURL=companies.d.ts.map
@@ -1,6 +1,12 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { basename, countImmediate, date, exists, immediateEntries, isHidden, log, projectStatus, readJson, sanitize, titleize, truncate, write } from "./shared.js";
4
+ export class CompanyIndexUserError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = "CompanyIndexUserError";
8
+ }
9
+ }
4
10
  function description(root, company, name) {
5
11
  const base = `companies/${company}/${name}`;
6
12
  const nonHidden = (child) => !isHidden(child);
@@ -17,9 +23,66 @@ function description(root, company, name) {
17
23
  default: return "—";
18
24
  }
19
25
  }
20
- export function renderCompanies(context) {
26
+ export function renderCompanies(context, args = []) {
27
+ if (args.some((arg) => arg === "--help" || arg === "-h")) {
28
+ (context.output ?? ((message) => process.stdout.write(message)))("Usage: hq core rebuild-index companies [--company <slug>]\n\n" +
29
+ "Regenerate company root INDEX.md files. Without --company, all companies are regenerated.\n" +
30
+ "--company <slug> regenerates only that company's index.\n" +
31
+ "Company-scoped writes require the cli.rebuild-index-company-scope flag.\n");
32
+ return { written: [] };
33
+ }
34
+ let selectedCompany;
35
+ for (let index = 0; index < args.length; index += 1) {
36
+ const arg = args[index];
37
+ if (arg === "--company") {
38
+ if (selectedCompany !== undefined)
39
+ throw new CompanyIndexUserError("rebuild-index companies: --company may be supplied once");
40
+ const value = args[index + 1];
41
+ if (!value || value.startsWith("--"))
42
+ throw new CompanyIndexUserError("rebuild-index companies: --company requires a company slug");
43
+ selectedCompany = value;
44
+ index += 1;
45
+ }
46
+ else if (arg.startsWith("--company=")) {
47
+ if (selectedCompany !== undefined)
48
+ throw new CompanyIndexUserError("rebuild-index companies: --company may be supplied once");
49
+ selectedCompany = arg.slice("--company=".length);
50
+ if (!selectedCompany)
51
+ throw new CompanyIndexUserError("rebuild-index companies: --company requires a company slug");
52
+ }
53
+ else {
54
+ throw new CompanyIndexUserError(`rebuild-index companies: unknown option ${arg}`);
55
+ }
56
+ }
21
57
  const written = [];
22
- for (const directory of immediateEntries(context.root, "companies", "dir")) {
58
+ let scopedDirectories;
59
+ if (selectedCompany === undefined) {
60
+ scopedDirectories = immediateEntries(context.root, "companies", "dir");
61
+ }
62
+ else {
63
+ if (selectedCompany === "." || selectedCompany === ".." ||
64
+ selectedCompany.includes("/") || selectedCompany.includes("\\") ||
65
+ path.basename(selectedCompany) !== selectedCompany ||
66
+ selectedCompany.startsWith("_") || isHidden(selectedCompany)) {
67
+ throw new CompanyIndexUserError(`rebuild-index companies: invalid company slug "${selectedCompany}"`);
68
+ }
69
+ const companiesRoot = path.resolve(context.root, "companies");
70
+ const selectedDirectory = path.resolve(companiesRoot, selectedCompany);
71
+ let selectedStat;
72
+ try {
73
+ selectedStat = fs.lstatSync(selectedDirectory);
74
+ }
75
+ catch (cause) {
76
+ if (cause.code !== "ENOENT")
77
+ throw cause;
78
+ throw new CompanyIndexUserError(`rebuild-index companies: no company directory named "${selectedCompany}"`);
79
+ }
80
+ if (!selectedStat.isDirectory()) {
81
+ throw new CompanyIndexUserError(`rebuild-index companies: no company directory named "${selectedCompany}"`);
82
+ }
83
+ scopedDirectories = [selectedDirectory];
84
+ }
85
+ for (const directory of scopedDirectories) {
23
86
  const company = basename(directory);
24
87
  if (company.startsWith("_") || isHidden(company))
25
88
  continue;
@@ -40,7 +103,7 @@ export function renderCompanies(context) {
40
103
  const parsed = readJson(prd);
41
104
  let text = typeof parsed?.description === "string" ? parsed.description : "";
42
105
  text = truncate(sanitize(text), 110) || "—";
43
- lines.push(`| \`${project}\` | ${projectStatus(context.root, project, prd, "active")} | ${text} |`);
106
+ lines.push(`| \`${project}\` | ${projectStatus(context.root, project, prd, "active", prd)} | ${text} |`);
44
107
  }
45
108
  }
46
109
  else
@@ -2,4 +2,5 @@ import { RenderContext, RenderResult } from "./shared.js";
2
2
  export type IndexTarget = "all" | "companies" | "company-knowledge" | "orchestrator" | "projects" | "public-knowledge" | "reports" | "social-drafts" | "threads" | "workers";
3
3
  export declare function renderIndexTarget(target: IndexTarget, context: RenderContext, args?: string[]): RenderResult;
4
4
  export { atomicWrite, comparePath, immediateEntries, type RenderContext, type RenderResult } from "./shared.js";
5
+ export { CompanyIndexUserError } from "./companies.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -10,7 +10,7 @@ import { renderWorkers } from "./workers.js";
10
10
  const allOrder = ["threads", "orchestrator", "companies", "projects", "company-knowledge", "public-knowledge", "workers", "reports", "social-drafts"];
11
11
  export function renderIndexTarget(target, context, args = []) {
12
12
  switch (target) {
13
- case "companies": return renderCompanies(context);
13
+ case "companies": return renderCompanies(context, args);
14
14
  case "company-knowledge": return renderCompanyKnowledge(context);
15
15
  case "orchestrator": return renderOrchestrator(context);
16
16
  case "projects": return renderProjects(context);
@@ -40,4 +40,5 @@ export function renderIndexTarget(target, context, args = []) {
40
40
  }
41
41
  }
42
42
  export { atomicWrite, comparePath, immediateEntries } from "./shared.js";
43
+ export { CompanyIndexUserError } from "./companies.js";
43
44
  //# sourceMappingURL=index.js.map
@@ -39,7 +39,7 @@ export declare function hasUnclosedKeepMarker(content: string): boolean;
39
39
  export declare function looksAutoGenerated(content: string): boolean;
40
40
  export declare function generatedStamp(content: string): string | undefined;
41
41
  export declare function log(context: RenderContext, message: string): void;
42
- export declare function projectStatus(root: string, project: string, prdPath: string, fallback: string): string;
42
+ export declare function projectStatus(root: string, project: string, prdPath: string, fallback: string, expectedStatePrdPath?: string): string;
43
43
  export declare function basename(file: string): string;
44
44
  export declare function isHidden(name: string): boolean;
45
45
  export declare function mtime(file: string): number;
@@ -134,9 +134,11 @@ export function generatedStamp(content) {
134
134
  return content.match(/^<!-- hq-generated-sha256: ([a-f0-9]{64}) -->/m)?.[1];
135
135
  }
136
136
  export function log(context, message) { context.log?.(message); }
137
- export function projectStatus(root, project, prdPath, fallback) {
137
+ export function projectStatus(root, project, prdPath, fallback, expectedStatePrdPath) {
138
138
  const state = readJson(at(root, `workspace/orchestrator/${project}/state.json`));
139
- if (state) {
139
+ const stateMatchesPrd = expectedStatePrdPath === undefined || (typeof state?.prd_path === "string" &&
140
+ path.resolve(root, state.prd_path) === path.resolve(root, expectedStatePrdPath));
141
+ if (state && stateMatchesPrd) {
140
142
  const raw = typeof state.status === "string" ? state.status : "active";
141
143
  if (["completed", "complete"].includes(raw))
142
144
  return "complete";
@@ -32,6 +32,7 @@ import { answeredQuestionEntries, questionRecordRefusalCode, raiseEnvelopeQuesti
32
32
  import { SILENCE_MS, appendWatcherEvent, classifyLaneExit, isPidAlive, missingExpectedEnvelopeFile, readLatestEnvelopeFile, readLatestEnvelopeRecord, watcherEventsPath, watcherOutLogPath, writeWatcherDecision, } from "./watcher.js";
33
33
  import { createLaneMesh, emitLaneDecisionEvents, emitLaneTerminalStateEvent, laneMeshCompany, } from "./mesh-events.js";
34
34
  import { activeLaneStories, laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
35
+ import { applyStorySwitchEnvelope } from "./story-switch.js";
35
36
  import { decideEnvelopeRepair, envelopeQuestionIds, routeProviderFailure } from "./watcher.js";
36
37
  import { PROVIDER_REJECTED_REASON, providerRejectionDetails, readProviderRejection, } from "./provider-rejection.js";
37
38
  /** Spec §9: unanswered at the current hop for 60 minutes → one hop. */
@@ -1271,11 +1272,28 @@ async function heartbeatAndDeadHosts(hqRoot, lanes, summary, deps, now) {
1271
1272
  }
1272
1273
  if (terminalEnvelope && latest) {
1273
1274
  const envelope = terminalEnvelope;
1274
- const storyStatus = terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
1275
+ next.last_envelope = {
1276
+ ref: latest.ref,
1277
+ decision: envelope.decision,
1278
+ at: nowIso,
1279
+ artifacts: envelope.artifacts,
1280
+ ...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
1281
+ };
1282
+ const storySwitch = applyStorySwitchEnvelope(current, envelope, nowIso, latest.ref);
1283
+ if (storySwitch.clearPending) {
1284
+ next.story_switch_pending = undefined;
1285
+ }
1286
+ if (storySwitch.storySwitchUnacknowledged !== undefined) {
1287
+ next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
1288
+ next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
1289
+ }
1290
+ const storyStatus = storySwitch.suppressCompletion
1291
+ ? "in_progress"
1292
+ : terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
1275
1293
  if (storyStatus !== undefined) {
1276
- next.story_status = envelope.story_status;
1277
- next.story_complete = envelope.story_complete;
1278
- next.story_complete_reason = envelope.story_complete_reason;
1294
+ next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
1295
+ next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
1296
+ next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
1279
1297
  next.stories = laneStories(current).map((story) => story.project_id === current.project_id && story.story_id === current.story_id
1280
1298
  ? {
1281
1299
  ...story,
@@ -0,0 +1,21 @@
1
+ import type { AttributionHistoryEntry, LaneEnvelope, LaneRecord, StorySwitchUnacknowledgedNote } from "./types.js";
2
+ export declare function storySwitchNotice(previousProjectId: string, previousStoryId: string, projectId: string, storyId: string): string | undefined;
3
+ export declare function artifactIdentityKeys(artifacts: readonly unknown[] | undefined): string[];
4
+ export interface StorySwitchRollbackSnapshot {
5
+ message_id: string;
6
+ project_id: string;
7
+ story_id: string;
8
+ no_story_reason?: string;
9
+ added_story?: NonNullable<LaneRecord["stories"]>[number];
10
+ story_switch_unacknowledged: StorySwitchUnacknowledgedNote | undefined;
11
+ }
12
+ /** Restore only the attribution write owned by this failed command attempt. */
13
+ export declare function rollbackStorySwitchAttribution(current: LaneRecord, snapshot: StorySwitchRollbackSnapshot): LaneRecord;
14
+ /** Apply and consume one attribution-switch marker when the next envelope arrives. */
15
+ export declare function applyStorySwitchEnvelope(lane: LaneRecord, envelope: LaneEnvelope, at: string, envelopeRef: string): {
16
+ suppressCompletion: boolean;
17
+ clearPending: boolean;
18
+ storySwitchUnacknowledged?: StorySwitchUnacknowledgedNote;
19
+ attributionHistory?: AttributionHistoryEntry[];
20
+ };
21
+ //# sourceMappingURL=story-switch.d.ts.map
@@ -0,0 +1,75 @@
1
+ export function storySwitchNotice(previousProjectId, previousStoryId, projectId, storyId) {
2
+ if (previousProjectId === projectId && previousStoryId === storyId)
3
+ return undefined;
4
+ const from = `${previousProjectId}/${previousStoryId || "(no story)"}`;
5
+ const to = `${projectId}/${storyId || "(no story)"}`;
6
+ return `Story changed: ${from} -> ${to}. The previous story is closed; report only on the new story in this turn's envelope.`;
7
+ }
8
+ export function artifactIdentityKeys(artifacts) {
9
+ if (!Array.isArray(artifacts))
10
+ return [];
11
+ return artifacts.flatMap((artifact) => {
12
+ if (typeof artifact === "string" && artifact.trim())
13
+ return [`value:${artifact.trim()}`];
14
+ if (artifact === null || typeof artifact !== "object" || Array.isArray(artifact))
15
+ return [];
16
+ const record = artifact;
17
+ const url = typeof record.url === "string" ? record.url.trim() : "";
18
+ const path = typeof record.path === "string" ? record.path.trim() : "";
19
+ const values = [url && `url:${url}`, path && `path:${path}`].filter((value) => Boolean(value));
20
+ return values;
21
+ }).sort();
22
+ }
23
+ /** Restore only the attribution write owned by this failed command attempt. */
24
+ export function rollbackStorySwitchAttribution(current, snapshot) {
25
+ if (current.story_switch_pending?.message_id !== snapshot.message_id)
26
+ return current;
27
+ const addedStory = snapshot.added_story;
28
+ const stories = (current.stories ?? []).filter((story) => !(addedStory && story.project_id === addedStory.project_id && story.story_id === addedStory.story_id &&
29
+ story.added_at === addedStory.added_at));
30
+ return {
31
+ ...current,
32
+ project_id: snapshot.project_id,
33
+ story_id: snapshot.story_id,
34
+ no_story_reason: snapshot.no_story_reason,
35
+ stories,
36
+ attribution_history: current.attribution_history.filter((entry) => entry.message_id !== snapshot.message_id),
37
+ story_switch_pending: undefined,
38
+ story_switch_unacknowledged: snapshot.story_switch_unacknowledged,
39
+ };
40
+ }
41
+ function sameKeys(left, right) {
42
+ return left.length > 0 && left.length === right.length && left.every((key, index) => key === right[index]);
43
+ }
44
+ /** Apply and consume one attribution-switch marker when the next envelope arrives. */
45
+ export function applyStorySwitchEnvelope(lane, envelope, at, envelopeRef) {
46
+ const pending = lane.story_switch_pending;
47
+ if (!pending)
48
+ return { suppressCompletion: false, clearPending: false };
49
+ if (envelope.story_complete !== true)
50
+ return { suppressCompletion: false, clearPending: false };
51
+ const currentKeys = artifactIdentityKeys(envelope.artifacts);
52
+ const suppressCompletion = sameKeys(pending.previous_artifact_keys, currentKeys);
53
+ if (!suppressCompletion)
54
+ return { suppressCompletion: false, clearPending: true };
55
+ const note = {
56
+ event: "story_switch_unacknowledged",
57
+ previous_project_id: pending.previous_project_id,
58
+ previous_story_id: pending.previous_story_id,
59
+ project_id: pending.project_id,
60
+ story_id: pending.story_id,
61
+ message_id: pending.message_id,
62
+ envelope_ref: envelopeRef,
63
+ at,
64
+ artifact_keys: currentKeys,
65
+ };
66
+ return {
67
+ suppressCompletion: true,
68
+ clearPending: true,
69
+ storySwitchUnacknowledged: note,
70
+ attributionHistory: lane.attribution_history.map((entry) => entry.message_id === pending.message_id
71
+ ? { ...entry, story_switch_unacknowledged: note }
72
+ : entry),
73
+ };
74
+ }
75
+ //# sourceMappingURL=story-switch.js.map
@@ -351,6 +351,27 @@ export interface AttributionHistoryEntry {
351
351
  story_id: string;
352
352
  message_id: string;
353
353
  at: string;
354
+ story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
355
+ }
356
+ export interface StorySwitchPending {
357
+ previous_project_id: string;
358
+ previous_story_id: string;
359
+ project_id: string;
360
+ story_id: string;
361
+ message_id: string;
362
+ at: string;
363
+ previous_artifact_keys: string[];
364
+ }
365
+ export interface StorySwitchUnacknowledgedNote {
366
+ event: "story_switch_unacknowledged";
367
+ previous_project_id: string;
368
+ previous_story_id: string;
369
+ project_id: string;
370
+ story_id: string;
371
+ message_id: string;
372
+ envelope_ref: string;
373
+ at: string;
374
+ artifact_keys: string[];
354
375
  }
355
376
  export interface LastEnvelopeRef {
356
377
  ref: string;
@@ -573,6 +594,10 @@ export interface LaneRecord {
573
594
  envelope_summary?: string;
574
595
  attribution_history: AttributionHistoryEntry[];
575
596
  last_envelope?: LastEnvelopeRef;
597
+ /** Pending attribution change, consumed by the next terminal envelope. */
598
+ story_switch_pending?: StorySwitchPending;
599
+ /** Last completion suppressed because the new story repeated prior evidence. */
600
+ story_switch_unacknowledged?: StorySwitchUnacknowledgedNote;
576
601
  /** Effective model id recorded at create/spawn. Omitted when the provider chooses. */
577
602
  model?: string;
578
603
  /** Where the effective model came from; repaired models stay pinned across the account move. */
@@ -227,7 +227,7 @@ export const LANE_FIELD_WRITERS = {
227
227
  priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], registry_worker: ["creator", "command", "spawn"], registry_worker_approval_history: ["command", "watcher", "reconcile"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
228
228
  keep_alive: ["creator", "command"], keep_alive_standing: ["command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_history: ["reconcile"], keep_alive_blocked_resume_streak: ["reconcile", "spawn", "watcher"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
229
229
  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"],
230
- version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command"],
230
+ version: ["creator"], created_at: ["creator"], updated_at: ["creator"], ended_at: ["reconcile"], envelope_summary: ["reconcile"], attribution_history: ["command", "watcher", "reconcile"], story_switch_pending: ["command", "watcher", "reconcile"], story_switch_unacknowledged: ["watcher", "reconcile"],
231
231
  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"],
232
232
  last_message_at: ["command"], status_gate: ["spawn", "watcher"], status_gate_history: ["watcher", "reconcile"], blocker_freshness_state: ["watcher"], kr_freshness_state: ["watcher"], last_compaction: ["spawn"], story_complete: ["watcher", "reconcile"], story_complete_reason: ["watcher", "reconcile"], story_status: ["watcher", "reconcile"],
233
233
  overdue: ["reconcile"], overdue_at: ["reconcile"], last_host_report_at: ["watcher", "spawn", "reconcile"], stalled: ["watcher"], stalled_at: ["watcher"], stalled_reason: ["watcher"],
@@ -37,6 +37,7 @@ import { LanesError } from "./errors.js";
37
37
  import { questionRecordRefusalCode, raiseEnvelopeQuestions, resolveQuestionCompanyWithFallback, } from "./questions.js";
38
38
  import { isTerminalLaneState, ENVELOPE_INVALID_REASON, parseLaneEnvelope, STORY_STATUSES, LANE_PROVIDERS, } from "./types.js";
39
39
  import { laneStories, terminalEnvelopeStoryStatus } from "./stories.js";
40
+ import { applyStorySwitchEnvelope } from "./story-switch.js";
40
41
  import { appendCappedHistory, listQuestionIds, readLane, readQuestion, retryUpdateLane, withLaneHireLocks } from "./store.js";
41
42
  import { enforceRegistryApprovalGate } from "./approval-gate.js";
42
43
  import { readWorkerRunResult } from "./delivery.js";
@@ -414,6 +415,7 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
414
415
  }), current.registry_worker_approval_history ?? []);
415
416
  }
416
417
  if (envelope) {
418
+ const storySwitch = applyStorySwitchEnvelope(current, envelope, at, envelopeRef);
417
419
  if (envelope.decision !== "blocked")
418
420
  next.keep_alive_blocked_resume_streak = 0;
419
421
  next.last_envelope = {
@@ -423,11 +425,20 @@ export function applyTerminalToLaneWithResult(hqRoot, laneId, classification, op
423
425
  artifacts: envelope.artifacts,
424
426
  ...(envelope.blocked_reason !== undefined ? { blocked_reason: envelope.blocked_reason } : {}),
425
427
  };
426
- const storyStatus = terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
428
+ if (storySwitch.clearPending) {
429
+ next.story_switch_pending = undefined;
430
+ }
431
+ if (storySwitch.storySwitchUnacknowledged !== undefined) {
432
+ next.story_switch_unacknowledged = storySwitch.storySwitchUnacknowledged;
433
+ next.attribution_history = storySwitch.attributionHistory ?? current.attribution_history;
434
+ }
435
+ const storyStatus = storySwitch.suppressCompletion
436
+ ? "in_progress"
437
+ : terminalEnvelopeStoryStatus(current, envelope.decision, envelope.story_status);
427
438
  if (storyStatus !== undefined) {
428
- next.story_status = envelope.story_status;
429
- next.story_complete = envelope.story_complete;
430
- next.story_complete_reason = envelope.story_complete_reason;
439
+ next.story_status = storySwitch.suppressCompletion ? "in_progress" : envelope.story_status;
440
+ next.story_complete = storySwitch.suppressCompletion ? false : envelope.story_complete;
441
+ next.story_complete_reason = storySwitch.suppressCompletion ? undefined : envelope.story_complete_reason;
431
442
  const currentProject = current.project_id;
432
443
  const currentStory = current.story_id;
433
444
  const updatedStories = laneStories(current).map((story) => story.project_id === currentProject && story.story_id === currentStory
@@ -0,0 +1,7 @@
1
+ import type { MqttClientEventCallbacks } from "mqtt";
2
+ /** Event callbacks shared by the daemon's two MQTT client seams. */
3
+ export type MeshMqttEventMap = Pick<MqttClientEventCallbacks, "connect" | "error" | "close" | "message">;
4
+ export interface MeshMqttEventClient {
5
+ on<Event extends keyof MeshMqttEventMap>(event: Event, handler: MeshMqttEventMap[Event]): unknown;
6
+ }
7
+ //# sourceMappingURL=mqtt-events.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mqtt-events.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=mqtt-events.test-d.d.ts.map
@@ -0,0 +1,10 @@
1
+ // @ts-expect-error unsupported event names are not part of mqtt's event map
2
+ presence.on("conect", () => { });
3
+ // @ts-expect-error MQTT message handlers receive a string topic
4
+ presence.on("message", (topic) => void topic);
5
+ // @ts-expect-error unsupported event names are not part of mqtt's event map
6
+ workCache.on("conect", () => { });
7
+ // @ts-expect-error MQTT message handlers receive a string topic
8
+ workCache.on("message", (topic) => void topic);
9
+ export {};
10
+ //# sourceMappingURL=mqtt-events.test-d.js.map
@@ -12,6 +12,7 @@
12
12
  * - Server credential refusals use a long retry (default 10m ±20%)
13
13
  */
14
14
  import { type IClientOptions, type MqttClient } from "mqtt";
15
+ import type { MeshMqttEventClient } from "./mqtt-events.js";
15
16
  import { type Contract3Bundle, type CredentialsFetcher, type TimerHost } from "./credentials.js";
16
17
  export type MqttConnectionState = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
17
18
  /**
@@ -49,8 +50,7 @@ export interface PresencePayload {
49
50
  at: string;
50
51
  }
51
52
  /** Structural mqtt client surface used by PresenceClient (test seam). */
52
- export interface MeshMqttClientLike {
53
- on(event: string, handler: (...args: any[]) => void): unknown;
53
+ export interface MeshMqttClientLike extends MeshMqttEventClient {
54
54
  publish(topic: string, message: string | Buffer, opts: {
55
55
  qos: 0 | 1 | 2;
56
56
  retain?: boolean;