@indigoai-us/hq-cli 5.345.32 → 5.345.33

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 (46) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/command-catalog.generated.d.ts +92 -3
  3. package/dist/command-catalog.generated.js +111 -3
  4. package/dist/commands/db-dump.d.ts +8 -0
  5. package/dist/commands/db-dump.js +50 -0
  6. package/dist/commands/db-provision.d.ts +3 -10
  7. package/dist/commands/db-provision.js +26 -47
  8. package/dist/commands/db-remote-deps.d.ts +18 -0
  9. package/dist/commands/db-remote-deps.js +59 -0
  10. package/dist/commands/db-sql.d.ts +2 -1
  11. package/dist/commands/db-sql.js +30 -5
  12. package/dist/commands/db-usage.d.ts +7 -0
  13. package/dist/commands/db-usage.js +28 -0
  14. package/dist/commands/db.js +4 -0
  15. package/dist/commands/files.js +33 -5
  16. package/dist/commands/lanes-message.js +40 -8
  17. package/dist/commands/lanes-questions.d.ts +6 -1
  18. package/dist/commands/lanes-questions.js +19 -7
  19. package/dist/commands/lanes-resume.js +17 -6
  20. package/dist/commands/lanes-shared.d.ts +6 -1
  21. package/dist/commands/lanes.js +74 -1
  22. package/dist/commands/mesh.js +150 -26
  23. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +68 -604
  24. package/dist/hq-cloud-sync-runner/package.json +1 -1
  25. package/dist/lib/db/control-plane.d.ts +32 -0
  26. package/dist/lib/db/control-plane.js +50 -0
  27. package/dist/lib/db/redact.d.ts +4 -0
  28. package/dist/lib/db/redact.js +17 -0
  29. package/dist/lib/db/remote-engine.d.ts +67 -0
  30. package/dist/lib/db/remote-engine.js +165 -0
  31. package/dist/lib/db/usage-format.d.ts +4 -0
  32. package/dist/lib/db/usage-format.js +41 -0
  33. package/dist/lib/lanes/keep-alive.d.ts +12 -0
  34. package/dist/lib/lanes/keep-alive.js +25 -6
  35. package/dist/lib/mesh/api.d.ts +2 -0
  36. package/dist/lib/mesh/project-board.d.ts +17 -0
  37. package/dist/lib/mesh/project-board.js +28 -0
  38. package/dist/lib/mesh/story-status.d.ts +16 -0
  39. package/dist/lib/mesh/story-status.js +32 -7
  40. package/dist/utils/self-update.d.ts +2 -0
  41. package/dist/utils/self-update.js +61 -23
  42. package/dist/utils/version-check.d.ts +1 -1
  43. package/dist/utils/version-check.js +2 -2
  44. package/dist/utils/version-gate.d.ts +21 -14
  45. package/dist/utils/version-gate.js +119 -28
  46. package/package.json +4 -1
@@ -1 +1 @@
1
- {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.33","bundleSha256":"ac95a726e29b0c0db65da6753d3c4e249f3f8a0cf7d56fe7579ac1804a14f34f","formatVersion":1}
1
+ {"name":"hq-cli-daemon-sync-runner","type":"module","version":"6.18.33","bundleSha256":"75c61dfadcd4d28795d082e0cc867b09f2b825baa3db95f662c51e9dce299412","formatVersion":1}
@@ -24,6 +24,33 @@ export interface RemoteStatusResponse {
24
24
  secretRef: string;
25
25
  } | null;
26
26
  }
27
+ export interface RemoteUsageResponse {
28
+ companyUid: string;
29
+ dpuUsed: number;
30
+ dpuCap: number;
31
+ storageBytes: number;
32
+ storageCap: number;
33
+ pctDpu: number;
34
+ pctStorage: number;
35
+ period: string;
36
+ measuredAt: string | null;
37
+ }
38
+ /**
39
+ * Scoped temporary AWS credentials for one app's database role. The control
40
+ * plane would issue these from `POST /v1/db/apps/{appId}/session`.
41
+ */
42
+ export interface RemoteAppSession {
43
+ endpoint: string;
44
+ region: string;
45
+ role: string;
46
+ schema: string;
47
+ credentials: {
48
+ accessKeyId: string;
49
+ secretAccessKey: string;
50
+ sessionToken: string;
51
+ expiration?: string;
52
+ };
53
+ }
27
54
  export interface ControlPlaneClientOptions {
28
55
  baseUrl: string;
29
56
  /** Bearer access token */
@@ -41,5 +68,10 @@ export declare class ControlPlaneDbClient {
41
68
  region?: string;
42
69
  }): Promise<RemoteProvisionResponse>;
43
70
  status(companyUid: string): Promise<RemoteStatusResponse>;
71
+ usage(companyUid: string): Promise<RemoteUsageResponse>;
72
+ appSession(input: {
73
+ companyUid: string;
74
+ appId: string;
75
+ }): Promise<RemoteAppSession>;
44
76
  }
45
77
  //# sourceMappingURL=control-plane.d.ts.map
@@ -78,5 +78,55 @@ export class ControlPlaneDbClient {
78
78
  }
79
79
  return JSON.parse(text);
80
80
  }
81
+ async usage(companyUid) {
82
+ const token = await this.getAccessToken();
83
+ const url = `${this.baseUrl}/v1/db/usage?companyUid=${encodeURIComponent(companyUid)}`;
84
+ const res = await this.fetchImpl(url, {
85
+ method: "GET",
86
+ headers: { Authorization: `Bearer ${token}` },
87
+ signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
88
+ });
89
+ const text = await res.text();
90
+ assertNoPostgresUrl("usage", text);
91
+ if (!res.ok)
92
+ throw httpError("usage", res.status, text);
93
+ return JSON.parse(text);
94
+ }
95
+ async appSession(input) {
96
+ const token = await this.getAccessToken();
97
+ const url = `${this.baseUrl}/v1/db/apps/${encodeURIComponent(input.appId)}/session`;
98
+ const res = await this.fetchImpl(url, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Bearer ${token}`,
102
+ "Content-Type": "application/json",
103
+ },
104
+ body: JSON.stringify({ companyUid: input.companyUid }),
105
+ signal: AbortSignal.timeout(DB_STATUS_TIMEOUT_MS),
106
+ });
107
+ const text = await res.text();
108
+ if (!res.ok)
109
+ throw httpError("session", res.status, text);
110
+ return JSON.parse(text);
111
+ }
112
+ }
113
+ function httpError(label, status, text) {
114
+ let msg = `${label} failed (${status})`;
115
+ let code;
116
+ try {
117
+ const j = JSON.parse(text);
118
+ if (typeof j.error === "string")
119
+ msg = j.error;
120
+ if (typeof j.code === "string")
121
+ code = j.code;
122
+ }
123
+ catch {
124
+ /* keep */
125
+ }
126
+ const err = new Error(msg);
127
+ err.status = status;
128
+ if (code)
129
+ err.code = code;
130
+ return err;
81
131
  }
82
132
  //# sourceMappingURL=control-plane.js.map
@@ -0,0 +1,4 @@
1
+ /** Strip connection strings, signed-URL parameters, and token-like strings. */
2
+ export declare function redactSecrets(text: string): string;
3
+ export declare function safeErrorMessage(error: unknown): string;
4
+ //# sourceMappingURL=redact.d.ts.map
@@ -0,0 +1,17 @@
1
+ const SECRET_PATTERNS = [
2
+ /postgres(?:ql)?:\/\/\S*/gi,
3
+ /X-Amz-[A-Za-z-]+=?[^\s&"']*/gi,
4
+ /Security-Token=?[^\s&"']*/gi,
5
+ /\b[A-Za-z0-9+/_=-]{40,}\b/g,
6
+ ];
7
+ /** Strip connection strings, signed-URL parameters, and token-like strings. */
8
+ export function redactSecrets(text) {
9
+ let out = text;
10
+ for (const pattern of SECRET_PATTERNS)
11
+ out = out.replace(pattern, "[redacted]");
12
+ return out;
13
+ }
14
+ export function safeErrorMessage(error) {
15
+ return redactSecrets(error instanceof Error ? error.message : "Unknown error");
16
+ }
17
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,67 @@
1
+ import type { ControlPlaneDbClient, RemoteAppSession } from "./control-plane.js";
2
+ /**
3
+ * Source of short-lived, app-scoped database credentials. The control plane
4
+ * route behind it, `POST /v1/db/apps/{appId}/session`, does not exist yet.
5
+ */
6
+ export interface DbCredentialProvider {
7
+ getSession(input: {
8
+ companyUid: string;
9
+ appId: string;
10
+ }): Promise<RemoteAppSession>;
11
+ }
12
+ export declare function controlPlaneCredentialProvider(client: Pick<ControlPlaneDbClient, "appSession">): DbCredentialProvider;
13
+ export interface RemoteQueryResult {
14
+ rowCount: number | null;
15
+ fields: {
16
+ name: string;
17
+ }[];
18
+ rows: Record<string, unknown>[];
19
+ }
20
+ export interface RemoteClient {
21
+ connect(): Promise<void>;
22
+ query(sql: string, params?: unknown[]): Promise<RemoteQueryResult | RemoteQueryResult[]>;
23
+ end(): Promise<void>;
24
+ }
25
+ export interface RemoteClientConfig {
26
+ host: string;
27
+ port: number;
28
+ user: string;
29
+ password: string;
30
+ database: string;
31
+ ssl: {
32
+ rejectUnauthorized: boolean;
33
+ };
34
+ }
35
+ export interface RemoteEngineDeps {
36
+ provider: DbCredentialProvider;
37
+ tokenFactory?: (session: RemoteAppSession) => Promise<string>;
38
+ clientFactory?: (config: RemoteClientConfig) => RemoteClient;
39
+ }
40
+ export declare function quoteIdent(name: string): string;
41
+ export interface RemoteAppConnection {
42
+ schema: string;
43
+ query(sql: string, params?: unknown[]): Promise<RemoteQueryResult[]>;
44
+ close(): Promise<void>;
45
+ }
46
+ /** Open a connection as the app role with `search_path` set to the app schema. */
47
+ export declare function openRemoteAppConnection(deps: RemoteEngineDeps, input: {
48
+ companyUid: string;
49
+ appId: string;
50
+ }): Promise<RemoteAppConnection>;
51
+ export interface RemoteSqlOutcome {
52
+ results: RemoteQueryResult[];
53
+ readonly: boolean;
54
+ }
55
+ /** Run SQL in the app schema. Without `write`, the SQL runs inside BEGIN READ ONLY and is rolled back. */
56
+ export declare function runRemoteSql(conn: RemoteAppConnection, sql: string, write: boolean): Promise<RemoteSqlOutcome>;
57
+ export declare function formatRemoteResults(outcome: RemoteSqlOutcome): string;
58
+ export interface DumpSummary {
59
+ tables: number;
60
+ rows: number;
61
+ }
62
+ /**
63
+ * Stream a schema dump (DDL then INSERTs) to `write`. Reads data in chunks
64
+ * of DUMP_CHUNK_ROWS rows ordered by primary key.
65
+ */
66
+ export declare function dumpRemoteSchema(conn: RemoteAppConnection, write: (text: string) => void): Promise<DumpSummary>;
67
+ //# sourceMappingURL=remote-engine.d.ts.map
@@ -0,0 +1,165 @@
1
+ import { DsqlSigner } from "@aws-sdk/dsql-signer";
2
+ import pg from "pg";
3
+ const SCHEMA_PATTERN = /^app_[a-z0-9_]+$/;
4
+ const DUMP_CHUNK_ROWS = 500;
5
+ export function controlPlaneCredentialProvider(client) {
6
+ return { getSession: (input) => client.appSession(input) };
7
+ }
8
+ async function defaultToken(session) {
9
+ const signer = new DsqlSigner({
10
+ hostname: session.endpoint,
11
+ region: session.region,
12
+ credentials: {
13
+ accessKeyId: session.credentials.accessKeyId,
14
+ secretAccessKey: session.credentials.secretAccessKey,
15
+ sessionToken: session.credentials.sessionToken,
16
+ },
17
+ });
18
+ return signer.getDbConnectAuthToken();
19
+ }
20
+ function defaultClient(config) {
21
+ return new pg.Client(config);
22
+ }
23
+ export function quoteIdent(name) {
24
+ return `"${name.replace(/"/g, '""')}"`;
25
+ }
26
+ /** Open a connection as the app role with `search_path` set to the app schema. */
27
+ export async function openRemoteAppConnection(deps, input) {
28
+ const session = await deps.provider.getSession(input);
29
+ if (!SCHEMA_PATTERN.test(session.schema)) {
30
+ throw new Error("Control plane returned an unexpected schema name");
31
+ }
32
+ const password = await (deps.tokenFactory ?? defaultToken)(session);
33
+ const client = (deps.clientFactory ?? defaultClient)({
34
+ host: session.endpoint,
35
+ port: 5432,
36
+ user: session.role,
37
+ password,
38
+ database: "postgres",
39
+ ssl: { rejectUnauthorized: true },
40
+ });
41
+ await client.connect();
42
+ try {
43
+ await client.query(`SET search_path TO ${quoteIdent(session.schema)}`);
44
+ }
45
+ catch (error) {
46
+ await client.end().catch(() => undefined);
47
+ throw error;
48
+ }
49
+ return {
50
+ schema: session.schema,
51
+ async query(sql, params) {
52
+ const res = await client.query(sql, params);
53
+ return Array.isArray(res) ? res : [res];
54
+ },
55
+ close: () => client.end(),
56
+ };
57
+ }
58
+ /** Run SQL in the app schema. Without `write`, the SQL runs inside BEGIN READ ONLY and is rolled back. */
59
+ export async function runRemoteSql(conn, sql, write) {
60
+ await conn.query(write ? "BEGIN" : "BEGIN READ ONLY");
61
+ try {
62
+ const results = await conn.query(sql);
63
+ await conn.query(write ? "COMMIT" : "ROLLBACK");
64
+ return { results, readonly: !write };
65
+ }
66
+ catch (error) {
67
+ await conn.query("ROLLBACK").catch(() => undefined);
68
+ throw error;
69
+ }
70
+ }
71
+ export function formatRemoteResults(outcome) {
72
+ const blocks = outcome.results.map((r) => {
73
+ if (r.fields.length === 0)
74
+ return JSON.stringify({ rows: r.rowCount ?? 0 });
75
+ if (r.rows.length === 0) {
76
+ return JSON.stringify({ columns: r.fields.map((f) => f.name), rows: 0 });
77
+ }
78
+ return r.rows.map((row) => JSON.stringify(row)).join("\n");
79
+ });
80
+ return blocks.join("\n");
81
+ }
82
+ function sqlLiteral(value) {
83
+ if (value === null || value === undefined)
84
+ return "NULL";
85
+ if (typeof value === "number" || typeof value === "bigint")
86
+ return String(value);
87
+ if (typeof value === "boolean")
88
+ return value ? "TRUE" : "FALSE";
89
+ if (value instanceof Date)
90
+ return `'${value.toISOString()}'`;
91
+ if (Buffer.isBuffer(value))
92
+ return `'\\x${value.toString("hex")}'`;
93
+ const text = typeof value === "object" ? JSON.stringify(value) : String(value);
94
+ return `'${text.replace(/'/g, "''")}'`;
95
+ }
96
+ function columnDdl(c) {
97
+ const parts = [quoteIdent(c.column_name), c.data_type];
98
+ if (c.column_default !== null)
99
+ parts.push(`DEFAULT ${c.column_default}`);
100
+ if (c.is_nullable === "NO")
101
+ parts.push("NOT NULL");
102
+ return parts.join(" ");
103
+ }
104
+ /**
105
+ * Stream a schema dump (DDL then INSERTs) to `write`. Reads data in chunks
106
+ * of DUMP_CHUNK_ROWS rows ordered by primary key.
107
+ */
108
+ export async function dumpRemoteSchema(conn, write) {
109
+ const [cols] = await conn.query(`SELECT table_name, column_name, data_type, is_nullable, column_default
110
+ FROM information_schema.columns
111
+ WHERE table_schema = $1
112
+ ORDER BY table_name, ordinal_position`, [conn.schema]);
113
+ const [pks] = await conn.query(`SELECT kcu.table_name, kcu.column_name
114
+ FROM information_schema.table_constraints tc
115
+ JOIN information_schema.key_column_usage kcu
116
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
117
+ WHERE tc.table_schema = $1 AND tc.constraint_type = 'PRIMARY KEY'
118
+ ORDER BY kcu.table_name, kcu.ordinal_position`, [conn.schema]);
119
+ const [indexes] = await conn.query(`SELECT tablename, indexdef FROM pg_indexes WHERE schemaname = $1 ORDER BY tablename, indexname`, [conn.schema]);
120
+ const byTable = new Map();
121
+ for (const row of cols.rows) {
122
+ const list = byTable.get(row.table_name) ?? [];
123
+ list.push(row);
124
+ byTable.set(row.table_name, list);
125
+ }
126
+ const pkByTable = new Map();
127
+ for (const row of pks.rows) {
128
+ const list = pkByTable.get(row.table_name) ?? [];
129
+ list.push(row.column_name);
130
+ pkByTable.set(row.table_name, list);
131
+ }
132
+ write(`-- hq db dump for schema ${conn.schema}\n\n`);
133
+ let totalRows = 0;
134
+ for (const [table, columns] of byTable) {
135
+ const pk = pkByTable.get(table) ?? [];
136
+ const defs = columns.map(columnDdl);
137
+ if (pk.length > 0)
138
+ defs.push(`PRIMARY KEY (${pk.map(quoteIdent).join(", ")})`);
139
+ write(`CREATE TABLE ${quoteIdent(table)} (\n ${defs.join(",\n ")}\n);\n\n`);
140
+ }
141
+ for (const row of indexes.rows) {
142
+ if (/_pkey\b/.test(row.indexdef))
143
+ continue;
144
+ write(`${row.indexdef.replace(/^CREATE (UNIQUE )?INDEX /, "CREATE $1INDEX ASYNC ")};\n`);
145
+ }
146
+ for (const [table, columns] of byTable) {
147
+ const pk = pkByTable.get(table) ?? [];
148
+ const order = (pk.length > 0 ? pk : columns.map((c) => c.column_name))
149
+ .map(quoteIdent)
150
+ .join(", ");
151
+ const names = columns.map((c) => quoteIdent(c.column_name)).join(", ");
152
+ for (let offset = 0;; offset += DUMP_CHUNK_ROWS) {
153
+ const [page] = await conn.query(`SELECT * FROM ${quoteIdent(table)} ORDER BY ${order} LIMIT ${DUMP_CHUNK_ROWS} OFFSET ${offset}`);
154
+ for (const row of page.rows) {
155
+ const values = columns.map((c) => sqlLiteral(row[c.column_name])).join(", ");
156
+ write(`INSERT INTO ${quoteIdent(table)} (${names}) VALUES (${values});\n`);
157
+ }
158
+ totalRows += page.rows.length;
159
+ if (page.rows.length < DUMP_CHUNK_ROWS)
160
+ break;
161
+ }
162
+ }
163
+ return { tables: byTable.size, rows: totalRows };
164
+ }
165
+ //# sourceMappingURL=remote-engine.js.map
@@ -0,0 +1,4 @@
1
+ import type { RemoteUsageResponse } from "./control-plane.js";
2
+ export declare function formatBytes(bytes: number): string;
3
+ export declare function formatUsage(usage: RemoteUsageResponse): string;
4
+ //# sourceMappingURL=usage-format.d.ts.map
@@ -0,0 +1,41 @@
1
+ const UNITS = ["bytes", "KB", "MB", "GB", "TB"];
2
+ export function formatBytes(bytes) {
3
+ if (!Number.isFinite(bytes) || bytes <= 0)
4
+ return "0 bytes";
5
+ let value = bytes;
6
+ let unit = 0;
7
+ while (value >= 1024 && unit < UNITS.length - 1) {
8
+ value /= 1024;
9
+ unit += 1;
10
+ }
11
+ const rounded = unit === 0 ? String(Math.round(value)) : value.toFixed(value >= 10 ? 0 : 1);
12
+ return `${rounded} ${UNITS[unit]}`;
13
+ }
14
+ function percent(pct) {
15
+ if (!Number.isFinite(pct))
16
+ return "0%";
17
+ return `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}%`;
18
+ }
19
+ export function formatUsage(usage) {
20
+ const lines = [
21
+ `Database usage for ${usage.period}`,
22
+ `Compute: ${usage.dpuUsed.toLocaleString("en-US")} of ${usage.dpuCap.toLocaleString("en-US")} units used (${percent(usage.pctDpu)})`,
23
+ `Storage: ${formatBytes(usage.storageBytes)} of ${formatBytes(usage.storageCap)} used (${percent(usage.pctStorage)})`,
24
+ ];
25
+ if (usage.measuredAt === null) {
26
+ lines.push("Not measured yet, so usage shows as 0. Check again in about 15 minutes.");
27
+ }
28
+ else {
29
+ lines.push(`Last measured ${usage.measuredAt}. Numbers can lag by a few minutes.`);
30
+ }
31
+ const over = [
32
+ usage.pctDpu >= 100 ? "compute" : null,
33
+ usage.pctStorage >= 100 ? "storage" : null,
34
+ ].filter(Boolean);
35
+ if (over.length > 0)
36
+ lines.push(`Over the cap for ${over.join(" and ")}.`);
37
+ else if (usage.pctDpu >= 80 || usage.pctStorage >= 80)
38
+ lines.push("Close to a cap (80% or more).");
39
+ return lines.join("\n");
40
+ }
41
+ //# sourceMappingURL=usage-format.js.map
@@ -58,6 +58,18 @@ export declare function hasPendingKeepAliveWork(hqRoot: string, lane: Pick<LaneR
58
58
  export declare function undeliveredAnsweredQuestions(lane: Pick<LaneRecord, "lane_id" | "company">, questions: readonly QuestionRecord[]): QuestionRecord[];
59
59
  /** Missing registry means no retired lanes; every present invalid file fails closed. */
60
60
  export declare function loadRetiredLaneIds(hqRoot: string): ReadonlySet<string>;
61
+ export interface RetiredLaneEntry {
62
+ lane: string;
63
+ index: number;
64
+ retired_at?: string;
65
+ }
66
+ /** Preserve registry metadata for diagnostics while validating the same shape as the eligibility reader. */
67
+ export declare function loadRetiredLaneEntries(hqRoot: string): RetiredLaneEntry[];
68
+ /** Return the matching registry row using the same bare/host-suffixed ID semantics as keepAliveExclusion. */
69
+ export declare function findRetiredLaneEntry(hqRoot: string, laneId: string): (RetiredLaneEntry & {
70
+ path: string;
71
+ }) | undefined;
72
+ export declare function matchesRetiredLaneEntry(entry: RetiredLaneEntry, laneId: string): boolean;
61
73
  export declare function keepAliveSupersededBy<T extends KeepAliveLane>(lane: T, lanes: readonly T[], lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): T | undefined;
62
74
  export declare function keepAliveExclusion<T extends KeepAliveLane>(lane: T, lanes: readonly T[], retiredLaneIds: ReadonlySet<string>, lineageAware?: boolean, companyForLane?: (lane: T) => T["company"]): "retired" | "superseded" | undefined;
63
75
  export declare function leadLaneKeepAliveBackoffMs(attempt: number): number;
@@ -187,6 +187,10 @@ function retiredLaneRegistryPath(hqRoot) {
187
187
  }
188
188
  /** Missing registry means no retired lanes; every present invalid file fails closed. */
189
189
  export function loadRetiredLaneIds(hqRoot) {
190
+ return new Set(loadRetiredLaneEntries(hqRoot).map((entry) => entry.lane));
191
+ }
192
+ /** Preserve registry metadata for diagnostics while validating the same shape as the eligibility reader. */
193
+ export function loadRetiredLaneEntries(hqRoot) {
190
194
  const filePath = retiredLaneRegistryPath(hqRoot);
191
195
  let text;
192
196
  try {
@@ -198,7 +202,7 @@ export function loadRetiredLaneIds(hqRoot) {
198
202
  }
199
203
  catch (err) {
200
204
  if (err.code === "ENOENT")
201
- return new Set();
205
+ return [];
202
206
  throw new LanesError("keep_alive_retired_list_invalid", `Cannot read retired lane list ${filePath}: ${err instanceof Error ? err.message : String(err)}`, { path: filePath });
203
207
  }
204
208
  let parsed;
@@ -213,21 +217,26 @@ export function loadRetiredLaneIds(hqRoot) {
213
217
  }
214
218
  const retired = parsed.retired;
215
219
  if (retired === undefined)
216
- return new Set();
220
+ return [];
217
221
  if (!Array.isArray(retired))
218
222
  throw invalidRetiredList(filePath, "retired must be an array");
219
- const laneIds = new Set();
223
+ const entries = [];
220
224
  for (const [index, entry] of retired.entries()) {
221
225
  if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
222
226
  throw invalidRetiredList(filePath, `retired[${index}] must be an object with a lane id`);
223
227
  }
224
- const laneId = entry.lane;
228
+ const row = entry;
229
+ const laneId = row.lane;
225
230
  if (typeof laneId !== "string" || laneId.trim() === "") {
226
231
  throw invalidRetiredList(filePath, `retired[${index}].lane must be a non-empty string`);
227
232
  }
228
- laneIds.add(laneId.trim());
233
+ entries.push({
234
+ lane: laneId.trim(),
235
+ index,
236
+ ...(typeof row.retired_at === "string" ? { retired_at: row.retired_at } : {}),
237
+ });
229
238
  }
230
- return laneIds;
239
+ return entries;
231
240
  }
232
241
  function invalidRetiredList(filePath, message) {
233
242
  return new LanesError("keep_alive_retired_list_invalid", `Invalid retired lane list ${filePath}: ${message}`, { path: filePath });
@@ -242,6 +251,16 @@ function isRetiredLaneId(laneId, retiredLaneIds) {
242
251
  }
243
252
  return false;
244
253
  }
254
+ /** Return the matching registry row using the same bare/host-suffixed ID semantics as keepAliveExclusion. */
255
+ export function findRetiredLaneEntry(hqRoot, laneId) {
256
+ const entry = loadRetiredLaneEntries(hqRoot).find((candidate) => matchesRetiredLaneEntry(candidate, laneId));
257
+ return entry === undefined ? undefined : { ...entry, path: retiredLaneRegistryPath(hqRoot) };
258
+ }
259
+ export function matchesRetiredLaneEntry(entry, laneId) {
260
+ return entry.lane === laneId ||
261
+ entry.lane.startsWith(`${laneId}_`) ||
262
+ laneId.startsWith(`${entry.lane}_`);
263
+ }
245
264
  export function keepAliveSupersededBy(lane, lanes, lineageAware = LANE_SUPERSESSION_LINEAGE_AWARE_DEFAULT_ENABLED, companyForLane) {
246
265
  const createdAt = Date.parse(lane.created_at);
247
266
  if (!Number.isFinite(createdAt))
@@ -51,6 +51,8 @@ export interface MeshProjectStoryInput {
51
51
  description?: string;
52
52
  acceptanceCriteria?: string[];
53
53
  status?: string;
54
+ /** Source ledger state preserved for Boards whose status enum has four columns. */
55
+ ledgerState?: string;
54
56
  passes?: boolean;
55
57
  priority?: number;
56
58
  assigneeUid?: string;
@@ -4,6 +4,23 @@ export declare function mergeProjectStories(current: MeshProjectStoryInput[], pl
4
4
  resetStatus?: boolean;
5
5
  }): MeshProjectStoryInput[];
6
6
  export declare function boardStoryStatus(story: MeshProjectStoryInput): string;
7
+ export interface ProjectStoryReconciliation {
8
+ storyId: string;
9
+ ledgerState: string | null;
10
+ expectedBoardStatus: string | null;
11
+ boardStatus: string | null;
12
+ boardLedgerState: string | null;
13
+ stateDrift: boolean;
14
+ missingOnBoard: boolean;
15
+ extraOnBoard: boolean;
16
+ }
17
+ export interface ProjectStoryReconciliationReport {
18
+ mismatches: ProjectStoryReconciliation[];
19
+ ledgerStateUnsupported: string[];
20
+ stateDriftCount: number;
21
+ }
22
+ /** Compare one PRD ledger with one Board snapshot without changing either. */
23
+ export declare function reconcileProjectStories(ledger: readonly MeshProjectStoryInput[], board: readonly MeshProjectStoryInput[]): ProjectStoryReconciliationReport;
7
24
  /**
8
25
  * Keep only prd-sync task_status events whose Board status actually differs.
9
26
  * Missing Board rows stay (ensure will add them). Same-status replays of
@@ -36,6 +36,34 @@ export function boardStoryStatus(story) {
36
36
  return status;
37
37
  return story.passes === true ? "done" : "queued";
38
38
  }
39
+ /** Compare one PRD ledger with one Board snapshot without changing either. */
40
+ export function reconcileProjectStories(ledger, board) {
41
+ const ledgerById = new Map(ledger.map((story) => [story.id, story]));
42
+ const boardById = new Map(board.map((story) => [story.id, story]));
43
+ const ids = [...ledgerById.keys(), ...[...boardById.keys()].filter((id) => !ledgerById.has(id))];
44
+ const ledgerStateUnsupported = [];
45
+ const mismatches = ids.flatMap((storyId) => {
46
+ const source = ledgerById.get(storyId);
47
+ const current = boardById.get(storyId);
48
+ const expectedBoardStatus = source ? boardStoryStatus(source) : null;
49
+ const boardStatus = current ? boardStoryStatus(current) : null;
50
+ const ledgerState = source?.ledgerState ?? null;
51
+ const boardLedgerState = current?.ledgerState ?? null;
52
+ const missingOnBoard = Boolean(source && !current);
53
+ const extraOnBoard = Boolean(current && !source);
54
+ if (source && ledgerState && current && boardLedgerState === null)
55
+ ledgerStateUnsupported.push(storyId);
56
+ const stateDrift = Boolean(source && ledgerState && boardLedgerState !== null && boardLedgerState !== ledgerState);
57
+ if (!missingOnBoard && !extraOnBoard && boardStatus === expectedBoardStatus && !stateDrift)
58
+ return [];
59
+ return [{ storyId, ledgerState, expectedBoardStatus, boardStatus, boardLedgerState, stateDrift, missingOnBoard, extraOnBoard }];
60
+ });
61
+ return {
62
+ mismatches,
63
+ ledgerStateUnsupported,
64
+ stateDriftCount: mismatches.filter((mismatch) => mismatch.stateDrift).length,
65
+ };
66
+ }
39
67
  /**
40
68
  * Keep only prd-sync task_status events whose Board status actually differs.
41
69
  * Missing Board rows stay (ensure will add them). Same-status replays of
@@ -1,6 +1,21 @@
1
1
  declare const BOARD_STORY_STATUSES: readonly ["queued", "in_progress", "review", "done"];
2
2
  export type BoardStoryStatus = (typeof BOARD_STORY_STATUSES)[number];
3
+ /**
4
+ * The ledger has more states than the Board. `blocked` and the review states
5
+ * need attention, while `dropped` is terminal; the Board has only one terminal
6
+ * column (`done`).
7
+ */
8
+ export declare const LEDGER_STATES: readonly ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped"];
9
+ export declare const ACCEPTED_LEDGER_STATES: readonly ["todo", "in_progress", "review", "waiting", "blocked", "done", "dropped", "in_review", "verification"];
10
+ export declare class UnknownProjectStoryStateError extends Error {
11
+ readonly source: string;
12
+ readonly storyId: string;
13
+ readonly value: unknown;
14
+ readonly code = "PROJECT_STORY_STATE_INVALID";
15
+ constructor(source: string, storyId: string, value: unknown);
16
+ }
3
17
  export declare function mapSourceStoryStatus(story: {
18
+ id?: unknown;
4
19
  status?: unknown;
5
20
  state?: unknown;
6
21
  }, source: string): BoardStoryStatus | undefined;
@@ -12,6 +27,7 @@ export declare function mapSourceStoryStatus(story: {
12
27
  export declare function mapReaderStoryStatus(story: {
13
28
  status?: unknown;
14
29
  state?: unknown;
30
+ ledgerState?: unknown;
15
31
  }): string | undefined;
16
32
  export {};
17
33
  //# sourceMappingURL=story-status.d.ts.map
@@ -5,27 +5,51 @@ const boardStoryStatusSet = new Set(BOARD_STORY_STATUSES);
5
5
  * need attention, while `dropped` is terminal; the Board has only one terminal
6
6
  * column (`done`).
7
7
  */
8
+ export const LEDGER_STATES = [
9
+ "todo", "in_progress", "review", "waiting", "blocked", "done", "dropped",
10
+ ];
8
11
  const LEDGER_STATE_TO_BOARD_STATUS = {
9
12
  todo: "queued",
10
13
  in_progress: "in_progress",
14
+ review: "review",
15
+ // The four-column Board cannot represent waiting separately. Preserve the
16
+ // ledger state on each story so consumers can distinguish it from blocked.
17
+ waiting: "queued",
11
18
  in_review: "review",
12
19
  verification: "review",
13
20
  blocked: "review",
14
21
  done: "done",
15
22
  dropped: "done",
16
23
  };
24
+ export const ACCEPTED_LEDGER_STATES = [
25
+ ...LEDGER_STATES, "in_review", "verification",
26
+ ];
27
+ export class UnknownProjectStoryStateError extends Error {
28
+ source;
29
+ storyId;
30
+ value;
31
+ code = "PROJECT_STORY_STATE_INVALID";
32
+ constructor(source, storyId, value) {
33
+ super(`${source}.state for story ${storyId || "<missing id>"} has unsupported value ${JSON.stringify(value)}; accepted: ${ACCEPTED_LEDGER_STATES.join("|")}.`);
34
+ this.source = source;
35
+ this.storyId = storyId;
36
+ this.value = value;
37
+ this.name = "UnknownProjectStoryStateError";
38
+ }
39
+ }
17
40
  export function mapSourceStoryStatus(story, source) {
41
+ if (Object.hasOwn(story, "state")) {
42
+ if (typeof story.state !== "string" || !Object.hasOwn(LEDGER_STATE_TO_BOARD_STATUS, story.state.trim())) {
43
+ throw new UnknownProjectStoryStateError(source, "id" in story && typeof story.id === "string" ? story.id : "", story.state);
44
+ }
45
+ }
18
46
  if (story.status !== undefined && story.status !== null) {
19
47
  if (typeof story.status !== "string" || !boardStoryStatusSet.has(story.status.trim())) {
20
48
  throw new Error(`${source}.status must be queued|in_progress|review|done.`);
21
49
  }
22
50
  return story.status.trim();
23
51
  }
24
- if (Object.hasOwn(story, "state")) {
25
- if (typeof story.state !== "string" ||
26
- !Object.hasOwn(LEDGER_STATE_TO_BOARD_STATUS, story.state.trim())) {
27
- throw new Error(`${source}.state must be todo|in_progress|in_review|verification|blocked|done|dropped.`);
28
- }
52
+ if (typeof story.state === "string") {
29
53
  return LEDGER_STATE_TO_BOARD_STATUS[story.state.trim()];
30
54
  }
31
55
  return undefined;
@@ -39,9 +63,10 @@ export function mapReaderStoryStatus(story) {
39
63
  if (story.status !== undefined && story.status !== null) {
40
64
  return typeof story.status === "string" ? story.status : undefined;
41
65
  }
42
- if (typeof story.state !== "string")
66
+ const stateValue = story.state ?? story.ledgerState;
67
+ if (typeof stateValue !== "string")
43
68
  return undefined;
44
- const state = story.state.trim();
69
+ const state = stateValue.trim();
45
70
  return Object.hasOwn(LEDGER_STATE_TO_BOARD_STATUS, state)
46
71
  ? LEDGER_STATE_TO_BOARD_STATUS[state]
47
72
  : undefined;