@indigoai-us/hq-cli 5.145.0 → 5.146.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 +18 -0
- package/dist/command-catalog.generated.js +23 -0
- package/dist/commands/mesh.js +115 -1
- package/dist/commands/report.d.ts +6 -0
- package/dist/commands/report.js +23 -5
- package/dist/lib/mesh/live/daemon/credentials.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/credentials.js +141 -10
- package/dist/lib/mesh/live/daemon/doctor.js +4 -3
- package/dist/lib/mesh/live/daemon/presence.d.ts +15 -0
- package/dist/lib/mesh/live/daemon/presence.js +69 -1
- package/dist/lib/mesh/live/daemon/run.js +3 -1
- package/dist/lib/mesh/project-board.d.ts +14 -0
- package/dist/lib/mesh/project-board.js +24 -1
- package/dist/lib/work-context/bind-best.d.ts +2 -0
- package/dist/lib/work-context/bind-best.js +34 -1
- package/dist/lib/work-context/bind-calling-session.d.ts +26 -0
- package/dist/lib/work-context/bind-calling-session.js +63 -0
- package/dist/lib/work-context/prd-sync.d.ts +34 -0
- package/dist/lib/work-context/prd-sync.js +120 -0
- package/dist/lib/work-context/prompt-rebind.d.ts +21 -0
- package/dist/lib/work-context/prompt-rebind.js +76 -0
- package/dist/lib/work-context/reconcile.d.ts +2 -0
- package/dist/lib/work-context/reconcile.js +31 -0
- package/dist/lib/work-context/state.d.ts +2 -0
- package/dist/lib/work-context/state.js +8 -0
- package/package.json +1 -1
|
@@ -84,6 +84,13 @@ export interface PresenceClientOptions {
|
|
|
84
84
|
onRefusal?: (refusal: PresenceRefusal | null) => void;
|
|
85
85
|
now?: () => Date;
|
|
86
86
|
env?: NodeJS.ProcessEnv;
|
|
87
|
+
/**
|
|
88
|
+
* Companies the daemon is working in. When the set changes, presence
|
|
89
|
+
* re-vends instead of waiting for credential expiry (at most once a minute).
|
|
90
|
+
* Undefined means a read failed — do not re-vend.
|
|
91
|
+
*/
|
|
92
|
+
readPreferredCompanyUids?: () => string[] | undefined;
|
|
93
|
+
preferredRevendMinMs?: number;
|
|
87
94
|
}
|
|
88
95
|
/**
|
|
89
96
|
* Full-jitter capped exponential backoff (1s base → 60s cap by default),
|
|
@@ -123,6 +130,9 @@ export declare class PresenceClient {
|
|
|
123
130
|
/** Last refusal code we logged via onError (episode dedupe). */
|
|
124
131
|
private loggedRefusalKey;
|
|
125
132
|
private refusal;
|
|
133
|
+
private preferredHandle;
|
|
134
|
+
private preferredSnapshot;
|
|
135
|
+
private lastPreferredVendMs;
|
|
126
136
|
private readonly mqttConnect;
|
|
127
137
|
private readonly timers;
|
|
128
138
|
private readonly random;
|
|
@@ -137,6 +147,11 @@ export declare class PresenceClient {
|
|
|
137
147
|
getRefusal(): PresenceRefusal | null;
|
|
138
148
|
/** Topics this client has published to (for doctor / tests). */
|
|
139
149
|
getPublishedTopics(): readonly string[];
|
|
150
|
+
private preferredMinMs;
|
|
151
|
+
private notePreferredVend;
|
|
152
|
+
private clearPreferredWatch;
|
|
153
|
+
private armPreferredWatch;
|
|
154
|
+
private maybeRevendPreferred;
|
|
140
155
|
private setState;
|
|
141
156
|
start(): Promise<void>;
|
|
142
157
|
/**
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - Server credential refusals use a long retry (default 10m ±20%)
|
|
13
13
|
*/
|
|
14
14
|
import mqtt from "mqtt";
|
|
15
|
-
import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, } from "./credentials.js";
|
|
15
|
+
import { CredentialVendError, CredentialRenewalManager, MQTT_KEEPALIVE_SECONDS, PREFERRED_REVEND_MIN_MS, clampRetryDelayMs, defaultRefusedRetryMs, realTimerHost, refusedRetryDelayMs, scheduleBoundedTimeout, shouldRevendPreferredCompanies, } from "./credentials.js";
|
|
16
16
|
import { presignIotWssUrl } from "./presign.js";
|
|
17
17
|
/**
|
|
18
18
|
* A connection that drops before this grace window after `connect` is treated
|
|
@@ -93,6 +93,9 @@ export class PresenceClient {
|
|
|
93
93
|
/** Last refusal code we logged via onError (episode dedupe). */
|
|
94
94
|
loggedRefusalKey = null;
|
|
95
95
|
refusal = null;
|
|
96
|
+
preferredHandle = null;
|
|
97
|
+
preferredSnapshot = [];
|
|
98
|
+
lastPreferredVendMs = 0;
|
|
96
99
|
mqttConnect;
|
|
97
100
|
timers;
|
|
98
101
|
random;
|
|
@@ -128,6 +131,67 @@ export class PresenceClient {
|
|
|
128
131
|
getPublishedTopics() {
|
|
129
132
|
return this.publishedTopics;
|
|
130
133
|
}
|
|
134
|
+
preferredMinMs() {
|
|
135
|
+
return this.options.preferredRevendMinMs ?? PREFERRED_REVEND_MIN_MS;
|
|
136
|
+
}
|
|
137
|
+
notePreferredVend() {
|
|
138
|
+
const read = this.options.readPreferredCompanyUids;
|
|
139
|
+
if (!read)
|
|
140
|
+
return;
|
|
141
|
+
let next;
|
|
142
|
+
try {
|
|
143
|
+
next = read();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (!next)
|
|
149
|
+
return;
|
|
150
|
+
this.preferredSnapshot = next;
|
|
151
|
+
this.lastPreferredVendMs = this.timers.now();
|
|
152
|
+
}
|
|
153
|
+
clearPreferredWatch() {
|
|
154
|
+
if (this.preferredHandle) {
|
|
155
|
+
this.preferredHandle.clear();
|
|
156
|
+
this.preferredHandle = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
armPreferredWatch() {
|
|
160
|
+
if (!this.options.readPreferredCompanyUids || this.stopped)
|
|
161
|
+
return;
|
|
162
|
+
this.clearPreferredWatch();
|
|
163
|
+
this.preferredHandle = scheduleBoundedTimeout(this.timers, () => {
|
|
164
|
+
this.preferredHandle = null;
|
|
165
|
+
if (this.stopped)
|
|
166
|
+
return;
|
|
167
|
+
this.maybeRevendPreferred();
|
|
168
|
+
this.armPreferredWatch();
|
|
169
|
+
}, this.preferredMinMs());
|
|
170
|
+
}
|
|
171
|
+
maybeRevendPreferred() {
|
|
172
|
+
const read = this.options.readPreferredCompanyUids;
|
|
173
|
+
if (!read || this.stopped || this.refusal)
|
|
174
|
+
return;
|
|
175
|
+
let next;
|
|
176
|
+
try {
|
|
177
|
+
next = read();
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (!shouldRevendPreferredCompanies({
|
|
183
|
+
previous: this.preferredSnapshot,
|
|
184
|
+
next,
|
|
185
|
+
lastVendMs: this.lastPreferredVendMs,
|
|
186
|
+
nowMs: this.timers.now(),
|
|
187
|
+
minIntervalMs: this.preferredMinMs(),
|
|
188
|
+
})) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.lastPreferredVendMs = this.timers.now();
|
|
192
|
+
this.preferredSnapshot = next;
|
|
193
|
+
this.renewal.renewNow();
|
|
194
|
+
}
|
|
131
195
|
setState(state) {
|
|
132
196
|
if (this.state === state)
|
|
133
197
|
return;
|
|
@@ -149,6 +213,7 @@ export class PresenceClient {
|
|
|
149
213
|
this.stopped = true;
|
|
150
214
|
this.generation += 1;
|
|
151
215
|
this.renewal.stop();
|
|
216
|
+
this.clearPreferredWatch();
|
|
152
217
|
this.clearReconnect();
|
|
153
218
|
this.clearStableTimer();
|
|
154
219
|
this.connectedAtMs = null;
|
|
@@ -190,6 +255,7 @@ export class PresenceClient {
|
|
|
190
255
|
}
|
|
191
256
|
async onCredentialsRenewed(bundle) {
|
|
192
257
|
this.bundle = bundle;
|
|
258
|
+
this.notePreferredVend();
|
|
193
259
|
// Successful vend ends a refusal episode (renewal skips connectOnce's vend path).
|
|
194
260
|
this.clearRefusal(true);
|
|
195
261
|
if (this.stopped)
|
|
@@ -249,6 +315,8 @@ export class PresenceClient {
|
|
|
249
315
|
// Successful vend ends a refusal episode (even before MQTT connects).
|
|
250
316
|
this.clearRefusal(true);
|
|
251
317
|
this.renewal.schedule(this.bundle);
|
|
318
|
+
this.notePreferredVend();
|
|
319
|
+
this.armPreferredWatch();
|
|
252
320
|
}
|
|
253
321
|
const bundle = this.bundle;
|
|
254
322
|
const url = presignIotWssUrl(bundle.credentials, bundle.iotEndpoint, bundle.region, this.now());
|
|
@@ -26,7 +26,7 @@ import { backfillHeldSessions, } from "../backfill-held.js";
|
|
|
26
26
|
import { reconcileObservation } from "../../../work-context/reconcile.js";
|
|
27
27
|
import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
|
|
28
28
|
import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
|
|
29
|
-
import { createContract3Fetcher, createPersonalRealtimeFetcher, realTimerHost, } from "./credentials.js";
|
|
29
|
+
import { createContract3Fetcher, createPersonalRealtimeFetcher, readPreferredCompanyUids, realTimerHost, } from "./credentials.js";
|
|
30
30
|
import { appendDaemonLog, daemonLogLine, ensureDaemonLog } from "./log.js";
|
|
31
31
|
import { acquirePidLock, defaultPidLockDeps, releasePidLock, } from "./pid-lock.js";
|
|
32
32
|
import { daemonDir } from "./paths.js";
|
|
@@ -392,6 +392,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
392
392
|
token: t,
|
|
393
393
|
baseUrl: resolveVaultApiBase(env),
|
|
394
394
|
env,
|
|
395
|
+
readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
|
|
395
396
|
})();
|
|
396
397
|
});
|
|
397
398
|
const fetchCredentials = async () => {
|
|
@@ -418,6 +419,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
418
419
|
}, now);
|
|
419
420
|
log(dir, `presence online companies=${companies.length}`);
|
|
420
421
|
},
|
|
422
|
+
readPreferredCompanyUids: () => readPreferredCompanyUids({ workContextRoot: ctxRoot }),
|
|
421
423
|
onRefusal: (refusal) => {
|
|
422
424
|
patchDaemonState(dir, {
|
|
423
425
|
presenceRefusal: refusal,
|
|
@@ -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,20 @@
|
|
|
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
|
+
await appendThreadEvent(input.token, input.companyUid, input.openedThreadId, "done", {
|
|
15
|
+
summary: "Superseded by the registered project thread.",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
2
18
|
/**
|
|
3
19
|
* Create a missing Board. Register returns 403 PROJECT_NOT_IN_COMPANY until
|
|
4
20
|
* the genesis thread and the project view both exist, so the order is fixed:
|
|
@@ -18,6 +34,13 @@ export async function ensureProjectBoard(input) {
|
|
|
18
34
|
repos: input.repos ?? [],
|
|
19
35
|
});
|
|
20
36
|
const registration = await registerMeshProject(input.token, input.company.companyUid, input.projectId);
|
|
37
|
+
await closeSupersededProjectThread({
|
|
38
|
+
token: input.token,
|
|
39
|
+
companyUid: input.company.companyUid,
|
|
40
|
+
openedThreadId: genesis.threadId,
|
|
41
|
+
canonicalThreadId: registration.threadId,
|
|
42
|
+
created: genesis.created,
|
|
43
|
+
});
|
|
21
44
|
const view = await getProjectView(input.token, input.company.companyUid, input.projectId);
|
|
22
45
|
if (view.projectId !== input.projectId || view.companyUid !== input.company.companyUid) {
|
|
23
46
|
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 =
|
|
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,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-040: UserPromptSubmit re-runs bind-best for a session still in
|
|
3
|
+
* needs_project. Catalog reads are cached. Failures stay local.
|
|
4
|
+
*/
|
|
5
|
+
export type PromptRebindResult = {
|
|
6
|
+
action: "bound" | "skipped" | "unchanged";
|
|
7
|
+
projectId?: string;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function rebindNeedsProjectOnPrompt(input: {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
root: string;
|
|
13
|
+
hqRoot?: string;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
companySlug?: string;
|
|
16
|
+
companyUid?: string;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
nowMs?: number;
|
|
19
|
+
now?: () => Date;
|
|
20
|
+
}): PromptRebindResult;
|
|
21
|
+
//# sourceMappingURL=prompt-rebind.d.ts.map
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-040: UserPromptSubmit re-runs bind-best for a session still in
|
|
3
|
+
* needs_project. Catalog reads are cached. Failures stay local.
|
|
4
|
+
*/
|
|
5
|
+
import { projectIdFromCwd } from "./company.js";
|
|
6
|
+
import { readMetaProjectTask } from "./project.js";
|
|
7
|
+
import { refineProjectBinding } from "./bind-best.js";
|
|
8
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
9
|
+
import { readSessionState, updateSessionState } from "./state.js";
|
|
10
|
+
export function rebindNeedsProjectOnPrompt(input) {
|
|
11
|
+
let state;
|
|
12
|
+
try {
|
|
13
|
+
state = readSessionState(input.sessionId, input.root);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { action: "skipped", reason: "state_unreadable" };
|
|
17
|
+
}
|
|
18
|
+
if (!state)
|
|
19
|
+
return { action: "skipped", reason: "no_state" };
|
|
20
|
+
if (state.contextStatus !== "needs_project") {
|
|
21
|
+
return { action: "skipped", reason: "not_needs_project" };
|
|
22
|
+
}
|
|
23
|
+
if (state.projectId)
|
|
24
|
+
return { action: "skipped", reason: "different_project" };
|
|
25
|
+
const env = input.env ?? process.env;
|
|
26
|
+
const hint = state.projectHint?.trim() ||
|
|
27
|
+
env.HQ_SPAWN_PROJECT?.trim() ||
|
|
28
|
+
readMetaProjectTask(input.sessionId, input.hqRoot).projectId ||
|
|
29
|
+
projectIdFromCwd(input.cwd, input.hqRoot) ||
|
|
30
|
+
undefined;
|
|
31
|
+
if (!hint)
|
|
32
|
+
return { action: "unchanged", reason: "no_hint" };
|
|
33
|
+
let refined;
|
|
34
|
+
try {
|
|
35
|
+
refined = refineProjectBinding({
|
|
36
|
+
hint,
|
|
37
|
+
hqRoot: input.hqRoot,
|
|
38
|
+
companySlug: input.companySlug ?? state.companySlug,
|
|
39
|
+
companyUid: input.companyUid ?? state.companyUid,
|
|
40
|
+
repoPath: input.cwd,
|
|
41
|
+
env,
|
|
42
|
+
nowMs: input.nowMs ?? Date.now(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { action: "unchanged", reason: err instanceof Error ? err.message : "bind_failed" };
|
|
47
|
+
}
|
|
48
|
+
if (refined.action !== "bind" && refined.action !== "create") {
|
|
49
|
+
return { action: "unchanged", reason: refined.action === "ask" ? refined.reason : "no_match" };
|
|
50
|
+
}
|
|
51
|
+
const projectId = refined.projectId;
|
|
52
|
+
const reason = refined.reason;
|
|
53
|
+
const nowIso = (input.now ?? (() => new Date()))().toISOString();
|
|
54
|
+
const updated = updateSessionState(input.sessionId, input.root, (current) => {
|
|
55
|
+
if (!current || current.contextStatus !== "needs_project")
|
|
56
|
+
return current;
|
|
57
|
+
if (current.projectId && current.projectId !== projectId)
|
|
58
|
+
return current;
|
|
59
|
+
return {
|
|
60
|
+
...current,
|
|
61
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
62
|
+
contextStatus: current.taskId ? "bound" : "needs_task",
|
|
63
|
+
projectId,
|
|
64
|
+
projectGate: undefined,
|
|
65
|
+
projectCandidates: undefined,
|
|
66
|
+
projectHint: undefined,
|
|
67
|
+
projectBindReason: reason,
|
|
68
|
+
updatedAt: nowIso,
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
if (!updated || updated.projectId !== projectId) {
|
|
72
|
+
return { action: "skipped", reason: "not_applied" };
|
|
73
|
+
}
|
|
74
|
+
return { action: "bound", projectId, reason };
|
|
75
|
+
}
|
|
76
|
+
//# 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;
|