@indigoai-us/hq-cli 5.108.0 → 5.108.2

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 (108) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/assets/mesh-daemon/README.md +23 -0
  3. package/assets/mesh-daemon/launchd.plist.template +33 -0
  4. package/assets/mesh-daemon/systemd.system.service.template +27 -0
  5. package/assets/mesh-daemon/systemd.user.service.template +18 -0
  6. package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +267 -0
  7. package/dist/commands/cloud.d.ts +6 -0
  8. package/dist/commands/cloud.js +26 -3
  9. package/dist/commands/core.js +5 -4
  10. package/dist/commands/mesh.d.ts +15 -0
  11. package/dist/commands/mesh.js +732 -4
  12. package/dist/commands/search.d.ts +23 -0
  13. package/dist/commands/search.js +42 -1
  14. package/dist/lib/doctor/checks/work-context.d.ts +23 -0
  15. package/dist/lib/doctor/checks/work-context.js +195 -0
  16. package/dist/lib/doctor/registry.js +4 -0
  17. package/dist/lib/mesh/api.d.ts +18 -0
  18. package/dist/lib/mesh/api.js +55 -0
  19. package/dist/lib/mesh/cache.js +35 -4
  20. package/dist/lib/mesh/client.d.ts +118 -0
  21. package/dist/lib/mesh/client.js +268 -0
  22. package/dist/lib/mesh/live/backoff.d.ts +16 -0
  23. package/dist/lib/mesh/live/backoff.js +20 -0
  24. package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
  25. package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
  26. package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
  27. package/dist/lib/mesh/live/daemon/credentials.js +227 -0
  28. package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
  29. package/dist/lib/mesh/live/daemon/doctor.js +139 -0
  30. package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
  31. package/dist/lib/mesh/live/daemon/index.js +14 -0
  32. package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
  33. package/dist/lib/mesh/live/daemon/install.js +330 -0
  34. package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
  35. package/dist/lib/mesh/live/daemon/log.js +79 -0
  36. package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
  37. package/dist/lib/mesh/live/daemon/paths.js +31 -0
  38. package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
  39. package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
  40. package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
  41. package/dist/lib/mesh/live/daemon/presence.js +250 -0
  42. package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
  43. package/dist/lib/mesh/live/daemon/presign.js +65 -0
  44. package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
  45. package/dist/lib/mesh/live/daemon/run.js +400 -0
  46. package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
  47. package/dist/lib/mesh/live/daemon/state.js +57 -0
  48. package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
  49. package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
  50. package/dist/lib/mesh/live/enqueue.d.ts +48 -0
  51. package/dist/lib/mesh/live/enqueue.js +100 -0
  52. package/dist/lib/mesh/live/flush.d.ts +56 -0
  53. package/dist/lib/mesh/live/flush.js +360 -0
  54. package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
  55. package/dist/lib/mesh/live/format-spool-line.js +106 -0
  56. package/dist/lib/mesh/live/index.d.ts +22 -0
  57. package/dist/lib/mesh/live/index.js +16 -0
  58. package/dist/lib/mesh/live/paths.d.ts +13 -0
  59. package/dist/lib/mesh/live/paths.js +30 -0
  60. package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
  61. package/dist/lib/mesh/live/session-events-client.js +120 -0
  62. package/dist/lib/mesh/live/session-identity.d.ts +39 -0
  63. package/dist/lib/mesh/live/session-identity.js +91 -0
  64. package/dist/lib/mesh/live/spool.d.ts +52 -0
  65. package/dist/lib/mesh/live/spool.js +193 -0
  66. package/dist/lib/mesh/live/ulid.d.ts +12 -0
  67. package/dist/lib/mesh/live/ulid.js +39 -0
  68. package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
  69. package/dist/lib/mesh/live/validate-session-event.js +128 -0
  70. package/dist/lib/search-index/index.d.ts +45 -0
  71. package/dist/lib/search-index/index.js +76 -0
  72. package/dist/lib/work-context/atomic.d.ts +18 -0
  73. package/dist/lib/work-context/atomic.js +132 -0
  74. package/dist/lib/work-context/company.d.ts +103 -0
  75. package/dist/lib/work-context/company.js +274 -0
  76. package/dist/lib/work-context/config.d.ts +59 -0
  77. package/dist/lib/work-context/config.js +172 -0
  78. package/dist/lib/work-context/contract.d.ts +81 -0
  79. package/dist/lib/work-context/contract.js +65 -0
  80. package/dist/lib/work-context/errors.d.ts +27 -0
  81. package/dist/lib/work-context/errors.js +46 -0
  82. package/dist/lib/work-context/index.d.ts +23 -0
  83. package/dist/lib/work-context/index.js +20 -0
  84. package/dist/lib/work-context/migrate.d.ts +84 -0
  85. package/dist/lib/work-context/migrate.js +287 -0
  86. package/dist/lib/work-context/organize.d.ts +130 -0
  87. package/dist/lib/work-context/organize.js +471 -0
  88. package/dist/lib/work-context/outbox.d.ts +97 -0
  89. package/dist/lib/work-context/outbox.js +357 -0
  90. package/dist/lib/work-context/paths.d.ts +14 -0
  91. package/dist/lib/work-context/paths.js +39 -0
  92. package/dist/lib/work-context/project.d.ts +78 -0
  93. package/dist/lib/work-context/project.js +247 -0
  94. package/dist/lib/work-context/reconcile.d.ts +85 -0
  95. package/dist/lib/work-context/reconcile.js +651 -0
  96. package/dist/lib/work-context/repo-remote.d.ts +43 -0
  97. package/dist/lib/work-context/repo-remote.js +200 -0
  98. package/dist/lib/work-context/state.d.ts +112 -0
  99. package/dist/lib/work-context/state.js +162 -0
  100. package/dist/main.js +40 -3
  101. package/dist/utils/cognito-session.d.ts +85 -9
  102. package/dist/utils/cognito-session.js +216 -9
  103. package/dist/utils/qmd-model-download-error.d.ts +27 -0
  104. package/dist/utils/qmd-model-download-error.js +114 -0
  105. package/dist/utils/qmd-query-document-error.d.ts +23 -0
  106. package/dist/utils/qmd-query-document-error.js +117 -0
  107. package/dist/utils/sentry-fingerprint.js +2 -0
  108. package/package.json +5 -1
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Shared work-mesh transport client.
3
+ * Existing `hq mesh` verbs and work-context reconcile both use this for
4
+ * vault HTTP + receipt-shaped responses (US-007A / US-007B).
5
+ */
6
+ import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, } from "./api.js";
7
+ export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
8
+ export async function openMeshTransport(opts) {
9
+ const token = await requireToken();
10
+ const company = await resolveMeshCompany(token, opts.company);
11
+ return { token, company };
12
+ }
13
+ /**
14
+ * POST work-session registration for a queued outbox operation.
15
+ * Maps HTTP status to retryable vs quarantine.
16
+ */
17
+ export function createWorkSessionDeliverer(opts) {
18
+ return async (op) => {
19
+ const companyUid = op.companyUid;
20
+ if (!companyUid) {
21
+ return { ok: false, retryable: false, code: "COMPANY_UID_REQUIRED" };
22
+ }
23
+ const body = {
24
+ contractVersion: 1,
25
+ companyUid,
26
+ sessionId: op.sessionId,
27
+ clientOperationId: op.clientOperationId,
28
+ operationId: op.operationId,
29
+ digest: op.digest,
30
+ contextStatus: op.projectId && op.taskId ? "bound" : op.projectId ? "needs_task" : "unresolved",
31
+ };
32
+ if (op.projectId)
33
+ body.projectId = op.projectId;
34
+ if (op.taskId)
35
+ body.taskId = op.taskId;
36
+ try {
37
+ if (opts.post) {
38
+ const res = await opts.post("/v1/work-mesh/work-sessions", body);
39
+ return mapRegisterResponse(res.status, res.body, op.operationId);
40
+ }
41
+ const data = await meshJson(opts.token, "/v1/work-mesh/work-sessions", {
42
+ method: "POST",
43
+ body,
44
+ });
45
+ return mapRegisterResponse(200, data, op.operationId);
46
+ }
47
+ catch (err) {
48
+ const message = err instanceof Error ? err.message : String(err);
49
+ // meshJson throws on !ok with error text; classify roughly.
50
+ if (/^401\b|^403\b|unauthorized|forbidden|auth/i.test(message)) {
51
+ return { ok: false, retryable: false, code: "AUTH_DENIED" };
52
+ }
53
+ if (/^400\b|^422\b|invalid|validation/i.test(message)) {
54
+ return { ok: false, retryable: false, code: "VALIDATION_FAILED" };
55
+ }
56
+ if (/^5\d\d\b|ECONN|ENOTFOUND|ETIMEDOUT|network|fetch failed/i.test(message)) {
57
+ return { ok: false, retryable: true, code: "NETWORK_OR_5XX" };
58
+ }
59
+ // Unknown: keep queued (fail open to retry, not quarantine).
60
+ return { ok: false, retryable: true, code: "TRANSPORT_ERROR" };
61
+ }
62
+ };
63
+ }
64
+ function mapRegisterResponse(status, body, operationId) {
65
+ if (status >= 200 && status < 300) {
66
+ const rec = body && typeof body === "object" ? body : {};
67
+ const receiptId = (typeof rec.receiptId === "string" && rec.receiptId) ||
68
+ (typeof rec.bindingEpisodeId === "string" && rec.bindingEpisodeId) ||
69
+ (typeof rec.operationId === "string" && rec.operationId) ||
70
+ operationId;
71
+ return { ok: true, receiptId };
72
+ }
73
+ if (status >= 500 || status === 429) {
74
+ return { ok: false, retryable: true, code: `HTTP_${status}` };
75
+ }
76
+ if (status === 401 || status === 403 || status === 400 || status === 422) {
77
+ return { ok: false, retryable: false, code: `HTTP_${status}` };
78
+ }
79
+ if (status === 0) {
80
+ return { ok: false, retryable: true, code: "NETWORK_UNAVAILABLE" };
81
+ }
82
+ return { ok: false, retryable: true, code: `HTTP_${status}` };
83
+ }
84
+ /**
85
+ * Probe whether the server advertises migration capability (US-017A).
86
+ * Failures return false (default-company stays locked).
87
+ */
88
+ export async function fetchMigrationCapability(token, companyUid) {
89
+ try {
90
+ const data = (await meshJson(token, "/v1/work-mesh/capabilities", {
91
+ query: { companyUid },
92
+ }));
93
+ return Boolean(data.migration || data.migrationCapability);
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ /**
100
+ * Unlock rule for default-company mode (US-017B): migration must be true for
101
+ * EVERY company the caller belongs to. Offline / empty memberships → locked.
102
+ */
103
+ export async function probeMigrationCapabilityForMemberships(token, opts) {
104
+ const now = (opts?.now ?? (() => new Date()))().toISOString();
105
+ const list = opts?.listCompanies ?? listActiveMembershipCompanies;
106
+ const fetchOne = opts?.fetchOne ?? fetchMigrationCapability;
107
+ let companies;
108
+ try {
109
+ companies = await list(token);
110
+ }
111
+ catch {
112
+ return { unlocked: false, offline: true, checkedAt: now, companies: [] };
113
+ }
114
+ if (companies.length === 0) {
115
+ return { unlocked: false, offline: false, checkedAt: now, companies: [] };
116
+ }
117
+ const results = [];
118
+ for (const c of companies) {
119
+ const migration = await fetchOne(token, c.companyUid);
120
+ results.push({
121
+ companyUid: c.companyUid,
122
+ companySlug: c.companySlug,
123
+ migration,
124
+ });
125
+ }
126
+ return {
127
+ unlocked: results.every((r) => r.migration),
128
+ offline: false,
129
+ checkedAt: now,
130
+ companies: results,
131
+ };
132
+ }
133
+ /**
134
+ * POST /v1/work-mesh/sessions/{sessionId}/migrate
135
+ */
136
+ export function createMigratePoster(opts) {
137
+ return async (input) => {
138
+ const path = `/v1/work-mesh/sessions/${encodeURIComponent(input.sessionId)}/migrate`;
139
+ const body = {
140
+ operationId: input.operationId,
141
+ digest: input.digest,
142
+ sourceCompanyUid: input.sourceCompanyUid,
143
+ destinationCompanyUid: input.destinationCompanyUid,
144
+ destination: input.destination,
145
+ expectedVersion: input.expectedVersion,
146
+ };
147
+ let status;
148
+ let data;
149
+ if (opts.post) {
150
+ const res = await opts.post(path, body);
151
+ status = res.status;
152
+ data = res.body;
153
+ }
154
+ else {
155
+ data = await meshJson(opts.token, path, { method: "POST", body });
156
+ status = 201;
157
+ }
158
+ if (status < 200 || status >= 300) {
159
+ const err = data && typeof data === "object" ? data : {};
160
+ const code = typeof err.code === "string" ? err.code : `HTTP_${status}`;
161
+ const message = typeof err.error === "string"
162
+ ? err.error
163
+ : typeof err.message === "string"
164
+ ? err.message
165
+ : code;
166
+ throw new Error(`${code}: ${message}`);
167
+ }
168
+ const rec = data;
169
+ if (!rec?.receipt?.operationId || !rec.destinationCompanyUid) {
170
+ throw new Error("RECEIPT_INCOMPLETE: migrate response missing receipt");
171
+ }
172
+ return rec;
173
+ };
174
+ }
175
+ /**
176
+ * GET /v1/work-mesh/sessions/{sessionId}/candidates?companyUid=...
177
+ */
178
+ export function createCandidatesFetcher(opts) {
179
+ return async ({ sessionId, companyUid }) => {
180
+ const path = `/v1/work-mesh/sessions/${encodeURIComponent(sessionId)}/candidates`;
181
+ const data = opts.get
182
+ ? await opts.get(path, { companyUid })
183
+ : await meshJson(opts.token, path, { query: { companyUid } });
184
+ return data;
185
+ };
186
+ }
187
+ /**
188
+ * POST /v1/work-mesh/sessions/{sessionId}/organize
189
+ */
190
+ export function createOrganizePoster(opts) {
191
+ return async (input) => {
192
+ const path = `/v1/work-mesh/sessions/${encodeURIComponent(input.sessionId)}/organize`;
193
+ const body = {
194
+ companyUid: input.companyUid,
195
+ operationId: input.operationId,
196
+ digest: input.digest,
197
+ decision: input.decision,
198
+ expectedVersion: input.expectedVersion,
199
+ };
200
+ if (input.decisionExpiresAt) {
201
+ body.decisionExpiresAt = input.decisionExpiresAt;
202
+ }
203
+ let status;
204
+ let data;
205
+ if (opts.post) {
206
+ const res = await opts.post(path, body);
207
+ status = res.status;
208
+ data = res.body;
209
+ }
210
+ else {
211
+ data = await meshJson(opts.token, path, { method: "POST", body });
212
+ status = 201;
213
+ }
214
+ if (status < 200 || status >= 300) {
215
+ const err = data && typeof data === "object" ? data : {};
216
+ const code = typeof err.code === "string" ? err.code : `HTTP_${status}`;
217
+ const message = typeof err.error === "string"
218
+ ? err.error
219
+ : typeof err.message === "string"
220
+ ? err.message
221
+ : code;
222
+ throw new Error(`${code}: ${message}`);
223
+ }
224
+ const rec = data;
225
+ if (!rec?.receipt?.operationId || !rec.projectId || !rec.bindingEpisodeId) {
226
+ throw new Error("RECEIPT_INCOMPLETE: organize response missing receipt");
227
+ }
228
+ return rec;
229
+ };
230
+ }
231
+ /** GET /v1/work-mesh/live?companyUid=... */
232
+ export async function fetchCompanyLive(token, companyUid, opts) {
233
+ const query = { companyUid };
234
+ if (opts?.cursor)
235
+ query.cursor = opts.cursor;
236
+ const data = opts?.get
237
+ ? await opts.get("/v1/work-mesh/live", query)
238
+ : await meshJson(token, "/v1/work-mesh/live", { query });
239
+ return data;
240
+ }
241
+ export function formatCompanyLiveTable(live, companyLabel) {
242
+ const lines = [
243
+ `Work mesh live: ${companyLabel} generatedAt=${live.generatedAt}`,
244
+ "actorUid\tpresence\tdisplayName\tsessionId\tstatus\tcontext\tproject\ttask\tturns",
245
+ ];
246
+ for (const p of live.participants ?? []) {
247
+ const sessions = p.sessions ?? [];
248
+ if (sessions.length === 0) {
249
+ lines.push(`${p.actorUid}\t${p.presence ?? "?"}\t${p.displayName ?? ""}\t-\t-\t-\t-\t-\t-`);
250
+ continue;
251
+ }
252
+ for (const s of sessions) {
253
+ lines.push([
254
+ p.actorUid,
255
+ p.presence ?? "?",
256
+ p.displayName ?? "",
257
+ s.sessionId,
258
+ s.status ?? "?",
259
+ s.contextStatus ?? "?",
260
+ s.projectId ?? "",
261
+ s.taskId ?? "",
262
+ String(s.turnCount ?? 0),
263
+ ].join("\t"));
264
+ }
265
+ }
266
+ return lines;
267
+ }
268
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Full-jitter exponential backoff (base 1s, cap 60s).
3
+ * sleep = random_uniform(0, min(cap, base * 2^attempt))
4
+ */
5
+ export declare const BACKOFF_BASE_MS = 1000;
6
+ export declare const BACKOFF_CAP_MS = 60000;
7
+ export declare const FLUSH_MAX_ATTEMPTS = 5;
8
+ export type RandomFn = () => number;
9
+ export type SleepFn = (ms: number) => Promise<void>;
10
+ export declare function fullJitterDelayMs(attempt: number, opts?: {
11
+ baseMs?: number;
12
+ capMs?: number;
13
+ random?: RandomFn;
14
+ }): number;
15
+ export declare function defaultSleep(ms: number): Promise<void>;
16
+ //# sourceMappingURL=backoff.d.ts.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Full-jitter exponential backoff (base 1s, cap 60s).
3
+ * sleep = random_uniform(0, min(cap, base * 2^attempt))
4
+ */
5
+ export const BACKOFF_BASE_MS = 1000;
6
+ export const BACKOFF_CAP_MS = 60_000;
7
+ export const FLUSH_MAX_ATTEMPTS = 5;
8
+ export function fullJitterDelayMs(attempt, opts = {}) {
9
+ const base = opts.baseMs ?? BACKOFF_BASE_MS;
10
+ const cap = opts.capMs ?? BACKOFF_CAP_MS;
11
+ const random = opts.random ?? Math.random;
12
+ const exp = Math.min(cap, base * 2 ** attempt);
13
+ return Math.floor(random() * exp);
14
+ }
15
+ export async function defaultSleep(ms) {
16
+ if (ms <= 0)
17
+ return;
18
+ await new Promise((resolve) => setTimeout(resolve, ms));
19
+ }
20
+ //# sourceMappingURL=backoff.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
+ * from GET /companies/{companyUid}/board at most every 60s.
4
+ * Metadata-only snapshot — never prompts or tokens.
5
+ */
6
+ export declare const BOARD_REFRESH_INTERVAL_MS = 60000;
7
+ export interface BoardStorySnapshot {
8
+ id: string;
9
+ title?: string;
10
+ status?: string;
11
+ }
12
+ export interface BoardReader {
13
+ (companyUid: string, projectId: string): Promise<{
14
+ projectId: string;
15
+ projectName?: string;
16
+ stories: BoardStorySnapshot[];
17
+ } | null>;
18
+ }
19
+ export interface BoardRefreshDeps {
20
+ workContextRoot: string;
21
+ readBoard: BoardReader;
22
+ now?: () => number;
23
+ /** Per-session last refresh timestamps (ms). */
24
+ lastRefresh?: Map<string, number>;
25
+ intervalMs?: number;
26
+ }
27
+ export declare function formatBoardMarkdown(input: {
28
+ sessionId: string;
29
+ companyUid: string;
30
+ projectId: string;
31
+ projectName?: string;
32
+ taskId?: string;
33
+ stories: BoardStorySnapshot[];
34
+ refreshedAt: string;
35
+ }): string;
36
+ export declare function writeBoardMarkdown(workContextRoot: string, sessionId: string, markdown: string): string;
37
+ /**
38
+ * Refresh board.md for bound sessions whose last refresh is older than interval.
39
+ */
40
+ export declare function refreshBoundSessionBoards(deps: BoardRefreshDeps): Promise<{
41
+ refreshed: number;
42
+ skipped: number;
43
+ errors: number;
44
+ }>;
45
+ /** Build a BoardReader from meshJson-compatible fetch. */
46
+ export declare function createVaultBoardReader(opts: {
47
+ getJson: (path: string) => Promise<unknown>;
48
+ }): BoardReader;
49
+ //# sourceMappingURL=board-refresh.d.ts.map
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Refresh ~/.hq/work-context/sessions/<sid>/board.md for bound sessions
3
+ * from GET /companies/{companyUid}/board at most every 60s.
4
+ * Metadata-only snapshot — never prompts or tokens.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { workContextSessionsDir } from "../../../work-context/paths.js";
9
+ import { readSessionState } from "../../../work-context/state.js";
10
+ import { sessionBoardPath } from "./paths.js";
11
+ export const BOARD_REFRESH_INTERVAL_MS = 60_000;
12
+ function listBoundSessionIds(workContextRoot) {
13
+ const dir = workContextSessionsDir(workContextRoot);
14
+ if (!fs.existsSync(dir))
15
+ return [];
16
+ const out = [];
17
+ for (const name of fs.readdirSync(dir)) {
18
+ if (!name.endsWith(".json"))
19
+ continue;
20
+ const sid = name.slice(0, -".json".length);
21
+ const state = readSessionState(sid, workContextRoot);
22
+ if (state?.contextStatus === "bound" && state.companyUid && state.projectId) {
23
+ out.push(sid);
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+ export function formatBoardMarkdown(input) {
29
+ const lines = [
30
+ `# Board snapshot`,
31
+ ``,
32
+ `- sessionId: ${input.sessionId}`,
33
+ `- companyUid: ${input.companyUid}`,
34
+ `- projectId: ${input.projectId}`,
35
+ ];
36
+ if (input.projectName)
37
+ lines.push(`- projectName: ${input.projectName}`);
38
+ if (input.taskId)
39
+ lines.push(`- taskId: ${input.taskId}`);
40
+ lines.push(`- refreshedAt: ${input.refreshedAt}`, ``, `## Stories`, ``);
41
+ if (input.stories.length === 0) {
42
+ lines.push(`_(no stories)_`, ``);
43
+ }
44
+ else {
45
+ for (const s of input.stories.slice(0, 50)) {
46
+ const title = s.title ? ` — ${s.title}` : "";
47
+ const status = s.status ? ` [${s.status}]` : "";
48
+ lines.push(`- ${s.id}${status}${title}`);
49
+ }
50
+ lines.push(``);
51
+ }
52
+ return lines.join("\n");
53
+ }
54
+ export function writeBoardMarkdown(workContextRoot, sessionId, markdown) {
55
+ const dest = sessionBoardPath(workContextRoot, sessionId);
56
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
57
+ const tmp = `${dest}.tmp.${process.pid}`;
58
+ fs.writeFileSync(tmp, markdown, { mode: 0o600 });
59
+ fs.renameSync(tmp, dest);
60
+ try {
61
+ fs.chmodSync(dest, 0o600);
62
+ }
63
+ catch {
64
+ /* best-effort */
65
+ }
66
+ return dest;
67
+ }
68
+ /**
69
+ * Refresh board.md for bound sessions whose last refresh is older than interval.
70
+ */
71
+ export async function refreshBoundSessionBoards(deps) {
72
+ const now = deps.now ?? Date.now;
73
+ const interval = deps.intervalMs ?? BOARD_REFRESH_INTERVAL_MS;
74
+ const last = deps.lastRefresh ?? new Map();
75
+ let refreshed = 0;
76
+ let skipped = 0;
77
+ let errors = 0;
78
+ for (const sessionId of listBoundSessionIds(deps.workContextRoot)) {
79
+ const prev = last.get(sessionId);
80
+ if (prev !== undefined && now() - prev < interval) {
81
+ skipped += 1;
82
+ continue;
83
+ }
84
+ const state = readSessionState(sessionId, deps.workContextRoot);
85
+ if (!state ||
86
+ state.contextStatus !== "bound" ||
87
+ !state.companyUid ||
88
+ !state.projectId) {
89
+ skipped += 1;
90
+ continue;
91
+ }
92
+ try {
93
+ const board = await deps.readBoard(state.companyUid, state.projectId);
94
+ if (!board) {
95
+ skipped += 1;
96
+ last.set(sessionId, now());
97
+ continue;
98
+ }
99
+ const md = formatBoardMarkdown({
100
+ sessionId,
101
+ companyUid: state.companyUid,
102
+ projectId: state.projectId,
103
+ projectName: board.projectName,
104
+ taskId: state.taskId,
105
+ stories: board.stories,
106
+ refreshedAt: new Date(now()).toISOString(),
107
+ });
108
+ writeBoardMarkdown(deps.workContextRoot, sessionId, md);
109
+ last.set(sessionId, now());
110
+ refreshed += 1;
111
+ }
112
+ catch {
113
+ errors += 1;
114
+ }
115
+ }
116
+ return { refreshed, skipped, errors };
117
+ }
118
+ /** Build a BoardReader from meshJson-compatible fetch. */
119
+ export function createVaultBoardReader(opts) {
120
+ return async (companyUid, projectId) => {
121
+ const raw = await opts.getJson(`/companies/${encodeURIComponent(companyUid)}/board`);
122
+ if (!raw || typeof raw !== "object")
123
+ return null;
124
+ const rec = raw;
125
+ const projects = Array.isArray(rec.projects)
126
+ ? rec.projects
127
+ : Array.isArray(rec.board) &&
128
+ typeof rec.board[0] === "object"
129
+ ? rec.board
130
+ : [];
131
+ // Also accept ACL-scoped model: { projects: [...] } or nested board.projects
132
+ const nested = rec.board && typeof rec.board === "object" && !Array.isArray(rec.board)
133
+ ? rec.board.projects
134
+ : undefined;
135
+ const list = Array.isArray(nested) ? nested : projects;
136
+ for (const item of list) {
137
+ if (!item || typeof item !== "object" || Array.isArray(item))
138
+ continue;
139
+ const p = item;
140
+ const id = typeof p.id === "string" ? p.id : typeof p.projectId === "string" ? p.projectId : "";
141
+ if (id !== projectId)
142
+ continue;
143
+ const storiesRaw = Array.isArray(p.stories)
144
+ ? p.stories
145
+ : Array.isArray(p.tasks)
146
+ ? p.tasks
147
+ : [];
148
+ const stories = [];
149
+ for (const s of storiesRaw) {
150
+ if (!s || typeof s !== "object" || Array.isArray(s))
151
+ continue;
152
+ const row = s;
153
+ const sid = typeof row.id === "string"
154
+ ? row.id
155
+ : typeof row.storyId === "string"
156
+ ? row.storyId
157
+ : typeof row.taskId === "string"
158
+ ? row.taskId
159
+ : "";
160
+ if (!sid)
161
+ continue;
162
+ stories.push({
163
+ id: sid,
164
+ title: typeof row.title === "string"
165
+ ? row.title.slice(0, 120)
166
+ : typeof row.name === "string"
167
+ ? row.name.slice(0, 120)
168
+ : undefined,
169
+ status: typeof row.status === "string" ? row.status : undefined,
170
+ });
171
+ }
172
+ return {
173
+ projectId,
174
+ projectName: typeof p.name === "string"
175
+ ? p.name
176
+ : typeof p.title === "string"
177
+ ? p.title
178
+ : undefined,
179
+ stories,
180
+ };
181
+ }
182
+ return { projectId, stories: [] };
183
+ };
184
+ }
185
+ //# sourceMappingURL=board-refresh.js.map
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Contract-3 realtime credential vend + 80%-of-lifetime renewal.
3
+ * Never logs or persists credential material.
4
+ */
5
+ import type { IotCredentials } from "./presign.js";
6
+ export declare const REALTIME_CREDENTIALS_PATH = "/v1/realtime/credentials";
7
+ export declare const MQTT_KEEPALIVE_SECONDS = 15;
8
+ /** Renew at 80% of credential lifetime. */
9
+ export declare const CREDENTIAL_RENEWAL_FRACTION = 0.8;
10
+ export declare const MIN_RENEWAL_DELAY_MS = 5000;
11
+ export interface PresenceCompany {
12
+ companyUid: string;
13
+ presenceTopic: string;
14
+ }
15
+ export interface Contract3Bundle {
16
+ contractVersion: 3;
17
+ credentials: IotCredentials;
18
+ iotEndpoint: string;
19
+ region: string;
20
+ clientId: string;
21
+ actorUid: string;
22
+ actorType: "human" | "agent";
23
+ companies: PresenceCompany[];
24
+ droppedCompanies: string[];
25
+ expiresAt: string;
26
+ }
27
+ export type CredentialsFetcher = () => Promise<Contract3Bundle>;
28
+ export interface TimerHost {
29
+ setTimeout(fn: () => void, ms: number): unknown;
30
+ clearTimeout(handle: unknown): void;
31
+ now(): number;
32
+ }
33
+ export declare const realTimerHost: TimerHost;
34
+ export declare function renewalDelayMs(nowMs: number, expirationIso: string): number;
35
+ export declare function normalizeContract3Bundle(raw: unknown): Contract3Bundle;
36
+ /**
37
+ * When `HQ_AGENT_IDENTITY_FILE` names a companyUid, keep only that company
38
+ * from the vend (must be present) and refuse every other company. Presence
39
+ * and session events then attribute to the box agent for its company only.
40
+ * No identity file → return the bundle unchanged (person laptop).
41
+ */
42
+ export declare function scopeBundleToAgentIdentity(bundle: Contract3Bundle, env?: NodeJS.ProcessEnv): Contract3Bundle;
43
+ export declare function createContract3Fetcher(opts: {
44
+ token: string;
45
+ baseUrl?: string;
46
+ post?: (path: string, body: unknown) => Promise<{
47
+ status: number;
48
+ body: unknown;
49
+ }>;
50
+ /** Process env for identity-file scoping (tests). */
51
+ env?: NodeJS.ProcessEnv;
52
+ }): CredentialsFetcher;
53
+ /**
54
+ * Proactive renewal: at 80% of lifetime, fetch fresh creds and call onRenewed.
55
+ * Does not drop the connection itself — caller reconnects with the new URL.
56
+ */
57
+ export declare class CredentialRenewalManager {
58
+ private readonly fetchCredentials;
59
+ private readonly onRenewed;
60
+ private readonly onError;
61
+ private readonly timers;
62
+ private readonly retryDelayMs;
63
+ private handle;
64
+ private stopped;
65
+ constructor(fetchCredentials: CredentialsFetcher, onRenewed: (bundle: Contract3Bundle) => void, onError?: (err: unknown) => void, timers?: TimerHost, retryDelayMs?: number);
66
+ schedule(bundle: Contract3Bundle): void;
67
+ private renew;
68
+ stop(): void;
69
+ private clear;
70
+ }
71
+ //# sourceMappingURL=credentials.d.ts.map