@indigoai-us/hq-cli 5.145.0 → 5.147.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/command-catalog.generated.d.ts +18 -0
  3. package/dist/command-catalog.generated.js +23 -0
  4. package/dist/commands/mesh.js +132 -1
  5. package/dist/commands/report.d.ts +6 -0
  6. package/dist/commands/report.js +23 -5
  7. package/dist/lib/mesh/api.d.ts +1 -1
  8. package/dist/lib/mesh/api.js +6 -2
  9. package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
  10. package/dist/lib/mesh/live/daemon/credentials.js +141 -10
  11. package/dist/lib/mesh/live/daemon/doctor.js +4 -3
  12. package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
  13. package/dist/lib/mesh/live/daemon/presence.js +69 -1
  14. package/dist/lib/mesh/live/daemon/run.js +59 -3
  15. package/dist/lib/mesh/live/flush.d.ts +8 -0
  16. package/dist/lib/mesh/live/flush.js +71 -9
  17. package/dist/lib/mesh/live/session-seq.d.ts +4 -0
  18. package/dist/lib/mesh/live/session-seq.js +33 -0
  19. package/dist/lib/mesh/project-board.d.ts +14 -0
  20. package/dist/lib/mesh/project-board.js +32 -1
  21. package/dist/lib/work-context/bind-best.d.ts +2 -0
  22. package/dist/lib/work-context/bind-best.js +34 -1
  23. package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
  24. package/dist/lib/work-context/bind-calling-session.js +63 -0
  25. package/dist/lib/work-context/prd-sync.d.ts +34 -0
  26. package/dist/lib/work-context/prd-sync.js +120 -0
  27. package/dist/lib/work-context/prompt-rebind.d.ts +25 -0
  28. package/dist/lib/work-context/prompt-rebind.js +85 -0
  29. package/dist/lib/work-context/reconcile.d.ts +2 -0
  30. package/dist/lib/work-context/reconcile.js +31 -0
  31. package/dist/lib/work-context/state.d.ts +2 -0
  32. package/dist/lib/work-context/state.js +8 -0
  33. package/package.json +1 -1
@@ -1,4 +1,18 @@
1
1
  import { type MeshCompany, type MeshProjectRegistration, type MeshProjectRepo, type MeshProjectStoryInput, type MeshProjectView } from "./api.js";
2
+ /**
3
+ * POST /v1/work-mesh/projects/{id}/register always binds the server-derived
4
+ * proj- thread (projectGenesisThreadId). It does not accept a client threadId.
5
+ * Close a thread only when this same ensure created it and register returned
6
+ * a different canonical id. An already-open thread stays open on a re-run.
7
+ * The Board view reads the registered thread.
8
+ */
9
+ export declare function closeSupersededProjectThread(input: {
10
+ token: string;
11
+ companyUid: string;
12
+ openedThreadId: string;
13
+ canonicalThreadId: string;
14
+ created: boolean;
15
+ }): Promise<void>;
2
16
  /**
3
17
  * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
18
  * the genesis thread and the project view both exist, so the order is fixed:
@@ -1,4 +1,28 @@
1
- import { ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
1
+ import { appendThreadEvent, ensureProjectThread, getProjectView, putProjectView, registerMeshProject, } from "./api.js";
2
+ /**
3
+ * POST /v1/work-mesh/projects/{id}/register always binds the server-derived
4
+ * proj- thread (projectGenesisThreadId). It does not accept a client threadId.
5
+ * Close a thread only when this same ensure created it and register returned
6
+ * a different canonical id. An already-open thread stays open on a re-run.
7
+ * The Board view reads the registered thread.
8
+ */
9
+ export async function closeSupersededProjectThread(input) {
10
+ if (!input.created)
11
+ return;
12
+ if (!input.openedThreadId || input.openedThreadId === input.canonicalThreadId)
13
+ return;
14
+ try {
15
+ await appendThreadEvent(input.token, input.companyUid, input.openedThreadId, "done", {
16
+ summary: "Superseded by the registered project thread.",
17
+ });
18
+ }
19
+ catch (err) {
20
+ // Best effort: the server may refuse (e.g. only the thread owner may mark
21
+ // it done). A leftover open thread must never fail registration.
22
+ const reason = err instanceof Error ? err.message : String(err);
23
+ process.stderr.write(`Work mesh: left extra thread ${input.openedThreadId} open (${reason.slice(0, 120)})\n`);
24
+ }
25
+ }
2
26
  /**
3
27
  * Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
4
28
  * the genesis thread and the project view both exist, so the order is fixed:
@@ -18,6 +42,13 @@ export async function ensureProjectBoard(input) {
18
42
  repos: input.repos ?? [],
19
43
  });
20
44
  const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
45
+ await closeSupersededProjectThread({
46
+ token: input.token,
47
+ companyUid: input.company.companyUid,
48
+ openedThreadId: genesis.threadId,
49
+ canonicalThreadId: registration.threadId,
50
+ created: genesis.created,
51
+ });
21
52
  const view = await getProjectView(input.token, input.company.companyUid, input.projectId);
22
53
  if (view.projectId !== input.projectId || view.companyUid !== input.company.companyUid) {
23
54
  throw new Error(`Work mesh project board does not match ${input.company.companyUid}/${input.projectId}.`);
@@ -41,6 +41,8 @@ export declare function discoverProjects(input: {
41
41
  env?: NodeJS.ProcessEnv;
42
42
  home?: string;
43
43
  }): CatalogProject[];
44
+ /** discoverProjects memoized by directory mtime. A cache hit does not rescan. */
45
+ export declare function discoverProjectsCached(input: Parameters<typeof discoverProjects>[0], nowMs?: number): CatalogProject[];
44
46
  export declare function refineProjectBinding(input: {
45
47
  hint?: string;
46
48
  hqRoot?: string;
@@ -188,11 +188,44 @@ export function discoverProjects(input) {
188
188
  }
189
189
  return [...map.values()];
190
190
  }
191
+ const CATALOG_CACHE_TTL_MS = 5000;
192
+ let catalogCache = null;
193
+ function catalogStamp(input) {
194
+ const hqRoot = input.hqRoot?.trim();
195
+ const slug = input.companySlug?.trim();
196
+ const dirs = [
197
+ hqRoot && slug ? path.join(hqRoot, "companies", slug, "projects") : "",
198
+ hqRoot ? path.join(hqRoot, "personal", "projects") : "",
199
+ input.companyUid
200
+ ? path.join(meshCacheRoot(input.home ?? os.homedir(), input.env), "projects", input.companyUid)
201
+ : "",
202
+ ];
203
+ return dirs.map((dir) => {
204
+ if (!dir)
205
+ return "0";
206
+ try {
207
+ return String(fs.statSync(dir).mtimeMs);
208
+ }
209
+ catch {
210
+ return "0";
211
+ }
212
+ }).join("|");
213
+ }
214
+ /** discoverProjects memoized by directory mtime. A cache hit does not rescan. */
215
+ export function discoverProjectsCached(input, nowMs = Date.now()) {
216
+ const key = `${input.hqRoot ?? ""}|${input.companySlug ?? ""}|${input.companyUid ?? ""}|${input.home ?? ""}|${catalogStamp(input)}`;
217
+ if (catalogCache && catalogCache.key === key && nowMs - catalogCache.at < CATALOG_CACHE_TTL_MS) {
218
+ return catalogCache.rows;
219
+ }
220
+ const rows = discoverProjects(input);
221
+ catalogCache = { key, at: nowMs, rows };
222
+ return rows;
223
+ }
191
224
  export function refineProjectBinding(input) {
192
225
  const hint = input.hint?.trim();
193
226
  if (!hint)
194
227
  return { action: "keep" };
195
- const catalog = discoverProjects(input);
228
+ const catalog = discoverProjectsCached(input, input.nowMs ?? Date.now());
196
229
  const exact = catalog.find((row) => row.projectId === hint);
197
230
  if (exact?.hasBoard) {
198
231
  return { action: "bind", projectId: hint, reason: "exact slug has a Board" };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * US-040: when this process ensures a project, bind the calling session
3
+ * if it is still unresolved in the same company. Never move a session
4
+ * that is already on a different project.
5
+ */
6
+ export interface CallingCompany {
7
+ companyUid: string;
8
+ companySlug: string;
9
+ }
10
+ export type BindCallingResult = {
11
+ bound: true;
12
+ sessionId: string;
13
+ projectId: string;
14
+ } | {
15
+ bound: false;
16
+ reason: "no_session" | "no_state" | "company_mismatch" | "different_project" | "already_bound" | "not_unresolved";
17
+ };
18
+ export declare function bindCallingSession(input: {
19
+ root: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ projectId: string;
22
+ company: CallingCompany;
23
+ now?: () => Date;
24
+ }): BindCallingResult;
25
+ export declare function formatSessionBoundLine(sessionId: string, projectId: string): string;
26
+ //# sourceMappingURL=bind-calling-session.d.ts.map
@@ -0,0 +1,63 @@
1
+ /**
2
+ * US-040: when this process ensures a project, bind the calling session
3
+ * if it is still unresolved in the same company. Never move a session
4
+ * that is already on a different project.
5
+ */
6
+ import { sessionIdFromEnv } from "../mesh/live/session-identity.js";
7
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
8
+ import { readSessionState, updateSessionState } from "./state.js";
9
+ function sameCompany(state, company) {
10
+ if (state.companyUid && state.companyUid === company.companyUid)
11
+ return true;
12
+ if (state.companySlug &&
13
+ company.companySlug &&
14
+ state.companySlug.toLowerCase() === company.companySlug.toLowerCase()) {
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ export function bindCallingSession(input) {
20
+ const sessionId = sessionIdFromEnv(input.env ?? process.env);
21
+ if (!sessionId)
22
+ return { bound: false, reason: "no_session" };
23
+ const current = readSessionState(sessionId, input.root);
24
+ if (!current)
25
+ return { bound: false, reason: "no_state" };
26
+ if (!sameCompany(current, input.company))
27
+ return { bound: false, reason: "company_mismatch" };
28
+ if (current.projectId && current.projectId !== input.projectId) {
29
+ return { bound: false, reason: "different_project" };
30
+ }
31
+ if (current.projectId === input.projectId && (current.contextStatus === "bound" || current.contextStatus === "needs_task")) {
32
+ return { bound: false, reason: "already_bound" };
33
+ }
34
+ if (current.contextStatus !== "needs_project" && current.contextStatus !== "unresolved") {
35
+ return { bound: false, reason: "not_unresolved" };
36
+ }
37
+ const nowIso = (input.now ?? (() => new Date()))().toISOString();
38
+ const updated = updateSessionState(sessionId, input.root, (state) => {
39
+ if (!state)
40
+ return null;
41
+ if (state.projectId && state.projectId !== input.projectId)
42
+ return state;
43
+ return {
44
+ ...state,
45
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
46
+ contextStatus: state.taskId ? "bound" : "needs_task",
47
+ projectId: input.projectId,
48
+ projectGate: undefined,
49
+ projectCandidates: undefined,
50
+ projectHint: undefined,
51
+ projectBindReason: `bound by project ensure to ${input.projectId}`,
52
+ updatedAt: nowIso,
53
+ };
54
+ });
55
+ if (!updated || updated.projectId !== input.projectId) {
56
+ return { bound: false, reason: "different_project" };
57
+ }
58
+ return { bound: true, sessionId, projectId: input.projectId };
59
+ }
60
+ export function formatSessionBoundLine(sessionId, projectId) {
61
+ return `Work mesh: session ${sessionId} bound to project ${projectId}`;
62
+ }
63
+ //# sourceMappingURL=bind-calling-session.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * US-040: diff a bound project's prd.json and describe task_status events.
3
+ * Planning never touches the network. The snapshot is written only after
4
+ * the caller accepts the plan (commit), so a failed emit can retry.
5
+ */
6
+ export interface PrdStatusEvent {
7
+ taskId: string;
8
+ status: "done" | "in_progress";
9
+ }
10
+ export interface PrdSnapshot {
11
+ stories: Record<string, {
12
+ passes: boolean;
13
+ status?: string;
14
+ }>;
15
+ seq: number;
16
+ }
17
+ export declare function prdSnapshotPath(sessionId: string, root: string): string;
18
+ export declare function readPrdSnapshot(sessionId: string, root: string): PrdSnapshot | null;
19
+ export declare function prdBelongsToProject(file: string, projectId: string, companySlug: string | undefined): boolean;
20
+ export interface PrdSyncPlan {
21
+ changed: boolean;
22
+ skip?: "no_state" | "not_bound" | "different_project" | "unreadable";
23
+ events: PrdStatusEvent[];
24
+ /** Story most recently moved to in_progress, if any. */
25
+ taskId?: string;
26
+ seqStart: number;
27
+ commit: () => void;
28
+ }
29
+ export declare function planPrdSync(input: {
30
+ sessionId: string;
31
+ file: string;
32
+ root: string;
33
+ }): PrdSyncPlan;
34
+ //# sourceMappingURL=prd-sync.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * US-040: diff a bound project's prd.json and describe task_status events.
3
+ * Planning never touches the network. The snapshot is written only after
4
+ * the caller accepts the plan (commit), so a failed emit can retry.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { readSessionState } from "./state.js";
9
+ export function prdSnapshotPath(sessionId, root) {
10
+ return path.join(root, "sessions", `${sessionId}.prd-snapshot.json`);
11
+ }
12
+ export function readPrdSnapshot(sessionId, root) {
13
+ try {
14
+ const parsed = JSON.parse(fs.readFileSync(prdSnapshotPath(sessionId, root), "utf8"));
15
+ if (!parsed || typeof parsed !== "object" || !parsed.stories)
16
+ return null;
17
+ return { stories: parsed.stories, seq: typeof parsed.seq === "number" ? parsed.seq : 0 };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function writeSnapshot(sessionId, root, snapshot) {
24
+ const dest = prdSnapshotPath(sessionId, root);
25
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
26
+ fs.writeFileSync(dest, `${JSON.stringify(snapshot)}\n`, { mode: 0o600 });
27
+ }
28
+ export function prdBelongsToProject(file, projectId, companySlug) {
29
+ if (!companySlug)
30
+ return false;
31
+ const parts = path.resolve(file).split(path.sep);
32
+ if (parts[parts.length - 1] !== "prd.json")
33
+ return false;
34
+ const projectsAt = parts.lastIndexOf("projects");
35
+ return (projectsAt >= 2 &&
36
+ parts[projectsAt - 2] === "companies" &&
37
+ parts[projectsAt - 1] === companySlug &&
38
+ parts[projectsAt + 1] === projectId &&
39
+ parts.length === projectsAt + 3);
40
+ }
41
+ function storiesFromPrd(file) {
42
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ const rows = Array.isArray(parsed.userStories) ? parsed.userStories
44
+ : Array.isArray(parsed.stories) ? parsed.stories
45
+ : [];
46
+ const out = [];
47
+ for (const row of rows) {
48
+ if (!row || typeof row !== "object")
49
+ continue;
50
+ const id = row.id;
51
+ if (typeof id !== "string" || !id.trim())
52
+ continue;
53
+ const status = row.status;
54
+ out.push({
55
+ id: id.trim(),
56
+ passes: row.passes === true,
57
+ ...(typeof status === "string" && status.trim() ? { status: status.trim() } : {}),
58
+ });
59
+ }
60
+ return out;
61
+ }
62
+ export function planPrdSync(input) {
63
+ const empty = (skip) => ({
64
+ changed: false,
65
+ skip,
66
+ events: [],
67
+ seqStart: 0,
68
+ commit: () => { },
69
+ });
70
+ let state;
71
+ try {
72
+ state = readSessionState(input.sessionId, input.root);
73
+ }
74
+ catch {
75
+ return empty("unreadable");
76
+ }
77
+ if (!state)
78
+ return empty("no_state");
79
+ if (!state.projectId || (state.contextStatus !== "bound" && state.contextStatus !== "needs_task")) {
80
+ return empty("not_bound");
81
+ }
82
+ if (!prdBelongsToProject(input.file, state.projectId, state.companySlug))
83
+ return empty("different_project");
84
+ let stories;
85
+ try {
86
+ stories = storiesFromPrd(input.file);
87
+ }
88
+ catch {
89
+ return empty("unreadable");
90
+ }
91
+ const previous = readPrdSnapshot(input.sessionId, input.root);
92
+ const before = previous?.stories ?? {};
93
+ const events = [];
94
+ let taskId;
95
+ const nextStories = {};
96
+ for (const story of stories) {
97
+ nextStories[story.id] = {
98
+ passes: story.passes,
99
+ ...(story.status ? { status: story.status } : {}),
100
+ };
101
+ const prior = previous ? before[story.id] : undefined;
102
+ if (story.passes && !prior?.passes) {
103
+ events.push({ taskId: story.id, status: "done" });
104
+ continue;
105
+ }
106
+ if (story.status === "in_progress" && prior?.status !== "in_progress") {
107
+ events.push({ taskId: story.id, status: "in_progress" });
108
+ taskId = story.id;
109
+ }
110
+ }
111
+ const snapshot = { stories: nextStories, seq: (previous?.seq ?? 0) + events.length };
112
+ return {
113
+ changed: events.length > 0,
114
+ events,
115
+ ...(taskId ? { taskId } : {}),
116
+ seqStart: previous?.seq ?? 0,
117
+ commit: () => writeSnapshot(input.sessionId, input.root, snapshot),
118
+ };
119
+ }
120
+ //# sourceMappingURL=prd-sync.js.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * US-040: re-run bind-best for a session still in needs_project, or
3
+ * unresolved once it has a company. The prompt hook and the mesh daemon
4
+ * flush both call this. Catalog reads are cached. Failures stay local.
5
+ */
6
+ export type PromptRebindResult = {
7
+ action: "bound" | "skipped" | "unchanged";
8
+ projectId?: string;
9
+ reason: string;
10
+ };
11
+ export declare function rebindNeedsProjectOnPrompt(input: {
12
+ sessionId: string;
13
+ root: string;
14
+ hqRoot?: string;
15
+ cwd?: string;
16
+ companySlug?: string;
17
+ companyUid?: string;
18
+ /** Used when the session has no projectHint (for example a spool `project` field). */
19
+ hint?: string;
20
+ env?: NodeJS.ProcessEnv;
21
+ home?: string;
22
+ nowMs?: number;
23
+ now?: () => Date;
24
+ }): PromptRebindResult;
25
+ //# sourceMappingURL=prompt-rebind.d.ts.map
@@ -0,0 +1,85 @@
1
+ /**
2
+ * US-040: re-run bind-best for a session still in needs_project, or
3
+ * unresolved once it has a company. The prompt hook and the mesh daemon
4
+ * flush both call this. Catalog reads are cached. Failures stay local.
5
+ */
6
+ import { projectIdFromCwd } from "./company.js";
7
+ import { readMetaProjectTask } from "./project.js";
8
+ import { refineProjectBinding } from "./bind-best.js";
9
+ import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
10
+ import { readSessionState, updateSessionState } from "./state.js";
11
+ export function rebindNeedsProjectOnPrompt(input) {
12
+ let state;
13
+ try {
14
+ state = readSessionState(input.sessionId, input.root);
15
+ }
16
+ catch {
17
+ return { action: "skipped", reason: "state_unreadable" };
18
+ }
19
+ if (!state)
20
+ return { action: "skipped", reason: "no_state" };
21
+ const companyUid = input.companyUid ?? state.companyUid;
22
+ const retryable = state.contextStatus === "needs_project" ||
23
+ (state.contextStatus === "unresolved" && Boolean(companyUid));
24
+ if (!retryable) {
25
+ return { action: "skipped", reason: "not_needs_project" };
26
+ }
27
+ if (state.projectId)
28
+ return { action: "skipped", reason: "different_project" };
29
+ const env = input.env ?? process.env;
30
+ const hint = state.projectHint?.trim() ||
31
+ input.hint?.trim() ||
32
+ env.HQ_SPAWN_PROJECT?.trim() ||
33
+ readMetaProjectTask(input.sessionId, input.hqRoot).projectId ||
34
+ projectIdFromCwd(input.cwd, input.hqRoot) ||
35
+ undefined;
36
+ if (!hint)
37
+ return { action: "unchanged", reason: "no_hint" };
38
+ let refined;
39
+ try {
40
+ refined = refineProjectBinding({
41
+ hint,
42
+ hqRoot: input.hqRoot,
43
+ companySlug: input.companySlug ?? state.companySlug,
44
+ companyUid,
45
+ repoPath: input.cwd,
46
+ env,
47
+ home: input.home,
48
+ nowMs: input.nowMs ?? Date.now(),
49
+ });
50
+ }
51
+ catch (err) {
52
+ return { action: "unchanged", reason: err instanceof Error ? err.message : "bind_failed" };
53
+ }
54
+ if (refined.action !== "bind" && refined.action !== "create") {
55
+ return { action: "unchanged", reason: refined.action === "ask" ? refined.reason : "no_match" };
56
+ }
57
+ const projectId = refined.projectId;
58
+ const reason = refined.reason;
59
+ const nowIso = (input.now ?? (() => new Date()))().toISOString();
60
+ const updated = updateSessionState(input.sessionId, input.root, (current) => {
61
+ if (!current || (current.contextStatus !== "needs_project" && current.contextStatus !== "unresolved")) {
62
+ return current;
63
+ }
64
+ if (current.contextStatus === "unresolved" && !current.companyUid)
65
+ return current;
66
+ if (current.projectId && current.projectId !== projectId)
67
+ return current;
68
+ return {
69
+ ...current,
70
+ contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
71
+ contextStatus: current.taskId ? "bound" : "needs_task",
72
+ projectId,
73
+ projectGate: undefined,
74
+ projectCandidates: undefined,
75
+ projectHint: undefined,
76
+ projectBindReason: reason,
77
+ updatedAt: nowIso,
78
+ };
79
+ });
80
+ if (!updated || updated.projectId !== projectId) {
81
+ return { action: "skipped", reason: "not_applied" };
82
+ }
83
+ return { action: "bound", projectId, reason };
84
+ }
85
+ //# sourceMappingURL=prompt-rebind.js.map
@@ -39,6 +39,8 @@ export interface ReconcileObservation {
39
39
  source?: "hooks" | "transcript";
40
40
  /** Optional turn signal for the 10-minute ask threshold (US-007B). */
41
41
  hasTurn?: boolean;
42
+ /** Hook name when the caller is a prompt submit. Session start omits this. */
43
+ hookEvent?: string;
42
44
  toolWrites?: number;
43
45
  /** Prohibited content may appear on the wire-in observation; must be stripped. */
44
46
  prompt?: unknown;
@@ -10,6 +10,7 @@ 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
12
  import { refineProjectBinding } from "./bind-best.js";
13
+ import { rebindNeedsProjectOnPrompt } from "./prompt-rebind.js";
13
14
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
14
15
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
15
16
  import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
@@ -509,6 +510,35 @@ export async function reconcileObservation(obs, deps) {
509
510
  diagnostics.push(`project ask: ${refined.reason}`);
510
511
  }
511
512
  }
513
+ const promptSubmit = obs.hookEvent === "UserPromptSubmit" || obs.hookEvent === "user_prompt_submit";
514
+ if (promptSubmit && prior?.contextStatus === "needs_project" && !projectId && prior.companyUid) {
515
+ try {
516
+ const rebound = rebindNeedsProjectOnPrompt({
517
+ sessionId,
518
+ root: deps.root,
519
+ hqRoot,
520
+ cwd,
521
+ companySlug: resolvedCompany.slug ?? prior.companySlug,
522
+ companyUid: resolvedCompany.uid ?? prior.companyUid,
523
+ env,
524
+ nowMs: nowFn().getTime(),
525
+ now: nowFn,
526
+ });
527
+ if (rebound.action === "bound" && rebound.projectId) {
528
+ projectId = rebound.projectId;
529
+ projectBindReason = rebound.reason;
530
+ projectCandidates = undefined;
531
+ projectGate = undefined;
532
+ diagnostics.push(`prompt rebind: ${rebound.reason}`);
533
+ }
534
+ }
535
+ catch (err) {
536
+ diagnostics.push(`prompt rebind failed: ${err instanceof Error ? err.message : String(err)}`);
537
+ }
538
+ }
539
+ const projectHint = !projectId
540
+ ? (prior?.projectHint || projectResolved?.projectId)
541
+ : undefined;
512
542
  const bindResultFields = {
513
543
  ...(projectBindReason ? { projectBindReason } : {}),
514
544
  ...(projectCandidates ? { projectCandidates } : {}),
@@ -516,6 +546,7 @@ export async function reconcileObservation(obs, deps) {
516
546
  const bindStateFields = {
517
547
  ...bindResultFields,
518
548
  ...(projectGate ? { projectGate } : {}),
549
+ ...(projectHint ? { projectHint } : {}),
519
550
  };
520
551
  // Trusted / deterministic auto-bind never asks.
521
552
  // Transcript-sourced sessions never ask (US-018).
@@ -104,6 +104,8 @@ export interface SessionStateFile {
104
104
  * `choose` — the match was unclear. `missing_board` — the server 404'd the project.
105
105
  */
106
106
  projectGate?: "choose" | "missing_board";
107
+ /** Slug the session was trying to bind. Re-checked on the next prompt. */
108
+ projectHint?: string;
107
109
  updatedAt: string;
108
110
  }
109
111
  /**
@@ -32,6 +32,7 @@ export const SESSION_STATE_ALLOWLIST = new Set([
32
32
  "projectBindReason",
33
33
  "projectCandidates",
34
34
  "projectGate",
35
+ "projectHint",
35
36
  "updatedAt",
36
37
  ]);
37
38
  const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
@@ -237,6 +238,8 @@ export function projectSessionState(state) {
237
238
  out.projectCandidates = state.projectCandidates;
238
239
  if (state.projectGate)
239
240
  out.projectGate = state.projectGate;
241
+ if (state.projectHint && !state.projectId)
242
+ out.projectHint = state.projectHint;
240
243
  return out;
241
244
  }
242
245
  /**
@@ -295,6 +298,11 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
295
298
  if (!out.companyConfidence && prior.companyConfidence && sameCompanyBinding) {
296
299
  out.companyConfidence = prior.companyConfidence;
297
300
  }
301
+ if (!out.projectId && !out.projectHint && prior.projectHint) {
302
+ out.projectHint = prior.projectHint;
303
+ }
304
+ if (out.projectId)
305
+ out.projectHint = undefined;
298
306
  const sameBinding = prior.companyUid === out.companyUid &&
299
307
  prior.companySlug === out.companySlug &&
300
308
  prior.projectId === out.projectId &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.145.0",
3
+ "version": "5.147.0",
4
4
  "description": "HQ by Indigo management CLI \u2014 modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {