@indigoai-us/hq-cli 5.345.152 → 5.345.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/bin/hq-auth-refresh.d.ts +4 -0
  3. package/dist/bin/hq-auth-refresh.js +9 -1
  4. package/dist/command-catalog.generated.d.ts +185 -0
  5. package/dist/command-catalog.generated.js +227 -0
  6. package/dist/command-registration-plan.d.ts +6 -0
  7. package/dist/command-registration-plan.js +1 -0
  8. package/dist/commands/agent-enroll.d.ts +12 -0
  9. package/dist/commands/agent-enroll.js +37 -4
  10. package/dist/commands/cloud.d.ts +17 -0
  11. package/dist/commands/cloud.js +48 -1
  12. package/dist/commands/core-policy.js +4 -4
  13. package/dist/commands/core-session-project-native.js +19 -4
  14. package/dist/commands/decide.d.ts +13 -0
  15. package/dist/commands/decide.js +74 -0
  16. package/dist/commands/integrations-connect.js +24 -1
  17. package/dist/commands/integrations-core.d.ts +14 -0
  18. package/dist/commands/integrations-core.js +19 -1
  19. package/dist/commands/integrations-import.js +22 -1
  20. package/dist/commands/integrations.js +43 -2
  21. package/dist/commands/lanes-message.js +4 -11
  22. package/dist/commands/lanes-questions.js +3 -0
  23. package/dist/commands/lanes-resume.js +4 -11
  24. package/dist/commands/lanes-stories.js +3 -0
  25. package/dist/commands/lanes.js +7 -1
  26. package/dist/commands/run.js +14 -5
  27. package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
  28. package/dist/lib/agent-kit/adopt-identity.js +18 -3
  29. package/dist/lib/agent-kit/services.d.ts +3 -1
  30. package/dist/lib/agent-kit/services.js +5 -1
  31. package/dist/lib/daemon/ops/policy.d.ts +9 -1
  32. package/dist/lib/daemon/ops/policy.js +37 -2
  33. package/dist/lib/decisions/client.d.ts +67 -0
  34. package/dist/lib/decisions/client.js +120 -0
  35. package/dist/lib/decisions/expiry.d.ts +2 -0
  36. package/dist/lib/decisions/expiry.js +12 -0
  37. package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
  38. package/dist/lib/decisions/fixtures/us001.js +21 -0
  39. package/dist/lib/decisions/import-lanes.d.ts +16 -0
  40. package/dist/lib/decisions/import-lanes.js +152 -0
  41. package/dist/lib/decisions/principal.d.ts +11 -0
  42. package/dist/lib/decisions/principal.js +52 -0
  43. package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
  44. package/dist/lib/doctor/checks/claude-wiring.js +21 -10
  45. package/dist/lib/doctor/checks/work-context.js +7 -2
  46. package/dist/lib/doctor/fix/apply.d.ts +3 -2
  47. package/dist/lib/doctor/fix/apply.js +19 -15
  48. package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
  49. package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
  50. package/dist/lib/doctor/fix/remediation.js +4 -2
  51. package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
  52. package/dist/lib/doctor/hook-gate-profiles.js +34 -0
  53. package/dist/lib/doctor/types.d.ts +5 -0
  54. package/dist/lib/install/provider-wrappers.js +5 -1
  55. package/dist/lib/lanes/autopilot.js +4 -1
  56. package/dist/lib/lanes/imported-question.d.ts +5 -0
  57. package/dist/lib/lanes/imported-question.js +17 -0
  58. package/dist/lib/lanes/questions.d.ts +0 -5
  59. package/dist/lib/lanes/questions.js +2 -0
  60. package/dist/lib/lanes/reconcile.js +21 -10
  61. package/dist/lib/lanes/store.d.ts +0 -8
  62. package/dist/lib/lanes/store.js +2 -1
  63. package/dist/lib/lanes/stories.d.ts +15 -0
  64. package/dist/lib/lanes/stories.js +25 -0
  65. package/dist/lib/mesh/client.d.ts +8 -0
  66. package/dist/lib/mesh/client.js +44 -3
  67. package/dist/lib/mesh/denial-codes.js +1 -1
  68. package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
  69. package/dist/lib/mesh/live/daemon/run.js +30 -1
  70. package/dist/lib/mesh/live/flush.d.ts +7 -0
  71. package/dist/lib/mesh/live/flush.js +53 -10
  72. package/dist/lib/registry/registry.js +6 -0
  73. package/dist/lib/scan-packages/index.js +7 -3
  74. package/dist/lib/sync/read-only-local-only.d.ts +45 -0
  75. package/dist/lib/sync/read-only-local-only.js +122 -0
  76. package/dist/lib/work-context/organize.d.ts +2 -0
  77. package/dist/lib/work-context/organize.js +22 -4
  78. package/dist/lib/workers-registry/index.d.ts +12 -1
  79. package/dist/lib/workers-registry/index.js +39 -1
  80. package/dist/utils/cognito-session.d.ts +4 -1
  81. package/dist/utils/cognito-session.js +11 -1
  82. package/package.json +7 -7
@@ -49,13 +49,41 @@ export function readRefreshSyncProgress(hqRoot) {
49
49
  }
50
50
  return record;
51
51
  }
52
- function writeProgressFile(hqRoot, progress) {
53
- const target = progressPath(hqRoot);
52
+ function writeProgressFile(hqRoot, progress, relativePath = REFRESH_SYNC_PROGRESS_RELATIVE_PATH) {
53
+ const target = path.join(hqRoot, relativePath);
54
54
  fs.mkdirSync(path.dirname(target), { recursive: true });
55
55
  const temporaryPath = `${target}.${process.pid}.tmp`;
56
56
  fs.writeFileSync(temporaryPath, `${JSON.stringify(progress)}\n`, { mode: 0o600 });
57
57
  fs.renameSync(temporaryPath, target);
58
58
  }
59
+ /**
60
+ * `--fix` manifest-upload cursor. Kept in its own file so the refresh-sync
61
+ * progress format, which doctor JSON reads, does not change. Written before
62
+ * each upload, like the pull cursor, so the next run starts after the last
63
+ * company attempted and one failing company cannot hold the front of the line.
64
+ */
65
+ export const REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-manifest-progress.json");
66
+ /** The last manifest scope `--fix` attempted, or null when absent or unreadable. */
67
+ export function readRefreshManifestCursor(hqRoot) {
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(fs.readFileSync(path.join(hqRoot, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH), "utf8"));
71
+ }
72
+ catch {
73
+ // The cursor only orders retries. A missing or damaged file restarts the
74
+ // rotation from the first scope instead of failing the repair run.
75
+ return null;
76
+ }
77
+ if (typeof parsed !== "object" || parsed === null)
78
+ return null;
79
+ const record = parsed;
80
+ if (record.version !== 1 || typeof record.lastAttemptedScope !== "string")
81
+ return null;
82
+ return record.lastAttemptedScope;
83
+ }
84
+ export function writeRefreshManifestCursor(hqRoot, scope) {
85
+ writeProgressFile(hqRoot, { version: 1, lastAttemptedScope: scope }, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH);
86
+ }
59
87
  /** Persist the resume cursor without claiming this run finished. */
60
88
  export function writeRefreshSyncCursor(hqRoot, slug) {
61
89
  const existing = readRefreshSyncProgress(hqRoot);
@@ -87,8 +87,10 @@ export function deriveRemediation(result) {
87
87
  command: `chmod +x "${chmodPath}"`,
88
88
  };
89
89
  }
90
- // gate-profile — a gated hook id present in some but not all three profiles.
91
- if (checkId.endsWith(".gate-profiles") && result.status === "FAIL" && target) {
90
+ // gate-profile — a gated hook id missing from a profile its tier requires.
91
+ if (checkId.endsWith(".gate-profiles") &&
92
+ (result.status === "FAIL" || result.status === "WARN") &&
93
+ target) {
92
94
  return {
93
95
  autoFixable: true,
94
96
  fixClass: "gate-profile",
@@ -52,4 +52,23 @@ export declare function parseHookGateProfiles(source: string): HookGateProfiles;
52
52
  * profile allowlists key on the hook id, not the script filename.
53
53
  */
54
54
  export declare function gateMembership(hookId: string, profiles: HookGateProfiles): GateMembership;
55
+ /**
56
+ * The profiles `hookId` is REQUIRED in but missing from.
57
+ *
58
+ * The shipped gate declares the profiles as nested tiers: minimal is
59
+ * "critical safety hooks only", standard is "all minimal + ...", strict is
60
+ * "all standard + ...". A hook's lowest listed profile is its declared tier,
61
+ * and it must be listed in every profile from that tier up. Absence from a
62
+ * LOWER profile is an intentional exclusion, not a defect (feedback 299: a
63
+ * standard hook absent from minimal is by design). The tier is capped at
64
+ * standard, so an id listed only in strict is still required in standard. An
65
+ * id listed in no profile is required everywhere.
66
+ */
67
+ export declare function requiredMissingProfiles(hookId: string, profiles: HookGateProfiles): GateProfile[];
68
+ /**
69
+ * Resolve the runtime gate profile the way hook-gate.sh does:
70
+ * `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
71
+ * `standard` here (the gate itself errors on it).
72
+ */
73
+ export declare function activeGateProfile(value?: string | null): GateProfile;
55
74
  //# sourceMappingURL=hook-gate-profiles.d.ts.map
@@ -104,4 +104,38 @@ function profileFunctionBody(source, profile) {
104
104
  const close = rest.search(/\n\s*\}/);
105
105
  return close >= 0 ? rest.slice(0, close) : rest;
106
106
  }
107
+ /**
108
+ * The profiles `hookId` is REQUIRED in but missing from.
109
+ *
110
+ * The shipped gate declares the profiles as nested tiers: minimal is
111
+ * "critical safety hooks only", standard is "all minimal + ...", strict is
112
+ * "all standard + ...". A hook's lowest listed profile is its declared tier,
113
+ * and it must be listed in every profile from that tier up. Absence from a
114
+ * LOWER profile is an intentional exclusion, not a defect (feedback 299: a
115
+ * standard hook absent from minimal is by design). The tier is capped at
116
+ * standard, so an id listed only in strict is still required in standard. An
117
+ * id listed in no profile is required everywhere.
118
+ */
119
+ export function requiredMissingProfiles(hookId, profiles) {
120
+ const { present, missing } = gateMembership(hookId, profiles);
121
+ if (present.length === 0)
122
+ return missing;
123
+ // The inferred tier is capped at standard, the default runtime profile. The
124
+ // strict list is reserved for future hooks and currently equals standard, so
125
+ // a strict-only id is a missed allowlist edit, not a strict tier: it never
126
+ // runs under the default profile and must still be required in standard.
127
+ const lowest = Math.min(GATE_PROFILES.indexOf(present[0]), GATE_PROFILES.indexOf("standard"));
128
+ return missing.filter((p) => GATE_PROFILES.indexOf(p) >= lowest);
129
+ }
130
+ /**
131
+ * Resolve the runtime gate profile the way hook-gate.sh does:
132
+ * `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
133
+ * `standard` here (the gate itself errors on it).
134
+ */
135
+ export function activeGateProfile(value) {
136
+ const v = (value ?? "").trim();
137
+ return GATE_PROFILES.includes(v)
138
+ ? v
139
+ : "standard";
140
+ }
107
141
  //# sourceMappingURL=hook-gate-profiles.js.map
@@ -79,6 +79,11 @@ export interface CheckContext {
79
79
  company?: string;
80
80
  /** Internal test seam; production `hq doctor` always enables integrations. */
81
81
  integrations?: boolean;
82
+ /**
83
+ * The runtime hook-gate profile (minimal|standard|strict) to judge gate
84
+ * membership against. Absent reads `HQ_HOOK_PROFILE`, default `standard`.
85
+ */
86
+ hookProfile?: string;
82
87
  }
83
88
  /**
84
89
  * A check family: an id, a human title, and an async `run` returning per-item
@@ -94,7 +94,11 @@ function wrapperSource(provider) {
94
94
  'log_file="$log_dir/provider-auth.log"',
95
95
  'tmp=$(mktemp -d "${TMPDIR:-/tmp}/hq-provider.XXXXXX") || exit 1',
96
96
  'trap \'rm -rf "$tmp"\' EXIT HUP INT TERM',
97
- `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; else auth_status=124; fi`,
97
+ // macOS ships no timeout(1), so the perl alarm fallback runs there. When the
98
+ // alarm fires the shell itself reports "Alarm clock: 14 perl ..." on its own
99
+ // stderr (the command's 2>&1 does not cover it); the brace group's 2>/dev/null
100
+ // keeps that report off the user's terminal. The exit status (142) is unchanged.
101
+ `if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then { perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; } 2>/dev/null; auth_status=$?; else auth_status=124; fi`,
98
102
  'case "$auth_status" in 124|142) : >"$tmp/status-timeout" ;; esac',
99
103
  'if [ "$auth_status" -ne 0 ]; then cat "$tmp/status" >>"$log_file" 2>/dev/null || :; fi',
100
104
  `if [ ! -e "$tmp/status-timeout" ] && grep -Eiq '${authFailure}' "$tmp/status"; then`,
@@ -1265,7 +1265,10 @@ export async function runLeadAutopilotPass(input) {
1265
1265
  for (let attempt = 0; attempt < 2; attempt += 1) {
1266
1266
  try {
1267
1267
  lockFd = fs.openSync(lockPath, "wx", 0o600);
1268
- fs.writeFileSync(lockFd, `${process.pid}\n${input.now.toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
1268
+ // The created-at stamp is compared with Date.now() by reclaimStaleLock
1269
+ // (the only liveness check where /proc is absent, e.g. macOS), so it must
1270
+ // be wall-clock time, not the caller's injected logical `now`.
1271
+ fs.writeFileSync(lockFd, `${process.pid}\n${new Date().toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
1269
1272
  break;
1270
1273
  }
1271
1274
  catch (error) {
@@ -0,0 +1,5 @@
1
+ export declare function importedQuestionMarker(root: string, id: string): string;
2
+ /** A pending transfer is also read-only: its remote POST may already have succeeded. */
3
+ export declare function assertQuestionNotImported(root: string, id: string): void;
4
+ export declare function isQuestionImported(root: string, id: string): boolean;
5
+ //# sourceMappingURL=imported-question.d.ts.map
@@ -0,0 +1,17 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { LanesError } from "./errors.js";
5
+ export function importedQuestionMarker(root, id) {
6
+ return path.join(root, "workspace", "decisions", "legacy-read-only", `${createHash("sha256").update(id).digest("hex")}.json`);
7
+ }
8
+ /** A pending transfer is also read-only: its remote POST may already have succeeded. */
9
+ export function assertQuestionNotImported(root, id) {
10
+ if (isQuestionImported(root, id)) {
11
+ throw new LanesError("question_imported_read_only", "This question was transferred to hq decide and is read-only in hq lanes. Use hq decide list to find and answer the decision.", { question_id: id });
12
+ }
13
+ }
14
+ export function isQuestionImported(root, id) {
15
+ return fs.existsSync(importedQuestionMarker(root, id));
16
+ }
17
+ //# sourceMappingURL=imported-question.js.map
@@ -1,8 +1,3 @@
1
- /**
2
- * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
- * path and for envelope `decision: "ask"` ingestion. Two writers here would
4
- * drift on hop, origin, raised_via, body store, and routing_history.
5
- */
6
1
  import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionPriority, type QuestionRecord, type RaisedVia } from "./types.js";
7
2
  export interface QuestionBody {
8
3
  question: string;
@@ -1,3 +1,4 @@
1
+ import { assertQuestionNotImported } from "./imported-question.js";
1
2
  /**
2
3
  * Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
3
4
  * path and for envelope `decision: "ask"` ingestion. Two writers here would
@@ -143,6 +144,7 @@ export function questionBodyAbsPath(hqRoot, question) {
143
144
  return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
144
145
  }
145
146
  export function writeQuestionBody(hqRoot, question, body) {
147
+ assertQuestionNotImported(hqRoot, question.question_id);
146
148
  assertQuestionBodySecretSafe(body);
147
149
  const store = parseQuestionBodyStore(question.body.store);
148
150
  const filePath = questionBodyAbsPath(hqRoot, question);
@@ -17,6 +17,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
17
17
  import { MAX_LANE_DEPTH } from "./constants.js";
18
18
  import { laneDepthCap } from "./depth-cap.js";
19
19
  import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
20
+ import { isQuestionImported } from "./imported-question.js";
20
21
  import { loadWorkerProfile } from "./profile.js";
21
22
  import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
22
23
  import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
@@ -650,16 +651,26 @@ async function closeInterruptedDoneStoryWorkers(hqRoot, lanes, questions, summar
650
651
  for (const question of questions) {
651
652
  if (question.origin.id !== lane.lane_id || question.status !== "pending")
652
653
  continue;
653
- casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
654
- ...current,
655
- status: "cancelled",
656
- routing_history: appendCappedHistory(current.routing_history, {
657
- hop: current.hop,
658
- audience: { kind: "user" },
659
- at,
660
- reason: "cancelled",
661
- }),
662
- }), false);
654
+ if (isQuestionImported(hqRoot, question.question_id))
655
+ continue;
656
+ try {
657
+ casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
658
+ ...current,
659
+ status: "cancelled",
660
+ routing_history: appendCappedHistory(current.routing_history, {
661
+ hop: current.hop,
662
+ audience: { kind: "user" },
663
+ at,
664
+ reason: "cancelled",
665
+ }),
666
+ }), false);
667
+ }
668
+ catch (error) {
669
+ // Import may acquire the record lock after our initial check. The
670
+ // decision service owns that question; continue closing this lane.
671
+ if (!(error instanceof LanesError) || error.error !== "question_imported_read_only")
672
+ throw error;
673
+ }
663
674
  }
664
675
  appendWatcherEvent(watcherEventsPath(hqRoot, lane.lane_id), {
665
676
  event: "story_closed",
@@ -1,11 +1,3 @@
1
- /**
2
- * File-backed lane / question record store (spec §10, §10.1).
3
- *
4
- * Root: workspace/lanes/ under the HQ root. One stable path per record;
5
- * `status` is the pending/answered discriminator — files are never moved
6
- * between directories. Every mutation is temp-file-plus-rename and a
7
- * compare-and-set on `version`. Histories are capped at 50 at the append site.
8
- */
9
1
  import * as fs from "node:fs";
10
2
  import { type OutpostIdentity } from "./admission-host.js";
11
3
  import { type AttributionHistoryEntry, type LaneRecord, type LaneWriter, type QuestionRecord, type RoutingHistoryEntry, type SeniorHistoryEntry } from "./types.js";
@@ -1,3 +1,4 @@
1
+ import { assertQuestionNotImported } from "./imported-question.js";
1
2
  /**
2
3
  * File-backed lane / question record store (spec §10, §10.1).
3
4
  *
@@ -2013,7 +2014,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
2013
2014
  return created;
2014
2015
  }
2015
2016
  export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
2016
- const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
2017
+ const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, (current) => { assertQuestionNotImported(hqRoot, questionId); return updater(current); }, deps);
2017
2018
  syncPendingMarker(hqRoot, next, deps);
2018
2019
  syncLaneQuestionReference(hqRoot, next, deps);
2019
2020
  return next;
@@ -20,4 +20,19 @@ export declare function storyStatusForEnvelope(status: string): "in_progress" |
20
20
  * and explicit story commands own those transitions.
21
21
  */
22
22
  export declare function terminalEnvelopeStoryStatus(lane: LaneRecord, decision: "done" | "blocked" | "ask", envelopeStatus?: string): LaneStoryStatus | undefined;
23
+ /**
24
+ * The stories array after attributing a lane to projectId/storyId.
25
+ *
26
+ * A lane with no story (empty story_id plus no_story_reason) carries no story
27
+ * rows, so attributing it to another project without a story keeps the rows
28
+ * unchanged. This is the single write site for attribution-driven appends:
29
+ * it never produces a row with an empty story_id, which the lane schema
30
+ * rejects (and which would roll back the whole write).
31
+ */
32
+ export declare function storiesAfterAttribution(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory[];
33
+ /**
34
+ * The row attribution would append, or undefined when nothing is appended
35
+ * (the story is already carried, or there is no story id).
36
+ */
37
+ export declare function attributedStoryRow(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory | undefined;
23
38
  //# sourceMappingURL=stories.d.ts.map
@@ -89,4 +89,29 @@ export function terminalEnvelopeStoryStatus(lane, decision, envelopeStatus = "in
89
89
  return "review";
90
90
  return storyStatusForEnvelope(envelopeStatus);
91
91
  }
92
+ /**
93
+ * The stories array after attributing a lane to projectId/storyId.
94
+ *
95
+ * A lane with no story (empty story_id plus no_story_reason) carries no story
96
+ * rows, so attributing it to another project without a story keeps the rows
97
+ * unchanged. This is the single write site for attribution-driven appends:
98
+ * it never produces a row with an empty story_id, which the lane schema
99
+ * rejects (and which would roll back the whole write).
100
+ */
101
+ export function storiesAfterAttribution(lane, projectId, storyId, at) {
102
+ const current = laneStories(lane);
103
+ const added = attributedStoryRow(lane, projectId, storyId, at);
104
+ return added ? [...current, added] : current;
105
+ }
106
+ /**
107
+ * The row attribution would append, or undefined when nothing is appended
108
+ * (the story is already carried, or there is no story id).
109
+ */
110
+ export function attributedStoryRow(lane, projectId, storyId, at) {
111
+ if (!storyId.trim())
112
+ return undefined;
113
+ if (laneStory(lane, projectId, storyId))
114
+ return undefined;
115
+ return { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at };
116
+ }
92
117
  //# sourceMappingURL=stories.js.map
@@ -54,6 +54,14 @@ export declare function createWorkSessionDeliverer(opts: {
54
54
  body: unknown;
55
55
  }>;
56
56
  }): DeliverFn;
57
+ /**
58
+ * Outbox error code for a 401/403 register refusal. Only real auth / identity
59
+ * refusals are AUTH_DENIED. A company whose work registry is off
60
+ * (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
61
+ * auth problem, so those keep their own code and doctor does not point at
62
+ * login (Shepherd issue 2726, feedback_6a792ab4).
63
+ */
64
+ export declare function registerDenialOutboxCode(serverCode: string | undefined): string;
57
65
  /**
58
66
  * Probe whether the server advertises migration capability (US-017A).
59
67
  * Failures return false (default-company stays locked).
@@ -4,7 +4,7 @@
4
4
  * vault HTTP + receipt-shaped responses (US-007A / US-007B).
5
5
  */
6
6
  import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, } from "./api.js";
7
- import { isWorkSessionDenialCode } from "./denial-codes.js";
7
+ import { isWorkSessionDenialCode, WORK_SESSION_DENIAL_CODES, } from "./denial-codes.js";
8
8
  export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
9
9
  export async function openMeshTransport(opts) {
10
10
  const token = await requireToken();
@@ -63,11 +63,11 @@ export function createWorkSessionDeliverer(opts) {
63
63
  if (status === 401 || status === 403) {
64
64
  const serverCode = isWorkSessionDenialCode(errorFields.code)
65
65
  ? errorFields.code
66
- : undefined;
66
+ : denialCodeFromMessage(message);
67
67
  return {
68
68
  ok: false,
69
69
  retryable: false,
70
- code: "AUTH_DENIED",
70
+ code: registerDenialOutboxCode(serverCode),
71
71
  httpStatus: status,
72
72
  ...(serverCode ? { serverCode } : {}),
73
73
  };
@@ -98,6 +98,15 @@ export function createWorkSessionDeliverer(opts) {
98
98
  return { ok: false, retryable: false, code: `HTTP_${status}` };
99
99
  }
100
100
  }
101
+ const messageCode = denialCodeFromMessage(message);
102
+ if (messageCode && registerDenialOutboxCode(messageCode) !== "AUTH_DENIED") {
103
+ return {
104
+ ok: false,
105
+ retryable: false,
106
+ code: registerDenialOutboxCode(messageCode),
107
+ serverCode: messageCode,
108
+ };
109
+ }
101
110
  if (/unauthorized|forbidden|auth/i.test(message)) {
102
111
  return { ok: false, retryable: false, code: "AUTH_DENIED" };
103
112
  }
@@ -112,6 +121,27 @@ export function createWorkSessionDeliverer(opts) {
112
121
  }
113
122
  };
114
123
  }
124
+ /**
125
+ * Outbox error code for a 401/403 register refusal. Only real auth / identity
126
+ * refusals are AUTH_DENIED. A company whose work registry is off
127
+ * (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
128
+ * auth problem, so those keep their own code and doctor does not point at
129
+ * login (Shepherd issue 2726, feedback_6a792ab4).
130
+ */
131
+ export function registerDenialOutboxCode(serverCode) {
132
+ if (serverCode === "WORK_REGISTRY_DISABLED" || serverCode === "INVALID_COMPANY") {
133
+ return serverCode;
134
+ }
135
+ return "AUTH_DENIED";
136
+ }
137
+ /** Find an allowlisted denial code in an error message when the error has no code field. */
138
+ function denialCodeFromMessage(message) {
139
+ for (const code of WORK_SESSION_DENIAL_CODES) {
140
+ if (new RegExp(`\\b${code}\\b`).test(message))
141
+ return code;
142
+ }
143
+ return undefined;
144
+ }
115
145
  function mapRegisterResponse(status, body, operationId) {
116
146
  if (status >= 200 && status < 300) {
117
147
  const rec = body && typeof body === "object" ? body : {};
@@ -124,6 +154,17 @@ function mapRegisterResponse(status, body, operationId) {
124
154
  if (status >= 500 || status === 429) {
125
155
  return { ok: false, retryable: true, code: `HTTP_${status}` };
126
156
  }
157
+ if (status === 401 || status === 403) {
158
+ const rec = body && typeof body === "object" ? body : {};
159
+ const serverCode = isWorkSessionDenialCode(rec.code) ? rec.code : undefined;
160
+ return {
161
+ ok: false,
162
+ retryable: false,
163
+ code: registerDenialOutboxCode(serverCode),
164
+ httpStatus: status,
165
+ ...(serverCode ? { serverCode } : {}),
166
+ };
167
+ }
127
168
  if (status >= 400 && status < 500) {
128
169
  return { ok: false, retryable: false, code: `HTTP_${status}` };
129
170
  }
@@ -26,7 +26,7 @@ export function isWorkSessionDenialCode(value) {
26
26
  }
27
27
  /** Plain-language reasons for denial codes the user can act on. */
28
28
  const WORK_SESSION_DENIAL_REASONS = {
29
- WORK_REGISTRY_DISABLED: "Work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-registry-enabled true",
29
+ WORK_REGISTRY_DISABLED: "Work registry is disabled for this company; ask a company admin to enable it with: hq company --company <slug> settings set --work-registry-enabled true. This is not a login problem.",
30
30
  FEATURE_DISABLED: "Live work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-mesh-live-enabled true",
31
31
  };
32
32
  /** Plain reason for a server denial code, or undefined when there is none. */
@@ -24,6 +24,12 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
24
24
  export declare const FLUSH_INTERVAL_MS = 10000;
25
25
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
26
26
  export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
27
+ /**
28
+ * A long-running daemon re-runs the held backfill this often while held
29
+ * events remain, so a backlog larger than one batch keeps draining after a
30
+ * reinstall instead of waiting for the next daemon start (Shepherd 2361).
31
+ */
32
+ export declare const HELD_BACKFILL_INTERVAL_MS: number;
27
33
  export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
28
34
  /** Skip further membership probes after a transport failure so a network drop
29
35
  * cannot fan out one hanging lookup per held session. */
@@ -60,10 +66,12 @@ export interface DaemonRunDeps {
60
66
  quarantined: number;
61
67
  skipped?: number;
62
68
  }>;
63
- /** Injected held-session recovery (tests). Runs once before the startup flush. */
69
+ /** Injected held-session recovery (tests). Runs before the startup flush and then periodically while held events remain. */
64
70
  backfillHeld?: () => Promise<BackfillHeldResult>;
65
71
  /** Maximum distinct held sessions reconciled during one daemon startup. */
66
72
  heldBackfillBatchSize?: number;
73
+ /** Minimum gap between periodic held backfills. Default HELD_BACKFILL_INTERVAL_MS. */
74
+ heldBackfillIntervalMs?: number;
67
75
  /** Injected board refresh (tests). */
68
76
  refreshBoards?: () => Promise<{
69
77
  refreshed: number;
@@ -46,6 +46,12 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
46
46
  export const FLUSH_INTERVAL_MS = 10_000;
47
47
  /** Keep startup recovery bounded: the next daemon start continues the backlog. */
48
48
  export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
49
+ /**
50
+ * A long-running daemon re-runs the held backfill this often while held
51
+ * events remain, so a backlog larger than one batch keeps draining after a
52
+ * reinstall instead of waiting for the next daemon start (Shepherd 2361).
53
+ */
54
+ export const HELD_BACKFILL_INTERVAL_MS = 15 * 60 * 1_000;
49
55
  export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
50
56
  /** Skip further membership probes after a transport failure so a network drop
51
57
  * cannot fan out one hanging lookup per held session. */
@@ -350,6 +356,19 @@ export async function runMeshDaemon(deps = {}) {
350
356
  };
351
357
  /** In-memory held re-attempt throttle (survives watch-triggered flushes). */
352
358
  let lastHeldRetryAtMs = 0;
359
+ /** Epoch ms of the last held backfill (startup or periodic). */
360
+ let lastHeldBackfillAtMs = 0;
361
+ const heldBackfillIntervalMs = deps.heldBackfillIntervalMs ?? HELD_BACKFILL_INTERVAL_MS;
362
+ const heldBackfillDue = (atMs) => {
363
+ if (atMs - lastHeldBackfillAtMs < heldBackfillIntervalMs)
364
+ return false;
365
+ try {
366
+ return fs.statSync(workMeshHeldPath(meshRoot)).size > 0;
367
+ }
368
+ catch {
369
+ return false;
370
+ }
371
+ };
353
372
  const shutdownController = new AbortController();
354
373
  const flushFn = deps.flush ??
355
374
  (async (signal) => {
@@ -380,6 +399,12 @@ export async function runMeshDaemon(deps = {}) {
380
399
  return "unavailable";
381
400
  return authorization.status === "authorized";
382
401
  },
402
+ // Held sessions whose local evidence only names a slug need the
403
+ // membership slug -> uid lookup, or they stay NEEDS_COMPANY forever.
404
+ resolveCompanyUid: async (slug) => {
405
+ const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault: false });
406
+ return authorization.status === "authorized" ? authorization.uid : null;
407
+ },
383
408
  });
384
409
  });
385
410
  const replayFn = deps.replayOutbox ??
@@ -674,7 +699,11 @@ export async function runMeshDaemon(deps = {}) {
674
699
  // A held session has already ended, so no later hook will naturally
675
700
  // revisit its context. Repair a bounded batch before the first flush;
676
701
  // recovered state is then retried immediately in this same cycle.
677
- if (reason === "start") {
702
+ // A long-running daemon repeats the bounded batch on an interval
703
+ // while held events remain, so the cursor keeps rotating through a
704
+ // backlog larger than one batch.
705
+ if (reason === "start" || heldBackfillDue(now().getTime())) {
706
+ lastHeldBackfillAtMs = now().getTime();
678
707
  try {
679
708
  const recovered = await backfillFn();
680
709
  if (recovered.reconciled > 0 ||
@@ -58,6 +58,13 @@ export interface FlushDeps {
58
58
  * stripped. Defaults to a per-flush cached git resolver (tests inject).
59
59
  */
60
60
  resolveWorkLocation?: WorkLocationResolver;
61
+ /**
62
+ * Membership-validated slug -> companyUid lookup for held sessions whose
63
+ * local evidence names a company slug but no uid. Return the uid, or null
64
+ * when the caller is not a member or the lookup is unavailable (the events
65
+ * stay held and are retried). Called at most once per slug per flush.
66
+ */
67
+ resolveCompanyUid?: (slug: string) => Promise<string | null>;
61
68
  }
62
69
  export interface FlushSummary {
63
70
  claimed: number;