@indigoai-us/hq-cli 5.139.0 → 5.141.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 +11 -0
- package/dist/command-catalog.generated.d.ts +26 -0
- package/dist/command-catalog.generated.js +34 -0
- package/dist/commands/bot-jobs.js +16 -3
- package/dist/commands/mesh.js +171 -65
- package/dist/lib/agent-kit/run/heartbeat.js +2 -2
- package/dist/lib/bot/run.js +2 -2
- package/dist/lib/heartbeat-identity.d.ts +1 -0
- package/dist/lib/heartbeat-identity.js +5 -0
- package/dist/lib/lanes/monitor.js +20 -1
- package/dist/lib/mesh/live/flush.d.ts +1 -1
- package/dist/lib/mesh/live/flush.js +35 -1
- package/dist/lib/mesh/project-board.d.ts +19 -0
- package/dist/lib/mesh/project-board.js +27 -0
- package/dist/lib/work-context/bind-best.d.ts +64 -0
- package/dist/lib/work-context/bind-best.js +299 -0
- package/dist/lib/work-context/contract.d.ts +8 -0
- package/dist/lib/work-context/reconcile.d.ts +9 -0
- package/dist/lib/work-context/reconcile.js +88 -3
- package/dist/lib/work-context/state.d.ts +13 -0
- package/dist/lib/work-context/state.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.141.0] — 2026-09-23
|
|
6
|
+
|
|
7
|
+
- `hq bot jobs` now checks the bot's configured agent ID against the machine credential before calling the jobs API. A mismatch reports both IDs and explains how to fix the bot configuration or re-mint its credentials.
|
|
8
|
+
|
|
9
|
+
## [5.140.0] — 2026-09-23
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `hq lanes monitor-check` now recognizes a live Claude Monitor task launched through the CLI's shell/node process wrapper, so active lanes are not reported as uncovered.
|
|
14
|
+
- A session hint now binds to the best existing project. An exact Board is used as-is. An exact local PRD gets a Board through the same genesis-thread, view, then register path as `project ensure`, and is bound. A single clear match is bound with a recorded reason. When nothing matches, or two candidates are close, the session stays `needs_project` and the start hook lists the candidates. `hq mesh context bind-project` applies the answer. A flush HTTP 404 downgrades that session, holds its events, and logs one line instead of dead-lettering them. `hq mesh doctor` lists `needs_project` sessions.
|
|
15
|
+
|
|
5
16
|
## [5.139.0] — 2026-09-23
|
|
6
17
|
|
|
7
18
|
### Added
|
|
@@ -5856,6 +5856,32 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
5856
5856
|
readonly description: "Print machine-readable JSON";
|
|
5857
5857
|
}];
|
|
5858
5858
|
readonly subcommands: readonly [];
|
|
5859
|
+
}, {
|
|
5860
|
+
readonly name: "bind-project";
|
|
5861
|
+
readonly description: "Bind this session to a project id, a numbered candidate, or new <slug>";
|
|
5862
|
+
readonly aliases: readonly [];
|
|
5863
|
+
readonly hidden: false;
|
|
5864
|
+
readonly usage: "[options] <target> [slug]";
|
|
5865
|
+
readonly arguments: readonly [{
|
|
5866
|
+
readonly name: "target";
|
|
5867
|
+
readonly required: true;
|
|
5868
|
+
readonly variadic: false;
|
|
5869
|
+
}, {
|
|
5870
|
+
readonly name: "slug";
|
|
5871
|
+
readonly required: false;
|
|
5872
|
+
readonly variadic: false;
|
|
5873
|
+
}];
|
|
5874
|
+
readonly options: readonly [{
|
|
5875
|
+
readonly flags: "--session <sid>";
|
|
5876
|
+
readonly description: "Canonical session id";
|
|
5877
|
+
}, {
|
|
5878
|
+
readonly flags: "--company <slug|uid>";
|
|
5879
|
+
readonly description: "Company override when creating a Board";
|
|
5880
|
+
}, {
|
|
5881
|
+
readonly flags: "--json";
|
|
5882
|
+
readonly description: "Print machine-readable JSON";
|
|
5883
|
+
}];
|
|
5884
|
+
readonly subcommands: readonly [];
|
|
5859
5885
|
}, {
|
|
5860
5886
|
readonly name: "organize";
|
|
5861
5887
|
readonly description: "List or submit one-time project/task decisions (US-007B / US-005B)";
|
|
@@ -7559,6 +7559,40 @@ export const COMMAND_CATALOG = [
|
|
|
7559
7559
|
],
|
|
7560
7560
|
"subcommands": []
|
|
7561
7561
|
},
|
|
7562
|
+
{
|
|
7563
|
+
"name": "bind-project",
|
|
7564
|
+
"description": "Bind this session to a project id, a numbered candidate, or new <slug>",
|
|
7565
|
+
"aliases": [],
|
|
7566
|
+
"hidden": false,
|
|
7567
|
+
"usage": "[options] <target> [slug]",
|
|
7568
|
+
"arguments": [
|
|
7569
|
+
{
|
|
7570
|
+
"name": "target",
|
|
7571
|
+
"required": true,
|
|
7572
|
+
"variadic": false
|
|
7573
|
+
},
|
|
7574
|
+
{
|
|
7575
|
+
"name": "slug",
|
|
7576
|
+
"required": false,
|
|
7577
|
+
"variadic": false
|
|
7578
|
+
}
|
|
7579
|
+
],
|
|
7580
|
+
"options": [
|
|
7581
|
+
{
|
|
7582
|
+
"flags": "--session <sid>",
|
|
7583
|
+
"description": "Canonical session id"
|
|
7584
|
+
},
|
|
7585
|
+
{
|
|
7586
|
+
"flags": "--company <slug|uid>",
|
|
7587
|
+
"description": "Company override when creating a Board"
|
|
7588
|
+
},
|
|
7589
|
+
{
|
|
7590
|
+
"flags": "--json",
|
|
7591
|
+
"description": "Print machine-readable JSON"
|
|
7592
|
+
}
|
|
7593
|
+
],
|
|
7594
|
+
"subcommands": []
|
|
7595
|
+
},
|
|
7562
7596
|
{
|
|
7563
7597
|
"name": "organize",
|
|
7564
7598
|
"description": "List or submit one-time project/task decisions (US-007B / US-005B)",
|
|
@@ -20,6 +20,7 @@ import * as fs from "node:fs";
|
|
|
20
20
|
import * as path from "node:path";
|
|
21
21
|
import { BotApi, BotApiError } from "../lib/bot/api.js";
|
|
22
22
|
import { effectiveBotKind } from "../lib/bot/config.js";
|
|
23
|
+
import { identityMismatchMessage, tokenAgentUid } from "../lib/heartbeat-identity.js";
|
|
23
24
|
// ─── Schedule translation ────────────────────────────────────────────────────
|
|
24
25
|
/** Mirrors hq-pro-agents `MIN_RECURRING_INTERVAL_SECONDS`. */
|
|
25
26
|
export const MIN_RECURRING_INTERVAL_SECONDS = 5 * 60;
|
|
@@ -363,10 +364,19 @@ export async function runBotJobs(nameArg, opts, deps) {
|
|
|
363
364
|
const { name, dir, config } = deps.requireBot(nameArg);
|
|
364
365
|
const kind = effectiveBotKind(config);
|
|
365
366
|
const out = deps.out ?? ((line) => console.log(line));
|
|
366
|
-
const api = deps.apiFor
|
|
367
|
-
? deps.apiFor(dir)
|
|
368
|
-
: new BotApi({ token: deps.botTokenSupplier(dir) });
|
|
369
367
|
const ctx = { botName: name, kind };
|
|
368
|
+
let client;
|
|
369
|
+
const jobsApi = async () => {
|
|
370
|
+
if (client)
|
|
371
|
+
return client;
|
|
372
|
+
const token = await deps.botTokenSupplier(dir)();
|
|
373
|
+
const tokenUid = tokenAgentUid(token);
|
|
374
|
+
if (tokenUid && tokenUid !== config.agentUid) {
|
|
375
|
+
refuse(identityMismatchMessage("bot jobs", config.agentUid, tokenUid));
|
|
376
|
+
}
|
|
377
|
+
client = deps.apiFor ? deps.apiFor(dir) : new BotApi({ token: async () => token });
|
|
378
|
+
return client;
|
|
379
|
+
};
|
|
370
380
|
if (opts.add !== undefined && opts.rm !== undefined) {
|
|
371
381
|
refuse("Add a job or remove one, not both in the same command.");
|
|
372
382
|
}
|
|
@@ -374,6 +384,7 @@ export async function runBotJobs(nameArg, opts, deps) {
|
|
|
374
384
|
const jobId = opts.rm.trim();
|
|
375
385
|
if (!jobId)
|
|
376
386
|
refuse("Say which job to remove: --rm <jobId>");
|
|
387
|
+
const api = await jobsApi();
|
|
377
388
|
try {
|
|
378
389
|
await api.cancelJob(config.agentUid, jobId);
|
|
379
390
|
}
|
|
@@ -395,6 +406,7 @@ export async function runBotJobs(nameArg, opts, deps) {
|
|
|
395
406
|
? parse(opts.every, { hqRoot: config.hqRoot, timezone })
|
|
396
407
|
: { cron: opts.cron, timezone };
|
|
397
408
|
const body = buildCreateJobBody({ prompt: opts.add, cron: schedule.cron, timezone: schedule.timezone });
|
|
409
|
+
const api = await jobsApi();
|
|
398
410
|
let created;
|
|
399
411
|
try {
|
|
400
412
|
created = await api.createJob(config.agentUid, body);
|
|
@@ -414,6 +426,7 @@ export async function runBotJobs(nameArg, opts, deps) {
|
|
|
414
426
|
return;
|
|
415
427
|
}
|
|
416
428
|
let jobs;
|
|
429
|
+
const api = await jobsApi();
|
|
417
430
|
try {
|
|
418
431
|
jobs = await api.listJobs(config.agentUid);
|
|
419
432
|
}
|
package/dist/commands/mesh.js
CHANGED
|
@@ -12,6 +12,7 @@ import chalk from "chalk";
|
|
|
12
12
|
import { loadCachedTokens, isMachineIdentity } from "../utils/cognito-session.js";
|
|
13
13
|
import * as readline from "node:readline/promises";
|
|
14
14
|
import { STORY_STATUSES, appendThreadEvent, ensureProjectThread, eventPayload, getProjectView, listActiveMembershipCompanies, listActiveThreads, patchStory, putProjectView, registerMeshProject, resolveActiveMembershipCompany, resolveMeshPrincipalUid, warmMeshConversationCache, } from "../lib/mesh/api.js";
|
|
15
|
+
import { ensureProjectBoard } from "../lib/mesh/project-board.js";
|
|
15
16
|
import { buildPreflightFixtures } from "../lib/work-context/preflight-contract.js";
|
|
16
17
|
import { createCandidatesFetcher, createMigratePoster, createOrganizePoster, createWorkSessionDeliverer, fetchCompanyLive, formatCompanyLiveTable, openMeshTransport, probeMigrationCapabilityForMemberships, requireToken, } from "../lib/mesh/client.js";
|
|
17
18
|
import { clearDefaultCompany, isDefaultCompanyDisabled, readDeviceConfig, recordMigrationCapabilitySnapshot, setRepairHeldWithDefault, setDefaultCompany, } from "../lib/work-context/config.js";
|
|
@@ -36,7 +37,9 @@ import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, findAncest
|
|
|
36
37
|
import { workContextRoot } from "../lib/work-context/paths.js";
|
|
37
38
|
import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-context/migrate.js";
|
|
38
39
|
import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
|
|
39
|
-
import { readSessionState } from "../lib/work-context/state.js";
|
|
40
|
+
import { readSessionState, updateSessionState } from "../lib/work-context/state.js";
|
|
41
|
+
import { findLocalPrd, listNeedsProjectSessions } from "../lib/work-context/bind-best.js";
|
|
42
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "../lib/work-context/contract.js";
|
|
40
43
|
import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
|
|
41
44
|
import { resolveCompany } from "../lib/work-context/company.js";
|
|
42
45
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../lib/work-context/repo-remote.js";
|
|
@@ -345,47 +348,44 @@ async function runProjectSet(projectId, opts) {
|
|
|
345
348
|
if (!current && !opts.create) {
|
|
346
349
|
fail(`Work mesh project ${projectId} has no Board view. Re-run with --create to add one.`);
|
|
347
350
|
}
|
|
348
|
-
if (!current) {
|
|
349
|
-
// Register requires a genesis thread and a project view. Create the thread
|
|
350
|
-
// before the PUT; register runs after the view exists.
|
|
351
|
-
await ensureProjectThread(token, company, projectId, {
|
|
352
|
-
summary: opts.name?.trim() || source?.name || projectId,
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
351
|
const principalUid = await resolveMeshPrincipalUid(token, loadCachedTokens());
|
|
356
352
|
const PROJECT_CHANGED = "The Board changed while you were editing. Re-run to pick up the latest.";
|
|
357
353
|
let view;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
repos: explicitRepos ?? source?.repos ?? current?.repos ?? [],
|
|
368
|
-
...(current?.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
354
|
+
if (!current) {
|
|
355
|
+
const ensured = await ensureProjectBoard({
|
|
356
|
+
token,
|
|
357
|
+
company,
|
|
358
|
+
projectId,
|
|
359
|
+
name: opts.name?.trim() || source?.name || projectId,
|
|
360
|
+
description: opts.description !== undefined ? opts.description : source?.description ?? "",
|
|
361
|
+
stories: source?.stories ?? [],
|
|
362
|
+
repos: explicitRepos ?? source?.repos ?? [],
|
|
369
363
|
});
|
|
364
|
+
view = ensured.view;
|
|
370
365
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
fail("Work mesh registration did not return a threadId and channelId.");
|
|
366
|
+
else {
|
|
367
|
+
try {
|
|
368
|
+
view = await putProjectView(token, company.companyUid, projectId, {
|
|
369
|
+
name: opts.name?.trim() || source?.name || current.name,
|
|
370
|
+
...(opts.description !== undefined
|
|
371
|
+
? { description: opts.description }
|
|
372
|
+
: source?.description !== undefined
|
|
373
|
+
? { description: source.description }
|
|
374
|
+
: { description: current.description }),
|
|
375
|
+
stories: source?.stories ?? current.stories,
|
|
376
|
+
repos: explicitRepos ?? source?.repos ?? current.repos,
|
|
377
|
+
...(current.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
378
|
+
});
|
|
385
379
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
380
|
+
catch (err) {
|
|
381
|
+
const conflict = err;
|
|
382
|
+
if (conflict.status === 409 && conflict.code === "VERSION_CONFLICT") {
|
|
383
|
+
if (opts.json) {
|
|
384
|
+
console.log(JSON.stringify({ ok: false, code: "VERSION_CONFLICT", error: PROJECT_CHANGED }));
|
|
385
|
+
}
|
|
386
|
+
fail(PROJECT_CHANGED);
|
|
387
|
+
}
|
|
388
|
+
throw err;
|
|
389
389
|
}
|
|
390
390
|
}
|
|
391
391
|
if (opts.json) {
|
|
@@ -422,44 +422,54 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
422
422
|
throw err;
|
|
423
423
|
}
|
|
424
424
|
// Genesis thread, then the view, then register. Register returns 403
|
|
425
|
-
// PROJECT_NOT_IN_COMPANY until both exist.
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (!genesis.threadId)
|
|
430
|
-
fail("Work mesh did not return a threadId.");
|
|
425
|
+
// PROJECT_NOT_IN_COMPANY until both exist. A missing view uses that chain
|
|
426
|
+
// from ensureProjectBoard; an existing view is merged, then registered.
|
|
427
|
+
let registration;
|
|
428
|
+
let verified;
|
|
431
429
|
if (!current) {
|
|
432
|
-
|
|
430
|
+
const ensured = await ensureProjectBoard({
|
|
431
|
+
token,
|
|
432
|
+
company,
|
|
433
|
+
projectId,
|
|
433
434
|
name: source.name || projectId,
|
|
434
435
|
description: source.description ?? "",
|
|
435
436
|
stories: source.stories,
|
|
436
437
|
repos: source.repos ?? [],
|
|
437
438
|
});
|
|
439
|
+
registration = ensured.registration;
|
|
440
|
+
verified = ensured.view;
|
|
438
441
|
}
|
|
439
442
|
else {
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
description
|
|
452
|
-
stories
|
|
453
|
-
repos
|
|
454
|
-
|
|
455
|
-
|
|
443
|
+
const genesis = await ensureProjectThread(token, company, projectId, {
|
|
444
|
+
summary: source.name || projectId,
|
|
445
|
+
});
|
|
446
|
+
if (!genesis.threadId)
|
|
447
|
+
fail("Work mesh did not return a threadId.");
|
|
448
|
+
{
|
|
449
|
+
const stories = mergeProjectStories(current.stories, source.stories);
|
|
450
|
+
const repos = mergeProjectRepos(current.repos, source.repos ?? []);
|
|
451
|
+
const name = source.name || current.name;
|
|
452
|
+
const description = source.description !== undefined ? source.description : current.description;
|
|
453
|
+
const changed = name !== current.name ||
|
|
454
|
+
description !== current.description ||
|
|
455
|
+
JSON.stringify(stories) !== JSON.stringify(current.stories) ||
|
|
456
|
+
JSON.stringify(repos) !== JSON.stringify(current.repos);
|
|
457
|
+
if (changed) {
|
|
458
|
+
await putProjectView(token, company.companyUid, projectId, {
|
|
459
|
+
name,
|
|
460
|
+
description,
|
|
461
|
+
stories,
|
|
462
|
+
repos,
|
|
463
|
+
...(current.version !== undefined ? { expectedVersion: current.version } : {}),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
456
466
|
}
|
|
467
|
+
registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
468
|
+
if (!registration.threadId || !registration.channelId) {
|
|
469
|
+
fail("Work mesh registration did not return a threadId and channelId.");
|
|
470
|
+
}
|
|
471
|
+
verified = await getProjectView(token, company.companyUid, projectId);
|
|
457
472
|
}
|
|
458
|
-
const registration = await registerMeshProject(token, company.companyUid, projectId);
|
|
459
|
-
if (!registration.threadId || !registration.channelId) {
|
|
460
|
-
fail("Work mesh registration did not return a threadId and channelId.");
|
|
461
|
-
}
|
|
462
|
-
const verified = await getProjectView(token, company.companyUid, projectId);
|
|
463
473
|
if (verified.companyUid !== company.companyUid) {
|
|
464
474
|
fail(`Work mesh project ensure: company mismatch (expected ${company.companyUid}, got ${verified.companyUid}).`);
|
|
465
475
|
}
|
|
@@ -503,13 +513,86 @@ async function runDoctor(opts) {
|
|
|
503
513
|
fail("Could not resolve the authenticated principal. Sign in again and retry `hq mesh doctor`.");
|
|
504
514
|
}
|
|
505
515
|
const warmed = await warmMeshConversationCache(token, principalUid);
|
|
516
|
+
const needsProject = listNeedsProjectSessions(workContextHomeRoot());
|
|
506
517
|
if (opts.json) {
|
|
507
|
-
console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed }, null, 2));
|
|
518
|
+
console.log(JSON.stringify({ ok: true, action: "doctor", ...warmed, needsProject }, null, 2));
|
|
508
519
|
return;
|
|
509
520
|
}
|
|
510
521
|
console.log(`Work mesh cache warmed at ${warmed.cacheRoot}` +
|
|
511
522
|
` (directory=${warmed.directory} inbox=${warmed.inbox}` +
|
|
512
523
|
` contacts=${warmed.contacts} pair-threads=${warmed.threads})`);
|
|
524
|
+
if (needsProject.length === 0) {
|
|
525
|
+
console.log("needs_project sessions: none");
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
console.log(`needs_project sessions: ${needsProject.length}`);
|
|
529
|
+
for (const row of needsProject) {
|
|
530
|
+
const choices = row.candidates.map((candidate) => candidate.projectId).join(", ") || "(none)";
|
|
531
|
+
console.log(`- ${row.sessionId}: ${row.reason ?? "no reason recorded"}; candidates: ${choices}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async function runBindProject(target, opts) {
|
|
535
|
+
const sessionId = opts.session.trim();
|
|
536
|
+
const root = workContextHomeRoot();
|
|
537
|
+
const current = readSessionState(sessionId, root);
|
|
538
|
+
if (!current)
|
|
539
|
+
fail(`No work-context state for session ${sessionId}.`);
|
|
540
|
+
let projectId = target.trim();
|
|
541
|
+
if (projectId === "new") {
|
|
542
|
+
const slug = opts.slug?.trim();
|
|
543
|
+
if (!slug)
|
|
544
|
+
fail("Usage: hq mesh context bind-project new <slug> --session <sid>");
|
|
545
|
+
const hqRoot = process.env.HQ_ROOT?.trim() || findHqRoot();
|
|
546
|
+
const prdPath = findLocalPrd(hqRoot, current.companySlug, slug);
|
|
547
|
+
if (!prdPath) {
|
|
548
|
+
fail(`No PRD for ${slug}. Run /prd before binding a new project.`);
|
|
549
|
+
}
|
|
550
|
+
if (!current.companyUid)
|
|
551
|
+
fail(`Session ${sessionId} has no company. Bind a company first.`);
|
|
552
|
+
const { token, company } = await withCompany({ company: opts.company ?? current.companySlug });
|
|
553
|
+
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
|
|
554
|
+
await ensureProjectBoard({
|
|
555
|
+
token,
|
|
556
|
+
company: {
|
|
557
|
+
companyUid: current.companyUid,
|
|
558
|
+
companySlug: current.companySlug || company.companySlug,
|
|
559
|
+
},
|
|
560
|
+
projectId: slug,
|
|
561
|
+
name: plan.name || slug,
|
|
562
|
+
description: plan.description ?? "",
|
|
563
|
+
stories: plan.stories,
|
|
564
|
+
repos: plan.repos ?? [],
|
|
565
|
+
});
|
|
566
|
+
projectId = slug;
|
|
567
|
+
}
|
|
568
|
+
else if (/^[1-9][0-9]*$/.test(projectId)) {
|
|
569
|
+
const index = Number(projectId) - 1;
|
|
570
|
+
const chosen = current.projectCandidates?.[index];
|
|
571
|
+
if (!chosen)
|
|
572
|
+
fail(`No candidate ${projectId} on session ${sessionId}.`);
|
|
573
|
+
projectId = chosen.projectId;
|
|
574
|
+
}
|
|
575
|
+
const updated = updateSessionState(sessionId, root, (state) => {
|
|
576
|
+
if (!state)
|
|
577
|
+
return null;
|
|
578
|
+
return {
|
|
579
|
+
...state,
|
|
580
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
581
|
+
contextStatus: state.taskId ? "bound" : "needs_task",
|
|
582
|
+
projectId,
|
|
583
|
+
projectGate: undefined,
|
|
584
|
+
projectCandidates: undefined,
|
|
585
|
+
projectBindReason: `operator selected ${projectId}`,
|
|
586
|
+
updatedAt: new Date().toISOString(),
|
|
587
|
+
};
|
|
588
|
+
});
|
|
589
|
+
if (!updated)
|
|
590
|
+
fail(`No work-context state for session ${sessionId}.`);
|
|
591
|
+
if (opts.json) {
|
|
592
|
+
console.log(JSON.stringify({ ok: true, action: "context.bind-project", sessionId, projectId, contextStatus: updated.contextStatus }));
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
console.log(`Work mesh: session ${sessionId} bound to project ${projectId}`);
|
|
513
596
|
}
|
|
514
597
|
async function runContextReconcile(opts) {
|
|
515
598
|
if (!opts.observationFile && !opts.observationJson) {
|
|
@@ -600,6 +683,20 @@ async function runContextReconcile(opts) {
|
|
|
600
683
|
fetchCandidates,
|
|
601
684
|
offline,
|
|
602
685
|
validateMembership,
|
|
686
|
+
ensureProjectBoard: token
|
|
687
|
+
? async ({ companyUid, projectId, prdPath }) => {
|
|
688
|
+
const plan = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath);
|
|
689
|
+
await ensureProjectBoard({
|
|
690
|
+
token,
|
|
691
|
+
company: { companyUid, companySlug: companyUid },
|
|
692
|
+
projectId,
|
|
693
|
+
name: plan.name || projectId,
|
|
694
|
+
description: plan.description ?? "",
|
|
695
|
+
stories: plan.stories,
|
|
696
|
+
repos: plan.repos ?? [],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
: undefined,
|
|
603
700
|
});
|
|
604
701
|
for (const line of outcome.diagnostics) {
|
|
605
702
|
console.error(line);
|
|
@@ -1545,6 +1642,15 @@ export function registerMeshCommand(program) {
|
|
|
1545
1642
|
.option("--use-device-default", "Opt in to historical repair using the device default (lower-confidence attribution)")
|
|
1546
1643
|
.option("--json", "Print machine-readable JSON")
|
|
1547
1644
|
.action((opts) => wrap(() => runContextBackfillHeld(opts))());
|
|
1645
|
+
context
|
|
1646
|
+
.command("bind-project")
|
|
1647
|
+
.description("Bind this session to a project id, a numbered candidate, or new <slug>")
|
|
1648
|
+
.argument("<target>", "Project id, candidate number, or new")
|
|
1649
|
+
.argument("[slug]", "Project slug when target is new")
|
|
1650
|
+
.requiredOption("--session <sid>", "Canonical session id")
|
|
1651
|
+
.option("--company <slug|uid>", "Company override when creating a Board")
|
|
1652
|
+
.option("--json", "Print machine-readable JSON")
|
|
1653
|
+
.action((target, slug, opts) => wrap(() => runBindProject(target, { ...opts, slug }))());
|
|
1548
1654
|
context
|
|
1549
1655
|
.command("organize")
|
|
1550
1656
|
.description("List or submit one-time project/task decisions (US-007B / US-005B)")
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import * as fs from "node:fs";
|
|
15
15
|
import * as os from "node:os";
|
|
16
16
|
import { CLI_VERSION } from "../../../cli-version.js";
|
|
17
|
-
import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, parseHeartbeatFailureBody, tokenAgentUid, } from "../../heartbeat-identity.js";
|
|
17
|
+
import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, parseHeartbeatFailureBody, tokenAgentUid, } from "../../heartbeat-identity.js";
|
|
18
18
|
import { vaultApiFetch } from "../../../utils/vault-api.js";
|
|
19
19
|
import { readComponentStatus } from "../creds.js";
|
|
20
20
|
import { KIT_COMPONENTS } from "../paths.js";
|
|
@@ -90,7 +90,7 @@ async function beatOnceResult(deps) {
|
|
|
90
90
|
...(tokenUid ? { tokenAgentUid: tokenUid } : {}),
|
|
91
91
|
});
|
|
92
92
|
const tokenLabel = tokenUid ?? "unknown";
|
|
93
|
-
deps.log("warn",
|
|
93
|
+
deps.log("warn", identityMismatchMessage("heartbeat", deps.agentUid, tokenLabel));
|
|
94
94
|
(deps.reportIdentityMismatch ?? captureHeartbeatIdentityMismatch)({
|
|
95
95
|
runner: "agent-kit",
|
|
96
96
|
configuredAgentUid: deps.agentUid,
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -69,7 +69,7 @@ import { buildJobTurnPrompt, isCatchUpRun, isJobItem, jobReportHeader, jobRunOut
|
|
|
69
69
|
import { clearPendingJobCompletion, isCompletionDue, isCompletionExpired, isCompletionForOtherAgent, readPendingJobCompletions, recordPendingJobCompletion } from "./job-completions.js";
|
|
70
70
|
import { isRuntimeSignInFailure, SIGN_IN_RETRY_MS, signInNeededText } from "./runtime-sign-in.js";
|
|
71
71
|
import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
|
|
72
|
-
import { captureHeartbeatIdentityMismatch, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
72
|
+
import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
73
73
|
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
74
74
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
75
75
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
@@ -459,7 +459,7 @@ export async function runBot(deps) {
|
|
|
459
459
|
if (err instanceof BotApiError &&
|
|
460
460
|
isHeartbeatIdentityMismatch({ status: err.status, code: err.code, message: err.detail })) {
|
|
461
461
|
const tokenLabel = err.tokenAgentUid ?? "unknown";
|
|
462
|
-
const reason =
|
|
462
|
+
const reason = identityMismatchMessage("heartbeat", config.agentUid, tokenLabel);
|
|
463
463
|
setStatus({
|
|
464
464
|
lastHeartbeatOk: false,
|
|
465
465
|
lastHeartbeatError: reason.slice(0, 400),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CaptureContext } from "@sentry/node";
|
|
2
2
|
export declare const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
3
|
+
export declare function identityMismatchMessage(surface: "heartbeat" | "bot jobs", configuredAgentUid: string, tokenAgentUid?: string): string;
|
|
3
4
|
export type HeartbeatRunner = "agent-kit" | "bot";
|
|
4
5
|
export type HeartbeatAgentUidSource = "config_file" | "env" | "flag";
|
|
5
6
|
export type HeartbeatCredsSource = "explicit" | "default";
|
|
@@ -2,6 +2,11 @@ import { CLI_VERSION } from "../cli-version.js";
|
|
|
2
2
|
import { Sentry } from "../sentry.js";
|
|
3
3
|
import { peekIdToken } from "../utils/id-token.js";
|
|
4
4
|
export const HEARTBEAT_IDENTITY_MISMATCH_CODE = "AGENT_IDENTITY_MISMATCH";
|
|
5
|
+
export function identityMismatchMessage(surface, configuredAgentUid, tokenAgentUid) {
|
|
6
|
+
return (`${surface} stopped: identity mismatch configured agentUid=${configuredAgentUid} ` +
|
|
7
|
+
`token agentUid=${tokenAgentUid ?? "unknown"}; fix the bot config to use the token agentUid, ` +
|
|
8
|
+
`or re-mint credentials for the configured agentUid.`);
|
|
9
|
+
}
|
|
5
10
|
const AGENT_UID_RE = /^agt_[A-Za-z0-9_-]{1,100}$/;
|
|
6
11
|
function normalize(value) {
|
|
7
12
|
return (value ?? "").replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
@@ -348,7 +348,26 @@ function defaultProcessCommands() {
|
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
function processHasExactCommand(processCommands, command) {
|
|
351
|
-
|
|
351
|
+
const laneIds = parseCanonicalWatchCommand(command);
|
|
352
|
+
if (!laneIds)
|
|
353
|
+
return false;
|
|
354
|
+
const expectedSuffix = ["lanes", "watch", ...laneIds];
|
|
355
|
+
return processCommands.some((line) => {
|
|
356
|
+
const tokens = line.trim().split(/\s+/u);
|
|
357
|
+
const suffixStart = tokens.length - expectedSuffix.length;
|
|
358
|
+
if (suffixStart < 1)
|
|
359
|
+
return false;
|
|
360
|
+
if (!expectedSuffix.every((token, index) => tokens[suffixStart + index] === token)) {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
const hqToken = tokens[suffixStart - 1];
|
|
364
|
+
if (path.basename(hqToken) !== "hq")
|
|
365
|
+
return false;
|
|
366
|
+
const prefix = tokens.slice(0, suffixStart - 1);
|
|
367
|
+
return (prefix.length === 0 ||
|
|
368
|
+
(prefix.length === 1 &&
|
|
369
|
+
["bun", "deno", "node", "nodejs", "ts-node", "tsx"].includes(path.basename(prefix[0]))));
|
|
370
|
+
});
|
|
352
371
|
}
|
|
353
372
|
export function readLiveMonitorEvidence(sessionId, options = {}) {
|
|
354
373
|
if (!SESSION_ID_PATTERN.test(sessionId)) {
|
|
@@ -16,7 +16,7 @@ export declare const HELD_TTL_MS: number;
|
|
|
16
16
|
export declare const HELD_RETRY_INTERVAL_MS: number;
|
|
17
17
|
/** Cap held.jsonl size; oldest overflow is dead-lettered with HELD_OVERFLOW. */
|
|
18
18
|
export declare const HELD_MAX_LINES = 20000;
|
|
19
|
-
export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
|
|
19
|
+
export type HeldReason = "STATE_ABSENT" | "NEEDS_COMPANY" | "COMPANY_CONFLICT" | "NEEDS_PROJECT" | "HELD_EXPIRED" | "HELD_OVERFLOW";
|
|
20
20
|
export interface HeldLineRecord {
|
|
21
21
|
/** Original spool event fields. */
|
|
22
22
|
event: Record<string, unknown>;
|
|
@@ -12,7 +12,7 @@ import * as fs from "node:fs";
|
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { resolveCompany } from "../../work-context/company.js";
|
|
14
14
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
15
|
-
import { readSessionState } from "../../work-context/state.js";
|
|
15
|
+
import { readSessionState, updateSessionState } from "../../work-context/state.js";
|
|
16
16
|
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
17
17
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
18
18
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
@@ -222,6 +222,7 @@ export async function flushSessionEvents(deps) {
|
|
|
222
222
|
const markDisposed = (event) => {
|
|
223
223
|
pendingRestore.delete(event);
|
|
224
224
|
};
|
|
225
|
+
const loggedProject404 = new Set();
|
|
225
226
|
for (const [sessionId, items] of bySession) {
|
|
226
227
|
const state = readSessionState(sessionId, deps.workContextRoot);
|
|
227
228
|
if (state?.companyConfidence === "device_default" &&
|
|
@@ -286,6 +287,36 @@ export async function flushSessionEvents(deps) {
|
|
|
286
287
|
accountBatchSuccess(result.body, originalChunk, deps.workMeshRoot, now, summary, markDisposed);
|
|
287
288
|
continue;
|
|
288
289
|
}
|
|
290
|
+
if (!result.retryable && result.status === 404) {
|
|
291
|
+
const rejectedProjectId = decision.projectId;
|
|
292
|
+
updateSessionState(sessionId, deps.workContextRoot, (current) => {
|
|
293
|
+
if (!current)
|
|
294
|
+
return current;
|
|
295
|
+
return {
|
|
296
|
+
...current,
|
|
297
|
+
contextStatus: "needs_project",
|
|
298
|
+
projectGate: "missing_board",
|
|
299
|
+
projectBindReason: "server returned HTTP 404 for this session's project",
|
|
300
|
+
projectCandidates: rejectedProjectId
|
|
301
|
+
? [{ projectId: rejectedProjectId }]
|
|
302
|
+
: current.projectCandidates,
|
|
303
|
+
projectId: undefined,
|
|
304
|
+
taskId: undefined,
|
|
305
|
+
updatedAt: now().toISOString(),
|
|
306
|
+
};
|
|
307
|
+
});
|
|
308
|
+
if (!loggedProject404.has(sessionId)) {
|
|
309
|
+
loggedProject404.add(sessionId);
|
|
310
|
+
deps.log?.(`work-mesh: session ${sessionId} project missing (HTTP 404); downgraded to needs_project and held events`);
|
|
311
|
+
}
|
|
312
|
+
const heldAt = now().toISOString();
|
|
313
|
+
for (const item of originalChunk) {
|
|
314
|
+
appendHeldLine(heldEnvelope(item.event, "NEEDS_PROJECT", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
315
|
+
summary.held += 1;
|
|
316
|
+
markDisposed(item.event);
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
289
320
|
if (!result.retryable) {
|
|
290
321
|
const at = now().toISOString();
|
|
291
322
|
for (const item of originalChunk) {
|
|
@@ -484,6 +515,9 @@ function classifySession(state) {
|
|
|
484
515
|
if (state.contextStatus === "company_conflict") {
|
|
485
516
|
return { action: "hold", heldReason: "COMPANY_CONFLICT" };
|
|
486
517
|
}
|
|
518
|
+
if (state.projectGate === "choose" || state.projectGate === "missing_board") {
|
|
519
|
+
return { action: "hold", heldReason: "NEEDS_PROJECT" };
|
|
520
|
+
}
|
|
487
521
|
if (!WRITABLE_CONTEXT_STATUSES.has(state.contextStatus)) {
|
|
488
522
|
return { action: "hold", heldReason: "STATE_ABSENT" };
|
|
489
523
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
|
|
4
|
+
* the genesis thread and the project view both exist, so the order is fixed:
|
|
5
|
+
* thread, view, register, then a GET to confirm the view.
|
|
6
|
+
*/
|
|
7
|
+
export declare function ensureProjectBoard(input: {
|
|
8
|
+
token: string;
|
|
9
|
+
company: MeshCompany;
|
|
10
|
+
projectId: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
stories: MeshProjectStoryInput[];
|
|
14
|
+
repos?: MeshProjectRepo[];
|
|
15
|
+
}): Promise<{
|
|
16
|
+
view: MeshProjectView;
|
|
17
|
+
registration: MeshProjectRegistration;
|
|
18
|
+
}>;
|
|
19
|
+
//# sourceMappingURL=project-board.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
|
|
4
|
+
* the genesis thread and the project view both exist, so the order is fixed:
|
|
5
|
+
* thread, view, register, then a GET to confirm the view.
|
|
6
|
+
*/
|
|
7
|
+
export async function ensureProjectBoard(input) {
|
|
8
|
+
const genesis = await ensureProjectThread(input.token, input.company, input.projectId, {
|
|
9
|
+
summary: input.name || input.projectId,
|
|
10
|
+
});
|
|
11
|
+
if (!genesis.threadId) {
|
|
12
|
+
throw new Error("Work mesh did not return a threadId.");
|
|
13
|
+
}
|
|
14
|
+
await putProjectView(input.token, input.company.companyUid, input.projectId, {
|
|
15
|
+
name: input.name || input.projectId,
|
|
16
|
+
description: input.description ?? "",
|
|
17
|
+
stories: input.stories,
|
|
18
|
+
repos: input.repos ?? [],
|
|
19
|
+
});
|
|
20
|
+
const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
|
|
21
|
+
const view = await getProjectView(input.token, input.company.companyUid, input.projectId);
|
|
22
|
+
if (view.projectId !== input.projectId || view.companyUid !== input.company.companyUid) {
|
|
23
|
+
throw new Error(`Work mesh project board does not match ${input.company.companyUid}/${input.projectId}.`);
|
|
24
|
+
}
|
|
25
|
+
return { view, registration };
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=project-board.js.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-036: pick the best existing project for a session hint.
|
|
3
|
+
* Exact Board, then exact local PRD, then a ranked match.
|
|
4
|
+
* A tie or an empty field stays unbound so the session can ask.
|
|
5
|
+
*/
|
|
6
|
+
export interface BindCandidate {
|
|
7
|
+
projectId: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
score: number;
|
|
10
|
+
}
|
|
11
|
+
export interface CatalogProject {
|
|
12
|
+
projectId: string;
|
|
13
|
+
title?: string;
|
|
14
|
+
repos: string[];
|
|
15
|
+
journalMtimeMs?: number;
|
|
16
|
+
hasPrd: boolean;
|
|
17
|
+
hasBoard: boolean;
|
|
18
|
+
prdPath?: string;
|
|
19
|
+
}
|
|
20
|
+
export type RefineAction = {
|
|
21
|
+
action: "keep";
|
|
22
|
+
} | {
|
|
23
|
+
action: "bind";
|
|
24
|
+
projectId: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
} | {
|
|
27
|
+
action: "create";
|
|
28
|
+
projectId: string;
|
|
29
|
+
prdPath: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
} | {
|
|
32
|
+
action: "ask";
|
|
33
|
+
reason: string;
|
|
34
|
+
candidates: BindCandidate[];
|
|
35
|
+
};
|
|
36
|
+
export declare function scoreProject(hint: string, row: CatalogProject, repoPath: string | undefined, nowMs: number): number;
|
|
37
|
+
export declare function discoverProjects(input: {
|
|
38
|
+
hqRoot?: string;
|
|
39
|
+
companySlug?: string;
|
|
40
|
+
companyUid?: string;
|
|
41
|
+
env?: NodeJS.ProcessEnv;
|
|
42
|
+
home?: string;
|
|
43
|
+
}): CatalogProject[];
|
|
44
|
+
export declare function refineProjectBinding(input: {
|
|
45
|
+
hint?: string;
|
|
46
|
+
hqRoot?: string;
|
|
47
|
+
companySlug?: string;
|
|
48
|
+
companyUid?: string;
|
|
49
|
+
repoPath?: string;
|
|
50
|
+
env?: NodeJS.ProcessEnv;
|
|
51
|
+
home?: string;
|
|
52
|
+
nowMs?: number;
|
|
53
|
+
}): RefineAction;
|
|
54
|
+
export declare function findLocalPrd(hqRoot: string | undefined, companySlug: string | undefined, projectId: string): string | null;
|
|
55
|
+
export declare function listNeedsProjectSessions(workContextRoot: string): Array<{
|
|
56
|
+
sessionId: string;
|
|
57
|
+
reason?: string;
|
|
58
|
+
candidates: BindCandidate[];
|
|
59
|
+
}>;
|
|
60
|
+
export declare function formatProjectAsk(candidates: Array<{
|
|
61
|
+
projectId: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
}>): string;
|
|
64
|
+
//# sourceMappingURL=bind-best.d.ts.map
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-036: pick the best existing project for a session hint.
|
|
3
|
+
* Exact Board, then exact local PRD, then a ranked match.
|
|
4
|
+
* A tie or an empty field stays unbound so the session can ask.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { meshCacheRoot } from "../mesh/cache.js";
|
|
10
|
+
const MARGIN = 15;
|
|
11
|
+
const CLEAR_FLOOR = 25;
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
function tokens(value) {
|
|
14
|
+
return value
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.split(/[^a-z0-9]+/)
|
|
17
|
+
.filter((part) => part.length > 2);
|
|
18
|
+
}
|
|
19
|
+
export function scoreProject(hint, row, repoPath, nowMs) {
|
|
20
|
+
const hintL = hint.toLowerCase();
|
|
21
|
+
const idL = row.projectId.toLowerCase();
|
|
22
|
+
let score = 0;
|
|
23
|
+
if (idL === hintL)
|
|
24
|
+
score += 100;
|
|
25
|
+
else if (idL.startsWith(hintL) || hintL.startsWith(idL))
|
|
26
|
+
score += 40;
|
|
27
|
+
const hintTokens = new Set(tokens(hint));
|
|
28
|
+
const titleTokens = new Set(tokens(`${row.projectId} ${row.title ?? ""}`));
|
|
29
|
+
for (const token of hintTokens) {
|
|
30
|
+
if (titleTokens.has(token))
|
|
31
|
+
score += 15;
|
|
32
|
+
}
|
|
33
|
+
if (repoPath) {
|
|
34
|
+
const repoL = repoPath.toLowerCase();
|
|
35
|
+
if (row.repos.some((repo) => {
|
|
36
|
+
const item = repo.toLowerCase();
|
|
37
|
+
return item.length > 0 && (repoL.includes(item) || item.includes(repoL));
|
|
38
|
+
})) {
|
|
39
|
+
score += 50;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (row.journalMtimeMs) {
|
|
43
|
+
const age = nowMs - row.journalMtimeMs;
|
|
44
|
+
if (age >= 0 && age < 14 * DAY_MS)
|
|
45
|
+
score += 10;
|
|
46
|
+
else if (age >= 0 && age < 60 * DAY_MS)
|
|
47
|
+
score += 5;
|
|
48
|
+
}
|
|
49
|
+
return score;
|
|
50
|
+
}
|
|
51
|
+
function readJson(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
54
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function reposFromMetadata(metadata) {
|
|
61
|
+
if (!metadata || typeof metadata !== "object")
|
|
62
|
+
return [];
|
|
63
|
+
const repos = metadata.repos;
|
|
64
|
+
if (!Array.isArray(repos))
|
|
65
|
+
return [];
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const repo of repos) {
|
|
68
|
+
if (typeof repo === "string" && repo.trim())
|
|
69
|
+
out.push(repo.trim());
|
|
70
|
+
else if (repo && typeof repo === "object" && typeof repo.path === "string") {
|
|
71
|
+
const value = repo.path.trim();
|
|
72
|
+
if (value)
|
|
73
|
+
out.push(value);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function journalMtime(dir) {
|
|
79
|
+
const candidates = ["journal.md", "journal.json", path.join("journal", "index.md")];
|
|
80
|
+
let newest = 0;
|
|
81
|
+
for (const name of candidates) {
|
|
82
|
+
try {
|
|
83
|
+
const stat = fs.statSync(path.join(dir, name));
|
|
84
|
+
if (stat.mtimeMs > newest)
|
|
85
|
+
newest = stat.mtimeMs;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* absent */
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const journalDir = path.join(dir, "journal");
|
|
93
|
+
for (const name of fs.readdirSync(journalDir)) {
|
|
94
|
+
const stat = fs.statSync(path.join(journalDir, name));
|
|
95
|
+
if (stat.mtimeMs > newest)
|
|
96
|
+
newest = stat.mtimeMs;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* absent */
|
|
101
|
+
}
|
|
102
|
+
return newest > 0 ? newest : undefined;
|
|
103
|
+
}
|
|
104
|
+
function absorbDir(map, dir, projectId) {
|
|
105
|
+
const prdPath = path.join(dir, "prd.json");
|
|
106
|
+
const prd = fs.existsSync(prdPath) ? readJson(prdPath) : null;
|
|
107
|
+
const current = map.get(projectId) ?? {
|
|
108
|
+
projectId,
|
|
109
|
+
repos: [],
|
|
110
|
+
hasPrd: false,
|
|
111
|
+
hasBoard: false,
|
|
112
|
+
};
|
|
113
|
+
if (prd) {
|
|
114
|
+
current.hasPrd = true;
|
|
115
|
+
current.prdPath = prdPath;
|
|
116
|
+
const title = typeof prd.name === "string" ? prd.name
|
|
117
|
+
: typeof prd.title === "string" ? prd.title
|
|
118
|
+
: undefined;
|
|
119
|
+
if (title)
|
|
120
|
+
current.title = title;
|
|
121
|
+
const repos = reposFromMetadata(prd.metadata);
|
|
122
|
+
if (repos.length)
|
|
123
|
+
current.repos = repos;
|
|
124
|
+
}
|
|
125
|
+
const journal = journalMtime(dir);
|
|
126
|
+
if (journal)
|
|
127
|
+
current.journalMtimeMs = Math.max(current.journalMtimeMs ?? 0, journal);
|
|
128
|
+
map.set(projectId, current);
|
|
129
|
+
}
|
|
130
|
+
function listProjectDirs(parent, map) {
|
|
131
|
+
if (!fs.existsSync(parent))
|
|
132
|
+
return;
|
|
133
|
+
let names;
|
|
134
|
+
try {
|
|
135
|
+
names = fs.readdirSync(parent);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const name of names) {
|
|
141
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name))
|
|
142
|
+
continue;
|
|
143
|
+
const dir = path.join(parent, name);
|
|
144
|
+
try {
|
|
145
|
+
if (!fs.statSync(dir).isDirectory())
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
absorbDir(map, dir, name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export function discoverProjects(input) {
|
|
155
|
+
const map = new Map();
|
|
156
|
+
const hqRoot = input.hqRoot?.trim();
|
|
157
|
+
const slug = input.companySlug?.trim();
|
|
158
|
+
if (hqRoot && slug) {
|
|
159
|
+
listProjectDirs(path.join(hqRoot, "companies", slug, "projects"), map);
|
|
160
|
+
listProjectDirs(path.join(hqRoot, "personal", "projects"), map);
|
|
161
|
+
}
|
|
162
|
+
if (input.companyUid) {
|
|
163
|
+
const cache = path.join(meshCacheRoot(input.home ?? os.homedir(), input.env), "projects", input.companyUid);
|
|
164
|
+
if (fs.existsSync(cache)) {
|
|
165
|
+
for (const name of fs.readdirSync(cache)) {
|
|
166
|
+
if (!name.endsWith(".json"))
|
|
167
|
+
continue;
|
|
168
|
+
const projectId = name.slice(0, -".json".length);
|
|
169
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(projectId))
|
|
170
|
+
continue;
|
|
171
|
+
const view = readJson(path.join(cache, name));
|
|
172
|
+
const current = map.get(projectId) ?? {
|
|
173
|
+
projectId,
|
|
174
|
+
repos: [],
|
|
175
|
+
hasPrd: false,
|
|
176
|
+
hasBoard: false,
|
|
177
|
+
};
|
|
178
|
+
current.hasBoard = true;
|
|
179
|
+
const title = typeof view?.name === "string" ? view.name : undefined;
|
|
180
|
+
if (title && !current.title)
|
|
181
|
+
current.title = title;
|
|
182
|
+
const repos = Array.isArray(view?.repos) ? reposFromMetadata({ repos: view?.repos }) : [];
|
|
183
|
+
if (repos.length && current.repos.length === 0)
|
|
184
|
+
current.repos = repos;
|
|
185
|
+
map.set(projectId, current);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return [...map.values()];
|
|
190
|
+
}
|
|
191
|
+
export function refineProjectBinding(input) {
|
|
192
|
+
const hint = input.hint?.trim();
|
|
193
|
+
if (!hint)
|
|
194
|
+
return { action: "keep" };
|
|
195
|
+
const catalog = discoverProjects(input);
|
|
196
|
+
const exact = catalog.find((row) => row.projectId === hint);
|
|
197
|
+
if (exact?.hasBoard) {
|
|
198
|
+
return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
|
|
199
|
+
}
|
|
200
|
+
if (exact?.hasPrd && exact.prdPath) {
|
|
201
|
+
return {
|
|
202
|
+
action: "create",
|
|
203
|
+
projectId: hint,
|
|
204
|
+
prdPath: exact.prdPath,
|
|
205
|
+
reason: "exact slug has a local PRD; Board created from that PRD",
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
if (catalog.length === 0)
|
|
209
|
+
return { action: "keep" };
|
|
210
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
211
|
+
const ranked = catalog
|
|
212
|
+
.filter((row) => row.projectId !== hint)
|
|
213
|
+
.map((row) => ({
|
|
214
|
+
projectId: row.projectId,
|
|
215
|
+
title: row.title,
|
|
216
|
+
score: scoreProject(hint, row, input.repoPath, nowMs),
|
|
217
|
+
}))
|
|
218
|
+
.filter((row) => row.score > 0)
|
|
219
|
+
.sort((a, b) => b.score - a.score || a.projectId.localeCompare(b.projectId));
|
|
220
|
+
if (ranked.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
action: "ask",
|
|
223
|
+
reason: `no project matches ${hint}`,
|
|
224
|
+
candidates: [],
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const top = ranked[0];
|
|
228
|
+
const second = ranked[1];
|
|
229
|
+
const clear = top.score >= CLEAR_FLOOR && (!second || top.score - second.score >= MARGIN);
|
|
230
|
+
if (clear) {
|
|
231
|
+
return {
|
|
232
|
+
action: "bind",
|
|
233
|
+
projectId: top.projectId,
|
|
234
|
+
reason: `best match for ${hint}: ${top.projectId} (score ${top.score})`,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
action: "ask",
|
|
239
|
+
reason: `unclear match for ${hint}`,
|
|
240
|
+
candidates: ranked.slice(0, 5).map((row) => ({
|
|
241
|
+
projectId: row.projectId,
|
|
242
|
+
...(row.title ? { title: row.title } : {}),
|
|
243
|
+
score: row.score,
|
|
244
|
+
})),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function findLocalPrd(hqRoot, companySlug, projectId) {
|
|
248
|
+
if (!hqRoot || !companySlug)
|
|
249
|
+
return null;
|
|
250
|
+
const candidates = [
|
|
251
|
+
path.join(hqRoot, "companies", companySlug, "projects", projectId, "prd.json"),
|
|
252
|
+
path.join(hqRoot, "personal", "projects", projectId, "prd.json"),
|
|
253
|
+
];
|
|
254
|
+
return candidates.find((file) => fs.existsSync(file)) ?? null;
|
|
255
|
+
}
|
|
256
|
+
export function listNeedsProjectSessions(workContextRoot) {
|
|
257
|
+
const dir = path.join(workContextRoot, "sessions");
|
|
258
|
+
if (!fs.existsSync(dir))
|
|
259
|
+
return [];
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const name of fs.readdirSync(dir)) {
|
|
262
|
+
if (!name.endsWith(".json"))
|
|
263
|
+
continue;
|
|
264
|
+
const raw = readJson(path.join(dir, name));
|
|
265
|
+
if (!raw || raw.contextStatus !== "needs_project")
|
|
266
|
+
continue;
|
|
267
|
+
const sessionId = typeof raw.sessionId === "string" ? raw.sessionId : name.replace(/\.json$/, "");
|
|
268
|
+
const candidates = Array.isArray(raw.projectCandidates)
|
|
269
|
+
? raw.projectCandidates.flatMap((item) => {
|
|
270
|
+
if (!item || typeof item !== "object")
|
|
271
|
+
return [];
|
|
272
|
+
const projectId = item.projectId;
|
|
273
|
+
if (typeof projectId !== "string" || !projectId)
|
|
274
|
+
return [];
|
|
275
|
+
const title = item.title;
|
|
276
|
+
const score = item.score;
|
|
277
|
+
return [{
|
|
278
|
+
projectId,
|
|
279
|
+
...(typeof title === "string" ? { title } : {}),
|
|
280
|
+
score: typeof score === "number" ? score : 0,
|
|
281
|
+
}];
|
|
282
|
+
})
|
|
283
|
+
: [];
|
|
284
|
+
out.push({
|
|
285
|
+
sessionId,
|
|
286
|
+
...(typeof raw.projectBindReason === "string" ? { reason: raw.projectBindReason } : {}),
|
|
287
|
+
candidates,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return out.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
291
|
+
}
|
|
292
|
+
export function formatProjectAsk(candidates) {
|
|
293
|
+
const parts = candidates.map((candidate, index) => {
|
|
294
|
+
const title = candidate.title?.trim() || candidate.projectId;
|
|
295
|
+
return `${index + 1}) ${candidate.projectId} ${title}`;
|
|
296
|
+
});
|
|
297
|
+
return `This session is not bound to a project. Best matches: ${parts.join(" ")}. Reply with a number, a project id, or \`new <slug>\`.`;
|
|
298
|
+
}
|
|
299
|
+
//# sourceMappingURL=bind-best.js.map
|
|
@@ -61,6 +61,14 @@ export interface ContextResult {
|
|
|
61
61
|
bindingEpisodeId?: string;
|
|
62
62
|
clientOperationId: string;
|
|
63
63
|
errorCode?: string;
|
|
64
|
+
/** Why a project was chosen, or why the session is asking. Local consumers only. */
|
|
65
|
+
projectBindReason?: string;
|
|
66
|
+
/** Ranked alternatives when classification is needs_project. */
|
|
67
|
+
projectCandidates?: Array<{
|
|
68
|
+
projectId: string;
|
|
69
|
+
title?: string;
|
|
70
|
+
score?: number;
|
|
71
|
+
}>;
|
|
64
72
|
}
|
|
65
73
|
export type NormalizeTaskIdResult = {
|
|
66
74
|
ok: true;
|
|
@@ -69,6 +69,15 @@ export interface ReconcileDeps {
|
|
|
69
69
|
* companyUid when local resolution only yielded a slug. Injectable for tests.
|
|
70
70
|
*/
|
|
71
71
|
validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
|
|
72
|
+
/**
|
|
73
|
+
* Register a Board and PUT the local PRD. Called only when online and the
|
|
74
|
+
* exact slug already has a local project folder.
|
|
75
|
+
*/
|
|
76
|
+
ensureProjectBoard?: (input: {
|
|
77
|
+
companyUid: string;
|
|
78
|
+
projectId: string;
|
|
79
|
+
prdPath: string;
|
|
80
|
+
}) => Promise<void>;
|
|
72
81
|
}
|
|
73
82
|
export interface ReconcileOutcome {
|
|
74
83
|
result: ContextResult;
|
|
@@ -9,6 +9,7 @@ import { invalidateDefaultCompanyMemberships, } from "./config.js";
|
|
|
9
9
|
import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
10
10
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
11
11
|
import { decisionFromCandidates, } from "./organize.js";
|
|
12
|
+
import { refineProjectBinding } from "./bind-best.js";
|
|
12
13
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
13
14
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
14
15
|
import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
@@ -35,6 +36,10 @@ function resultOf(parts) {
|
|
|
35
36
|
out.bindingEpisodeId = parts.bindingEpisodeId;
|
|
36
37
|
if (parts.errorCode)
|
|
37
38
|
out.errorCode = parts.errorCode;
|
|
39
|
+
if (parts.projectBindReason)
|
|
40
|
+
out.projectBindReason = parts.projectBindReason;
|
|
41
|
+
if (parts.projectCandidates)
|
|
42
|
+
out.projectCandidates = parts.projectCandidates;
|
|
38
43
|
return out;
|
|
39
44
|
}
|
|
40
45
|
function parseUntrackedOrigin(obs) {
|
|
@@ -441,8 +446,77 @@ export async function reconcileObservation(obs, deps) {
|
|
|
441
446
|
projectResolved = null;
|
|
442
447
|
}
|
|
443
448
|
}
|
|
444
|
-
|
|
445
|
-
|
|
449
|
+
let projectId = projectResolved?.projectId;
|
|
450
|
+
let taskId = projectResolved?.taskId;
|
|
451
|
+
let projectBindReason;
|
|
452
|
+
let projectCandidates;
|
|
453
|
+
let projectGate;
|
|
454
|
+
if (projectResolved &&
|
|
455
|
+
projectId &&
|
|
456
|
+
projectResolved.source !== "existing_binding" &&
|
|
457
|
+
obs.source !== "transcript" &&
|
|
458
|
+
hqRoot &&
|
|
459
|
+
resolvedCompany.slug) {
|
|
460
|
+
const refined = refineProjectBinding({
|
|
461
|
+
hint: projectId,
|
|
462
|
+
hqRoot,
|
|
463
|
+
companySlug: resolvedCompany.slug,
|
|
464
|
+
companyUid: resolvedCompany.uid,
|
|
465
|
+
repoPath: cwd,
|
|
466
|
+
env,
|
|
467
|
+
nowMs: nowFn().getTime(),
|
|
468
|
+
});
|
|
469
|
+
if (refined.action === "bind") {
|
|
470
|
+
if (refined.projectId !== projectId)
|
|
471
|
+
taskId = undefined;
|
|
472
|
+
projectId = refined.projectId;
|
|
473
|
+
projectBindReason = refined.reason;
|
|
474
|
+
diagnostics.push(`project bind: ${refined.reason}`);
|
|
475
|
+
}
|
|
476
|
+
else if (refined.action === "create") {
|
|
477
|
+
projectBindReason = refined.reason;
|
|
478
|
+
if (!deps.offline && resolvedCompany.uid && deps.ensureProjectBoard) {
|
|
479
|
+
try {
|
|
480
|
+
await deps.ensureProjectBoard({
|
|
481
|
+
companyUid: resolvedCompany.uid,
|
|
482
|
+
projectId,
|
|
483
|
+
prdPath: refined.prdPath,
|
|
484
|
+
});
|
|
485
|
+
diagnostics.push(`project board created for ${projectId}`);
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
489
|
+
diagnostics.push(`project board create failed: ${message}`);
|
|
490
|
+
projectBindReason = `Board create failed for ${projectId}: ${message}`;
|
|
491
|
+
projectCandidates = [{ projectId, title: projectId }];
|
|
492
|
+
projectGate = "choose";
|
|
493
|
+
projectId = undefined;
|
|
494
|
+
taskId = undefined;
|
|
495
|
+
projectResolved = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
diagnostics.push(`project bind: local PRD for ${projectId}; Board create waits for online reconcile`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
else if (refined.action === "ask") {
|
|
503
|
+
projectBindReason = refined.reason;
|
|
504
|
+
projectCandidates = refined.candidates;
|
|
505
|
+
projectGate = "choose";
|
|
506
|
+
projectId = undefined;
|
|
507
|
+
taskId = undefined;
|
|
508
|
+
projectResolved = null;
|
|
509
|
+
diagnostics.push(`project ask: ${refined.reason}`);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const bindResultFields = {
|
|
513
|
+
...(projectBindReason ? { projectBindReason } : {}),
|
|
514
|
+
...(projectCandidates ? { projectCandidates } : {}),
|
|
515
|
+
};
|
|
516
|
+
const bindStateFields = {
|
|
517
|
+
...bindResultFields,
|
|
518
|
+
...(projectGate ? { projectGate } : {}),
|
|
519
|
+
};
|
|
446
520
|
// Trusted / deterministic auto-bind never asks.
|
|
447
521
|
// Transcript-sourced sessions never ask (US-018).
|
|
448
522
|
const fromTranscript = obs.source === "transcript";
|
|
@@ -457,7 +531,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
457
531
|
(projectResolved.source === "deterministic_cwd" ||
|
|
458
532
|
projectResolved.source === "deterministic_registry"));
|
|
459
533
|
let classification;
|
|
460
|
-
if (
|
|
534
|
+
if (projectGate === "choose") {
|
|
535
|
+
classification = "needs_project";
|
|
536
|
+
}
|
|
537
|
+
else if (autoBound) {
|
|
461
538
|
classification = "bound";
|
|
462
539
|
}
|
|
463
540
|
else if (projectId && taskId) {
|
|
@@ -569,6 +646,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
569
646
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
570
647
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
571
648
|
...(decision ? { decision } : {}),
|
|
649
|
+
...bindStateFields,
|
|
572
650
|
...transcriptFields,
|
|
573
651
|
}, prior), deps.root);
|
|
574
652
|
const kind = registerClassification === "bound"
|
|
@@ -591,6 +669,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
591
669
|
projectId,
|
|
592
670
|
taskId,
|
|
593
671
|
bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
|
|
672
|
+
...bindResultFields,
|
|
594
673
|
}),
|
|
595
674
|
exitCode: EXIT_OK,
|
|
596
675
|
diagnostics,
|
|
@@ -652,6 +731,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
652
731
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
653
732
|
...(decision ? { decision } : {}),
|
|
654
733
|
...(companyConfidence ? { companyConfidence } : {}),
|
|
734
|
+
...bindStateFields,
|
|
655
735
|
...transcriptFields,
|
|
656
736
|
}, prior);
|
|
657
737
|
statePath = writeSessionState(stateWrite, deps.root);
|
|
@@ -690,6 +770,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
690
770
|
companySlug: resolvedCompany.slug,
|
|
691
771
|
projectId,
|
|
692
772
|
taskId,
|
|
773
|
+
...bindResultFields,
|
|
693
774
|
}),
|
|
694
775
|
exitCode: EXIT_OK,
|
|
695
776
|
diagnostics: [...diagnostics, "offline/local: operation remains queued"],
|
|
@@ -736,6 +817,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
736
817
|
startedAt,
|
|
737
818
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
738
819
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
820
|
+
...bindStateFields,
|
|
739
821
|
...transcriptFields,
|
|
740
822
|
},
|
|
741
823
|
// Merge against the just-written projection rather than the stale
|
|
@@ -761,6 +843,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
761
843
|
projectId,
|
|
762
844
|
taskId,
|
|
763
845
|
bindingEpisodeId: delivered.receiptId,
|
|
846
|
+
...bindResultFields,
|
|
764
847
|
}),
|
|
765
848
|
exitCode: EXIT_OK,
|
|
766
849
|
diagnostics: [...diagnostics, `acked receipt=${delivered.receiptId}`],
|
|
@@ -781,6 +864,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
781
864
|
projectId,
|
|
782
865
|
taskId,
|
|
783
866
|
errorCode: delivered.code,
|
|
867
|
+
...bindResultFields,
|
|
784
868
|
}),
|
|
785
869
|
exitCode: EXIT_OK,
|
|
786
870
|
diagnostics: [...diagnostics, `transient ${delivered.code}; remains queued`],
|
|
@@ -801,6 +885,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
801
885
|
projectId,
|
|
802
886
|
taskId,
|
|
803
887
|
errorCode: delivered.code,
|
|
888
|
+
...bindResultFields,
|
|
804
889
|
}),
|
|
805
890
|
exitCode: EXIT_OK,
|
|
806
891
|
diagnostics: [...diagnostics, `quarantined: ${delivered.code}`],
|
|
@@ -91,6 +91,19 @@ export interface SessionStateFile {
|
|
|
91
91
|
transcriptMarker?: "from transcript";
|
|
92
92
|
/** Historical repair under device-default evidence is intentionally weak. */
|
|
93
93
|
companyConfidence?: "device_default";
|
|
94
|
+
/** Why the current project was chosen, or why the session is asking. */
|
|
95
|
+
projectBindReason?: string;
|
|
96
|
+
/** Ranked alternatives when the session is waiting for a project choice. */
|
|
97
|
+
projectCandidates?: Array<{
|
|
98
|
+
projectId: string;
|
|
99
|
+
title?: string;
|
|
100
|
+
score?: number;
|
|
101
|
+
}>;
|
|
102
|
+
/**
|
|
103
|
+
* When set, flush holds events instead of posting them.
|
|
104
|
+
* `choose` — the match was unclear. `missing_board` — the server 404'd the project.
|
|
105
|
+
*/
|
|
106
|
+
projectGate?: "choose" | "missing_board";
|
|
94
107
|
updatedAt: string;
|
|
95
108
|
}
|
|
96
109
|
/**
|
|
@@ -29,6 +29,9 @@ export const SESSION_STATE_ALLOWLIST = new Set([
|
|
|
29
29
|
"localHarness",
|
|
30
30
|
"transcriptMarker",
|
|
31
31
|
"companyConfidence",
|
|
32
|
+
"projectBindReason",
|
|
33
|
+
"projectCandidates",
|
|
34
|
+
"projectGate",
|
|
32
35
|
"updatedAt",
|
|
33
36
|
]);
|
|
34
37
|
const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -228,6 +231,12 @@ export function projectSessionState(state) {
|
|
|
228
231
|
out.transcriptMarker = state.transcriptMarker;
|
|
229
232
|
if (state.companyConfidence)
|
|
230
233
|
out.companyConfidence = state.companyConfidence;
|
|
234
|
+
if (state.projectBindReason)
|
|
235
|
+
out.projectBindReason = state.projectBindReason;
|
|
236
|
+
if (state.projectCandidates?.length)
|
|
237
|
+
out.projectCandidates = state.projectCandidates;
|
|
238
|
+
if (state.projectGate)
|
|
239
|
+
out.projectGate = state.projectGate;
|
|
231
240
|
return out;
|
|
232
241
|
}
|
|
233
242
|
/**
|