From 13565c5eb9a8505ed287cab3a80075b3cc8c1fbf Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sat, 1 Aug 2026 11:53:05 +0200 Subject: [PATCH 1/2] docs: use grammar-v2 URNs in CLAUDE.md and help-text examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI has emitted flat grammar-v2 URNs since the urn-compose-v2 change (hrn:mem::, hrn:node:::), and the server and MCP layer both emit v2 too — but CLAUDE.md still listed the four Hadron memories in v1, which is where agents (and humans copying from it) picked the old spelling up. Several help-text examples showed v1 as well. - CLAUDE.md: the four memory refs, the two hadron_get_node calls, and the tasks:review-changes task node are now v2. Verified each resolves live, including a colon-bearing loc (hrn:node:hadronmemory.com:hadron-cli:tasks:review-changes). - CLAUDE.md gained an explicit "emit v2, accept everything" rule, so the next reader knows v1 in a test fixture is deliberate acceptance coverage (#239) rather than drift — and that the spec group's memoryRefV1 is a documented exception, because a fixed-arity flat v2 node URN can't round-trip a compound app-mem memory (:::app-mem:). - `access check` help/examples and `memory attach`'s example now show v2; `notFoundMemory` and the under-qualified-resource error name hrn:mem: as canonical while still naming the legacy prefix. Behaviour is unchanged — every one of these is help text, an example, or an error string. The access-check guard already passed any hrn:-prefixed ref through untouched, so v2 resources always worked; confirmed live that hrn:mem:hadronmemory.com:hadron-cli resolves as kind=memory. The under-qualified-resource error deliberately keeps `e.g. hrn:memory:`: it interpolates what the user just typed, which contains "::", so the legacy prefix composes into a valid URN where hrn:mem: would not. Not touched: memoryRefHelp already listed hrn:mem: as canonical, and the v1 test fixtures are the legacy-acceptance coverage. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 19 +++++++++++-------- internal/cmd/access/access.go | 7 ++++++- internal/cmd/access/check.go | 9 +++++---- internal/cmd/memory/attach.go | 2 +- internal/cmd/memory/memory.go | 2 +- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb51496..c09d84f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,18 +10,18 @@ Hadron is the platform's institutional memory — assume it covers things not ob code alone (past incidents, decisions, conventions baked into several places). Relevant memories: -- `hrn:memory:hadronmemory.com::hadron-cli` — this CLI's own findings/conventions -- `hrn:memory:hadronmemory.com::dev` — shared findings, conventions, ops, the `preflight` routing index -- `hrn:memory:hadronmemory.com::hadron-server` — the GraphQL contract this CLI targets; server findings -- `hrn:memory:hadronmemory.com::specs` — product specs (loc-as-citation); the `spec` command group is the citation-aware surface over this corpus +- `hrn:mem:hadronmemory.com:hadron-cli` — this CLI's own findings/conventions +- `hrn:mem:hadronmemory.com:dev` — shared findings, conventions, ops, the `preflight` routing index +- `hrn:mem:hadronmemory.com:hadron-server` — the GraphQL contract this CLI targets; server findings +- `hrn:mem:hadronmemory.com:specs` — product specs (loc-as-citation); the `spec` command group is the citation-aware surface over this corpus (1) **Query Hadron before reading code.** For the topics/entities in a request, run `hadron_find_nodes` first, then `hadron_get_node` on promising hits; cite node `loc` values. (Note the CLI *is* a superset of the MCP tools — but for memory reads while developing it, the `hadron_*` MCP tools are simplest; don't rely on the dev binary you may be mid-change on.) -(2) Read `hadron_get_node hrn:node:hadronmemory.com::dev::instructions` once per session (what -Hadron is, URN grammar, the specs corpus), and `hadron_get_node hrn:node:hadronmemory.com::dev::preflight` +(2) Read `hadron_get_node hrn:node:hadronmemory.com:dev:instructions` once per session (what +Hadron is, URN grammar, the specs corpus), and `hadron_get_node hrn:node:hadronmemory.com:dev:preflight` before a change (the shared server/platform routing index). (3) Capture a non-obvious finding the moment it emerges (`hadron_create_node` / `hadron_update_node`) — @@ -67,7 +67,10 @@ Every command supports `--json`, and those shapes are a stable public contract t - Each group is `internal/cmd//` with a `NewCmd(*cmdutil.Factory)` constructor, wired in `internal/cmd/root.go`. - `cmdutil.Factory` is the DI seam: lazily resolves config, the token store, and the GraphQL client (`f.GraphQLClient()`), and carries the persistent `--json/--server/--app` flags plus `f.IOStreams`. Commands take the Factory; tests inject a fake one. - Destructive commands (`memory rm`, `node rm`, `edge rm`, `app uninstall`) prompt on a TTY and require `--yes` non-interactively (`cmdutil.ConfirmDeletion`). -- Node references are fully-qualified URNs `::::` (double-colon between segments — a loc contains single colons, so single-colon `org:memory:loc` is ambiguous and rejected); a bare loc is rejected *unless* `-m/--memory ` is given (single-colon `:` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs likewise accept id / `hrn:memory:::` / `::` / `:` via `cmdutil.CanonicalMemoryRef`. `spec` commands likewise take `-m/--memory` + a bare citation (the loc *is* a legal-code citation — see `docs/how-to/maintain-product-specs.md`). +- **URN grammar — emit v2, accept everything.** The CLI *emits* the flat grammar-v2 form the server now hands out: `hrn:mem::` (`cmdutil.CanonicalMemoryRef`) and `hrn:node:::` (`cmdutil.NodeURN`). *Input* stays Postel-liberal — every v1 spelling (`hrn:memory:::`, `::::`) is accepted forever (#239), so don't "fix" a legacy ref in a test fixture; those are the acceptance coverage. See [docs/plans/urn-compose-v2.md](docs/plans/urn-compose-v2.md). + - A *scheme-less* node ref must use `::::`: a loc contains single colons, so `org:memory:loc` is ambiguous and rejected. The `hrn:node:` prefix removes the ambiguity, which is why the v2 flat form can use single colons throughout. + - A bare loc is rejected *unless* `-m/--memory ` is given (single-colon `:` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs accept id / v2 / `::` / `:` / legacy `hrn:memory:` alike. `spec` commands likewise take `-m/--memory` + a bare citation (the loc *is* a legal-code citation — see `docs/how-to/maintain-product-specs.md`). + - **Exception:** the `spec` group composes v1 internally on purpose (`memoryRefV1`, `internal/cmd/spec/spec.go`) — a fixed-arity flat v2 node URN can't round-trip a COMPOUND app-mem memory (`:::app-mem:`). Don't migrate that one without solving the compound case. ## Whole-corpus reads — paginate, don't truncate @@ -82,6 +85,6 @@ Command-level tests live in `internal/cmd/*_test.go` against a fake GraphQL serv ## Conventions for changes - **Substantial features get a design-as-built plan doc in `docs/plans/`**, bundled in the PR for review (see the existing ones). -- **Before opening a PR, run the Hadron review flow** — the CLI-specific checklist pass, complementing the generic `/code-review` skill. Run the task node `hrn:node:hadronmemory.com::hadron-cli::tasks:review-changes` (read the `review` parent, then walk the applicable `Applies when …` children against your diff). When a defect or near-miss reveals a reusable pattern, capture it with `tasks:add-review-node`. +- **Before opening a PR, run the Hadron review flow** — the CLI-specific checklist pass, complementing the generic `/code-review` skill. Run the task node `hrn:node:hadronmemory.com:hadron-cli:tasks:review-changes` (read the `review` parent, then walk the applicable `Applies when …` children against your diff). When a defect or near-miss reveals a reusable pattern, capture it with `tasks:add-review-node`. - One change per PR, squash-merged; CI (build/test/lint + `goreleaser check`) must be green. - **Releasing is tag-driven** (push `vX.Y.Z` → goreleaser publishes archives + auto-bumps the Homebrew cask). See the README "Releasing" section. diff --git a/internal/cmd/access/access.go b/internal/cmd/access/access.go index a46c985..7e52001 100644 --- a/internal/cmd/access/access.go +++ b/internal/cmd/access/access.go @@ -41,8 +41,13 @@ func normalizeResourceRef(ref string) (string, error) { } if strings.Contains(r, ":") { return "", exitcode.Newf(exitcode.Usage, + // The suggested example keeps the legacy hrn:memory: prefix on + // purpose: it composes correctly with the "::" shorthand the user + // just typed, whereas the canonical hrn:mem: form uses single + // colons throughout (hrn:mem:acme.com:kb). "%q is not a fully-qualified resource URN — prefix it with its kind "+ - "(hrn:memory:, hrn:node:, hrn:app:, or hrn:agent:), e.g. hrn:memory:%[1]s; "+ + "(hrn:mem:, hrn:node:, hrn:app:, or hrn:agent:; the legacy hrn:memory: "+ + "is also accepted), e.g. hrn:memory:%[1]s; "+ "a bare, colon-free id is read as an AiServiceConfig id", r) } return r, nil diff --git a/internal/cmd/access/check.go b/internal/cmd/access/check.go index 6a2605a..83d4ca7 100644 --- a/internal/cmd/access/check.go +++ b/internal/cmd/access/check.go @@ -55,14 +55,15 @@ func newCmdCheck(f *cmdutil.Factory) *cobra.Command { that confer it. The user is identified by id, email, or handle. The resource is a -fully-qualified URN — hrn:memory:…, hrn:node:…, hrn:app:…, or -hrn:agent:… — or a bare AiServiceConfig id. +fully-qualified URN — hrn:mem:…, hrn:node:…, hrn:app:…, or hrn:agent:… +— or a bare AiServiceConfig id. The legacy hrn:memory::: +spelling is still accepted. Reading this requires permission to audit the resource: a platform admin, an ADMIN/OWNER of the resource's owning org, or (for a strict- owner memory) the memory's principal.`, - Example: ` hadron access check alice@acme.com hrn:memory:acme.com::kb - hadron access check @alice hrn:node:acme.com::kb::start-here + Example: ` hadron access check alice@acme.com hrn:mem:acme.com:kb + hadron access check @alice hrn:node:acme.com:kb:start-here hadron access check usr_123 hrn:app:acme.com::support --json`, Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/memory/attach.go b/internal/cmd/memory/attach.go index 9e717d1..3db7bd5 100644 --- a/internal/cmd/memory/attach.go +++ b/internal/cmd/memory/attach.go @@ -24,7 +24,7 @@ The memory must belong to you, the Agent must be installed in the App, and you must be an App member. The memory keeps its URN, class, and owner. Memory, App, and Agent references accept IDs, bare URNs, or prefixed URNs.`, Example: ` hadron memory attach acme.com::my-notes --app acme.com::coach --agent acme.com::agent - hadron memory attach hrn:memory:acme.com::private-notes --app app-id --agent agent-id --json`, + hadron memory attach hrn:mem:acme.com:private-notes --app app-id --agent agent-id --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if app == "" || agent == "" { diff --git a/internal/cmd/memory/memory.go b/internal/cmd/memory/memory.go index 425d726..b42b0aa 100644 --- a/internal/cmd/memory/memory.go +++ b/internal/cmd/memory/memory.go @@ -158,5 +158,5 @@ func resolveMemoryID(cmd *cobra.Command, client graphql.Client, ref string) (str // rejected short form isn't mistaken for a genuinely-absent memory (#108). func notFoundMemory(ref string) error { return exitcode.Newf(exitcode.NotFound, - "no memory found for %q — expected a memory id or an :: URN (single-colon : and an hrn:memory: prefix are also accepted)", ref) + "no memory found for %q — expected a memory id or a URN: hrn:mem:: (canonical), the :: / : short forms, or the legacy hrn:memory: prefix", ref) } From 651abb13859d868ba0d2c7313282cdf2a0356fb6 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sat, 1 Aug 2026 12:36:44 +0200 Subject: [PATCH 2/2] fix(access,agentic): separator-aware URN example; v2 in the embedded contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on #333. - The under-qualified-resource error always suggested `hrn:memory:`. I had called that deliberate, but it only holds for the "::" shorthand: for the single-colon form it produced hrn:memory:acme.com:kb, mixing a v1 prefix with a v2 separator. memoryURNExample now picks the prefix that matches the separator the caller used — hrn:mem: for `a:b`, hrn:memory: for `a::b`. Worth recording: Copilot's premise was that the mixed form is invalid. It isn't — the server's decomposer resolved all three of hrn:memory::, hrn:mem:: and hrn:memory::: to the same memory. So this is about handing back a canonically-SHAPED example, not about validity, and the comment says so. - agentic-usage.md — the embedded contract `hadron agentic-usage` prints — still documented only v1, so an agent reading it (rather than cobra help) would keep emitting the legacy spelling and never learn that server-emitted flat URNs are accepted. It now leads with the emit-v2/accept-everything rule, gives the canonical memory and node forms, and `access check` lists hrn:mem: alongside the legacy prefix. Adds the access package's first test file: memoryURNExample against both separators, and normalizeResourceRef's pass-through / bare-id / rejection paths. Co-Authored-By: Claude Opus 5 --- internal/cmd/access/access.go | 23 ++++++--- internal/cmd/access/access_test.go | 67 +++++++++++++++++++++++++++ internal/cmd/agentic/agentic-usage.md | 26 +++++++---- 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 internal/cmd/access/access_test.go diff --git a/internal/cmd/access/access.go b/internal/cmd/access/access.go index 7e52001..c8936db 100644 --- a/internal/cmd/access/access.go +++ b/internal/cmd/access/access.go @@ -24,6 +24,21 @@ func NewCmdAccess(f *cmdutil.Factory) *cobra.Command { return cmd } +// memoryURNExample prefixes an under-qualified shorthand with the memory-kind +// scheme that matches the separator the caller used: the canonical hrn:mem: +// for the single-colon short form, the legacy hrn:memory: for the "::" one. +// +// The server's decomposer is liberal enough to resolve either spelling against +// either separator — hrn:memory:acme.com:kb, hrn:mem:acme.com:kb and +// hrn:memory:acme.com::kb all resolve to the same memory — so this is about +// handing back a canonically-SHAPED example rather than about validity. +func memoryURNExample(ref string) string { + if strings.Contains(ref, "::") { + return "hrn:memory:" + ref + } + return "hrn:mem:" + ref +} + // normalizeResourceRef shapes a resource reference into what // effectiveAccess(resource:) expects: a fully-qualified hrn:: URN for a // memory/node/app/agent, or a bare id for an AiServiceConfig (the one URN-less @@ -41,14 +56,10 @@ func normalizeResourceRef(ref string) (string, error) { } if strings.Contains(r, ":") { return "", exitcode.Newf(exitcode.Usage, - // The suggested example keeps the legacy hrn:memory: prefix on - // purpose: it composes correctly with the "::" shorthand the user - // just typed, whereas the canonical hrn:mem: form uses single - // colons throughout (hrn:mem:acme.com:kb). "%q is not a fully-qualified resource URN — prefix it with its kind "+ "(hrn:mem:, hrn:node:, hrn:app:, or hrn:agent:; the legacy hrn:memory: "+ - "is also accepted), e.g. hrn:memory:%[1]s; "+ - "a bare, colon-free id is read as an AiServiceConfig id", r) + "is also accepted), e.g. %s; "+ + "a bare, colon-free id is read as an AiServiceConfig id", r, memoryURNExample(r)) } return r, nil } diff --git a/internal/cmd/access/access_test.go b/internal/cmd/access/access_test.go new file mode 100644 index 0000000..4bb749a --- /dev/null +++ b/internal/cmd/access/access_test.go @@ -0,0 +1,67 @@ +package access + +import ( + "strings" + "testing" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// The suggested example must compose with the separator the caller used, so a +// single-colon shorthand isn't handed back a "::"-shaped v1 prefix. +func TestMemoryURNExampleMatchesSeparator(t *testing.T) { + cases := []struct{ in, want string }{ + {"acme.com:kb", "hrn:mem:acme.com:kb"}, // v2 short form → canonical prefix + {"acme.com::kb", "hrn:memory:acme.com::kb"}, // v1 short form → legacy prefix + {"acme.com::kb::start-here", "hrn:memory:acme.com::kb::start-here"}, + } + for _, tc := range cases { + if got := memoryURNExample(tc.in); got != tc.want { + t.Errorf("memoryURNExample(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestNormalizeResourceRef(t *testing.T) { + // Any scheme-prefixed ref passes through verbatim — the server dispatches + // on its type, and it accepts v2 and legacy spellings alike. + for _, ref := range []string{ + "hrn:mem:acme.com:kb", + "hrn:memory:acme.com::kb", + "hrn:node:acme.com:kb:start-here", + "hrn:node:acme.com::kb::start-here", + "hrn:app:acme.com::support", + "urn:memory:acme.com::kb", + } { + got, err := normalizeResourceRef(ref) + if err != nil { + t.Errorf("%q should pass through, got error %v", ref, err) + } + if got != ref { + t.Errorf("%q was rewritten to %q", ref, got) + } + } + + // A colon-free id is an AiServiceConfig id. + if got, err := normalizeResourceRef("cfg_123"); err != nil || got != "cfg_123" { + t.Errorf("bare id: got %q, %v", got, err) + } + + // An under-qualified shorthand is rejected with guidance that composes. + for _, ref := range []string{"acme.com:kb", "acme.com::kb"} { + _, err := normalizeResourceRef(ref) + if err == nil { + t.Fatalf("%q should be rejected", ref) + } + if got := exitcode.FromError(err); got != exitcode.Usage { + t.Errorf("%q should be a usage error, got %d", ref, got) + } + if !strings.Contains(err.Error(), memoryURNExample(ref)) { + t.Errorf("%q: the error should suggest %q, got %q", ref, memoryURNExample(ref), err.Error()) + } + } + + if _, err := normalizeResourceRef(" "); err == nil { + t.Error("an empty ref should be rejected") + } +} diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index 598e679..86f2d88 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -117,15 +117,24 @@ Conventions: `--handle` defers to the server. - `agent update --urn` also accepts the spec-047 user-author form `@handle:slug`. -- Memory references accept the memory id, the full `hrn:memory:::` - URN, or the short `::` / `:` forms (all resolve to the - same memory) across `memory get|set|attach|rm|member|share|export`. -- Node references are fully-qualified URNs +- **URN grammar: the CLI emits flat grammar-v2, and accepts every spelling.** + What it prints back — and what the server hands out — is + `hrn:mem::` for a memory and `hrn:node:::` for a + node (e.g. `hrn:node:hadronmemory.com:dev:start-here`). Prefer those when + composing a reference; every legacy form below stays accepted indefinitely, so + a URN you captured earlier keeps working. +- Memory references accept the memory id, the canonical `hrn:mem::` + URN, the legacy `hrn:memory:::`, or the short `::` / + `:` forms (all resolve to the same memory) across + `memory get|set|attach|rm|member|share|export`. +- Node references are fully-qualified URNs. Either the canonical flat v2 form + `hrn:node:::`, or the scheme-less `::::` (double-colon between segments — e.g. `hadronmemory.com::dev::start-here`), optionally `hrn:node:`-prefixed (legacy - `urn:node:` also accepted). Single-colon `::` is **not** a - valid full URN — a loc itself contains single colons - (`services:secureid:user-reporting`), so it's ambiguous. A bare loc is rejected + `urn:node:` also accepted). Scheme-less single-colon `::` is + **not** a valid full URN — a loc itself contains single colons + (`services:secureid:user-reporting`), so it's ambiguous; the `hrn:node:` prefix + is what removes that ambiguity for the flat v2 form. A bare loc is rejected (exit 2) *unless* you pass `-m/--memory ` (single-colon `:` also accepted) to name the memory — then `node get|update|move|clone|rm|export` and `edge add|list` take a bare `` @@ -579,7 +588,8 @@ Conventions: this resource?" — the authoritative, server-computed effective access plus the grants that confer it (no client-side re-derivation). `` is an id, email, or handle (resolved via `searchUsers`); `` is a fully-qualified URN - — `hrn:memory:…`, `hrn:node:…`, `hrn:app:…`, `hrn:agent:…` — or a bare + — `hrn:mem:…`, `hrn:node:…`, `hrn:app:…`, `hrn:agent:…` (the legacy + `hrn:memory:::` is also accepted) — or a bare AiServiceConfig id. Output carries `canRead/canWrite/canManage/canDelete`, a `role` label, and a `grants[]` array (each `{source, role, via}`); an empty `grants[]` is the first-class "no access" answer. Reading it requires audit