@staix/agent-hub 0.12.25 → 0.12.26

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,12 @@
2
2
 
3
3
  Issue and pull request numbers in the entries for 0.7.7 and earlier refer to the previous repository, archived on 2026-09-30 when this repository's history was rewritten; the one exception is the open smoke-check issue, formerly #12, which moved here as #1. Numbers in newer entries refer to this repository.
4
4
 
5
+ ## 0.12.26
6
+
7
+ - Import claude-mem observations under a checkout-independent identity, so the same observation is one entry on every machine after sync however the checkouts differ; scan the source database project-aware with a persisted machine-local cursor (`--restart` re-scans), so unrelated projects cannot starve the requested one; reconcile legacy duplicate imports with `ahub memory dedupe` through append-only supersede operations (#380).
8
+ - Protect the targets of direct-child symlinks of a protected directory under every data-policy form (exact entry, `data/*`, `data/**`), with case-compatible on-disk root discovery and a streaming scan inside the shared 4096-entry bound: a dangling link covers its lexical target, an unresolvable link loop aliases nothing, a root whose discovery budget is exhausted contributes no aliases, and an unreadable protected root still fails closed (#385).
9
+ - State the document converter's project-scoped read guarantee and the bounded, cleared-when-full failure record precisely in code comments and docs (#386).
10
+
5
11
  ## 0.12.25
6
12
 
7
13
  - Preserve person-owned reservations through planner grooming, including same-value changes (#378).
package/README.md CHANGED
@@ -4,7 +4,7 @@ Native multi-agent hub for one developer's machine: Claude Code, Codex, Kimi Cod
4
4
  hub-owned local-LLM worker, with optional Qwen Code and Jcode support, collaborate as peers in independent project directories, with
5
5
  task-aware model routing (Switchyard) in front of a self-hosted gateway (OmniRoute).
6
6
 
7
- Status: 0.12.25, control protocol 16. Durable delivery records distinguish queued
7
+ Status: 0.12.26, control protocol 16. Durable delivery records distinguish queued
8
8
  work from uncertain execution. The [smoke checklist](docs/smoke.md) records
9
9
  verified paths and remaining prerequisites.
10
10
 
@@ -33,7 +33,7 @@ cd <your project> && ahub init && ahub up
33
33
  Or install the same version from GitHub:
34
34
 
35
35
  ```bash
36
- bun add -g github:STAIxBWLB/agent-hub#v0.12.25 && ahub setup
36
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.26 && ahub setup
37
37
  ```
38
38
 
39
39
  The installed commands remain `ahub` and `agent-hub`.
@@ -28,7 +28,8 @@
28
28
 
29
29
  - `*` matches any characters within one path segment, `?` matches one character, and a whole-segment `**` matches zero or more segments. Matching includes dot names and folds case consistently with the existing exact policy. A matched directory protects its subtree. Directory task scopes are private if they can contain a future matching descendant, even before a file exists.
30
30
  - Examples: `pii.paths = ["data/**/*.csv"]` and `data.readonly = ["data/raw-*/**"]`. Up to 16 wildcard characters per pattern. Absolute paths, parent traversal, bracket classes, braces, negation and mixed `**suffix` forms are refused. Backslashes remain literal in exact entries but are refused in glob entries; no escape syntax is interpreted.
31
- - Match both declared spelling and real target. Wildcard directory/file aliases are discovered from compatible prefixes, including symlink aliases to future targets. Discovery is refreshed per decision, with no stale cached expansion. It reads at most 4096 directory entries and traverses at most 32 levels. An unreadable/dangling/cyclic/out-of-project alias or an exhausted discovery bound conservatively protects rather than declaring a path public/writable. No discovered name or policy body is logged.
31
+ - An exact directory entry (`data`), a single-segment glob (`data/*`) and a subtree glob (`data/**`) all protect targets of symlinks directly inside `data`, including file targets. Only this one level is aliased: `data/sub/link` does not protect its outside target under any of these forms, while paths through the protected tree remain protected lexically. Exact entries and trailing `**` matches scan immediate children once, without descending; `*` retains its existing matched-link resolution. Retargeted links are evaluated afresh on the next decision. A self-link such as `data/loop -> .` adds only the directory itself and does not trigger a recursive walk.
32
+ - Match both declared spelling and real target. Wildcard directory/file aliases are discovered from compatible prefixes, including symlink aliases to future targets. Discovery is refreshed per decision, with no stale cached expansion. Wildcard discovery and immediate alias scans share one per-call budget of 4096 directory entries; wildcard traversal is limited to depth 32. Existing wildcard discovery runs before the new direct-child scans, so these scans cannot consume its budget first. Immediate scans stream directory entry types and resolve only symlink children, with no descent. For exact entries and trailing-`**` roots, a dangling direct child protects its lexical target (including a future target), an ELOOP child is skipped, and a root whose scan cannot complete within the remaining shared budget contributes no child aliases, even if an earlier child matched. Unreadable matched roots still fail closed, including when earlier roots used up the budget. Lexical protection of the tree stays in force; tree depth and cycles below that one level are never traversed. `data/*` retains its existing matched-link failure behavior, and descending glob discovery (`data/**/*.csv`, `**/ß`) retains its existing fail-closed handling of unreadable/dangling/cyclic/out-of-project aliases and exhausted bounds. No discovered name or policy body is logged.
32
33
  - Managed local/Pi write/edit and private Tasks/facts use this same matcher. Existing hardlink refusal, post-approval recheck and filesystem-read-only command profile remain unchanged.
33
34
  - Claude's managed `ask-when-needed` hook receives `ask` for a write matching data.readonly. Reads and ordinary project writes retain their existing scoped decisions. The daemon evaluates policy locally only for the current managed PreToolUse ask-when-needed write grant and sends no pattern list to the hook. PostToolUse, unavailable/ask/never-ask modes and non-write tools perform no grant-policy discovery. Explicit native approval and `never-ask` remain the person's native authority; this is scoped auto-grant filtering, not a new Claude sandbox or containment claim.
34
35
  - The previous malformed-config fixture that refused every wildcard now refuses an unsupported bracket class; its rejection assertion is retained. New tests establish the deliberately added glob grammar, future paths, wildcard aliases/retargeting, private scopes, discovery failures and actual daemon hook replies. Native account/model consumption is unverified.
package/docs/memory.md CHANGED
@@ -196,21 +196,58 @@ ahub memory demote 1a2b3c4d # a long-term entry becomes short-term
196
196
  ahub memory forget 1a2b3c4d # gone from the index here, and on other machines once synced
197
197
  ahub memory supersede <old> <new> # the old one is dropped from briefs and recall
198
198
  ahub memory rebuild
199
+ ahub memory dedupe # inspect legacy claude-mem duplicate groups
200
+ ahub memory dedupe --apply # append supersede relations, never rewrite log bytes
199
201
  ```
200
202
 
201
203
  A forgotten long-term entry stays in the log file and in the sync remote's git history; removing it from there is a
202
- person's rewrite of that history.
204
+ person's rewrite of that history. Forgetting a claude-mem import forgets every known non-forgotten member of its tuple group (source, project, source timestamp and text), including hidden duplicates, so they cannot reappear when their replacement is forgotten.
203
205
 
204
206
  ## Importing from claude-mem
205
207
 
206
- `ahub memory import claude-mem [--db <path>] [--limit N]` (a person's) copies the hub's own notes from claude-mem
208
+ `ahub memory import claude-mem [--db <path>] [--limit N] [--restart]` (a person's) copies the hub's own notes from claude-mem
207
209
  (observations whose metadata names a `peer`: `hub_remember` and the hub's task notes) into long-term hub memory as
208
- `source: claude-mem`, for this project only (claude-mem files them under the repository's own name; a superproject's
209
- notes stay out, and two unrelated repositories with one name share it). Model answers from `ahub ask --remember`, text
210
- matching a PII pattern, text about a path the project's `pii.paths` makes private and secret-looking text are skipped. An
211
- imported note is placed in the graph by the paths and symbols in its text (claude-mem keeps no task proposal time).
212
- claude-mem's database is opened read-only and never changed; an observation always maps to the same entry, on any
213
- machine, so importing again adds nothing.
210
+ `source: claude-mem`, for this project only. The SQL filters by the repository's basename before applying the limit;
211
+ a superproject's notes stay out, while two unrelated repositories with one basename still share claude-mem's namespace.
212
+ Model answers from `ahub ask --remember`, PII-pattern text, text about a path made private by the project's `pii.paths`
213
+ and secret-looking text are skipped. The source database is opened read-only and never changed.
214
+
215
+ - Import graph nodes are derived from text only: no checkout existence check and no machine-local `local.deny`.
216
+ Built-in denylisted names, relative-path normalization, absolute/parent exclusion, sorting and the 64-node cap still apply.
217
+ The source's timestamp and the existing content hash stay unchanged. Matching observations on different checkouts now
218
+ produce the same id; old ids, references and content validation remain valid. The local tuple fallback keeps re-import
219
+ idempotent even for legacy ids (its known ceiling is that the tuple excludes title).
220
+ - A machine-local `memory/import-state.json` records the last fully handled row, including all skips. The cursor key
221
+ combines the source DB's real path, claude-mem project name and hub project key. The file uses mode 0600 and atomic
222
+ replacement; it never syncs. Unreadable or malformed state replays from zero, write failure logs a warning and preserves
223
+ the import result, and a source whose maximum candidate id for this project is below the cursor resets it (unrelated projects cannot mask a recreated source). Completed progress is saved even if a later
224
+ row throws. `--restart` starts from zero; use it after changing a PII policy to revisit rows skipped under the old policy.
225
+ - The default limit is 10000. JSON reports `read` (project-matched, peer-tagged SQL rows this run), `added`,
226
+ `skipped.model`, `skipped.pii`, `skipped.secret`, `cursor` (last handled id), and `more` (`read === limit`). Malformed
227
+ metadata, non-string peers and empty text count only in `read`; already imported rows add nothing. `more` asks for
228
+ another run, which can return zero rows when the last batch exactly filled the limit. There is no project-skip counter.
229
+ - Repository identity still depends on the local real path without an origin remote, and non-GitHub remotes spelled
230
+ differently can yield different project keys. These affect all hub memory and remain outside this import fix.
231
+
232
+ ### Legacy duplicate imports
233
+
234
+ `ahub memory dedupe [--apply] [--json]` is console-only, including the listing, because it shows unscreened note text.
235
+ It groups this project's non-forgotten claude-mem entries by `(source, project, at, text)`. Listing reports only actionable
236
+ groups, their members, heads and chosen target; `--apply` appends ordinary supersede operations in this machine's log.
237
+ Historical log bytes are never rewritten. Forget operates on the whole known tuple group.
238
+
239
+ - Heads have no current replacement. Existing replacement relations, including a person's distinct outside notes, stay
240
+ intact. If every head can reach a valid synced outside terminal (shared when any head is shared), the newest qualifying
241
+ terminal by `(at, id)` replaces all heads, even a single head. Otherwise multiple heads converge onto the smallest id
242
+ among the heads; zero heads or one head without a qualifying outside terminal require no writes. A refused supersede
243
+ is reported as an error. Re-running after convergence writes nothing.
244
+ - When `A -> B` already exists inside a group, dedupe connects another head `C -> B`, without trying the cyclic `B -> A`.
245
+ When `B -> X` and `C -> Y` name outside user replacements, `X` and `Y` both remain visible; a remaining head moves to
246
+ the newer qualifying terminal. A later outside replacement can make a previously settled group actionable again.
247
+ - Old or mixed extractor versions can still introduce duplicate ids. Run dedupe after syncing them. Two machines acting
248
+ on divergent prior relations can choose different targets; sync folds relations deterministically using the existing
249
+ latest-relation and cycle guards. If several group members remain visible, one more dedupe run converges them. New
250
+ same-version imports with stable project keys converge automatically in either sync order.
214
251
 
215
252
  ## Measuring it
216
253
 
@@ -1,6 +1,6 @@
1
1
  # Operations guide
2
2
 
3
- This guide describes ahub 0.12.25 and control protocol 16. Live verification
3
+ This guide describes ahub 0.12.26 and control protocol 16. Live verification
4
4
  results and remaining prerequisites are recorded separately in [the smoke ledger](smoke.md).
5
5
 
6
6
  ## Command output
@@ -1619,21 +1619,21 @@ section is what they run.
1619
1619
 
1620
1620
  Upgrade running projects with the target release's own coordinator. It accepts
1621
1621
  a running source on control protocol 9 (0.6.x), 10 (0.7.0 through 0.12.0),
1622
- 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15), 14 (0.12.16), 15 (0.12.17 through 0.12.19) or 16 (0.12.20 through 0.12.25), and only
1622
+ 11 (0.12.1 and 0.12.2), 12 (0.12.3), 13 (0.12.4 through 0.12.15), 14 (0.12.16), 15 (0.12.17 through 0.12.19) or 16 (0.12.20 through 0.12.26), and only
1623
1623
  a target on its own protocol, so the target's coordinator fits every supported
1624
1624
  source and carries every recovery fix released up to it. Protocol 8 and older
1625
1625
  (0.5.x and earlier) are refused as `manual-bootstrap-required`. Run from the
1626
1626
  project directory, without replacing the global CLI first:
1627
1627
 
1628
1628
  ```bash
1629
- bunx --package @staix/agent-hub@0.12.25 ahub upgrade --to 0.12.25 --dry-run
1630
- bunx --package @staix/agent-hub@0.12.25 ahub upgrade --to 0.12.25 --yes
1629
+ bunx --package @staix/agent-hub@0.12.26 ahub upgrade --to 0.12.26 --dry-run
1630
+ bunx --package @staix/agent-hub@0.12.26 ahub upgrade --to 0.12.26 --yes
1631
1631
  ```
1632
1632
 
1633
1633
  | Running now | Coordinator to use |
1634
1634
  | --- | --- |
1635
1635
  | 0.6.x (protocol 9) | the target's, through `bunx` as above |
1636
- | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 through 0.12.19 (protocol 15), 0.12.20 through 0.12.25 (protocol 16) | the target's, through `bunx` as above |
1636
+ | 0.7.0 through 0.12.0 (protocol 10), 0.12.1 and 0.12.2 (protocol 11), 0.12.3 (protocol 12), 0.12.4 through 0.12.15 (protocol 13), 0.12.16 (protocol 14), 0.12.17 through 0.12.19 (protocol 15), 0.12.20 through 0.12.26 (protocol 16) | the target's, through `bunx` as above |
1637
1637
  | any supported source, with the installed CLI already at the target | `ahub upgrade` below, which is the same coordinator |
1638
1638
  | 0.5.x or earlier (protocol 8 and older) | not supported: bootstrap by hand with the matching CLI |
1639
1639
 
@@ -1665,14 +1665,14 @@ projects first:
1665
1665
 
1666
1666
  ```bash
1667
1667
  ahub restart --dry-run
1668
- ahub upgrade --to 0.12.25 --dry-run
1668
+ ahub upgrade --to 0.12.26 --dry-run
1669
1669
  ```
1670
1670
 
1671
1671
  Apply only after reviewing the plan:
1672
1672
 
1673
1673
  ```bash
1674
1674
  ahub restart --yes
1675
- ahub upgrade --to 0.12.25 --yes
1675
+ ahub upgrade --to 0.12.26 --yes
1676
1676
  ahub recovery status [<operation-id>]
1677
1677
  ahub recovery resume [<operation-id>]
1678
1678
  ahub recovery abort [<operation-id>]
@@ -2622,7 +2622,7 @@ Native policy is unmanaged and has NO hub sandbox; no permission_modes.jcode or
2622
2622
 
2623
2623
  # Rich-document text projections (#259)
2624
2624
 
2625
- - Configure text_projection only in an untracked machine-local file, with a separate {input} argv argument. Facts uses bounded read-only/no-network conversion from a scratch directory, where the converter reads only its input snapshot and its own program and script files (no other project file, even under `local.read_allow`), and a content/config cache; failure keeps the prior file view and logs a bounded notice, and a conversion that failed with its full deadline is not retried for the same bytes and command for the life of the hub process (a bounded in-memory record) (#382).
2625
+ - Configure text_projection only in an untracked machine-local file, with a separate {input} argv argument. Facts uses bounded read-only/no-network conversion from a scratch directory, where the converter reads only its input snapshot and its own program and script files (no other project file, even under `local.read_allow`), and a content/config cache; failure keeps the prior file view and logs a bounded notice, and a conversion that failed with its full deadline is not retried for the same bytes and command while its entry lives in the failure record, which is in memory, bounded at 1024 entries, and cleared when full or at restart (#382).
2626
2626
  - Raw change identity and projected presentation stay separate; private paths never enter conversion. Schema, limits and current surface scope: [text projections](text-projection.md).
2627
2627
 
2628
2628
  # Task catalog guidance (#295)
@@ -17,7 +17,7 @@ ahub setup # installs the Claude Code channel plu
17
17
  Or use the matching GitHub release:
18
18
 
19
19
  ```bash
20
- bun add -g github:STAIxBWLB/agent-hub#v0.12.25
20
+ bun add -g github:STAIxBWLB/agent-hub#v0.12.26
21
21
  ahub setup
22
22
  ```
23
23
 
@@ -2424,7 +2424,7 @@ the queue.
2424
2424
 
2425
2425
  ## Amendment: bounded data policy globs (#259)
2426
2426
 
2427
- pii.paths and data.readonly accept exact paths/subtrees and *, ? and whole-segment ** patterns. Validation keeps 64 entries, 200 characters each and at most 16 wildcard characters. Unsupported bracket/brace/negation/escape syntax, absolute paths and parent traversal refuse. Matching folds case, protects matched subtrees and treats parent task scopes as private when a future matching descendant is possible. Resolve both declared and real paths; wildcard alias discovery is uncached and bounded to 4096 directory entries and 32 levels. Resolution/discovery failure conservatively matches an active policy. Managed local/Pi tools retain hardlink and read-only command protections. Only an authenticated pre-tool ask-when-needed write grant evaluates the daemon-side read-only matcher; other phases/modes perform no grant-policy discovery. Protected grants are withheld without exposing policy paths; explicit approval/never-ask remain native authority, with no new sandbox claim. No account/research pilot is implied.
2427
+ pii.paths and data.readonly accept exact paths/subtrees and *, ? and whole-segment ** patterns. Validation keeps 64 entries, 200 characters each and at most 16 wildcard characters. Unsupported bracket/brace/negation/escape syntax, absolute paths and parent traversal refuse. Matching folds case, protects matched subtrees and treats parent task scopes as private when a future matching descendant is possible. Resolve both declared and real paths; wildcard alias discovery is uncached and bounded to 4096 directory entries per decision and 32 levels. Wildcard resolution/discovery failure conservatively matches an active policy. Exact entries and trailing-** roots also protect the targets of symlinks directly inside the matched directory, one level only (#385): a dangling child protects its lexical target, an ELOOP child is skipped, a root whose scan does not complete within the shared budget contributes no child aliases, and an unreadable root matches; * keeps its matched-link resolution. Managed local/Pi tools retain hardlink and read-only command protections. Only an authenticated pre-tool ask-when-needed write grant evaluates the daemon-side read-only matcher; other phases/modes perform no grant-policy discovery. Protected grants are withheld without exposing policy paths; explicit approval/never-ask remain native authority, with no new sandbox claim. No account/research pilot is implied.
2428
2428
 
2429
2429
  ## Amendment: custom lifecycle data (#257)
2430
2430
 
@@ -3,7 +3,7 @@
3
3
  - Optional machine-local text_projection maps lowercase extensions to argv arrays. Each uses one separate {input} argument. No shell string, automatic converter install or default command.
4
4
  - Example: { "text_projection": { ".docx": ["/usr/bin/textutil", "-convert", "txt", "-stdout", "{input}"] } }. Configure the converter explicitly for the machine. Shared/tracked config cannot choose these programs.
5
5
  - Facts excludes private paths before conversion. The converter reads an immutable snapshot in an owned temporary directory, from its own scratch directory, with literal read grants for that file and for the program and script files its argv names (relative ones are passed as absolute paths, and the directories above an in-project script can be listed) (nothing else in the project is readable, even under a `local.read_allow` entry that encloses it, so a converter that follows an include or loads a sibling helper module fails), uses the existing macOS no-network/read-only sandbox and has a 5-second deadline, 16 MiB input cap and 256 KiB stdout/stderr cap. A trusted helper owns and stops its process group; converter stderr is never published.
6
- - Output caches in state by raw content hash, extension and argv/program/script metadata fingerprint. Invalid/oversized cache or failing/unavailable conversion falls back to the existing raw/binary facts behavior, with one bounded notice per extension. A conversion that fails with its full deadline is not tried again for the same bytes and command for the life of the hub process, within a bounded in-memory record (#382).
6
+ - Output caches in state by raw content hash, extension and argv/program/script metadata fingerprint. Invalid/oversized cache or failing/unavailable conversion falls back to the existing raw/binary facts behavior, with one bounded notice per extension. A conversion that fails with its full deadline is not tried again for the same bytes and command while its entry lives in the failure record, which is in memory, bounded at 1024 entries, and cleared when full or at restart (#382).
7
7
  - Raw hashes and git blob identities remain the change identities. Projected text only supplies presentation diffs. A projected read or patch cannot establish raw-file write attribution. Existing text privacy checks and header sanitization still apply.
8
8
  - Converter programs may require temporary writes/caches; those fail under this read-only policy. Use a converter that emits stdout. The owner and other native same-user processes remain outside this boundary.
9
9
  - Linux retains no-sandbox/no-converter behavior. No native model/account study runs. Shadow repositories and profile override/glob follow-ups remain open in #259.
@@ -0,0 +1,31 @@
1
+ # 0.12.26 release verification
2
+
3
+ This record covers release preparation and source verification. It does not certify tagging, package publication, global installation or a live upgrade; those receipts are recorded separately.
4
+
5
+ ## Candidate
6
+
7
+ 0.12.26 carries the hub-collaboration round for issues #380, #385 and #386: PRs #389, #387 and #388. Control protocol remains 16.
8
+
9
+ - #389 (#380): claude-mem imports derive their graph nodes from the observation text without consulting the checkout, so one observation is one content-hashed entry on every machine and sync converges regardless of import order; the import scan filters the project in SQL and resumes from a persisted machine-local cursor (`--restart` re-scans), so unrelated projects cannot exhaust the window; `ahub memory dedupe [--apply]` reconciles pre-fix duplicate groups with append-only supersede operations onto the lexicographically smallest id.
10
+ - #387 (#385): an exact or trailing-`**` data-policy entry that matches a directory resolves that directory's direct-child symlinks and protects their targets, matching what `data/*` already did for a matched link; discovery of the on-disk root is case-compatible, the scan streams inside the shared 4096-entry budget without descending, a dangling link covers its lexical target, an unresolvable link loop aliases nothing, a root whose budget is exhausted contributes no aliases, and an unreadable protected root fails closed. The failure semantics for the new scan are the user's direct ruling of 2026-10-14. The spec paragraph in docs/specs/2026-09-19-agent-hub-design.md was amended in the same PR.
11
+ - #388 (#386): the text-projection converter comment and both docs state the project-scoped read guarantee and the 1024-entry, cleared-when-full in-memory failure record.
12
+
13
+ Every fix carries regression tests that failed before it. Review was adversarial and multi-round on the board (tasks #45-#52): the review of #387 found the case-sensitive exact-root miss (P1) and the unbounded readdir (P2), CI found the Linux EACCES-at-read edge (#51) and two seed guards that needed retargeting after the refactor (#52), each fixed and re-gated before merge.
14
+
15
+ ## Document review
16
+
17
+ Every stale document was rechecked against the ten source commits of the three PRs. README, Quickstart and Operations carry the 0.12.26 version and install/upgrade strings; Operations' converter failure-record wording landed with #388. The data-policy contract document (docs/data-policy.md) and docs/memory.md were updated inside their fix PRs. The adapters and daemon area notes were confirmed accurate against the `cliCommandLabel` addition, the tests note against the seed-selector repairs, and Security against the policy-matching change (it makes no per-form matching claim; the contract lives in data-policy.md). Budget, Bus, Benchmarks, Models, Tasks and the local-worker notes were not stale.
18
+
19
+ ## Recorded checks
20
+
21
+ - PR #388 head 483ca546: CI Linux, macOS and seeded guards passed; board review approved.
22
+ - PR #389 head d5738472: worktree `scripts/check.sh` 2702 pass, 0 fail, 189163 assertions, `check: OK`, no daemon leaks; CI green; board review approved.
23
+ - PR #387 head 5fbe5d3a: worktree full gate 2714 pass, `check: OK`; CI Linux, macOS and seeded guards passed; board review approved.
24
+ - Release candidate fbf96c08: local `scripts/check.sh` in an isolated worktree — 2728 pass, 0 fail, 189269 assertions, `check: OK`, no daemon leaks. Later commits on this branch change documentation only.
25
+ - Release head: CI, document freshness at the merge commit, and the release workflow are required before tagging is reported complete.
26
+
27
+ Note: `scripts/check.sh` in the main checkout fails 35/68 daemon tests on unmodified main because the live hub daemon serving the project interferes; the isolated worktree gate is the valid signal (recorded in shared memory #194264).
28
+
29
+ ## Not verified
30
+
31
+ Native, account and research legs remain open in #273 and #1. The installed runtime and live hubs are not upgraded by this release.
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "documents": {
4
4
  "README.md": {
5
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
5
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
6
6
  "paths": [
7
7
  "package.json",
8
8
  "src/",
@@ -11,14 +11,14 @@
11
11
  ]
12
12
  },
13
13
  "docs/security.md": {
14
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
14
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
15
15
  "paths": [
16
16
  "src/",
17
17
  "templates/"
18
18
  ]
19
19
  },
20
20
  "docs/operations.md": {
21
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
21
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
22
22
  "paths": [
23
23
  "package.json",
24
24
  "src/",
@@ -35,7 +35,7 @@
35
35
  ]
36
36
  },
37
37
  "docs/quickstart.md": {
38
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
38
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
39
39
  "paths": [
40
40
  "package.json",
41
41
  "src/",
@@ -43,7 +43,7 @@
43
43
  ]
44
44
  },
45
45
  "docs/agent-notes/adapters.md": {
46
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
46
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
47
47
  "paths": [
48
48
  "src/adapters/",
49
49
  "src/pi/",
@@ -98,7 +98,7 @@
98
98
  ]
99
99
  },
100
100
  "docs/agent-notes/daemon.md": {
101
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
101
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
102
102
  "paths": [
103
103
  "src/hub/daemon.ts",
104
104
  "src/hub/control-client.ts",
@@ -172,7 +172,7 @@
172
172
  ]
173
173
  },
174
174
  "docs/agent-notes/tests.md": {
175
- "verifiedAgainst": "d093a5a81b341a3ae1c777154a41497aeaf1899d",
175
+ "verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
176
176
  "paths": [
177
177
  "test/",
178
178
  "scripts/check.sh",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-hub",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
4
4
  "description": "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-hub",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
4
4
  "description": "Channel between Claude Code and the agent-hub daemon: peer messages from Codex, Kimi and the local worker arrive as channel events; hub_send replies.",
5
5
  "author": {
6
6
  "name": "Young Joon Lee",
@@ -16157,7 +16157,7 @@ import { join as join4 } from "path";
16157
16157
  // package.json
16158
16158
  var package_default = {
16159
16159
  name: "@staix/agent-hub",
16160
- version: "0.12.25",
16160
+ version: "0.12.26",
16161
16161
  description: "Native multi-agent hub: Claude Code, Codex, Kimi Code, Pi and local inference as peers in one project",
16162
16162
  license: "MIT",
16163
16163
  type: "module",
package/src/cli/help.ts CHANGED
@@ -110,7 +110,7 @@ const SECTIONS: [string, [usage: string, description: string][]][] = [
110
110
  ["ahub memory search <words> | show <id> | graph <id|node> [--hops 1|2] [--json]", "find entries, read one with its provenance, or list its graph neighbourhood"],
111
111
  ["ahub memory promote|demote|forget <id> | supersede <old> <new> | rebuild", "a person's changes to memory: move an entry between tiers, forget it, mark it superseded, or rebuild the index from the logs"],
112
112
  ["ahub memory sync [--remote <private git url>] | machine [--label <name>]", "sync hub memory with your other machines through a private git remote (a person's), or show or name this machine"],
113
- ["ahub memory import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
113
+ ["ahub memory import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <id>", "copy the hub's notes from claude-mem into hub memory (a person's); review the model's suggested edges"],
114
114
  ["ahub goal [list] [--all] | show <id> [--json]", "long-term tasks: state, which machine holds each and until when, linked board tasks"],
115
115
  ["ahub goal new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task> | state <id> <state> | done <id> | take <id> [--force]", "a person's changes to long-term tasks; take --force voids other machines' claims"],
116
116
  ]],
@@ -61,7 +61,7 @@ export function cliCommandLabel(command: string, args: readonly string[]): strin
61
61
  if (!["upgrade", "restart", "recovery", "recovery-run", "help", "--help", "--version", "version", "projects", "ui", "manager", "setup", "init", "daemon", "up", "console", "claude", "codex", "kimi", "qwen", "jcode", "pi", "local", "stop", "models", "say", "queue", "tail", "watch", "budget", "board", "task", "review", "remember", "ask", "route", "pause", "resume", "permit", "permission", "settings", "roster", "status", "pet", "logs", "export", "report", "research", "memory", "goal", "bench", "sdlc", "role", "facts", "check-path", "turns", "undo", "kill", "reset", "doctor"].includes(command)) return "unknown";
62
62
  if (["task", "queue", "budget", "route", "recovery", "models", "projects", "research", "memory", "goal", "bench"].includes(command)) {
63
63
  const sub = command === "bench" || command === "memory" || command === "goal" ? args.find((arg) => arg !== "--json") : args[0];
64
- if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
64
+ if (sub && ["propose", "show", "assign", "escalate", "label", "list", "resolve", "resume", "set", "execution", "explain", "status", "abort", "dispose", "setup", "start", "stop", "remove", "export", "backfill", "addons", "run", "report", "compare", "search", "graph", "promote", "demote", "forget", "supersede", "dedupe", "rebuild", "sync", "machine", "import", "suggestions", "confirm", "reject", "new", "link", "state", "done", "take"].includes(sub)) return `${command} ${sub}`;
65
65
  }
66
66
  return (/^[a-z][a-z0-9-]*$/.test(command) && !/\s/.test(command)) || ["--help", "--version"].includes(command) ? command : "unknown";
67
67
  }
package/src/cli/memory.ts CHANGED
@@ -12,7 +12,7 @@ const loadConfigSafe = (cwd: string) => { try { return loadConfig(cwd); } catch
12
12
  * #258: `ahub memory`, straight on the machine's store (no hub needed). Reading is anyone's; promote, demote, forget,
13
13
  * supersede and rebuild are a person's (the identity gate refuses agent shells).
14
14
  */
15
- export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] | suggestions | confirm|reject <suggestion id> [--json]";
15
+ export const MEMORY_USAGE = "usage: ahub memory [list] [--tier short|long] [--kind <kind>] [--peer <p>] [--machine <id>] [--since <iso>] [--limit N] | search <words> | show <id> | graph <id|node> [--hops 1|2] | promote|demote|forget <id> | supersede <old> <new> | rebuild | sync [--remote <url>] | machine [--label <name>] | import claude-mem [--db <path>] [--limit N] [--restart] | dedupe [--apply] | suggestions | confirm|reject <suggestion id> [--json]";
16
16
  export const GOAL_USAGE = "usage: ahub goal [list] [--all] | show <id> | new <title> [--milestone <text>]... [--depends <id>]... [--shared] | link <id> <task id> | state <id> open|active|blocked|done|dropped | done <id> | take <id> [--force] [--json]";
17
17
 
18
18
  const fail = (message: string): never => { throw new Error(message); };
@@ -85,17 +85,27 @@ export async function memoryCommand(args: string[], cwd: string, opts: { store?:
85
85
  if (!r.ok) fail(`hub memory sync: ${r.error}`);
86
86
  return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
87
87
  }
88
+ case "dedupe": {
89
+ const apply = rest.includes("--apply");
90
+ if (rest.some(arg => arg !== "--apply")) fail("usage: ahub memory dedupe [--apply] [--json]");
91
+ const result = store.dedupe(project, apply);
92
+ if (json) return JSON.stringify(result, null, 2);
93
+ return [result.groups.length ? `${result.groups.length} duplicate import group(s)${apply ? `; appended ${result.applied} supersede operation(s)` : "; use --apply to reconcile"}` : "no duplicate import groups to reconcile",
94
+ ...result.groups.map(g => `${g.members.map(v => v.id.slice(0, 8)).join(", ")} -> ${g.target.slice(0, 8)}: ${g.members[0]!.text}`)].join("\n");
95
+ }
88
96
  case "import": {
89
97
  const db = flag("--db"), limit = flag("--limit");
90
- if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N]");
98
+ const restart = rest.includes("--restart");
99
+ if (restart) rest.splice(rest.indexOf("--restart"), 1);
100
+ if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
91
101
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
92
102
  const routing = currentRouting(cwd);
93
103
  const policy = privatePaths(); // a note about a private path never reaches the synced log
94
- const key = memoryProjectKey(cwd), deny = loadConfigSafe(cwd)?.local.deny ?? [];
95
- const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, deny });
96
- const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
104
+ const key = memoryProjectKey(cwd);
105
+ const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
106
+ const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii: (text) => detectSignals({ title: text, detail: "", refs: {} }, routing, cwd).includes("pii") || mentionsPrivatePath(text, [], policy, cwd), ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
97
107
  if (json) return JSON.stringify(r, null, 2);
98
- return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.project} other projects, ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; the rest were already here)`;
108
+ return `imported ${r.added} of ${r.read} hub notes from claude-mem as long-term entries (skipped: ${r.skipped.model} model answers, ${r.skipped.pii} PII, ${r.skipped.secret} secret-looking; other candidates were empty, malformed or already imported)${r.more ? "\nmore remain: run it again" : ""}`;
99
109
  }
100
110
  case "suggestions": {
101
111
  const pending = store.suggestions(project).filter((x) => x.status === "suggested");
@@ -1,4 +1,4 @@
1
- import { lstatSync, opendirSync, statSync } from "node:fs";
1
+ import { accessSync, constants, lstatSync, opendirSync, readlinkSync, statSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { realPath } from "./project.ts";
4
4
  import { foldSegment } from "./permission-mode.ts";
@@ -25,6 +25,47 @@ export function policyTargets(cwd: string, paths: string[]): string[] {
25
25
  const root = realPath(cwd);
26
26
  return [...new Set(paths.flatMap(path => { const lexical = resolve(root, path); return [lexical, policyRealPath(lexical)]; }))];
27
27
  }
28
+ type DiscoveryBudget = { entries: number };
29
+ /** A dangling direct child names a future lexical target; an ELOOP child opens no file. */
30
+ function directAliasTarget(path: string): string | undefined {
31
+ let target = path;
32
+ // Include the final target resolution after the last bounded redirect.
33
+ for (let hops = 0; hops <= 40; hops++) {
34
+ try { return policyRealPath(target); }
35
+ catch (error) {
36
+ const code = (error as NodeJS.ErrnoException).code;
37
+ if (code === "ELOOP") return undefined;
38
+ if (code !== "ENOENT") throw error;
39
+ }
40
+ let ancestor = target;
41
+ while (!existing(ancestor)) {
42
+ const parent = dirname(ancestor);
43
+ if (parent === ancestor) return undefined;
44
+ ancestor = parent;
45
+ }
46
+ if (!lstatSync(ancestor).isSymbolicLink()) return undefined;
47
+ target = resolve(dirname(ancestor), readlinkSync(ancestor), relative(ancestor, target));
48
+ }
49
+ return undefined;
50
+ }
51
+ /** Only a completed bounded scan contributes aliases; a truncated root contributes none. */
52
+ function aliasScope(dir: string, lexical: string, actual: string, includeParents: boolean, budget: DiscoveryBudget): boolean {
53
+ // Readability must not depend on reaching readSync after an exhausted-budget return.
54
+ accessSync(dir, constants.R_OK);
55
+ const handle = opendirSync(dir);
56
+ let matched = false;
57
+ try {
58
+ if (budget.entries >= 4096) return false;
59
+ for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
60
+ if (budget.entries >= 4096) return false;
61
+ budget.entries++;
62
+ if (!entry.isSymbolicLink()) continue;
63
+ const target = directAliasTarget(join(dir, entry.name));
64
+ if (target !== undefined && (beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) matched = true;
65
+ }
66
+ } finally { handle.closeSync(); }
67
+ return matched;
68
+ }
28
69
  /**
29
70
  * Either spelling protects: the lowercase comparison keeps `?` matching one code point whose fold expands (ß, fi), and
30
71
  * the folded one catches NFD/NFC, compatibility and long-s aliases. Adding a match only ever adds protection.
@@ -69,7 +110,7 @@ function patternScope(pattern: string[], states: Set<number>, path: string, incl
69
110
  return includeParents && states.size > 0; // this scope can contain a future protected descendant
70
111
  }
71
112
  /** Resolve wildcard directory aliases without trusting a cached expansion. Bound all filesystem discovery. */
72
- function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: { entries: number }): boolean {
113
+ function globScope(root: string, lexical: string, actual: string, path: string, includeParents: boolean, budget: DiscoveryBudget, roots: Set<string>, aliasDiscovery = false): boolean {
73
114
  const pattern = path.split("/").filter(p => p && p !== ".");
74
115
  const start = closure(pattern, new Set([0]));
75
116
  if (patternScope(pattern, start, relative(root, lexical), includeParents) || patternScope(pattern, start, relative(root, actual), includeParents)) return true;
@@ -77,6 +118,8 @@ function globScope(root: string, lexical: string, actual: string, path: string,
77
118
  const real = policyRealPath(dir);
78
119
  if (states.has(pattern.length)) {
79
120
  if (beneath(lexical, real) || beneath(actual, real) || includeParents && (beneath(real, lexical) || beneath(real, actual))) return true;
121
+ // Defer new child aliases until every pre-existing policy match has had its discovery budget.
122
+ if (pattern.at(-1) === "**" && existing(real) && statSync(real).isDirectory()) roots.add(real);
80
123
  // An earlier `**` can still match deeper: an alias below this match (`ss/ß -> other` for `**/ß`) is searched too.
81
124
  // A trailing `**` only re-matches everything below, so it never walks the subtree (as before, for paths outside it).
82
125
  states = new Set([...states].filter(i => i !== pattern.length && !(i === pattern.length - 1 && pattern[i] === "**")));
@@ -88,11 +131,13 @@ function globScope(root: string, lexical: string, actual: string, path: string,
88
131
  if (!lowerBeneath(real, root)) throw new Error("policy glob discovery outside project");
89
132
  const info = statSync(dir);
90
133
  if (!info.isDirectory()) return false;
134
+ if (aliasDiscovery && budget.entries >= 4096) return false;
91
135
  if (depth > 32 || ancestors.has(real) || budget.entries > 4096) throw new Error("policy glob discovery unavailable");
92
136
  const chain = new Set(ancestors); chain.add(real);
93
137
  const handle = opendirSync(dir);
94
138
  try {
95
139
  for (let entry = handle.readSync(); entry; entry = handle.readSync()) {
140
+ if (aliasDiscovery && budget.entries >= 4096) return false;
96
141
  if (++budget.entries > 4096) throw new Error("policy glob discovery unavailable");
97
142
  const next = advance(pattern, states, entry.name);
98
143
  if (next.size && visit(join(dir, entry.name), next, depth + 1, chain)) return true;
@@ -102,14 +147,28 @@ function globScope(root: string, lexical: string, actual: string, path: string,
102
147
  };
103
148
  return visit(root, start, 0, new Set());
104
149
  }
105
- /** Compare declared spelling, real targets and wildcard aliases. Unknown discovery conservatively protects. */
150
+ /** Compare declared spelling, real targets and bounded aliases; legacy discovery errors fail closed. */
106
151
  export function matchesPolicy(cwd: string, path: string, paths: string[], includeParents = false): boolean {
107
152
  if (!paths.length) return false;
108
153
  try {
109
154
  if (path.length > 4096) return true;
110
155
  const root = realPath(cwd), lexical = resolve(root, path), actual = policyRealPath(lexical);
111
156
  includeParents &&= !existing(lexical) || statSync(lexical).isDirectory();
112
- const budget = { entries: 0 };
113
- return paths.some(entry => /[?*]/.test(entry) ? globScope(root, lexical, actual, entry, includeParents, budget) : policyTargets(root, [entry]).some(target => beneath(lexical, target) || beneath(actual, target) || (includeParents && (beneath(target, lexical) || beneath(target, actual)))));
157
+ const budget = { entries: 0 }, roots = new Set<string>(), exact: string[] = [];
158
+ if (paths.some(entry => {
159
+ if (/[?*]/.test(entry)) return globScope(root, lexical, actual, entry, includeParents, budget, roots);
160
+ const targets = policyTargets(root, [entry]);
161
+ if (targets.some(target => beneath(lexical, target) || beneath(actual, target) || includeParents && (beneath(target, lexical) || beneath(target, actual)))) return true;
162
+ const real = targets.at(-1)!;
163
+ if (existing(real) && statSync(real).isDirectory()) roots.add(real);
164
+ exact.push(entry);
165
+ return false;
166
+ })) return true;
167
+ // New exact-root discovery cannot consume the budget before legacy wildcard matches are checked.
168
+ for (const entry of exact) {
169
+ if (globScope(root, lexical, actual, `${entry}/**`, includeParents, budget, roots, true)) return true;
170
+ }
171
+ for (const dir of roots) if (aliasScope(dir, lexical, actual, includeParents, budget)) return true;
172
+ return false;
114
173
  } catch { return true; }
115
174
  }
@@ -55,7 +55,9 @@ export class TextProjections {
55
55
  /** Inputs whose full-budget conversion failed: the same bytes under the same command are not converted again. */
56
56
  private readonly failed = new Set<string>();
57
57
  constructor(private readonly options: { cwd: string; stateDir: string; config: ProjectionConfig; deny?: string[]; readAllow?: string[]; log?: (line: string) => void }) {
58
- // Rooted at an empty system directory, not the project: the converter reads its snapshot and program files only.
58
+ // Rooted at an empty system directory, not the project. The project guarantee: project file data is denied, then the
59
+ // snapshot, the argv-named program/script files and the directory listings above an in-project script are granted, so
60
+ // no other project file is readable (system, toolchain, home and local.read_allow paths outside the project remain granted).
59
61
  // Only where conversion can run: /var/empty is a macOS directory, and a host without the sandbox never converts.
60
62
  this.sandbox = Object.keys(options.config).length && sandboxAvailable() ? profile(EMPTY_ROOT, false, options.readAllow, options.deny, ["."]) : "";
61
63
  }
@@ -1,5 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { realPath } from "../hub/project.ts";
3
5
  import { homedir } from "node:os";
4
6
  import { join } from "node:path";
5
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
@@ -9,7 +11,24 @@ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
9
11
  /** Titles `ahub ask --remember` gives a model's answer (ask.ts ASK_NOTE_TITLE): never imported as a finding. */
10
12
  const MODEL_ANSWER = "ahub ask (model answer)";
11
13
 
12
- export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number; project: number } }
14
+ export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
15
+
16
+ /** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
17
+ function readCursors(file: string): Record<string, number> {
18
+ try {
19
+ const value: unknown = JSON.parse(readFileSync(file, "utf8"));
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
21
+ return Object.fromEntries(Object.entries(value).filter(([, id]) => Number.isSafeInteger(id) && id >= 0));
22
+ } catch { return {}; }
23
+ }
24
+ function writeCursor(file: string, key: string, cursor: number, log: (line: string) => void): void {
25
+ const temp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
26
+ try {
27
+ writeFileSync(temp, JSON.stringify({ ...readCursors(file), [key]: cursor }) + "\n", { mode: 0o600, flag: "wx" });
28
+ renameSync(temp, file);
29
+ } catch { log("claude-mem import progress could not be saved; the next run may replay handled rows"); }
30
+ finally { try { rmSync(temp, { force: true }); } catch { /* disposable progress */ } }
31
+ }
13
32
 
14
33
  /**
15
34
  * #258 phase 3: copy the hub's own notes from claude-mem (observations whose metadata names a `peer`: `hub_remember`
@@ -17,30 +36,38 @@ export interface ImportResult { read: number; added: number; skipped: { model: n
17
36
  * the projects in `chain` (claude-mem files them under the repository's basename). Model answers, PII-pattern text and secret-looking text are skipped.
18
37
  * Idempotent: an observation always maps to the same entry.
19
38
  */
20
- export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number }): ImportResult {
39
+ export function importClaudeMem(store: MemoryStore, opts: { project: string; chain: string[]; isPii: (text: string) => boolean; nodes?: (text: string) => string[]; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): ImportResult {
21
40
  const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
22
41
  if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
42
+ const limit = opts.limit ?? 10_000;
43
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
44
+ const state = join(store.dir, "import-state.json");
45
+ const name = opts.chain.at(-1) ?? "";
46
+ const key = createHash("sha256").update(JSON.stringify([realPath(file), name, opts.project])).digest("hex");
47
+ let cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
23
48
  const db = new Database(file, { readonly: true });
24
49
  db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
25
- const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0, project: 0 } };
50
+ const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
26
51
  try {
27
- const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
28
- .all(opts.limit ?? 10_000) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
52
+ const max = db.query("SELECT MAX(id) AS id FROM observations WHERE project = ? AND metadata LIKE '%\"peer\"%'").get(name) as { id: number | null };
53
+ if ((max.id ?? 0) < cursor) cursor = 0;
54
+ const rows = db.query("SELECT id, project, title, text, narrative, created_at_epoch, metadata FROM observations WHERE project = ? AND id > ? AND metadata LIKE '%\"peer\"%' ORDER BY id LIMIT ?")
55
+ .all(name, cursor, limit) as { id: number; project: string | null; title: string | null; text: string | null; narrative: string | null; created_at_epoch: number | null; metadata: string | null }[];
29
56
  // The hub files its claude-mem notes under the repository's own name (the chain's last); the names above it are other
30
57
  // projects (a superproject) whose notes and privacy policy are not this one's. ponytail: claude-mem keys projects by
31
58
  // basename, so two unrelated repositories with one name still share it.
32
- const project = opts.project, names = new Set(opts.chain.slice(-1));
33
- for (const r of rows) {
34
- result.read++;
59
+ const project = opts.project;
60
+ result.read = rows.length;
61
+ result.more = rows.length === limit;
62
+ const handle = (r: typeof rows[number]) => {
35
63
  let meta: Record<string, unknown>;
36
- try { meta = JSON.parse(r.metadata ?? "{}"); } catch { continue; }
37
- if (typeof meta.peer !== "string" || !meta.peer) continue;
38
- if (!r.project || !names.has(r.project)) { result.skipped.project++; continue; }
64
+ try { meta = JSON.parse(r.metadata ?? "{}"); } catch { return; }
65
+ if (!meta || typeof meta !== "object" || typeof meta.peer !== "string" || !meta.peer) return;
39
66
  const title = r.title ?? "", text = (r.text ?? r.narrative ?? "").trim();
40
- if (!text) continue;
41
- if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; continue; }
42
- if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; continue; }
43
- if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; continue; }
67
+ if (!text) return;
68
+ if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++; return; }
69
+ if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++; return; }
70
+ if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++; return; }
44
71
  const kind: Kind = KINDS.includes(meta.kind as Kind) ? meta.kind as Kind : "finding";
45
72
  const at = new Date(r.created_at_epoch ?? 0).toISOString();
46
73
  const { added } = store.importNote({ op: "note", project, scope: "project", tier: "long", kind, peer: meta.peer.slice(0, 32), ...(title ? { title } : {}), text,
@@ -48,8 +75,16 @@ export function importClaudeMem(store: MemoryStore, opts: { project: string; cha
48
75
  // own paths and symbols place the note in the graph.
49
76
  about: opts.nodes?.(`${title}\n${text}`) ?? [], source: "claude-mem" }, at);
50
77
  if (added) result.added++;
78
+ };
79
+ for (const row of rows) {
80
+ handle(row);
81
+ cursor = row.id; // only fully handled rows, including every skip
51
82
  }
52
- } finally { db.close(); }
83
+ } finally {
84
+ db.close();
85
+ result.cursor = cursor;
86
+ writeCursor(state, key, cursor, opts.log ?? console.error);
87
+ }
53
88
  store.refresh();
54
89
  return result;
55
90
  }
@@ -78,10 +78,10 @@ const SECRETS = [/-----BEGIN [A-Z ]*PRIVATE KEY-----/, /\bAKIA[0-9A-Z]{16}\b/, /
78
78
  export const looksSecret = (text: string): boolean => SECRETS.some((re) => re.test(text));
79
79
 
80
80
  /** `task`: the board task's id and proposal time; its node carries both, so a task id reused after `ahub reset --all` is another node. */
81
- export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number } }
81
+ export interface Extract { cwd: string; project: string; repo: string; deny?: string[]; paths?: string[]; symbols?: string[]; task?: { id: number; created: number }; checkExists?: boolean }
82
82
  /**
83
83
  * Deterministic edges, fixed at write time so a rebuild never depends on the file system: the task's refs and plan
84
- * paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then,
84
+ * paths, plan symbols (with the path when the plan names exactly one), paths in the text that exist in the project then (or text-only paths when checkExists is false),
85
85
  * `#N` as an issue of `repo`, and `task #N` as a board task. Paths stay relative to the project root; denylisted ones
86
86
  * are left out.
87
87
  */
@@ -99,7 +99,7 @@ export function extractNodes(text: string, x: Extract): Node[] {
99
99
  for (const m of text.matchAll(/(?:^|[\s(`'"])((?:[\w.-]+\/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?=$|[\s)`'",:;])/g)) {
100
100
  const p = m[1]!;
101
101
  if (!p.includes("/") && !/\.(ts|js|tsx|jsx|py|rs|go|md|json|toml|yaml|yml|sh|html|css)$/.test(p)) continue;
102
- try { if (!isAbsolute(p) && !p.startsWith("..") && existsSync(join(x.cwd, p))) path(p); } catch { /* unreadable: not an edge */ }
102
+ try { if (!isAbsolute(p) && !p.startsWith("..") && (x.checkExists === false || existsSync(join(x.cwd, p)))) path(p); } catch { /* unreadable: not an edge */ }
103
103
  }
104
104
  for (const m of text.matchAll(/\btask #(\d{1,9})\b/gi)) out.add(`task:${x.project}#${m[1]}`);
105
105
  for (const m of text.matchAll(/(?<![\w/]|task )#(\d{1,9})\b/gi)) out.add(`issue:${x.repo}#${m[1]}`);
@@ -244,7 +244,7 @@ export class MemoryStore {
244
244
  // A fixed machine: the same observation imported on two machines is one entry once they sync.
245
245
  const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
246
246
  const id = entryId(body);
247
- // The observation, not the nodes derived from it (they follow the repository's files), says whether it is here already.
247
+ // The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
248
248
  const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
249
249
  if (known) return { id: known.id, added: false };
250
250
  appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
@@ -255,7 +255,55 @@ export class MemoryStore {
255
255
  private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
256
256
  return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
257
257
  }
258
- forget(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v) this.op("forget", v, actor); return v; }
258
+ forget(id: string, actor: string, project?: string): View | undefined {
259
+ const v = this.get(id, project);
260
+ if (!v) return undefined;
261
+ // Legacy imports may have different node-derived ids. Forget every known copy, including hidden ones.
262
+ const group = v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
263
+ for (const member of group) this.op("forget", member, actor);
264
+ return v;
265
+ }
266
+
267
+ /** Plan or append reconciliation of legacy imports, preserving every existing replacement relation. */
268
+ dedupe(project: string, apply = false): { groups: { members: View[]; heads: string[]; target: string }[]; applied: number } {
269
+ const rows = this.live(), byId = new Map(rows.map(v => [v.id, v]));
270
+ const tuples = new Map<string, View[]>();
271
+ for (const v of rows) {
272
+ if (v.project !== project || v.source !== "claude-mem") continue;
273
+ const key = JSON.stringify([v.at, v.text]);
274
+ const group = tuples.get(key) ?? []; group.push(v); tuples.set(key, group);
275
+ }
276
+ const groups: { members: View[]; heads: string[]; target: string }[] = [];
277
+ for (const members of tuples.values()) {
278
+ if (members.length < 2) continue;
279
+ members.sort((a, b) => a.id.localeCompare(b.id));
280
+ const heads = members.filter(v => !v.supersededBy);
281
+ if (!heads.length) continue;
282
+ const ids = new Set(members.map(v => v.id));
283
+ const terminals = new Map<string, View>();
284
+ for (const v of members) {
285
+ let terminal = v;
286
+ const seen = new Set<string>();
287
+ while (terminal.supersededBy && !seen.has(terminal.id)) {
288
+ seen.add(terminal.id);
289
+ const next = byId.get(terminal.supersededBy);
290
+ if (!next) break;
291
+ terminal = next;
292
+ }
293
+ if (!terminal.supersededBy && !ids.has(terminal.id) && !terminal.local && (terminal.project === project || terminal.scope === "shared") && heads.every(h => h.scope !== "shared" || terminal.scope === "shared")) terminals.set(terminal.id, terminal);
294
+ }
295
+ const outside = [...terminals.values()].sort((a, b) => b.at.localeCompare(a.at) || b.id.localeCompare(a.id))[0];
296
+ if (!outside && heads.length === 1) continue;
297
+ groups.push({ members, heads: heads.map(v => v.id), target: outside?.id ?? heads[0]!.id });
298
+ }
299
+ let applied = 0;
300
+ if (apply) for (const group of groups) for (const head of group.heads) {
301
+ if (head === group.target) continue;
302
+ if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
303
+ applied++;
304
+ }
305
+ return { groups, applied };
306
+ }
259
307
  /** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
260
308
  keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out
261
309
  demote(id: string, actor: string, project?: string): View | undefined { const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, this.until()); return v; }