@indigoai-us/hq-cli 5.168.0 → 5.170.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/dist/commands/lanes.js +144 -31
- package/dist/lib/lanes/mesh-events.d.ts +4 -0
- package/dist/lib/lanes/mesh-events.js +72 -4
- package/dist/lib/mesh/live/flush.js +160 -11
- package/dist/lib/work-context/company-rebind.d.ts +23 -0
- package/dist/lib/work-context/company-rebind.js +59 -0
- package/dist/lib/work-context/company.d.ts +3 -2
- package/dist/lib/work-context/company.js +14 -6
- package/dist/lib/work-context/index.d.ts +1 -0
- package/dist/lib/work-context/index.js +1 -0
- package/dist/lib/work-context/reconcile.js +68 -5
- package/dist/lib/work-context/state.d.ts +11 -0
- package/dist/lib/work-context/state.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.170.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
|
|
10
|
+
|
|
11
|
+
## [5.169.0] — 2026-09-24
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `hq lanes create` and `hq lanes story <add|start|block|done>` keep story work
|
|
16
|
+
local when an existing Work Mesh Board rejects a missing story. The
|
|
17
|
+
`story_warning` identifies the project and story and suggests:
|
|
18
|
+
`hq mesh project ensure <projectId> --company <slug> --stories-file prd.json`.
|
|
19
|
+
|
|
5
20
|
## [5.168.0] — 2026-09-24
|
|
6
21
|
|
|
7
22
|
### Fixed
|
package/dist/commands/lanes.js
CHANGED
|
@@ -1340,13 +1340,23 @@ async function emitLaneTerminalStateSoft(ctx, lane) {
|
|
|
1340
1340
|
}
|
|
1341
1341
|
}
|
|
1342
1342
|
async function emitLaneStartedForStories(ctx, company, lane, threadId) {
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1343
|
+
let ready = lane;
|
|
1344
|
+
if (ctx.mesh.ensureLaneThread) {
|
|
1345
|
+
try {
|
|
1346
|
+
ready = await ensureLaneThreadRecord(ctx, lane, lane.project_id, lane.story_id, "running");
|
|
1347
|
+
}
|
|
1348
|
+
catch (err) {
|
|
1349
|
+
const warning = storyNotOnBoardWarning(err);
|
|
1350
|
+
if (warning !== undefined)
|
|
1351
|
+
return [warning];
|
|
1352
|
+
throw err;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1346
1355
|
const stableThreadId = ready.thread_id ?? threadId;
|
|
1347
1356
|
if (!stableThreadId)
|
|
1348
|
-
return;
|
|
1357
|
+
return [];
|
|
1349
1358
|
const failures = [];
|
|
1359
|
+
const warnings = [];
|
|
1350
1360
|
for (const story of laneStories(ready)) {
|
|
1351
1361
|
try {
|
|
1352
1362
|
await emitLaneMeshEvent(ctx, company, stableThreadId, "lane_started", {
|
|
@@ -1355,14 +1365,59 @@ async function emitLaneStartedForStories(ctx, company, lane, threadId) {
|
|
|
1355
1365
|
project_id: story.project_id,
|
|
1356
1366
|
story_id: story.story_id,
|
|
1357
1367
|
});
|
|
1358
|
-
await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
|
|
1359
1368
|
}
|
|
1360
1369
|
catch (err) {
|
|
1370
|
+
const warning = storyNotOnBoardWarning(err);
|
|
1371
|
+
if (warning !== undefined) {
|
|
1372
|
+
warnings.push(warning);
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1361
1375
|
failures.push(err);
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
try {
|
|
1379
|
+
await ctx.mesh.patchStoryStatus(company, story.project_id, story.story_id, "in_progress");
|
|
1380
|
+
}
|
|
1381
|
+
catch (err) {
|
|
1382
|
+
const warning = storyNotOnBoardWarning(err);
|
|
1383
|
+
if (warning !== undefined)
|
|
1384
|
+
warnings.push(warning);
|
|
1385
|
+
else
|
|
1386
|
+
failures.push(err);
|
|
1362
1387
|
}
|
|
1363
1388
|
}
|
|
1364
1389
|
if (failures.length > 0)
|
|
1365
1390
|
throw failures[0];
|
|
1391
|
+
return warnings;
|
|
1392
|
+
}
|
|
1393
|
+
async function assertStoriesOnBoard(ctx, company, projectId, storyIds) {
|
|
1394
|
+
const check = ctx.mesh.assertProjectStoriesOnBoard;
|
|
1395
|
+
if (!check) {
|
|
1396
|
+
throw new LanesError("story_board_check_unavailable", "Cannot confirm stories are on the Work Mesh Board; refusing to write lane state", { project_id: projectId, story_id: storyIds[0] ?? null });
|
|
1397
|
+
}
|
|
1398
|
+
await check.call(ctx.mesh, company, projectId, storyIds);
|
|
1399
|
+
}
|
|
1400
|
+
function storyNotOnBoardWarning(err) {
|
|
1401
|
+
return isLanesError(err) && err.error === "story_not_on_board"
|
|
1402
|
+
? err.message
|
|
1403
|
+
: undefined;
|
|
1404
|
+
}
|
|
1405
|
+
function combinedStoryWarning(lane, additional = []) {
|
|
1406
|
+
const warnings = [storyWarning(lane), ...additional]
|
|
1407
|
+
.filter((warning) => warning !== undefined && warning.trim().length > 0);
|
|
1408
|
+
return [...new Set(warnings)].join(" ") || null;
|
|
1409
|
+
}
|
|
1410
|
+
async function storyBoardWarning(ctx, company, projectId, storyIds) {
|
|
1411
|
+
try {
|
|
1412
|
+
await assertStoriesOnBoard(ctx, company, projectId, storyIds);
|
|
1413
|
+
return undefined;
|
|
1414
|
+
}
|
|
1415
|
+
catch (err) {
|
|
1416
|
+
const warning = storyNotOnBoardWarning(err);
|
|
1417
|
+
if (warning !== undefined)
|
|
1418
|
+
return warning;
|
|
1419
|
+
throw err;
|
|
1420
|
+
}
|
|
1366
1421
|
}
|
|
1367
1422
|
export const PICKUP_TERMINAL_REASONS = [
|
|
1368
1423
|
"account_not_logged_in",
|
|
@@ -1631,6 +1686,12 @@ async function runCreate(ctx, opts) {
|
|
|
1631
1686
|
}
|
|
1632
1687
|
const seniorCompany = companyOfSenior(hqRoot, senior, ctx);
|
|
1633
1688
|
assertSameTenant(company, senior, seniorCompany);
|
|
1689
|
+
const createStoryWarnings = [];
|
|
1690
|
+
if (meshCompany) {
|
|
1691
|
+
const warning = await storyBoardWarning(ctx, meshCompany, project, storyIds);
|
|
1692
|
+
if (warning !== undefined)
|
|
1693
|
+
createStoryWarnings.push(warning);
|
|
1694
|
+
}
|
|
1634
1695
|
let resolvedWorker;
|
|
1635
1696
|
if (opts.worker) {
|
|
1636
1697
|
resolvedWorker = resolveLaneWorker(hqRoot, opts.worker, company?.slug);
|
|
@@ -2079,19 +2140,25 @@ async function runCreate(ctx, opts) {
|
|
|
2079
2140
|
try {
|
|
2080
2141
|
lane = await ensureLaneThreadRecord(ctx, lane);
|
|
2081
2142
|
}
|
|
2082
|
-
catch {
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
state: "failed",
|
|
2087
|
-
queued_reason: undefined,
|
|
2088
|
-
terminal_reason: "mesh_thread_registration_failed",
|
|
2089
|
-
}));
|
|
2143
|
+
catch (err) {
|
|
2144
|
+
const warning = storyNotOnBoardWarning(err);
|
|
2145
|
+
if (warning !== undefined) {
|
|
2146
|
+
createStoryWarnings.push(warning);
|
|
2090
2147
|
}
|
|
2091
|
-
|
|
2092
|
-
|
|
2148
|
+
else {
|
|
2149
|
+
try {
|
|
2150
|
+
lane = casUpdateLane(ctx.hqRoot(), lane.lane_id, (current) => ({
|
|
2151
|
+
...current,
|
|
2152
|
+
state: "failed",
|
|
2153
|
+
queued_reason: undefined,
|
|
2154
|
+
terminal_reason: "mesh_thread_registration_failed",
|
|
2155
|
+
}));
|
|
2156
|
+
}
|
|
2157
|
+
catch {
|
|
2158
|
+
throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed for ${lane.lane_id}, and its local record could not be marked terminal.`, { lane_id: lane.lane_id });
|
|
2159
|
+
}
|
|
2160
|
+
throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed; lane ${lane.lane_id} was marked failed.`, { lane_id: lane.lane_id, state: lane.state });
|
|
2093
2161
|
}
|
|
2094
|
-
throw new LanesError("mesh_thread_registration_failed", `Work Mesh lane registration failed; lane ${lane.lane_id} was marked failed.`, { lane_id: lane.lane_id, state: lane.state });
|
|
2095
2162
|
}
|
|
2096
2163
|
}
|
|
2097
2164
|
// Thread registration runs after persistence; anchor the reservation after
|
|
@@ -2137,8 +2204,9 @@ async function runCreate(ctx, opts) {
|
|
|
2137
2204
|
runCompaction: ctx.runCompaction,
|
|
2138
2205
|
locateTranscript: ctx.locateTranscript,
|
|
2139
2206
|
});
|
|
2140
|
-
if (meshCompany)
|
|
2141
|
-
await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id);
|
|
2207
|
+
if (meshCompany) {
|
|
2208
|
+
createStoryWarnings.push(...await emitLaneStartedForStories(ctx, meshCompany, started, started.thread_id));
|
|
2209
|
+
}
|
|
2142
2210
|
}
|
|
2143
2211
|
else if (outpostId) {
|
|
2144
2212
|
started = await dispatchCreatedOutpostLane(ctx, {
|
|
@@ -2174,7 +2242,7 @@ async function runCreate(ctx, opts) {
|
|
|
2174
2242
|
project_id: started.project_id,
|
|
2175
2243
|
story_id: started.story_id,
|
|
2176
2244
|
stories: laneStories(started),
|
|
2177
|
-
story_warning:
|
|
2245
|
+
story_warning: combinedStoryWarning(started, createStoryWarnings),
|
|
2178
2246
|
thread_id: started.thread_id ?? null,
|
|
2179
2247
|
senior: started.senior,
|
|
2180
2248
|
depth: started.depth,
|
|
@@ -4138,6 +4206,17 @@ async function ensureStoryThread(ctx, lane, projectId, storyId = lane.story_id)
|
|
|
4138
4206
|
return lane;
|
|
4139
4207
|
return ensureLaneThreadRecord(ctx, lane, projectId, storyId);
|
|
4140
4208
|
}
|
|
4209
|
+
async function ensureStoryThreadForTransition(ctx, lane, projectId, storyId) {
|
|
4210
|
+
try {
|
|
4211
|
+
return { lane: await ensureStoryThread(ctx, lane, projectId, storyId) };
|
|
4212
|
+
}
|
|
4213
|
+
catch (err) {
|
|
4214
|
+
const warning = storyNotOnBoardWarning(err);
|
|
4215
|
+
if (warning === undefined)
|
|
4216
|
+
throw err;
|
|
4217
|
+
return { lane, warning };
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4141
4220
|
async function ensureLaneThreadRecord(ctx, lane, projectId = lane.project_id, storyId = lane.story_id, laneStatus = lane.state) {
|
|
4142
4221
|
const company = lane.mesh_company ?? lane.company;
|
|
4143
4222
|
if (!company)
|
|
@@ -4185,8 +4264,12 @@ function sameCommittedStory(current, expected, projectId, storyId) {
|
|
|
4185
4264
|
async function commitStoryTransition(ctx, before, next, projectId, storyId, event, status, payload = {}) {
|
|
4186
4265
|
try {
|
|
4187
4266
|
await emitStoryTransition(ctx, next, projectId, storyId, event, status, payload);
|
|
4267
|
+
return undefined;
|
|
4188
4268
|
}
|
|
4189
4269
|
catch (err) {
|
|
4270
|
+
const warning = storyNotOnBoardWarning(err);
|
|
4271
|
+
if (warning !== undefined)
|
|
4272
|
+
return warning;
|
|
4190
4273
|
try {
|
|
4191
4274
|
const current = readLane(ctx.hqRoot(), next.lane_id);
|
|
4192
4275
|
if (!sameCommittedStory(current, next, projectId, storyId)) {
|
|
@@ -4231,14 +4314,26 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
4231
4314
|
const normalizedStoryId = storyId.trim();
|
|
4232
4315
|
if (!normalizedStoryId)
|
|
4233
4316
|
throw new LanesError("missing_story", "<story-id> is required", {});
|
|
4317
|
+
const company = lane.mesh_company ?? lane.company;
|
|
4318
|
+
const warnings = [];
|
|
4319
|
+
if (company) {
|
|
4320
|
+
const warning = await storyBoardWarning(ctx, company, projectId, [normalizedStoryId]);
|
|
4321
|
+
if (warning !== undefined)
|
|
4322
|
+
warnings.push(warning);
|
|
4323
|
+
}
|
|
4234
4324
|
const at = ctx.now().toISOString();
|
|
4235
|
-
|
|
4325
|
+
const thread = await ensureStoryThreadForTransition(ctx, lane, projectId, normalizedStoryId);
|
|
4326
|
+
lane = thread.lane;
|
|
4327
|
+
if (thread.warning !== undefined)
|
|
4328
|
+
warnings.push(thread.warning);
|
|
4236
4329
|
const before = lane;
|
|
4237
4330
|
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4238
4331
|
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4239
4332
|
return addCarriedStory(current, projectId, normalizedStoryId, at);
|
|
4240
4333
|
});
|
|
4241
|
-
await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
|
|
4334
|
+
const transitionWarning = await commitStoryTransition(ctx, before, next, projectId, normalizedStoryId, "lane_started", "in_progress");
|
|
4335
|
+
if (transitionWarning !== undefined)
|
|
4336
|
+
warnings.push(transitionWarning);
|
|
4242
4337
|
const finalLane = readLane(hqRoot, laneId);
|
|
4243
4338
|
printJson({
|
|
4244
4339
|
ok: true,
|
|
@@ -4247,7 +4342,7 @@ async function runStoryAdd(ctx, laneId, storyId, opts) {
|
|
|
4247
4342
|
project_id: projectId,
|
|
4248
4343
|
story_id: normalizedStoryId,
|
|
4249
4344
|
status: "in_progress",
|
|
4250
|
-
story_warning:
|
|
4345
|
+
story_warning: combinedStoryWarning(finalLane, warnings),
|
|
4251
4346
|
lane: finalLane,
|
|
4252
4347
|
});
|
|
4253
4348
|
}
|
|
@@ -4264,15 +4359,21 @@ async function runStoryStart(ctx, laneId, storyId, opts) {
|
|
|
4264
4359
|
});
|
|
4265
4360
|
}
|
|
4266
4361
|
const at = ctx.now().toISOString();
|
|
4267
|
-
|
|
4362
|
+
const warnings = [];
|
|
4363
|
+
const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
|
|
4364
|
+
lane = thread.lane;
|
|
4365
|
+
if (thread.warning !== undefined)
|
|
4366
|
+
warnings.push(thread.warning);
|
|
4268
4367
|
const before = lane;
|
|
4269
4368
|
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4270
4369
|
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4271
4370
|
return updateCarriedStory(current, target.projectId, target.storyId, "in_progress", at);
|
|
4272
4371
|
});
|
|
4273
|
-
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
|
|
4372
|
+
const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_started", "in_progress");
|
|
4373
|
+
if (transitionWarning !== undefined)
|
|
4374
|
+
warnings.push(transitionWarning);
|
|
4274
4375
|
const finalLane = readLane(hqRoot, laneId);
|
|
4275
|
-
printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning:
|
|
4376
|
+
printJson({ ok: true, action: "story.start", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "in_progress", story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
|
|
4276
4377
|
}
|
|
4277
4378
|
async function runStoryBlock(ctx, laneId, storyId, opts) {
|
|
4278
4379
|
const hqRoot = ctx.hqRoot();
|
|
@@ -4290,15 +4391,21 @@ async function runStoryBlock(ctx, laneId, storyId, opts) {
|
|
|
4290
4391
|
if (!reason)
|
|
4291
4392
|
throw new LanesError("missing_block_reason", "--reason is required", {});
|
|
4292
4393
|
const at = ctx.now().toISOString();
|
|
4293
|
-
|
|
4394
|
+
const warnings = [];
|
|
4395
|
+
const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
|
|
4396
|
+
lane = thread.lane;
|
|
4397
|
+
if (thread.warning !== undefined)
|
|
4398
|
+
warnings.push(thread.warning);
|
|
4294
4399
|
const before = lane;
|
|
4295
4400
|
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4296
4401
|
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
4297
4402
|
return updateCarriedStory(current, target.projectId, target.storyId, "blocked", at, { blocked_reason: reason });
|
|
4298
4403
|
});
|
|
4299
|
-
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
|
|
4404
|
+
const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_blocked", "blocked", { blocked_reason: reason });
|
|
4405
|
+
if (transitionWarning !== undefined)
|
|
4406
|
+
warnings.push(transitionWarning);
|
|
4300
4407
|
const finalLane = readLane(hqRoot, laneId);
|
|
4301
|
-
printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning:
|
|
4408
|
+
printJson({ ok: true, action: "story.block", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "blocked", blocked_reason: reason, story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
|
|
4302
4409
|
}
|
|
4303
4410
|
async function runStoryDone(ctx, laneId, storyId, opts) {
|
|
4304
4411
|
const hqRoot = ctx.hqRoot();
|
|
@@ -4323,7 +4430,11 @@ async function runStoryDone(ctx, laneId, storyId, opts) {
|
|
|
4323
4430
|
});
|
|
4324
4431
|
}
|
|
4325
4432
|
const at = ctx.now().toISOString();
|
|
4326
|
-
|
|
4433
|
+
const warnings = [];
|
|
4434
|
+
const thread = await ensureStoryThreadForTransition(ctx, lane, target.projectId, target.storyId);
|
|
4435
|
+
lane = thread.lane;
|
|
4436
|
+
if (thread.warning !== undefined)
|
|
4437
|
+
warnings.push(thread.warning);
|
|
4327
4438
|
const before = lane;
|
|
4328
4439
|
const next = casUpdateLane(hqRoot, laneId, (current) => {
|
|
4329
4440
|
assertLaneRunsHereForOperatorMutation(ctx, current, "update story status for");
|
|
@@ -4332,9 +4443,11 @@ async function runStoryDone(ctx, laneId, storyId, opts) {
|
|
|
4332
4443
|
verified_by: current.senior,
|
|
4333
4444
|
});
|
|
4334
4445
|
});
|
|
4335
|
-
await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
|
|
4446
|
+
const transitionWarning = await commitStoryTransition(ctx, before, next, target.projectId, target.storyId, "lane_done", "done", { summary: "Senior verification complete" });
|
|
4447
|
+
if (transitionWarning !== undefined)
|
|
4448
|
+
warnings.push(transitionWarning);
|
|
4336
4449
|
const finalLane = readLane(hqRoot, laneId);
|
|
4337
|
-
printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning:
|
|
4450
|
+
printJson({ ok: true, action: "story.done", lane_id: laneId, project_id: target.projectId, story_id: target.storyId, status: "done", verified: true, story_warning: combinedStoryWarning(finalLane, warnings), lane: finalLane });
|
|
4338
4451
|
}
|
|
4339
4452
|
/**
|
|
4340
4453
|
* Registration state of the drain hook in the lane's account home, read the
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
* of opening a browser nobody is watching, and the watcher records
|
|
43
43
|
* `mesh_event_failed` and moves on. No credential file is read here.
|
|
44
44
|
*/
|
|
45
|
+
import { LanesError } from "./errors.js";
|
|
45
46
|
import type { LaneCompanyRef, LaneEnvelope, LaneRecord, TerminalLaneState } from "./types.js";
|
|
46
47
|
export declare const LANE_MESH_EVENTS: readonly ["lane_started", "lane_progress", "lane_question", "lane_question_at_user", "lane_ask", "lane_blocked", "lane_done", "lane_failed", "lane_interrupted", "lane_cancelled", "lane_killed"];
|
|
47
48
|
export type LaneMeshEvent = (typeof LANE_MESH_EVENTS)[number];
|
|
@@ -73,6 +74,8 @@ export interface LaneMesh {
|
|
|
73
74
|
ensureLaneThread?: (company: LaneCompanyRef, lane: LaneRecord, laneStatus?: string, signal?: AbortSignal) => Promise<string>;
|
|
74
75
|
appendThreadEvent: (company: LaneCompanyRef, threadId: string, event: LaneMeshEvent, payload: Record<string, unknown>, signal?: AbortSignal) => Promise<void>;
|
|
75
76
|
patchStoryStatus: (company: LaneCompanyRef, projectId: string, storyId: string, status: string) => Promise<void>;
|
|
77
|
+
/** Read-only Board membership check before a command writes lane state. */
|
|
78
|
+
assertProjectStoriesOnBoard?: (company: LaneCompanyRef, projectId: string, storyIds: readonly string[]) => Promise<void>;
|
|
76
79
|
}
|
|
77
80
|
export interface CreateLaneMeshOptions {
|
|
78
81
|
/** False for detached callers (the watcher). See the token note above. */
|
|
@@ -88,6 +91,7 @@ export interface CreateLaneMeshOptions {
|
|
|
88
91
|
status: string;
|
|
89
92
|
}) => Promise<void>;
|
|
90
93
|
}
|
|
94
|
+
export declare function storyNotOnBoardError(company: LaneCompanyRef, projectId: string, storyId: string): LanesError;
|
|
91
95
|
export declare function createLaneMesh(opts?: CreateLaneMeshOptions): LaneMesh;
|
|
92
96
|
/** Resolve the explicit mesh attribution before falling back to the lane tenant. */
|
|
93
97
|
export declare function laneMeshCompany(lane: Pick<LaneRecord, "company" | "mesh_company">): LaneCompanyRef | undefined;
|
|
@@ -220,6 +220,31 @@ function isProjectViewNotFound(error) {
|
|
|
220
220
|
return (candidate.code === "PROJECT_VIEW_NOT_FOUND" ||
|
|
221
221
|
(typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_NOT_FOUND")));
|
|
222
222
|
}
|
|
223
|
+
function isUnknownProjectStory(error) {
|
|
224
|
+
if (!error || typeof error !== "object")
|
|
225
|
+
return false;
|
|
226
|
+
const candidate = error;
|
|
227
|
+
return (candidate.status === 400 &&
|
|
228
|
+
candidate.code === "PROJECT_VIEW_INVALID" &&
|
|
229
|
+
typeof candidate.message === "string" &&
|
|
230
|
+
candidate.message.includes("Unknown story"));
|
|
231
|
+
}
|
|
232
|
+
function shellQuote(value) {
|
|
233
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
234
|
+
}
|
|
235
|
+
export function storyNotOnBoardError(company, projectId, storyId) {
|
|
236
|
+
const command = "hq mesh project ensure " + shellQuote(projectId) +
|
|
237
|
+
" --company " + shellQuote(company.slug) +
|
|
238
|
+
" --stories-file '<prd.json>'";
|
|
239
|
+
const message = "Story " + JSON.stringify(storyId) + " is not on the Work Mesh Board for project " +
|
|
240
|
+
JSON.stringify(projectId) + ". Add it to the project PRD, then run: " + command;
|
|
241
|
+
return new LanesError("story_not_on_board", message, {
|
|
242
|
+
message,
|
|
243
|
+
project_id: projectId,
|
|
244
|
+
story_id: storyId,
|
|
245
|
+
command,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
223
248
|
function isAlreadyRegistered(error) {
|
|
224
249
|
if (!error || typeof error !== "object")
|
|
225
250
|
return false;
|
|
@@ -293,18 +318,61 @@ export function createLaneMesh(opts = {}) {
|
|
|
293
318
|
senior: { kind: lane.senior.kind, id: lane.senior.id },
|
|
294
319
|
laneVersion: lane.version,
|
|
295
320
|
};
|
|
296
|
-
|
|
297
|
-
|
|
321
|
+
try {
|
|
322
|
+
const ensured = await ensureLaneMeshThread(tok, resolved, lane.project_id, descriptor, isMeshLaneStatus(laneStatus) ? laneStatus : "running", signal);
|
|
323
|
+
return ensured.threadId;
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (lane.story_id && isUnknownProjectStory(error)) {
|
|
327
|
+
throw storyNotOnBoardError(company, lane.project_id, lane.story_id);
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
298
331
|
},
|
|
299
332
|
async appendThreadEvent(company, threadId, event, payload, signal) {
|
|
300
333
|
const wire = toWireThreadEvent(event, payload);
|
|
301
334
|
const tok = await token();
|
|
302
335
|
await appendThreadEvent(tok, company.uid, threadId, wire.eventKind, wire.payload, signal);
|
|
303
336
|
},
|
|
337
|
+
async assertProjectStoriesOnBoard(company, projectId, storyIds) {
|
|
338
|
+
if (storyIds.length === 0)
|
|
339
|
+
return;
|
|
340
|
+
const tok = await token();
|
|
341
|
+
let view;
|
|
342
|
+
try {
|
|
343
|
+
view = await getProjectView(tok, company.uid, projectId);
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
// A missing Board has a separate, established repair path in
|
|
347
|
+
// patchStoryStatus. Keep it available; an existing Board is strict.
|
|
348
|
+
if (isProjectViewNotFound(error))
|
|
349
|
+
return;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
if (view.companyUid !== company.uid || view.projectId !== projectId) {
|
|
353
|
+
throw new LanesError("project_board_scope_mismatch", "Work Mesh returned a Board for a different company or project", { project_id: projectId });
|
|
354
|
+
}
|
|
355
|
+
for (const storyId of storyIds) {
|
|
356
|
+
if (!view.stories.some((story) => story.id === storyId)) {
|
|
357
|
+
throw storyNotOnBoardError(company, projectId, storyId);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
304
361
|
async patchStoryStatus(company, projectId, storyId, status) {
|
|
305
362
|
const tok = await token();
|
|
363
|
+
const patch = async () => {
|
|
364
|
+
try {
|
|
365
|
+
await patchStoryStatus(tok, company.uid, projectId, storyId, status);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (isUnknownProjectStory(error)) {
|
|
369
|
+
throw storyNotOnBoardError(company, projectId, storyId);
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
};
|
|
306
374
|
try {
|
|
307
|
-
await
|
|
375
|
+
await patch();
|
|
308
376
|
}
|
|
309
377
|
catch (error) {
|
|
310
378
|
if (!isProjectViewNotFound(error))
|
|
@@ -316,7 +384,7 @@ export function createLaneMesh(opts = {}) {
|
|
|
316
384
|
storyId,
|
|
317
385
|
status,
|
|
318
386
|
});
|
|
319
|
-
await
|
|
387
|
+
await patch();
|
|
320
388
|
}
|
|
321
389
|
},
|
|
322
390
|
};
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { resolveCompany } from "../../work-context/company.js";
|
|
13
|
+
import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
|
|
14
|
+
import { companyBindingChanged, reboundSessionProjection, } from "../../work-context/company-rebind.js";
|
|
14
15
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
15
16
|
import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
|
|
16
|
-
import { readSessionState, updateSessionState } from "../../work-context/state.js";
|
|
17
|
+
import { readSessionState, updateSessionState, writeSessionState } from "../../work-context/state.js";
|
|
18
|
+
import { generateUlid } from "./ulid.js";
|
|
17
19
|
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
18
20
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
19
21
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
@@ -252,6 +254,23 @@ export async function flushSessionEvents(deps) {
|
|
|
252
254
|
if (deps.signal?.aborted)
|
|
253
255
|
break sessionLoop;
|
|
254
256
|
let state = readSessionState(sessionId, deps.workContextRoot);
|
|
257
|
+
state = applySessionMetaCompanyRebind(sessionId, items, state, deps, now);
|
|
258
|
+
const leaveResult = await postPresenceLeaveIfNeeded({
|
|
259
|
+
sessionId,
|
|
260
|
+
items,
|
|
261
|
+
state,
|
|
262
|
+
deps,
|
|
263
|
+
now,
|
|
264
|
+
maxAttempts,
|
|
265
|
+
sleep,
|
|
266
|
+
random,
|
|
267
|
+
summary,
|
|
268
|
+
markDisposed,
|
|
269
|
+
});
|
|
270
|
+
state = leaveResult.state;
|
|
271
|
+
if (leaveResult.abort)
|
|
272
|
+
continue;
|
|
273
|
+
const remainingItems = leaveResult.items;
|
|
255
274
|
if (state?.companyConfidence === "device_default" &&
|
|
256
275
|
deps.authorizeDeviceDefaultTenant) {
|
|
257
276
|
const allowed = await deps.authorizeDeviceDefaultTenant(state);
|
|
@@ -259,7 +278,7 @@ export async function flushSessionEvents(deps) {
|
|
|
259
278
|
state = readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
260
279
|
if (!allowed) {
|
|
261
280
|
const heldAt = now().toISOString();
|
|
262
|
-
for (const item of
|
|
281
|
+
for (const item of remainingItems) {
|
|
263
282
|
appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
264
283
|
summary.held += 1;
|
|
265
284
|
markDisposed(item.event);
|
|
@@ -269,33 +288,34 @@ export async function flushSessionEvents(deps) {
|
|
|
269
288
|
}
|
|
270
289
|
// After the company gate (which may restore a cleared company), try to
|
|
271
290
|
// bind a needs_project session to an existing Board before classifying.
|
|
272
|
-
state = rebindUnboundSession(sessionId,
|
|
291
|
+
state = rebindUnboundSession(sessionId, remainingItems, state, deps, nowMs);
|
|
273
292
|
const decision = classifySession(state);
|
|
274
293
|
if (decision.action === "drop") {
|
|
275
294
|
appendDroppedReceipt({
|
|
276
295
|
v: 1,
|
|
277
296
|
sessionId,
|
|
278
|
-
dropped:
|
|
297
|
+
dropped: remainingItems.length,
|
|
279
298
|
reason: "untracked",
|
|
280
299
|
at: now().toISOString(),
|
|
281
300
|
}, deps.workMeshRoot);
|
|
282
|
-
summary.dropped +=
|
|
283
|
-
for (const item of
|
|
301
|
+
summary.dropped += remainingItems.length;
|
|
302
|
+
for (const item of remainingItems)
|
|
284
303
|
markDisposed(item.event);
|
|
285
304
|
continue;
|
|
286
305
|
}
|
|
287
306
|
if (decision.action === "hold") {
|
|
288
307
|
const heldAt = now().toISOString();
|
|
289
|
-
for (const item of
|
|
308
|
+
for (const item of remainingItems) {
|
|
290
309
|
appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
291
310
|
summary.held += 1;
|
|
292
311
|
markDisposed(item.event);
|
|
293
312
|
}
|
|
294
313
|
continue;
|
|
295
314
|
}
|
|
296
|
-
// Post a bounded portion of this session's backlog
|
|
297
|
-
//
|
|
298
|
-
|
|
315
|
+
// Post a bounded portion of this session's remaining backlog (after any
|
|
316
|
+
// presence-leave). Untouched events stay in pendingRestore and are put
|
|
317
|
+
// back on the spool below.
|
|
318
|
+
const flushItems = remainingItems.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
|
|
299
319
|
const stripped = flushItems.map((item) => stripLocalOnlyFields(item.event));
|
|
300
320
|
for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
|
|
301
321
|
const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
@@ -547,6 +567,135 @@ function eventText(event, key) {
|
|
|
547
567
|
const value = event[key];
|
|
548
568
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
549
569
|
}
|
|
570
|
+
const SESSION_EVENT_HARNESSES = new Set([
|
|
571
|
+
"claude-code",
|
|
572
|
+
"claude-desktop",
|
|
573
|
+
"codex",
|
|
574
|
+
"grok",
|
|
575
|
+
"hq-sessions",
|
|
576
|
+
"agent-box",
|
|
577
|
+
"agents-v2",
|
|
578
|
+
]);
|
|
579
|
+
function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
|
|
580
|
+
if (!state)
|
|
581
|
+
return state;
|
|
582
|
+
const hqRoot = items.map((item) => eventText(item.event, "hqRoot")).find(Boolean);
|
|
583
|
+
if (!hqRoot)
|
|
584
|
+
return state;
|
|
585
|
+
const meta = readMetaCompanySlug(sessionId, hqRoot);
|
|
586
|
+
if (!meta)
|
|
587
|
+
return state;
|
|
588
|
+
if (!companyBindingChanged(state, { slug: meta }))
|
|
589
|
+
return state;
|
|
590
|
+
const next = reboundSessionProjection({
|
|
591
|
+
prior: state,
|
|
592
|
+
sessionId,
|
|
593
|
+
nextSlug: meta,
|
|
594
|
+
nowIso: now().toISOString(),
|
|
595
|
+
});
|
|
596
|
+
writeSessionState(next, deps.workContextRoot);
|
|
597
|
+
return readSessionState(sessionId, deps.workContextRoot) ?? next;
|
|
598
|
+
}
|
|
599
|
+
function leaveContextStatus(leave) {
|
|
600
|
+
if (leave.projectId && leave.taskId)
|
|
601
|
+
return "bound";
|
|
602
|
+
if (leave.projectId)
|
|
603
|
+
return "needs_task";
|
|
604
|
+
return "needs_project";
|
|
605
|
+
}
|
|
606
|
+
function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
607
|
+
const harnessRaw = eventText(sample, "harness");
|
|
608
|
+
const harness = harnessRaw && SESSION_EVENT_HARNESSES.has(harnessRaw)
|
|
609
|
+
? harnessRaw
|
|
610
|
+
: "claude-code";
|
|
611
|
+
return stripLocalOnlyFields({
|
|
612
|
+
v: 1,
|
|
613
|
+
eventId: generateUlid(),
|
|
614
|
+
kind: "session_end",
|
|
615
|
+
sessionId,
|
|
616
|
+
harness,
|
|
617
|
+
adapterVersion: eventText(sample, "adapterVersion") || "1.0.0",
|
|
618
|
+
at,
|
|
619
|
+
seq,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async function postPresenceLeaveIfNeeded(input) {
|
|
623
|
+
const leave = input.state?.presenceLeave;
|
|
624
|
+
if (!leave?.companyUid) {
|
|
625
|
+
return { state: input.state, items: input.items, abort: false };
|
|
626
|
+
}
|
|
627
|
+
const oldSlug = leave.companySlug?.trim().toLowerCase();
|
|
628
|
+
const leaveItems = oldSlug
|
|
629
|
+
? input.items.filter((item) => {
|
|
630
|
+
const slug = eventText(item.event, "companySlug")?.toLowerCase();
|
|
631
|
+
return slug === oldSlug;
|
|
632
|
+
})
|
|
633
|
+
: [];
|
|
634
|
+
const remaining = input.items.filter((item) => !leaveItems.includes(item));
|
|
635
|
+
const sample = leaveItems[0]?.event ?? input.items[0]?.event ?? {};
|
|
636
|
+
const maxSeq = input.items.reduce((max, item) => {
|
|
637
|
+
const seq = item.event.seq;
|
|
638
|
+
return typeof seq === "number" && Number.isFinite(seq) ? Math.max(max, seq) : max;
|
|
639
|
+
}, 0);
|
|
640
|
+
const sessionEnd = synthesizeSessionEnd(input.sessionId, sample, maxSeq + 1, input.now().toISOString());
|
|
641
|
+
const events = [
|
|
642
|
+
...leaveItems.map((item) => stripLocalOnlyFields(item.event)),
|
|
643
|
+
sessionEnd,
|
|
644
|
+
];
|
|
645
|
+
const body = {
|
|
646
|
+
contractVersion: 1,
|
|
647
|
+
companyUid: leave.companyUid,
|
|
648
|
+
sessionId: input.sessionId,
|
|
649
|
+
contextStatus: leaveContextStatus(leave),
|
|
650
|
+
events,
|
|
651
|
+
};
|
|
652
|
+
if (leave.bindingEpisodeId)
|
|
653
|
+
body.bindingEpisodeId = leave.bindingEpisodeId;
|
|
654
|
+
if (leave.projectId)
|
|
655
|
+
body.projectId = leave.projectId;
|
|
656
|
+
if (leave.taskId)
|
|
657
|
+
body.taskId = leave.taskId;
|
|
658
|
+
input.summary.batches += 1;
|
|
659
|
+
const result = await postBatchWithRetries(input.deps.poster, body, {
|
|
660
|
+
maxAttempts: input.maxAttempts,
|
|
661
|
+
sleep: input.sleep,
|
|
662
|
+
random: input.random,
|
|
663
|
+
signal: input.deps.signal,
|
|
664
|
+
});
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
const at = input.now().toISOString();
|
|
667
|
+
if (!result.retryable) {
|
|
668
|
+
for (const item of input.items) {
|
|
669
|
+
appendDeadLetterLine(deadLetterEnvelope(item.event, `HTTP_${result.status}`, result.status, at), input.deps.workMeshRoot);
|
|
670
|
+
input.summary.deadLettered += 1;
|
|
671
|
+
input.markDisposed(item.event);
|
|
672
|
+
}
|
|
673
|
+
return { state: input.state, items: [], abort: true };
|
|
674
|
+
}
|
|
675
|
+
for (const item of input.items) {
|
|
676
|
+
appendSpoolLine(JSON.stringify(canonicalizeForRestore(item.event)), input.deps.workMeshRoot);
|
|
677
|
+
input.summary.restored += 1;
|
|
678
|
+
input.markDisposed(item.event);
|
|
679
|
+
}
|
|
680
|
+
return { state: input.state, items: [], abort: true };
|
|
681
|
+
}
|
|
682
|
+
accountBatchSuccess({
|
|
683
|
+
accepted: events.length,
|
|
684
|
+
duplicates: 0,
|
|
685
|
+
rejected: [],
|
|
686
|
+
}, [...leaveItems, { event: sessionEnd }], input.deps.workMeshRoot, input.now, input.summary, input.markDisposed);
|
|
687
|
+
const cleared = updateSessionState(input.sessionId, input.deps.workContextRoot, (current) => {
|
|
688
|
+
if (!current)
|
|
689
|
+
return current;
|
|
690
|
+
const { presenceLeave: _leave, ...rest } = current;
|
|
691
|
+
return { ...rest, updatedAt: input.now().toISOString() };
|
|
692
|
+
});
|
|
693
|
+
return {
|
|
694
|
+
state: cleared ?? input.state,
|
|
695
|
+
items: remaining,
|
|
696
|
+
abort: false,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
550
699
|
/**
|
|
551
700
|
* Before a needs_project (or unresolved-with-company) batch is classified,
|
|
552
701
|
* run the prompt-submit bind-best refinement. A bind updates the session
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-047: when the HQ session company changes, drop the old Work Mesh project
|
|
3
|
+
* and remember a presence leave for the previous company.
|
|
4
|
+
*/
|
|
5
|
+
import type { SessionStateFile } from "./state.js";
|
|
6
|
+
export declare const NON_CLOUD_COMPANY_SLUG = "personal";
|
|
7
|
+
export declare function isNonCloudCompanySlug(slug?: string | null): boolean;
|
|
8
|
+
export declare function companyBindingChanged(prior: SessionStateFile | null | undefined, next: {
|
|
9
|
+
slug?: string;
|
|
10
|
+
uid?: string;
|
|
11
|
+
}): boolean;
|
|
12
|
+
export declare function presenceLeaveFromState(prior: SessionStateFile): NonNullable<SessionStateFile["presenceLeave"]> | undefined;
|
|
13
|
+
export declare function reboundSessionProjection(input: {
|
|
14
|
+
prior: SessionStateFile;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
nextSlug?: string;
|
|
17
|
+
nextUid?: string;
|
|
18
|
+
nowIso: string;
|
|
19
|
+
startedAt?: string;
|
|
20
|
+
toolWrites?: number;
|
|
21
|
+
hasTurn?: boolean;
|
|
22
|
+
}): SessionStateFile;
|
|
23
|
+
//# sourceMappingURL=company-rebind.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-047: when the HQ session company changes, drop the old Work Mesh project
|
|
3
|
+
* and remember a presence leave for the previous company.
|
|
4
|
+
*/
|
|
5
|
+
import { sameCompany } from "./company.js";
|
|
6
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
|
+
export const NON_CLOUD_COMPANY_SLUG = "personal";
|
|
8
|
+
export function isNonCloudCompanySlug(slug) {
|
|
9
|
+
return slug?.trim().toLowerCase() === NON_CLOUD_COMPANY_SLUG;
|
|
10
|
+
}
|
|
11
|
+
export function companyBindingChanged(prior, next) {
|
|
12
|
+
if (!prior)
|
|
13
|
+
return false;
|
|
14
|
+
if (!prior.companySlug && !prior.companyUid)
|
|
15
|
+
return false;
|
|
16
|
+
if (!next.slug && !next.uid)
|
|
17
|
+
return false;
|
|
18
|
+
return !sameCompany({ slug: prior.companySlug, uid: prior.companyUid }, next);
|
|
19
|
+
}
|
|
20
|
+
export function presenceLeaveFromState(prior) {
|
|
21
|
+
const uid = prior.companyUid?.trim();
|
|
22
|
+
if (!uid)
|
|
23
|
+
return undefined;
|
|
24
|
+
return {
|
|
25
|
+
companyUid: uid,
|
|
26
|
+
...(prior.companySlug ? { companySlug: prior.companySlug } : {}),
|
|
27
|
+
...(prior.projectId ? { projectId: prior.projectId } : {}),
|
|
28
|
+
...(prior.taskId ? { taskId: prior.taskId } : {}),
|
|
29
|
+
...(prior.bindingEpisodeId ? { bindingEpisodeId: prior.bindingEpisodeId } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function reboundSessionProjection(input) {
|
|
33
|
+
const nonCloud = isNonCloudCompanySlug(input.nextSlug) || !input.nextUid;
|
|
34
|
+
const leave = presenceLeaveFromState(input.prior);
|
|
35
|
+
const out = {
|
|
36
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
37
|
+
sessionId: input.sessionId,
|
|
38
|
+
contextStatus: nonCloud ? "needs_company" : "needs_project",
|
|
39
|
+
updatedAt: input.nowIso,
|
|
40
|
+
};
|
|
41
|
+
if (input.nextSlug)
|
|
42
|
+
out.companySlug = input.nextSlug;
|
|
43
|
+
if (!nonCloud && input.nextUid)
|
|
44
|
+
out.companyUid = input.nextUid;
|
|
45
|
+
const startedAt = input.startedAt ?? input.prior.startedAt;
|
|
46
|
+
if (startedAt)
|
|
47
|
+
out.startedAt = startedAt;
|
|
48
|
+
if (typeof input.toolWrites === "number")
|
|
49
|
+
out.toolWrites = input.toolWrites;
|
|
50
|
+
else if (typeof input.prior.toolWrites === "number") {
|
|
51
|
+
out.toolWrites = input.prior.toolWrites;
|
|
52
|
+
}
|
|
53
|
+
if (input.hasTurn || input.prior.hasTurn)
|
|
54
|
+
out.hasTurn = true;
|
|
55
|
+
if (leave)
|
|
56
|
+
out.presenceLeave = leave;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=company-rebind.js.map
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Order:
|
|
5
5
|
* 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
|
|
6
|
-
* 2. existing authoritative session scope
|
|
7
|
-
*
|
|
6
|
+
* 2. existing authoritative session scope — unless session meta.yaml
|
|
7
|
+
* names a different company (hq-session.sh rebind, US-047)
|
|
8
|
+
* 3. session meta.yaml company_slug (/startwork / hq-session.sh)
|
|
8
9
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
10
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
11
|
* above device default)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Order:
|
|
5
5
|
* 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
|
|
6
|
-
* 2. existing authoritative session scope
|
|
7
|
-
*
|
|
6
|
+
* 2. existing authoritative session scope — unless session meta.yaml
|
|
7
|
+
* names a different company (hq-session.sh rebind, US-047)
|
|
8
|
+
* 3. session meta.yaml company_slug (/startwork / hq-session.sh)
|
|
8
9
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
10
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
11
|
* above device default)
|
|
@@ -254,12 +255,23 @@ export function resolveCompany(input) {
|
|
|
254
255
|
? input.existingState
|
|
255
256
|
: readSessionState(input.sessionId, input.root);
|
|
256
257
|
const prior = authoritativeCompanyFromState(state);
|
|
258
|
+
// 3. session meta.yaml company_slug — read before existing_scope so an
|
|
259
|
+
// operator rebind via hq-session.sh can replace a stale Work Mesh company.
|
|
260
|
+
const metaSlug = input.metaCompanySlug !== undefined
|
|
261
|
+
? normalizeSlug(input.metaCompanySlug)
|
|
262
|
+
: readMetaCompanySlug(input.sessionId, input.hqRoot);
|
|
257
263
|
if (prior) {
|
|
258
264
|
const scoped = {
|
|
259
265
|
slug: prior.slug,
|
|
260
266
|
uid: prior.uid,
|
|
261
267
|
source: "existing_scope",
|
|
262
268
|
};
|
|
269
|
+
if (metaSlug && !sameCompany(scoped, { slug: metaSlug })) {
|
|
270
|
+
return {
|
|
271
|
+
status: "resolved",
|
|
272
|
+
company: { slug: metaSlug, source: "session_meta" },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
263
275
|
// Deterministic evidence from another company never silently replaces a
|
|
264
276
|
// scoped session, even before it has a project binding. Preserve the
|
|
265
277
|
// original scope and surface a conflict before tenant I/O.
|
|
@@ -288,10 +300,6 @@ export function resolveCompany(input) {
|
|
|
288
300
|
}
|
|
289
301
|
return { status: "resolved", company: scoped };
|
|
290
302
|
}
|
|
291
|
-
// 3. session meta.yaml company_slug
|
|
292
|
-
const metaSlug = input.metaCompanySlug !== undefined
|
|
293
|
-
? normalizeSlug(input.metaCompanySlug)
|
|
294
|
-
: readMetaCompanySlug(input.sessionId, input.hqRoot);
|
|
295
303
|
if (metaSlug) {
|
|
296
304
|
return {
|
|
297
305
|
status: "resolved",
|
|
@@ -12,6 +12,7 @@ export * from "./config.js";
|
|
|
12
12
|
export * from "./state.js";
|
|
13
13
|
export * from "./outbox.js";
|
|
14
14
|
export * from "./company.js";
|
|
15
|
+
export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
15
16
|
export * from "./project.js";
|
|
16
17
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
18
|
export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
|
|
@@ -12,6 +12,7 @@ export * from "./config.js";
|
|
|
12
12
|
export * from "./state.js";
|
|
13
13
|
export * from "./outbox.js";
|
|
14
14
|
export * from "./company.js";
|
|
15
|
+
export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
15
16
|
export * from "./project.js";
|
|
16
17
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
18
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import { InvalidSessionIdentityError, normalizeSessionIdentity, } from "../mesh/live/session-identity.js";
|
|
7
7
|
import { companyCorrectionPath, companySlugFromCwd, resolveCompany, } from "./company.js";
|
|
8
|
+
import { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
8
9
|
import { invalidateDefaultCompanyMemberships, } from "./config.js";
|
|
9
10
|
import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
10
11
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
@@ -354,6 +355,43 @@ export async function reconcileObservation(obs, deps) {
|
|
|
354
355
|
// A device default is weak, cached evidence: validate it authoritatively on
|
|
355
356
|
// every tenant-I/O path so a membership revocation takes effect immediately.
|
|
356
357
|
let resolvedCompany = company.company;
|
|
358
|
+
if (isNonCloudCompanySlug(resolvedCompany.slug)) {
|
|
359
|
+
const projection = prior
|
|
360
|
+
? reboundSessionProjection({
|
|
361
|
+
prior,
|
|
362
|
+
sessionId,
|
|
363
|
+
nextSlug: resolvedCompany.slug,
|
|
364
|
+
nowIso,
|
|
365
|
+
startedAt,
|
|
366
|
+
toolWrites,
|
|
367
|
+
hasTurn,
|
|
368
|
+
})
|
|
369
|
+
: {
|
|
370
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
371
|
+
sessionId,
|
|
372
|
+
contextStatus: "needs_company",
|
|
373
|
+
companySlug: resolvedCompany.slug,
|
|
374
|
+
updatedAt: nowIso,
|
|
375
|
+
startedAt,
|
|
376
|
+
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
377
|
+
...(hasTurn ? { hasTurn: true } : {}),
|
|
378
|
+
};
|
|
379
|
+
const statePath = writeSessionState(mergeLocalSessionFields({ ...projection, ...transcriptFields }, prior, { preserveBindingEpisode: false, preserveCompanyBinding: false }), deps.root);
|
|
380
|
+
diagnostics.push(`company rebind to ${resolvedCompany.slug}; cleared project; held (non-cloud)`);
|
|
381
|
+
return {
|
|
382
|
+
result: resultOf({
|
|
383
|
+
kind: "needs_company",
|
|
384
|
+
classification: "needs_company",
|
|
385
|
+
delivery: "clean",
|
|
386
|
+
sessionId,
|
|
387
|
+
clientOperationId: obs.clientOperationId,
|
|
388
|
+
companySlug: resolvedCompany.slug,
|
|
389
|
+
}),
|
|
390
|
+
exitCode: EXIT_OK,
|
|
391
|
+
diagnostics: [...diagnostics, "needs_company: non-cloud session company; no tenant I/O"],
|
|
392
|
+
statePath,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
357
395
|
const deviceDefaultRequiresValidation = resolvedCompany.source === "device_default";
|
|
358
396
|
if (deviceDefaultRequiresValidation && !deps.validateMembership) {
|
|
359
397
|
return {
|
|
@@ -385,6 +423,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
385
423
|
invalidateDefaultCompanyMemberships({ root: deps.root, now: nowFn });
|
|
386
424
|
}
|
|
387
425
|
diagnostics.push(`membership denied for slug=${resolvedCompany.slug}; treating as needs_company`);
|
|
426
|
+
const deniedLeave = prior ? presenceLeaveFromState(prior) : undefined;
|
|
388
427
|
const statePath = writeSessionState(mergeLocalSessionFields({
|
|
389
428
|
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
390
429
|
sessionId,
|
|
@@ -395,7 +434,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
395
434
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
396
435
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
397
436
|
...transcriptFields,
|
|
398
|
-
|
|
437
|
+
...(deniedLeave ? { presenceLeave: deniedLeave } : {}),
|
|
438
|
+
}, prior, companyBindingChanged(prior, resolvedCompany)
|
|
439
|
+
? { preserveBindingEpisode: false, preserveCompanyBinding: false }
|
|
440
|
+
: {}), deps.root);
|
|
399
441
|
return {
|
|
400
442
|
result: resultOf({
|
|
401
443
|
kind: "needs_company",
|
|
@@ -431,13 +473,23 @@ export async function reconcileObservation(obs, deps) {
|
|
|
431
473
|
}
|
|
432
474
|
}
|
|
433
475
|
// Company resolved — resolve project/task (US-007B order).
|
|
476
|
+
const companyRebind = companyBindingChanged(prior, resolvedCompany);
|
|
477
|
+
const projectPrior = companyRebind && prior
|
|
478
|
+
? {
|
|
479
|
+
...prior,
|
|
480
|
+
projectId: undefined,
|
|
481
|
+
taskId: undefined,
|
|
482
|
+
bindingEpisodeId: undefined,
|
|
483
|
+
contextStatus: "needs_project",
|
|
484
|
+
}
|
|
485
|
+
: prior;
|
|
434
486
|
let projectResolved = resolveProjectTask({
|
|
435
487
|
sessionId,
|
|
436
488
|
env,
|
|
437
489
|
trusted,
|
|
438
490
|
cwd,
|
|
439
491
|
hqRoot,
|
|
440
|
-
existingState:
|
|
492
|
+
existingState: projectPrior,
|
|
441
493
|
});
|
|
442
494
|
// Deterministic cwd projects belong to the cwd company. A higher-precedence
|
|
443
495
|
// identity UID must not adopt a project from an unrelated/unverified slug.
|
|
@@ -537,7 +589,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
const projectHint = !projectId
|
|
540
|
-
? (prior?.projectHint || projectResolved?.projectId)
|
|
592
|
+
? (companyRebind ? projectResolved?.projectId : (prior?.projectHint || projectResolved?.projectId))
|
|
541
593
|
: undefined;
|
|
542
594
|
const bindResultFields = {
|
|
543
595
|
...(projectBindReason ? { projectBindReason } : {}),
|
|
@@ -634,6 +686,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
634
686
|
// Do not let a weak later observation erase a server-acknowledged company
|
|
635
687
|
// binding. Keep the result unresolved so the normal candidate threshold can
|
|
636
688
|
// still advance the session, while preserving the durable emitter projection.
|
|
689
|
+
if (companyRebind && !projectId && resolvedCompany.uid) {
|
|
690
|
+
registerClassification = "needs_project";
|
|
691
|
+
diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
|
|
692
|
+
}
|
|
637
693
|
const stateClassification = registerClassification === "unresolved" &&
|
|
638
694
|
prior?.contextStatus === "needs_project" &&
|
|
639
695
|
prior.companyUid === resolvedCompany.uid &&
|
|
@@ -707,9 +763,12 @@ export async function reconcileObservation(obs, deps) {
|
|
|
707
763
|
};
|
|
708
764
|
}
|
|
709
765
|
let outboxOp;
|
|
710
|
-
const companyConfidence =
|
|
766
|
+
const companyConfidence = !companyRebind &&
|
|
767
|
+
company.status === "resolved" &&
|
|
768
|
+
company.company.source === "device_default"
|
|
711
769
|
? "device_default"
|
|
712
770
|
: undefined;
|
|
771
|
+
const presenceLeave = companyRebind && prior ? presenceLeaveFromState(prior) : undefined;
|
|
713
772
|
try {
|
|
714
773
|
outboxOp = enqueueOutbox({
|
|
715
774
|
clientOperationId: obs.clientOperationId,
|
|
@@ -762,9 +821,12 @@ export async function reconcileObservation(obs, deps) {
|
|
|
762
821
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
763
822
|
...(decision ? { decision } : {}),
|
|
764
823
|
...(companyConfidence ? { companyConfidence } : {}),
|
|
824
|
+
...(presenceLeave ? { presenceLeave } : {}),
|
|
765
825
|
...bindStateFields,
|
|
766
826
|
...transcriptFields,
|
|
767
|
-
}, prior
|
|
827
|
+
}, prior, companyRebind
|
|
828
|
+
? { preserveBindingEpisode: false, preserveCompanyBinding: false }
|
|
829
|
+
: {});
|
|
768
830
|
statePath = writeSessionState(stateWrite, deps.root);
|
|
769
831
|
}
|
|
770
832
|
catch (err) {
|
|
@@ -871,6 +933,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
871
933
|
sessionId,
|
|
872
934
|
clientOperationId: obs.clientOperationId,
|
|
873
935
|
companyUid: resolvedCompany.uid,
|
|
936
|
+
companySlug: resolvedCompany.slug,
|
|
874
937
|
projectId,
|
|
875
938
|
taskId,
|
|
876
939
|
bindingEpisodeId: delivered.receiptId,
|
|
@@ -106,6 +106,17 @@ export interface SessionStateFile {
|
|
|
106
106
|
projectGate?: "choose" | "missing_board";
|
|
107
107
|
/** Slug the session was trying to bind. Re-checked on the next prompt. */
|
|
108
108
|
projectHint?: string;
|
|
109
|
+
/**
|
|
110
|
+
* LOCAL-ONLY. Pending session_end against a company this session just left
|
|
111
|
+
* (US-047). Flush posts it to that companyUid, then clears the field.
|
|
112
|
+
*/
|
|
113
|
+
presenceLeave?: {
|
|
114
|
+
companyUid: string;
|
|
115
|
+
companySlug?: string;
|
|
116
|
+
projectId?: string;
|
|
117
|
+
taskId?: string;
|
|
118
|
+
bindingEpisodeId?: string;
|
|
119
|
+
};
|
|
109
120
|
updatedAt: string;
|
|
110
121
|
}
|
|
111
122
|
/**
|
|
@@ -33,6 +33,7 @@ export const SESSION_STATE_ALLOWLIST = new Set([
|
|
|
33
33
|
"projectCandidates",
|
|
34
34
|
"projectGate",
|
|
35
35
|
"projectHint",
|
|
36
|
+
"presenceLeave",
|
|
36
37
|
"updatedAt",
|
|
37
38
|
]);
|
|
38
39
|
const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -240,6 +241,8 @@ export function projectSessionState(state) {
|
|
|
240
241
|
out.projectGate = state.projectGate;
|
|
241
242
|
if (state.projectHint && !state.projectId)
|
|
242
243
|
out.projectHint = state.projectHint;
|
|
244
|
+
if (state.presenceLeave?.companyUid)
|
|
245
|
+
out.presenceLeave = state.presenceLeave;
|
|
243
246
|
return out;
|
|
244
247
|
}
|
|
245
248
|
/**
|
|
@@ -298,9 +301,15 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
|
|
|
298
301
|
if (!out.companyConfidence && prior.companyConfidence && sameCompanyBinding) {
|
|
299
302
|
out.companyConfidence = prior.companyConfidence;
|
|
300
303
|
}
|
|
301
|
-
if (!out.projectId &&
|
|
304
|
+
if (!out.projectId &&
|
|
305
|
+
!out.projectHint &&
|
|
306
|
+
prior.projectHint &&
|
|
307
|
+
sameCompanyBinding) {
|
|
302
308
|
out.projectHint = prior.projectHint;
|
|
303
309
|
}
|
|
310
|
+
if (!out.presenceLeave && prior.presenceLeave) {
|
|
311
|
+
out.presenceLeave = prior.presenceLeave;
|
|
312
|
+
}
|
|
304
313
|
if (out.projectId)
|
|
305
314
|
out.projectHint = undefined;
|
|
306
315
|
const sameBinding = prior.companyUid === out.companyUid &&
|