@indigoai-us/hq-cli 5.312.1 → 5.312.2-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +2 -5
  2. package/dist/command-catalog.generated.d.ts +0 -65
  3. package/dist/command-catalog.generated.js +0 -80
  4. package/dist/commands/cloud.js +1 -0
  5. package/dist/commands/company.js +0 -2
  6. package/dist/commands/core.js +0 -7
  7. package/dist/fast-core.js +3 -10
  8. package/dist/fast-scaffold.d.ts +0 -2
  9. package/dist/fast-scaffold.js +0 -11
  10. package/dist/lib/doctor/checks/work-context.js +1 -9
  11. package/dist/lib/mesh/client.js +5 -23
  12. package/dist/lib/mesh/live/daemon/doctor.d.ts +0 -2
  13. package/dist/lib/mesh/live/daemon/doctor.js +1 -11
  14. package/dist/lib/search-index/embed-lock.d.ts +0 -2
  15. package/dist/lib/search-index/embed-lock.js +1 -10
  16. package/dist/lib/work-context/contract.d.ts +0 -3
  17. package/dist/lib/work-context/contract.schema.json +1 -3
  18. package/dist/lib/work-context/outbox.d.ts +5 -14
  19. package/dist/lib/work-context/outbox.js +5 -48
  20. package/dist/lib/work-context/reconcile.js +5 -26
  21. package/dist/main.d.ts +0 -20
  22. package/dist/main.js +1 -8
  23. package/package.json +1 -1
  24. package/assets/scaffold/core/scripts/lib/work-mesh-live-hook.sh +0 -378
  25. package/assets/scaffold/core/scripts/lib/work-mesh-live-rebind.sh +0 -141
  26. package/assets/scaffold/core/scripts/migrate-policy-triggers.sh +0 -373
  27. package/assets/scaffold/core/scripts/register-project.sh +0 -262
  28. package/assets/scaffold/core/scripts/work-mesh-live-rebind.sh +0 -59
  29. package/dist/commands/company-rename-flag.d.ts +0 -4
  30. package/dist/commands/company-rename-flag.js +0 -7
  31. package/dist/commands/company-rename.d.ts +0 -4
  32. package/dist/commands/company-rename.js +0 -90
  33. package/dist/commands/core-eval-trigger-native.d.ts +0 -4
  34. package/dist/commands/core-eval-trigger-native.js +0 -12
  35. package/dist/commands/core-migrate-policy-triggers-native.d.ts +0 -21
  36. package/dist/commands/core-migrate-policy-triggers-native.js +0 -378
  37. package/dist/commands/core-native-hook-helper-commands.d.ts +0 -4
  38. package/dist/commands/core-native-hook-helper-commands.js +0 -56
  39. package/dist/commands/core-native-hook-helpers-flag.d.ts +0 -4
  40. package/dist/commands/core-native-hook-helpers-flag.js +0 -7
  41. package/dist/commands/core-register-project-native.d.ts +0 -30
  42. package/dist/commands/core-register-project-native.js +0 -347
  43. package/dist/commands/core-work-mesh-live-rebind-native.d.ts +0 -18
  44. package/dist/commands/core-work-mesh-live-rebind-native.js +0 -262
  45. package/dist/lib/mesh/denial-codes.d.ts +0 -5
  46. package/dist/lib/mesh/denial-codes.js +0 -14
  47. package/dist/utils/core-eval-trigger.d.ts +0 -4
  48. package/dist/utils/core-eval-trigger.js +0 -88
@@ -8,7 +8,6 @@ import * as path from "node:path";
8
8
  import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
9
9
  import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
10
10
  import { NotTrackingError } from "./errors.js";
11
- import { isWorkSessionDenialCode } from "../mesh/denial-codes.js";
12
11
  import { readSessionState, stripRegisterReceiptEpisodes, updateSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
13
12
  import { isSafeWorkContextSegment, workContextOutboxDir, workContextOutboxPath, } from "./paths.js";
14
13
  /** Fields allowed on a durable outbox operation (privacy allowlist). */
@@ -30,8 +29,6 @@ export const OUTBOX_ALLOWLIST = [
30
29
  "updatedAt",
31
30
  "attemptCount",
32
31
  "lastErrorCode",
33
- "lastErrorHttpStatus",
34
- "lastServerErrorCode",
35
32
  "receiptId",
36
33
  "destinationCompanyUid",
37
34
  "destinationCompanySlug",
@@ -104,11 +101,6 @@ function projectOutbox(op) {
104
101
  out.companyConfidence = op.companyConfidence;
105
102
  if (op.lastErrorCode)
106
103
  out.lastErrorCode = op.lastErrorCode;
107
- if (op.lastErrorHttpStatus !== undefined) {
108
- out.lastErrorHttpStatus = op.lastErrorHttpStatus;
109
- }
110
- if (op.lastServerErrorCode)
111
- out.lastServerErrorCode = op.lastServerErrorCode;
112
104
  if (op.receiptId)
113
105
  out.receiptId = op.receiptId;
114
106
  if (op.destinationCompanyUid) {
@@ -345,13 +337,12 @@ export function repairAckedRegisterStates(root, sessionIds, now = () => new Date
345
337
  }
346
338
  return repaired;
347
339
  }
348
- export function markOutboxQueued(operationId, root, errorCode, now = () => new Date(), random = Math.random, details) {
340
+ export function markOutboxQueued(operationId, root, errorCode, now = () => new Date(), random = Math.random) {
349
341
  const op = readOutboxOperation(operationId, root);
350
342
  if (!op)
351
343
  return null;
352
344
  op.delivery = "queued";
353
345
  op.lastErrorCode = errorCode;
354
- applyOutboxErrorDetails(op, details);
355
346
  op.attemptCount += 1;
356
347
  const at = now();
357
348
  op.updatedAt = at.toISOString();
@@ -360,40 +351,17 @@ export function markOutboxQueued(operationId, root, errorCode, now = () => new D
360
351
  updateOutboxOperation(op, root);
361
352
  return op;
362
353
  }
363
- export function markOutboxQuarantined(operationId, root, errorCode, now = () => new Date(), details) {
354
+ export function markOutboxQuarantined(operationId, root, errorCode, now = () => new Date()) {
364
355
  const op = readOutboxOperation(operationId, root);
365
356
  if (!op)
366
357
  return null;
367
358
  op.delivery = "quarantined";
368
359
  op.lastErrorCode = errorCode;
369
- applyOutboxErrorDetails(op, details);
370
360
  op.attemptCount += 1;
371
361
  op.updatedAt = now().toISOString();
372
362
  updateOutboxOperation(op, root);
373
363
  return op;
374
364
  }
375
- function applyOutboxErrorDetails(op, details) {
376
- delete op.lastErrorHttpStatus;
377
- delete op.lastServerErrorCode;
378
- const safe = sanitizeOutboxErrorDetails(details);
379
- if (safe.httpStatus !== undefined)
380
- op.lastErrorHttpStatus = safe.httpStatus;
381
- if (safe.serverCode !== undefined)
382
- op.lastServerErrorCode = safe.serverCode;
383
- }
384
- export function sanitizeOutboxErrorDetails(details) {
385
- const safe = {};
386
- if (details?.httpStatus !== undefined &&
387
- Number.isInteger(details.httpStatus) &&
388
- details.httpStatus >= 100 &&
389
- details.httpStatus <= 599) {
390
- safe.httpStatus = details.httpStatus;
391
- }
392
- if (isWorkSessionDenialCode(details?.serverCode)) {
393
- safe.serverCode = details.serverCode;
394
- }
395
- return safe;
396
- }
397
365
  /**
398
366
  * Requeue quarantined outbox operations so the daemon's replay picks them up
399
367
  * again. Quarantine is otherwise terminal: after the work-mesh-live gap 10 fix
@@ -421,8 +389,6 @@ export function requeueQuarantinedOutbox(root, opts = {}) {
421
389
  op.delivery = "queued";
422
390
  op.attemptCount = 0;
423
391
  op.lastErrorCode = undefined;
424
- op.lastErrorHttpStatus = undefined;
425
- op.lastServerErrorCode = undefined;
426
392
  op.nextAttemptAt = undefined;
427
393
  op.updatedAt = now().toISOString();
428
394
  updateOutboxOperation(op, root);
@@ -503,15 +469,6 @@ export function outboxStats(root) {
503
469
  queued: ops.filter((o) => o.delivery === "queued").length,
504
470
  acked: ops.filter((o) => o.delivery === "acked").length,
505
471
  lastQuarantineReason: latestQuarantine?.lastErrorCode ?? null,
506
- lastQuarantineHttpStatus: latestQuarantine &&
507
- Number.isInteger(latestQuarantine.lastErrorHttpStatus) &&
508
- latestQuarantine.lastErrorHttpStatus >= 100 &&
509
- latestQuarantine.lastErrorHttpStatus <= 599
510
- ? latestQuarantine.lastErrorHttpStatus
511
- : null,
512
- lastQuarantineServerCode: isWorkSessionDenialCode(latestQuarantine?.lastServerErrorCode)
513
- ? latestQuarantine.lastServerErrorCode
514
- : null,
515
472
  };
516
473
  }
517
474
  function isOutboxDue(op, nowIso) {
@@ -594,18 +551,18 @@ export async function replayOutbox(root, deliver, opts = {}) {
594
551
  }
595
552
  else if (result.retryable) {
596
553
  if (op.attemptCount + 1 >= OUTBOX_MAX_ATTEMPTS) {
597
- markOutboxQuarantined(op.operationId, root, `${result.code}_MAX_ATTEMPTS`, now, result);
554
+ markOutboxQuarantined(op.operationId, root, `${result.code}_MAX_ATTEMPTS`, now);
598
555
  quarantined += 1;
599
556
  }
600
557
  else {
601
- markOutboxQueued(op.operationId, root, result.code, now, random, result);
558
+ markOutboxQueued(op.operationId, root, result.code, now, random);
602
559
  queued += 1;
603
560
  }
604
561
  // Stop this session's FIFO on transient failure.
605
562
  break;
606
563
  }
607
564
  else {
608
- markOutboxQuarantined(op.operationId, root, result.code, now, result);
565
+ markOutboxQuarantined(op.operationId, root, result.code, now);
609
566
  quarantined += 1;
610
567
  break;
611
568
  }
@@ -14,7 +14,7 @@ import { refineProjectBinding } from "./bind-best.js";
14
14
  import { rebindNeedsProjectOnPrompt } from "./prompt-rebind.js";
15
15
  import { resolveProjectTask, shouldAskAfter } from "./project.js";
16
16
  import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
17
- import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, sanitizeOutboxErrorDetails, writeAckedRegisterState, } from "./outbox.js";
17
+ import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
18
18
  import { mergeLocalSessionFields, isRegisterReceiptEpisodeId, readSessionState, stripRegisterReceiptEpisodes, writeSessionState, } from "./state.js";
19
19
  function resultOf(parts) {
20
20
  const out = {
@@ -38,10 +38,6 @@ function resultOf(parts) {
38
38
  out.bindingEpisodeId = parts.bindingEpisodeId;
39
39
  if (parts.errorCode)
40
40
  out.errorCode = parts.errorCode;
41
- if (parts.httpStatus !== undefined)
42
- out.httpStatus = parts.httpStatus;
43
- if (parts.serverCode)
44
- out.serverCode = parts.serverCode;
45
41
  if (parts.projectBindReason)
46
42
  out.projectBindReason = parts.projectBindReason;
47
43
  if (parts.projectCandidates)
@@ -978,8 +974,7 @@ export async function reconcileObservation(obs, deps) {
978
974
  };
979
975
  }
980
976
  if (delivered.retryable) {
981
- const details = sanitizeOutboxErrorDetails(delivered);
982
- markOutboxQueued(outboxOp.operationId, deps.root, delivered.code, nowFn, undefined, details);
977
+ markOutboxQueued(outboxOp.operationId, deps.root, delivered.code, nowFn);
983
978
  return {
984
979
  result: resultOf({
985
980
  kind: "queued",
@@ -991,20 +986,15 @@ export async function reconcileObservation(obs, deps) {
991
986
  projectId,
992
987
  taskId,
993
988
  errorCode: delivered.code,
994
- ...details,
995
989
  ...bindResultFields,
996
990
  }),
997
991
  exitCode: EXIT_OK,
998
- diagnostics: [
999
- ...diagnostics,
1000
- `transient ${delivered.code}${formatDeliveryDetails(details)}; remains queued`,
1001
- ],
992
+ diagnostics: [...diagnostics, `transient ${delivered.code}; remains queued`],
1002
993
  statePath,
1003
994
  outboxOperationId: outboxOp.operationId,
1004
995
  };
1005
996
  }
1006
- const details = sanitizeOutboxErrorDetails(delivered);
1007
- markOutboxQuarantined(outboxOp.operationId, deps.root, delivered.code, nowFn, details);
997
+ markOutboxQuarantined(outboxOp.operationId, deps.root, delivered.code, nowFn);
1008
998
  return {
1009
999
  result: resultOf({
1010
1000
  kind: "error",
@@ -1017,24 +1007,13 @@ export async function reconcileObservation(obs, deps) {
1017
1007
  projectId,
1018
1008
  taskId,
1019
1009
  errorCode: delivered.code,
1020
- ...details,
1021
1010
  ...bindResultFields,
1022
1011
  }),
1023
1012
  exitCode: EXIT_OK,
1024
- diagnostics: [
1025
- ...diagnostics,
1026
- `quarantined: ${delivered.code}${formatDeliveryDetails(details)}`,
1027
- ],
1013
+ diagnostics: [...diagnostics, `quarantined: ${delivered.code}`],
1028
1014
  statePath,
1029
1015
  outboxOperationId: outboxOp.operationId,
1030
1016
  };
1031
1017
  }
1032
- function formatDeliveryDetails(details) {
1033
- const parts = [
1034
- details.httpStatus !== undefined ? `HTTP ${details.httpStatus}` : "",
1035
- details.serverCode ?? "",
1036
- ].filter(Boolean);
1037
- return parts.length ? ` (${parts.join(" ")})` : "";
1038
- }
1039
1018
  export { replayOutbox };
1040
1019
  //# sourceMappingURL=reconcile.js.map
package/dist/main.d.ts CHANGED
@@ -12,25 +12,6 @@ import { upgradeToTeam } from "./utils/team-upgrade.js";
12
12
  * finalization; command execution itself does not consume this allowance.
13
13
  */
14
14
  export declare const FOREGROUND_NETWORK_BUDGET_MS = 1500;
15
- /**
16
- * Shared accounting for awaited foreground network work. Each lifecycle phase
17
- * debits only the bounded await it performs; ordinary command runtime leaves
18
- * the allowance untouched. Earlier phases reserve the finalization floor, so
19
- * error flushing cannot disappear after a long command.
20
- */
21
- declare class ForegroundNetworkBudget {
22
- private readonly budgetMs;
23
- private readonly now;
24
- private consumedMs;
25
- constructor(budgetMs?: number, now?: () => number);
26
- timeoutFor(phaseMaximumMs: number, reservedMs?: number): number;
27
- waitFor<T>(phaseMaximumMs: number, reservedMs: number, work: (timeoutMs: number) => Promise<T>): Promise<T | undefined>;
28
- }
29
- /** Create a shared invocation budget; exported so the time accounting is directly testable. */
30
- export declare function createForegroundNetworkBudget(options?: {
31
- budgetMs?: number;
32
- now?: () => number;
33
- }): ForegroundNetworkBudget;
34
15
  export type StreamErrorDependencies = {
35
16
  stderr: Pick<typeof process.stderr, "write">;
36
17
  exit: (code: number) => void;
@@ -49,5 +30,4 @@ export type TopLevelErrorDependencies = {
49
30
  };
50
31
  /** Classify a top-level failure without making the CLI process boundary opaque to tests. */
51
32
  export declare function handleTopLevelError(err: unknown, deps?: TopLevelErrorDependencies): Promise<void>;
52
- export {};
53
33
  //# sourceMappingURL=main.d.ts.map
package/dist/main.js CHANGED
@@ -108,13 +108,6 @@ class ForegroundNetworkBudget {
108
108
  }
109
109
  }
110
110
  }
111
- /** Create a shared invocation budget; exported so the time accounting is directly testable. */
112
- export function createForegroundNetworkBudget(options = {}) {
113
- if (options.budgetMs === undefined && options.now === undefined) {
114
- return new ForegroundNetworkBudget();
115
- }
116
- return new ForegroundNetworkBudget(options.budgetMs ?? FOREGROUND_NETWORK_BUDGET_MS, options.now ?? (() => performance.now()));
117
- }
118
111
  let activeForegroundNetworkBudget;
119
112
  /**
120
113
  * The RUNNING install's own entrypoint (`<pkg>/dist/index.js`), used as the
@@ -200,7 +193,7 @@ program.hook("preAction", async () => {
200
193
  });
201
194
  });
202
195
  export async function runCli() {
203
- const foregroundNetworkBudget = createForegroundNetworkBudget();
196
+ const foregroundNetworkBudget = new ForegroundNetworkBudget();
204
197
  activeForegroundNetworkBudget = foregroundNetworkBudget;
205
198
  // Login must be usable before any identity or company is available, so do
206
199
  // not make a pre-auth feature-flag request on this path. Other commands
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.312.1",
3
+ "version": "5.312.2-beta.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -1,378 +0,0 @@
1
- #!/usr/bin/env bash
2
- # hq-core: public
3
- # work-mesh-live-hook.sh — shared helpers for Work Mesh Live enqueue-only hooks.
4
- # Sourced by core/hooks/*/3*-work-mesh-*.sh. Never execute directly.
5
-
6
- # work_mesh_live_disabled
7
- # Exit 0 (true) when kill switches say these hooks must no-op.
8
- work_mesh_live_disabled() {
9
- case "${HQ_WORK_MESH_DISABLED:-}" in
10
- 1|true|TRUE|yes|YES|on|ON) return 0 ;;
11
- esac
12
- case ",${HQ_DISABLED_HOOKS:-}," in
13
- *,work-mesh,*|*,work-mesh-live,*|*,\*,*) return 0 ;;
14
- esac
15
- return 1
16
- }
17
-
18
- # work_mesh_live_bootstrap
19
- # Resolve HQ_ROOT from the calling hook path; source enqueue + session-id.
20
- # Caller must set _WM_HOOK_DIR to dirname of BASH_SOURCE[0] before calling,
21
- # OR pass the hook file path as $1.
22
- work_mesh_live_bootstrap() {
23
- local hook_file="${1:-${BASH_SOURCE[1]:-}}"
24
- local hook_dir hq
25
- hook_dir="$(cd "$(dirname "$hook_file")" 2>/dev/null && pwd)" || return 1
26
- # core/hooks/<Event>/<file> -> HQ root is ../../..
27
- hq="${HQ_ROOT:-$(cd "$hook_dir/../../.." 2>/dev/null && pwd)}"
28
- [ -n "$hq" ] || return 1
29
- export HQ_ROOT="$hq"
30
- # shellcheck source=core/scripts/lib/work-mesh-enqueue.sh
31
- . "$HQ_ROOT/core/scripts/lib/work-mesh-enqueue.sh" 2>/dev/null || return 1
32
- # shellcheck source=core/scripts/lib/session-id.sh
33
- . "$HQ_ROOT/core/scripts/lib/session-id.sh" 2>/dev/null || true
34
- return 0
35
- }
36
-
37
- # work_mesh_live_session_id <stdin-json>
38
- # Prefer env (session_id_from_env); else payload session_id / sessionId.
39
- work_mesh_live_session_id() {
40
- local input="${1:-}" id=""
41
- if command -v session_id_from_env >/dev/null 2>&1; then
42
- id="$(session_id_from_env)"
43
- fi
44
- if [ -z "$id" ]; then
45
- for var in HQ_SESSION_ID CLAUDE_CODE_SESSION_ID CLAUDE_SESSION_ID CODEX_SESSION_ID CODEX_THREAD_ID; do
46
- id="${!var:-}"
47
- id="$(printf '%s' "$id" | tr -d '[:space:]')"
48
- [ -n "$id" ] && break
49
- done
50
- fi
51
- if [ -z "$id" ] && [ -n "$input" ]; then
52
- # Prefer jq when present; pure-bash fallback for the common keys.
53
- if command -v jq >/dev/null 2>&1; then
54
- id="$(printf '%s' "$input" | jq -r '.session_id // .sessionId // .conversation_id // .conversationId // .thread_id // .threadId // empty' 2>/dev/null || true)"
55
- else
56
- id="$(printf '%s' "$input" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p; t; s/.*"sessionId"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)"
57
- fi
58
- id="$(printf '%s' "$id" | tr -d '[:space:]')"
59
- fi
60
- if command -v session_id_is_valid >/dev/null 2>&1; then
61
- session_id_is_valid "$id" || id=""
62
- else
63
- case "$id" in
64
- ""|.|..|*[!A-Za-z0-9._-]*) id="" ;;
65
- esac
66
- fi
67
- printf '%s' "$id"
68
- }
69
-
70
- # work_mesh_live_harness
71
- # HQ_HARNESS / HQ_WORK_MESH_HARNESS / HQ_CHECKPOINT_RUNTIME -> contract harness.
72
- work_mesh_live_harness() {
73
- local h="${HQ_HARNESS:-${HQ_WORK_MESH_HARNESS:-${HQ_CHECKPOINT_RUNTIME:-claude-code}}}"
74
- case "$h" in
75
- claude|claude-code|Claude|ClaudeCode) printf 'claude-code' ;;
76
- codex|Codex) printf 'codex' ;;
77
- grok|Grok) printf 'grok' ;;
78
- hq-sessions|hq_sessions|sessions) printf 'hq-sessions' ;;
79
- agent-box|agent_box|box) printf 'agent-box' ;;
80
- *) printf '%s' "$h" ;;
81
- esac
82
- }
83
-
84
- # work_mesh_live_adapter_version
85
- work_mesh_live_adapter_version() {
86
- if [ -n "${HQ_ADAPTER_CONTRACT_VERSION:-}" ]; then
87
- printf '%s' "$HQ_ADAPTER_CONTRACT_VERSION"
88
- return 0
89
- fi
90
- if [ -f "${HQ_ROOT:-}/core/scripts/lib/provider-adapter-version.sh" ]; then
91
- # shellcheck source=core/scripts/lib/provider-adapter-version.sh
92
- . "$HQ_ROOT/core/scripts/lib/provider-adapter-version.sh" 2>/dev/null || true
93
- fi
94
- printf '%s' "${HQ_ADAPTER_CONTRACT_VERSION:-1.0.0}"
95
- }
96
-
97
- # work_mesh_live_runtime_version
98
- work_mesh_live_runtime_version() {
99
- printf '%s' "${HQ_RUNTIME_VERSION:-${CLAUDE_CODE_VERSION:-${CODEX_VERSION:-${GROK_VERSION:-}}}}"
100
- }
101
-
102
- # work_mesh_live_next_seq <session-id>
103
- # Monotonic seq under ~/.hq/work-mesh/seq/<sid> (or WORK_MESH_SEQ_DIR).
104
- # Hot path: bash file read, no tr/chmod when the dir already exists.
105
- work_mesh_live_next_seq() {
106
- local sid="$1" dir file n
107
- dir="${WORK_MESH_SEQ_DIR:-${HOME}/.hq/work-mesh/seq}"
108
- if [ ! -d "$dir" ]; then
109
- mkdir -p -- "$dir" 2>/dev/null || true
110
- chmod 700 -- "$dir" 2>/dev/null || true
111
- fi
112
- file="$dir/$sid"
113
- n=0
114
- if [ -f "$file" ]; then
115
- n="$(<"$file")"
116
- n="${n//[[:space:]]/}"
117
- case "$n" in
118
- ""|*[!0-9]*) n=0 ;;
119
- esac
120
- fi
121
- n=$((n + 1))
122
- printf '%s\n' "$n" >"$file" 2>/dev/null || return 1
123
- printf '%s' "$n"
124
- }
125
-
126
- # work_mesh_live_meta_get <root> <sid> <key>
127
- work_mesh_live_meta_get() {
128
- local root="$1" sid="$2" key="$3" meta
129
- meta="$root/workspace/sessions/$sid/meta.yaml"
130
- [ -f "$meta" ] || return 0
131
- awk -v k="$key" '$1==k":"{ sub(/^[^:]+:[[:space:]]*/,""); gsub(/^"|"$/,""); print; exit }' "$meta" 2>/dev/null
132
- }
133
-
134
- # work_mesh_live_state_path <sid>
135
- work_mesh_live_state_path() {
136
- printf '%s/.hq/work-context/sessions/%s.json' "${WORK_MESH_HOME:-$HOME}" "$1"
137
- }
138
-
139
- # work_mesh_live_board_path <sid>
140
- work_mesh_live_board_path() {
141
- printf '%s/.hq/work-context/sessions/%s/board.md' "${WORK_MESH_HOME:-$HOME}" "$1"
142
- }
143
-
144
- # work_mesh_live_ask_surfaced_path <sid>
145
- work_mesh_live_ask_surfaced_path() {
146
- printf '%s/.hq/work-context/sessions/%s.ask-surfaced' "${WORK_MESH_HOME:-$HOME}" "$1"
147
- }
148
-
149
- # work_mesh_live_pending_decision_path <sid>
150
- work_mesh_live_pending_decision_path() {
151
- printf '%s/.hq/work-context/sessions/%s.pending-decision' "${WORK_MESH_HOME:-$HOME}" "$1"
152
- }
153
-
154
- # work_mesh_live_word_count <text> — count whitespace-separated tokens.
155
- work_mesh_live_word_count() {
156
- local t="$1"
157
- # shellcheck disable=SC2086
158
- set -- $t
159
- printf '%s' "$#"
160
- }
161
-
162
- # work_mesh_live_is_slash_or_short <prompt>
163
- # True (0) when slash command or fewer than 3 words.
164
- work_mesh_live_is_slash_or_short() {
165
- local p="$1" n
166
- case "$p" in
167
- /*) return 0 ;;
168
- esac
169
- n="$(work_mesh_live_word_count "$p")"
170
- [ "$n" -lt 3 ]
171
- }
172
-
173
- # work_mesh_live_bump_tool_writes <sid>
174
- # Atomically increment toolWrites on the state JSON (create stub if absent).
175
- # Sets REPLY to the new count. Uses temp+mv and chmod 600.
176
- # Merge-safe: only toolWrites and updatedAt change; every other field the
177
- # hq-cli reconcile/ack path wrote (companyUid, companySlug, projectId, taskId,
178
- # startedAt, bindingEpisodeId, decision, contextStatus, ...) is preserved
179
- # byte-for-byte. Prefers jq; the no-jq fallback edits the two values in place
180
- # in pure bash (compact or pretty-printed JSON, any key order) and only
181
- # appends a missing key. contextStatus is set to "unresolved" only on a fresh
182
- # stub. Bash 3.2 compatible; no sed/awk/GNU flags.
183
- # Concurrency: hq-cli writes the same path with same-dir tmp + rename and has
184
- # no lock convention (hq-cli src/lib/work-context/atomic.ts). Two lines of
185
- # defense against a reconcile write landing between our read and mv:
186
- # 1. mkdir lock at <path>.lock (Bash 3.2 safe), bounded wait ~200ms, stale
187
- # lock reclaimed by age (>5s). If the lock cannot be taken, the bump is
188
- # skipped (return 1) rather than risk a clobber.
189
- # 2. Re-read immediately before mv; if the file changed since the first
190
- # read, rebuild from the new body.
191
- # Test seam: WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ=<secs> sleeps between the
192
- # first read and the re-read so a test can inject a concurrent writer.
193
- work_mesh_live_bump_tool_writes() {
194
- local sid="$1" path dir lock rc
195
- path="$(work_mesh_live_state_path "$sid")"
196
- dir="${path%/*}"
197
- if [ ! -d "$dir" ]; then
198
- mkdir -p -- "$dir" 2>/dev/null || true
199
- chmod 700 -- "$dir" 2>/dev/null || true
200
- fi
201
- lock="$path.lock"
202
- _work_mesh_live_lock_acquire "$lock" || return 1
203
- _work_mesh_live_bump_locked "$sid" "$path"
204
- rc=$?
205
- rm -rf -- "$lock" 2>/dev/null || true
206
- return $rc
207
- }
208
-
209
- # _work_mesh_live_lock_acquire <lockdir>
210
- # mkdir lock; ~20 x 10ms bounded wait; reclaim when the holder's stamp is
211
- # older than 5s (or missing after the full wait). 0 = held, 1 = give up.
212
- _work_mesh_live_lock_acquire() {
213
- local lock="$1" i=0 now stamp
214
- while :; do
215
- if mkdir -- "$lock" 2>/dev/null; then
216
- date +%s >"$lock/ts" 2>/dev/null || true
217
- return 0
218
- fi
219
- i=$((i + 1))
220
- if [ "$i" -ge 20 ]; then
221
- now="$(date +%s 2>/dev/null || printf '0')"
222
- stamp="$(cat "$lock/ts" 2>/dev/null || printf '')"
223
- case "$stamp" in ""|*[!0-9]*) stamp=0 ;; esac
224
- if [ "$stamp" -eq 0 ] || [ $((now - stamp)) -gt 5 ]; then
225
- rm -rf -- "$lock" 2>/dev/null || true
226
- if mkdir -- "$lock" 2>/dev/null; then
227
- date +%s >"$lock/ts" 2>/dev/null || true
228
- return 0
229
- fi
230
- fi
231
- return 1
232
- fi
233
- sleep 0.01 2>/dev/null || sleep 1
234
- done
235
- }
236
-
237
- # _work_mesh_live_bump_render <sid> <path> <tmp>
238
- # Read <path> (if any), build the bumped document into <tmp>.
239
- # Sets _WM_SEEN to the body that was read ("" when absent) and REPLY to the
240
- # new count. Returns 1 when the file must not be touched.
241
- _work_mesh_live_bump_render() {
242
- local sid="$1" path="$2" tmp="$3" cur="" next ts body="" rest have_jq=0 written=0
243
- _WM_SEEN=""
244
- command -v jq >/dev/null 2>&1 && have_jq=1
245
- if [ -f "$path" ]; then
246
- body="$(<"$path")"
247
- _WM_SEEN="$body"
248
- case "$body" in
249
- *"\"toolWrites\""*)
250
- rest="${body#*\"toolWrites\"}"; rest="${rest#*:}"
251
- while [ "${rest#"${rest%%[![:space:]]*}"}" != "$rest" ]; do rest="${rest#?}"; done
252
- cur="${rest%%[!0-9]*}"
253
- ;;
254
- esac
255
- fi
256
- case "$cur" in ""|*[!0-9]*) cur=0 ;; esac
257
- next=$((cur + 1))
258
- ts=""
259
- # %(...)T needs Bash 4.2+; macOS Bash 3.2 falls back to date -u (BSD + GNU).
260
- TZ=UTC printf -v ts '%(%Y-%m-%dT%H:%M:%S)T.000Z' -1 2>/dev/null || ts=""
261
- case "$ts" in
262
- ""|*%*) ts="$(TZ=UTC date -u '+%Y-%m-%dT%H:%M:%S.000Z' 2>/dev/null || true)" ;;
263
- esac
264
- [ -n "$ts" ] || ts="1970-01-01T00:00:00.000Z"
265
- if [ ! -f "$path" ]; then
266
- printf '{"contractVersion":1,"sessionId":"%s","contextStatus":"unresolved","toolWrites":%s,"updatedAt":"%s"}\n' \
267
- "$sid" "$next" "$ts" >"$tmp" || return 1
268
- REPLY="$next"
269
- return 0
270
- fi
271
- if [ "$have_jq" -eq 1 ]; then
272
- if printf '%s' "$body" | jq --argjson n "$next" --arg ts "$ts" \
273
- '.toolWrites = $n | .updatedAt = $ts' >"$tmp" 2>/dev/null \
274
- && [ -s "$tmp" ]; then
275
- written=1
276
- elif jq -n empty >/dev/null 2>&1; then
277
- # jq works but could not parse the file (malformed/partial JSON).
278
- # Never replace the CLI's file with a stub: leave it untouched and skip.
279
- rm -f -- "$tmp" 2>/dev/null
280
- return 1
281
- fi
282
- # else: jq on PATH is unusable; take the pure-bash path below.
283
- fi
284
- if [ "$written" -eq 0 ]; then
285
- # No-jq fallback: pure-bash in-place edit of the two values (no extra
286
- # spawns, Bash 3.2 safe). Works for compact and pretty JSON in any key
287
- # order; every other byte of the CLI's file is left as is.
288
- local pre digits add
289
- case "$body" in
290
- *"\"toolWrites\""*)
291
- pre="${body%%\"toolWrites\"*}"
292
- rest="${body#*\"toolWrites\"}"
293
- while [ "${rest#"${rest%%[![:space:]:]*}"}" != "$rest" ]; do rest="${rest#?}"; done
294
- digits="${rest%%[!0-9]*}"
295
- rest="${rest#"$digits"}"
296
- body="$pre\"toolWrites\": $next$rest"
297
- ;;
298
- esac
299
- case "$body" in
300
- *"\"updatedAt\""*)
301
- pre="${body%%\"updatedAt\"*}"
302
- rest="${body#*\"updatedAt\"}"
303
- while [ "${rest#"${rest%%[![:space:]:]*}"}" != "$rest" ]; do rest="${rest#?}"; done
304
- case "$rest" in
305
- \"*) rest="${rest#\"}"; rest="${rest#*\"}" ;;
306
- esac
307
- body="$pre\"updatedAt\": \"$ts\"$rest"
308
- ;;
309
- esac
310
- add=""
311
- case "$body" in *"\"toolWrites\""*) ;; *) add="\"toolWrites\": $next" ;; esac
312
- case "$body" in *"\"updatedAt\""*) ;; *) add="${add:+$add, }\"updatedAt\": \"$ts\"" ;; esac
313
- if [ -n "$add" ]; then
314
- # Insert right after the opening brace so the closing brace stays put.
315
- case "$body" in
316
- *\{*)
317
- pre="${body%%\{*}"
318
- rest="${body#*\{}"
319
- local peek="$rest"
320
- while [ "${peek#"${peek%%[![:space:]]*}"}" != "$peek" ]; do peek="${peek#?}"; done
321
- case "$peek" in
322
- \}*) body="$pre{$add$peek" ;;
323
- *) body="$pre{$add, $rest" ;;
324
- esac
325
- ;;
326
- *) body="{$add}" ;;
327
- esac
328
- fi
329
- if printf '%s\n' "$body" >"$tmp" 2>/dev/null && [ -s "$tmp" ]; then
330
- written=1
331
- fi
332
- fi
333
- if [ "$written" -eq 0 ]; then
334
- rm -f -- "$tmp" 2>/dev/null
335
- return 1
336
- fi
337
- REPLY="$next"
338
- return 0
339
- }
340
-
341
- # _work_mesh_live_bump_locked <sid> <path> (caller holds the lock)
342
- _work_mesh_live_bump_locked() {
343
- local sid="$1" path="$2" tmp seen again
344
- tmp="$path.tmp.$$"
345
- _work_mesh_live_bump_render "$sid" "$path" "$tmp" || return 1
346
- seen="$_WM_SEEN"
347
- if [ -n "${WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ:-}" ]; then
348
- sleep "$WORK_MESH_BUMP_TEST_SLEEP_AFTER_READ" 2>/dev/null || true
349
- fi
350
- # Second line of defense: re-read right before mv; if a concurrent writer
351
- # (hq-cli reconcile/ack) changed the file since our read, rebuild from it.
352
- again=""
353
- [ -f "$path" ] && again="$(<"$path")"
354
- if [ "$again" != "$seen" ]; then
355
- rm -f -- "$tmp" 2>/dev/null
356
- _work_mesh_live_bump_render "$sid" "$path" "$tmp" || return 1
357
- fi
358
- mv -f -- "$tmp" "$path" 2>/dev/null || { rm -f -- "$tmp"; return 1; }
359
- chmod 600 -- "$path" 2>/dev/null || true
360
- return 0
361
- }
362
-
363
- # work_mesh_live_is_file_write_tool <tool_name> <bash_command?>
364
- work_mesh_live_is_file_write_tool() {
365
- local tool="$1" cmd="${2:-}"
366
- case "$tool" in
367
- Edit|Write|MultiEdit|NotebookEdit|apply_patch|StrReplace|search_replace|write) return 0 ;;
368
- Bash|Shell|run_terminal_command)
369
- # Redirect / common file-mutating bash forms (no nested quotes in case arms).
370
- case "$cmd" in
371
- *\>*|*tee\ *|*mv\ *|*cp\ *|*truncate\ *) return 0 ;;
372
- *sed\ -i*|*dd\ if=*) return 0 ;;
373
- *) return 1 ;;
374
- esac
375
- ;;
376
- *) return 1 ;;
377
- esac
378
- }