Commit graph

17 commits

Author SHA1 Message Date
7dc0add768 feat(research-loop-cap): give the discovery ceiling a reader, not just a sentence
The bounded-cost NFR asks for explicit ceilings on BOTH axes - max
conversation turns and max discovered dimensions. The turn axis got
MAX_CONV_TURNS, a ledger-backed reader and a PreToolUse enforcer. The
discovery axis got one sentence in Phase 4.5 prose ("append candidates only
while the whole list stays at or below maxDimensions: 8") with no constant
of its own, no reader, and no test that a run exceeding it is caught. That
is the brief_reviewer_iter_cap shape the operator decision warned about: a
cap nothing reads.

checkDimensionCeiling() is the reader, exposed on the CLI as
--check-dimensions N (exit 0 within, exit 1 rejected), and Phase 4.5 step 3
now calls it once the final list is settled instead of merely describing the
bound.

Three deliberate choices:

- The ceiling IS MAX_TOTAL_DIMENSIONS, the constant that sizes the turn
  budget. Both axes read one settings.json:16 value, so they cannot end up
  enforcing different numbers - a second constant is how that drift starts.
- An unreadable count is REJECTED ('abc', null, undefined, {}, -1, NaN,
  non-integers). A cost ceiling that waves through what it cannot measure is
  not a ceiling.
- --check-dimensions requires no run id, effort or VOYAGE_STORM_ENABLED.
  Phase 4.5 never calls the budget gate - that is why its skip-guard reads
  the flag directly - so the ceiling check must not inherit the gate's
  preconditions.

The mitigation the review already verified still holds and is unchanged:
MAX_TOTAL_DIMENSIONS bounds actual retrieval cost regardless of how many
dimensions discovery appends. What was missing was anything that FAILS on a
list over the bound, and now a run over it is rejected by exit code.

Review finding 96a3ee51152dfe72aca703f771843f2f3639e7b6 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 23:09:11 +02:00
32e20fc0dc fix(cap): move the enforcement boundary to a denial tombstone, not the turn count
allowTurn() appends BEFORE the turn runs, so during granted turn N the
ledger holds N records. The hook denied at `used >= budget`, which blocked
every tool call of the FINAL granted turn: the primitive granted B turns
and the harness permitted B-1. Worse, an exhausted run therefore always
terminated through an exit-2 tool denial instead of the graceful "cap
exhausted" exit at commands/trekresearch.md - and the prose says in as
many words that exit 2 is not exit 1, so the model was pushed out through
the one exit it is told NOT to treat as a cap.

The review recommended denying at `used > budget`. Taken alone that fixes
the count and breaks the hook: once the O_EXCL claim (previous commit)
makes a breached ledger impossible, `granted > budget` can no longer fire,
and the case this hook exists for - the loop consults the gate, is denied,
and issues the tool call anyway - would be allowed. A deny branch that
cannot be reached is a dead security claim, which is the same thing S82
removed two of rather than leave standing.

So the denial itself became a record. allowTurn() appends a tombstone
{runId, exhausted: true} when it denies for budget, and the hook denies on
the tombstone. Both properties now hold at once:

  granted == budget, no tombstone  -> turn B is in flight   -> ALLOW
  tombstone present                -> the gate already said no -> DENY
  granted  >  budget               -> breached, any cause   -> DENY

A tombstone is not a turn: readLedger reports {granted, exhausted}
separately so it can never consume budget. allowTurn short-circuits on an
existing tombstone, so a hammered gate neither re-walks every slot nor
grows the ledger. The tombstone write is best effort on purpose - the
denial is already the correct answer, so a ledger that cannot take the
record must not turn a denial into a grant.

The parallel-boundary test now asserts GRANTED turns rather than raw
ledger lines, because the denied callers legitimately add tombstones.

Review finding 8eb53458ac3efec778094f9f03b09e1cc1077a09 (MINOR).
Operator decision: tombstone over the literal recommended_action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:56:55 +02:00
156539204a fix(research-loop-cap): claim each turn slot with O_EXCL so the bound survives concurrency
countTurns-then-appendFileSync is read-then-write. N callers that all
observe used == budget-1 all decide to grant, and the bound is exceeded
by N-1. The comment above allowTurn asserted "Append-only: never
read-modify-write" and named the concurrent case - Phase 4.5/5 may spawn
several agents in a single message - as the reason it had to be. The
decision path was exactly what the comment denied, so the concurrency
claim had nothing under it.

Each grant now creates <data root>/trekresearch-loop-claims/<runId>-<slot>.claim
with flag 'wx' (O_CREAT|O_EXCL) before appending. The kernel picks the
winner per slot, slot numbers are bounded by the budget, and each can be
created exactly once - so total grants for a run cannot exceed the budget
however many callers arrive together. The ledger count now only says
where to start looking for a free slot.

Two of the three tests are deterministic and do not race anything: they
assert the invariant directly by pre-creating claims, including the state
a mid-append competitor leaves behind (ledger 7, slots 1-8 claimed, budget
8 -> deny). That matters because the third test - six real concurrent shim
processes at the boundary - passed even BEFORE the fix, since process
startup jitter serialised them. A race test that passes by luck is not
evidence, so it ships as a real-world regression guard next to the two
that are.

Stated rather than left to be discovered: claim files are empty, at most
budget per run, and never cleaned - the same standing as the ledger, which
also grows for the life of the data root. Reusing a runId across runs, or
two runIds colliding after filename sanitisation, both deny a turn, which
is the safe direction for a budget control.

Review finding 3994491ef1fdba6e0e3645b5b713cbdbdeb2b328 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:52:21 +02:00
d2b6a696bd fix(cap): fail closed when the ledger cannot be READ, in both modules
An unreadable ledger returned 0 from countTurns in BOTH the primitive and
the hook, so a run whose ledger existed but could not be read (EISDIR,
EACCES, EIO) was handed the full budget again on every call - unbounded.
research-loop-cap.mjs argues against exactly that three lines above the
code that did it, and its missing-DIRECTORY case already failed closed.
The unreadable-FILE case now agrees with it.

Only ENOENT still counts as zero turns spent: that is the legitimate
first-turn state, and the reason this cannot just throw on any read
failure.

The hook no longer carries its own countTurns. It imports the primitive's
exported readLedger(), the same way it already resolves the data root
through resolveDataRoot() - a reader and a writer with private copies of
the counting rule is how a hook ends up enforcing a different bound than
the gate it backs. In scope + cannot count now exits 2 with a message
that says counting failed, not that the budget is spent.

Fail-closed stays scoped to the loop: a test pins that an unreadable
ledger in an OUT-of-scope session still exits 0, because a PreToolUse
hook that over-blocks bricks every session on the box.

Also dropped the existsSync pre-check before the read - readFileSync's
own ENOENT carries the same information without a second syscall that
can disagree with the read that follows it.

Review finding 5e1c6230f48ead38fa77cd8f4b06bfdc2b5b7bbf (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:49:08 +02:00
def6c05384 fix(research-loop-cap): floor the turn cap before the guard, not after
A fractional TREKRESEARCH_MAX_CONV_TURNS below 1 cleared the `n <= 0`
guard on its raw value and only then floored, so '0.5' and '0.9' became
0 and the budget became 0 x MAX_TOTAL_DIMENSIONS = 0: every turn denied,
the loop silently dead rather than bounded.

README.md:229 and docs/architecture.md:15 both promise that invalid
values fall back to 3. docs/command-modes.md:42 enumerated "empty,
non-numeric, zero, or negative" and happened to sidestep the case; the
enumeration is now exhaustive about it.

Measured before: '0.5' -> 0, '0.9' -> 0, '2.7' -> 2, '' / 'abc' / '-2'
/ '0' -> 3. Measured after: '0.5' -> 3, '0.9' -> 3, '2.7' -> 2, and
'Infinity' -> 3 (it is not a cap either).

A cap of 0 is not a narrower cap, it is an off switch. The tests pin
both directions: the fraction falls back, and allowTurn cannot report a
budget of 0 under it.

Review finding fc516799e6042e246a4b62d81903ac27c2efab84 (MINOR).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LuGhWAbWyRFBFeemfhxoVv
2026-08-12 22:46:35 +02:00
6dafdf2a2a fix(research-loop-cap): resolve the data root in code so the loop can run
CLAUDE_PLUGIN_DATA is empty in the Bash tool's process env, and the Phase 5
bash snippet is the cap's only caller. resolveLedgerPath() returned null there
and allowTurn() failed closed, so the budget gate denied turn 1 of every real
run: the loop this delivery exists to bound could never spend a turn, and the
pre-registered measurement could not be run at all.

resolveDataRoot() is now the single root for everything the loop writes --
CLAUDE_PLUGIN_DATA when the harness sets it, ~/.claude/voyage when it does
not. Three consumers resolve through it, which is the point: the cap ledger,
the PreToolUse hook's scope-marker lookup, and the command's bash snippets.
A writer and a reader that resolved the root separately are what made the
enforcement hook allow unconditionally in every real run while CLAUDE.md and
docs/architecture.md called it enforcing.

Same root cause, same commit:
- Marker write and remove now share ONE absolute-path guard and one root; the
  write requires a non-empty CLAUDE_CODE_SESSION_ID before composing the path
  (unset, the marker was named `.json`, which no lookup matches and no TTL
  sweep cleans up).
- The per-turn gates resolve VOYAGE_ROOT with a plugin-cache fallback and
  reserve exit 2 for "gate could not run". Interpolating an empty
  ${CLAUDE_PLUGIN_ROOT} ran `node /lib/...` -> exit 1, which the contract read
  as "privacy gate says no" -- an unsatisfiable rewrite loop no query could
  clear.

Two now-unreachable deny branches are removed rather than left as dead safety
claims (allowTurn's no_plugin_data_dir; the hook's uncountable-ledger deny).
The fail-closed stance stays where it is still real: a ledger that cannot be
WRITTEN denies the turn.

Verified end-to-end through the real bash snippets and the real hook with both
variables stripped and HOME sandboxed: marker written under the fallback root,
8 turns spent, 9th denied, hook exits 2, and exits 0 again after removal.
Note: the fallback exit-2 branch fires against the installed v5.9.1 cache,
which predates lib/util/research-loop-cap.mjs -- correct behaviour, and it
clears when the plugin is reinstalled.

Review findings 2670c10a, fbd6d534, 93550dfb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vPSXe88qp5aqWUqbDNWoF
2026-08-12 22:26:46 +02:00
abc5bd8967 feat(util): add stateful default-off research loop cap 2026-08-09 14:23:24 +02:00
440594f1b2 feat(eval): SKAL-1·4b offline gold-scored output eval
Scores committed agent-run fixtures against the golden corpus at
(file, rule_key) granularity, building on the deterministic coordinator
contract (4a). Offline: committed reviewer payloads, no live agent spawn,
no LLM, no network (the LLM-in-the-loop grading is the separate 4c tier).

- lib/review/gold-scorer.mjs: scoreFindings (precision/recall/f1 at
  (file,rule_key) granularity, line+severity ignored) + scoreVerdict; pure,
  with documented vacuous-set conventions.
- tests/fixtures/bakeoff-rich/runs/run-perfect.json: committed run that
  reproduces all 5 seeded gold findings through runContract.
- tests/lib/gold-eval.test.mjs: the scoring RUN (precision/recall/f1 = 1.0,
  verdict == expected_verdict BLOCK, nothing suppressed/skipped).
- lib/util/test-census.mjs: third census category (goldEval) — a scoring run
  is neither behavior coverage nor a doc-pin; honest-count invariant now 3-way.
- docs/eval-corpus/README.md: 4b moved from Future hardening to implemented.

Suite 809 -> 822 (820/0/2). gold-scorer covers TP+FP+FN+degenerate paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJQYC5vpkJWxndS55vQQZ6
2026-06-30 09:00:33 +02:00
494a344700 docs(voyage): S19 — leanness: honest test census + prune 3 redundant prose-pins
Closes devils-advocate audit §Top changes #8 (MINOR×MED). Reports the
behavior-test count separately from the doc-consistency prose-pin count so the
cited total no longer oversells behavior coverage, and removes the clearest
reword-fragile redundancies. Conservative scope (operator-chosen): no genuine
regression guard dropped.

1. Suite census (report separately). lib/util/test-census.mjs +
   tests/lib/test-census.test.mjs walk tests/**/*.test.mjs and bucket top-level
   test() declarations into behavior vs doc-consistency-pins (bucket per file,
   regex PIN_FILE_RE), asserting the two sum to the total so neither can drift
   silently. Split emitted as a t.diagnostic: behavior=601 doc-consistency-pins=69.
   Metric = top-level declarations; node:test's runtime total counts subtests
   too and is therefore >= it.

2. Conservative prune (3 tests, each provably subsumed):
   - "trekexecute.md still parses v1.7 plan schema" — tautological OR-chain;
     real coverage = the plan_version:1.7 template pin + plan-validator /
     plan-schema behavior tests.
   - "CLAUDE.md mentions all six pipeline commands" — hardcoded six-string list
     subsumed by the filesystem-driven "commands table mentions every
     commands/*.md file" structural pin.
   - "CLAUDE.md mentions /trekcontinue command" — same subsumption.
   Each removal leaves an in-place note (why + where coverage lives).

3. doc-consistency.test.mjs header now documents the structural-invariant vs
   prose/existence-pin distinction so new pins land in the right kind.

TDD: census test written failing-first (ERR_MODULE_NOT_FOUND before
lib/util/test-census.mjs existed). Suite 699->698 (696 pass / 2 skip / 0 fail):
-3 prune +2 census. plugin validate passes (1 accepted warning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqBYc8Ltrk7LipyJmGxXiB
2026-06-19 13:52:30 +02:00
3683ae0070 chore(voyage): release v5.0.0 — remove bespoke playground + /trekrevise + Handover 8; render produced artifacts to HTML + link, annotate via /playground
The v4.2/v4.3 bespoke playground SPA (~388 KB), the /trekrevise command,
Handover 8 (annotation → revision), the supporting lib/ modules
(anchor-parser, annotation-digest, markdown-write, revision-guard), the
Playwright e2e suite, and the @playwright/test / @axe-core/playwright
devDeps are removed. A browser walkthrough found the playground borderline
unusable, and it duplicated the official /playground plugin's
document-critique / diff-review templates.

In their place: scripts/render-artifact.mjs — a small, zero-dependency
renderer that turns a brief/plan/review .md into a self-contained,
design-system-styled, zero-network .html (frontmatter folded into a
<details> block). /trekbrief, /trekplan, and /trekreview call it on their
last step and print the file:// link; to annotate, run /playground
(document-critique) on the .md and paste the generated prompt back.

Resolves the v4.3.1-deferred findings as moot (their target files are
deleted). npm test green: 509 tests, 507 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:05:07 +02:00
cc7228ea17 feat(voyage): add markdown-write.mjs + revision-guard.mjs + forward-compat policy comments — v4.2 Step 1
- lib/util/markdown-write.mjs: serializeFrontmatter (subset matches frontmatter.mjs parser), atomicWriteMarkdown (single tmp+rename, body bytes verbatim), readAndUpdate (read+mutate+write).
- lib/util/revision-guard.mjs: revisionGuard(path, mutator, validator) — backup -> mutate -> validate -> restore-on-fail. Extracted from /trekrevise prompt so rollback can be unit-tested.
- 12 tests for markdown-write, including 6-key source_annotations round-trip + walk-all-fixtures regression.
- 6 tests for revision-guard: applied/rolled-back/mutator-failed/sha256 stability/pre-existing-bak abort.
- Forward-compat policy comments in 3 validators (brief/plan/review) — non-functional pin against future strict-key refactors.

Pass: 508/510 (was 490; +18 net from v4.2 Step 1, 2 skipped Docker)
2026-05-09 12:48:40 +02:00
c909466c99 feat(voyage)!: bulk content rewrite ultra -> voyage/trek prose [skip-docs]
Sed-pipeline (16 patterns, longest-match-first) sweeper residuelle ultra*-treff
i prose, command-narrativ, agent-prompts, hook-kommentarer, doc-prosa.

Pipeline-utvidelser fra V4-prompten:
- BSD-syntax [[:<:]]ultra[[:>:]] istedenfor \bultra\b (BSD sed mangler \b)
- 6 compound-patterns for ultraplan/ultraexecute/ultraresearch/ultrabrief/
  ultrareview/ultracontinue uten -local-suffiks
- ultra*-stats glob -> trek*-stats glob
- Linje-eksklusjon redusert til ultra-cc-architect (Q8); session-state-
  eksklusjonen var over-protektiv
- File-eksklusjon utvidet til settings.json, package.json, plugin.json,
  hele .claude/-treet (gitignored + V5-territorium)

Q8-undantak holdt: architecture-discovery.mjs + project-discovery.mjs urort.
Filnavn-konvensjon holdt: .session-state.local.json + *.local.* preservert.

Manuell narrative-fix: tests/lib/agent-frontmatter.test.mjs linje 10
mangled "/ultra*-local" til "/voyage*-local" (ingen slik kommando finnes);
korrigert til "/trek*".

Residualer utenfor scope (V5 handterer): package.json + .claude-plugin/
plugin.json (Step 12-14 versjons-bump). .claude/* er gitignored
spec-historikk med tilsiktet BEFORE/AFTER-narrativ.

Part of voyage-rebrand session 3 (Wave 4 / Step 10).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 15:08:20 +02:00
c7c6d8d09d feat(ultraplan-local): cleanup util (Bug 4 dry-run/confirm/idempotent) [skip-docs]
Step 9 of v3.4.1 plan.

lib/util/cleanup.mjs (new):
- cleanupProject(projectDir, {dryRun, confirm}) reads
  .session-state.local.json via validateSessionState; refuses unless the
  parsed status is strictly equal to 'completed' (per risk-assessor
  Critical 2 — no soft-match on similar statuses).
- Default dryRun: true; refuses dryRun: false without explicit
  confirm: true (CLEANUP_REQUIRES_CONFIRM).
- Removes .session-state.local.json + NEXT-SESSION-PROMPT.local.md
  candidates; ENOENT counts as "already absent" so the function is
  idempotent.
- No CLI shim — invoked from /ultracontinue --cleanup via inline ESM
  (Step 10 wires this in).

tests/lib/cleanup.test.mjs (new):
- 7 cases: dry-run lists candidates without deleting; confirm-mode
  deletes both files; idempotent re-run signals CLEANUP_NO_STATE_FILE
  after fully cleaned; refuses on status: in_progress
  (CLEANUP_NOT_COMPLETED); refuses dryRun: false without confirm
  (CLEANUP_REQUIRES_CONFIRM); defaults to dry-run; missing state file
  returns CLEANUP_NO_STATE_FILE.

Internal scaffolding consumed by Step 10 (Phase 0.5 wire-up). User-facing
docs land with Step 14.

Tests 348 -> 355 (+7).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 17:41:06 +02:00
65b8fbc16f feat(ultraplan-local): add autonomy-gate state machine + manifest schema extensions for skip_commit_check + memory_write
Step 4 of plan-v2 (ultra-pipeline-speedup).

lib/util/autonomy-gate.mjs (NEW)
  5-state machine {idle, gates_on, auto_running, paused_for_gate, completed}
  honoring the --gates flag intent. Re-entry to completed is idempotent.
  Includes CLI shim:
    node lib/util/autonomy-gate.mjs --state X --event Y [--gates true|false]
  → JSON: { ok, next_state | error }, exit 0 on success / 1 on invalid.

lib/parsers/manifest-yaml.mjs (EXTENDED)
  OPTIONAL_KEYS list adds skip_commit_check and memory_write — both boolean,
  default false when absent, MANIFEST_OPTIONAL_TYPE when non-boolean.
  Existing REQUIRED_KEYS contract untouched; existing 9 manifest tests
  still pass.

Tests: 19 (autonomy-gate) + 8 (manifest-schema-extensions) = 27 new.

[skip-docs]
2026-05-04 06:28:47 +02:00
Kjell Tore Guttormsen
5c2911253b refactor(ultraplan-local): extract atomicWriteJson to lib/util
Three changes in one commit:

1. NEW lib/util/atomic-write.mjs — exports atomicWriteJson(path, obj),
   the canonical tmp+rename pattern. Reused by pre-compact-flush.mjs and
   (in subsequent steps) by the new session-state writer.

2. NEW tests/lib/atomic-write.test.mjs — 4 unit tests covering
   round-trip, no-orphan-tmp, overwrite-atomic, pretty-print formatting.

3. REFACTOR hooks/scripts/pre-compact-flush.mjs — replace the inline
   atomicWrite() with the imported atomicWriteJson(). Also fixes a
   pre-existing syntax error (leading whitespace + stray --resume token
   outside the comment block) that silently broke the hook from v3.1.0
   onward — PreCompact runtime is fail-open and swallowed the error.
   File reformatted with standard zero-indent JS.

163 → 167 tests, 0 fail.

Step 2 of /ultracontinue v3.3.0 (project 2026-05-01-ultracontinue).
2026-05-01 20:21:15 +02:00
Kjell Tore Guttormsen
02e2fe9c9d feat(graceful-handoff): 2.0 — migrate to skills/ with disable-model-invocation [skip-docs]
Step 1 of v2.0 plan. Hard cut from commands/ to skills/ per Anthropic
recommendation for new plugins. Frontmatter sets disable-model-invocation:
true and pins model: claude-sonnet-4-6. Docs (README, CLAUDE.md, root
README) deferred to Step 9 per plan.
2026-05-01 05:45:26 +02:00
Kjell Tore Guttormsen
dd83afa8ca feat(ultraplan-local): Spor 1 wave 1 — lib/parsers + 66 tests grønn
7 nye moduler:
- lib/util/result.mjs — Result-shape m/ ok/fail/combine helpers
- lib/util/frontmatter.mjs — håndruller YAML-frontmatter-parser (subset, zero deps)
- lib/parsers/plan-schema.mjs — v1.7 step-regex + forbidden-heading-deteksjon (Fase/Phase/Stage/Steg)
- lib/parsers/manifest-yaml.mjs — per-step Manifest YAML-ekstraksjon m/ regex-validering
- lib/parsers/project-discovery.mjs — finn brief/research/architecture/plan/progress i prosjektmappe
- lib/parsers/arg-parser.mjs — $ARGUMENTS for alle 4 commands m/ flag-schema
- lib/parsers/bash-normalize.mjs — løftet fra hooks/scripts/pre-bash-executor.mjs

6 test-filer (66 tester totalt) — alle grønn:
- frontmatter (CRLF/BOM, scalars, lister, indent-rejection)
- plan-schema (positive Step-form, negative Fase/Phase/Stage/Steg, numbering, slicing)
- manifest-yaml (extraction, parsing, regex-validering, missing-key detection)
- project-discovery (sortert research, architecture-detection, phase-requirements)
- arg-parser (boolean/valued/multi-value flags, kvotert positional, ukjente flag)
- bash-normalize (\${x}/\\\\evasion, ANSI-stripping, full canonicalize-pipeline)

Forbereder Wave 2 (validators) og Spor 1-wiring inn i commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 05:35:28 +02:00