@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.
@@ -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;
@@ -52,7 +52,8 @@
52
52
  "codex",
53
53
  "grok",
54
54
  "hq-sessions",
55
- "agent-box"
55
+ "agent-box",
56
+ "agents-v2"
56
57
  ]
57
58
  },
58
59
  "adapterVersion": {
@@ -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 = "be300120b4793a5c6f1e713bf9f72b2fd22f6ac082bef8eedfa507b684b09ccb";
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 = "be300120b4793a5c6f1e713bf9f72b2fd22f6ac082bef8eedfa507b684b09ccb";
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