@indigoai-us/hq-cli 5.89.0 → 5.89.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.89.2]
6
+
7
+ ### Fixed
8
+
9
+ - The `hq core checkpoint` maintenance sibling no longer loses queued payloads.
10
+ Both drain paths read `pending.jsonl` and then truncated it, so a checkpoint
11
+ queued between the read and the truncate was discarded unread and never
12
+ enriched. The queue is now claimed atomically by renaming it into the run
13
+ directory, leaving producers a fresh queue and keeping the claimed file as an
14
+ audit and crash-recovery trail; the sibling prompt claims by `mv` in a loop
15
+ rather than truncating in place. (#317)
16
+
17
+ ## [5.89.1]
18
+
19
+ ### Fixed
20
+
21
+ - `hq core worker lint` now partitions its duplicate check by ownership domain
22
+ (`core/workers`, each `core/packages/<pack>`, each `companies/<co>`) and
23
+ excludes dot-directories (`.archive/`) and `._*` AppleDouble files. A
24
+ whole-tree scan of a real install previously reported false positives for
25
+ packs that intentionally vendor a byte-identical copy of a core worker skill,
26
+ for archived pack snapshots, and for macOS metadata files. Within-domain
27
+ duplicates are still flagged. (policy
28
+ hq-lint-comparison-domains-must-match-ownership)
29
+
5
30
  ## [5.89.0]
6
31
 
7
32
  ### Added
@@ -12,22 +12,37 @@
12
12
  #
13
13
  # This linter makes that convention enforceable. It flags two failure shapes:
14
14
  #
15
- # DUPLICATE — two or more NON-symlink skill files, in any worker under the
16
- # scanned roots, whose byte content is identical. Identical bytes
17
- # across two real files is exactly an un-single-sourced copy: the
18
- # moment someone edits one, they drift. (Symlinks that resolve to
19
- # a shared canonical are single-sourced and are NOT flagged, even
20
- # though their resolved content matches the canonical.)
15
+ # DUPLICATE — two or more NON-symlink skill files, WITHIN A SINGLE OWNERSHIP
16
+ # DOMAIN, whose byte content is identical. Identical bytes across
17
+ # two real files in the same domain is exactly an un-single-sourced
18
+ # copy: the moment someone edits one, they drift. (Symlinks that
19
+ # resolve to a shared canonical are single-sourced and are NOT
20
+ # flagged, even though their resolved content matches.)
21
21
  # BROKEN — a skill entry that is a symlink whose target does not resolve.
22
22
  #
23
23
  # It deliberately does NOT flag two same-NAMED skills whose content differs
24
24
  # (e.g. an API-level vs a browser-level e2e skill): distinct content means they
25
25
  # are distinct skills that merely share a filename, not a drifted share.
26
26
  #
27
+ # OWNERSHIP DOMAINS — filesystem adjacency does NOT establish duplicate
28
+ # ownership, so comparison is partitioned by the tree that OWNS a skill (policy
29
+ # hq-lint-comparison-domains-must-match-ownership):
30
+ #
31
+ # - `core/workers` — the curated first-party worker library
32
+ # - `core/packages/<pack>` — one installed pack (self-contained; may legally
33
+ # vendor a byte-identical copy of a core worker)
34
+ # - `companies/<co>` — one tenant's workers
35
+ #
36
+ # A byte-identical file in TWO DIFFERENT domains (a pack vendoring a core skill,
37
+ # or two packs shipping the same skill) is intentional distribution, NOT drift,
38
+ # and is never flagged. Excluded from the scan entirely: dot-directories
39
+ # (`.archive/`, `.git/`, …) and `._*` AppleDouble OS-metadata files — byte-
40
+ # identical by design, and not skills.
41
+ #
27
42
  # Usage: lint-shared-worker-skills.sh [root ...]
28
43
  # Default roots: core/workers core/packages (the SHIPPED hq-core scope —
29
- # company `_shared-skills/` stores live under companies/<co>/ and are linted
30
- # per-tenant, not here).
44
+ # company workers live under companies/<co>/ and are linted per-tenant; pass
45
+ # them explicitly to include them).
31
46
  #
32
47
  # Exit 0 + "OK:" line when clean; exit 1 + a report naming every offending group
33
48
  # when not. Matches the loud-and-specific style of lint-skill-script-refs.sh.
@@ -54,11 +69,29 @@ content_hash() {
54
69
  fi
55
70
  }
56
71
 
72
+ # The ownership domain that OWNS a skill path — the boundary within which two
73
+ # byte-identical files are a real duplicate. Across domains, identical content
74
+ # is intentional (a pack vendoring a core skill, two packs shipping the same
75
+ # skill), so it must never be compared. See policy header above.
76
+ domain_of() {
77
+ local p="${1#./}" rest
78
+ case "$p" in
79
+ core/packages/*) rest="${p#core/packages/}"; printf 'core/packages/%s' "${rest%%/*}" ;;
80
+ companies/*) rest="${p#companies/}"; printf 'companies/%s' "${rest%%/*}" ;;
81
+ core/workers/*|core/workers) printf 'core/workers' ;;
82
+ # Arbitrary root (custom scans, tests): the top path segment is the domain.
83
+ *) printf '%s' "${p%%/*}" ;;
84
+ esac
85
+ }
86
+
57
87
  # Collect skill entries: regular files AND symlinks named *.md under any
58
88
  # .../skills/... path. -type l must be matched explicitly — a symlink is not a
59
89
  # -type f, so a skills symlink would otherwise be invisible to the scan.
90
+ # EXCLUDED: any dot-directory component (`*/.*/*` — covers `.archive/`, `.git/`)
91
+ # and `._*` AppleDouble files; both are byte-identical by design and not skills.
60
92
  mapfile -t entries < <(
61
- find "${roots[@]}" \( -type f -o -type l \) -path '*/skills/*.md' 2>/dev/null | sort
93
+ find "${roots[@]}" \( -type f -o -type l \) -path '*/skills/*.md' \
94
+ -not -path '*/.*/*' -not -name '._*' 2>/dev/null | sort
62
95
  )
63
96
 
64
97
  # Empty is clean — and guards `"${entries[@]}"` under `set -u` on bash 3.2
@@ -69,10 +102,13 @@ if [[ ${#entries[@]} -eq 0 ]]; then
69
102
  fi
70
103
 
71
104
  broken=()
72
- # Parallel arrays keyed by content hash: hash_keys[i] is a hash, and
73
- # hash_regfiles[i] is a newline-joined list of the NON-symlink files with that
74
- # hash. Bash 3.2 (macOS) has no associative arrays in a portable-guaranteed way,
75
- # so a linear scan over parallel arrays keeps this runnable everywhere HQ runs.
105
+ # Parallel arrays keyed by "<domain>|<content-hash>": hash_keys[i] is that
106
+ # composite key, hash_regfiles[i] a newline-joined list of the NON-symlink files
107
+ # carrying it. Keying on domain AS WELL AS hash is what confines a duplicate
108
+ # finding to a single ownership tree two identical files in different domains
109
+ # land in different buckets and are never flagged. Bash 3.2 (macOS) has no
110
+ # portable associative arrays, so a linear scan over parallel arrays keeps this
111
+ # runnable everywhere HQ runs.
76
112
  hash_keys=()
77
113
  hash_regfiles=()
78
114
 
@@ -94,12 +130,13 @@ for entry in "${entries[@]}"; do
94
130
  fi
95
131
  continue
96
132
  fi
97
- # Regular file: hash its bytes and bucket it. Two regular files sharing a
98
- # bucket are two copies of the same skill the drift hazard.
99
- h="$(content_hash "$entry")"
100
- idx="$(hash_index "$h")"
133
+ # Regular file: bucket it by (ownership domain, byte content). Two regular
134
+ # files sharing a bucket are two copies of the same skill in the same tree —
135
+ # the drift hazard.
136
+ key="$(domain_of "$entry")|$(content_hash "$entry")"
137
+ idx="$(hash_index "$key")"
101
138
  if [[ "$idx" == "-1" ]]; then
102
- hash_keys+=("$h")
139
+ hash_keys+=("$key")
103
140
  hash_regfiles+=("$entry")
104
141
  else
105
142
  hash_regfiles[$idx]="${hash_regfiles[$idx]}"$'\n'"$entry"
@@ -116,7 +153,8 @@ for i in "${!hash_keys[@]}"; do
116
153
  echo "lint-shared-worker-skills: FAIL — duplicated worker skills (single-source these via _shared-skills/ + relative symlinks):" >&2
117
154
  fi
118
155
  findings=$((findings + 1))
119
- echo " DUPLICATE (identical content, ${count} copies — pick one canonical and symlink the rest):" >&2
156
+ domain="${hash_keys[$i]%%|*}"
157
+ echo " DUPLICATE in domain '${domain}' (identical content, ${count} copies — pick one canonical and symlink the rest):" >&2
120
158
  printf ' %s\n' "$group" >&2
121
159
  fi
122
160
  done
@@ -11,7 +11,7 @@ import { Command } from "commander";
11
11
  * Kept in TypeScript rather than in a bundled asset: it is an instruction to
12
12
  * a locally-installed agent, not a scaffold script that should be packaged.
13
13
  */
14
- export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint sibling \u2014 a background maintenance agent for this\nHQ install. Your parent session's state is in <payloadPath>. Work\nquietly and do not ask questions; if something is ambiguous, record it in the\nreport instead of guessing.\n\n1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)\n both for session context and to extract additional reusable learnings/insights\n the parent did not pass explicitly. Never quote secrets\n or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists\n under this HQ root, read it and follow it wherever it goes beyond these instructions;\n the write bounds below always win over the skill text.\n2. Upgrade the thread file named in the payload IN PLACE: verify/repair its\n JSON; fill git.remote_url, git.initial_commit, git.commits_made, and\n git.knowledge_repos by scanning core/knowledge/public/*,\n core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge\n for symlinks or directories containing .git, recording dirty repositories\n as {\"<name>\": {\"commit\": \"<short>\", \"dirty\": true}}. Fill worker,\n next_steps, and insights; set type to \"checkpoint\"; then rename the file to\n drop -auto- from its filename. Use the renamed path in every reference you\n write afterwards.\n3. For every explicit or transcript-derived learning that is a reusable rule,\n distill a non-duplicate policy file under personal/policies/ or, only when\n the payload names a company and the rule is company-specific,\n companies/<company>/policies/, following\n core/knowledge/public/hq-core/policies-spec.md. Store up to two explicit or\n transcript-derived insights per core/knowledge/public/hq-core/insights-spec.md\n when present, otherwise workspace/insights/. Durable facts (not rules) may\n go under personal/knowledge/ or companies/<company>/knowledge/ only.\n4. Close an active session journal fail-soft with\n bash .claude/skills/_shared/journal.sh close \"<project_dir>\" \"<one-line synthesis>\".\n Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with\n id, created_at, summary, files, and next_steps for backward compatibility.\n5. Update workspace/threads/recent.md and regenerate\n workspace/threads/INDEX.md. For each company whose knowledge path appears\n in files_touched, regenerate companies/<company>/knowledge/INDEX.md under\n core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation\n is allowed for those companies, but knowledge/policy content writes remain\n restricted to the payload's named company.\n6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;\n skip silently on any failure. Hook or automation improvements go ONLY under\n personal/hooks/ as proposals.\n7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.\n8. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), and what you skipped and why. If\n workspace/checkpoints/sibling/pending.jsonl is non-empty when you finish,\n process those payloads with this same flow, then truncate the file.\n";
14
+ export declare const SIBLING_PROMPT_TEMPLATE = "You are the HQ checkpoint sibling \u2014 a background maintenance agent for this\nHQ install. Your parent session's state is in <payloadPath>. Work\nquietly and do not ask questions; if something is ambiguous, record it in the\nreport instead of guessing.\n\n1. Read the payload. If it lists a transcript path that exists, read its tail (~400 lines)\n both for session context and to extract additional reusable learnings/insights\n the parent did not pass explicitly. Never quote secrets\n or tokens from the transcript. If .claude/skills/checkpoint/SKILL.md exists\n under this HQ root, read it and follow it wherever it goes beyond these instructions;\n the write bounds below always win over the skill text.\n2. Upgrade the thread file named in the payload IN PLACE: verify/repair its\n JSON; fill git.remote_url, git.initial_commit, git.commits_made, and\n git.knowledge_repos by scanning core/knowledge/public/*,\n core/knowledge/private/*, personal/knowledge/*, and companies/*/knowledge\n for symlinks or directories containing .git, recording dirty repositories\n as {\"<name>\": {\"commit\": \"<short>\", \"dirty\": true}}. Fill worker,\n next_steps, and insights; set type to \"checkpoint\"; then rename the file to\n drop -auto- from its filename. Use the renamed path in every reference you\n write afterwards.\n3. For every explicit or transcript-derived learning that is a reusable rule,\n distill a non-duplicate policy file under personal/policies/ or, only when\n the payload names a company and the rule is company-specific,\n companies/<company>/policies/, following\n core/knowledge/public/hq-core/policies-spec.md. Store up to two explicit or\n transcript-derived insights per core/knowledge/public/hq-core/insights-spec.md\n when present, otherwise workspace/insights/. Durable facts (not rules) may\n go under personal/knowledge/ or companies/<company>/knowledge/ only.\n4. Close an active session journal fail-soft with\n bash .claude/skills/_shared/journal.sh close \"<project_dir>\" \"<one-line synthesis>\".\n Write a legacy checkpoint JSON under workspace/checkpoints/<id>.json with\n id, created_at, summary, files, and next_steps for backward compatibility.\n5. Update workspace/threads/recent.md and regenerate\n workspace/threads/INDEX.md. For each company whose knowledge path appears\n in files_touched, regenerate companies/<company>/knowledge/INDEX.md under\n core/knowledge/public/hq-core/index-md-spec.md. Mechanical index generation\n is allowed for those companies, but knowledge/policy content writes remain\n restricted to the payload's named company.\n6. Run .claude/skills/document-release/SKILL.md best-effort when it exists;\n skip silently on any failure. Hook or automation improvements go ONLY under\n personal/hooks/ as proposals.\n7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.\n8. Write <runDir>/report.md \u2014 full prose: what you read, what you changed\n (paths), and what you skipped and why. Then drain the queue: while\n workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim\n it atomically by renaming it aside \u2014\n mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl\n (N counting up from 2) \u2014 and process the claimed payloads with this same\n flow. Repeat until a claim finds nothing left, then update the report.\n NEVER read the queue and truncate it in place: a payload appended between\n your read and the truncate is lost, and its checkpoint is never enriched.\n";
15
15
  export declare function renderSiblingPrompt(runDir: string, payloadPath: string): string;
16
16
  /** Attach the native checkpoint command to the hidden `hq core` group. */
17
17
  export declare function registerCoreCheckpointCommand(core: Command): void;
@@ -78,9 +78,14 @@ report instead of guessing.
78
78
  personal/hooks/ as proposals.
79
79
  7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.
80
80
  8. Write <runDir>/report.md — full prose: what you read, what you changed
81
- (paths), and what you skipped and why. If
82
- workspace/checkpoints/sibling/pending.jsonl is non-empty when you finish,
83
- process those payloads with this same flow, then truncate the file.
81
+ (paths), and what you skipped and why. Then drain the queue: while
82
+ workspace/checkpoints/sibling/pending.jsonl exists and is non-empty, claim
83
+ it atomically by renaming it aside
84
+ mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl
85
+ (N counting up from 2) — and process the claimed payloads with this same
86
+ flow. Repeat until a claim finds nothing left, then update the report.
87
+ NEVER read the queue and truncate it in place: a payload appended between
88
+ your read and the truncate is lost, and its checkpoint is never enriched.
84
89
  `;
85
90
  export function renderSiblingPrompt(runDir, payloadPath) {
86
91
  return SIBLING_PROMPT_TEMPLATE
@@ -457,26 +462,43 @@ function appendPending(pendingPath, payload) {
457
462
  .filter((line) => line.trim());
458
463
  if (lines.length <= MAX_PENDING_PAYLOADS)
459
464
  return lines.length;
465
+ // Best-effort trim: this rewrite can race a concurrent append, but it only
466
+ // runs once the queue is already over cap, where newest-wins is the point.
460
467
  const kept = lines.slice(-MAX_PENDING_PAYLOADS);
461
468
  fs.writeFileSync(pendingPath, `${kept.join("\n")}\n`);
462
469
  return kept.length;
463
470
  }
464
- function drainPending(pendingPath) {
465
- if (!fs.existsSync(pendingPath))
466
- return [];
471
+ /**
472
+ * Take ownership of the queued payloads by renaming the queue aside.
473
+ *
474
+ * The queue must never be claimed by read-then-truncate: a payload appended
475
+ * between the read and the truncate is discarded unread, and the checkpoint it
476
+ * describes is never enriched. Renaming is atomic, so this run receives exactly
477
+ * the file that existed at the moment of the claim while concurrent callers go
478
+ * on appending to a fresh queue. The claimed file is left in the run directory
479
+ * as an audit trail and as a recovery point if this sibling dies.
480
+ */
481
+ function claimPending(pendingPath, claimPath) {
482
+ try {
483
+ fs.renameSync(pendingPath, claimPath);
484
+ }
485
+ catch (error) {
486
+ if (error?.code === "ENOENT")
487
+ return [];
488
+ throw error;
489
+ }
467
490
  const entries = [];
468
- for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
491
+ for (const line of fs.readFileSync(claimPath, "utf8").split("\n")) {
469
492
  if (!line.trim())
470
493
  continue;
471
494
  try {
472
495
  entries.push(JSON.parse(line));
473
496
  }
474
497
  catch {
475
- // A partial line must not prevent a future sibling from processing the
476
- // valid queued payloads around it.
498
+ // A partial line must not prevent this run from processing the valid
499
+ // queued payloads around it.
477
500
  }
478
501
  }
479
- fs.writeFileSync(pendingPath, "");
480
502
  return entries;
481
503
  }
482
504
  function startSibling(liveRoot, input, threadPath, backend) {
@@ -494,7 +516,7 @@ function startSibling(liveRoot, input, threadPath, backend) {
494
516
  }
495
517
  const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
496
518
  fs.mkdirSync(runDir, { recursive: true });
497
- payload.pending_payloads = drainPending(pendingPath);
519
+ payload.pending_payloads = claimPending(pendingPath, path.join(runDir, "pending-claimed.jsonl"));
498
520
  const payloadPath = path.join(runDir, "payload.json");
499
521
  fs.writeFileSync(payloadPath, `${JSON.stringify(payload, null, 2)}\n`);
500
522
  const prompt = renderSiblingPrompt(runDir, payloadPath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.89.0",
3
+ "version": "5.89.2",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {