Skip to content

feat: read and edit a plan or spec by address, not by the whole file - #17

Merged
protonspy merged 4 commits into
mainfrom
feat/artifact-addressing
Aug 4, 2026
Merged

feat: read and edit a plan or spec by address, not by the whole file#17
protonspy merged 4 commits into
mainfrom
feat/artifact-addressing

Conversation

@protonspy

Copy link
Copy Markdown
Owner

An agent answering "what is the next open task?" reads the whole file to find out. Measured on a real workspace: one plan is 56KB / ~14k tokens, and its 31 specs bring the corpus to ~90k tokens. That cost is paid on every request rather than once, because a token read is paid on entry and 0.1× on every subsequent request of the session.

This adds a read surface and a write surface that address an artifact by name.

scc map — read

index | outline | tasks | show | blocks | find | trace

tokens
the plan ~14,350
scc map <plan> ~220
scc map blocks <plan> notes ~1,295
scc map show <plan> notes:10 ~128

scc patch — write

check | uncheck | task | add | rm | append | prepend | replace | fm

It exists for a policy rather than a cost: a harness makes a tool read a file before it may edit it. That guard is right for a text editor and wrong for a structured artifact, so it is replaced by three that are stronger here — an address that does not resolve is an error and never an insert at a guess; the file is re-validated and the change rolled back if it introduced a finding (exit 2, file untouched); and the changed lines are printed back.

A change deleting more than a screenful stops and asks for --force, with the displaced region elided — a confirmation that echoed 400 lines would put the file in context by the back door while reporting that it refused to touch it.

Addresses

None is a line number, which is what lets a write survive an edit above it:

1.2          a task           #notes     a section, by anchor slug
R1.2         a requirement    notes:7    the 7th paragraph of that section
specs/foo/   a leaf           L120-160   an explicit range, the escape hatch

Two things measurement decided

blocks exists because section addressing bottoms out. That plan's ## Notes is 411 lines — half the file — with no headings inside it, but every paragraph opens with a bolded thesis. The leads alone are an index a twentieth of the size.

No search engine. At 352KB / 94 artifacts a linear BM25 pass ranks the whole workspace in 55ms. Tantivy or its kin would cost a CGO surface or a second binary against a stdlib-only go.mod and a six-platform cross-compile. What precision needed was a better unit — addressable regions rather than lines, so a hit comes back as something show accepts. The seam is artifact.Search.

One grammar, two consumers

The task grammar moves into internal/artifact and internal/validate consumes it. A reader that disagreed with the validator about what a task is would be worse than no reader. The parser states facts about a line; turning a fact into a finding stays in validate — which is also what lets map open a malformed artifact instead of refusing exactly the file a user most needs to inspect.

Also

  • artifacts.md, a new rule inside the 55-line preload budget; code-search.md points at it, closing the gap it already named.
  • plan-run stops re-reading the plan to find its place — it maps it, and ticks boxes with patch check.
  • The entry file's layout block is now one column across all three harnesses, with the padding computed from the profile: a run of spaces that lines up for .codex/ is ragged for .opencode/. TestEntryLayoutBlockIsAligned is what keeps the hand-written and derived halves agreeing.
  • assets.Version12.

Verification

gofmt · go vet · full suite green. The race detector could not run locally (no gcc on this Windows box) — CI covers it on Linux and macOS.

Behaviour proven against the real 94-artifact corpus: check/uncheck round-trips the 56KB plan byte-identically, an edit that introduces plan.item-has-two-records is rolled back with the file intact, and 28 pre-existing findings do not block an unrelated edit.

🤖 Generated with Claude Code

https://claude.ai/code/session_019YLNd5uRpQDQ8ffvEvLJ3q

protonspy and others added 4 commits August 4, 2026 14:20
An agent answering "what is the next open task?" reads the whole file to
find out, and a plan that decomposes into thirty specs is tens of kilobytes
of prose wrapped around a dozen checkboxes. Measured on a real workspace,
one plan is 56KB and its 31 specs bring the corpus to ~90k tokens.

internal/artifact is the navigable model of one artifact, layered on mdscan
so fenced blocks and HTML comments are excluded here exactly as they are for
the validators. It turns a file into addressable pieces — sections, tasks,
requirements, decomposition leaves, paragraph blocks — each with a stable
name and a line range.

None of those addresses is a line number, which is the point: a line number
stops being true the moment anything above it moves, so an editor that
addresses by line has to read the file first. Editor applies splices resolved
against the original and applied bottom-up, so a batch cannot invalidate its
own line numbers halfway through, and two edits to one region are refused
rather than merged.

Two things measurement decided rather than taste. Blocks exist because
section addressing bottoms out: that plan's `## Notes` is 411 lines with no
headings inside it, but every paragraph opens with a bolded thesis, so the
leads alone are an index a twentieth of the size. And Search indexes
addressable regions rather than lines — at 352KB a linear BM25 pass ranks the
whole workspace in 55ms, and what precision needed was a better unit, not an
inverted index that would cost a CGO surface against a stdlib-only go.mod.

The task grammar moves here and validate consumes it. A reader that disagreed
with the validator about what a task is would be worse than no reader. The
parser now states facts about a line — how many methodology annotations it
carried, whether it carried the citation separator — and turning a fact into
a finding stays in validate, which is also what lets the reader open a
malformed artifact instead of refusing exactly the file a user most needs to
inspect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YLNd5uRpQDQ8ffvEvLJ3q
…ed by address

`scc map` is the read half — index | outline | tasks | show | blocks | find |
trace. Measured on a 56KB plan: the outline is 878 bytes, the paragraph index
of a 411-line Notes section is 5KB, and showing one addressed note is 514.

`scc patch` is the write half — check | uncheck | task | add | rm | append |
prepend | replace | fm. It exists for a policy rather than a cost: a harness
makes a tool read a file before it may edit it, because a string-match edit
against a file nobody looked at is a footgun. That guard is right for a text
editor and wrong for a structured artifact, so it is replaced by three that
are stronger here.

An address that does not resolve is an error and never an insert at a guess.
The file is re-validated after the write and the change is rolled back if it
introduced a finding the file did not already have — exit 2, file untouched.
And the displaced and written lines are printed back, which is the
confirmation the read was standing in for.

Two details in that verification are load-bearing. The comparison is on rule
plus message and deliberately not on line number: an insertion moves every
finding below it, and comparing on line would blame this edit for the whole
tail of a pre-existing problem. And an artifact scc has no validator for is
written and reported as unverified rather than silently claimed clean.

A change that would delete more than a screenful stops and asks for --force.
An address is a name, not a span, so `replace #notes` reads as one small edit
and resolves to four hundred lines — and the whole point of the command is
that nobody looked at the file first. The refusal elides what it declined to
delete, since a confirmation that echoed four hundred displaced lines would
put the file in context by the back door while reporting that it refused to
touch it.

A requirement id is scoped to its own spec, so `map trace R2.5` unscoped
answers with the list of specs that define it and stops rather than
concatenating nine traces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YLNd5uRpQDQ8ffvEvLJ3q
… the layout block

A capability the agent never learns about is a capability nobody uses, so the
knowledge lands in three places on three different terms.

artifacts.md is a new rule, 55 lines, inside the preload budget: the address
forms, the read verbs, and the write verbs, plus why patch is the way to tick
a box. It exists as its own rule rather than as a section of code-search.md
because that rule is about the symbol graph and was already over its line
cap — and because the concern is not only search. code-search.md now points
at it, closing the gap it already named: the graph indexes code, not this
repository's Markdown.

The entry file carries the reflex rather than the rule — which tool for which
corpus, in nine lines. That earns its place for Codex and opencode, where
rules/ is scc's own directory and nothing loads it, so the entry file is the
only thing that will ever mention map or patch. On Claude Code, which loads
rules/*.md at launch with the same priority as CLAUDE.md, it is a summary of
a rule already in context. The explanatory bullets for code-search and
artifacts became one-line pointers to pay for it.

plan-run stops reading the plan to find its place. Mapping it is how the loop
reads the shape, `patch check` is how a group's boxes get ticked, `patch fm`
is how the kickoff answers get recorded, and re-mapping at each group boundary
replaces re-reading — the read this loop performs most often. `## Notes` gets
its own instruction, because that section is the longest in any real plan and
most of it decides nothing about order: the paragraph index answers the
ordering question for about a sixth of the tokens.

The layout block is now one column across all three harnesses. The padding is
computed from the profile rather than written into the template, because a run
of spaces that lines up for `.codex/` is ragged for `.opencode/`, and half
that column is hand-written while half is derived — so nothing but
TestEntryLayoutBlockIsAligned keeps the two halves agreeing, and the failure
is invisible in review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YLNd5uRpQDQ8ffvEvLJ3q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019YLNd5uRpQDQ8ffvEvLJ3q
@protonspy
protonspy merged commit 8418e0b into main Aug 4, 2026
4 checks passed
@protonspy
protonspy deleted the feat/artifact-addressing branch August 4, 2026 17:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant