@indigoai-us/hq-cli 5.108.22 → 5.108.24
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 +49 -0
- package/dist/commands/cloud.d.ts +10 -0
- package/dist/commands/cloud.js +76 -1
- package/dist/commands/mesh.js +225 -4
- package/dist/lib/mesh/api.js +14 -1
- package/dist/lib/mesh/live/backfill-held.d.ts +81 -0
- package/dist/lib/mesh/live/backfill-held.js +131 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +6 -1
- package/dist/lib/mesh/live/format-spool-line.d.ts +1 -1
- package/dist/lib/mesh/live/session-event.schema.json +2 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/work-context/company.d.ts +11 -1
- package/dist/lib/work-context/company.js +17 -1
- package/dist/lib/work-context/config.d.ts +28 -0
- package/dist/lib/work-context/config.js +75 -0
- package/dist/lib/work-context/index.d.ts +3 -1
- package/dist/lib/work-context/index.js +2 -1
- package/dist/lib/work-context/outbox.d.ts +19 -0
- package/dist/lib/work-context/outbox.js +34 -0
- package/dist/lib/work-context/reconcile.js +5 -1
- package/dist/lib/work-context/repo-prompt.d.ts +74 -0
- package/dist/lib/work-context/repo-prompt.js +98 -0
- package/dist/lib/work-context/repo-remote.d.ts +17 -0
- package/dist/lib/work-context/repo-remote.js +45 -0
- package/package.json +2 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill held session events by reconciling their ENDED sessions (gap 2b).
|
|
3
|
+
*
|
|
4
|
+
* Background: hq-cli 5.108.22 fixed company resolution for sessions reconciled
|
|
5
|
+
* from now on, but the daemon's held retry (see flush.ts `classifySession` →
|
|
6
|
+
* NEEDS_COMPANY) decides purely from the per-session work-context state file,
|
|
7
|
+
* and only a reconcile rewrites that file. Held events belong to sessions that
|
|
8
|
+
* already ENDED, so nothing reconciles them again — the held backlog sits held
|
|
9
|
+
* forever.
|
|
10
|
+
*
|
|
11
|
+
* This module re-runs `reconcileObservation` once per distinct held session
|
|
12
|
+
* that still lacks a companyUid, which rewrites its state file with the resolved
|
|
13
|
+
* company (via the identity-file default resolver shipped in 5.108.22). The
|
|
14
|
+
* daemon's next held retry then re-attributes and posts those events naturally.
|
|
15
|
+
*
|
|
16
|
+
* Constraints (owner directive):
|
|
17
|
+
* - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
|
|
18
|
+
* - Idempotent: sessions that already carry a companyUid are skipped.
|
|
19
|
+
* - Never deletes held events; the daemon posts them on the next retry.
|
|
20
|
+
* - Purely local to the box it runs on; no fleet fan-out.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import { readSessionState } from "../../work-context/state.js";
|
|
24
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
|
|
25
|
+
import { workMeshHeldPath } from "./paths.js";
|
|
26
|
+
/**
|
|
27
|
+
* Read held.jsonl and return distinct sessions (first occurrence wins),
|
|
28
|
+
* carrying the harness from whichever held event we saw first for that session.
|
|
29
|
+
* Lines that fail to parse or carry no sessionId are ignored (the daemon owns
|
|
30
|
+
* their disposition; this backfill never mutates or deletes held lines).
|
|
31
|
+
*/
|
|
32
|
+
export function readHeldSessions(workMeshRoot) {
|
|
33
|
+
const heldPath = workMeshHeldPath(workMeshRoot);
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(heldPath, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
for (const line of raw.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
continue;
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(trimmed);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
54
|
+
continue;
|
|
55
|
+
const obj = parsed;
|
|
56
|
+
// Peel the held envelope { event, heldReason, heldAt } if present.
|
|
57
|
+
const event = obj.event &&
|
|
58
|
+
typeof obj.event === "object" &&
|
|
59
|
+
!Array.isArray(obj.event) &&
|
|
60
|
+
typeof obj.heldReason === "string"
|
|
61
|
+
? obj.event
|
|
62
|
+
: obj;
|
|
63
|
+
const sessionId = typeof event.sessionId === "string" && event.sessionId.trim()
|
|
64
|
+
? event.sessionId.trim()
|
|
65
|
+
: null;
|
|
66
|
+
if (!sessionId || seen.has(sessionId))
|
|
67
|
+
continue;
|
|
68
|
+
const harness = typeof event.harness === "string" && event.harness.trim()
|
|
69
|
+
? event.harness.trim()
|
|
70
|
+
: undefined;
|
|
71
|
+
seen.set(sessionId, { sessionId, harness });
|
|
72
|
+
}
|
|
73
|
+
return [...seen.values()];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reconcile ended sessions whose held events lack a company. Idempotent:
|
|
77
|
+
* re-running skips sessions that already carry a companyUid.
|
|
78
|
+
*/
|
|
79
|
+
export async function backfillHeldSessions(deps) {
|
|
80
|
+
const dryRun = Boolean(deps.dryRun);
|
|
81
|
+
const limit = deps.limit && deps.limit > 0 ? deps.limit : 0;
|
|
82
|
+
const contractVersion = deps.contractVersion ?? WORK_CONTEXT_CONTRACT_VERSION;
|
|
83
|
+
const readState = deps.readState ?? readSessionState;
|
|
84
|
+
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
85
|
+
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
86
|
+
const considered = limit > 0 ? sessions.slice(0, limit) : sessions;
|
|
87
|
+
const result = {
|
|
88
|
+
scanned: sessions.length,
|
|
89
|
+
considered: considered.length,
|
|
90
|
+
alreadyAttributed: 0,
|
|
91
|
+
reconciled: 0,
|
|
92
|
+
unresolved: 0,
|
|
93
|
+
errors: 0,
|
|
94
|
+
dryRun,
|
|
95
|
+
};
|
|
96
|
+
for (const { sessionId, harness } of considered) {
|
|
97
|
+
const state = readState(sessionId, deps.workContextRoot);
|
|
98
|
+
if (state?.companyUid) {
|
|
99
|
+
result.alreadyAttributed += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
// Would reconcile: no state read/write beyond the skip check above.
|
|
104
|
+
result.reconciled += 1;
|
|
105
|
+
deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const obs = {
|
|
109
|
+
contractVersion,
|
|
110
|
+
identity: harness ? { sessionId, harness } : { sessionId },
|
|
111
|
+
clientOperationId: newOperationId(),
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
const outcome = await deps.reconcile(obs);
|
|
115
|
+
if (outcome.result.companyUid) {
|
|
116
|
+
result.reconciled += 1;
|
|
117
|
+
deps.log?.(`reconciled ${sessionId} → ${outcome.result.companyUid}`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
result.unresolved += 1;
|
|
121
|
+
deps.log?.(`unresolved ${sessionId} (no company)`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
result.errors += 1;
|
|
126
|
+
deps.log?.(`error ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=backfill-held.js.map
|
|
@@ -32,7 +32,7 @@ import { CLI_VERSION } from "../../../../cli-version.js";
|
|
|
32
32
|
import { companySlugFromCwd, projectIdFromCwd, resolveCompany, } from "../../../work-context/company.js";
|
|
33
33
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../../work-context/contract.js";
|
|
34
34
|
import { reconcileObservation, } from "../../../work-context/reconcile.js";
|
|
35
|
-
import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
35
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../../work-context/repo-remote.js";
|
|
36
36
|
import { isHookWrittenSessionState, readSessionState, writeSessionState, } from "../../../work-context/state.js";
|
|
37
37
|
import { enqueueSessionEvent } from "../enqueue.js";
|
|
38
38
|
import { isValidSessionId } from "../session-identity.js";
|
|
@@ -448,6 +448,10 @@ export function resolveTranscriptRegistration(input) {
|
|
|
448
448
|
const remoteOwnerSlug = input.cwd
|
|
449
449
|
? deriveRemoteOwnerSlug({ cwd: input.cwd, hqRoot: input.hqRoot })
|
|
450
450
|
: null;
|
|
451
|
+
// Detached transcript watch never prompts, but honours a persisted repo map.
|
|
452
|
+
const repoIdentityKey = input.cwd
|
|
453
|
+
? deriveRepoIdentityKey({ cwd: input.cwd })
|
|
454
|
+
: null;
|
|
451
455
|
const resolution = resolveCompany({
|
|
452
456
|
root: input.workContextRoot,
|
|
453
457
|
sessionId: input.sessionId,
|
|
@@ -455,6 +459,7 @@ export function resolveTranscriptRegistration(input) {
|
|
|
455
459
|
cwd: input.cwd,
|
|
456
460
|
hqRoot: input.hqRoot,
|
|
457
461
|
remoteOwnerSlug,
|
|
462
|
+
repoIdentityKey,
|
|
458
463
|
});
|
|
459
464
|
const company = resolution.status === "resolved" ? resolution.company : undefined;
|
|
460
465
|
const companySlug = company?.slug;
|
|
@@ -7,7 +7,7 @@ export type LocalOnlyField = (typeof LOCAL_ONLY_FIELDS)[number];
|
|
|
7
7
|
/** Network + local key order matching work-mesh-enqueue.sh. */
|
|
8
8
|
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
9
9
|
export type SessionEventKind = "session_start" | "turn_start" | "turn_end" | "session_end" | "task_status" | "blocked" | "note";
|
|
10
|
-
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box";
|
|
10
|
+
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box" | "agents-v2";
|
|
11
11
|
export type SessionEventSource = "hooks" | "transcript";
|
|
12
12
|
export interface SpoolEventInput {
|
|
13
13
|
v?: 1;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ValidateFunction } from "ajv";
|
|
9
9
|
export declare const SESSION_EVENT_SCHEMA_PATH: string;
|
|
10
10
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
11
|
-
export declare const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
11
|
+
export declare const SESSION_EVENT_SCHEMA_SHA256 = "4284bcc3718b8c9008c26156c1f79ae5cfd70fdd248beb2b1c776988f109e415";
|
|
12
12
|
export declare const PROHIBITED_CONTENT_CLASSES: readonly ["prompts", "model_output", "transcripts", "message_bodies", "tokens", "credentials"];
|
|
13
13
|
export type ProhibitedContentClass = (typeof PROHIBITED_CONTENT_CLASSES)[number];
|
|
14
14
|
/** Fixture field name → prohibited class. */
|
|
@@ -13,7 +13,7 @@ import Ajv2020 from "ajv/dist/2020.js";
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
|
|
15
15
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
16
|
-
export const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
16
|
+
export const SESSION_EVENT_SCHEMA_SHA256 = "4284bcc3718b8c9008c26156c1f79ae5cfd70fdd248beb2b1c776988f109e415";
|
|
17
17
|
export const PROHIBITED_CONTENT_CLASSES = [
|
|
18
18
|
"prompts",
|
|
19
19
|
"model_output",
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
9
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
10
|
* above device default)
|
|
11
|
+
* 4b. persisted per-repo company map (gap 4 — person "always ask per repo",
|
|
12
|
+
* remembered by repoIdentityKey; above the git-remote heuristic + device
|
|
13
|
+
* default, below the identity file so an agent box always wins)
|
|
11
14
|
* 5. enabled device default
|
|
12
15
|
* 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
|
|
13
16
|
*
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
*/
|
|
17
20
|
import type { TrustedExplicitContext } from "./contract.js";
|
|
18
21
|
import { type SessionStateFile } from "./state.js";
|
|
19
|
-
export type CompanyResolutionSource = "trusted_explicit" | "existing_scope" | "session_meta" | "device_default" | "deterministic_cwd" | "deterministic_remote";
|
|
22
|
+
export type CompanyResolutionSource = "trusted_explicit" | "existing_scope" | "session_meta" | "repo_map" | "device_default" | "deterministic_cwd" | "deterministic_remote";
|
|
20
23
|
export interface ResolvedCompany {
|
|
21
24
|
slug?: string;
|
|
22
25
|
uid?: string;
|
|
@@ -55,6 +58,13 @@ export interface CompanyResolveInput {
|
|
|
55
58
|
metaCompanySlug?: string | null;
|
|
56
59
|
/** Optional injected deterministic remote ownership slug. */
|
|
57
60
|
remoteOwnerSlug?: string | null;
|
|
61
|
+
/**
|
|
62
|
+
* Normalised per-repo identity key (deriveRepoIdentityKey). When present, the
|
|
63
|
+
* resolver consults the persisted repo→company map (gap 4) — above the
|
|
64
|
+
* git-remote heuristic and device default, below identity file / explicit /
|
|
65
|
+
* session-meta. Omitted (or unmapped) → the ladder falls through unchanged.
|
|
66
|
+
*/
|
|
67
|
+
repoIdentityKey?: string | null;
|
|
58
68
|
}
|
|
59
69
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
60
70
|
export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
9
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
10
|
* above device default)
|
|
11
|
+
* 4b. persisted per-repo company map (gap 4 — person "always ask per repo",
|
|
12
|
+
* remembered by repoIdentityKey; above the git-remote heuristic + device
|
|
13
|
+
* default, below the identity file so an agent box always wins)
|
|
11
14
|
* 5. enabled device default
|
|
12
15
|
* 6. exactly one deterministic mapping (cwd under companies/{slug}/ or repo remote)
|
|
13
16
|
*
|
|
@@ -16,7 +19,7 @@
|
|
|
16
19
|
*/
|
|
17
20
|
import * as fs from "node:fs";
|
|
18
21
|
import * as path from "node:path";
|
|
19
|
-
import { getDefaultCompany } from "./config.js";
|
|
22
|
+
import { getDefaultCompany, getRepoCompany } from "./config.js";
|
|
20
23
|
import { authoritativeCompanyFromState, readSessionState, } from "./state.js";
|
|
21
24
|
/** Pointer to the US-017B correction client (never auto-switches). */
|
|
22
25
|
export const COMPANY_CORRECTION_PATH_PREFIX = "hq mesh context correct --session";
|
|
@@ -247,6 +250,19 @@ export function resolveCompany(input) {
|
|
|
247
250
|
company: { uid: identityUid, source: "trusted_explicit" },
|
|
248
251
|
};
|
|
249
252
|
}
|
|
253
|
+
// 4b. Persisted per-repo company map (gap 4). A person answered once for this
|
|
254
|
+
// repo; remember it. Above the git-remote heuristic + device default, below
|
|
255
|
+
// the identity file / explicit / session-meta so an agent box always wins.
|
|
256
|
+
const repoKey = input.repoIdentityKey?.trim();
|
|
257
|
+
if (repoKey) {
|
|
258
|
+
const mapped = getRepoCompany(repoKey, { root: input.root });
|
|
259
|
+
if (mapped?.slug) {
|
|
260
|
+
return {
|
|
261
|
+
status: "resolved",
|
|
262
|
+
company: { slug: mapped.slug, uid: mapped.uid, source: "repo_map" },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
250
266
|
// 5 + 6. Device default vs deterministic evidence.
|
|
251
267
|
const deviceDefault = getDefaultCompany({ root: input.root });
|
|
252
268
|
const deterministic = resolveDeterministicCompany({
|
|
@@ -20,12 +20,27 @@ export interface MigrationCapabilitySnapshot {
|
|
|
20
20
|
migration: boolean;
|
|
21
21
|
}>;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Persisted per-repo company mapping (gap 4 — person "always ask per repo").
|
|
25
|
+
* Keyed by the normalised repo identity from deriveRepoIdentityKey
|
|
26
|
+
* (`remote:owner/name` or `root:<abs work-tree>`). Written only after a person
|
|
27
|
+
* answers the one-time interactive prompt; consulted by the resolver so the
|
|
28
|
+
* same repo never re-asks.
|
|
29
|
+
*/
|
|
30
|
+
export interface RepoCompanyMapping {
|
|
31
|
+
slug: string;
|
|
32
|
+
uid?: string;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
export type RepoCompanyMap = Record<string, RepoCompanyMapping>;
|
|
23
36
|
export interface WorkContextDeviceConfig {
|
|
24
37
|
schemaVersion: typeof DEVICE_CONFIG_SCHEMA_VERSION;
|
|
25
38
|
/** Explicit device default. Never copied from activeCompany. */
|
|
26
39
|
defaultCompany?: DeviceDefaultCompany | null;
|
|
27
40
|
/** Last-known migration capability across memberships (doctor reports this). */
|
|
28
41
|
migrationCapability?: MigrationCapabilitySnapshot | null;
|
|
42
|
+
/** Per-repo company map (gap 4). Remembers each repo's filed-under company. */
|
|
43
|
+
repoCompanyMap?: RepoCompanyMap | null;
|
|
29
44
|
updatedAt: string;
|
|
30
45
|
}
|
|
31
46
|
/** true / { uid } = member; false = not a member. */
|
|
@@ -50,6 +65,19 @@ export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps &
|
|
|
50
65
|
allowWithoutMigration?: boolean;
|
|
51
66
|
}): Promise<WorkContextDeviceConfig>;
|
|
52
67
|
export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
|
|
68
|
+
/**
|
|
69
|
+
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
70
|
+
* Returns null when unmapped. Never throws.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getRepoCompany(key: string, deps: Pick<DeviceConfigDeps, "root">): RepoCompanyMapping | null;
|
|
73
|
+
/**
|
|
74
|
+
* Persist (or overwrite) the company mapping for a repo identity key (gap 4).
|
|
75
|
+
* Called after a person answers the one-time interactive prompt.
|
|
76
|
+
*/
|
|
77
|
+
export declare function setRepoCompany(key: string, mapping: {
|
|
78
|
+
slug: string;
|
|
79
|
+
uid?: string;
|
|
80
|
+
}, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
53
81
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
54
82
|
export declare function recordMigrationCapabilitySnapshot(snapshot: MigrationCapabilitySnapshot, deps: Pick<DeviceConfigDeps, "root" | "now">): WorkContextDeviceConfig;
|
|
55
83
|
/** Convenience: build deps from HOME / injectable root. */
|
|
@@ -15,9 +15,36 @@ function emptyConfig(now) {
|
|
|
15
15
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
16
16
|
defaultCompany: null,
|
|
17
17
|
migrationCapability: null,
|
|
18
|
+
repoCompanyMap: null,
|
|
18
19
|
updatedAt: now().toISOString(),
|
|
19
20
|
};
|
|
20
21
|
}
|
|
22
|
+
/** Sanitize a raw repoCompanyMap from disk: drop malformed entries. */
|
|
23
|
+
function sanitizeRepoCompanyMap(raw) {
|
|
24
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
25
|
+
return null;
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
28
|
+
if (!key.trim())
|
|
29
|
+
continue;
|
|
30
|
+
if (!value || typeof value !== "object")
|
|
31
|
+
continue;
|
|
32
|
+
const v = value;
|
|
33
|
+
const slug = typeof v.slug === "string" ? v.slug.trim() : "";
|
|
34
|
+
if (!slug)
|
|
35
|
+
continue;
|
|
36
|
+
const entry = {
|
|
37
|
+
slug,
|
|
38
|
+
updatedAt: typeof v.updatedAt === "string" && v.updatedAt.trim()
|
|
39
|
+
? v.updatedAt
|
|
40
|
+
: new Date(0).toISOString(),
|
|
41
|
+
};
|
|
42
|
+
if (typeof v.uid === "string" && v.uid.trim())
|
|
43
|
+
entry.uid = v.uid.trim();
|
|
44
|
+
out[key] = entry;
|
|
45
|
+
}
|
|
46
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
47
|
+
}
|
|
21
48
|
function assertSafeConfigPath(configPath) {
|
|
22
49
|
const dir = path.dirname(configPath);
|
|
23
50
|
try {
|
|
@@ -60,6 +87,7 @@ export function readDeviceConfig(deps) {
|
|
|
60
87
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
61
88
|
defaultCompany: raw.defaultCompany ?? null,
|
|
62
89
|
migrationCapability: raw.migrationCapability ?? null,
|
|
90
|
+
repoCompanyMap: sanitizeRepoCompanyMap(raw.repoCompanyMap),
|
|
63
91
|
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : new Date(0).toISOString(),
|
|
64
92
|
};
|
|
65
93
|
return cleaned;
|
|
@@ -122,6 +150,7 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
122
150
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
123
151
|
defaultCompany,
|
|
124
152
|
migrationCapability: prior.migrationCapability ?? null,
|
|
153
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
125
154
|
updatedAt: now().toISOString(),
|
|
126
155
|
};
|
|
127
156
|
writeDeviceConfig(deps, next);
|
|
@@ -134,11 +163,56 @@ export function clearDefaultCompany(deps) {
|
|
|
134
163
|
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
135
164
|
defaultCompany: null,
|
|
136
165
|
migrationCapability: prior.migrationCapability ?? null,
|
|
166
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
137
167
|
updatedAt: now().toISOString(),
|
|
138
168
|
};
|
|
139
169
|
writeDeviceConfig(deps, next);
|
|
140
170
|
return next;
|
|
141
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
174
|
+
* Returns null when unmapped. Never throws.
|
|
175
|
+
*/
|
|
176
|
+
export function getRepoCompany(key, deps) {
|
|
177
|
+
const trimmed = key?.trim();
|
|
178
|
+
if (!trimmed)
|
|
179
|
+
return null;
|
|
180
|
+
const cfg = readDeviceConfig(deps);
|
|
181
|
+
const entry = cfg.repoCompanyMap?.[trimmed];
|
|
182
|
+
if (!entry || !entry.slug?.trim())
|
|
183
|
+
return null;
|
|
184
|
+
return entry;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Persist (or overwrite) the company mapping for a repo identity key (gap 4).
|
|
188
|
+
* Called after a person answers the one-time interactive prompt.
|
|
189
|
+
*/
|
|
190
|
+
export function setRepoCompany(key, mapping, deps) {
|
|
191
|
+
const trimmed = key?.trim();
|
|
192
|
+
if (!trimmed) {
|
|
193
|
+
throw new WorkContextError("InvalidRepoKey", "Empty repo identity key");
|
|
194
|
+
}
|
|
195
|
+
const slug = mapping.slug?.trim();
|
|
196
|
+
if (!slug || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(slug)) {
|
|
197
|
+
throw new WorkContextError("InvalidCompanySlug", `Invalid company slug: ${mapping.slug}`);
|
|
198
|
+
}
|
|
199
|
+
const now = deps.now ?? (() => new Date());
|
|
200
|
+
const prior = readDeviceConfig({ root: deps.root });
|
|
201
|
+
const entry = { slug, updatedAt: now().toISOString() };
|
|
202
|
+
const uid = mapping.uid?.trim();
|
|
203
|
+
if (uid)
|
|
204
|
+
entry.uid = uid;
|
|
205
|
+
const nextMap = { ...(prior.repoCompanyMap ?? {}), [trimmed]: entry };
|
|
206
|
+
const next = {
|
|
207
|
+
schemaVersion: DEVICE_CONFIG_SCHEMA_VERSION,
|
|
208
|
+
defaultCompany: prior.defaultCompany ?? null,
|
|
209
|
+
migrationCapability: prior.migrationCapability ?? null,
|
|
210
|
+
repoCompanyMap: nextMap,
|
|
211
|
+
updatedAt: now().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
writeDeviceConfig({ root: deps.root }, next);
|
|
214
|
+
return next;
|
|
215
|
+
}
|
|
142
216
|
/** Persist the last memberships-wide migration capability probe (US-017B). */
|
|
143
217
|
export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
144
218
|
const prior = readDeviceConfig(deps);
|
|
@@ -155,6 +229,7 @@ export function recordMigrationCapabilitySnapshot(snapshot, deps) {
|
|
|
155
229
|
migration: c.migration,
|
|
156
230
|
})),
|
|
157
231
|
},
|
|
232
|
+
repoCompanyMap: prior.repoCompanyMap ?? null,
|
|
158
233
|
updatedAt: now().toISOString(),
|
|
159
234
|
};
|
|
160
235
|
writeDeviceConfig({ root: deps.root }, next);
|
|
@@ -17,7 +17,9 @@ export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prep
|
|
|
17
17
|
export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
|
|
18
18
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
19
19
|
export type { MigrateDestination, MigrateReceipt, MigrateSubmitResult, PostMigrateFn, } from "./migrate.js";
|
|
20
|
-
export { deriveRemoteOwnerSlug, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
20
|
+
export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
21
|
+
export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
|
|
22
|
+
export type { RepoPromptCompany, RepoPromptDeps, RepoPromptOutcome, RepoPromptSkipReason, } from "./repo-prompt.js";
|
|
21
23
|
export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
|
|
22
24
|
export type { ReconcileDeps, ReconcileObservation, ReconcileOutcome, } from "./reconcile.js";
|
|
23
25
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -15,6 +15,7 @@ export * from "./company.js";
|
|
|
15
15
|
export * from "./project.js";
|
|
16
16
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
17
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
18
|
-
export { deriveRemoteOwnerSlug, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
18
|
+
export { deriveRemoteOwnerSlug, deriveRepoIdentityKey, findWorkTreeRoot, matchCompanySlugForRepo, normalizeRemoteOwnerName, } from "./repo-remote.js";
|
|
19
|
+
export { matchMembershipAnswer, promptRepoCompany, } from "./repo-prompt.js";
|
|
19
20
|
export { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "./reconcile.js";
|
|
20
21
|
//# sourceMappingURL=index.js.map
|
|
@@ -78,6 +78,25 @@ export declare function updateOutboxOperation(op: OutboxOperation, root: string)
|
|
|
78
78
|
export declare function markOutboxAcked(operationId: string, root: string, receiptId: string, now?: () => Date): OutboxOperation | null;
|
|
79
79
|
export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date, random?: () => number): OutboxOperation | null;
|
|
80
80
|
export declare function markOutboxQuarantined(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
|
|
81
|
+
/**
|
|
82
|
+
* Requeue quarantined outbox operations so the daemon's replay picks them up
|
|
83
|
+
* again. Quarantine is otherwise terminal: after the work-mesh-live gap 10 fix
|
|
84
|
+
* (agent boxes now send the ID token, see src/lib/mesh/api.ts requireToken),
|
|
85
|
+
* ops previously quarantined as AUTH_DENIED — 403 NO_PERSON_ENTITY caused by
|
|
86
|
+
* sending the person ACCESS token — will not retry on their own. This flips
|
|
87
|
+
* them back to `queued`, resets attemptCount, and clears nextAttemptAt so they
|
|
88
|
+
* are due immediately.
|
|
89
|
+
*
|
|
90
|
+
* `opts.errorCode` selects which quarantined ops to requeue by lastErrorCode:
|
|
91
|
+
* - omitted → defaults to "AUTH_DENIED" (a bare call only recovers the gap 10
|
|
92
|
+
* regression, never e.g. VALIDATION_FAILED ops that are genuinely bad).
|
|
93
|
+
* - a string → only ops whose lastErrorCode matches.
|
|
94
|
+
* - `null` → every quarantined op regardless of code.
|
|
95
|
+
*/
|
|
96
|
+
export declare function requeueQuarantinedOutbox(root: string, opts?: {
|
|
97
|
+
errorCode?: string | null;
|
|
98
|
+
now?: () => Date;
|
|
99
|
+
}): OutboxOperation[];
|
|
81
100
|
export type OutboxListReadFile = (filePath: string) => string;
|
|
82
101
|
export declare function listOutboxOperations(root: string, deps?: {
|
|
83
102
|
readFile?: OutboxListReadFile;
|
|
@@ -283,6 +283,40 @@ export function markOutboxQuarantined(operationId, root, errorCode, now = () =>
|
|
|
283
283
|
updateOutboxOperation(op, root);
|
|
284
284
|
return op;
|
|
285
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Requeue quarantined outbox operations so the daemon's replay picks them up
|
|
288
|
+
* again. Quarantine is otherwise terminal: after the work-mesh-live gap 10 fix
|
|
289
|
+
* (agent boxes now send the ID token, see src/lib/mesh/api.ts requireToken),
|
|
290
|
+
* ops previously quarantined as AUTH_DENIED — 403 NO_PERSON_ENTITY caused by
|
|
291
|
+
* sending the person ACCESS token — will not retry on their own. This flips
|
|
292
|
+
* them back to `queued`, resets attemptCount, and clears nextAttemptAt so they
|
|
293
|
+
* are due immediately.
|
|
294
|
+
*
|
|
295
|
+
* `opts.errorCode` selects which quarantined ops to requeue by lastErrorCode:
|
|
296
|
+
* - omitted → defaults to "AUTH_DENIED" (a bare call only recovers the gap 10
|
|
297
|
+
* regression, never e.g. VALIDATION_FAILED ops that are genuinely bad).
|
|
298
|
+
* - a string → only ops whose lastErrorCode matches.
|
|
299
|
+
* - `null` → every quarantined op regardless of code.
|
|
300
|
+
*/
|
|
301
|
+
export function requeueQuarantinedOutbox(root, opts = {}) {
|
|
302
|
+
const errorCode = opts.errorCode === undefined ? "AUTH_DENIED" : opts.errorCode;
|
|
303
|
+
const now = opts.now ?? (() => new Date());
|
|
304
|
+
const requeued = [];
|
|
305
|
+
for (const op of listOutboxOperations(root)) {
|
|
306
|
+
if (op.delivery !== "quarantined")
|
|
307
|
+
continue;
|
|
308
|
+
if (errorCode !== null && op.lastErrorCode !== errorCode)
|
|
309
|
+
continue;
|
|
310
|
+
op.delivery = "queued";
|
|
311
|
+
op.attemptCount = 0;
|
|
312
|
+
op.lastErrorCode = undefined;
|
|
313
|
+
op.nextAttemptAt = undefined;
|
|
314
|
+
op.updatedAt = now().toISOString();
|
|
315
|
+
updateOutboxOperation(op, root);
|
|
316
|
+
requeued.push(op);
|
|
317
|
+
}
|
|
318
|
+
return requeued;
|
|
319
|
+
}
|
|
286
320
|
export function listOutboxOperations(root, deps = {}) {
|
|
287
321
|
const readFile = deps.readFile ?? ((filePath) => fs.readFileSync(filePath, "utf8"));
|
|
288
322
|
const dir = workContextOutboxDir(root);
|
|
@@ -9,7 +9,7 @@ import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
|
9
9
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
11
11
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
12
|
-
import { deriveRemoteOwnerSlug } from "./repo-remote.js";
|
|
12
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
13
13
|
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, } from "./outbox.js";
|
|
14
14
|
import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
|
|
15
15
|
function resultOf(parts) {
|
|
@@ -210,6 +210,9 @@ export async function reconcileObservation(obs, deps) {
|
|
|
210
210
|
cwd: cwd ?? process.cwd(),
|
|
211
211
|
hqRoot,
|
|
212
212
|
}) ?? undefined;
|
|
213
|
+
// Persisted per-repo company map key (gap 4). The detached reconcile path
|
|
214
|
+
// NEVER prompts, but it does honour a mapping a person already answered.
|
|
215
|
+
const repoIdentityKey = deriveRepoIdentityKey({ cwd: cwd ?? process.cwd() });
|
|
213
216
|
const company = resolveCompany({
|
|
214
217
|
root: deps.root,
|
|
215
218
|
sessionId,
|
|
@@ -218,6 +221,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
218
221
|
cwd,
|
|
219
222
|
hqRoot,
|
|
220
223
|
remoteOwnerSlug,
|
|
224
|
+
repoIdentityKey,
|
|
221
225
|
existingState: prior,
|
|
222
226
|
});
|
|
223
227
|
const nowIso = nowFn().toISOString();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive per-repo company prompt (Work Mesh Live gap 4 — person attribution).
|
|
3
|
+
*
|
|
4
|
+
* Owner decision ("Always ask per repo"): every person is asked ONCE PER REPO
|
|
5
|
+
* which company the work is filed under, then the answer is remembered for that
|
|
6
|
+
* repo (persisted repo→company map in the device config).
|
|
7
|
+
*
|
|
8
|
+
* Hard gates — the prompt NEVER fires:
|
|
9
|
+
* - on machine / agent-box identities (isMachineIdentity) — those resolve from
|
|
10
|
+
* the identity file, never a prompt;
|
|
11
|
+
* - when stdin/stdout is not a TTY, or in --json / --machine / non-interactive
|
|
12
|
+
* contexts;
|
|
13
|
+
* - when cwd is not inside a git repo (no stable key to remember an answer);
|
|
14
|
+
* - when the repo is already mapped (a second resolve is silent).
|
|
15
|
+
*
|
|
16
|
+
* There is NO auto-pick from a sole membership: even a caller with exactly one
|
|
17
|
+
* active membership is asked once. The core is dependency-injected so tests never
|
|
18
|
+
* need a real TTY, network, or identity file.
|
|
19
|
+
*/
|
|
20
|
+
import { type RepoCompanyMapping } from "./config.js";
|
|
21
|
+
export interface RepoPromptCompany {
|
|
22
|
+
companyUid: string;
|
|
23
|
+
companySlug: string;
|
|
24
|
+
}
|
|
25
|
+
export interface RepoPromptDeps {
|
|
26
|
+
/** Work-context config root (~/.hq/work-context or injected tmp). */
|
|
27
|
+
root: string;
|
|
28
|
+
/** cwd used to derive the repo identity key. */
|
|
29
|
+
cwd?: string;
|
|
30
|
+
/** True when the caller is a machine / agent-box identity. */
|
|
31
|
+
isMachineIdentity: () => boolean;
|
|
32
|
+
/** True only for an interactive person TTY (not --json/--machine/piped). */
|
|
33
|
+
isInteractive: () => boolean;
|
|
34
|
+
/** Caller's active membership companies (reused across memberships). */
|
|
35
|
+
listMemberships: () => Promise<RepoPromptCompany[]>;
|
|
36
|
+
/** Ask a single plain question; returns the raw answer (trimmed by caller). */
|
|
37
|
+
ask: (question: string) => Promise<string>;
|
|
38
|
+
/** Injectable repo key derivation (defaults to deriveRepoIdentityKey). */
|
|
39
|
+
deriveKey?: (cwd?: string) => string | null;
|
|
40
|
+
/** Injectable persisted-map lookup (defaults to getRepoCompany). */
|
|
41
|
+
lookup?: (key: string, root: string) => RepoCompanyMapping | null;
|
|
42
|
+
/** Injectable persistence (defaults to setRepoCompany). */
|
|
43
|
+
persist?: (key: string, mapping: {
|
|
44
|
+
slug: string;
|
|
45
|
+
uid?: string;
|
|
46
|
+
}, root: string) => void;
|
|
47
|
+
now?: () => Date;
|
|
48
|
+
}
|
|
49
|
+
export type RepoPromptSkipReason = "machine" | "non_interactive" | "no_repo_key" | "no_memberships" | "cancelled";
|
|
50
|
+
export type RepoPromptOutcome = {
|
|
51
|
+
status: "resolved";
|
|
52
|
+
repoKey: string;
|
|
53
|
+
company: {
|
|
54
|
+
slug: string;
|
|
55
|
+
uid?: string;
|
|
56
|
+
};
|
|
57
|
+
/** true when this call wrote the mapping; false when already remembered. */
|
|
58
|
+
persisted: boolean;
|
|
59
|
+
alreadyMapped: boolean;
|
|
60
|
+
} | {
|
|
61
|
+
status: "skipped";
|
|
62
|
+
reason: RepoPromptSkipReason;
|
|
63
|
+
repoKey?: string;
|
|
64
|
+
};
|
|
65
|
+
/** Match a raw answer to a membership by 1-based number or slug/uid. */
|
|
66
|
+
export declare function matchMembershipAnswer(answer: string, companies: RepoPromptCompany[]): RepoPromptCompany | null;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a repo's company via the one-time interactive prompt (gap 4).
|
|
69
|
+
* Pure control flow over injected effects — no direct TTY/network/fs beyond the
|
|
70
|
+
* default config helpers. Callers must first confirm the session is unresolved
|
|
71
|
+
* (needs_company); this only decides whether/how to prompt and persist.
|
|
72
|
+
*/
|
|
73
|
+
export declare function promptRepoCompany(deps: RepoPromptDeps): Promise<RepoPromptOutcome>;
|
|
74
|
+
//# sourceMappingURL=repo-prompt.d.ts.map
|