@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 +6 -0
- package/README.md +2 -2
- package/docs/data-policy.md +2 -1
- package/docs/memory.md +45 -8
- package/docs/operations.md +8 -8
- package/docs/quickstart.md +1 -1
- package/docs/specs/2026-09-19-agent-hub-design.md +1 -1
- package/docs/text-projection.md +1 -1
- package/docs/verification/2026-10-14-0.12.26.md +31 -0
- package/docs/verified.json +7 -7
- package/package.json +1 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1 -1
- package/src/cli/help.ts +1 -1
- package/src/cli/identity.ts +1 -1
- package/src/cli/memory.ts +16 -6
- package/src/hub/path-policy.ts +64 -5
- package/src/hub/text-projection.ts +3 -1
- package/src/memory/import.ts +52 -17
- package/src/memory/store.ts +53 -5
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.
|
|
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.
|
|
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`.
|
package/docs/data-policy.md
CHANGED
|
@@ -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
|
-
-
|
|
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
|
|
209
|
-
notes stay out,
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
package/docs/operations.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Operations guide
|
|
2
2
|
|
|
3
|
-
This guide describes ahub 0.12.
|
|
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.
|
|
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.
|
|
1630
|
-
bunx --package @staix/agent-hub@0.12.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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)
|
package/docs/quickstart.md
CHANGED
|
@@ -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.
|
|
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
|
|
package/docs/text-projection.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/verified.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"documents": {
|
|
4
4
|
"README.md": {
|
|
5
|
-
"verifiedAgainst": "
|
|
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": "
|
|
14
|
+
"verifiedAgainst": "fbf96c0804f823282de4e3c4c19f16d94b725e57",
|
|
15
15
|
"paths": [
|
|
16
16
|
"src/",
|
|
17
17
|
"templates/"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
20
|
"docs/operations.md": {
|
|
21
|
-
"verifiedAgainst": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "agent-hub",
|
|
3
|
-
"version": "0.12.
|
|
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.
|
|
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
|
]],
|
package/src/cli/identity.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
95
|
-
const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key,
|
|
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.
|
|
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");
|
package/src/hub/path-policy.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/memory/import.ts
CHANGED
|
@@ -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;
|
|
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,
|
|
50
|
+
const result: ImportResult = { read: 0, added: 0, skipped: { model: 0, pii: 0, secret: 0 }, more: false, cursor };
|
|
26
51
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
37
|
-
if (typeof meta.peer !== "string" || !meta.peer)
|
|
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)
|
|
41
|
-
if (meta.source === "ahub ask" || title.startsWith(MODEL_ANSWER)) { result.skipped.model++;
|
|
42
|
-
if (opts.isPii(`${title}\n${text}`)) { result.skipped.pii++;
|
|
43
|
-
if (looksSecret(`${title}\n${text}`)) { result.skipped.secret++;
|
|
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 {
|
|
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
|
}
|
package/src/memory/store.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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; }
|