Skip to content

fix: make rule meta explain itself, and route improve-rule off it - #238

Merged
thecodedrift merged 3 commits into
mainfrom
fix/rule-metadata-sidecar-never-written
Sep 2, 2026
Merged

fix: make rule meta explain itself, and route improve-rule off it#238
thecodedrift merged 3 commits into
mainfrom
fix/rule-metadata-sidecar-never-written

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

The command cannot succeed today

.taskless/rule-metadata/<id>.yml is written from the meta block of a rule status response — ticketId, installationId, generatedAt, schemaVersion, keyed by rule filename. The rule service does not populate that block. The generator team confirmed it: ctx.meta is typed "set by the building step", the only assignment anywhere restores it from a serialized context that never set it either, and generatedAt appears nowhere in their tree. They are documenting the deadness at their schema rather than filling it in.

So in this CLI:

  • writeRuleMetaFiles sits behind if (status.meta) on both the create and improve paths. Never true, so .taskless/rule-metadata/ is never written.
  • rule meta <id> reads that sidecar and failed RULE_NOT_FOUND when it was absent. It is always absent. taskless rule meta could only ever fail, and it failed with a code that reads as "try a different id" for a rule sitting on disk in front of the user.

What the recipes promised

rule-meta.txt listed ".taskless/rule-metadata/<id>.yml exists" as a PRECONDITION and called itself "used internally by the rule improve recipe to fetch the ticketId needed for iteration". improve-rule.txt repeated that as a precondition and made rule meta <id> --json step 3 of 10. create-remote-rule.txt told the user create writes metadata there. delete-rule.txt and create-sg-rule.txt described the sidecar as ordinarily present.

Meanwhile the mechanism that works was documented as the fallback: rule improve --from <file> takes { ruleId, guidance, references? }, and ruleId is the ticket id the iterate endpoint is addressed by — printed as ruleId by rule create --json. The caller supplies it; nothing on disk holds it.

Direction chosen: keep the surface, make it truthful

Not removal. Three reasons:

  1. rule meta is a named part of the user-facing surface in openspec/specs/cli-rules/spec.md, and appears in cli/spec.md (--anonymous no-op) and analytics/spec.md. Deleting the command falsifies three spec documents and needs an OpenSpec change of its own; making it honest keeps all three accurate.
  2. The failure is the useful artifact. An agent that runs rule meta — from an older recipe, a pinned skill, a habit — gets told exactly why there is no data and where the ticket id actually comes from. command not found teaches nothing.
  3. If the service ever populates meta, the read and write paths are intact and start working. The write branches are kept, annotated as dead until then.

Concretely:

  • New stable code RULE_META_UNAVAILABLE, distinct from RULE_NOT_FOUND precisely because the remedies differ: there is no id that produces a sidecar. The message says the sidecar is never written by this version, says it is not specific to the id asked for, and names the working path. The sidecar is still read, so a file that exists is still reported; only the meaning of its absence changed.
  • Adding a code is explicitly allowed by cli/spec.md; nothing was renamed.

What an agent following improve-rule now does

Step 3 was "run rule meta <id> --json to get the ticketId". It is now: take the ticket id from the ruleId field of that rule's rule create --json output, or ask the user for it — and do not run rule meta, which fails for every rule. If nobody has the id, fetch improve-rule --anonymous and iterate locally. The precondition changed from "a file exists on disk" (unsatisfiable) to "you have the ticket id" (satisfiable, and stated where it comes from). The input-schema note and the RULE_NOT_FOUND row were corrected to match.

create-remote-rule now says nothing is written under .taskless/rule-metadata/ and tells the agent to record ruleId from the create output, which is the only place it appears. rule-meta was rewritten to be a redirect. rule, delete-rule, and create-sg-rule were corrected. Topic versions bumped: improve-rule v5, rule-meta v3, create-sg-rule v5, create-remote-rule v3, delete-rule v3, rule v2.

Checks

pnpm build, pnpm typecheck, pnpm lint, vitest run (1141 passed), pnpm cli check — clean, with only the 4 pre-existing no-hedging warnings in route.txt and onboard.txt.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 3m 4s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing (.prior-review.json contains only the trigger comment itself, no prior reviews, threads, or comments — this is the first review of this PR).

  • Read .prior-review.json
  • Fetch PR diff and metadata (gh pr diff 238, gh pr view 238)
  • Review all 12 changed files
  • Trace error-code paths (rules.ts, api/rules.ts, rules/files.ts) against the updated recipe docs
  • Cross-check openspec/specs/cli-rules/spec.md against the PR's own findings
  • Post findings

Summary

This is a well-scoped fix. I traced the core claim end to end: status.meta is indeed never populated by the service-facing types, writeRuleMetaFiles is dead on both create and improve, and the new RULE_META_UNAVAILABLE path in metaCommand (packages/cli/src/commands/rules.ts:624-640) is reached correctly whether or not the rule exists on disk (both new error-envelope.test.ts cases confirm this). The ruleId field really is emitted on rule create --json success (schemas/rules-create.ts:23, rules.ts:271-277), so the recipes' new guidance to source the ticket id from there instead of rule meta is accurate. delete-rule.txt's updated description of metadata removal matches deleteRuleFiles in rules/files.ts:212-229. No CLAUDE.md import/convention violations in the diff.

Two things worth a look:

[New] Inline comment on packages/cli/src/agent/improve-rule.txt:129 — the RULE_NOT_FOUND row in the improve-rule error table was rewritten to say "the service has no such ticket id," but tracing the code shows improveCommand cannot actually emit RULE_NOT_FOUND: every iterateRule API failure (including the 404 request_not_found case that row is describing) is caught and re-thrown as NETWORK_ERROR at rules.ts:436-440. An agent hitting a bad-ticket-id 404 gets NETWORK_ERROR + "report and suggest retry," not the more specific fix this row promises. Since the row was touched by this diff as part of the truthfulness cleanup, it's a good candidate to either fix in code (map the 404 to RULE_NOT_FOUND) or correct in text.

[New] Not part of this diff, but surfaced by it: openspec/specs/cli-rules/spec.md:296 ("rule create --anonymous ... 5. NOT write a metadata sidecar (the API-backed branch does)") directly contradicts this PR's own premise — the generator team confirmed the API-backed branch has never written a metadata sidecar either. The PR body explicitly argues that keeping the rule meta surface (rather than deleting it) is what keeps cli-rules/spec.md, cli/spec.md, and analytics/spec.md accurate; this line in cli-rules/spec.md is the one place that's still asserting the false behavior in writing. Not a regression from this diff (the inaccuracy predates it), but since the stated goal is spec truthfulness, it's a loose end worth a follow-up.

Everything else — the RULE_META_UNAVAILABLE error code addition, the sidecar-still-read behavior, the topic version bumps, the dead-code comments on the if (status.meta) branches, and the test updates — checked out against the actual code paths.

Comment thread packages/cli/src/agent/improve-rule.txt
thecodedrift added a commit that referenced this pull request Sep 2, 2026
Review follow-up on #236, taking both low items as one change because
the second is only worth doing as the first's mechanism.

A purge failure became a normal, reachable outcome in the previous
commit, and it landed in a catch that reported "could not be written".
That is the opposite of true: the blessed bytes ARE on disk and only the
cleanup is partial. A reader acting on it re-runs a repair that already
succeeded, or concludes the rule is unrepaired and edits it by hand. The
error string already said so, wrapped in a sentence contradicting it.

`PurgeIncompleteError` carries the surviving entries, so the caller
branches on a type rather than matching prose — which is the point: the
two failures ask the reader for opposite things ("the rule is not there"
versus "the rule is there and something stale is too"), and a call site
that could only read the message would get it wrong the first time the
wording changed. The repair notice now names what survived and says an
engine still reads it.

The second item was `DeliveryWrite.written`/`.removed`, computed and
never consumed. Resolved by deleting the shape rather than plumbing it
to a caller. Plumbing it would mean widening `writeRuleFile`'s return,
which changes call sites in `commands/rules.ts` — a file PR #238 owns
concurrently — for a value nothing needed once the failure path carries
its own detail. The data now lives where it is used.

Verified the new test bites by throwing a plain `Error` instead: it
fails, and passes again when restored. Five unrelated tests timed out at
the limit on one run and did not reproduce across four further runs,
including three consecutive runs of the two suites this change touches.
thecodedrift added a commit that referenced this pull request Sep 2, 2026
Review follow-up on #236, taking both low items as one change because
the second is only worth doing as the first's mechanism.

A purge failure became a normal, reachable outcome in the previous
commit, and it landed in a catch that reported "could not be written".
That is the opposite of true: the blessed bytes ARE on disk and only the
cleanup is partial. A reader acting on it re-runs a repair that already
succeeded, or concludes the rule is unrepaired and edits it by hand. The
error string already said so, wrapped in a sentence contradicting it.

`PurgeIncompleteError` carries the surviving entries, so the caller
branches on a type rather than matching prose — which is the point: the
two failures ask the reader for opposite things ("the rule is not there"
versus "the rule is there and something stale is too"), and a call site
that could only read the message would get it wrong the first time the
wording changed. The repair notice now names what survived and says an
engine still reads it.

The second item was `DeliveryWrite.written`/`.removed`, computed and
never consumed. Resolved by deleting the shape rather than plumbing it
to a caller. Plumbing it would mean widening `writeRuleFile`'s return,
which changes call sites in `commands/rules.ts` — a file PR #238 owns
concurrently — for a value nothing needed once the failure path carries
its own detail. The data now lives where it is used.

Verified the new test bites by throwing a plain `Error` instead: it
fails, and passes again when restored. Five unrelated tests timed out at
the limit on one run and did not reproduce across four further runs,
including three consecutive runs of the two suites this change touches.
@thecodedrift
thecodedrift force-pushed the fix/rule-metadata-sidecar-never-written branch from 6bc3944 to a9b08b1 Compare September 2, 2026 00:21
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 3m 4s —— [View job]…"
#238 (comment)

Both findings verified against the code and addressed in a9b08b1.

improve-rule.txt:129 / RULE_NOT_FOUND — confirmed: iterateRule threw a plain Error for the 404 request_not_found, and improveCommand's catch flattened every failure to NETWORK_ERROR, so the documented code was unreachable. Fixed in code rather than in the text, since a wrong ticket id is a realistic outcome of the very flow this PR introduces (source the id from rule create --json or from the user) and "re-check the id" is strictly more useful than "retry". iterateRule now throws a CLIError carrying RULE_NOT_FOUND, following the same "the code travels on the error" contract as resolveIdentity/identityFailureCode, and improveCommand reads the code off the error. Poll failures stay NETWORK_ERROR on purpose: that requestId was just issued by the CLI, so it is not an id the caller can re-check. New test packages/cli/test/api-rule-errors.test.ts covers the coded and uncoded branches, and was proved to bite by reverting the throw.

openspec/specs/cli-rules/spec.md:296 — agreed, and fixed here rather than deferred, since it was the one place still asserting the false behavior in writing. The requirement itself ("NOT write a metadata sidecar") was already correct; only the parenthetical "(the API-backed branch does)" was wrong. It now states that no branch of rule create has ever written one, and why.

pnpm build, pnpm typecheck, pnpm lint, the CLI suite (1172 tests), and pnpm cli check are all clean — check reports only the 4 pre-existing no-hedging warnings in route.txt and onboard.txt.

— AI Coding Agent

…e through it

`.taskless/rule-metadata/<id>.yml` is written from the `meta` block of a
rule status response. The service does not populate that block, so the
sidecar has never been written for any rule, in any mode, under any
tier. `rule meta` read it and failed `RULE_NOT_FOUND` — a code that
reads as "try a different id" when no id works, for rules sitting on
disk in front of the user.

Keep the command, and make its failure true. A new
`RULE_META_UNAVAILABLE` code names the actual state and the message
points at the ticket id that does drive iteration. The sidecar is still
read, so a file that exists is still reported; what changed is what its
absence means. The write branches stay with a note that they are dead
until the service populates `meta`.

The recipes promised more than the CLI can do. `improve-rule` listed
the sidecar as a PRECONDITION and made `rule meta` step 3, so an agent
following it stalled on a command that cannot succeed while the working
mechanism — the `ruleId` from `rule create --json`, which is what the
iterate endpoint is addressed by — was documented as the fallback.
Step 3 now names that id and says where it comes from. `create-remote-rule`
claimed create writes the sidecar; `delete-rule` and `create-sg-rule`
described it as ordinarily present. All corrected, topics bumped.
The improve-rule recipe documents RULE_NOT_FOUND as "the service has no
such ticket id, re-check the id from `rule create --json`", but no code
path could emit it: every iterateRule failure, including the 404
request_not_found that IS a bad ticket id, was caught and reported as
NETWORK_ERROR. An agent handed a wrong id was told to retry an id that
will never resolve.

iterateRule now throws a CLIError carrying RULE_NOT_FOUND for that 404,
following the same "the code travels on the error" contract as
resolveIdentity, and improveCommand reads the code off the error rather
than flattening every failure to NETWORK_ERROR.

Also corrects openspec/specs/cli-rules/spec.md, which asserted in a
parenthetical that the API-backed branch of `rule create` writes a
metadata sidecar. It never has, which is this change's premise.
Review feedback on #239, landed here because #239 carried the
regenerated types and this file is on this branch.

`submitRule`'s response now carries both `requestId` and a deprecated
`ruleId` with the same value. Line 175 read the deprecated one while its
sibling at 435 already read `requestId`, so one file gave two answers
about the same response.

Worth noting the two were never inconsistent through carelessness:
iterate has returned `requestId` all along, and submit only offered
`ruleId` until the service renamed the resource. The skew appeared the
moment the types were regenerated.

The local variable KEEPS its name, deliberately. It feeds this command's
own `--json` `ruleId` field, which is our published contract, read by
`improve-rule` and by any agent following it. Renaming the service's
field and renaming ours are separate decisions, and doing the second
silently as a side effect of the first would move the ground under every
recipe that reads it.

No `response.ruleId` reads remain in `src/`.
@thecodedrift
thecodedrift force-pushed the fix/rule-metadata-sidecar-never-written branch from a9b08b1 to 602d186 Compare September 2, 2026 01:10
@thecodedrift

Copy link
Copy Markdown
Member Author

We ended up merging #239 first, which made this an easier fix

@thecodedrift
thecodedrift merged commit e6c438a into main Sep 2, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the fix/rule-metadata-sidecar-never-written branch September 2, 2026 01:28
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