Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`) —
Expand Down Expand Up @@ -67,7 +67,10 @@ Every command supports `--json`, and those shapes are a stable public contract t
- Each group is `internal/cmd/<group>/` with a `New<Group>Cmd(*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 `<org>::<memory>::<loc>` (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 <org::memory>` is given (single-colon `<org>:<memory>` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `<loc>` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs likewise accept id / `hrn:memory:<org>::<slug>` / `<org>::<slug>` / `<org>:<slug>` 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:<root>:<slug>` (`cmdutil.CanonicalMemoryRef`) and `hrn:node:<root>:<slug>:<loc…>` (`cmdutil.NodeURN`). *Input* stays Postel-liberal — every v1 spelling (`hrn:memory:<org>::<slug>`, `<org>::<memory>::<loc>`) 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 `<org>::<memory>::<loc>`: 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 <org::memory>` is given (single-colon `<org>:<memory>` also accepted — `cmdutil.canonicalOrgMemory` normalizes it), which `node get|update|rm|export` and `edge add|ls` accept to name a bare `<loc>` (resolved via `cmdutil.ResolveNodeRef`, which joins memory+loc then defers to `ResolveNodeURN`). Memory refs accept id / v2 / `<org>::<slug>` / `<org>:<slug>` / 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 (`<org>::<agent>:app-mem:<slug>`). Don't migrate that one without solving the compound case.

## Whole-corpus reads — paginate, don't truncate

Expand All @@ -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.
20 changes: 18 additions & 2 deletions internal/cmd/access/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<type>: URN for a
// memory/node/app/agent, or a bare id for an AiServiceConfig (the one URN-less
Expand All @@ -42,8 +57,9 @@ func normalizeResourceRef(ref string) (string, error) {
if strings.Contains(r, ":") {
return "", exitcode.Newf(exitcode.Usage,
"%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; "+
"a bare, colon-free id is read as an AiServiceConfig id", r)
"(hrn:mem:, hrn:node:, hrn:app:, or hrn:agent:; the legacy hrn:memory: "+
"is also accepted), e.g. %s; "+
"a bare, colon-free id is read as an AiServiceConfig id", r, memoryURNExample(r))
}
return r, nil
}
67 changes: 67 additions & 0 deletions internal/cmd/access/access_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
9 changes: 5 additions & 4 deletions internal/cmd/access/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:<org>::<slug>
spelling is still accepted.
Comment on lines +58 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the embedded agentic usage for v2 URNs

After declaring the flat v2 forms canonical here, the separately exposed hadron agentic-usage contract still documents only hrn:memory:<org>::<slug> and the double-colon node form (internal/cmd/agentic/agentic-usage.md:120-126), and its access check section still lists only hrn:memory: (:578-583). Agents using that command rather than Cobra help will therefore continue generating the legacy spelling and are not told that server-emitted flat node URNs are accepted; update the embedded contract alongside these help changes. CLAUDE.mdL63-L63

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and this was the more consequential of the two — fixed in 651abb1.

agentic-usage.md is the file hadron agentic-usage prints, and its own header calls it "the single reference an agent needs to drive the CLI". Leaving it on v1 while declaring v2 canonical in cobra help would have meant agents reading the documented contract keep emitting the legacy spelling and never learn that the server-emitted flat URNs are accepted at all — exactly the loop that put v1 in CLAUDE.md in the first place.

Both places you flagged are updated:

  • :120-126 now leads with the emit-v2/accept-everything rule and gives the canonical forms (hrn:mem:<root>:<slug>, hrn:node:<root>:<slug>:<loc…>), with every legacy spelling still listed as accepted. The node bullet also explains why scheme-less single-colon stays ambiguous while the flat v2 form is not — the hrn:node: prefix is what disambiguates.
  • access check now lists hrn:mem:… alongside the legacy prefix.

Verified in the rendered output:

$ hadron agentic-usage | grep -n 'hrn:mem:|grammar-v2'
120:- **URN grammar: the CLI emits flat grammar-v2, and accepts every spelling.**
122:  `hrn:mem:<root>:<slug>` for a memory and `hrn:node:<root>:<slug>:<loc…>` for a
126:- Memory references accept the memory id, the canonical `hrn:mem:<root>:<slug>`
591:  — `hrn:mem:…`, `hrn:node:…`, `hrn:app:…`, `hrn:agent:…` (the legacy


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 {
Expand Down
26 changes: 18 additions & 8 deletions internal/cmd/agentic/agentic-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<org>::<slug>`
URN, or the short `<org>::<slug>` / `<org>:<slug>` 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:<root>:<slug>` for a memory and `hrn:node:<root>:<slug>:<loc…>` 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:<root>:<slug>`
URN, the legacy `hrn:memory:<org>::<slug>`, or the short `<org>::<slug>` /
`<org>:<slug>` 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:<root>:<slug>:<loc…>`, or the scheme-less
`<org>::<memory>::<loc>` (double-colon between segments — e.g.
`hadronmemory.com::dev::start-here`), optionally `hrn:node:`-prefixed (legacy
`urn:node:` also accepted). Single-colon `<org>:<memory>:<loc>` 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 `<org>:<memory>:<loc>` 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 <org::memory>` (single-colon
`<org>:<memory>` also accepted) to name the memory — then
`node get|update|move|clone|rm|export` and `edge add|list` take a bare `<loc>`
Expand Down Expand Up @@ -579,7 +588,8 @@ Conventions:
this resource?" — the authoritative, server-computed effective access plus the
grants that confer it (no client-side re-derivation). `<user>` is an id, email,
or handle (resolved via `searchUsers`); `<resource>` 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:<org>::<slug>` 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
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/memory/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/memory/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <org>::<slug> URN (single-colon <org>:<slug> and an hrn:memory: prefix are also accepted)", ref)
"no memory found for %q — expected a memory id or a URN: hrn:mem:<root>:<slug> (canonical), the <root>::<slug> / <root>:<slug> short forms, or the legacy hrn:memory: prefix", ref)
}