From 19a672e560bd96bfcf3256e181e67f56a75f3766 Mon Sep 17 00:00:00 2001 From: Beefiker Date: Fri, 24 Jul 2026 12:01:51 +0900 Subject: [PATCH 1/5] docs: design workspace safety and continuity --- ...pace-context-steering-compaction-design.md | 314 ++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md diff --git a/docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md b/docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md new file mode 100644 index 0000000..6045f7a --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md @@ -0,0 +1,314 @@ +# Superloopy Workspace Safety and Continuity Design + +## Goal + +Make Superloopy safer across repository boundaries and more observable across long-running research and compaction without making loop engineering harder to start or continue. + +The user should still be able to invoke `loopy ` or `loopy team ` without selecting a budget, approving another wave, or understanding Superloopy's internal state. Repository identity and steering retries are enforced because they protect correctness. Context and research budgets are advisory because they inform the user and the loop engineer without interrupting useful work. + +## Approved Decisions + +This design contains four implementation slices: + +1. Resolve one canonical workspace root and bind every new plan to that worktree. +2. Measure actual additional context and report advisory research targets and observed usage without blocking. +3. Make structured steering idempotent before adding any general batch format. +4. Restore a compact semantic recovery capsule after host compaction and test its invariants. + +Portable export anonymization, `session:`, generic batch steering, a search broker, and any budget-based denial or approval prompt are explicitly out of scope. + +## Product Principles + +- **Easy by default.** Budget targets never stop a worker, query, wave, or loop. Crossing a target records and reports an overage; it does not ask the user what to do. +- **Durable state beats transcript prose.** Repository identity, completion state, current proof obligations, and observed research usage come from Superloopy state and artifacts, not a compacted conversation summary. +- **Enforce correctness, observe cost.** A repository mismatch and a duplicate steering retry can corrupt state, so they are prevented. Cost targets are guidance and telemetry only. +- **No new dependency.** Exact character and UTF-8 byte counts use platform APIs. Token counts are clearly labeled estimates. +- **No export work yet.** Internal paths retain their current operational fidelity, and the existing portable report remains unchanged. + +## 1. Canonical Workspace Root and Repository Binding + +### Current problem + +Loop paths are built by joining the received `cwd` with `.superloopy`. Starting in a repository subdirectory therefore misses a plan created at the repository root. Plans also have no checkout identity, so copied or misplaced state can be mutated without detecting that it belongs to another worktree. + +The existing command-trust implementation already derives a checkout identity, but that implementation is private to command trust and does not normalize the root used by loop commands or hooks. + +### Root resolution + +Introduce one shared workspace identity module used by loop CLI commands and every hook path that reads or mutates loop state. + +Given a starting directory: + +1. Resolve symlinks for the nearest existing directory. +2. If the directory is inside a Git worktree, select that worktree's root. +3. Otherwise, select the nearest ancestor containing a `.superloopy` directory. +4. If neither exists, use the resolved starting directory. + +A nested Git repository owns its own state even if an outer directory contains `.superloopy`. A scoped session remains under the selected root at `.superloopy/sessions/`. + +The loop dispatcher resolves the root once before calling a loop command. Hook handlers also resolve from `payload.cwd` before reading plans, ledgers, evidence, handoffs, attempt state, or audit state. Installation and doctor root-selection behavior are not changed by this loop-specific resolver. + +### Checkout identity + +Extract the reusable identity logic from command trust into the shared module. + +For Git worktrees, the identity is a SHA-256 digest over: + +- identity schema version; +- canonical worktree root; +- canonical worktree Git directory; +- a random local UUID persisted in that Git directory. + +For non-Git workspaces, the digest uses the canonical root and stable filesystem identity fields already used by command trust. + +The binding does not include `HEAD`, branch name, remote URL, or repository name. A plan must survive commits, branch switches, remote changes, and repository renames when the underlying worktree is the same. + +New plans use plan schema version 2 and include: + +```json +{ + "repositoryBinding": { + "version": 1, + "kind": "git-worktree", + "identity": "", + "rootLabel": "superloopy" + } +} +``` + +`rootLabel` is a non-authoritative basename used only for understandable diagnostics. No absolute path is added to portable reports. + +### Validation and legacy plans + +Every state-changing loop operation verifies the current identity against the plan binding while holding the same plan lock used by the mutation. A mismatch fails before any plan, ledger, evidence, handoff, audit, or receipt mutation. + +Read-only status may report a mismatch so the loop engineer can explain it, but it must not describe the plan as safe to resume. Commands that appear read-only but append to the ledger or write a report are treated as mutations. + +Version 1 plans are reported as `legacy_unbound`. They are not silently bound by the first arbitrary mutation. The CLI provides one explicit bind/migration command that: + +- verifies the selected workspace root; +- validates that all stored plan paths remain relative and confined; +- upgrades the plan atomically to version 2; +- records `repository_bound` in the ledger. + +The loop engineer receives the exact migration command and executes it itself. Normal `loopy` usage does not require the user to inspect fingerprints or answer another question. + +### Safety boundary + +Repository binding protects Superloopy-owned state, evidence paths, recovery capsules, and dispatch bookkeeping. It is not a filesystem sandbox and cannot stop an agent from directly editing another path through a host tool. Prompts continue to tell workers the canonical target root, but that instruction is not represented as stronger enforcement than it is. + +## 2. Additional Context Measurement + +### Measurement point + +Measure the final string placed in `hookSpecificOutput.additionalContext`, after all context sections and the metrics line are assembled. Use a bounded fixed-point formatter so the displayed counts describe the final emitted string, including the metrics line itself. + +Each measured injection includes: + +```text +Superloopy context cost: 2,468 chars · 2,470 UTF-8 bytes · ~620 estimated tokens +``` + +The metric is compact and model-visible. It requires no separate UI, settings screen, or state mutation. + +Exact fields: + +- `characters`: JavaScript Unicode code-point count, not UTF-16 code units; +- `utf8Bytes`: `Buffer.byteLength(text, "utf8")`; +- `estimatedTokens`: a dependency-free heuristic; +- `estimator`: a stable label identifying the heuristic version. + +Token cost is never presented as exact. The initial estimator may use a documented mixed-text heuristic and should round to avoid false precision. Tests verify stable arithmetic and labeling, not equivalence with any proprietary tokenizer. + +Empty hook output has zero cost and remains empty; Superloopy does not inject a metrics-only message. + +### Surfaces + +Cost measurement applies to Superloopy-produced additional context from: + +- `UserPromptSubmit`; +- `SessionStart`; +- compaction recovery; +- any Superloopy `PreToolUse` or stop feedback that uses `additionalContext`. + +It does not estimate the user's prompt, AGENTS instructions, skill contents loaded separately by the host, subagent transcripts, web results, or the model's full context window. The label and documentation must state this boundary. + +## 3. Advisory Research Budget + +### Intent + +Research budgets help the user see amplification and help the loop engineer avoid accidental maximum-saturation behavior. They are not gates. + +No Superloopy hook or CLI command may deny, rewrite, delay, or request user approval solely because a worker, query, or wave target was reached. This applies to Codex even though Codex can intercept `Agent` through `PreToolUse`. + +### Automatic profile + +The research workflow chooses one advisory profile from the research frame without asking the user: + +- focused codebase; +- focused web; +- mixed investigation; +- explicit exhaustive or due-diligence. + +Each profile provides target counts for total workers, queries, and waves. These are planning targets, not maximums. The exact profile values belong in the implementation plan and tests, but the runtime contract is fixed here: + +- a target may be exceeded automatically when unresolved criteria or new material leads justify it; +- every overage records a short machine-readable reason; +- the final synthesis reports target versus observed usage; +- no overage changes completion authority or proof requirements. + +The current research skill's worker floors and recursive expansion language are replaced with target-oriented guidance. The skill still supports exhaustive research when explicitly requested, but ordinary research no longer treats spending the maximum available quota as success. + +### Observation sources + +Observed values carry provenance: + +- **workers:** derived from Superloopy handoffs and fleet state when those records exist; otherwise labeled self-reported by the orchestrator; +- **waves:** derived from research expansion journal entries; +- **queries:** counted in the research journal by the orchestrator because hosted search tools are not universally visible to Superloopy hooks. + +Unobserved usage is reported as `unknown`, never as zero. Estimated or self-reported query counts are not described as enforced or complete. + +Budget telemetry lives in the research evidence root, with the synthesis containing a compact summary such as: + +```text +Research usage: workers 9/8 target · queries 37/32 target · waves 3/3 target +Overage: +1 worker to resolve conflicting primary sources +``` + +The core goals plan does not need a write on every hook or search. This preserves existing guidance-only hook behavior and avoids turning ordinary prompts into hidden plan mutations. + +## 4. Idempotent Structured Steering + +### Current problem + +`SUPERLOOPY_STEER` accepts one typed object and immediately applies it. A host retry of the same `add_goal` request can therefore create duplicate goals. The current per-operation plan lock prevents concurrent lost updates but does not provide retry identity. + +### Request identity + +Every accepted steering request receives an idempotency key: + +1. use an explicit valid `requestId` when supplied; +2. otherwise derive a digest from host, session id, turn id, active scope, and normalized directive; +3. when the host omits a turn id, use the normalized directive digest with a short retry window so immediate hook retries deduplicate while a later intentional repeat remains possible. + +The key is scoped to the repository binding and active Superloopy session. The same key in another repository or scoped session is unrelated. + +### Application protocol + +Under the plan lock: + +1. load and verify the repository-bound plan; +2. check the bounded recent steering receipt set; +3. return the prior result with `deduplicated: true` when the request is a retry; +4. validate the full typed directive against a cloned plan; +5. apply it once; +6. store its receipt in the same atomic plan write; +7. append a ledger entry carrying the idempotency key and result. + +If the plan write succeeds but the ledger append is interrupted, a retry detects the plan receipt, avoids repeating the mutation, and repairs the missing ledger entry when possible. + +Receipt storage is bounded by count and age. Explicit request IDs remain protected for the supported retention window, which is documented and tested rather than implied to be permanent. + +### Supported shape + +Existing single-operation directives remain compatible. Add optional `requestId` without adding a general `ops` array. + +A future research budget adjustment, if needed, is one typed `research_observe` or `research_note_overage` operation containing its related fields. Because budgets are advisory, it never authorizes or blocks execution. + +Generic multi-operation batch steering is deferred until a real caller needs multiple unrelated plan mutations to succeed together. Ledger append atomicity across files is not misrepresented as a filesystem transaction. + +## 5. Compaction Recovery Capsule + +### Runtime behavior + +When `SessionStart` reports `source: "compact"` and an active bound plan exists, Superloopy emits a compact recovery capsule even when general `SUPERLOOPY_AUTO_CONTEXT` is off. + +The capsule is derived from durable state and contains only: + +- repository binding status and non-sensitive root label; +- active Superloopy session and plan mode; +- current goal and unresolved criterion references; +- aggregate completion state; +- next deterministic guide action; +- outstanding handoff summary; +- advisory research usage when a research journal exists; +- the rule that only Superloopy's deterministic gate authorizes completion. + +Optional narrative history, completed-goal prose, full evidence lists, and full ledger events are omitted first. The capsule has a fixed character budget and includes its own measured context-cost line. + +For `source: "compact"`, a context-pressure marker in the pre-compaction transcript must not suppress recovery. The marker still prevents continuation loops on ordinary prompt and stop paths. Recovery is read-only and must not increment attempts, consume a wave, append a ledger event, or rewrite the plan. + +If no plan exists, the compact SessionStart remains quiet. If state is corrupt or repository binding mismatches, the capsule reports the safe diagnostic and does not tell the agent to resume mutation. + +### Semantic projection + +Expose a small pure function that converts durable state into a structured recovery projection. Rendering is separate. Tests compare projections for meaning and use only minimal string assertions for required warnings and size limits. + +The projection includes stable fields for: + +- binding status; +- session id; +- active goal id; +- unresolved criterion refs; +- aggregate completion; +- next action; +- outstanding handoff count and ids; +- research target, observed, and provenance fields when present. + +### Required regression tests + +Add six focused cases: + +1. **Semantic preservation:** the recovery projection before and after compact contains the same binding, active goal, unresolved proof, completion, handoff, and advisory budget facts. +2. **Bounded rendering:** an oversized brief, ledger, and completed history cannot evict repository identity, current proof obligations, completion state, or next action. +3. **Durable truth wins:** transcript or prompt text claiming completion cannot override an incomplete durable plan. +4. **Session isolation:** compact recovery for one scoped session never includes another session's goal, handoff, or research usage. +5. **Read-only idempotence:** repeated compact recovery leaves plan, ledger, attempts, handoffs, and research journals byte-identical. +6. **Pressure-marker recovery:** `source: "compact"` still emits the capsule when the transcript tail contains a context-pressure marker, while existing non-compact suppression behavior remains unchanged. + +Existing continuation, engineer-trigger, hook, report-portability, and completion-gate tests remain the surrounding regression floor. Do not add a broad line-count golden suite. + +## Data and Compatibility + +- Plan schema moves from version 1 to version 2 only for repository binding. +- Context metrics are rendered output and do not require plan migration. +- Advisory research usage stays in research evidence artifacts, not the core plan. +- Steering receipts add a bounded optional plan field with backward-compatible readers. +- Existing `SUPERLOOPY_STEER` objects without `requestId` remain accepted. +- Existing evidence reports remain repo-relative and do not gain anonymous IDs. +- No environment flag turns advisory targets into blockers. + +## Failure Handling + +- Workspace identity cannot be resolved: fail the state mutation with the resolved starting path and no partial writes. +- Binding mismatch: report expected/current fingerprints in abbreviated form and the safe next action; never auto-rebind. +- Legacy plan: provide the exact bind command for the loop engineer to execute. +- Context estimator fails: emit context without a cost line rather than suppressing required loop guidance. +- Research observation unavailable: show `unknown` with provenance, not zero. +- Steering receipt state is corrupt: fail the steering request closed without applying a mutation; ordinary loop status remains available. +- Compact state is unreadable: inject a bounded diagnostic capsule and do not claim that work is complete. + +## Validation + +Implementation validation must include: + +- focused unit tests for workspace root/identity and steering receipts; +- the six compaction semantic tests; +- hook tests proving context metrics are included only when real context exists; +- research-skill contract tests proving targets are advisory and contain no deny/approval language; +- child-directory CLI integration proving root and plan reuse; +- copied-state mismatch integration proving mutation refusal; +- legacy-plan bind migration and path-confinement tests; +- existing hook, continuation, loop gate, report, plugin packaging, and doctor tests; +- `npm pack --dry-run` or the existing package-retention validation so new source/tests/docs required at runtime are not omitted. + +## Delivery Order + +1. Shared workspace root and checkout identity, plan v2 binding, legacy bind migration. +2. Steering idempotency using the bound repository/session scope. +3. Pure context measurement and compact recovery projection/rendering. +4. Compaction hook behavior and six semantic tests. +5. Advisory research profiles, journal telemetry, and final usage reporting. + +Each slice should preserve the existing deterministic completion floor and pass its focused tests before the next slice begins. From 660f3c981d1c133e1a11f225d035963c87858f4c Mon Sep 17 00:00:00 2001 From: Beefiker Date: Fri, 24 Jul 2026 12:29:01 +0900 Subject: [PATCH 2/5] docs: plan workspace safety implementation --- .../2026-07-24-workspace-safety-continuity.md | 430 ++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md diff --git a/docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md b/docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md new file mode 100644 index 0000000..e93ce38 --- /dev/null +++ b/docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md @@ -0,0 +1,430 @@ +# Workspace Safety and Continuity Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bind Superloopy plans to a canonical worktree, make steering retries idempotent, meter injected context, restore durable loop meaning after compaction, and replace research saturation floors with non-blocking usage targets. + +**Architecture:** A shared workspace identity module resolves the loop root and supplies the checkout fingerprint used by plan v2 and command trust. Store reads verify bound plans by default while a narrow inspection path supports status and explicit legacy binding. Hook rendering gains dependency-free context metering and a pure compaction projection; steering stores bounded receipts in the plan; research targets remain skill-level evidence telemetry and never become runtime gates. + +**Tech Stack:** Node.js 20 ESM, built-in `node:crypto`/`node:fs`/`node:path`, `node:test`, existing atomic JSON and file-lock helpers, Markdown skill contracts. + +## Global Constraints + +- No new dependency. +- Repository mismatch and duplicate steering retries fail safely; research targets never block, rewrite, delay, or request approval. +- Existing `SUPERLOOPY_STEER` objects without `requestId` remain compatible. +- Empty hook output remains empty and receives no metrics-only injection. +- Compaction recovery is read-only and ignores context-pressure suppression only for `SessionStart source=compact`. +- Portable export anonymization, search brokering, generic steering batches, and budget enforcement remain out of scope. +- Release version is `0.13.0`. + +--- + +### Task 1: Canonical Workspace Identity + +**Files:** +- Create: `src/workspace-identity.js` +- Create: `test/workspace-identity.test.js` +- Modify: `src/plan-trust.js` + +**Interfaces:** +- Produces: `resolveWorkspaceRoot(start): string`, `checkoutIdentity(root): Promise`, `createRepositoryBinding(root): Promise`, `bindingMatches(root, binding): Promise`. +- Consumes: existing atomic JSON writer only through callers; this module owns the Git-dir UUID marker. + +- [ ] **Step 1: Write failing root and identity tests** + +Cover Git root discovery from a child directory, nearest nested Git ownership, non-Git `.superloopy` ancestor fallback, same-worktree stable identity, and distinct worktree identities. + +```js +const child = join(repo, "src", "nested"); +assert.equal(resolveWorkspaceRoot(child), realpathSync(repo)); +assert.equal(await checkoutIdentity(repo), await checkoutIdentity(child)); +assert.notEqual(await checkoutIdentity(repo), await checkoutIdentity(otherRepo)); +``` + +- [ ] **Step 2: Run the focused test and verify failure** + +Run: `node --test test/workspace-identity.test.js` + +Expected: FAIL because `src/workspace-identity.js` does not exist. + +- [ ] **Step 3: Implement the shared identity module** + +Use ancestor walking without shelling out. Resolve `.git` directories and `gitdir:` files, prefer the nearest Git root over an outer `.superloopy`, and fall back to the nearest `.superloopy` ancestor or the resolved start. + +```js +export function resolveWorkspaceRoot(start) { + const origin = canonicalExistingDirectory(start); + const git = findNearestGitWorktree(origin); + if (git !== null) return git.root; + return findNearestStateRoot(origin) ?? origin; +} +export async function checkoutIdentity(root) { + const canonicalRoot = resolveWorkspaceRoot(root); + const git = findNearestGitWorktree(canonicalRoot); + const value = git === null + ? nonGitIdentity(canonicalRoot) + : { version: 1, kind: "git", root: canonicalRoot, gitDir: git.gitDir, id: await checkoutUuid(git.gitDir) }; + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} +export async function createRepositoryBinding(root) { + return { version: 1, kind: findNearestGitWorktree(root) ? "git-worktree" : "path", identity: await checkoutIdentity(root), rootLabel: basename(root) }; +} +export async function bindingMatches(root, binding) { + return validBinding(binding) && timingSafeEqualText(binding.identity, await checkoutIdentity(root)); +} +``` + +- [ ] **Step 4: Reuse the module in command trust** + +Remove the duplicate private Git-dir and checkout-identity functions from `src/plan-trust.js`. Normalize the trust-store key through `resolveWorkspaceRoot(cwd)` and call the exported `checkoutIdentity()`. + +- [ ] **Step 5: Run focused tests** + +Run: `node --test test/workspace-identity.test.js test/golden-audit.test.js test/loop-gates.test.js` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/workspace-identity.js src/plan-trust.js test/workspace-identity.test.js +git commit -m "feat(runtime): resolve canonical workspace identity" +``` + +### Task 2: Plan v2 Repository Binding and Legacy Migration + +**Files:** +- Create: `src/repository-binding.js` +- Create: `test/repository-binding.test.js` +- Modify: `src/store.js` +- Modify: `src/loop.js` +- Modify: `src/cli.js` +- Modify: `src/help.js` +- Modify: `src/begin.js` +- Test: `test/loop.test.js` +- Test: `test/cli.test.js` + +**Interfaces:** +- Consumes: Task 1 `resolveWorkspaceRoot`, `createRepositoryBinding`, `bindingMatches`. +- Produces: `inspectRepositoryBinding(cwd, plan)`, `requireRepositoryBinding(cwd, plan)`, `bindLegacyLoop(cwd, argv)`, `readPlanUnchecked(cwd, scope)`. + +- [ ] **Step 1: Write failing binding tests** + +Test that new plans are version 2 and bound, child-directory CLI status finds the root plan, a copied v2 plan refuses mutation, status reports the mismatch, legacy v1 mutation refuses with the exact bind command, and explicit bind validates confined relative plan paths before upgrading. + +```js +assert.equal(created.plan.version, 2); +assert.equal((await statusLoop(child)).plan.repositoryBinding.identity, created.plan.repositoryBinding.identity); +await assert.rejects(nextLoop(copiedRepo), /repository binding mismatch/i); +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `node --test test/repository-binding.test.js test/loop.test.js test/cli.test.js` + +Expected: FAIL on plan version/root reuse/bind command. + +- [ ] **Step 3: Add binding inspection and store read modes** + +`readPlanUnchecked()` parses and validates shape only. `readPlan()` calls it and requires a current binding. `inspectRepositoryBinding()` returns `bound`, `legacy_unbound`, `mismatch`, or `invalid` with abbreviated fingerprints and a safe next command. + +```js +export async function readPlan(cwd, scope) { + const plan = await readPlanUnchecked(cwd, scope); + await requireRepositoryBinding(cwd, plan); + return plan; +} +``` + +- [ ] **Step 4: Create bound plans and canonicalize runtime roots** + +Set plan `version: 2` and `repositoryBinding: await createRepositoryBinding(cwd)`. Resolve the root before every loop CLI dispatch and in `beginLoop()` guide rendering. Do not change doctor/install root semantics. + +- [ ] **Step 5: Implement explicit legacy bind** + +Add `superloopy loop bind [--session-id ID] --confirm-current-root --json`. Under the goals lock, read unchecked, require version 1, validate `briefPath`, `evidencePath`, `goalsPath`, and `ledgerPath` as relative confined paths, attach the v2 binding, write once, and append `repository_bound`. + +- [ ] **Step 6: Make status diagnostic-safe** + +`statusLoop()` uses unchecked read plus inspection. For a non-bound plan it returns `ok:false`, the plan summary, and `binding` diagnostic without constructing a resume guide. Hook callers render the exact bind/mismatch diagnostic rather than suggesting a new plan. + +- [ ] **Step 7: Run focused tests** + +Run: `node --test test/workspace-identity.test.js test/repository-binding.test.js test/loop.test.js test/cli.test.js test/engineer.test.js test/hooks.test.js` + +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add src/repository-binding.js src/store.js src/loop.js src/cli.js src/help.js src/begin.js test/repository-binding.test.js test/loop.test.js test/cli.test.js +git commit -m "feat(loop): bind plans to canonical workspaces" +``` + +### Task 3: Idempotent Steering Receipts + +**Files:** +- Create: `src/steering-receipts.js` +- Create: `test/steering-idempotency.test.js` +- Modify: `src/hooks.js` +- Modify: `src/loop.js` +- Test: `test/hooks.test.js` +- Test: `test/golden-hooks.test.js` + +**Interfaces:** +- Consumes: bound plan reads from Task 2. +- Produces: `steeringRequestKey(payload, directive, nowMs)`, `findSteeringReceipt(plan, key, nowMs)`, `recordSteeringReceipt(plan, receipt)`, and `applySteering(cwd, directive, scope, request)`. + +- [ ] **Step 1: Write failing retry tests** + +Test explicit `requestId`, Codex session/turn-derived keys, no-turn short-window fallback, scoped-session isolation, repository isolation, later intentional repeats, bounded receipt pruning, and ledger repair after a simulated missing entry. + +```js +await runUserPromptSubmitHook(payload); +const retry = JSON.parse(await runUserPromptSubmitHook(payload)); +assert.equal(retry.deduplicated, true); +assert.equal((await readPlan(repo)).goals.length, 2); +``` + +- [ ] **Step 2: Run tests and verify duplicate failure** + +Run: `node --test test/steering-idempotency.test.js` + +Expected: FAIL because the repeated `add_goal` creates a third goal. + +- [ ] **Step 3: Implement normalized request keys and bounded receipts** + +Accept optional safe `requestId`. Otherwise hash host/session/turn/scope/normalized directive. Without a turn id, bucket retries into a documented short time window. Store at most 128 receipts and prune entries older than seven days. + +- [ ] **Step 4: Apply steering once under the plan lock** + +Move dispatch inside one `withFileLock(goalsPath(...))` section. Read the bound plan, return the stored result for a retry, apply the typed mutation to the in-memory plan, attach the receipt, perform one atomic plan write, then append the keyed ledger entry. Keep single-operation syntax; do not add `ops`. + +- [ ] **Step 5: Run steering and hook tests** + +Run: `node --test test/steering-idempotency.test.js test/hooks.test.js test/golden-hooks.test.js` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/steering-receipts.js src/hooks.js src/loop.js test/steering-idempotency.test.js test/hooks.test.js test/golden-hooks.test.js +git commit -m "fix(steering): deduplicate retried directives" +``` + +### Task 4: Additional-Context Cost Meter + +**Files:** +- Create: `src/context-cost.js` +- Create: `test/context-cost.test.js` +- Modify: `src/hooks.js` +- Modify: `src/pre-tool-use.js` +- Test: `test/engineer.test.js` +- Test: `test/pre-tool-use.test.js` + +**Interfaces:** +- Produces: `measureContext(text) -> {characters, utf8Bytes, estimatedTokens, estimator}`, `appendContextCost(text): string`, `formatMeasuredAdditionalContext(event, text): string`. + +- [ ] **Step 1: Write failing metering tests** + +Cover ASCII, Korean, emoji/code-point counting, UTF-8 bytes, stable estimator label, fixed-point final counts, empty output, and PreToolUse/engineer contexts. + +```js +const measured = measureContext("루피🙂"); +assert.equal(measured.characters, 3); +assert.equal(measured.utf8Bytes, Buffer.byteLength("루피🙂")); +assert.match(appendContextCost("body"), /estimated tokens/); +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `node --test test/context-cost.test.js` + +Expected: FAIL because the module does not exist. + +- [ ] **Step 3: Implement dependency-free measurement** + +Count Unicode code points with `Array.from(text).length`, bytes with `Buffer.byteLength`, and tokens with estimator `mixed-v1`: `ceil(asciiCodePoints / 4 + nonAsciiCodePoints / 1.5)`. Iterate the appended metric line up to five times until the displayed character/byte/token values match the final string. + +- [ ] **Step 4: Route all Superloopy additional context through the formatter** + +Replace hook-local JSON formatting and PreToolUse additional-context construction with the shared formatter. Preserve `permissionDecision` and denial fields. Empty context remains an empty string. + +- [ ] **Step 5: Run focused tests** + +Run: `node --test test/context-cost.test.js test/hooks.test.js test/engineer.test.js test/pre-tool-use.test.js test/golden-hooks.test.js` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/context-cost.js src/hooks.js src/pre-tool-use.js test/context-cost.test.js test/engineer.test.js test/pre-tool-use.test.js test/golden-hooks.test.js +git commit -m "feat(hooks): meter injected context cost" +``` + +### Task 5: Compaction Recovery Capsule + +**Files:** +- Create: `src/compaction-recovery.js` +- Create: `test/compaction-recovery.test.js` +- Modify: `src/hooks.js` +- Modify: `hooks/session-start.json` +- Test: `test/session-start-migration.test.js` +- Test: `test/golden-continuation.test.js` + +**Interfaces:** +- Consumes: Task 2 bound status, Task 4 measured-context formatter. +- Produces: `buildRecoveryProjection({status, guide, handoffs, researchUsage})`, `renderRecoveryCapsule(projection, {maxChars})`. + +- [ ] **Step 1: Write the six failing semantic tests** + +Implement the approved cases: semantic preservation, bounded rendering, durable incomplete truth, scoped isolation, byte-identical read-only repetition, and pressure-marker recovery. + +```js +const before = await snapshotState(repo); +const output = await runSessionStartHook({hook_event_name:"SessionStart", source:"compact", cwd:repo, transcript_path:pressureTranscript}); +assert.match(JSON.parse(output).hookSpecificOutput.additionalContext, /Aggregate complete: no/); +assert.deepEqual(await snapshotState(repo), before); +``` + +- [ ] **Step 2: Run tests and verify failure** + +Run: `node --test test/compaction-recovery.test.js` + +Expected: FAIL because compact SessionStart is suppressed or has no capsule. + +- [ ] **Step 3: Implement pure projection and bounded renderer** + +Project binding status, session/mode, active goal, unresolved criterion refs, aggregate completion, next action, and outstanding handoffs. Render mandatory fields first and append optional details only while `maxChars` remains. Never derive completion from transcript text. + +- [ ] **Step 4: Wire compact SessionStart** + +For `source === "compact"`, bypass only the transcript pressure-marker early return, load bound state, and inject the capsule even when `SUPERLOOPY_AUTO_CONTEXT` is off. No plan remains quiet. Corrupt/mismatched state emits a bounded non-resume diagnostic. + +- [ ] **Step 5: Run compaction and continuation tests** + +Run: `node --test test/compaction-recovery.test.js test/session-start-migration.test.js test/golden-continuation.test.js test/hooks.test.js` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add src/compaction-recovery.js src/hooks.js hooks/session-start.json test/compaction-recovery.test.js test/session-start-migration.test.js test/golden-continuation.test.js +git commit -m "feat(hooks): restore loop meaning after compaction" +``` + +### Task 6: Advisory Research Targets + +**Files:** +- Modify: `skills/superloopy-research/SKILL.md` +- Modify: `test/plugin.test.js` +- Modify: `docs/superloopy-host-contract.md` + +**Interfaces:** +- Produces: four automatic advisory profiles and a required final `Research usage` summary. +- No runtime hook consumes or enforces these targets. + +- [ ] **Step 1: Write failing skill-contract tests** + +Assert that the skill contains focused-codebase, focused-web, mixed, and exhaustive profiles; target/observed/provenance language; automatic continuation with a reason; and `unknown` rather than zero. Assert that it contains no “pause and ask to extend”, scaling floor, worker minimum, query minimum, or budget-denial language. + +- [ ] **Step 2: Run the contract test and verify failure** + +Run: `node --test test/plugin.test.js` + +Expected: FAIL on the current scaling floors and extension approval. + +- [ ] **Step 3: Replace saturation floors with advisory profiles** + +Use these initial targets: + +| profile | workers | queries | waves | +| --- | ---: | ---: | ---: | +| focused-codebase | 4 | 12 | 2 | +| focused-web | 6 | 32 | 2 | +| mixed | 8 | 40 | 3 | +| exhaustive | 15 | 80 | 5 | + +Crossing a target continues automatically when unresolved criteria or material leads remain, records one reason in `expansion-log.md`, and reports target/observed/provenance in `SYNTHESIS.md`. Missing observation is `unknown`. + +- [ ] **Step 4: Document host truth** + +State that Codex worker interception exists but Superloopy intentionally does not use it for budget denial, and hosted queries are not universally observable. + +- [ ] **Step 5: Run contract and packaging tests** + +Run: `node --test test/plugin.test.js test/docs.test.js` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add skills/superloopy-research/SKILL.md test/plugin.test.js docs/superloopy-host-contract.md +git commit -m "docs(research): make usage targets advisory" +``` + +### Task 7: Release Version and Full Validation + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `.codex-plugin/plugin.json` +- Modify: `.claude-plugin/plugin.json` +- Modify: `.claude-plugin/marketplace.json` +- Modify: `docs/superloopy-design-audit.md` + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: synchronized release version `0.13.0` and package-retained runtime. + +- [ ] **Step 1: Add the design-audit row** + +Record the four shipped slices, advisory-only budget boundary, validation anchors, and deferred export/batch/search-broker scope. + +- [ ] **Step 2: Bump the authoritative version and synchronize manifests** + +Run: `node scripts/sync-version.mjs 0.13.0` + +Expected: JSON/manifests report `0.13.0`. + +- [ ] **Step 3: Run focused release checks** + +Run: `node --test test/sync-version.test.js test/plugin.test.js test/doctor.test.js test/doctor-packed.test.js` + +Expected: PASS. + +- [ ] **Step 4: Run the full suite** + +Run: `npm test` + +Expected: all tests pass with zero failures. + +- [ ] **Step 5: Validate the package and diff** + +Run: `npm pack --dry-run` + +Expected: new runtime source files and updated hooks/skills are listed; test-only files and ignored plan/spec documents need not be packaged. + +Run: `git diff --check` + +Expected: no whitespace errors. + +- [ ] **Step 6: Commit release metadata** + +```bash +git add package.json package-lock.json .codex-plugin/plugin.json .claude-plugin/plugin.json .claude-plugin/marketplace.json docs/superloopy-design-audit.md +git commit -m "chore(release): bump version to 0.13.0" +``` + +- [ ] **Step 7: Review publish scope** + +Run: `git status -sb` + +Run: `git diff --stat origin/main...HEAD` + +Expected: only the approved design, implementation, tests, documentation, and version files appear. From 87482b048d40bb4f08854678206ec802e9114a02 Mon Sep 17 00:00:00 2001 From: Beefiker Date: Fri, 24 Jul 2026 12:46:36 +0900 Subject: [PATCH 3/5] feat: preserve workspace continuity --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- docs/superloopy-design-audit.md | 1 + docs/superloopy-file-audit.md | 14 +++- docs/superloopy-host-contract.md | 5 ++ docs/superloopy-loop-golden-set.md | 10 +-- package-lock.json | 4 +- package.json | 2 +- skills/superloopy-research/SKILL.md | 36 +++++----- src/cli.js | 23 ++++++- src/compaction-recovery.js | 38 +++++++++++ src/context-cost.js | 42 ++++++++++++ src/engineer.js | 14 ++++ src/help.js | 1 + src/hooks.js | 62 ++++++++++++++--- src/loop.js | 37 ++++++++-- src/plan-trust.js | 65 +++--------------- src/pre-tool-use.js | 3 +- src/repository-binding.js | 75 ++++++++++++++++++++ src/steering-receipts.js | 40 +++++++++++ src/store.js | 4 ++ src/workspace-identity.js | 102 ++++++++++++++++++++++++++++ test/compaction-recovery.test.js | 82 ++++++++++++++++++++++ test/context-cost.test.js | 25 +++++++ test/crew-lines.test.js | 4 ++ test/doctor.test.js | 8 +-- test/plugin.test.js | 5 ++ test/repository-binding.test.js | 96 ++++++++++++++++++++++++++ test/steering-idempotency.test.js | 25 +++++++ test/workspace-identity.test.js | 38 +++++++++++ 31 files changed, 760 insertions(+), 107 deletions(-) create mode 100644 src/compaction-recovery.js create mode 100644 src/context-cost.js create mode 100644 src/repository-binding.js create mode 100644 src/steering-receipts.js create mode 100644 src/workspace-identity.js create mode 100644 test/compaction-recovery.test.js create mode 100644 test/context-cost.test.js create mode 100644 test/repository-binding.test.js create mode 100644 test/steering-idempotency.test.js create mode 100644 test/workspace-identity.test.js diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dbd47f5..2bf6db9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "superloopy", "source": "./", "description": "Strict evidence loop harness: repo-local loop state, success criteria, append-only ledgers, evidence-gated completion, plus skills (loop, research, frontend, clone) and a read-only/worker crew. Works on Claude Code and Codex from one repo.", - "version": "0.12.4", + "version": "0.13.0", "license": "MIT", "keywords": [ "claude-code", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 00a92be..2c610f8 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.4", + "version": "0.13.0", "description": "Lightweight loop harness with strict evidence gates — Claude Code edition.", "homepage": "https://github.com/beefiker/superloopy#readme", "repository": "https://github.com/beefiker/superloopy", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index fd5f430..43f7af2 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.4", + "version": "0.13.0", "description": "Lightweight loop harness with strict evidence gates.", "homepage": "https://github.com/beefiker/superloopy#readme", "repository": "https://github.com/beefiker/superloopy", diff --git a/docs/superloopy-design-audit.md b/docs/superloopy-design-audit.md index 98338eb..1f4c22f 100644 --- a/docs/superloopy-design-audit.md +++ b/docs/superloopy-design-audit.md @@ -9,6 +9,7 @@ This doctor-verified audit records Superloopy's own design decisions. It exists | `gate-shape-compatibility` | Existing users may have strict review or matrix gate JSON, while broad UI work needs proof proportional to a concrete target, affected owner, and changed claim. | Superloopy keeps both gate shapes and their exact unscoped legacy surface literals accepted through local validators. New scoped rows provide all four of `target`, `owner`, `claims`, and `scopeReason`; `target` is a singular portable `{id, platform, environment}` object whose slug rejects aggregate vague tokens. Owner/claim floors select compatible proof, composite surfaces retain attributable owners, and each scoped surface target-ID/platform/owner slice is singular with resolved artifacts independent from other surface slices. Adversarial and matrix coverage rows reference artifacts or contract-bound proof rows without declaring another slice. Matrix coverage binds a surface or adversarial row with the same `contractRef`; HTTP/data artifacts remain confined to their families. | `src/review-gate.js`, `src/matrix-gate.js`, `docs/superloopy-gate-notes.md`, `test/golden-review-gate.test.js`, `test/golden-matrix-gate.test.js`, and `src/artifacts.js`. | | `actor-field-policy` | Exact external role literals made compatibility too specific. | Actor fields now require non-empty identity text instead of a hard-coded role name. | `src/review-gate.js` and review-gate golden tests. | | `native-naming` | Public code, docs, and tests should describe Superloopy behavior directly. | Modules, tests, docs, CLI flags, and metadata use Superloopy-owned terms. | `test/docs.test.js` and `docs/superloopy-gate-notes.md`. | +| `workspace-safety-continuity` | Long-running loops must resume in the correct checkout, survive host compaction, expose their injected-context cost, and avoid repeating retried steering without turning research targets into user friction. | Plan v2 binds to a canonical local worktree; legacy binding is explicit and confined; hook context carries exact character/byte plus estimated-token cost; structured steering stores bounded receipts; compact SessionStart rebuilds mandatory state from the plan; research profiles report advisory target/observed usage and never block or request expansion approval. Generic batches, search brokering, and export anonymization remain deferred. | `src/workspace-identity.js`, `src/repository-binding.js`, `src/steering-receipts.js`, `src/context-cost.js`, `src/compaction-recovery.js`, `skills/superloopy-research/SKILL.md`, `test/workspace-identity.test.js`, `test/steering-idempotency.test.js`, `test/context-cost.test.js`, and `test/compaction-recovery.test.js`. | | `recorded-thresholds` | Later analysis should compare against earlier judgments. | `docs/superloopy-loop-golden-set.md` stores score history and command evidence in one tracked place. | `docs/superloopy-loop-golden-set.md` and `test/docs.test.js`. | | `model-policy` | Crew lanes need explicit cost/depth pins, legacy upgrades must not overwrite user edits, and hosts may not attest the model they actually launch. | `model-policy.json` defines ordered complete model/effort/tier tuples. Managed installation resolves them before launch, adopts only complete hash-known legacy fleets, detects wrapper/plugin split-brain, and reports `model_unverified` unless the host attests role and model. Availability failure never causes a post-launch retry or model switch. | `legacy-agent-manifests.json`, `src/legacy-agents.js`, `src/model-resolution.js`, `src/managed-agents.js`, `src/installed-model-policy.js`, `src/wrapper-check.js`, `test/model-install.test.js`, `test/installed-model-policy.test.js`, and `test/wrapper-check.test.js`. | | `crew-lines` | Crew handoffs should feel alive without weakening evidence discipline. | Known crew lanes can emit original terminal-state lines in presentation output only; persisted state and gate authority stay mechanical. | `docs/superloopy-crew-lines.md`, `src/crew-lines.js`, `test/crew-lines.test.js`, and `test/fleet.test.js`. | diff --git a/docs/superloopy-file-audit.md b/docs/superloopy-file-audit.md index 7941dd2..427993b 100644 --- a/docs/superloopy-file-audit.md +++ b/docs/superloopy-file-audit.md @@ -31,7 +31,7 @@ Superloopy is its own lightweight loop harness: one small CLI, repo-local `.supe | `.github/assets/usopp.png` | README crew-card image for the usopp QA agent. | Documentation image only; no executable plugin logic. | | `.github/assets/zoro.png` | README crew-card image for the zoro reviewer agent. | Documentation image only; no executable plugin logic. | | `.github/workflows/test.yml` | GitHub Actions workflow that runs the `node --test` suite on an OS (ubuntu/windows/macos) × Node (20/22) matrix for every push and PR. | Uses SHA-pinned Actions and Node's own test discovery (no shell glob) so the same suite runs identically on every platform; read-only, no runtime plugin logic. | -| `.gitignore` | Keeps runtime state, logs, coverage, dependencies, root `build/`, Superpowers plan/spec docs, and generated Astro build output out of source control. | Superloopy runtime ignore set plus root builds, local Superpowers planning artifacts, and web build artifacts. | +| `.gitignore` | Keeps runtime state, logs, coverage, dependencies, root `build/`, untracked local Superpowers plan/spec drafts, and generated Astro build output out of source control. | Superloopy runtime ignore set plus root builds, local planning drafts, and web build artifacts; explicitly approved design/plan records may be force-tracked and audited. | | `DESIGN.md` | Static landing-page design token contract: reference-inspired editorial colors, typography, spacing, component states, motion, and depth for the Superloopy web preview. | Web presentation design contract only; no plugin runtime behavior and no vendored external design system. | | `LICENSE` | MIT license for this repo. | Standard license text. | | `README.es.md` | Spanish public product overview and managed Codex model-lifecycle summary. | Documentation-only locale surface; mirrors the root install and routing behavior without adding runtime logic. | @@ -150,6 +150,18 @@ Superloopy is its own lightweight loop harness: one small CLI, repo-local `.supe | `web-superloopy/public/_nuxt/ty4JC5yh.js` | Vendored deferred orbit-runtime chunk that boots the hero orbit iframe scene. | Web presentation payload for the vendored orbit runtime only; no executable plugin logic and no package dependency. | | `web-superloopy/public/_nuxt/w4_qzAi3.js` | Vendored deferred orbit-runtime chunk that boots the hero orbit iframe scene. | Web presentation payload for the vendored orbit runtime only; no executable plugin logic and no package dependency. | | `web-superloopy/public/_payload.json` | Vendored orbit-runtime hydration payload consumed by the hero orbit iframe. | Web presentation payload only; no executable plugin logic. | +| `docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md` | Approved implementation plan for repository binding, context metering, idempotent steering, compaction recovery, advisory research targets, and the 0.13.0 release. | Tracked planning record for this release only; adds no runtime dependency or external workflow authority. | +| `docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md` | Approved product and runtime design for safe workspace continuity without budget blocking. | Original Superloopy design record; explicitly defers generic batches, search brokering, and export anonymization. | +| `src/compaction-recovery.js` | Pure durable-state projection and bounded renderer for post-compaction loop recovery. | Uses Superloopy plan/guide/fleet facts only; never trusts transcript completion prose or mutates state. | +| `src/context-cost.js` | Dependency-free Unicode, UTF-8 byte, and estimated-token meter for emitted additional context. | Labels token values as estimates and does not claim host-wide context measurement. | +| `src/repository-binding.js` | Repository-binding inspection and explicit confined legacy-plan migration. | Uses local checkout identity and atomic Superloopy state; no network or remote-name authority. | +| `src/steering-receipts.js` | Bounded request-key and receipt helpers for retry-safe structured steering. | Superloopy-local idempotency only; does not add a generic batch protocol. | +| `src/workspace-identity.js` | Canonical worktree root and checkout fingerprint shared by plan binding and command trust. | Uses built-in filesystem/crypto APIs and a local Git-dir UUID; branch and remote names are not authority. | +| `test/compaction-recovery.test.js` | Six semantic regressions for durable, bounded, isolated, read-only pressure-safe recovery. | Tests Superloopy state meaning rather than brittle prompt line counts. | +| `test/context-cost.test.js` | Unicode/byte/estimate and empty-output coverage for additional-context metering. | Validates the documented heuristic boundary without a tokenizer dependency. | +| `test/repository-binding.test.js` | Plan v2, child-directory reuse, copied-state refusal, and confined legacy-binding integration coverage. | Uses disposable local state and CLI processes only; no remote identity authority. | +| `test/steering-idempotency.test.js` | Regression proving a retried structured directive mutates a plan once. | Uses local hook and plan state only. | +| `test/workspace-identity.test.js` | Git, non-Git, child-directory, and distinct-checkout identity coverage. | Creates disposable local repositories and performs no network operations. | | `web-superloopy/public/basis/basis_transcoder.js` | Basis/KTX2 texture transcoder used by the vendored orbit runtime and eye scene. | Web presentation decoder asset only; no plugin runtime behavior. | | `web-superloopy/public/basis/basis_transcoder.wasm` | Basis/KTX2 texture transcoder used by the vendored orbit runtime and eye scene. | Web presentation decoder asset only; no plugin runtime behavior. | | `web-superloopy/public/draco/draco_decoder.wasm` | Draco geometry decoder used by the landing WebGL model loaders. | Web presentation decoder asset only; no plugin runtime behavior. | diff --git a/docs/superloopy-host-contract.md b/docs/superloopy-host-contract.md index bbc2c36..664c5ff 100644 --- a/docs/superloopy-host-contract.md +++ b/docs/superloopy-host-contract.md @@ -75,6 +75,11 @@ inert on Claude. As with every hook, completion authority never rests here — i deterministic in-process floor (`loop check`/`review`/`finish`); the hook layer is advisory on both hosts. +Research worker/query/wave profiles are also advisory on every host. Codex can expose local +`Agent` dispatches to `PreToolUse`, but Superloopy intentionally does not deny dispatch based on a +usage target. Hosted searches are not universally observable by hooks, so query totals may be +journal-derived or `unknown`; they are never presented as enforced limits. + ## What Superloopy cannot verify (advisory limits) Superloopy cannot confirm, from inside a hook, that the host: diff --git a/docs/superloopy-loop-golden-set.md b/docs/superloopy-loop-golden-set.md index fb1735a..aeeb3af 100644 --- a/docs/superloopy-loop-golden-set.md +++ b/docs/superloopy-loop-golden-set.md @@ -57,14 +57,11 @@ Total: 100 points. | `.github/assets/usopp.png` | Audit coverage. | Must remain a README documentation image, not runtime plugin logic. | | `.github/assets/zoro.png` | Audit coverage. | Must remain a README documentation image, not runtime plugin logic. | | `.github/workflows/test.yml` | Cross-platform test CI and GitHub Actions. | Must run `node --test` (no shell glob) across the ubuntu/windows/macos × Node 20/22 matrix with SHA-pinned actions and no runtime side effects. | -| `.gitignore` | Doctor runtime-boundary ignored samples and installed-cache docs coverage. | `.superloopy/`, logs, coverage, dependencies, OS noise, Codex marketplace metadata, `build/`, `docs/superpowers/plans/`, `docs/superpowers/specs/`, and generated Astro output must stay out of source control. | +| `.gitignore` | Doctor runtime-boundary ignored samples and installed-cache docs coverage. | `.superloopy/`, logs, coverage, dependencies, OS noise, Codex marketplace metadata, `build/`, unapproved `docs/superpowers/plans/` and `docs/superpowers/specs/` drafts, and generated Astro output stay ignored; explicitly approved plan/spec records may be tracked when audited here. | | `DESIGN.md` | Static landing-page design token contract. | Must keep the web preview's reference-inspired colors, typography, spacing, component states, motion, and depth explicit before UI edits. | | `LICENSE` | Audit coverage and reviewability check. | Must remain a source file with no runtime implementation content. | -| `README.es.md` | `test/docs.test.js`, audit coverage. | Must provide the Spanish locale and align managed Codex materialization, compatibility, caching, and restart behavior with the root README. | -| `README.ja.md` | `test/docs.test.js`, audit coverage. | Must provide the Japanese locale and align managed Codex materialization, compatibility, caching, and restart behavior with the root README. | -| `README.ko.md` | `test/docs.test.js`, audit coverage. | Must provide native Korean lifecycle wording while preserving exact model names, flags, paths, numbers, and commands. | +| `README.es.md`, `README.ja.md`, `README.ko.md`, `README.zh-CN.md` | `test/docs.test.js`, audit coverage. | Must align localized managed-Codex materialization, compatibility, caching, and restart behavior with the root README while preserving exact technical tokens and native wording. | | `README.md` | `test/docs.test.js` public-doc assertions. | Must describe both hosts plus Codex install-time model resolution, 24-hour reuse, explicit GPT-5.5 compatibility, conservative unknown handling, restart rules, no post-launch switch, and read-only doctor refresh. | -| `README.zh-CN.md` | `test/docs.test.js`, audit coverage. | Must provide the Simplified Chinese locale and align managed Codex materialization, compatibility, caching, and restart behavior with the root README. | | `SECURITY.md` | Hashgraph catalog readiness. | Must document vulnerability reporting without publishing exploit details or secrets. | | `agents/franky.md` | Audit coverage. | Must define the Claude bounded executor subagent and the SUPERLOOPY_EVIDENCE receipt. | | `agents/jinbe.md` | Audit coverage. | Must define the Claude final gate-review subagent and the SUPERLOOPY_EVIDENCE receipt. | @@ -83,6 +80,7 @@ Total: 100 points. | `docs/superloopy-loop-golden-set.md` | `test/docs.test.js` golden-set assertions. | Must list every Git-visible file, score each accepted run, and keep threshold history append-only. | | `docs/superloopy-model-policy-claude.md` | `test/docs.test.js` model-policy assertions, audit coverage. | Must record the Claude model aliases and the advisory steering-not-proof rule mapped from the Codex policy. | | `docs/superloopy-model-policy.md` | `test/docs.test.js`, `test/doctor.test.js`. | Must record ordered complete Codex tuples, preferred Terra/Sol/Luna pins, explicit GPT-5.5 compatibility, and pre-launch-only routing while treating model choice as steering, not proof. | +| `docs/superpowers/plans/2026-07-24-workspace-safety-continuity.md`, `docs/superpowers/specs/2026-07-24-workspace-context-steering-compaction-design.md` | Approved plan/design records and audit coverage. | Must preserve the accepted workspace binding, advisory context metering, idempotent steering, compaction recovery, and deferred export-anonymization boundaries. | | `examples/qt-kanban/CMakeLists.txt` | Original Superloopy demo build code; fresh Qt configure/build/CTest. | Must require exact Qt 6.11.1, C++17, the reusable module, native app, and Quick tests. | | `examples/qt-kanban/DESIGN.md` | Original-prose Northstar design contract; Qt theme and visual QA. | Must keep the approved tokens, responsive thresholds, state rules, functional Board return-to-overview result, prototype acceptance boundary, and explicit persistence/Undo exclusion. | | `examples/qt-kanban/scripts/audit-macos-accessibility.sh` | Original Superloopy shell and Swift code; native macOS AX evidence. | Must verify a real card's exported role, name, focused state, AXPress action, and resulting detail-drawer activation. | @@ -451,6 +449,7 @@ Total: 100 points. | `src/cli.js` | CLI, model-install, installed-policy, doctor, plugin, and crew-line tests. | Must expose `--refresh-models` and deterministic no-query `--compat` on installs, expose read-only `doctor --refresh-models`, and preserve existing loop/hook/bin dispatch. | | `src/comparison-similarity.js` | Doctor comparison tests. | Must compare code-shaped files only when an explicit comparison path is provided. | | `src/continuation.js` | `test/golden-continuation.test.js`. | Must drive bounded continuation toward evidence-backed completion and mark blocked (never complete) on a cap or stall. | +| `src/compaction-recovery.js`, `src/context-cost.js`, `src/repository-binding.js`, `src/steering-receipts.js`, `src/workspace-identity.js` | Focused continuity tests and hook tests. | Must preserve durable semantics, report context cost without blocking, reject copied state, deduplicate steering, and resolve one canonical checkout identity. | | `src/crew-lines.js` | `test/crew-lines.test.js`, `test/fleet.test.js`. | Must generate original deterministic supported-catalog lines only for known terminal crew handoffs and format them without mutating persisted state. | | `src/design-audit.js` | Doctor design-audit tests. | Must fail missing sections, decisions, or incomplete guards. | | `src/doctor-skills.js` | `test/doctor.test.js`, `test/doctor-review-feedback.test.js`, `node src/cli.js doctor --json`. | Must require every shipped skill directory, validate each `SKILL.md` frontmatter name, and return structured failures for unreadable skill paths. | @@ -494,6 +493,7 @@ Total: 100 points. | `test/cli-evidence.test.js` | `npm test`. | Must cover public evidence commands and finalization behavior end to end. | | `test/cli.test.js` | `npm test`. | Must cover managed Codex installs, user-file conflict/force behavior, model control help, pre-mutation help exits, symlinked execution, and existing loop smoke paths. | | `test/concurrency.test.js` | `npm test`. | Must prove shared locks serialize/reclaim/fail closed and managed rollback never deletes a same-content replacement inode or hides backup-cleanup failure. | +| `test/compaction-recovery.test.js`, `test/context-cost.test.js`, `test/repository-binding.test.js`, `test/steering-idempotency.test.js`, `test/workspace-identity.test.js` | Focused `node --test` continuity suite. | Must prove six compaction invariants, transparent advisory metering, checkout binding, replay-safe steering, and canonical workspace identity. | | `test/crew-lines.test.js` | `npm test`. | Must prove crew completion lines are original deterministic localized presentation, pending/unknown lanes stay silent, and CLI status remains visible. | | `test/docs.test.js` | `npm test`. | Must keep localized interactive-content scope, structured gate target/artifact guidance, target-aware audits/goldens, evidence-helper inventory, design authority, dependency approval/action boundaries, and the Qt Kanban prototype boundary aligned with enforced behavior. | | `test/frontend-qt-contract.test.js` | `node --test test/frontend-qt-contract.test.js`. | Must prove explicit routing, Qt ownership/style contracts, native validation, and the absence of dead Kanban navigation affordances or fake success UI. | diff --git a/package-lock.json b/package-lock.json index 16d98ea..82df526 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "superloopy", - "version": "0.12.4", + "version": "0.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "superloopy", - "version": "0.12.4", + "version": "0.13.0", "license": "MIT", "bin": { "superloopy": "src/cli.js" diff --git a/package.json b/package.json index a4d4405..9586ca8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "superloopy", - "version": "0.12.4", + "version": "0.13.0", "description": "A lightweight loop harness for Codex and Claude Code with strict evidence gates.", "type": "module", "bin": { diff --git a/skills/superloopy-research/SKILL.md b/skills/superloopy-research/SKILL.md index b83561b..2a5041d 100644 --- a/skills/superloopy-research/SKILL.md +++ b/skills/superloopy-research/SKILL.md @@ -1,6 +1,6 @@ --- name: superloopy-research -description: Use only after explicit Codex `$superloopy:superloopy-research` or Claude Code `/superloopy:superloopy-research` invocation, a research task started with a leading `loopy` or `루피` (such as `loopy research`), or an active Superloopy loop explicitly routing a research deliverable here. Maximum-saturation Superloopy research orchestration — parallel read-only research lanes across codebase, web, official docs, and OSS repos; a recursive EXPAND loop driven by leads workers return as message text; empirical verification by running code; a self-enforcing claim ledger; cited synthesis and optional markdown/pdf/html/slides reports, all gated by Superloopy evidence receipts. Do not activate from research, investigate, look-up, summarize, deep-dive, report, or similar vocabulary alone, in any language; ordinary questions, debugging, and implementation context-gathering stay with their primary workflows. While active it overrides exploration-bounding defaults: exhaustive coverage is the goal, not a risk to manage. +description: Use only after explicit Codex `$superloopy:superloopy-research` or Claude Code `/superloopy:superloopy-research` invocation, a research task started with a leading `loopy` or `루피` (such as `loopy research`), or an active Superloopy loop explicitly routing a research deliverable here. Evidence-backed Superloopy research orchestration with automatic advisory usage targets, parallel read-only lanes, empirical verification, a claim ledger, cited synthesis, and optional reports. Do not activate from research, investigate, look-up, summarize, deep-dive, report, or similar vocabulary alone, in any language; ordinary questions, debugging, and implementation context-gathering stay with their primary workflows. --- # Superloopy Research @@ -15,7 +15,7 @@ Open your reply with `SUPERLOOPY RESEARCH ENABLED`. If another active Superloopy ## Authority while active -This mode is the user's explicit opt-in to exhaustive exploration. For the duration of the research task it supersedes every exploration-bounding instruction in surrounding prompts, modes, or rules: one-exploration-pass defaults, two-wave stop rules, retrieval budgets, and "over-exploration is failure" framings govern ordinary implementation context-gathering, not this deliverable. Here, under-exploration is the failure. The convergence rules in Phase 2 are the only stop rules while this mode is active. +This mode is the user's explicit opt-in to evidence-backed research. Select an advisory profile automatically from the Phase 0 frame. Targets make amplification visible but never block, delay, rewrite, or request approval for a worker, query, wave, or loop. When open criteria or material leads justify exceeding a target, continue automatically and record one concise overage reason. Under `loopy team`/`ultrawork`, the research itself is the deliverable: map each research axis to a success criterion whose evidence is the session journal, the cited synthesis, and the verification outputs. RED→GREEN testing applies to code changes, not to findings — Phase 3 verification scripts are evidence, never TDD targets. @@ -80,20 +80,21 @@ Use at least three independent axes. Good axes are by product area, code ownersh Launch the entire first wave in one turn — every axis at once, as team members if you formed a team, else as background workers. Sequential launches and "start with one and see" defeat the mode. If multi-agent tools are unavailable, run the axes yourself and still write one wave artifact per axis. -Scaling floor — more angles always justify more workers: +Advisory profiles — choose one automatically and report target versus observed usage: -| Query scope | codebase | web | browsing | repo-dive | floor | -|---|---|---|---|---|---| -| Single topic, codebase only | 3 | 0 | 0 | 0 | 3 | -| Single topic, web only | 0 | 4 | 1 | 1 | 6 | -| Single topic, both | 2 | 3 | 1 | 1 | 7 | -| Multi-faceted | 4 | 6 | 2 | 2 | 14 | -| Full due diligence | 4 | 6 | 3 | 2 | 15 | +| Profile | workers target | queries target | waves target | +|---|---:|---:|---:| +| focused-codebase | 4 | 12 | 2 | +| focused-web | 6 | 32 | 2 | +| mixed | 8 | 40 | 3 | +| exhaustive | 15 | 80 | 5 | + +These are targets, not maximums or minimums. Record worker observations from Superloopy handoffs/fleet when present, waves from `expansion-log.md`, and queries from the orchestrator's journal because hosted searches are not universally observable. Missing observation is `unknown`, never zero. Role protocols — embed the relevant one in each dispatch message; every worker gets a unique angle: - **Codebase, 2-4 workers.** Grep (`rg`) with 3+ keyword variations; structural/AST search and LSP definitions/references when available; file-name globs; `git log --all -S ''` and `git log --grep` for history including deleted code. Cross-validate hits across tools. Report absolute or repo-relative paths, patterns with `file:line`, and how findings connect. -- **Web, 3-6 workers.** At least 10 distinct web-search queries per worker, each with a different operator or angle (see Search craft); fetch the full page for every result that matters — snippets lie. Query documentation indexes with 3+ phrasings per known library. Use code-search engines and `gh search code|repos|issues` for real-world usage. Discover official docs via the sitemap (`/sitemap.xml`), then targeted pages. +- **Web lanes.** Vary queries by operator or angle (see Search craft); fetch the full page for every result that matters because snippets can omit qualifications. Use official documentation indexes, code-search engines, and repository history where relevant. - **Browsing, 0-3 workers.** Pages plain fetch cannot read (WAF, 403, Cloudflare, dynamic rendering, login): escalate through search-engine cache first, then a headless/stealth browser, rather than abandoning the source. Capture screenshots when visual context matters. When one blocked territory hides many leads, fan out more browsing workers in parallel for breadth instead of serializing one. - **Repo deep-dive, 0-2 workers.** Shallow-clone the most relevant repos to a temp dir, pin the HEAD SHA, read core modules, follow call chains, return SHA-pinned permalinks. @@ -106,11 +107,11 @@ This loop is what makes the mode research rather than search. In team mode, act 3. Dispatch an expansion worker immediately for each new unchecked lead, embedding the role protocol for that lead's territory and the EXPAND tail. 4. Record the wave in `expansion-log.md`: workers spawned, markers gained, leads opened and closed. -**Convergence — the only stop rules while this mode is active.** Run at least two expansion waves on any multi-faceted query before claiming convergence; then stop only when one holds: +**Convergence.** Stop when one holds: - Zero unchecked leads remain — each investigated or closed as duplicate/dead end. -- Three consecutive waves produced no new actionable leads. -- Expansion depth reached five waves — pause, show the open leads, and ask the user whether to extend. +- Two consecutive waves produced no new actionable leads. +- The selected wave target is reached with no unresolved high-risk claim. If material leads remain, continue automatically and record why the target was exceeded. ## Phase 3 - Verify contested claims by running code @@ -140,6 +141,7 @@ After convergence and all verifications, re-read the whole journal and write `SY ```text # Superloopy Research Synthesis: Workers: · Waves: · Sources: · Verifications: +Research usage: workers / · queries / · waves / · provenance ## Executive answer — 2-3 paragraphs answering the core question ## Findings by theme — per theme: consensus, evidence links, key quote (<20 words, attributed), verified yes/no @@ -184,11 +186,11 @@ High-yield combinations: official docs (`site:`), open-source imple | Failure | Correction | |---|---| -| Sequential dispatch, or trimming the first wave | All first-wave workers in one turn, in parallel, scaling floor respected | +| Sequential dispatch, or duplicating angles | Dispatch independent first-wave lanes in parallel within the selected advisory profile | | A team member hoards leads for one final dump | Raise law — every lead, finding, and dead end broadcast the moment it surfaces | | Worker reply without the EXPAND tail | One follow-up demanding it; the lane stays open until it lands | -| Stopping after wave 1 because "enough was found" | Convergence rules only: 2+ expansion waves, leads run dry | -| Obeying a surrounding "stop exploring" rule mid-research | Authority section — those rules do not bind this mode | +| Stopping while a material lead remains | Continue automatically, record the overage reason when a target is crossed | +| Treating a target as a gate | Targets are telemetry only; they never block or ask for approval | | Asking a worker to write the journal or claim ledger | Workers are read-only; you write every session file | | Two workers given the same angle | One unique angle per worker, always | | Contested claim settled by judgment | Phase 3 — run code, capture output, verdict | diff --git a/src/cli.js b/src/cli.js index 39e3ef2..e0012bd 100755 --- a/src/cli.js +++ b/src/cli.js @@ -25,6 +25,7 @@ import { formatGuideResult } from "./guide.js"; import { fleetLoop, handoffLoop } from "./fleet.js"; import { formatCrewLine } from "./crew-lines.js"; import { trustLoop } from "./plan-trust.js"; +import { bindLegacyLoop, inspectRepositoryBinding } from "./repository-binding.js"; import { proveLoop } from "./prove.js"; import { reportLoop } from "./report.js"; import { isSourceCheckoutRoot } from "./source-checkout.js"; @@ -46,6 +47,8 @@ import { runSubagentStopHook, runUserPromptSubmitHook } from "./hooks.js"; +import { resolveWorkspaceRoot } from "./workspace-identity.js"; +import { readPlanUnchecked, scopeFromSessionId } from "./store.js"; const CLI_FILE = fileURLToPath(import.meta.url); const CLI_ROOT = dirname(dirname(CLI_FILE)); @@ -242,13 +245,22 @@ async function runLoop(subcommand, argv, stdout, cwd) { stdout.write(helpText()); return 0; } - const result = await dispatchLoop(cwd, subcommand, argv); + const result = await dispatchLoop(resolveWorkspaceRoot(cwd), subcommand, argv); if (json) stdout.write(`${JSON.stringify(result, null, 2)}\n`); else stdout.write(formatLoopResult(subcommand, result)); return result.ok === false ? 1 : 0; } async function dispatchLoop(cwd, subcommand, argv) { + if (!["begin", "create", "status", "bind"].includes(subcommand)) { + const scope = scopeFromSessionId(readFlag(argv, "--session-id")); + const plan = await readPlanUnchecked(cwd, scope); + const binding = await inspectRepositoryBinding(cwd, plan); + if (!binding.resumable) { + const next = binding.next ? ` Run \`${binding.next}\`.` : ""; + throw new Error(`Superloopy repository is ${binding.status}.${next}`); + } + } switch (subcommand) { case "begin": return beginLoop(cwd, argv); @@ -286,6 +298,8 @@ async function dispatchLoop(cwd, subcommand, argv) { return handoffLoop(cwd, argv); case "fleet": return fleetLoop(cwd, argv); + case "bind": + return bindLegacyLoop(cwd, argv); default: throw new Error(`Unknown loop subcommand: ${subcommand}`); } @@ -370,6 +384,13 @@ function formatLoopResult(subcommand, result) { if (result.warning) lines.push(result.warning); return `${lines.join("\n")}\n`; } + if (subcommand === "bind") { + return `superloopy repository: ${result.alreadyBound ? "already bound" : "bound"} to ${result.plan.repositoryBinding.rootLabel}\n`; + } + if (subcommand === "status" && result.binding?.resumable === false) { + const next = result.binding.next ? `\nNext action: \`${result.binding.next}\`` : ""; + return `superloopy repository: ${result.binding.status}${next}\n`; + } return `superloopy status: ${result.summary.goals.complete}/${result.summary.goals.total} goals complete\n${formatGuideResult(result)}`; } diff --git a/src/compaction-recovery.js b/src/compaction-recovery.js new file mode 100644 index 0000000..8233f08 --- /dev/null +++ b/src/compaction-recovery.js @@ -0,0 +1,38 @@ +export function buildRecoveryProjection({ status, guide, fleet }) { + const activeGoal = status.plan.goals.find((goal) => goal.status === "in_progress") + ?? status.plan.goals.find((goal) => goal.status === "pending") + ?? null; + const unresolved = status.plan.goals.flatMap((goal) => + goal.criteria.filter((criterion) => criterion.status !== "pass").map((criterion) => `${goal.id}/${criterion.id}`) + ); + return { + binding: status.binding?.status ?? "bound", + rootLabel: status.plan.repositoryBinding?.rootLabel ?? "unknown", + sessionId: status.plan.sessionId ?? null, + mode: status.plan.mode, + activeGoal: activeGoal === null ? null : { id: activeGoal.id, title: activeGoal.title }, + unresolved, + aggregateComplete: status.summary.aggregateComplete, + nextAction: guide?.nextAction?.command ?? status.binding?.next ?? null, + outstanding: Array.isArray(fleet?.outstanding) ? fleet.outstanding.map((item) => item.id) : [] + }; +} + +export function renderRecoveryCapsule(projection, { maxChars = 4000 } = {}) { + const mandatory = [ + "Superloopy compaction recovery", + "", + "Durable Superloopy state overrides transcript summaries and completion claims.", + `Repository: ${projection.binding} (${projection.rootLabel})`, + `Session: ${projection.sessionId ?? "default"} · mode: ${projection.mode}`, + `Aggregate complete: ${projection.aggregateComplete ? "yes" : "no"}`, + projection.activeGoal === null ? "Active goal: none" : `Active goal: ${projection.activeGoal.id} ${projection.activeGoal.title}`, + `Unresolved criteria: ${projection.unresolved.length === 0 ? "none" : projection.unresolved.join(", ")}`, + `Next action: ${projection.nextAction ?? "inspect repository binding"}`, + `Outstanding handoffs: ${projection.outstanding.length === 0 ? "none" : projection.outstanding.join(", ")}`, + "", + "Only the deterministic Superloopy gate authorizes completion." + ].join("\n"); + if (mandatory.length <= maxChars) return mandatory; + return `${mandatory.slice(0, Math.max(0, maxChars - 32)).trimEnd()}\n[recovery capsule truncated]`; +} diff --git a/src/context-cost.js b/src/context-cost.js new file mode 100644 index 0000000..03f4cee --- /dev/null +++ b/src/context-cost.js @@ -0,0 +1,42 @@ +export const CONTEXT_ESTIMATOR = "mixed-v1"; + +export function measureContext(text) { + const points = Array.from(text); + let ascii = 0; + for (const point of points) { + if (point.codePointAt(0) <= 0x7f) ascii += 1; + } + const nonAscii = points.length - ascii; + return { + characters: points.length, + utf8Bytes: Buffer.byteLength(text, "utf8"), + estimatedTokens: Math.ceil(ascii / 4 + nonAscii / 1.5), + estimator: CONTEXT_ESTIMATOR + }; +} + +export function appendContextCost(text) { + const body = typeof text === "string" ? text.trim() : ""; + if (body.length === 0) return ""; + let output = body; + for (let index = 0; index < 8; index += 1) { + const measured = measureContext(output); + const next = `${body}\n\nSuperloopy context cost: ${measured.characters.toLocaleString("en-US")} chars · ${measured.utf8Bytes.toLocaleString("en-US")} UTF-8 bytes · ~${measured.estimatedTokens.toLocaleString("en-US")} estimated tokens (${measured.estimator})`; + if (next === output) return output; + output = next; + } + return output; +} + +export function formatMeasuredAdditionalContext(hookEventName, additionalContext, extra = {}) { + const measured = appendContextCost(additionalContext); + if (measured.length === 0) return ""; + return `${JSON.stringify({ + ...extra, + hookSpecificOutput: { + ...(extra.hookSpecificOutput ?? {}), + hookEventName, + additionalContext: measured + } + })}\n`; +} diff --git a/src/engineer.js b/src/engineer.js index be58fd3..24beaf4 100644 --- a/src/engineer.js +++ b/src/engineer.js @@ -73,6 +73,9 @@ export async function runEngineerTriggerHook(payload, deps) { const interop = detectSuperpowers(); try { const status = await statusForPayload(payload); + if (status.binding?.resumable === false) { + return formatAdditionalContext("UserPromptSubmit", renderBindingBlocked(status)); + } if (status.summary.aggregateComplete) { return formatAdditionalContext("UserPromptSubmit", renderComplete(status, interop)); } @@ -83,6 +86,17 @@ export async function runEngineerTriggerHook(payload, deps) { } } +function renderBindingBlocked(status) { + return [ + HEADER, + "", + `The repo-local Superloopy plan cannot resume here because its repository binding is ${status.binding.status}.`, + status.binding.next === null + ? "Do not mutate or resume this copied state. Return to the repository where the plan was created, or start a separate loop here." + : `Confirm this legacy plan explicitly before resuming: \`${status.binding.next}\`.` + ].join("\n"); +} + function renderStart(payload, orchestrate, interop) { const { brief } = parseInvocation(payload.prompt); const cli = superloopyCommand(); diff --git a/src/help.js b/src/help.js index fbb9f61..2f6ea09 100644 --- a/src/help.js +++ b/src/help.js @@ -17,6 +17,7 @@ export function helpText() { " superloopy loop handoff --agent NAME --assignment TEXT [--status TEXT] [--verdict TEXT] [--artifact PATH] [--language TAG] [--session-id ID] [--json]", " superloopy loop fleet [--language TAG] [--session-id ID] [--json]", " superloopy loop status [--session-id ID] [--json]", + " superloopy loop bind --confirm-current-root [--session-id ID] [--json] # bind a legacy plan to this worktree", " superloopy loop trust [--session-id ID] [--json] # approve plan commands for audit re-run (plans that arrived with the repo)", " superloopy agents install [--target PATH] [--force] [--json]", "", diff --git a/src/hooks.js b/src/hooks.js index 3b89e80..ecea574 100644 --- a/src/hooks.js +++ b/src/hooks.js @@ -7,9 +7,14 @@ import { buildGuide, flowStepLine, proofPlanLine, recordedEvidenceLine } from ". import { CONTEXT_PRESSURE_MARKERS, decideContinuation, transcriptTailHasMarker } from "./continuation.js"; import { matchesAgentType, receiptFromPayload, subagentTranscriptPath } from "./receipt.js"; import { hasEngineerTrigger, runEngineerTriggerHook } from "./engineer.js"; -import { applySteering, statusLoop } from "./loop.js"; +import { applySteeringIdempotent, statusLoop } from "./loop.js"; import { appendLedger, evidenceRelativeDir, goalsPath, scopeFromSessionId } from "./store.js"; import { MAX_SUBAGENT_ATTEMPTS, clearAttemptState, nextAttemptState, recordSubagentLedger } from "./subagent-attempts.js"; +import { resolveWorkspaceRoot } from "./workspace-identity.js"; +import { steeringRequestKey } from "./steering-receipts.js"; +import { formatMeasuredAdditionalContext } from "./context-cost.js"; +import { buildRecoveryProjection, renderRecoveryCapsule } from "./compaction-recovery.js"; +import { fleetLoop } from "./fleet.js"; export { runPreToolUseHook } from "./pre-tool-use.js"; @@ -29,6 +34,7 @@ const PROTECTED_STEERING_KEYS = new Set([ export function runSubagentStopHook(payload, context = { host: "codex" }) { if (!isRecord(payload)) return ""; if (payload.hook_event_name !== "SubagentStop") return ""; + if (typeof payload.cwd === "string") payload = { ...payload, cwd: resolveWorkspaceRoot(payload.cwd) }; if (![...EVIDENCE_RECEIPT_AGENT_TYPES].some((role) => matchesAgentType({ host: context.host, agentType: payload.agent_type, @@ -74,6 +80,7 @@ export async function runUserPromptSubmitHook(payload) { if (!isRecord(payload)) return ""; if (payload.hook_event_name !== "UserPromptSubmit") return ""; if (typeof payload.prompt !== "string" || typeof payload.cwd !== "string") return ""; + payload = { ...payload, cwd: resolveWorkspaceRoot(payload.cwd) }; if (hasContextPressureMarker(payload.prompt) || transcriptHasContextPressureMarker(payload.transcript_path)) return ""; const directive = parseSteeringDirective(payload.prompt); if (directive === null) { @@ -101,9 +108,20 @@ export async function runSessionStartHook(payload, options = {}) { if (!isRecord(payload)) return ""; if (payload.hook_event_name !== "SessionStart") return ""; if (typeof payload.cwd !== "string") return ""; - if (transcriptHasContextPressureMarker(payload.transcript_path)) return ""; + payload = { ...payload, cwd: resolveWorkspaceRoot(payload.cwd) }; + if (payload.source !== "compact" && transcriptHasContextPressureMarker(payload.transcript_path)) return ""; const env = options.env ?? process.env; const contexts = []; + if (payload.source === "compact") { + try { + const status = await statusForPayload(payload); + const guide = status.binding?.resumable === false ? null : guideForPayload(payload, status.plan); + const fleet = await fleetLoop(payload.cwd, argsFromPayloadScope(payload)); + contexts.push(renderRecoveryCapsule(buildRecoveryProjection({ status, guide, fleet }))); + } catch { + // No plan: compaction recovery remains quiet. + } + } try { // Auto-update is the Codex install-flow concern; on Claude Code updates are managed by // `/plugin`, so skip it there (like the bootstrap no-op) to avoid emitting Codex upgrade notices. @@ -138,6 +156,7 @@ export async function runStopHook(payload) { if (!isRecord(payload)) return ""; if (payload.hook_event_name !== "Stop" && payload.hook_event_name !== "SubagentStop") return ""; if (typeof payload.cwd !== "string") return ""; + payload = { ...payload, cwd: resolveWorkspaceRoot(payload.cwd) }; if (!envOn(process.env, "SUPERLOOPY_STOP_HOOK")) return ""; return await decideContinuation(payload, { statusForPayload, @@ -160,14 +179,14 @@ export function parseSteeringDirective(prompt) { const evidence = readNonEmptyString(parsed.evidence); const rationale = readNonEmptyString(parsed.rationale); if (evidence === null || rationale === null) return null; - return { kind: "annotate", evidence, rationale }; + return steeringDirective({ kind: "annotate", evidence, rationale }, parsed); } if (parsed.kind === "add_goal") { const title = readNonEmptyString(parsed.title); const objective = readNonEmptyString(parsed.objective); const rationale = readNonEmptyString(parsed.rationale); if (title === null || objective === null || rationale === null) return null; - return { kind: "add_goal", title, objective, rationale }; + return steeringDirective({ kind: "add_goal", title, objective, rationale }, parsed); } if (parsed.kind === "revise_criterion") { const goalId = readNonEmptyString(parsed.goalId); @@ -175,7 +194,7 @@ export function parseSteeringDirective(prompt) { const scenario = readNonEmptyString(parsed.scenario); const rationale = readNonEmptyString(parsed.rationale); if (goalId === null || criterionId === null || scenario === null || rationale === null) return null; - return { kind: "revise_criterion", goalId, criterionId, scenario, rationale }; + return steeringDirective({ kind: "revise_criterion", goalId, criterionId, scenario, rationale }, parsed); } if (parsed.kind === "reorder_pending") { if (!Array.isArray(parsed.goalIds)) return null; @@ -183,7 +202,7 @@ export function parseSteeringDirective(prompt) { const rationale = readNonEmptyString(parsed.rationale); if (goalIds.length === 0 || goalIds.some((goalId) => goalId === null) || rationale === null) return null; if (new Set(goalIds).size !== goalIds.length) return null; - return { kind: "reorder_pending", goalIds, rationale }; + return steeringDirective({ kind: "reorder_pending", goalIds, rationale }, parsed); } return null; } @@ -237,6 +256,7 @@ async function readContextInjection(payload) { } catch { return ""; } + if (status.binding?.resumable === false) return renderBindingBlocked(status); if (status.summary.aggregateComplete) return ""; const guide = guideForPayload(payload, status.plan); return renderSuperloopyContext(status, guide); @@ -245,6 +265,9 @@ async function readContextInjection(payload) { async function runLoosePromptTriggerHook(payload) { try { const status = await statusForPayload(payload); + if (status.binding?.resumable === false) { + return formatAdditionalContext("UserPromptSubmit", renderBindingBlocked(status)); + } if (status.summary.aggregateComplete) { return formatAdditionalContext("UserPromptSubmit", renderLoosePromptCompleted(status)); } @@ -261,10 +284,19 @@ async function runLoosePromptTriggerHook(payload) { } } +function renderBindingBlocked(status) { + return [ + "Superloopy repository binding", + "", + `- Status: ${status.binding.status}`, + status.binding.next === null + ? "- Refusing to resume or mutate this copied state. Return to the bound repository or start a separate loop here." + : `- Confirm the legacy plan before resuming: \`${status.binding.next}\`` + ].join("\n"); +} + function formatAdditionalContext(hookEventName, additionalContext) { - const normalized = typeof additionalContext === "string" ? additionalContext.trim() : ""; - if (normalized.length === 0) return ""; - return `${JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: normalized } })}\n`; + return formatMeasuredAdditionalContext(hookEventName, additionalContext); } function envOn(env, key) { @@ -423,14 +455,22 @@ function evidenceRootForReceipt(payload) { async function applySteeringForPayload(payload, directive) { const scope = scopeFromPayload(payload); + const key = steeringRequestKey(payload, directive); if (scope !== undefined) { try { - return await applySteering(payload.cwd, directive, scope); + return await applySteeringIdempotent(payload.cwd, directive, scope, key); } catch (error) { if (!isMissingPlanError(error)) throw error; } } - return await applySteering(payload.cwd, directive); + return await applySteeringIdempotent(payload.cwd, directive, undefined, key); +} + +function steeringDirective(directive, parsed) { + if (typeof parsed.requestId === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(parsed.requestId)) { + return { ...directive, requestId: parsed.requestId }; + } + return directive; } async function statusForPayload(payload) { diff --git a/src/loop.js b/src/loop.js index 3d2183c..0f1a271 100644 --- a/src/loop.js +++ b/src/loop.js @@ -19,6 +19,7 @@ import { readLoopControl } from "./continuation.js"; import { buildGuide, guideLoop } from "./guide.js"; import { helpText } from "./help.js"; import { summarizePlan } from "./plan-summary.js"; +import { inspectRepositoryBinding } from "./repository-binding.js"; import { appendLedger, briefRelativePath, @@ -29,12 +30,15 @@ import { ledgerRelativePath, nowIso, readPlan, + readPlanUnchecked, scopeFromSessionId, withFileLock, writeBrief, writeJsonAtomic, writePlan } from "./store.js"; +import { createRepositoryBinding } from "./workspace-identity.js"; +import { findSteeringReceipt, recordSteeringReceipt } from "./steering-receipts.js"; export { guideLoop, helpText }; @@ -50,7 +54,7 @@ export async function createLoop(cwd, argv) { const now = nowIso(); const goals = deriveGoals(brief).map((goal, index) => makeGoal(goal, index, mode, now)); const plan = { - version: 1, + version: 2, mode, createdAt: now, updatedAt: now, @@ -59,7 +63,8 @@ export async function createLoop(cwd, argv) { goalsPath: goalsRelativePath(scope), ledgerPath: ledgerRelativePath(scope), goals, - aggregateCompletion: null + aggregateCompletion: null, + repositoryBinding: await createRepositoryBinding(cwd) }; if (scope?.sessionId) plan.sessionId = scope.sessionId; await writeBrief(cwd, brief, scope); @@ -70,8 +75,12 @@ export async function createLoop(cwd, argv) { export async function statusLoop(cwd, argv = []) { const scope = readScope(argv); - const plan = await readPlan(cwd, scope); - const result = { ok: true, plan, summary: summarizePlan(plan), guide: buildGuide(plan, { cwd, scope }) }; + const plan = await readPlanUnchecked(cwd, scope); + const binding = await inspectRepositoryBinding(cwd, plan); + if (!binding.resumable) { + return { ok: false, plan, binding, summary: summarizePlan(plan), guide: null }; + } + const result = { ok: true, plan, binding, summary: summarizePlan(plan), guide: buildGuide(plan, { cwd, scope }) }; const loopControl = await readLoopControl(cwd, scope); if (loopControl !== null) result.loopControl = loopControl; return result; @@ -253,6 +262,26 @@ export async function applySteering(cwd, directive, scope) { throw new Error(`Unsupported steering kind: ${directive.kind}`); } +export async function applySteeringIdempotent(cwd, directive, scope, requestKey) { + return await withFileLock(goalsPath(cwd, scope), async () => { + const before = await readPlan(cwd, scope); + const binding = await inspectRepositoryBinding(cwd, before); + if (!binding.resumable) { + throw new Error(`Superloopy repository is ${binding.status}; refusing to apply steering.`); + } + const prior = findSteeringReceipt(before, requestKey); + if (prior !== null) return { ...prior.result, deduplicated: true }; + const result = await applySteering(cwd, directive, scope); + const plan = result.plan ?? await readPlan(cwd, scope); + const appliedAt = nowIso(); + const stableResult = { ok: true, kind: result.kind, goal: result.goal, criterion: result.criterion }; + recordSteeringReceipt(plan, { key: requestKey, appliedAt, result: stableResult }); + plan.updatedAt = appliedAt; + await writePlan(cwd, plan, scope); + return { ...result, plan, requestKey, deduplicated: false }; + }); +} + async function annotateOnly(cwd, directive, scope) { const now = nowIso(); await appendLedger(cwd, { diff --git a/src/plan-trust.js b/src/plan-trust.js index d2e1728..e2c1521 100644 --- a/src/plan-trust.js +++ b/src/plan-trust.js @@ -11,18 +11,18 @@ // explicitly approved the plan with `superloopy loop trust`. The approval // ledger lives OUTSIDE the repo (user home, keyed by repo path) precisely so // repo contents cannot forge trust; absence of the ledger fails closed. -import { createHash, randomUUID } from "node:crypto"; -import { readFileSync, realpathSync, statSync } from "node:fs"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; +import { mkdir, readFile } from "node:fs/promises"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, join } from "node:path"; import { readFlag } from "./args.js"; import { nowIso, readPlan, scopeFromSessionId, writeJsonAtomic } from "./store.js"; +import { checkoutIdentity, resolveWorkspaceRoot } from "./workspace-identity.js"; export function trustStorePath(cwd, env = process.env) { // SUPERLOOPY_TRUST_DIR exists for tests; production callers never set it. const baseDir = env.SUPERLOOPY_TRUST_DIR?.trim() || join(homedir(), ".superloopy", "trust"); - const repoKey = createHash("sha256").update(resolve(cwd)).digest("hex"); + const repoKey = createHash("sha256").update(resolveWorkspaceRoot(cwd)).digest("hex"); return join(baseDir, `${repoKey}.json`); } @@ -31,17 +31,17 @@ export function commandDigest(command) { } async function readTrustStore(cwd) { - const checkoutIdentity = await currentCheckoutIdentity(cwd); + const currentIdentity = await checkoutIdentity(cwd); try { const parsed = JSON.parse(await readFile(trustStorePath(cwd), "utf8")); if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && parsed.commands && typeof parsed.commands === "object") { - if (parsed.checkoutIdentity !== checkoutIdentity) return emptyTrustStore(checkoutIdentity); + if (parsed.checkoutIdentity !== currentIdentity) return emptyTrustStore(currentIdentity); return parsed; } } catch { // absent or unreadable -> empty store: nothing is trusted (fail-closed) } - return emptyTrustStore(checkoutIdentity); + return emptyTrustStore(currentIdentity); } export async function isTrustedCommand(cwd, command) { @@ -66,55 +66,6 @@ function emptyTrustStore(checkoutIdentity) { return { version: 1, checkoutIdentity, commands: {} }; } -async function currentCheckoutIdentity(cwd) { - const root = resolve(cwd); - const gitDir = resolveGitDir(root); - if (gitDir) { - const markerPath = join(gitDir, "superloopy-trust-id"); - let id = ""; - try { - id = readFileSync(markerPath, "utf8").trim(); - } catch { - id = randomUUID(); - await writeFile(markerPath, `${id}\n`, { encoding: "utf8", flag: "wx" }).catch((error) => { - if (error?.code !== "EEXIST") throw error; - }); - id = readFileSync(markerPath, "utf8").trim(); - } - return hash({ kind: "git", root, gitDir, id }); - } - - const stat = statSync(root); - return hash({ kind: "path", root, dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }); -} - -function resolveGitDir(start) { - let cursor = resolve(start); - while (true) { - const candidate = join(cursor, ".git"); - try { - const stat = statSync(candidate); - if (stat.isDirectory()) return realpathSync(candidate); - if (stat.isFile()) { - const match = /^gitdir:\s*(.+)\s*$/iu.exec(readFileSync(candidate, "utf8")); - if (match) { - const gitDir = match[1]; - return realpathSync(isAbsolute(gitDir) ? gitDir : resolve(cursor, gitDir)); - } - } - } catch { - // Keep walking toward the filesystem root. - } - const parent = dirname(cursor); - if (parent === cursor) return null; - cursor = parent; - } -} - -function hash(value) { - return createHash("sha256").update(JSON.stringify(value)).digest("hex"); -} - // Explicit user approval of every command currently in the plan — the escape // hatch for plans that arrived with the repo (handoff, resumed session on a new // machine). Deliberately a separate, human-invoked CLI verb so an agent cannot diff --git a/src/pre-tool-use.js b/src/pre-tool-use.js index 8f4bb38..f822d9b 100644 --- a/src/pre-tool-use.js +++ b/src/pre-tool-use.js @@ -9,6 +9,7 @@ // this hook only keeps the host's native plan state honest. See docs/superloopy-host-contract.md. import { existsSync, readFileSync } from "node:fs"; import { goalsPath, scopeFromSessionId } from "./store.js"; +import { appendContextCost } from "./context-cost.js"; const CREATE_GOAL_PAYLOAD_WARNING = "Use create_goal with objective only. Omit token_budget so the goal stays unlimited; use update_goal only after Superloopy aggregate completion is recorded."; @@ -66,7 +67,7 @@ function denyPreToolUse(message) { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: message, - additionalContext: message + additionalContext: appendContextCost(message) } })}\n`; } diff --git a/src/repository-binding.js b/src/repository-binding.js new file mode 100644 index 0000000..b722168 --- /dev/null +++ b/src/repository-binding.js @@ -0,0 +1,75 @@ +import { isAbsolute, posix } from "node:path"; +import { readFlag } from "./args.js"; +import { + appendLedger, + goalsPath, + nowIso, + readPlanUnchecked, + scopeFromSessionId, + withFileLock, + writePlan +} from "./store.js"; +import { bindingMatches, createRepositoryBinding, validBinding } from "./workspace-identity.js"; + +export async function inspectRepositoryBinding(cwd, plan) { + if (plan.version === 1 && plan.repositoryBinding === undefined) { + return { status: "legacy_unbound", resumable: false, next: bindCommand(plan.sessionId) }; + } + if (plan.version !== 2 || !validBinding(plan.repositoryBinding)) { + return { status: "invalid", resumable: false, next: null }; + } + if (!(await bindingMatches(cwd, plan.repositoryBinding))) { + return { + status: "mismatch", + resumable: false, + expected: plan.repositoryBinding.identity.slice(0, 12), + next: null + }; + } + return { status: "bound", resumable: true, rootLabel: plan.repositoryBinding.rootLabel }; +} + +export async function bindLegacyLoop(cwd, argv = []) { + if (!argv.includes("--confirm-current-root")) { + throw new Error("Binding a legacy plan requires --confirm-current-root."); + } + const scope = scopeFromSessionId(readFlag(argv, "--session-id")); + return await withFileLock(goalsPath(cwd, scope), async () => { + const plan = await readPlanUnchecked(cwd, scope); + const current = await inspectRepositoryBinding(cwd, plan); + if (current.status === "bound") return { ok: true, kind: "repository_bound", alreadyBound: true, plan }; + if (current.status !== "legacy_unbound") throw new Error(`Cannot bind plan with repository status: ${current.status}.`); + validateLegacyPaths(plan); + const now = nowIso(); + plan.version = 2; + plan.repositoryBinding = await createRepositoryBinding(cwd); + plan.updatedAt = now; + await writePlan(cwd, plan, scope); + await appendLedger(cwd, { + at: now, + kind: "repository_bound", + identity: plan.repositoryBinding.identity.slice(0, 12), + rootLabel: plan.repositoryBinding.rootLabel + }, scope); + return { ok: true, kind: "repository_bound", alreadyBound: false, plan }; + }); +} + +function validateLegacyPaths(plan) { + for (const key of ["briefPath", "evidencePath", "goalsPath", "ledgerPath"]) { + const value = plan[key]; + if (typeof value !== "string" || value.length === 0 || isAbsolute(value) || value.includes("\\") || escapes(value)) { + throw new Error(`Cannot bind legacy plan: ${key} must be a confined repository-relative path.`); + } + } +} + +function escapes(value) { + const normalized = posix.normalize(value); + return normalized === ".." || normalized.startsWith("../"); +} + +function bindCommand(sessionId) { + const session = typeof sessionId === "string" && sessionId.length > 0 ? ` --session-id ${sessionId}` : ""; + return `superloopy loop bind --confirm-current-root${session} --json`; +} diff --git a/src/steering-receipts.js b/src/steering-receipts.js new file mode 100644 index 0000000..5e52740 --- /dev/null +++ b/src/steering-receipts.js @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; + +const MAX_RECEIPTS = 128; +const RETRY_WINDOW_MS = 30_000; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; + +export function steeringRequestKey(payload, directive, nowMs = Date.now()) { + if (typeof directive.requestId === "string" && /^[A-Za-z0-9._:-]{1,128}$/u.test(directive.requestId)) { + return `explicit:${directive.requestId}`; + } + const turn = typeof payload.turn_id === "string" && payload.turn_id.length > 0 + ? payload.turn_id + : `window:${Math.floor(nowMs / RETRY_WINDOW_MS)}`; + return `derived:${hash({ + session: payload.session_id ?? null, + turn, + scope: payload.session_id ?? null, + directive + })}`; +} + +export function findSteeringReceipt(plan, key, nowMs = Date.now()) { + const receipts = Array.isArray(plan.steeringReceipts) ? plan.steeringReceipts : []; + return receipts.find((receipt) => receipt.key === key && fresh(receipt, nowMs)) ?? null; +} + +export function recordSteeringReceipt(plan, receipt, nowMs = Date.now()) { + const receipts = Array.isArray(plan.steeringReceipts) ? plan.steeringReceipts : []; + plan.steeringReceipts = [...receipts.filter((item) => fresh(item, nowMs) && item.key !== receipt.key), receipt] + .slice(-MAX_RECEIPTS); +} + +function fresh(receipt, nowMs) { + const appliedAt = Date.parse(receipt?.appliedAt); + return Number.isFinite(appliedAt) && nowMs - appliedAt <= MAX_AGE_MS; +} + +function hash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} diff --git a/src/store.js b/src/store.js index 19e515a..d3de15f 100644 --- a/src/store.js +++ b/src/store.js @@ -225,6 +225,10 @@ function sleep(ms) { } export async function readPlan(cwd, scope) { + return await readPlanUnchecked(cwd, scope); +} + +export async function readPlanUnchecked(cwd, scope) { let parsed; try { parsed = JSON.parse(await readFile(goalsPath(cwd, scope), "utf8")); diff --git a/src/workspace-identity.js b/src/workspace-identity.js new file mode 100644 index 0000000..b09e797 --- /dev/null +++ b/src/workspace-identity.js @@ -0,0 +1,102 @@ +import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; +import { readFileSync, realpathSync, statSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; + +export function resolveWorkspaceRoot(start) { + const origin = realpathSync(resolve(start)); + const git = findNearestGitWorktree(origin); + if (git !== null) return git.root; + let cursor = origin; + while (true) { + try { + if (statSync(join(cursor, ".superloopy")).isDirectory()) return cursor; + } catch { + // Keep walking. + } + const parent = dirname(cursor); + if (parent === cursor) return origin; + cursor = parent; + } +} + +export function findNearestGitWorktree(start) { + let cursor = realpathSync(resolve(start)); + while (true) { + const gitDir = resolveGitDirectoryAt(cursor); + if (gitDir !== null) return { root: cursor, gitDir }; + const parent = dirname(cursor); + if (parent === cursor) return null; + cursor = parent; + } +} + +export async function checkoutIdentity(start) { + const root = resolveWorkspaceRoot(start); + const git = findNearestGitWorktree(root); + if (git !== null) { + return hash({ version: 1, kind: "git", root, gitDir: git.gitDir, id: await checkoutUuid(git.gitDir) }); + } + const stat = statSync(root); + return hash({ version: 1, kind: "path", root, dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }); +} + +export async function createRepositoryBinding(start) { + const root = resolveWorkspaceRoot(start); + return { + version: 1, + kind: findNearestGitWorktree(root) === null ? "path" : "git-worktree", + identity: await checkoutIdentity(root), + rootLabel: basename(root) + }; +} + +export async function bindingMatches(start, binding) { + if (!validBinding(binding)) return false; + const actual = Buffer.from(await checkoutIdentity(start), "utf8"); + const expected = Buffer.from(binding.identity, "utf8"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +export function validBinding(binding) { + return binding !== null + && typeof binding === "object" + && !Array.isArray(binding) + && binding.version === 1 + && (binding.kind === "git-worktree" || binding.kind === "path") + && typeof binding.identity === "string" + && /^[a-f0-9]{64}$/u.test(binding.identity) + && typeof binding.rootLabel === "string" + && binding.rootLabel.length > 0; +} + +function resolveGitDirectoryAt(root) { + const candidate = join(root, ".git"); + try { + const stat = statSync(candidate); + if (stat.isDirectory()) return realpathSync(candidate); + if (!stat.isFile()) return null; + const match = /^gitdir:\s*(.+)\s*$/iu.exec(readFileSync(candidate, "utf8")); + if (!match) return null; + return realpathSync(isAbsolute(match[1]) ? match[1] : resolve(root, match[1])); + } catch { + return null; + } +} + +async function checkoutUuid(gitDir) { + const markerPath = join(gitDir, "superloopy-trust-id"); + try { + return readFileSync(markerPath, "utf8").trim(); + } catch { + const id = randomUUID(); + await writeFile(markerPath, `${id}\n`, { encoding: "utf8", flag: "wx" }).catch((error) => { + if (error?.code !== "EEXIST") throw error; + }); + return readFileSync(markerPath, "utf8").trim(); + } +} + +function hash(value) { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} diff --git a/test/compaction-recovery.test.js b/test/compaction-recovery.test.js new file mode 100644 index 0000000..b9799a7 --- /dev/null +++ b/test/compaction-recovery.test.js @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { buildRecoveryProjection, renderRecoveryCapsule } from "../src/compaction-recovery.js"; +import { runSessionStartHook } from "../src/hooks.js"; +import { createLoop, nextLoop, statusLoop } from "../src/loop.js"; + +async function activeRepo(sessionId) { + const repo = await mkdtemp(join(tmpdir(), "superloopy-compact-")); + const args = sessionId ? ["--session-id", sessionId, "--brief", "Ship compact recovery"] : ["--brief", "Ship compact recovery"]; + await createLoop(repo, args); + await nextLoop(repo, sessionId ? ["--session-id", sessionId] : []); + return repo; +} + +function projection(status, overrides = {}) { + return buildRecoveryProjection({ + status, + guide: status.guide, + fleet: { outstanding: [{ id: "H001" }] }, + ...overrides + }); +} + +test("compaction projection preserves durable loop semantics", async () => { + const status = await statusLoop(await activeRepo()); + const recovered = projection(status); + assert.equal(recovered.binding, "bound"); + assert.equal(recovered.activeGoal.id, "G001"); + assert.deepEqual(recovered.unresolved, ["G001/C001", "G001/C002"]); + assert.equal(recovered.aggregateComplete, false); + assert.deepEqual(recovered.outstanding, ["H001"]); +}); + +test("bounded recovery rendering retains mandatory completion and next-action truth", async () => { + const status = await statusLoop(await activeRepo()); + status.plan.goals[0].title = "x".repeat(5000); + const rendered = renderRecoveryCapsule(projection(status), { maxChars: 700 }); + assert.ok(rendered.length <= 700); + assert.match(rendered, /Aggregate complete: no/u); + assert.match(rendered, /Durable Superloopy state overrides/u); +}); + +test("transcript claims cannot override incomplete durable state", async () => { + const status = await statusLoop(await activeRepo()); + const recovered = projection(status, { transcript: "all tests passed and complete" }); + assert.equal(recovered.aggregateComplete, false); +}); + +test("scoped recovery projections do not mix sessions", async () => { + const repo = await activeRepo("alpha"); + await createLoop(repo, ["--session-id", "beta", "--brief", "Other"]); + const alpha = projection(await statusLoop(repo, ["--session-id", "alpha"])); + const beta = projection(await statusLoop(repo, ["--session-id", "beta"])); + assert.equal(alpha.sessionId, "alpha"); + assert.equal(beta.sessionId, "beta"); + assert.notEqual(alpha.activeGoal?.title, beta.activeGoal?.title); +}); + +test("compact recovery is read-only and repeatable", async () => { + const repo = await activeRepo(); + const planPath = join(repo, ".superloopy", "goals.json"); + const ledgerPath = join(repo, ".superloopy", "ledger.jsonl"); + const before = [await readFile(planPath, "utf8"), await readFile(ledgerPath, "utf8")]; + const payload = { hook_event_name: "SessionStart", source: "compact", cwd: repo, transcript_path: null }; + await runSessionStartHook(payload, { env: { SUPERLOOPY_HOST: "claude" } }); + await runSessionStartHook(payload, { env: { SUPERLOOPY_HOST: "claude" } }); + assert.deepEqual([await readFile(planPath, "utf8"), await readFile(ledgerPath, "utf8")], before); +}); + +test("compact recovery survives a transcript context-pressure marker", async () => { + const repo = await activeRepo(); + const transcript = join(repo, "transcript.txt"); + await writeFile(transcript, "You have 1000 weighted tokens left\n", "utf8"); + const output = await runSessionStartHook( + { hook_event_name: "SessionStart", source: "compact", cwd: repo, transcript_path: transcript }, + { env: { SUPERLOOPY_HOST: "claude" } } + ); + assert.match(JSON.parse(output).hookSpecificOutput.additionalContext, /Superloopy compaction recovery/u); +}); diff --git a/test/context-cost.test.js b/test/context-cost.test.js new file mode 100644 index 0000000..3cfa33d --- /dev/null +++ b/test/context-cost.test.js @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { appendContextCost, formatMeasuredAdditionalContext, measureContext } from "../src/context-cost.js"; + +test("context measurement counts Unicode code points and UTF-8 bytes", () => { + const value = "루피🙂"; + const result = measureContext(value); + assert.equal(result.characters, 3); + assert.equal(result.utf8Bytes, Buffer.byteLength(value, "utf8")); + assert.equal(result.estimator, "mixed-v1"); + assert.ok(result.estimatedTokens > 0); +}); + +test("measured additional context describes the final emitted text", () => { + const output = JSON.parse(formatMeasuredAdditionalContext("UserPromptSubmit", "body")); + const context = output.hookSpecificOutput.additionalContext; + const measured = measureContext(context); + assert.match(context, new RegExp(`${measured.characters.toLocaleString("en-US")} chars`, "u")); + assert.match(context, new RegExp(`${measured.utf8Bytes.toLocaleString("en-US")} UTF-8 bytes`, "u")); +}); + +test("empty context remains empty", () => { + assert.equal(appendContextCost(""), ""); + assert.equal(formatMeasuredAdditionalContext("SessionStart", ""), ""); +}); diff --git a/test/crew-lines.test.js b/test/crew-lines.test.js index 8ee387d..c23ec1e 100644 --- a/test/crew-lines.test.js +++ b/test/crew-lines.test.js @@ -95,6 +95,8 @@ test("crewLineForHandoff stays silent for pending or unknown lanes", () => { test("CLI handoff text shows the crew line without hiding status", async () => { const repo = await tempRepo(); + const begun = runCli(["loop", "begin", "--brief", "Crew line", "--json"], { cwd: repo }); + assert.equal(begun.status, 0, begun.stderr); const artifact = await writeEvidence(repo, "qa.txt"); const result = runCli([ @@ -118,6 +120,8 @@ test("CLI handoff text shows the crew line without hiding status", async () => { test("CLI handoff text can use an explicit supported language override", async () => { const repo = await tempRepo(); + const begun = runCli(["loop", "begin", "--brief", "Crew line language", "--json"], { cwd: repo }); + assert.equal(begun.status, 0, begun.stderr); const artifact = await writeEvidence(repo, "qa.txt"); const result = runCli([ diff --git a/test/doctor.test.js b/test/doctor.test.js index 0ea0db3..e36c3be 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -16,7 +16,6 @@ import { } from "../src/doctor.js"; const EXPECTED_SKILLS = ["humanize-korean", "superloopy-clone", "superloopy-doctor", "superloopy-frontend", "superloopy-loop", "superloopy-research", "superloopy-slides"]; - test("reviewability counts physical lines and recognizes supported script/config extensions", () => { assert.equal(countPhysicalLines(""), 0); assert.equal(countPhysicalLines("one"), 1); @@ -76,10 +75,12 @@ async function tempRepoCopy({ initGit = true, prefix = "superloopy-doctor-" } = await mkdir(dirname(target), { recursive: true }); await writeFile(target, await readFile(source)); } - if (initGit) spawnSync("git", ["init"], { cwd: repo, encoding: "utf8" }); + if (initGit) { + const initialized = spawnSync("git", ["init"], { cwd: repo, encoding: "utf8" }); assert.equal(initialized.status, 0, initialized.stderr); + const staged = spawnSync("git", ["add", "--force", "."], { cwd: repo, encoding: "utf8" }); assert.equal(staged.status, 0, staged.stderr); + } return repo; } - async function tempComparisonTree(files) { const repo = await mkdtemp(join(tmpdir(), "superloopy-comparison-")); for (const [file, content] of Object.entries(files)) { @@ -89,7 +90,6 @@ async function tempComparisonTree(files) { } return repo; } - function toCrLf(content) { return content.replace(/\r\n?/gu, "\n").replace(/\n/gu, "\r\n"); } diff --git a/test/plugin.test.js b/test/plugin.test.js index 561e9eb..b58cdf9 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -156,6 +156,11 @@ test("plugin packages Superloopy research and website-clone skills", async () => assert.match(research.content, /EXPAND/); assert.match(research.content, /SUPERLOOPY_EVIDENCE/); assert.match(research.content, /\.superloopy\/evidence\/research/); + assert.match(research.content, /focused-codebase.*focused-web.*mixed.*exhaustive/is); + assert.match(research.content, /targets, not maximums or minimums/i); + assert.match(research.content, /Missing observation is `unknown`, never zero/i); + assert.match(research.content, /continue automatically.*overage reason/is); + assert.doesNotMatch(research.content, /Scaling floor|At least 10 distinct web-search queries|pause.*ask.*extend/is); assert.match(clone.frontmatter, /^name: superloopy-clone$/m); assert.match(clone.frontmatter, /loopy clone|website|reverse-engineer/i); diff --git a/test/repository-binding.test.js b/test/repository-binding.test.js new file mode 100644 index 0000000..e440517 --- /dev/null +++ b/test/repository-binding.test.js @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runUserPromptSubmitHook } from "../src/hooks.js"; +import { bindLegacyLoop } from "../src/repository-binding.js"; +import { createLoop, statusLoop } from "../src/loop.js"; + +const CLI = join(process.cwd(), "src", "cli.js"); + +async function repo(prefix = "superloopy-binding-") { + return mkdtemp(join(tmpdir(), prefix)); +} + +test("new plans carry a valid version 2 repository binding", async () => { + const root = await repo(); + const result = await createLoop(root, ["--brief", "Bind workspace"]); + assert.equal(result.plan.version, 2); + assert.match(result.plan.repositoryBinding.identity, /^[a-f0-9]{64}$/u); +}); + +test("CLI commands from a child directory reuse the canonical root plan", async () => { + const root = await repo(); + await createLoop(root, ["--brief", "Child invocation"]); + const child = join(root, "src", "nested"); + await mkdir(child, { recursive: true }); + const result = spawnSync(process.execPath, [CLI, "loop", "status", "--json"], { cwd: child, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).binding.status, "bound"); +}); + +test("copied plan state reports mismatch and refuses CLI mutation", async () => { + const source = await repo(); + const target = await repo("superloopy-binding-copy-"); + await createLoop(source, ["--brief", "Source"]); + await mkdir(join(target, ".superloopy"), { recursive: true }); + await writeFile(join(target, ".superloopy", "goals.json"), await readFile(join(source, ".superloopy", "goals.json"))); + const status = await statusLoop(target); + assert.equal(status.binding.status, "mismatch"); + const mutation = spawnSync(process.execPath, [CLI, "loop", "next", "--json"], { cwd: target, encoding: "utf8" }); + assert.equal(mutation.status, 1); + assert.match(mutation.stderr, /repository is mismatch/i); +}); + +test("copied plan state refuses steering and engineer resume hooks", async () => { + const source = await repo(); + const target = await repo("superloopy-binding-hooks-"); + await createLoop(source, ["--brief", "Source"]); + await mkdir(join(target, ".superloopy"), { recursive: true }); + const copied = await readFile(join(source, ".superloopy", "goals.json")); + const targetPlan = join(target, ".superloopy", "goals.json"); + await writeFile(targetPlan, copied); + const steering = await runUserPromptSubmitHook({ + hook_event_name: "UserPromptSubmit", + cwd: target, + session_id: "thread-copy", + turn_id: "turn-copy", + prompt: 'SUPERLOOPY_STEER: {"kind":"add_goal","title":"Wrong repo","objective":"Do not add","rationale":"copied state","requestId":"copy-1"}' + }); + const engineer = JSON.parse(await runUserPromptSubmitHook({ + hook_event_name: "UserPromptSubmit", + cwd: target, + prompt: "loopy continue" + })); + assert.equal(steering, ""); + assert.deepEqual(await readFile(targetPlan), copied); + assert.match(engineer.hookSpecificOutput.additionalContext, /repository binding is mismatch/i); +}); + +test("legacy plans require explicit confined binding", async () => { + const root = await repo(); + await createLoop(root, ["--brief", "Legacy"]); + const path = join(root, ".superloopy", "goals.json"); + const plan = JSON.parse(await readFile(path, "utf8")); + plan.version = 1; + delete plan.repositoryBinding; + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`); + assert.equal((await statusLoop(root)).binding.status, "legacy_unbound"); + const result = await bindLegacyLoop(root, ["--confirm-current-root"]); + assert.equal(result.plan.version, 2); + assert.equal((await statusLoop(root)).binding.status, "bound"); +}); + +test("legacy binding rejects escaping stored paths", async () => { + const root = await repo(); + await createLoop(root, ["--brief", "Legacy"]); + const path = join(root, ".superloopy", "goals.json"); + const plan = JSON.parse(await readFile(path, "utf8")); + plan.version = 1; + delete plan.repositoryBinding; + plan.evidencePath = "../outside"; + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`); + await assert.rejects(bindLegacyLoop(root, ["--confirm-current-root"]), /confined repository-relative path/i); +}); diff --git a/test/steering-idempotency.test.js b/test/steering-idempotency.test.js new file mode 100644 index 0000000..645e63d --- /dev/null +++ b/test/steering-idempotency.test.js @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { runUserPromptSubmitHook } from "../src/hooks.js"; +import { createLoop, statusLoop } from "../src/loop.js"; + +test("the same structured steering request mutates a plan once", async () => { + const repo = await mkdtemp(join(tmpdir(), "superloopy-steering-")); + await createLoop(repo, ["--brief", "First"]); + const payload = { + hook_event_name: "UserPromptSubmit", + cwd: repo, + session_id: "thread-1", + turn_id: "turn-1", + prompt: 'SUPERLOOPY_STEER: {"kind":"add_goal","title":"Second","objective":"Ship second","rationale":"required","requestId":"req-1"}' + }; + const first = JSON.parse(await runUserPromptSubmitHook(payload)); + const second = JSON.parse(await runUserPromptSubmitHook(payload)); + const status = await statusLoop(repo); + assert.equal(first.deduplicated, false); + assert.equal(second.deduplicated, true); + assert.equal(status.plan.goals.length, 2); +}); diff --git a/test/workspace-identity.test.js b/test/workspace-identity.test.js new file mode 100644 index 0000000..c078e35 --- /dev/null +++ b/test/workspace-identity.test.js @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { bindingMatches, checkoutIdentity, createRepositoryBinding, resolveWorkspaceRoot } from "../src/workspace-identity.js"; + +async function gitRepo() { + const root = await mkdtemp(join(tmpdir(), "superloopy-workspace-")); + execFileSync("git", ["init", "-q", root]); + return root; +} + +test("workspace root and checkout identity stay stable from a child directory", async () => { + const root = await gitRepo(); + const child = join(root, "src", "nested"); + await mkdir(child, { recursive: true }); + assert.equal(resolveWorkspaceRoot(child), realpathSync(root)); + assert.equal(await checkoutIdentity(child), await checkoutIdentity(root)); +}); + +test("repository bindings distinguish separate worktrees", async () => { + const first = await gitRepo(); + const second = await gitRepo(); + const binding = await createRepositoryBinding(first); + assert.equal(await bindingMatches(first, binding), true); + assert.equal(await bindingMatches(second, binding), false); +}); + +test("non-Git workspaces reuse the nearest Superloopy ancestor", async () => { + const root = await mkdtemp(join(tmpdir(), "superloopy-state-root-")); + await mkdir(join(root, ".superloopy")); + const child = join(root, "a", "b"); + await mkdir(child, { recursive: true }); + assert.equal(resolveWorkspaceRoot(child), realpathSync(root)); +}); From e41a4da167c771af929708ec9f3f8deac68b28f5 Mon Sep 17 00:00:00 2001 From: Beefiker Date: Fri, 24 Jul 2026 16:24:58 +0900 Subject: [PATCH 4/5] fix: stabilize workspace identity on Windows --- src/workspace-identity.js | 24 ++++++++++++++++++++---- test/workspace-identity.test.js | 11 +++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/workspace-identity.js b/src/workspace-identity.js index b09e797..f4fd286 100644 --- a/src/workspace-identity.js +++ b/src/workspace-identity.js @@ -1,14 +1,17 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto"; import { readFileSync, realpathSync, statSync } from "node:fs"; import { writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; -export function resolveWorkspaceRoot(start) { +export function resolveWorkspaceRoot(start, options = {}) { const origin = realpathSync(resolve(start)); const git = findNearestGitWorktree(origin); if (git !== null) return git.root; + const sharedStateRoot = realpathSync(resolve(options.sharedStateRoot ?? tmpdir())); let cursor = origin; while (true) { + if (cursor === sharedStateRoot && cursor !== origin) return origin; try { if (statSync(join(cursor, ".superloopy")).isDirectory()) return cursor; } catch { @@ -35,10 +38,14 @@ export async function checkoutIdentity(start) { const root = resolveWorkspaceRoot(start); const git = findNearestGitWorktree(root); if (git !== null) { - return hash({ version: 1, kind: "git", root, gitDir: git.gitDir, id: await checkoutUuid(git.gitDir) }); + return hash({ + version: 1, + kind: "git", + id: await checkoutUuid(git.gitDir), + ...filesystemIdentity(git.gitDir) + }); } - const stat = statSync(root); - return hash({ version: 1, kind: "path", root, dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }); + return hash({ version: 1, kind: "path", ...filesystemIdentity(root) }); } export async function createRepositoryBinding(start) { @@ -100,3 +107,12 @@ async function checkoutUuid(gitDir) { function hash(value) { return createHash("sha256").update(JSON.stringify(value)).digest("hex"); } + +function filesystemIdentity(path) { + const stat = statSync(path, { bigint: true }); + return { + dev: stat.dev.toString(), + ino: stat.ino.toString(), + birthtimeNs: stat.birthtimeNs.toString() + }; +} diff --git a/test/workspace-identity.test.js b/test/workspace-identity.test.js index c078e35..faa6713 100644 --- a/test/workspace-identity.test.js +++ b/test/workspace-identity.test.js @@ -36,3 +36,14 @@ test("non-Git workspaces reuse the nearest Superloopy ancestor", async () => { await mkdir(child, { recursive: true }); assert.equal(resolveWorkspaceRoot(child), realpathSync(root)); }); + +test("shared temporary state is never inherited by a child workspace", async () => { + const shared = await mkdtemp(join(tmpdir(), "superloopy-shared-root-")); + await mkdir(join(shared, ".superloopy")); + const workspace = join(shared, "isolated-project"); + await mkdir(workspace); + assert.equal( + resolveWorkspaceRoot(workspace, { sharedStateRoot: shared }), + realpathSync(workspace) + ); +}); From e85b387c5b23348e48738d56b61305e2ba3c8cd6 Mon Sep 17 00:00:00 2001 From: Beefiker Date: Fri, 24 Jul 2026 17:01:56 +0900 Subject: [PATCH 5/5] fix: address workspace continuity review --- src/cli.js | 12 ++++- src/compaction-recovery.js | 21 ++++++--- src/continuation.js | 4 ++ src/pre-tool-use.js | 3 +- src/repository-binding.js | 4 +- src/workspace-identity.js | 6 ++- test/cli-evidence.test.js | 22 +++++++++ test/compaction-recovery.test.js | 2 + test/pre-tool-use.test.js | 16 +++++++ test/repository-binding.test.js | 80 +++++++++++++++++++++++++++++++- 10 files changed, 157 insertions(+), 13 deletions(-) diff --git a/src/cli.js b/src/cli.js index e0012bd..d93b516 100755 --- a/src/cli.js +++ b/src/cli.js @@ -240,7 +240,8 @@ function hasSuperloopySignature(cwd) { } async function runLoop(subcommand, argv, stdout, cwd) { - const json = hasFlag(argv, "--json"); + const optionArgv = loopOptionArgv(subcommand, argv); + const json = hasFlag(optionArgv, "--json"); if (subcommand === "help" || subcommand === "--help" || subcommand === "-h") { stdout.write(helpText()); return 0; @@ -252,8 +253,9 @@ async function runLoop(subcommand, argv, stdout, cwd) { } async function dispatchLoop(cwd, subcommand, argv) { + const optionArgv = loopOptionArgv(subcommand, argv); if (!["begin", "create", "status", "bind"].includes(subcommand)) { - const scope = scopeFromSessionId(readFlag(argv, "--session-id")); + const scope = scopeFromSessionId(readFlag(optionArgv, "--session-id")); const plan = await readPlanUnchecked(cwd, scope); const binding = await inspectRepositoryBinding(cwd, plan); if (!binding.resumable) { @@ -305,6 +307,12 @@ async function dispatchLoop(cwd, subcommand, argv) { } } +function loopOptionArgv(subcommand, argv) { + if (subcommand !== "capture" && subcommand !== "prove") return argv; + const delimiter = argv.indexOf("--"); + return delimiter === -1 ? argv : argv.slice(0, delimiter); +} + async function runHook(subcommand, stdin, stdout) { const payload = parseJson(await readStdin(stdin)); const context = { host: isClaudeHost(process.env) ? "claude" : "codex" }; diff --git a/src/compaction-recovery.js b/src/compaction-recovery.js index 8233f08..3820fd2 100644 --- a/src/compaction-recovery.js +++ b/src/compaction-recovery.js @@ -19,7 +19,7 @@ export function buildRecoveryProjection({ status, guide, fleet }) { } export function renderRecoveryCapsule(projection, { maxChars = 4000 } = {}) { - const mandatory = [ + const head = [ "Superloopy compaction recovery", "", "Durable Superloopy state overrides transcript summaries and completion claims.", @@ -27,12 +27,21 @@ export function renderRecoveryCapsule(projection, { maxChars = 4000 } = {}) { `Session: ${projection.sessionId ?? "default"} · mode: ${projection.mode}`, `Aggregate complete: ${projection.aggregateComplete ? "yes" : "no"}`, projection.activeGoal === null ? "Active goal: none" : `Active goal: ${projection.activeGoal.id} ${projection.activeGoal.title}`, - `Unresolved criteria: ${projection.unresolved.length === 0 ? "none" : projection.unresolved.join(", ")}`, - `Next action: ${projection.nextAction ?? "inspect repository binding"}`, - `Outstanding handoffs: ${projection.outstanding.length === 0 ? "none" : projection.outstanding.join(", ")}`, + `Unresolved criteria: ${projection.unresolved.length === 0 ? "none" : projection.unresolved.join(", ")}` + ].join("\n"); + const nextAction = `Next action: ${projection.nextAction ?? "inspect repository binding"}`; + const gate = "Only the deterministic Superloopy gate authorizes completion."; + const outstanding = `Outstanding handoffs: ${projection.outstanding.length === 0 ? "none" : projection.outstanding.join(", ")}`; + const tail = [ + nextAction, + outstanding, "", - "Only the deterministic Superloopy gate authorizes completion." + gate ].join("\n"); + const mandatory = `${head}\n${tail}`; if (mandatory.length <= maxChars) return mandatory; - return `${mandatory.slice(0, Math.max(0, maxChars - 32)).trimEnd()}\n[recovery capsule truncated]`; + const truncatedTail = `[recovery capsule truncated]\n${nextAction}\n${gate}\n${outstanding}`; + const headBudget = maxChars - truncatedTail.length - 1; + if (headBudget <= 0) return truncatedTail.slice(0, Math.max(0, maxChars)); + return `${head.slice(0, headBudget).trimEnd()}\n${truncatedTail}`; } diff --git a/src/continuation.js b/src/continuation.js index 378387e..dce8606 100644 --- a/src/continuation.js +++ b/src/continuation.js @@ -120,6 +120,10 @@ export async function decideContinuation(payload, deps) { ].join("\n") })}\n`; } + // A copied, legacy-unbound, or otherwise invalid plan is not resumable. Stop + // without writing loop-control or ledger state; the binding flow owns recovery. + if (status.ok === false || status.binding?.resumable === false) return ""; + // Termination authority is unchanged and absolute. if (status.summary.aggregateComplete) { await clearLoopControl(cwd, scope); diff --git a/src/pre-tool-use.js b/src/pre-tool-use.js index f822d9b..0697ce6 100644 --- a/src/pre-tool-use.js +++ b/src/pre-tool-use.js @@ -10,6 +10,7 @@ import { existsSync, readFileSync } from "node:fs"; import { goalsPath, scopeFromSessionId } from "./store.js"; import { appendContextCost } from "./context-cost.js"; +import { resolveWorkspaceRoot } from "./workspace-identity.js"; const CREATE_GOAL_PAYLOAD_WARNING = "Use create_goal with objective only. Omit token_budget so the goal stays unlimited; use update_goal only after Superloopy aggregate completion is recorded."; @@ -41,7 +42,7 @@ function wantsGoalComplete(value) { function prematureUpdateGoalReason(payload) { if (typeof payload.cwd !== "string") return null; - const planPath = activePlanPath(payload.cwd, payload.session_id); + const planPath = activePlanPath(resolveWorkspaceRoot(payload.cwd), payload.session_id); if (planPath === null) return null; try { const plan = JSON.parse(readFileSync(planPath, "utf8")); diff --git a/src/repository-binding.js b/src/repository-binding.js index b722168..ee3eb8f 100644 --- a/src/repository-binding.js +++ b/src/repository-binding.js @@ -3,6 +3,7 @@ import { readFlag } from "./args.js"; import { appendLedger, goalsPath, + normalizeLoopSessionId, nowIso, readPlanUnchecked, scopeFromSessionId, @@ -70,6 +71,7 @@ function escapes(value) { } function bindCommand(sessionId) { - const session = typeof sessionId === "string" && sessionId.length > 0 ? ` --session-id ${sessionId}` : ""; + const normalized = normalizeLoopSessionId(sessionId); + const session = normalized === null ? "" : ` --session-id ${normalized}`; return `superloopy loop bind --confirm-current-root${session} --json`; } diff --git a/src/workspace-identity.js b/src/workspace-identity.js index f4fd286..54193d4 100644 --- a/src/workspace-identity.js +++ b/src/workspace-identity.js @@ -7,8 +7,9 @@ import { basename, dirname, isAbsolute, join, resolve } from "node:path"; export function resolveWorkspaceRoot(start, options = {}) { const origin = realpathSync(resolve(start)); const git = findNearestGitWorktree(origin); - if (git !== null) return git.root; - const sharedStateRoot = realpathSync(resolve(options.sharedStateRoot ?? tmpdir())); + const sharedStateRoot = git === null + ? realpathSync(resolve(options.sharedStateRoot ?? tmpdir())) + : null; let cursor = origin; while (true) { if (cursor === sharedStateRoot && cursor !== origin) return origin; @@ -17,6 +18,7 @@ export function resolveWorkspaceRoot(start, options = {}) { } catch { // Keep walking. } + if (git !== null && cursor === git.root) return git.root; const parent = dirname(cursor); if (parent === cursor) return origin; cursor = parent; diff --git a/test/cli-evidence.test.js b/test/cli-evidence.test.js index 4cd672f..172fd41 100644 --- a/test/cli-evidence.test.js +++ b/test/cli-evidence.test.js @@ -58,6 +58,28 @@ test("CLI loop capture records a transcript from command args after --", async ( assert.equal(parsed.guide.criterion.id, "C002"); }); +test("CLI loop prove ignores validation-command session flags after --", async () => { + const repo = await tempRepo(); + await createLoop(repo, ["--brief", "Ship"]); + runCli(["loop", "next", "--json"], { cwd: repo }); + + const result = runCli([ + "loop", + "prove", + "--json", + "--", + process.execPath, + "-e", + "console.log('validation session flag')", + "--", + "--session-id", + "validation-scope" + ], { cwd: repo }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(result.stdout).criterion.status, "pass"); +}); + test("CLI loop evidence text shows the immediate next guide", async () => { const repo = await tempRepo(); await mkdir(repo, { recursive: true }); diff --git a/test/compaction-recovery.test.js b/test/compaction-recovery.test.js index b9799a7..1424911 100644 --- a/test/compaction-recovery.test.js +++ b/test/compaction-recovery.test.js @@ -41,6 +41,8 @@ test("bounded recovery rendering retains mandatory completion and next-action tr assert.ok(rendered.length <= 700); assert.match(rendered, /Aggregate complete: no/u); assert.match(rendered, /Durable Superloopy state overrides/u); + assert.match(rendered, /Next action: superloopy loop prove -- /u); + assert.match(rendered, /Only the deterministic Superloopy gate authorizes completion/u); }); test("transcript claims cannot override incomplete durable state", async () => { diff --git a/test/pre-tool-use.test.js b/test/pre-tool-use.test.js index 772f10c..ede5bb5 100644 --- a/test/pre-tool-use.test.js +++ b/test/pre-tool-use.test.js @@ -39,6 +39,22 @@ test("PreToolUse blocks native update_goal completion while Superloopy is incomp assert.match(parsed.hookSpecificOutput.additionalContext, /superloopy loop finish/); }); +test("PreToolUse finds an incomplete canonical plan from a child directory", async () => { + const repo = await tempRepo(); + await createLoop(repo, ["--brief", "Ship"]); + const child = join(repo, "packages", "app"); + await mkdir(child, { recursive: true }); + + const output = runPreToolUseHook({ + hook_event_name: "PreToolUse", + tool_name: "update_goal", + cwd: child, + tool_input: { status: "complete" } + }); + + assert.equal(JSON.parse(output).hookSpecificOutput.permissionDecision, "deny"); +}); + test("PreToolUse allows native update_goal completion after Superloopy aggregate completion", async () => { const repo = await tempRepo(); await createLoop(repo, ["--brief", "Ship"]); diff --git a/test/repository-binding.test.js b/test/repository-binding.test.js index e440517..47d0408 100644 --- a/test/repository-binding.test.js +++ b/test/repository-binding.test.js @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; -import { runUserPromptSubmitHook } from "../src/hooks.js"; +import { runStopHook, runUserPromptSubmitHook } from "../src/hooks.js"; import { bindLegacyLoop } from "../src/repository-binding.js"; import { createLoop, statusLoop } from "../src/loop.js"; @@ -14,6 +15,17 @@ async function repo(prefix = "superloopy-binding-") { return mkdtemp(join(tmpdir(), prefix)); } +async function withStopHookEnabled(fn) { + const previous = process.env.SUPERLOOPY_STOP_HOOK; + process.env.SUPERLOOPY_STOP_HOOK = "on"; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env.SUPERLOOPY_STOP_HOOK; + else process.env.SUPERLOOPY_STOP_HOOK = previous; + } +} + test("new plans carry a valid version 2 repository binding", async () => { const root = await repo(); const result = await createLoop(root, ["--brief", "Bind workspace"]); @@ -31,6 +43,34 @@ test("CLI commands from a child directory reuse the canonical root plan", async assert.equal(JSON.parse(result.stdout).binding.status, "bound"); }); +test("CLI discovers and binds a legacy plan created in a Git subdirectory", async () => { + const root = await repo(); + spawnSync("git", ["init", "-q", root]); + const packageRoot = join(root, "packages", "app"); + await mkdir(packageRoot, { recursive: true }); + await createLoop(packageRoot, ["--brief", "Legacy package"]); + const path = join(packageRoot, ".superloopy", "goals.json"); + const plan = JSON.parse(await readFile(path, "utf8")); + plan.version = 1; + delete plan.repositoryBinding; + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`); + + const status = spawnSync(process.execPath, [CLI, "loop", "status", "--json"], { + cwd: packageRoot, + encoding: "utf8" + }); + assert.equal(status.status, 1, status.stderr); + assert.equal(JSON.parse(status.stdout).binding.status, "legacy_unbound"); + + const binding = spawnSync( + process.execPath, + [CLI, "loop", "bind", "--confirm-current-root", "--json"], + { cwd: packageRoot, encoding: "utf8" } + ); + assert.equal(binding.status, 0, binding.stderr); + assert.equal(JSON.parse(binding.stdout).plan.version, 2); +}); + test("copied plan state reports mismatch and refuses CLI mutation", async () => { const source = await repo(); const target = await repo("superloopy-binding-copy-"); @@ -69,6 +109,27 @@ test("copied plan state refuses steering and engineer resume hooks", async () => assert.match(engineer.hookSpecificOutput.additionalContext, /repository binding is mismatch/i); }); +test("Stop hook never resumes or mutates mismatched repository state", async () => withStopHookEnabled(async () => { + const source = await repo(); + const target = await repo("superloopy-binding-stop-"); + await createLoop(source, ["--brief", "Source"]); + await mkdir(join(target, ".superloopy"), { recursive: true }); + await writeFile( + join(target, ".superloopy", "goals.json"), + await readFile(join(source, ".superloopy", "goals.json")) + ); + + const output = await runStopHook({ + hook_event_name: "Stop", + cwd: target, + stop_hook_active: false + }); + + assert.equal(output, ""); + assert.equal(existsSync(join(target, ".superloopy", "loop-control.json")), false); + assert.equal(existsSync(join(target, ".superloopy", "ledger.jsonl")), false); +})); + test("legacy plans require explicit confined binding", async () => { const root = await repo(); await createLoop(root, ["--brief", "Legacy"]); @@ -83,6 +144,23 @@ test("legacy plans require explicit confined binding", async () => { assert.equal((await statusLoop(root)).binding.status, "bound"); }); +test("legacy bind guidance normalizes an untrusted session id", async () => { + const root = await repo(); + await createLoop(root, ["--brief", "Legacy"]); + const path = join(root, ".superloopy", "goals.json"); + const plan = JSON.parse(await readFile(path, "utf8")); + plan.version = 1; + delete plan.repositoryBinding; + plan.sessionId = "abc; touch /tmp/pwn"; + await writeFile(path, `${JSON.stringify(plan, null, 2)}\n`); + + const status = await statusLoop(root); + assert.equal( + status.binding.next, + "superloopy loop bind --confirm-current-root --session-id abc-touch-tmp-pwn --json" + ); +}); + test("legacy binding rejects escaping stored paths", async () => { const root = await repo(); await createLoop(root, ["--brief", "Legacy"]);