diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 52d11c7..20b1140 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pinmind", - "version": "0.8.2", + "version": "0.9.0", "description": "Adaptive RU/EN task controller with deterministic routing, outcome contracts, and verified evidence.", "author": { "name": "Pinmind Project" diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index 0378f40..414b89c 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pinmind", - "version": "0.8.2", + "version": "0.9.0", "description": "Adaptive RU/EN task controller with deterministic routing, outcome contracts, and verified evidence.", "author": { "name": "Pinmind Project" diff --git a/CHANGELOG.md b/CHANGELOG.md index d38d8ac..17178cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/2.0.0/), and ## [Unreleased] +## [0.9.0] - 2026-08-19 + +### Removed + +- Token-usage receipts, `usage record`, `usage.json`, `token-usage.md`, and + token sections in reports. The kernel no longer stores or prints token + counts. + +### Changed + +- Router classifies by speech act before nouns. Critique, inspection, and + "think about" stay `audit` even when the sentence mentions code or + optimization. `чтобы он был более человечный` is not product-desire. +- Always-loaded `SKILL.md` is one screen. Work-loop and routing essays stay in + their reference files. +- Kernel router lives in `scripts/lib/route.mjs`. `core.mjs` re-exports it. + +### Added + +- Eight speech-act routing regressions. The deterministic route corpus is now + 455 cases. + ## [0.8.2] - 2026-08-19 ### Added diff --git a/LANGUAGE_ROUTING.md b/LANGUAGE_ROUTING.md index b7e4898..c590409 100644 --- a/LANGUAGE_ROUTING.md +++ b/LANGUAGE_ROUTING.md @@ -1,6 +1,6 @@ # Language routing and activation evaluation -Status: Phase A is included in the stable `0.6` line, following its experimental introduction in `0.5.0-experimental`. The current `0.8.2` source has a **447-case deterministic routing regression corpus**, **64 closed-schema language-evaluation cases** (32 development and 32 frozen release-gate cases), and **28 host-activation fixtures**. The local evaluator and release gate are implemented; fresh-host selection and end-task utility remain separate, unproven layers. The release split is a fixed regression gate, not a statistically independent benchmark. This document does not claim that ChatGPT, Codex, or Grok will activate Pinmind for every possible phrase. +Status: Phase A is included in the stable `0.6` line, following its experimental introduction in `0.5.0-experimental`. The current `0.9.0` source has a **455-case deterministic routing regression corpus**, **64 closed-schema language-evaluation cases** (32 development and 32 frozen release-gate cases), and **28 host-activation fixtures**. The local evaluator and release gate are implemented; fresh-host selection and end-task utility remain separate, unproven layers. The release split is a fixed regression gate, not a statistically independent benchmark. This document does not claim that ChatGPT, Codex, or Grok will activate Pinmind for every possible phrase. ## Decision @@ -137,7 +137,7 @@ Selection observations require a fresh ChatGPT chat or Codex thread and concrete ### Phase A — phrase corpus and evaluator (implemented) -1. The 447-case deterministic router corpus and 28 host-activation fixtures remain compatibility and host-boundary coverage; they are not substitutes for the split language corpus. +1. The 455-case deterministic router corpus and 28 host-activation fixtures remain compatibility and host-boundary coverage; they are not substitutes for the split language corpus. 2. `language-dev.json` and `language-release.json` contain 64 original or sanitized cases, 32 validated pairs, closed provenance, and non-overlapping prompt hashes. 3. `scripts/evaluate-language-routing.mjs` reports exact decisions, authority, mutation boundaries, pair agreement, unsafe downgrades, and locale/phenomenon slices. The frozen release gate requires 100% agreement. 4. Grow only from reviewed real misses; do not generate hundreds merely to raise a count. Keep the smaller representative host subset in `activation-smoke.json`. diff --git a/PRIVACY.md b/PRIVACY.md index f813341..6dae8f0 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy -Pinmind `0.8.2` is a skills-only plugin. It provides local instructions, a bundled kernel, and offline AEP and language-routing evaluation corpora; it has no account system, connector, MCP server, telemetry service, or project-operated network endpoint. +Pinmind `0.9.0` is a skills-only plugin. It provides local instructions, a bundled kernel, and offline AEP and language-routing evaluation corpora; it has no account system, connector, MCP server, telemetry service, or project-operated network endpoint. Pinmind processes only the task context and files made available by the active ChatGPT or Codex host. Data handling by that host and by any separately enabled tool is governed by the corresponding provider and user configuration. diff --git a/README.md b/README.md index 5f9706a..ae0dfdc 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Pinmind is a Russian-and-English workflow controller packaged as a Grok skill and a Codex App plugin. It classifies non-trivial work, including colloquial and lightly misspelled Russian, applies a process proportional to risk, composes specialist skills, and requires current evidence before calling a task complete. -Current stable version: `0.8.2`. +Current stable version: `0.9.0`. - GitHub repository marketplace: included in this repository. - Universal Plugins Directory: **not listed yet**. ChatGPT catalog steps apply only after OpenAI approval and a live listing check. @@ -16,14 +16,14 @@ Current stable version: `0.8.2`. 1. Add the pinned stable repository marketplace: ```bash - codex plugin marketplace add iammedved/Pinmind --ref v0.8.2 + codex plugin marketplace add iammedved/Pinmind --ref v0.9.0 ``` 2. Start Codex, run `/plugins`, select **Pinmind Project**, and install **Pinmind**. 3. Start a new Codex session. 4. Run `/skills` and confirm that `pinmind` is available. -The repository marketplace is separate from OpenAI's universal Plugins Directory. Pinning `v0.8.2` selects this exact stable release; omit `--ref` only when you intentionally want the latest repository state. +The repository marketplace is separate from OpenAI's universal Plugins Directory. Pinning `v0.9.0` selects this exact stable release; omit `--ref` only when you intentionally want the latest repository state. ### Codex CLI: upgrade or reinstall a reviewed revision @@ -64,7 +64,7 @@ grok plugin install . --trust Start a new Grok session. Pinmind should appear in `/skills`. The host still chooses implicit activation; `$pinmind` or `@Pinmind` is the reliable trigger for critical work. -Start a new Codex session and send: +Send: ```text $pinmind Audit this repository without changing files. Determine the route first and report only verified findings. @@ -177,7 +177,7 @@ publish or display Codex cachebuster build metadata. The stable public line starts at `0.6.0`. Backward-compatible fixes use patch versions such as `0.6.1`, `0.6.2`, `0.6.3`, `0.8.1`, and `0.8.2`; backward-compatible feature releases use a -new minor such as `0.7.0` or `0.8.0`. Existing experimental tags are immutable history and +new minor such as `0.7.0`, `0.8.0`, or `0.9.0`. Existing experimental tags are immutable history and are never moved to newer commits. ## Validation @@ -218,6 +218,7 @@ node --test tests/language-routing-evaluator.test.mjs node --test tests/release-verification.test.mjs node scripts/validate-plugin-skill.mjs node --check skills/pinmind/scripts/lib/core.mjs +node --check skills/pinmind/scripts/lib/route.mjs node --check skills/pinmind/scripts/pinmind.mjs node scripts/check-release-identity.mjs node scripts/check-repository-diff.mjs diff --git a/ROADMAP.md b/ROADMAP.md index 48f7b44..dd82913 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ ## Verified baseline -The released public baseline is `v0.8.2`; see [CHANGELOG.md](CHANGELOG.md). It +The released public baseline is `v0.9.0`; see [CHANGELOG.md](CHANGELOG.md). It provides deterministic post-activation routing, local run-state containment and recovery diagnostics, baseline/freshness evidence, read-only `final check`, and explicit `finalize`. It also includes the reviewed P0 routing contrasts, required diff --git a/evals/fixtures/routes.json b/evals/fixtures/routes.json index 4d8ff83..ee56608 100644 --- a/evals/fixtures/routes.json +++ b/evals/fixtures/routes.json @@ -7792,5 +7792,133 @@ "signalsInclude": [ "intent:change" ] + }, + { + "name": "p083-speech-critique-inconsistencies-ru", + "input": { + "text": "покритикуй pinmind найти возможные не состыковки в тексте или в коде, продумай как можно было бы сделать проще и легче, подумай над оптимизацией кода, чтобы он был более человечный, а не ИИшный с присущим ему AI-slop" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "clarity": "clear", + "needsHumanConfirmation": false, + "signalsInclude": [ + "intent:inspect", + "intent:think" + ], + "signalsExclude": [ + "intent:implement-directive", + "intent:change", + "intent:unrecognized" + ] + }, + { + "name": "p083-speech-think-simpler-ru", + "input": { + "text": "продумай как можно было бы сделать проще и легче" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsInclude": [ + "intent:think" + ], + "signalsExclude": [ + "intent:unrecognized", + "intent:change" + ] + }, + { + "name": "p083-speech-think-optimize-human-ru", + "input": { + "text": "подумай над оптимизацией кода, чтобы он был более человечный, а не ИИшный" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsInclude": [ + "intent:think" + ], + "signalsExclude": [ + "intent:implement-directive", + "intent:change" + ] + }, + { + "name": "p083-speech-quality-clause-not-desire-ru", + "input": { + "text": "чтобы он был более человечный" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "needsHumanConfirmation": true, + "signalsExclude": [ + "intent:product-desire", + "intent:implement-directive" + ] + }, + { + "name": "p083-speech-find-inconsistencies-in-code-ru", + "input": { + "text": "найди несостыковки в тексте или в коде" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsInclude": [ + "intent:inspect" + ], + "signalsExclude": [ + "intent:change" + ] + }, + { + "name": "p083-speech-think-optimize-en", + "input": { + "text": "think about optimizing the code so it is more human, not AI-slop" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsInclude": [ + "intent:think" + ], + "signalsExclude": [ + "intent:change" + ] + }, + { + "name": "p083-speech-optimize-imperative-en", + "input": { + "text": "optimize the code to make it more human and less AI-slop" + }, + "expected": "software-change", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsInclude": [ + "intent:change" + ] + }, + { + "name": "p083-speech-audit-inconsistencies-en", + "input": { + "text": "Audit pinmind, find inconsistencies, think how to simplify the code so it feels more human" + }, + "expected": "audit", + "pluginVersion": "0.8.3", + "surface": "public-route-cli", + "disposition": "human-authored", + "signalsExclude": [ + "intent:change" + ] } ] diff --git a/evals/release-manifest.json b/evals/release-manifest.json index ca3917a..ae41cfd 100644 --- a/evals/release-manifest.json +++ b/evals/release-manifest.json @@ -5,8 +5,8 @@ "frozenInputs": [ { "role": "router", - "path": "skills/pinmind/scripts/lib/core.mjs", - "sha256": "8bffc441e4921c2842c056d11feac9cf2b88181ab3861f5c033eaebe6891794d" + "path": "skills/pinmind/scripts/lib/route.mjs", + "sha256": "13afa103911b8b3ac41f683bc7540d84c58bbde6158d6af15082a309eb1ecbed" }, { "role": "language-schema-validator", @@ -31,7 +31,7 @@ { "role": "unsafe-negative-regressions", "path": "evals/fixtures/routes.json", - "sha256": "84022417631cf34eaa19956b95547b9ebf667998a963010c7746082ec1075134" + "sha256": "bbe14873c7c41c4b8ee59a58dadef5bf7a589288e6f78edd1a8b3b4007deb6d0" } ], "commands": [ @@ -111,6 +111,14 @@ "skills/pinmind/scripts/lib/core.mjs" ] }, + { + "id": "route-syntax", + "argv": [ + "node", + "--check", + "skills/pinmind/scripts/lib/route.mjs" + ] + }, { "id": "cli-syntax", "argv": [ @@ -158,7 +166,7 @@ } ], "fixtureCases": { - "routes": 447, + "routes": 455, "activation": 28, "aepDev": 8, "aepRelease": 8, diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 2c8e5b1..3e09605 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -47,7 +47,7 @@ const INVENTORY_KEYS = new Set(['testFiles', 'fixtureCases']); const TEST_FILE_KEYS = new Set(['path', 'topLevelDeclarations']); export const EXPECTED_FROZEN_INPUTS = Object.freeze([ - ['router', 'skills/pinmind/scripts/lib/core.mjs'], + ['router', 'skills/pinmind/scripts/lib/route.mjs'], ['language-schema-validator', 'scripts/evaluate-language-routing.mjs'], ['github-web-flow-key', 'scripts/keys/github-web-flow.gpg'], ['development-corpus', 'evals/fixtures/language-dev.json'], @@ -66,6 +66,7 @@ export const EXPECTED_COMMANDS = Object.freeze([ Object.freeze({ id: 'release-tests', argv: Object.freeze(['node', '--test', 'tests/release-verification.test.mjs']) }), Object.freeze({ id: 'plugin-skill-validator', argv: Object.freeze(['node', 'scripts/validate-plugin-skill.mjs']) }), Object.freeze({ id: 'core-syntax', argv: Object.freeze(['node', '--check', 'skills/pinmind/scripts/lib/core.mjs']) }), + Object.freeze({ id: 'route-syntax', argv: Object.freeze(['node', '--check', 'skills/pinmind/scripts/lib/route.mjs']) }), Object.freeze({ id: 'cli-syntax', argv: Object.freeze(['node', '--check', 'skills/pinmind/scripts/pinmind.mjs']) }), Object.freeze({ id: 'release-identity', argv: Object.freeze(['node', 'scripts/check-release-identity.mjs']) }), Object.freeze({ id: 'diff-check', argv: Object.freeze(['node', 'scripts/check-repository-diff.mjs']) }), diff --git a/skills/pinmind/SKILL.md b/skills/pinmind/SKILL.md index a27d857..66e1429 100644 --- a/skills/pinmind/SKILL.md +++ b/skills/pinmind/SKILL.md @@ -1,136 +1,63 @@ --- name: pinmind -description: "Default RU/EN controller for every new Grok, Codex, or ChatGPT chat on non-trivial Russian or English tasks (задачи на русском или английском, including colloquial RU) requiring tools/files, multi-step reasoning, research/исследование, audit/аудит, diagnosis/диагностику, planning/планирование, implementation, or changes to code, UI, documents, data, integrations, or tests. Use implicitly at session start alongside required domain skills; after selection, load only this SKILL.md and immediately run the bundled route before memory, references, workspace inspection, or task tools. Do not use for greetings, one stable fact, tiny translation/rewrite, or trivial formatting." +description: "Default RU/EN controller for non-trivial Russian or English work, including colloquial RU (разговорный русский): research/исследование, audit, diagnosis/диагностика, or code changes. After selection, load this SKILL.md and run the bundled route before memory, references, or task tools. Do not use for greetings, one stable fact, tiny translation, or trivial formatting." --- # Pinmind -Deliver substantive outcomes through one adaptive controller. Treat the user's request as intent, the frozen contract as the normative outcome, and current evidence as the basis for completion. Keep process proportional and communicate in the user's language. +Classify the request, then do only as much process as the route needs. Speak the user's language. ## Mandatory first action -Except for a higher-priority required skill-use announcement and loading this `SKILL.md`, run the bundled kernel router before the first substantive progress update, before reading any task reference or memory, before workspace inspection, and before any task tools or writes. The only permitted bootstrap work before routing is locating the bundled kernel and preparing the sanitized request input needed by `route --file`. - -The request file must contain the full sanitized user wording under the exact `text` field; do not paraphrase it, rename the field, or add inferred authority: - -```json -{"text":""} -``` +Except for a higher-priority required skill-use announcement and loading this `SKILL.md`, run the bundled kernel router before the first substantive progress update, before reading any task reference or memory, before workspace inspection, and before any task tools or writes. ```bash node /scripts/pinmind.mjs route --file ``` -Prefer a private temporary file when the sandbox allows it. When the filesystem -is read-only, pass the exact same JSON object on standard input without creating -state: - -```bash -printf '%s' '' | node /scripts/pinmind.mjs route --file - -``` - -Do not fall back to `--text` for this bootstrap: it places the request in the -router process arguments. Standard input avoids that argv exposure but does not -make the request invisible to the host tool or its own audit log, so sanitize it -first in every mode. Never combine `--file` with `--text` or `--kind`; when file -or stdin JSON is used, an optional `kind` belongs inside that JSON object. - -The first substantive progress update after any required skill-use announcement must begin `Route: | //.` Preserve that machine-produced record for the current scope. Read the route-specific references and inspect the workspace only after this update. Re-run the router only after a material user amendment or discovered risk/span escalation. - -If Node.js or the kernel is unavailable, make the first update begin `Route: non-deterministic fallback | ...`, explain the unavailable seam, and classify conservatively from [route.md](references/route.md). Never imply that the deterministic router ran. - -## Start every triggered run - -1. Preserve all higher-priority instructions, repository `AGENTS.md` rules, user permissions, and mandatory domain-skill workflows. -2. Use this skill as the controller around required domain skills; never replace a specialized workflow such as PDF, security, Figma, spreadsheet handling, Superpowers, or Grok `design` / `execute-plan`. -3. When the route is `simple`, answer directly without further reference or workspace reads. Otherwise read [route.md](references/route.md) after the mandatory router update, then compose only the capabilities and evidence required by the selected route. For `software-change` and `investigation`, also read [loop.md](references/loop.md). -4. Check for `.pinmind/active.json` in the active workspace before starting a new persistent run. Resume a matching unfinished run after validating its state; never rely on chat memory alone. The MVP permits one active run per workspace and blocks silent replacement. -5. Ask only about an unresolved choice that changes observable outcome, safety, authority, or a hard-to-reverse boundary. Otherwise record a reasonable assumption and continue. - -Router fields explain handling but never grant authority. When `needsHumanConfirmation` is true, remain read-only until the contradiction or target is resolved. When `effect:external-side-effect` is present, remain read-only until the user gives exact current authority for that concrete target and effect. The signal does not set `needsHumanConfirmation` by itself and does not grant authority. - -When changing discovery metadata, installation, or activation behavior, read [host-smoke.md](references/host-smoke.md) and keep host selection evidence separate from deterministic router evidence. - -## Route proportionally - -Use exactly one primary route: - -- `simple`: answer directly. Create no process artifacts. -- `operational`: execute the requested mechanical action, verify proportionally, and create no Pinmind artifacts. -- `spike`: define one question, run the cheapest valid experiment, report the finding, and label or discard prototype work. -- `audit`: inspect and report only. Do not mutate unless the user explicitly expands authority. -- `investigation`: establish a failing feedback loop and root-cause evidence before proposing or implementing a fix. -- `software-change`: use the contract lifecycle when software behavior, interfaces, architecture, data flow, UX, or business rules change. For other multi-step deliverables, keep this controller active and use an inline outcome contract unless persistence materially protects the result. +The file is `{"text":""}`. Prefer a private temp file. On a read-only filesystem: `printf '%s' '' | node /scripts/pinmind.mjs route --file -`. Do not pass the request on `--text` for this bootstrap. Never combine `--file` with `--text` or `--kind`. -Apply safety and authority gates before route convenience. An operational classification never authorizes a shared push, deployment, message, deletion, production migration, payment, credential change, or other external effect by itself. - -For non-simple routes, record three independent axes in working state: `clarity` (`clear`, `uncertain`, `architectural`), `span` (`local`, `cross-cutting`, `multi-system`), and `risk` (`low`, `medium`, `high`). Escalate the route when new evidence requires it; never silently downgrade risk. Unclear, unrecognized, or contradictory wording stays `uncertain` and read-only until confirmed; never dump it into `software-change`. - -## Work loop - -After the route line, keep Superpowers' defining loop without extra artifacts on light routes: - -- Design or name 2–3 alternatives before implementing a behavior change. A bounded change can be two sentences in chat; still stop for approval when the outcome is reversible only with effort. -- Observe a failing public-seam check before a production behavior change. -- Collect root-cause evidence before a fix. -- Run fresh verification before any "done" claim. - -`simple` and `operational` stay a light no-artifact path: no `.pinmind` contract, no evidence store, no process files. Authority gates still block shared-branch push, deployment, deletion, production migration, payment, and credential changes. - -## Build and freeze the outcome contract - -For a software-change run, read [contract.md](references/contract.md) and use the bundled kernel when Node.js is available. Run it from the target workspace and provide a prepared brief source file: - -```bash -node /scripts/pinmind.mjs init --run --brief -``` - -Use persistent `.pinmind` artifacts for substantive workspace changes. Keep local, low-risk reasoning inline when persistence would cost more than it protects, but never omit observable acceptance or verification. Never edit a frozen contract in place. - -## Execute through evidence - -Read [execution.md](references/execution.md) before changing substantive behavior. - -Select one contract slice at a time. Prefer a vertical feedback loop: +Start the first progress update with the kernel record: ```text -choose obligation -> observe valid failure -> implement minimum behavior --> observe pass -> refactor without behavior drift -> record evidence +Route: | //. ``` -Test through public seams. Keep expected values independent from production logic. Preserve existing behavior explicitly in brownfield work. Work inline by default. A large coupled task is not a reason to spawn; admit parallelism only by the execution.md five-yes rule. +If Node or the kernel is missing, start with `Route: non-deterministic fallback | ...` and classify conservatively from [route.md](references/route.md). Re-run only after a material user amendment or a discovered risk/span escalation. -## Verify before claiming completion - -Read [verification.md](references/verification.md) for any substantive or risky run. +## Route proportionally -Run fresh targeted checks and the broadest affordable regression checks. Verify four separate questions: +Exactly one primary route. Details live in [route.md](references/route.md). When the route is `simple`, answer directly without further reference or workspace reads. -1. Did the contract cover the brief? -2. Does current evidence prove every MUST, invariant, preservation rule, and forbidden-change boundary? -3. Does the real user journey achieve the original goal? When fresh independent context is available, test this goal axis from the immutable brief and running outcome without exposing the contract; evaluate contract compliance separately. If only an inline check is possible, label it non-independent. -4. Is the whole change maintainable, safe, and internally consistent? +| Route | Do | +|---|---| +| `simple` | Answer. Create no process artifacts. | +| `operational` | Do the mechanical action. Create no Pinmind artifacts. | +| `spike` | One question, cheapest valid experiment, label or discard the prototype. | +| `audit` | Inspect and report. Do not mutate unless the user expands authority. | +| `investigation` | Failing feedback loop and root-cause evidence before a fix. See [loop.md](references/loop.md). | +| `software-change` | Frozen outcome contract and evidence. See [contract.md](references/contract.md) and [loop.md](references/loop.md). | -Never convert uncertainty into a pass by judgment alone. +`simple` and `operational` stay a light no-artifact path. Speech act beats nouns: inspect, critique, and think-about stay `audit` even if the text mentions code. Unclear or contradictory wording stays `audit` and read-only until confirmed; do not dump it into `software-change`. Router fields never grant authority. -## Break failing loops +Axes: `clarity` (`clear`, `uncertain`, `architectural`), `executionSpan` (`local`, `cross-cutting`, `multi-system`), `risk` (`low`, `medium`, `high`). Escalate when evidence requires it; never silently downgrade risk. -Stop repeating the same strategy when the same failure class occurs twice, three repair rounds finish, scope expands materially, a new public boundary appears, evidence cannot distinguish success from failure, or the next action would be a guess. +Ask only when an unresolved choice changes outcome, safety, authority, or a hard-to-reverse boundary. Otherwise record an assumption and continue. -Re-check the original request and current evidence. Search the web or primary sources when local docs and the repo cannot settle the blocker. Then change strategy or report the real gap. +## Stop -When Pinmind itself misses activation, misroutes, downgrades unsafely, composes the wrong capability, or produces defective evidence/receipts, read [regression-inbox.md](references/regression-inbox.md). Preserve a sanitized reproducible case before changing metadata or policy; never self-edit merely because one task felt awkward. +Remain read-only when `needsHumanConfirmation` is true, or when `effect:external-side-effect` is present until the user names the concrete target and effect. Operational routing never authorizes a shared push, deploy, message, deletion, production migration, payment, or credential change. -## Protect the workspace +Check `.pinmind/active.json` before a new persistent run. Resume a matching unfinished run; the MVP allows one active run per workspace. See [safety.md](references/safety.md) for secrets, dirty trees, and production. -Read [safety.md](references/safety.md) for high-risk work, external side effects, secrets, migrations, production, or destructive operations. +Keep specialist skills (PDF, Superpowers, `/design`, `/execute-plan`). Pinmind wraps them; it does not replace them. -Never erase a dirty tree, expose secrets in artifacts, weaken acceptance to make tests pass, or perform deployment, publication, payment, messaging, deletion, production migration, shared-branch push, or credential rotation without the user's required authority. +## Loop -## Finalize honestly +For `software-change` and `investigation`, follow [loop.md](references/loop.md): design or name 2–3 alternatives, observe a failing public-seam check, collect root-cause evidence before a fix, and run fresh verification before any "done" claim. Search the web or primary sources when the repo cannot settle the blocker. Parallelism only by the five-yes rule in [execution.md](references/execution.md). -Generate the final report from current artifacts and fresh evidence, not memory. See [verification.md](references/verification.md) for the completed, failed, unproven, and manual split. +## Finish -Do not report token usage and do not read token-accounting references while finishing a task. Token counting is out of the skill and plugin surface so it cannot displace routing, contracts, or verification. +[verification.md](references/verification.md) splits completed, failed, unproven, and manual. Kernel structure is necessary and not sufficient. Commands: [kernel-cli.md](references/kernel-cli.md). -Use the bundled kernel for state, schema, trace, evidence, and freeze checks; see [kernel-cli.md](references/kernel-cli.md) for exact commands. Treat kernel validation as necessary but not sufficient: deterministic structure cannot replace product judgment or a real user journey. +If Pinmind itself misroutes or produces bad evidence, capture a sanitized case first: [regression-inbox.md](references/regression-inbox.md). diff --git a/skills/pinmind/agents/openai.yaml b/skills/pinmind/agents/openai.yaml index 06e9113..3245b39 100644 --- a/skills/pinmind/agents/openai.yaml +++ b/skills/pinmind/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Pinmind" short_description: "Adaptive verified RU/EN task controller" - default_prompt: "Use $pinmind для нетривиальной задачи: route it deterministically before task inspection, compose needed domain skills, follow the design/public-seam/root-cause/verify loop, and prove the result with current evidence." + default_prompt: "For non-trivial work, determine the route first, load only the needed specialist skills, and prove the result with current evidence." policy: allow_implicit_invocation: true diff --git a/skills/pinmind/references/contract.md b/skills/pinmind/references/contract.md index 0d71a89..19ee800 100644 --- a/skills/pinmind/references/contract.md +++ b/skills/pinmind/references/contract.md @@ -19,7 +19,6 @@ Use this layout for a persistent run: brief.md contracts/contract-v001.json evidence.json - usage.json # actual host usage or explicit unavailable receipt state.json execution.json # only when work units pay back discoveries.md # only for reusable verified facts @@ -28,7 +27,7 @@ Use this layout for a persistent run: final.md ``` -Treat the sanitized immutable `brief.md`, dated user additions stored as amendment authority, the current frozen contract JSON, `evidence.json`, `usage.json`, and `state.json` as canonical. Generate human-readable summaries from them; do not hand-edit generated views. State explicitly when redaction means the stored brief is not byte-for-byte identical to chat input. Token usage is informational and cannot change a contract or evidence verdict. +Treat the sanitized immutable `brief.md`, dated user additions stored as amendment authority, the current frozen contract JSON, `evidence.json`, and `state.json` as canonical. Generate human-readable summaries from them; do not hand-edit generated views. State explicitly when redaction means the stored brief is not byte-for-byte identical to chat input. ## Contract contents diff --git a/skills/pinmind/references/design-decisions.md b/skills/pinmind/references/design-decisions.md index 42b64f1..c0b8be7 100644 --- a/skills/pinmind/references/design-decisions.md +++ b/skills/pinmind/references/design-decisions.md @@ -1,20 +1,17 @@ -# Design decisions: activation, routing, usage, and UI +# Design decisions: activation, routing, and UI Research snapshot: 2026-08-16. GitHub star counts are point-in-time discovery signals, not quality scores, and will drift. ## Host constraints - OpenAI [Build skills](https://learn.chatgpt.com/docs/build-skills) says discovery initially exposes the skill name and description; implicit selection depends on matching that description. It recommends concise scope, boundaries, and front-loaded trigger terms. It does not promise deterministic recognition of every language or phrasing. -- Codex [App Server](https://learn.chatgpt.com/docs/app-server) and the Codex SDK [`turn.completed` event](https://github.com/openai/codex/blob/main/sdk/typescript/src/events.ts) expose usage after model work completes. The instruction-only skill does not receive a documented, exact whole-task counter before composing its own final response. -- OpenAI [hooks](https://learn.chatgpt.com/docs/hooks) do not expose a stable whole-task token counter to a Stop hook. - A custom ChatGPT [plugin UI](https://developers.openai.com/plugins/build/chatgpt-ui) is an MCP resource rendered in an iframe. Adding it would introduce a server, lifecycle, CSP, privacy, and security boundary; see [Security and privacy](https://developers.openai.com/plugins/guides/security-privacy). Consequences: 1. Improve implicit activation at the discovery description, then route semantically after activation. 2. Treat arbitrary-language capture as a measured eval target, not a 100 percent guarantee. -3. Keep token accounting out of the skill and plugin surface so it cannot displace routing or verification. -4. Keep UI optional. A local read-only JSON/Markdown report answers the current question without a daemon or MCP server. +3. Keep UI optional. A local read-only JSON/Markdown report answers the current question without a daemon or MCP server. ## Project evidence @@ -45,9 +42,8 @@ Additional 100-star-or-more references shaped the implementation boundary: ```text concise bilingual host description -> deterministic post-activation route - (route + clarity + span + risk + signals + confidence) + (route + clarity + executionSpan + risk + signals + confidence) -> proportional skill composition and evidence lifecycle - -> no chat token line; optional kernel receipts stay off the skill surface -> optional hash-checked local receipt and read-only report ``` diff --git a/skills/pinmind/references/host-smoke.md b/skills/pinmind/references/host-smoke.md index e1dff3a..af9f140 100644 --- a/skills/pinmind/references/host-smoke.md +++ b/skills/pinmind/references/host-smoke.md @@ -26,7 +26,7 @@ Each observation must contain: "caseId": "ru-positive-investigation", "host": "codex-cli", "hostVersion": "observed version", - "pluginVersion": "0.8.2", + "pluginVersion": "0.9.0", "observedAt": "canonical UTC timestamp", "freshSession": true, "selection": "implicit", diff --git a/skills/pinmind/references/kernel-cli.md b/skills/pinmind/references/kernel-cli.md index 0d178e5..06dcd5a 100644 --- a/skills/pinmind/references/kernel-cli.md +++ b/skills/pinmind/references/kernel-cli.md @@ -54,7 +54,7 @@ node "$KERNEL" execution validate --run --file Provide `units[]` with `unitId`, obligation or criterion references, optional `dependsOn`, and non-empty relative `zone` paths. The gate rejects unknown traces, dependency cycles, unsafe paths, and overlapping zones between units that could run in parallel. -Usage and execution replacements take an optimistic snapshot before entering the writer lock. If another writer changed the same singleton view first, the later commit fails with `STALE_USAGE` or `STALE_EXECUTION` instead of reporting success for a silently overwritten update. Append-only evidence reloads under the lock, while contract and final-state commits revalidate their current version or status there. +Execution replacements take an optimistic snapshot before entering the writer lock. If another writer changed the same singleton view first, the later commit fails with `STALE_EXECUTION` instead of reporting success for a silently overwritten update. Append-only evidence reloads under the lock, while contract and final-state commits revalidate their current version or status there. ## Record and verify evidence @@ -81,16 +81,11 @@ An evidence template may declare `freshnessPaths`, a unique list of at most 64 c `final check` requires every evidence ID planned by each required MUST trace, invariant, and preservation rule to have trustworthy current passing evidence. It is read-only and byte-idempotent. Explicit `finalize` repeats the same gate while holding the writer lock before writing `final.md`, completing state, and clearing the active pointer. The legacy `final verify` command remains a deprecated compatibility alias for finalization. Neither command replays stored commands or external work. -## Record token usage and render reports - -Read [token-usage.md](token-usage.md) before connecting host telemetry. +## Render reports ```bash -node "$KERNEL" usage record --run --file node "$KERNEL" report --run --format json node "$KERNEL" report --run --format md ``` -New runs begin with `status: unavailable`. `usage record` accepts actual counts only with an allowed observed source and derives `totalTokens` as input plus output. Cached input, cache-write input, and reasoning output are subsets and are never double-counted. Invalid, negative, inconsistent, or secret-bearing data is rejected or redacted. A checksum mismatch blocks reporting of an accidentally changed or incompletely rewritten receipt; this unkeyed hash is not a security boundary against a writer who can recompute it. - -`report` is read-only. It renders lifecycle, baseline classification, evidence counts, and exact observed token usage or an explicit unavailable status. A post-turn host adapter may record authoritative usage after `finalize`; the report then reflects it without rewriting the contract or evidence verdict. +`report` is read-only. It renders lifecycle, baseline classification, evidence counts, and remaining contract boundaries. diff --git a/skills/pinmind/references/loop.md b/skills/pinmind/references/loop.md index f761711..b388628 100644 --- a/skills/pinmind/references/loop.md +++ b/skills/pinmind/references/loop.md @@ -41,5 +41,4 @@ Then change strategy or report the blocker. ## Do not copy Do not require a visual companion, a git worktree, or 2–5 minute plan -microsteps. Do not report or estimate token usage; it is out of the skill -surface. +microsteps. diff --git a/skills/pinmind/references/route.md b/skills/pinmind/references/route.md index c14bacd..51e7312 100644 --- a/skills/pinmind/references/route.md +++ b/skills/pinmind/references/route.md @@ -25,6 +25,8 @@ Do not equate short wording with a simple task. Treat "fix the race," "deploy th Never dump unrecognized, vague, or contradictory wording into `software-change`. Those cases stay `clarity: uncertain`, `needsHumanConfirmation: true`, and a read-only `audit` route until the user confirms the outcome. Classify paraphrases by intended outcome, not by the old fixture keywords. +Speech act beats nouns. Inspect, critique, review, and think-about stay `audit` even when the sentence mentions code, APIs, or optimization. `продумай` / `подумай` / `think about` is not an implement directive. A quality clause such as `чтобы он был более человечный` is not product-desire by itself. A plan followed by an explicit implement clause (`продумай план и начинай исправлять`) remains `software-change`. + ## Classification axes Classify independently: @@ -122,5 +124,5 @@ Honor an explicit `audit` or `investigation` kind because both are non-mutating Use one-line progress wording such as: ```text -Route: software-change | clear/local/medium - user-visible validation behavior changes. +Route: software-change | clear/local/medium — user-visible validation behavior changes. ``` diff --git a/skills/pinmind/references/token-usage.md b/skills/pinmind/references/token-usage.md deleted file mode 100644 index 62a3e17..0000000 --- a/skills/pinmind/references/token-usage.md +++ /dev/null @@ -1,61 +0,0 @@ -# Token usage receipts - -Token accounting is out of the skill and plugin surface. Agents must not read -this file to finish a task and must not print a chat token line. The optional -kernel receipt below exists only for host adapters that already captured -authoritative usage; it is not part of the controller contract. - -## What the optional kernel receipt can store - -Do not report token usage from the skill. Never estimate, never substitute -zero, and never pad a chat answer with `unavailable`. - -An instruction-only skill normally cannot know the complete usage of its own final response: the host emits final usage after that response completes. Do not estimate it, infer it from text length, parse private rollout files, or substitute zero. - -`cachedInputTokens`, `cacheWriteInputTokens`, and `reasoningOutputTokens` are breakdown fields. They are not added again to the total. Pinmind defines: - -```text -totalTokens = inputTokens + outputTokens -``` - -## Persistent receipt - -Every new persistent run starts with a hash-checked `usage.json` receipt whose status is `unavailable`. A supported observer can replace it after authoritative usage becomes available: - -```json -{ - "status": "actual", - "source": "codex-sdk", - "scope": "task", - "model": "gpt-5.6", - "inputTokens": 1200, - "cachedInputTokens": 400, - "outputTokens": 300, - "reasoningOutputTokens": 50, - "capturedAt": "2026-08-16T18:00:00.000Z", - "reference": "turn-123" -} -``` - -Allowed sources are `codex-sdk`, `codex-exec-json`, `app-server`, `openai-api`, `manual-attestation`, and `host-unavailable`. Allowed scopes are `turn`, `task`, and `run`. A manual transcription may preserve an observed number, but its source remains visibly manual. - -Record and inspect without a server: - -```bash -node "$KERNEL" usage record --run --file -node "$KERNEL" report --run --format json -node "$KERNEL" report --run --format md -``` - -`report` is read-only. It renders exact observed counts or an explicit unavailable status from local canonical artifacts. Receipt text fields are redacted, unknown fields are rejected, inconsistent totals or subsets are rejected, and a changed hash blocks the report. - -## Host adapters - -Prefer documented public events rather than private logs: - -- Codex SDK `turn.completed` usage for a completed turn; -- `codex exec --json` completion events; -- App Server token-usage or raw-response events, aggregated only with an explicit scope; -- OpenAI API response usage. - -A host adapter may write the receipt after completion, then render `report`. It must not modify the frozen brief, contract, evidence, or final verdict. ChatGPT plugin/skill execution currently exposes no documented skill-level counter that can be read before the assistant sends its own final response. diff --git a/skills/pinmind/references/verification.md b/skills/pinmind/references/verification.md index 0c85639..5fbebfa 100644 --- a/skills/pinmind/references/verification.md +++ b/skills/pinmind/references/verification.md @@ -57,6 +57,4 @@ Generate `final.md` or the final response from current canonical state. Separate - manual steps and external gates; - exact checks run and their current result. -Do not report token usage in the chat completion. Token accounting is out of the skill surface and must not appear in the user-facing report. - Never claim success from stale evidence, historical output, memory, an unexecuted command, or an unavailable capability. State partial completion plainly. diff --git a/skills/pinmind/scripts/lib/core.mjs b/skills/pinmind/scripts/lib/core.mjs index 3412867..bd1edfa 100644 --- a/skills/pinmind/scripts/lib/core.mjs +++ b/skills/pinmind/scripts/lib/core.mjs @@ -1,1647 +1,2 @@ -import { createHash, randomUUID } from 'node:crypto'; -import { readFile, writeFile, mkdir, access, rename, unlink, open, realpath, stat, lstat, readdir } from 'node:fs/promises'; -import { spawn } from 'node:child_process'; -import { hostname } from 'node:os'; -import path from 'node:path'; - -export class KernelError extends Error { - constructor(message, code = 'KERNEL_ERROR', details = []) { - super(message); - this.name = 'KernelError'; - this.code = code; - this.details = details; - } -} - -const FORMAT = 1; -const EVIDENCE_STATUSES = new Set(['pass', 'fail', 'uncertain', 'pending-review', 'not-applicable', 'invalidated']); -const USAGE_STATUSES = new Set(['actual', 'unavailable']); -const USAGE_SOURCES = new Set(['codex-sdk', 'codex-exec-json', 'app-server', 'openai-api', 'manual-attestation', 'host-unavailable']); -const USAGE_SCOPES = new Set(['turn', 'task', 'run']); -const USAGE_FIELDS = new Set(['format', 'status', 'source', 'scope', 'model', 'inputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens', 'capturedAt', 'reason', 'reference', 'usageSha256']); -const PRIORITIES = new Set(['must', 'should', 'could']); -const EVIDENCE_TYPES = new Set(['unit-test', 'integration-test', 'end-to-end-test', 'property-test', 'static-typecheck', 'lint-static-analysis', 'browser-journey', 'screenshot-reference-comparison', 'accessibility-check', 'benchmark', 'migration-dry-run', 'log-trace-observation', 'manual-pending-review', 'external-service-proof']); -const PREFIXES = { obligations: 'REQ', acceptanceCriteria: 'AC', invariants: 'INV', preservation: 'PRES', publicSeams: 'SEAM', nonFunctional: 'NFR' }; -const TOP_LEVEL_TOKENS = ['INTENT', 'ACTORS', 'BOUNDARIES', 'ASSUMPTIONS', 'OUT-OF-SCOPE']; -const CONTRACT_FIELDS = new Set(['contractId', 'version', 'status', 'source', 'intent', 'actors', 'obligations', 'acceptanceCriteria', 'invariants', 'preservation', 'boundaries', 'publicSeams', 'nonFunctional', 'assumptions', 'outOfScope', 'contractSha256', 'amends']); -const LOCK_WAIT_MS = 5000; -const LOCK_RETRY_MS = 15; -const CAPTURE_TIMEOUT_MS = 30000; -const CAPTURE_TIMEOUT_MIN_MS = 50; -const CAPTURE_TIMEOUT_MAX_MS = 300000; -const CAPTURE_TERMINATION_GRACE_MS = 250; -const TRANSITION_OPERATIONS = new Set(['init', 'freeze', 'amend', 'evidence', 'finalize']); - -export const sha256 = (value) => createHash('sha256').update(value).digest('hex'); -export const canonicalJson = (value) => JSON.stringify(sortValue(value)); - -function sortValue(value) { - if (Array.isArray(value)) return value.map(sortValue); - if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])])); - return value; -} - -export function hashWithout(object, key) { - const clone = structuredClone(object); - delete clone[key]; - return sha256(canonicalJson(clone)); -} - -export function safeRunId(value) { - if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(value) || value.includes('..')) { - throw new KernelError('Run id must contain only letters, digits, and hyphens.', 'UNSAFE_RUN_ID'); - } - return value; -} - -export function safeRelativePath(value, label = 'path') { - if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new KernelError(`${label} is required.`, 'UNSAFE_PATH'); - const normalized = value.replace(/\\/g, '/'); - if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized) || normalized.split('/').includes('..')) { - throw new KernelError(`${label} must be a safe relative path.`, 'UNSAFE_PATH'); - } - return normalized.replace(/^\.\//, ''); -} - -export function redact(text) { - if (typeof text !== 'string') return text; - return text - .replace(/-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]') - .replace(/(\[\s*)(["'])((?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING))\2(\s*\]\s*=\s*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|`(?:\\.|[^`\\\r\n])*`|[^;,\r\n)\]}]+)/gi, '$1$2$3$2$4"[REDACTED]"') - .replace(/(["'])((?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING))\1(\s*:\s*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^,}\r\n]+)/gi, '$1$2$1$3"[REDACTED]"') - .replace(/(^|[^A-Za-z0-9_])((?:(?:export|set)[ \t]+|\$env:)?(?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING)[ \t]*[=:][ \t]*)[^\r\n]*/gim, '$1$2[REDACTED]') - .replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi, '[REDACTED]') - .replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, '[REDACTED]') - .replace(/\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/g, '[REDACTED]') - .replace(/\b(Authorization\s*[:=]\s*(?:(?:Basic|Bearer)\s+)?)[^\s,'"`]+/gi, '$1[REDACTED]') - .replace(/\b((?:Set-)?Cookie\s*:\s*)[^\r\n]+/gi, '$1[REDACTED]') - .replace(/\b((?:session(?:id)?|sid)\s*=\s*)[^;\s,'"`]+/gi, '$1[REDACTED]') - .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@') - .replace(/\b(Bearer\s+)[^\s,'"`]+/gi, '$1[REDACTED]') - .replace(/\b((?:api[_-]?key|secret|password|token)\s*[=:]\s*)[^\s,'"`]+/gi, '$1[REDACTED]'); -} - -export function redactValue(value, key = '') { - if (/(password|secret|token|api[_-]?key|authorization|cookie|session|sid|set-cookie)/i.test(key)) return '[REDACTED]'; - if (typeof value === 'string') return redact(value); - if (Array.isArray(value)) return value.map((item) => redactValue(item)); - if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v, k)])); - return value; -} - -const SAFE_EXECUTABLE_NAME = /^(?:bash|bun|cargo|cmake|cmd|deno|eslint|git|go|jest|make|meson|ninja|node(?:js)?|npm|npx|pnpm|powershell|pwsh|pytest|python(?:3(?:\.\d+)?)?|ruby|rustc|sh|true|tsc|vitest|yarn|zsh)(?:\.exe)?$/i; - -export function redactArgv(argv) { - if (!Array.isArray(argv)) return argv; - if (argv.length === 0) return []; - const executable = typeof argv[0] === 'string' ? path.basename(argv[0]) : ''; - const executableLabel = SAFE_EXECUTABLE_NAME.test(executable) ? executable : '[REDACTED EXECUTABLE]'; - return [executableLabel, ...argv.slice(1).map(() => '[REDACTED ARG]')]; -} - -async function exists(file) { try { await access(file); return true; } catch { return false; } } -async function readJson(file, label = 'JSON file') { - try { return JSON.parse(await readFile(file, 'utf8')); } - catch (error) { throw new KernelError(`${label} is unreadable or invalid JSON: ${file}`, 'INVALID_JSON', [error.message]); } -} -async function syncDirectory(directory) { - let handle; - try { - handle = await open(directory, 'r'); - await handle.sync(); - } catch (error) { - if (process.platform !== 'win32' || !['EISDIR', 'EPERM', 'EINVAL', 'ENOTSUP'].includes(error.code)) throw error; - } finally { await handle?.close(); } -} -async function ensureDirectoryDurable(directory) { - const before = await existingEntry(directory, 'write parent'); - if (before) { - if (before.isSymbolicLink() || !before.isDirectory()) throw unsafeStatePath('write parent', 'Expected a physical directory.'); - return; - } - const parent = path.dirname(directory); - if (parent !== directory) await ensureDirectoryDurable(parent); - try { await mkdir(directory); } - catch (error) { if (error.code !== 'EEXIST') throw error; } - await syncDirectory(parent); -} -async function ensureParentDirectory(file) { await ensureDirectoryDurable(path.dirname(file)); } -async function writeJsonAtomic(file, value) { - await ensureParentDirectory(file); - const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; - let handle; - try { - handle = await open(temporary, 'wx', 0o600); - await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); await handle.sync(); await handle.close(); handle = null; - await rename(temporary, file); await syncDirectory(path.dirname(file)); - } - catch (error) { try { await unlink(temporary); } catch {} throw error; } - finally { await handle?.close(); } -} -async function writeTextAtomic(file, value) { - await ensureParentDirectory(file); - const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; - let handle; - try { - handle = await open(temporary, 'wx', 0o600); - await handle.writeFile(value, 'utf8'); await handle.sync(); await handle.close(); handle = null; - await rename(temporary, file); await syncDirectory(path.dirname(file)); - } - catch (error) { try { await unlink(temporary); } catch {} throw error; } - finally { await handle?.close(); } -} -async function unlinkDurable(file) { await unlink(file); await syncDirectory(path.dirname(file)); } -const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); - -export function layout(cwd, runId) { - const safe = safeRunId(runId); - const root = path.resolve(cwd, '.pinmind'); - const runs = path.join(root, 'runs'); - const run = path.join(runs, safe); - return { root, runs, run, lock: path.join(root, 'writer.lock'), transition: path.join(root, 'transition.json'), active: path.join(root, 'active.json'), brief: path.join(run, 'brief.md'), state: path.join(run, 'state.json'), baseline: path.join(run, 'baseline.json'), evidence: path.join(run, 'evidence.json'), usage: path.join(run, 'usage.json'), final: path.join(run, 'final.md'), execution: path.join(run, 'execution.json'), contracts: path.join(run, 'contracts'), amendments: path.join(run, 'amendments') }; -} - -function unsafeStatePath(label, detail = '') { - return new KernelError(`Pinmind state ${label} must be a physical path inside the workspace.${detail ? ` ${detail}` : ''}`, 'UNSAFE_STATE_PATH'); -} - -async function existingEntry(candidate, label) { - try { return await lstat(candidate); } - catch (error) { - if (error.code === 'ENOENT') return null; - throw unsafeStatePath(label, error.message); - } -} - -async function verifyStateEntry(candidate, boundary, kind, label) { - const entry = await existingEntry(candidate, label); - if (!entry) return false; - const validKind = kind === 'directory' ? entry.isDirectory() : entry.isFile(); - if (entry.isSymbolicLink() || !validKind) throw unsafeStatePath(label, `Expected a regular ${kind}.`); - let resolved; - try { resolved = await realpath(candidate); } - catch (error) { throw unsafeStatePath(label, error.message); } - if (!isPhysicalDescendant(boundary, resolved)) throw unsafeStatePath(label, 'The resolved path escapes its state boundary.'); - return true; -} - -async function verifiedStateRoot(cwd, { create = false } = {}) { - let workspace; - try { workspace = await realpath(path.resolve(cwd)); } - catch (error) { throw unsafeStatePath('workspace', error.message); } - const workspaceEntry = await existingEntry(workspace, 'workspace'); - if (!workspaceEntry?.isDirectory() || workspaceEntry.isSymbolicLink()) throw unsafeStatePath('workspace', 'Expected a physical directory.'); - const root = path.join(workspace, '.pinmind'); - let exists = await verifyStateEntry(root, workspace, 'directory', 'root'); - if (!exists && create) { - try { await mkdir(root, { mode: 0o700 }); } - catch (error) { if (error.code !== 'EEXIST') throw unsafeStatePath('root', error.message); } - exists = await verifyStateEntry(root, workspace, 'directory', 'root'); - if (!exists) throw unsafeStatePath('root', 'The directory could not be created safely.'); - } - if (exists) { - await verifyStateEntry(path.join(root, 'active.json'), root, 'file', 'active pointer'); - await verifyStateEntry(path.join(root, 'writer.lock'), root, 'file', 'writer lock'); - await verifyStateEntry(path.join(root, 'transition.json'), root, 'file', 'transition journal'); - } - return { workspace, root, exists }; -} - -async function verifiedLayout(cwd, runId, { createRoot = false } = {}) { - const stateRoot = await verifiedStateRoot(cwd, { create: createRoot }); - const files = layout(stateRoot.workspace, runId); - if (!stateRoot.exists) return files; - if (!(await verifyStateEntry(files.runs, files.root, 'directory', 'runs directory'))) return files; - if (!(await verifyStateEntry(files.run, files.root, 'directory', 'run directory'))) return files; - await verifyStateEntry(files.contracts, files.run, 'directory', 'contracts directory'); - await verifyStateEntry(files.amendments, files.run, 'directory', 'amendments directory'); - for (const [label, file] of Object.entries({ brief: files.brief, state: files.state, baseline: files.baseline, evidence: files.evidence, usage: files.usage, final: files.final, execution: files.execution })) { - await verifyStateEntry(file, files.run, 'file', `${label} file`); - } - return files; -} - -async function inspectExistingLock(lockFile) { - let current; - try { current = await readJson(lockFile, 'Writer lock'); } - // A contender can observe the file after O_EXCL creation but before the owner - // finishes writing metadata. Retry boundedly instead of misclassifying that - // short initialization window as a stale lock. - catch { return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: false }; } - if (current?.hostname !== hostname() || !Number.isInteger(current?.pid) || current.pid < 1 || typeof current?.ownerId !== 'string') { - return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: true }; - } - try { process.kill(current.pid, 0); return { code: 'LOCK_HELD', terminal: false }; } - catch (error) { - if (error.code === 'ESRCH') return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: true }; - return { code: 'LOCK_HELD', terminal: false }; - } -} - -async function acquireWorkspaceLock(cwd, operation, waitMs = LOCK_WAIT_MS) { - const { root } = await verifiedStateRoot(cwd, { create: true }); const lockFile = path.join(root, 'writer.lock'); - const ownerId = randomUUID(); const deadline = Date.now() + waitMs; - let heldCode = 'LOCK_HELD'; - while (true) { - try { - const handle = await open(lockFile, 'wx', 0o600); - const metadata = { format: FORMAT, ownerId, pid: process.pid, hostname: hostname(), operation, startedAt: new Date().toISOString() }; - let writeError; - try { await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); await handle.sync(); } - catch (error) { writeError = error; } - finally { await handle.close(); } - if (writeError) { try { await unlink(lockFile); } catch {} throw writeError; } - return { lockFile, ownerId }; - } catch (error) { - if (error.code !== 'EEXIST') throw new KernelError(`Could not acquire workspace writer lock: ${error.message}`, 'LOCK_FAILED'); - const status = await inspectExistingLock(lockFile); heldCode = status.code || heldCode; - if (status.terminal) throw new KernelError('The Pinmind workspace writer lock needs explicit recovery.', heldCode); - if (Date.now() >= deadline) throw new KernelError('The Pinmind workspace writer lock is held.', heldCode); - await sleep(LOCK_RETRY_MS); - } - } -} - -async function releaseWorkspaceLock(lock) { - try { - const current = await readJson(lock.lockFile, 'Writer lock'); - if (current.ownerId === lock.ownerId) await unlink(lock.lockFile); - } catch (error) { - if (error.code !== 'ENOENT') return; - } -} - -async function withWorkspaceLock(cwd, operation, action) { - const lock = await acquireWorkspaceLock(cwd, operation); - try { return await action(lock); } - finally { await releaseWorkspaceLock(lock); } -} - -function transitionHash(transition) { return hashWithout(transition, 'transitionSha256'); } -function jsonText(value) { return `${JSON.stringify(value, null, 2)}\n`; } -function transitionTargetAllowed(operation, runId, relative) { - const run = `.pinmind/runs/${runId}`; - const exact = { - init: new Set([`${run}/brief.md`, `${run}/state.json`, `${run}/evidence.json`, `${run}/usage.json`, '.pinmind/active.json']), - freeze: new Set([`${run}/contracts/contract-v001.json`, `${run}/state.json`]), - evidence: new Set([`${run}/evidence.json`]), - finalize: new Set([`${run}/usage.json`, `${run}/final.md`, `${run}/state.json`, '.pinmind/active.json']), - }; - if (exact[operation]?.has(relative)) return true; - if (operation !== 'amend') return false; - return relative === `${run}/evidence.json` || relative === `${run}/state.json` - || (relative.startsWith(`${run}/contracts/`) && /^contract-v\d{3,}\.json$/.test(relative.slice(`${run}/contracts/`.length))) - || (relative.startsWith(`${run}/amendments/`) && /^amendment-v\d{3,}\.json$/.test(relative.slice(`${run}/amendments/`.length))); -} -async function verifyPhysicalParentChain(workspace, target) { - const relative = path.relative(workspace, path.dirname(target)); - if (relative.startsWith('..') || path.isAbsolute(relative)) throw new KernelError('Transition target parent escapes the workspace.', 'TRANSITION_CONFLICT'); - let current = workspace; - for (const segment of relative.split(path.sep).filter(Boolean)) { - current = path.join(current, segment); const entry = await existingEntry(current, 'transition target parent'); - if (!entry) return; - if (entry.isSymbolicLink() || !entry.isDirectory()) throw new KernelError('Transition target parent must be a physical directory.', 'TRANSITION_CONFLICT', [path.relative(workspace, current).replace(/\\/g, '/')]); - } -} -async function transitionTargetState(workspace, action) { - const absolute = path.resolve(workspace, action.path); const relative = path.relative(workspace, absolute); - if (relative.startsWith('..') || path.isAbsolute(relative)) throw new KernelError('Transition target escapes the workspace.', 'TRANSITION_CONFLICT'); - await verifyPhysicalParentChain(workspace, absolute); - const entry = await existingEntry(absolute, 'transition target'); - if (entry && (entry.isSymbolicLink() || !entry.isFile())) throw new KernelError('Transition target must be a physical file.', 'TRANSITION_CONFLICT', [action.path]); - const content = entry ? await readFile(absolute, 'utf8') : null; const currentSha256 = content === null ? null : sha256(content); - const matchesBefore = currentSha256 === action.beforeSha256; const matchesAfter = currentSha256 === action.afterSha256; - return { absolute, currentSha256, matchesBefore, matchesAfter }; -} -function validateTransitionShape(transition) { - const errors = []; - if (!transition || transition.format !== FORMAT || typeof transition.transitionId !== 'string') errors.push('invalid transition header'); - if (!TRANSITION_OPERATIONS.has(transition?.operation)) errors.push('invalid transition operation'); - try { safeRunId(transition?.runId); } catch { errors.push('invalid transition run id'); } - if (!Array.isArray(transition?.actions) || transition.actions.length === 0) errors.push('transition actions are required'); - const seen = new Set(); - for (const action of transition?.actions || []) { - if (!action || typeof action.path !== 'string' || seen.has(action.path)) errors.push('transition target paths must be unique strings'); - seen.add(action?.path); - if (!transitionTargetAllowed(transition.operation, transition.runId, action.path)) errors.push(`transition target is not allowed: ${action?.path}`); - if (action.beforeSha256 !== null && !/^[a-f0-9]{64}$/.test(action.beforeSha256 || '')) errors.push(`invalid before hash: ${action?.path}`); - if (action.afterSha256 !== null && !/^[a-f0-9]{64}$/.test(action.afterSha256 || '')) errors.push(`invalid after hash: ${action?.path}`); - if (action.afterContent !== null && typeof action.afterContent !== 'string') errors.push(`invalid post-image: ${action?.path}`); - if ((action.afterContent === null ? null : sha256(action.afterContent)) !== action.afterSha256) errors.push(`post-image hash mismatch: ${action?.path}`); - } - if (typeof transition?.transitionSha256 !== 'string' || transitionHash(transition) !== transition.transitionSha256) errors.push('transition hash mismatch'); - return errors; -} -async function inspectPendingTransition(cwd) { - const stateRoot = await verifiedStateRoot(cwd); const file = path.join(stateRoot.root, 'transition.json'); - if (!stateRoot.exists || !(await exists(file))) return null; - let transition; - try { transition = await readJson(file, 'Transition journal'); } - catch (error) { return { classification: 'transition-conflict', issues: [error.code || 'INVALID_JSON'], transition: null, summary: null }; } - const errors = validateTransitionShape(transition); - if (errors.length) return { classification: 'transition-conflict', issues: errors, transition, summary: { transitionId: transition.transitionId ?? null, operation: transition.operation ?? null, runId: transition.runId ?? null, transitionSha256: transition.transitionSha256 ?? null } }; - const states = []; - try { for (const action of transition.actions) states.push(await transitionTargetState(stateRoot.workspace, action)); } - catch (error) { return { classification: 'transition-conflict', issues: [error.message], transition, summary: { transitionId: transition.transitionId, operation: transition.operation, runId: transition.runId, transitionSha256: transition.transitionSha256 } }; } - const conflicts = transition.actions.filter((action, index) => !states[index].matchesBefore && !states[index].matchesAfter).map((action) => action.path); - return { - classification: conflicts.length ? 'transition-conflict' : 'transition-recovery-required', - issues: conflicts.map((target) => `unexpected target hash: ${target}`), transition, - summary: { transitionId: transition.transitionId, operation: transition.operation, runId: transition.runId, transitionSha256: transition.transitionSha256, applied: states.filter((state) => state.matchesAfter).length, total: states.length }, - }; -} -async function inspectWriterLockForRecovery(cwd) { - const stateRoot = await verifiedStateRoot(cwd); const lockFile = path.join(stateRoot.root, 'writer.lock'); - if (!stateRoot.exists || !(await exists(lockFile))) return { status: 'absent', lockSha256: null }; - const raw = await readFile(lockFile, 'utf8'); const lockSha256 = sha256(raw); let lock; - try { lock = JSON.parse(raw); } - catch { return { status: 'invalid', lockSha256 }; } - if (lock?.format !== FORMAT || typeof lock.ownerId !== 'string' || !Number.isInteger(lock.pid) || lock.pid < 1 || typeof lock.hostname !== 'string' || typeof lock.operation !== 'string') return { status: 'invalid', lockSha256 }; - const summary = { status: 'held', lockSha256, ownerId: lock.ownerId, pid: lock.pid, hostname: lock.hostname, operation: lock.operation }; - if (lock.hostname !== hostname()) return { ...summary, status: 'foreign' }; - try { process.kill(lock.pid, 0); return summary; } - catch (error) { - if (error.code === 'ESRCH') return { ...summary, status: 'stale-local' }; - return summary; - } -} -function transitionMatchesLock(transition, lock) { - const expected = { - init: [`init:${transition.runId}`], freeze: [`freeze-contract:${transition.runId}`], amend: [`amend-contract:${transition.runId}`], - evidence: [`record-evidence:${transition.runId}`, `capture-evidence:${transition.runId}`], finalize: [`finalize:${transition.runId}`], - }; - return (expected[transition.operation] || []).includes(lock.operation); -} -async function recoverExactStaleLock(cwd, pending, expectedLockSha256) { - if (typeof expectedLockSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedLockSha256)) throw new KernelError('An exact expected writer-lock hash is required.', 'EXPECTED_LOCK_HASH_REQUIRED'); - const lock = await inspectWriterLockForRecovery(cwd); - if (lock.lockSha256 !== expectedLockSha256) throw new KernelError('The writer lock changed after inspection.', 'LOCK_HASH_MISMATCH'); - if (lock.status !== 'stale-local') throw new KernelError('Only an explicitly matched dead local writer lock can be recovered.', 'LOCK_NOT_RECOVERABLE', [lock.status]); - if (!transitionMatchesLock(pending.transition, lock)) throw new KernelError('The stale writer lock does not own the prepared transition.', 'LOCK_TRANSITION_MISMATCH'); - const stateRoot = await verifiedStateRoot(cwd); const lockFile = path.join(stateRoot.root, 'writer.lock'); - if (sha256(await readFile(lockFile, 'utf8')) !== expectedLockSha256) throw new KernelError('The writer lock changed before removal.', 'LOCK_HASH_MISMATCH'); - await unlinkDurable(lockFile); return lock; -} -async function buildTransition(cwd, operation, runId, changes) { - const stateRoot = await verifiedStateRoot(cwd, { create: true }); const actions = []; - for (const change of changes) { - const absolute = path.resolve(change.file); const relative = path.relative(stateRoot.workspace, absolute).replace(/\\/g, '/'); - if (!transitionTargetAllowed(operation, runId, relative)) throw new KernelError('Transition target is outside the operation allowlist.', 'TRANSITION_TARGET_FORBIDDEN', [relative]); - const current = await existingEntry(absolute, 'transition target'); - if (current && (current.isSymbolicLink() || !current.isFile())) throw new KernelError('Transition target must be a physical file.', 'TRANSITION_CONFLICT', [relative]); - const beforeContent = current ? await readFile(absolute, 'utf8') : null; const afterContent = change.content; - actions.push({ path: relative, beforeSha256: beforeContent === null ? null : sha256(beforeContent), afterSha256: afterContent === null ? null : sha256(afterContent), afterContent }); - } - const now = new Date().toISOString(); - const transition = { format: FORMAT, transitionId: randomUUID(), operation, runId, createdAt: now, actions }; - transition.transitionSha256 = transitionHash(transition); return { stateRoot, transition }; -} -async function maybeInjectTransitionFault(options, step) { - if (typeof options?.onTransitionStep === 'function') await options.onTransitionStep(step); - if (options?.faultAfterStep === step) throw new KernelError(`Injected transition interruption after step ${step}.`, 'INJECTED_TRANSITION_CRASH', [step]); -} -async function applyTransitionActions(workspace, transition, options = {}) { - for (let index = 0; index < transition.actions.length; index += 1) { - const action = transition.actions[index]; const state = await transitionTargetState(workspace, action); - if (!state.matchesAfter) { - if (!state.matchesBefore) throw new KernelError('A transition target changed outside its prepared before/after states.', 'TRANSITION_CONFLICT', [action.path]); - if (action.afterContent === null) await unlinkDurable(state.absolute); else await writeTextAtomic(state.absolute, action.afterContent); - } - await maybeInjectTransitionFault(options, index + 1); - } -} -async function executeTransition(cwd, operation, runId, changes, options = {}) { - const pending = await inspectPendingTransition(cwd); - if (pending) throw new KernelError('A prepared transition requires explicit recovery before another mutation.', 'TRANSITION_RECOVERY_REQUIRED', [pending.summary || pending.issues]); - const { stateRoot, transition } = await buildTransition(cwd, operation, runId, changes); - const journal = path.join(stateRoot.root, 'transition.json'); await writeJsonAtomic(journal, transition); await maybeInjectTransitionFault(options, 0); - await applyTransitionActions(stateRoot.workspace, transition, options); - for (const action of transition.actions) if (!(await transitionTargetState(stateRoot.workspace, action)).matchesAfter) throw new KernelError('Transition post-image verification failed.', 'TRANSITION_CONFLICT', [action.path]); - await unlinkDurable(journal); - return { transitionId: transition.transitionId, transitionSha256: transition.transitionSha256 }; -} - -export async function recoverTransition(cwd, expectedTransitionSha256, options = {}) { - if (typeof expectedTransitionSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedTransitionSha256)) throw new KernelError('An exact expected transition hash is required.', 'EXPECTED_TRANSITION_HASH_REQUIRED'); - const preflight = await inspectPendingTransition(cwd); - if (preflight?.classification === 'transition-recovery-required' && preflight.transition.transitionSha256 !== expectedTransitionSha256) throw new KernelError('The prepared transition hash changed.', 'TRANSITION_HASH_MISMATCH'); - if (options.expectedLockSha256 !== undefined) { - if (!preflight || preflight.classification !== 'transition-recovery-required') throw new KernelError('No recoverable prepared transition owns the stale lock.', 'TRANSITION_CONFLICT', preflight?.issues || []); - await recoverExactStaleLock(cwd, preflight, options.expectedLockSha256); - } - return withWorkspaceLock(cwd, 'recover-transition', async () => { - const pending = await inspectPendingTransition(cwd); - if (!pending) throw new KernelError('No prepared transition exists.', 'NO_PENDING_TRANSITION'); - if (pending.classification !== 'transition-recovery-required') throw new KernelError('The prepared transition conflicts with current state.', 'TRANSITION_CONFLICT', pending.issues); - if (pending.transition.transitionSha256 !== expectedTransitionSha256) throw new KernelError('The prepared transition hash changed.', 'TRANSITION_HASH_MISMATCH'); - const stateRoot = await verifiedStateRoot(cwd); await applyTransitionActions(stateRoot.workspace, pending.transition); - for (const action of pending.transition.actions) if (!(await transitionTargetState(stateRoot.workspace, action)).matchesAfter) throw new KernelError('Transition recovery post-image verification failed.', 'TRANSITION_CONFLICT', [action.path]); - await unlinkDurable(path.join(stateRoot.root, 'transition.json')); - const reconciliation = await reconcileActiveRuns(cwd); - if (!reconciliation.ok) throw new KernelError('Transition applied but active-run reconciliation is not valid.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation]); - return { recovered: true, transitionId: pending.transition.transitionId, operation: pending.transition.operation, runId: pending.transition.runId, reconciliation }; - }); -} - -function stateHash(state) { return hashWithout(state, 'stateSha256'); } -function setStateHash(state) { state.stateSha256 = stateHash(state); return state; } - -export async function loadState(cwd, runId) { - const files = await verifiedLayout(cwd, runId); - const state = await readJson(files.state, 'Run state'); - if (state.format !== FORMAT || state.runId !== runId || typeof state.stateSha256 !== 'string' || stateHash(state) !== state.stateSha256) { - throw new KernelError(`Run state is corrupt: ${runId}`, 'CORRUPT_STATE'); - } - return { files, state }; -} - -function requireActiveRun(state, runId) { if (state.status !== 'active') throw new KernelError(`Run ${runId} is complete.`, 'RUN_COMPLETE'); } - -function reconciliationResult(classification, pointerRunId, activeRunIds, runIds, issues = [], pendingTransition = null) { - const ok = classification === 'clean-idle' || classification === 'canonical-active'; - const nextSafeSteps = { - 'clean-idle': 'No recovery action is required.', - 'canonical-active': `Resume only ${pointerRunId}; this diagnostic did not execute or replay task work.`, - 'orphan-active': 'Inspect the orphan run and restore a canonical pointer only through a future explicitly authorized repair.', - 'split-brain': 'Stop writers and inspect every listed active run; do not choose or rewrite an owner automatically.', - 'pointer-nonactive': 'Inspect the interrupted finalization boundary; do not delete the pointer automatically.', - 'pointer-missing-run': 'Inspect the pointer and workspace history; the referenced run is missing.', - 'pointer-diverged': 'Inspect the pointer and active run states; ownership is inconsistent.', - 'pointer-invalid': 'Inspect the invalid active pointer without replacing it automatically.', - 'run-corrupt': 'Inspect the listed managed run entries; reconciliation cannot trust corrupted state.', - 'transition-recovery-required': 'Run explicit hash-bound state recovery; this diagnostic did not apply post-images or replay task work.', - 'transition-conflict': 'Inspect the transition and target hashes; do not overwrite or delete the journal automatically.', - }; - return { ok, classification, pointerRunId, activeRunIds, managedRunCount: runIds.length, issues, pendingTransition, nextSafeStep: nextSafeSteps[classification] }; -} - -export async function reconcileActiveRuns(cwd) { - const stateRoot = await verifiedStateRoot(cwd); - if (!stateRoot.exists) return reconciliationResult('clean-idle', null, [], []); - const pending = await inspectPendingTransition(cwd); - if (pending) { - const result = reconciliationResult(pending.classification, null, [], [], pending.issues, pending.summary); - result.writerLock = await inspectWriterLockForRecovery(cwd); - if (result.writerLock.status === 'stale-local' && pending.classification === 'transition-recovery-required') result.nextSafeStep = 'Use explicit state recovery with both the printed transition hash and dead local writer-lock hash; no task work will be replayed.'; - return result; - } - const runsPath = path.join(stateRoot.root, 'runs'); - const hasRuns = await verifyStateEntry(runsPath, stateRoot.root, 'directory', 'runs directory'); - const runIds = []; const activeRunIds = []; const issues = []; - if (hasRuns) { - const entries = (await readdir(runsPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of entries) { - if (entry.isSymbolicLink() || !entry.isDirectory()) { - issues.push(`${entry.name}: expected a physical run directory`); - continue; - } - let runId; - try { runId = safeRunId(entry.name); } - catch { issues.push(`${entry.name}: invalid run id`); continue; } - runIds.push(runId); - try { - const { state } = await loadState(cwd, runId); - if (state.status === 'active') activeRunIds.push(runId); - } catch (error) { - if (error.code === 'UNSAFE_STATE_PATH') throw error; - issues.push(`${runId}: ${error.code || 'INVALID_STATE'}`); - } - } - } - runIds.sort(); activeRunIds.sort(); - - const activePath = path.join(stateRoot.root, 'active.json'); - let pointerRunId = null; let pointerInvalid = false; - if (await exists(activePath)) { - try { - const pointer = await readJson(activePath, 'Active run pointer'); - if (pointer.format !== FORMAT) pointerInvalid = true; - else pointerRunId = safeRunId(pointer.runId); - } catch (error) { - if (error.code === 'UNSAFE_STATE_PATH') throw error; - pointerInvalid = true; - } - } - - if (issues.length) return reconciliationResult('run-corrupt', pointerRunId, activeRunIds, runIds, issues); - if (activeRunIds.length > 1) return reconciliationResult('split-brain', pointerRunId, activeRunIds, runIds); - if (pointerInvalid) return reconciliationResult('pointer-invalid', null, activeRunIds, runIds); - if (!pointerRunId) return activeRunIds.length === 0 - ? reconciliationResult('clean-idle', null, activeRunIds, runIds) - : reconciliationResult('orphan-active', null, activeRunIds, runIds); - if (!runIds.includes(pointerRunId)) return reconciliationResult('pointer-missing-run', pointerRunId, activeRunIds, runIds); - if (activeRunIds.length === 0) return reconciliationResult('pointer-nonactive', pointerRunId, activeRunIds, runIds); - if (activeRunIds[0] !== pointerRunId) return reconciliationResult('pointer-diverged', pointerRunId, activeRunIds, runIds); - return reconciliationResult('canonical-active', pointerRunId, activeRunIds, runIds); -} - -async function requireCanonicalActiveRun(cwd, state, runId) { - requireActiveRun(state, runId); - const reconciliation = await reconcileActiveRuns(cwd); - if (!reconciliation.ok || reconciliation.classification !== 'canonical-active') { - throw new KernelError('The active-run state is inconsistent and requires read-only reconciliation.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation.classification, ...reconciliation.activeRunIds]); - } - if (reconciliation.pointerRunId !== runId) throw new KernelError(`Run ${runId} is not the canonical active run.`, 'NOT_ACTIVE_RUN'); - return reconciliation; -} - -function ids(items) { return new Set((items || []).map((item) => item.id)); } -function requireArray(value, label, errors) { if (!Array.isArray(value)) errors.push(`${label} must be an array.`); } -function validId(value) { return typeof value === 'string' && /^[A-Z][A-Z0-9]*-\d{3,}$/.test(value); } -function validEvidenceId(value) { return typeof value === 'string' && /^EV-\d{3,}$/.test(value); } -function refsExist(refs, known) { return Array.isArray(refs) && refs.length > 0 && refs.every((id) => known.has(id)); } - -export function validateContract(contract, { expectedVersion } = {}) { - const errors = []; - if (!contract || typeof contract !== 'object' || Array.isArray(contract)) return { ok: false, errors: ['Contract must be an object.'] }; - for (const key of Object.keys(contract)) if (!CONTRACT_FIELDS.has(key)) errors.push(`Unknown top-level contract field: ${key}.`); - if (typeof contract.contractId !== 'string' || !contract.contractId.trim()) errors.push('contractId is required.'); - if (!Number.isInteger(contract.version) || contract.version < 1) errors.push('version must be a positive integer.'); - if (expectedVersion !== undefined && contract.version !== expectedVersion) errors.push(`version must be ${expectedVersion}.`); - if (!['draft', 'frozen'].includes(contract.status || 'draft')) errors.push('status must be draft or frozen.'); - if (typeof contract.intent !== 'string' || !contract.intent.trim()) errors.push('intent is required.'); - for (const name of ['obligations', 'acceptanceCriteria', 'invariants', 'preservation']) requireArray(contract[name], name, errors); - const collections = ['obligations', 'acceptanceCriteria', 'invariants', 'preservation', 'publicSeams', 'nonFunctional']; - const allIds = new Set(); - for (const collection of collections) for (const item of contract[collection] || []) { - if (!item || !validId(item.id) || !item.id.startsWith(`${PREFIXES[collection]}-`)) errors.push(`${collection} contains an invalid ${PREFIXES[collection]} id.`); - else if (allIds.has(item.id)) errors.push(`Duplicate id: ${item.id}.`); else allIds.add(item.id); - } - const acIds = ids(contract.acceptanceCriteria); const invIds = ids(contract.invariants); const targetIds = new Set([...acIds, ...invIds, ...ids(contract.preservation)]); - for (const obligation of contract.obligations || []) { - if (!PRIORITIES.has(obligation.priority)) errors.push(`${obligation.id || 'obligation'} has an invalid priority.`); - if (typeof obligation.statement !== 'string' || !obligation.statement.trim()) errors.push(`${obligation.id || 'obligation'} needs a statement.`); - if (!Array.isArray(obligation.sourceQuotes) || obligation.sourceQuotes.length === 0 || !obligation.sourceQuotes.every((q) => typeof q === 'string' && q.trim())) errors.push(`${obligation.id || 'obligation'} needs sourceQuotes.`); - const linked = [...(obligation.acceptance || []), ...(obligation.invariants || [])]; - if (obligation.priority === 'must' && (!linked.length || !linked.every((id) => acIds.has(id) || invIds.has(id)))) errors.push(`${obligation.id || 'must obligation'} needs traced acceptance or invariant.`); - } - for (const item of contract.acceptanceCriteria || []) { - const observableStatement = typeof item.statement === 'string' && item.statement.trim() && typeof item.observation === 'string' && item.observation.trim(); - const observableScenario = typeof item.given === 'string' && item.given.trim() && typeof item.when === 'string' && item.when.trim() && Array.isArray(item.then) && item.then.length > 0 && item.then.every((step) => typeof step === 'string' && step.trim()); - if (!observableStatement && !observableScenario) errors.push(`${item.id || 'criterion'} needs an observation with statement or given/when/then.`); - if (!Array.isArray(item.evidence) || item.evidence.length === 0 || !item.evidence.every(validEvidenceId)) errors.push(`${item.id || 'criterion'} needs planned EV evidence ids.`); - } - for (const item of [...(contract.invariants || []), ...(contract.preservation || [])]) { - if (typeof item.statement !== 'string' || !item.statement.trim()) errors.push(`${item.id || 'criterion'} needs a statement.`); - if (!Array.isArray(item.evidence) || item.evidence.length === 0 || !item.evidence.every(validEvidenceId)) errors.push(`${item.id || 'criterion'} needs planned EV evidence ids.`); - } - if (contract.boundaries && (!Array.isArray(contract.boundaries.allowed) || !Array.isArray(contract.boundaries.forbidden))) errors.push('boundaries must have allowed and forbidden arrays.'); - return { ok: errors.length === 0, errors, targetIds }; -} - -export async function verifyRun(cwd, runId) { - const { files, state } = await loadState(cwd, runId); - if (!(await exists(files.brief))) throw new KernelError('Immutable brief is missing.', 'CORRUPT_STATE'); - const briefHash = sha256(await readFile(files.brief, 'utf8')); - if (briefHash !== state.briefSha256) throw new KernelError('Immutable brief hash does not match state.', 'CORRUPT_STATE'); - for (const [version, expectedHash] of Object.entries(state.contractHashes || {})) { - const file = path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`); - await verifyStateEntry(file, files.contracts, 'file', `contract v${version}`); - const contract = await readJson(file, 'Frozen contract'); - if (contract.contractSha256 !== expectedHash || hashWithout(contract, 'contractSha256') !== expectedHash) throw new KernelError(`Frozen contract v${version} was changed.`, 'FROZEN_CONTRACT_CHANGED'); - } - if (await exists(files.usage)) await loadUsage(files); - return { files, state }; -} - -async function initRunUnlocked(cwd, runId, briefText, options = {}) { - const files = await verifiedLayout(cwd, runId, { createRoot: true }); - if (await exists(files.run)) throw new KernelError(`Run already exists: ${runId}`, 'RUN_EXISTS'); - const reconciliation = await reconcileActiveRuns(cwd); - if (reconciliation.classification === 'canonical-active') throw new KernelError(`An active run already exists: ${reconciliation.pointerRunId}`, 'ACTIVE_RUN_EXISTS'); - if (reconciliation.classification !== 'clean-idle') throw new KernelError('The active-run state is inconsistent and must be reconciled before initialization.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation.classification, ...reconciliation.activeRunIds]); - if (typeof briefText !== 'string' || !briefText.trim()) throw new KernelError('briefText is required.', 'INVALID_BRIEF'); - const brief = redact(briefText); - const now = new Date().toISOString(); - const state = setStateHash({ format: FORMAT, runId, status: 'active', phase: 'understand', baselineRequired: true, briefSha256: sha256(brief), contractHashes: {}, currentContractVersion: null, createdAt: now, updatedAt: now }); - const evidence = { format: FORMAT, entries: [] }; evidence.storeSha256 = evidenceStoreHash(evidence); - const usage = unavailableUsage(now); usage.usageSha256 = usageHash(usage); - await executeTransition(cwd, 'init', runId, [ - { file: files.brief, content: brief }, - { file: files.state, content: jsonText(state) }, - { file: files.evidence, content: jsonText(evidence) }, - { file: files.usage, content: jsonText(usage) }, - { file: files.active, content: jsonText({ format: FORMAT, runId, updatedAt: now }) }, - ], options); - return { runId, briefSha256: state.briefSha256 }; -} - -export async function initRun(cwd, runId, briefText, options = {}) { - return withWorkspaceLock(cwd, `init:${safeRunId(runId)}`, () => initRunUnlocked(cwd, runId, briefText, options)); -} - -async function currentContract(files, state) { - if (!state.currentContractVersion) throw new KernelError('No frozen contract exists.', 'NO_CONTRACT'); - const version = state.currentContractVersion; - const file = path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`); - await verifyStateEntry(file, files.contracts, 'file', `contract v${version}`); - return readJson(file, 'Frozen contract'); -} - -function normalizeForMatch(value) { return String(value).replace(/\s+/g, ' ').trim().toLocaleLowerCase(); } -async function validateSourceQuotes(files, contract, { authority = '', changedObligationIds = new Set() } = {}) { - const brief = normalizeForMatch(await readFile(files.brief, 'utf8')); - const authorityText = normalizeForMatch(redact(authority)); - const missing = (contract.obligations || []).flatMap((obligation) => (obligation.sourceQuotes || []).filter((quote) => { - const normalized = normalizeForMatch(quote); - return !brief.includes(normalized) && !(changedObligationIds.has(obligation.id) && authorityText.includes(normalized)); - }).map((quote) => `${obligation.id}: ${quote}`)); - if (missing.length) throw new KernelError('Contract sourceQuotes are not present in the sanitized brief.', 'SOURCE_QUOTE_NOT_IN_BRIEF', missing); -} - -function collectionMap(contract, collection) { return new Map((contract[collection] || []).map((item) => [item.id, item])); } -export function normativeDiff(previous, candidate) { - const changes = []; - for (const collection of Object.keys(PREFIXES)) { - const before = collectionMap(previous, collection); const after = collectionMap(candidate, collection); - for (const id of new Set([...before.keys(), ...after.keys()])) { - const prior = before.get(id); const next = after.get(id); - if (canonicalJson(prior) !== canonicalJson(next)) changes.push({ token: id, previous: prior ?? null, next: next ?? null }); - } - } - const top = { INTENT: 'intent', ACTORS: 'actors', BOUNDARIES: 'boundaries', ASSUMPTIONS: 'assumptions', 'OUT-OF-SCOPE': 'outOfScope' }; - for (const [token, property] of Object.entries(top)) if (canonicalJson(previous[property] ?? null) !== canonicalJson(candidate[property] ?? null)) changes.push({ token, previous: previous[property] ?? null, next: candidate[property] ?? null }); - return changes; -} - -function invalidationTargets(previous, changes) { - const broad = changes.some((change) => TOP_LEVEL_TOKENS.includes(change.token) || change.token.startsWith('SEAM-') || change.token.startsWith('NFR-')); - if (broad) return null; - const targets = new Set(); const requirements = collectionMap(previous, 'obligations'); - for (const change of changes) { - if (/^(AC|INV|PRES)-/.test(change.token)) targets.add(change.token); - if (change.token.startsWith('REQ-')) for (const requirement of [change.previous, change.next, requirements.get(change.token)]) for (const target of [...(requirement?.acceptance || []), ...(requirement?.invariants || [])]) targets.add(target); - } - return targets; -} - -async function freezeContractUnlocked(cwd, runId, candidate, options = {}) { - const { files, state } = await verifyRun(cwd, runId); - await requireCanonicalActiveRun(cwd, state, runId); - if (state.baselineRequired === true) { - if (!(await exists(files.baseline))) throw new KernelError('Record an explicit baseline before freezing a new run.', 'BASELINE_REQUIRED'); - await loadBaseline(files); - } - const expectedVersion = (state.currentContractVersion || 0) + 1; - if (state.currentContractVersion) throw new KernelError('A frozen contract already exists; use contract amend.', 'AMEND_REQUIRED'); - const validation = validateContract(candidate, { expectedVersion }); - if (!validation.ok) throw new KernelError('Contract validation failed.', 'INVALID_CONTRACT', validation.errors); - const contract = redactValue(structuredClone(candidate)); - await validateSourceQuotes(files, contract); - contract.status = 'frozen'; - contract.source = { briefPath: 'brief.md', briefSha256: state.briefSha256 }; - contract.contractSha256 = hashWithout(contract, 'contractSha256'); - const output = path.join(files.contracts, 'contract-v001.json'); - const nextState = structuredClone(state); nextState.currentContractVersion = 1; nextState.contractHashes['1'] = contract.contractSha256; nextState.phase = 'execute'; nextState.updatedAt = new Date().toISOString(); setStateHash(nextState); - await executeTransition(cwd, 'freeze', runId, [{ file: output, content: jsonText(contract) }, { file: files.state, content: jsonText(nextState) }], options); - return { version: 1, contractSha256: contract.contractSha256, path: output }; -} - -export async function freezeContract(cwd, runId, candidate, options = {}) { - return withWorkspaceLock(cwd, `freeze-contract:${safeRunId(runId)}`, () => freezeContractUnlocked(cwd, runId, candidate, options)); -} - -async function amendContractUnlocked(cwd, runId, candidate, reason, affected, authority, options = {}) { - const { files, state } = await verifyRun(cwd, runId); - await requireCanonicalActiveRun(cwd, state, runId); - const previous = await currentContract(files, state); - if (typeof reason !== 'string' || !reason.trim()) throw new KernelError('An amendment reason is required.', 'AMENDMENT_REASON_REQUIRED'); - if (typeof authority !== 'string' || !authority.trim()) throw new KernelError('An amendment authority is required.', 'AMENDMENT_AUTHORITY_REQUIRED'); - if (!Array.isArray(affected) || affected.length === 0) throw new KernelError('Affected contract ids are required.', 'AMENDMENT_AFFECTED_REQUIRED'); - const version = state.currentContractVersion + 1; - const validation = validateContract(candidate, { expectedVersion: version }); - if (!validation.ok) throw new KernelError('Contract validation failed.', 'INVALID_CONTRACT', validation.errors); - const contract = redactValue(structuredClone(candidate)); - const changes = normativeDiff(previous, contract); - await validateSourceQuotes(files, contract, { authority, changedObligationIds: new Set(changes.filter((change) => change.token.startsWith('REQ-')).map((change) => change.token)) }); - const actualTokens = new Set(changes.map((change) => change.token)); const suppliedTokens = new Set(affected); - if (changes.length === 0 || [...actualTokens].some((token) => !suppliedTokens.has(token)) || [...suppliedTokens].some((token) => !actualTokens.has(token))) { - throw new KernelError('Amendment affects must exactly cover the normative contract diff.', 'INVALID_AMENDMENT', [...actualTokens]); - } - contract.status = 'frozen'; contract.source = { briefPath: 'brief.md', briefSha256: state.briefSha256 }; contract.amends = previous.version; - contract.contractSha256 = hashWithout(contract, 'contractSha256'); - const evidence = await loadEvidence(files); - const now = new Date().toISOString(); - const targets = invalidationTargets(previous, changes); - for (const entry of evidence.entries || []) if (entry.contractVersion === previous.version && (targets === null || (entry.covers || []).some((id) => targets.has(id)))) { - entry.status = 'invalidated'; entry.invalidatedAt = now; entry.invalidatedBy = `contract-v${version}`; - } - evidence.storeSha256 = evidenceStoreHash(evidence); - const amendment = { format: FORMAT, fromVersion: previous.version, toVersion: version, reason: redact(reason), authority: redact(authority), affected: [...suppliedTokens], changes, createdAt: now }; - const nextState = structuredClone(state); nextState.currentContractVersion = version; nextState.contractHashes[String(version)] = contract.contractSha256; nextState.phase = 'execute'; nextState.updatedAt = now; setStateHash(nextState); - await executeTransition(cwd, 'amend', runId, [ - { file: path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`), content: jsonText(contract) }, - { file: files.evidence, content: jsonText(evidence) }, - { file: path.join(files.amendments, `amendment-v${String(version).padStart(3, '0')}.json`), content: jsonText(amendment) }, - { file: files.state, content: jsonText(nextState) }, - ], options); - return { version, changes: [...actualTokens], invalidatedEvidence: (evidence.entries || []).filter((entry) => entry.status === 'invalidated' && entry.invalidatedBy === `contract-v${version}`).map((entry) => entry.evidenceId) }; -} - -export async function amendContract(cwd, runId, candidate, reason, affected, authority, options = {}) { - return withWorkspaceLock(cwd, `amend-contract:${safeRunId(runId)}`, () => amendContractUnlocked(cwd, runId, candidate, reason, affected, authority, options)); -} - -function allTargetIds(contract) { return new Set([...ids(contract.acceptanceCriteria), ...ids(contract.invariants), ...ids(contract.preservation)]); } -function plannedEvidence(contract) { return new Set([...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])].flatMap((item) => item.evidence || [])); } - -function evidenceStoreHash(store) { return hashWithout(store, 'storeSha256'); } -async function loadEvidence(files) { - const store = await readJson(files.evidence, 'Evidence store'); - if (store.format !== FORMAT || !Array.isArray(store.entries) || typeof store.storeSha256 !== 'string' || evidenceStoreHash(store) !== store.storeSha256) { - throw new KernelError('Evidence store is corrupt.', 'CORRUPT_EVIDENCE'); - } - return store; -} -function nonEmptyText(value) { return typeof value === 'string' && value.trim().length > 0; } - -function usageHash(receipt) { return hashWithout(receipt, 'usageSha256'); } -function unavailableUsage(capturedAt = new Date().toISOString(), reason = 'The current surface did not expose authoritative per-task token usage.') { - return { format: FORMAT, status: 'unavailable', source: 'host-unavailable', scope: 'task', capturedAt, reason }; -} -function nonNegativeInteger(value) { return Number.isInteger(value) && value >= 0; } -function canonicalTimestamp(value) { - if (typeof value !== 'string') return false; - const match = value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,3}))?Z$/); - if (!match) return false; - const normalized = `${match[1]}.${(match[2] || '').padEnd(3, '0')}Z`; const parsed = new Date(value); - return !Number.isNaN(parsed.valueOf()) && parsed.toISOString() === normalized; -} -function singleLineText(value, maximum = 500) { return nonEmptyText(value) && value.length <= maximum && !/[\u0000-\u001f\u007f]/u.test(value); } -function usageErrors(receipt, { requireHash = false } = {}) { - const errors = []; - if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)) return ['Usage receipt must be an object.']; - for (const key of Object.keys(receipt)) if (!USAGE_FIELDS.has(key)) errors.push(`Unknown usage field: ${key}.`); - if (receipt.format !== FORMAT) errors.push(`format must be ${FORMAT}.`); - if (!USAGE_STATUSES.has(receipt.status)) errors.push('status must be actual or unavailable.'); - if (!USAGE_SOURCES.has(receipt.source)) errors.push('source is not supported.'); - if (!USAGE_SCOPES.has(receipt.scope)) errors.push('scope must be turn, task, or run.'); - if (!canonicalTimestamp(receipt.capturedAt)) errors.push('capturedAt must be a canonical UTC ISO-8601 timestamp.'); - if (receipt.model !== undefined && (typeof receipt.model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(receipt.model))) errors.push('model must be a bounded model identifier when supplied.'); - if (receipt.reference !== undefined && (typeof receipt.reference !== 'string' || !/^(?:(?:turn|response|resp|request|req|run|thread|trace|event|usage)[-_:][A-Za-z0-9._:-]{1,240}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$/.test(receipt.reference))) errors.push('reference must be a recognized opaque event identifier.'); - if (receipt.reason !== undefined && !singleLineText(receipt.reason)) errors.push('reason must be bounded single-line text when supplied.'); - const numericFields = ['inputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens']; - for (const field of numericFields) if (receipt[field] !== undefined && !nonNegativeInteger(receipt[field])) errors.push(`${field} must be a non-negative integer.`); - if (receipt.status === 'actual') { - if (receipt.source === 'host-unavailable') errors.push('actual usage needs an observed source.'); - if (!nonNegativeInteger(receipt.inputTokens) || !nonNegativeInteger(receipt.outputTokens)) errors.push('actual usage needs inputTokens and outputTokens.'); - if (nonNegativeInteger(receipt.inputTokens) && nonNegativeInteger(receipt.outputTokens) && receipt.totalTokens !== receipt.inputTokens + receipt.outputTokens) errors.push('totalTokens must equal inputTokens plus outputTokens.'); - if (nonNegativeInteger(receipt.cachedInputTokens) && nonNegativeInteger(receipt.inputTokens) && receipt.cachedInputTokens > receipt.inputTokens) errors.push('cachedInputTokens cannot exceed inputTokens.'); - if (nonNegativeInteger(receipt.cacheWriteInputTokens) && nonNegativeInteger(receipt.inputTokens) && receipt.cacheWriteInputTokens > receipt.inputTokens) errors.push('cacheWriteInputTokens cannot exceed inputTokens.'); - if (nonNegativeInteger(receipt.reasoningOutputTokens) && nonNegativeInteger(receipt.outputTokens) && receipt.reasoningOutputTokens > receipt.outputTokens) errors.push('reasoningOutputTokens cannot exceed outputTokens.'); - } - if (receipt.status === 'unavailable') { - if (receipt.source !== 'host-unavailable') errors.push('unavailable usage must use host-unavailable source.'); - if (!nonEmptyText(receipt.reason)) errors.push('unavailable usage needs a reason.'); - for (const field of numericFields) if (receipt[field] !== undefined) errors.push(`unavailable usage must not claim ${field}.`); - } - if (requireHash && (typeof receipt.usageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(receipt.usageSha256))) errors.push('usageSha256 is missing or invalid.'); - return errors; -} -function sanitizeUsageInput(record) { - if (!record || typeof record !== 'object' || Array.isArray(record)) throw new KernelError('Usage validation failed.', 'INVALID_USAGE', ['Usage receipt must be an object.']); - const inputErrors = Object.keys(record).filter((key) => !USAGE_FIELDS.has(key) || key === 'usageSha256').map((key) => `Unknown or protected usage field: ${key}.`); - if (record.format !== undefined && record.format !== FORMAT) inputErrors.push(`format must be ${FORMAT} when supplied.`); - if (inputErrors.length) throw new KernelError('Usage validation failed.', 'INVALID_USAGE', inputErrors); - const receipt = { - format: FORMAT, - status: record.status, - source: record.source, - scope: record.scope || 'task', - capturedAt: record.capturedAt || new Date().toISOString(), - }; - for (const field of ['model', 'reason', 'reference']) if (record[field] !== undefined) receipt[field] = redact(record[field]); - for (const field of ['inputTokens', 'cachedInputTokens', 'cacheWriteInputTokens', 'outputTokens', 'reasoningOutputTokens', 'totalTokens']) if (record[field] !== undefined) receipt[field] = record[field]; - if (receipt.status === 'actual' && receipt.totalTokens === undefined && nonNegativeInteger(receipt.inputTokens) && nonNegativeInteger(receipt.outputTokens)) receipt.totalTokens = receipt.inputTokens + receipt.outputTokens; - return receipt; -} -async function loadUsage(files) { - const receipt = await readJson(files.usage, 'Usage receipt'); - const errors = usageErrors(receipt, { requireHash: true }); - if (errors.length || usageHash(receipt) !== receipt.usageSha256) throw new KernelError('Usage receipt is corrupt.', 'CORRUPT_USAGE', errors); - return receipt; -} -async function writeUsage(files, receipt) { - const stored = structuredClone(receipt); delete stored.usageSha256; stored.usageSha256 = usageHash(stored); await writeJsonAtomic(files.usage, stored); return stored; -} - -async function recordUsageUnlocked(cwd, runId, record, expectedUsageSha256) { - const { files, state } = await verifyRun(cwd, runId); const current = await loadUsage(files); - if (state.status === 'active') await requireCanonicalActiveRun(cwd, state, runId); - if (expectedUsageSha256 !== undefined && current.usageSha256 !== expectedUsageSha256) throw new KernelError('Usage changed after the caller snapshot.', 'STALE_USAGE'); - const receipt = sanitizeUsageInput(record); const errors = usageErrors(receipt); - if (errors.length) throw new KernelError('Usage validation failed.', 'INVALID_USAGE', errors); - return writeUsage(files, receipt); -} - -export async function recordUsage(cwd, runId, record, options = {}) { - const expectedUsageSha256 = Object.prototype.hasOwnProperty.call(options, 'expectedUsageSha256') - ? options.expectedUsageSha256 - : (await loadUsage((await verifyRun(cwd, safeRunId(runId))).files)).usageSha256; - if (typeof expectedUsageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedUsageSha256)) throw new KernelError('expectedUsageSha256 must identify the caller snapshot.', 'INVALID_USAGE'); - return withWorkspaceLock(cwd, `record-usage:${safeRunId(runId)}`, () => recordUsageUnlocked(cwd, runId, record, expectedUsageSha256)); -} - -function evidenceEntryErrors(entry, contract, version) { - const errors = []; - if (!entry || typeof entry !== 'object' || !validEvidenceId(entry.evidenceId)) errors.push('evidenceId must use the EV-NNN prefix.'); - if (entry.contractVersion !== version) errors.push('Evidence must use the current contract version.'); - if (!Array.isArray(entry.covers) || entry.covers.length === 0 || !entry.covers.every((id) => allTargetIds(contract).has(id))) errors.push('Evidence covers unknown criteria.'); - if (!EVIDENCE_STATUSES.has(entry.status) || entry.status === 'invalidated') errors.push('Evidence status is invalid.'); - if (!EVIDENCE_TYPES.has(entry.type)) errors.push('Evidence type is invalid.'); - if (!nonEmptyText(entry.command) && !nonEmptyText(entry.procedure)) errors.push('Evidence needs a nonempty command or procedure.'); - if (!nonEmptyText(entry.observed)) errors.push('Evidence needs a nonempty observed result.'); - if (entry.status === 'pass' && !nonEmptyText(entry.artifact) && !nonEmptyText(entry.reference)) errors.push('Passing evidence needs an artifact or reference.'); - const criticalTargets = new Set([...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])].filter((item) => item.critical).map((item) => item.id)); - if (entry.status === 'pass' && (entry.covers || []).some((id) => criticalTargets.has(id)) && (!nonEmptyText(entry.sensitivity?.method) || !nonEmptyText(entry.sensitivity?.observed))) errors.push('Passing evidence for a critical target needs sensitivity.method and sensitivity.observed.'); - if (!plannedEvidence(contract).has(entry.evidenceId)) errors.push('Evidence id is not planned by the frozen contract.'); - if (entry.provenance?.kind === 'manual-attestation' && nonEmptyText(entry.command)) errors.push('Manual-attestation evidence must not contain a command.'); - if (entry.provenance && !['manual-attestation', 'captured-command'].includes(entry.provenance.kind)) errors.push('Evidence provenance kind is invalid.'); - return errors; -} - -async function recordEvidenceUnlocked(cwd, runId, record, { allowCaptured = false, expectedSnapshot, transitionOptions = {} } = {}) { - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); - await requireCanonicalActiveRun(cwd, state, runId); - if (expectedSnapshot && (state.stateSha256 !== expectedSnapshot.stateSha256 || state.currentContractVersion !== expectedSnapshot.contractVersion || contract.contractSha256 !== expectedSnapshot.contractSha256)) { - throw new KernelError('The run changed while evidence was being captured.', 'STALE_CAPTURE'); - } - const entry = redactValue(structuredClone(record)); const errors = evidenceEntryErrors(entry, contract, state.currentContractVersion); - if (entry.provenance?.kind === 'captured-command' && !allowCaptured) errors.push('Captured-command provenance may only be written by evidence capture.'); - if (errors.length) throw new KernelError('Evidence validation failed.', 'INVALID_EVIDENCE', errors); - entry.verifiedAt = entry.verifiedAt || new Date().toISOString(); - const evidence = await loadEvidence(files); - // Evidence is historical: a new version may intentionally reuse EV-001. - evidence.entries = (evidence.entries || []).filter((item) => item.evidenceId !== entry.evidenceId || item.contractVersion !== entry.contractVersion); - evidence.entries.push(entry); - evidence.storeSha256 = evidenceStoreHash(evidence); - await executeTransition(cwd, 'evidence', runId, [{ file: files.evidence, content: jsonText(evidence) }], transitionOptions); return entry; -} - -export async function recordEvidence(cwd, runId, record, options = {}) { - return withWorkspaceLock(cwd, `record-evidence:${safeRunId(runId)}`, () => recordEvidenceUnlocked(cwd, runId, record, { transitionOptions: options })); -} - -function captureOutput(stream, limit = 65536) { - let settled = false; let storedBytes = 0; let truncated = false; const chunks = []; let settle; - const promise = new Promise((resolve) => { settle = (incomplete = false) => { - if (settled) return; settled = true; - resolve({ value: redact(Buffer.concat(chunks).toString('utf8')), truncated, incomplete }); - }; }); - stream.on('data', (chunk) => { - const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - const remaining = Math.max(0, limit - storedBytes); - if (remaining > 0) { const kept = value.subarray(0, remaining); chunks.push(kept); storedBytes += kept.length; } - if (value.length > remaining) truncated = true; - }); - stream.once('end', () => settle(false)); stream.once('close', () => { if (stream.readableEnded) settle(false); }); stream.once('error', () => settle(true)); - return { promise, force: () => { settle(true); stream.destroy(); } }; -} - -function isPhysicalDescendant(root, candidate) { - const relative = path.relative(root, candidate); - return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); -} - -async function resolveContainedExisting(cwd, requested, label, kind) { - const relative = safeRelativePath(requested, label); let rootReal; let candidateReal; - try { rootReal = await realpath(path.resolve(cwd)); } - catch (error) { throw new KernelError(`Workspace path is unreadable: ${cwd}`, 'UNSAFE_PATH', [error.message]); } - try { candidateReal = await realpath(path.resolve(rootReal, relative)); } - catch (error) { - const code = kind === 'directory' ? 'CAPTURE_CWD_INVALID' : 'CAPTURE_ARTIFACT_MISSING'; - throw new KernelError(`${label} is missing or unreadable: ${relative}`, code, [error.message]); - } - if (!isPhysicalDescendant(rootReal, candidateReal)) throw new KernelError(`${label} escapes the physical workspace.`, 'UNSAFE_PATH'); - const information = await stat(candidateReal); - if (kind === 'directory' && !information.isDirectory()) throw new KernelError(`${label} must resolve to a directory.`, 'CAPTURE_CWD_INVALID'); - if (kind === 'file' && !information.isFile()) throw new KernelError(`${label} must resolve to a regular file.`, 'CAPTURE_ARTIFACT_MISSING'); - return { relative, absolute: candidateReal }; -} - -async function artifactHashes(cwd, entry, { required = true } = {}) { - if (!nonEmptyText(entry.artifact)) return {}; - try { - const artifact = await resolveContainedExisting(cwd, entry.artifact, 'artifact', 'file'); - return { [artifact.relative]: sha256(await readFile(artifact.absolute)) }; - } catch (error) { - if (!required && error.code === 'CAPTURE_ARTIFACT_MISSING') return {}; - throw error; - } -} - -function declaredFreshnessPaths(value, fallback = []) { - const input = value === undefined ? fallback : value; - if (!Array.isArray(input) || input.length > 64) throw new KernelError('freshnessPaths must contain at most 64 relative file paths.', 'INVALID_FRESHNESS_SCOPE'); - const paths = [...new Set(input.map((item) => safeRelativePath(item, 'freshness path')))]; - if (paths.length !== input.length) throw new KernelError('freshnessPaths must be unique.', 'INVALID_FRESHNESS_SCOPE'); - return paths; -} -async function gitHeadForFingerprint(cwd) { - return new Promise((resolve) => { - let output = ''; let settled = false; - const child = spawn('git', ['-C', cwd, 'rev-parse', 'HEAD'], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); - const finish = (value) => { if (!settled) { settled = true; resolve(value); } }; - const timer = setTimeout(() => { child.kill(); finish(null); }, 2000); - child.stdout.on('data', (chunk) => { if (output.length < 256) output += chunk.toString('utf8'); }); - child.once('error', () => { clearTimeout(timer); finish(null); }); - child.once('close', (code) => { clearTimeout(timer); const head = output.trim(); finish(code === 0 && /^[a-f0-9]{40,64}$/i.test(head) ? head.toLowerCase() : null); }); - }); -} -async function workspaceFingerprint(cwd, requestedPaths) { - const paths = declaredFreshnessPaths(requestedPaths); - if (paths.length === 0) return { kind: 'unavailable', status: 'unavailable', reason: 'No bounded freshness paths were declared.', paths: [] }; - const entries = []; - for (const relative of paths) { - try { - const lexical = path.resolve(cwd, relative); const lexicalEntry = await existingEntry(lexical, 'freshness path'); - if (lexicalEntry?.isSymbolicLink()) throw new KernelError('freshness path must be a physical regular file.', 'INVALID_FRESHNESS_SCOPE', [relative]); - const file = await resolveContainedExisting(cwd, relative, 'freshness path', 'file'); - entries.push({ path: file.relative, sha256: sha256(await readFile(file.absolute)) }); - } catch (error) { - if (error.code !== 'CAPTURE_ARTIFACT_MISSING') throw error; - return { kind: 'unavailable', status: 'unavailable', reason: `Declared freshness path is missing: ${relative}`, paths }; - } - } - const gitHead = await gitHeadForFingerprint(cwd); const fingerprintSha256 = sha256(canonicalJson({ entries })); - return { kind: gitHead ? 'git-paths-v1' : 'artifacts-v1', status: 'current', gitHead, paths, entries, fingerprintSha256 }; -} -async function assessWorkspaceFingerprint(cwd, fingerprint) { - if (!fingerprint || fingerprint.status !== 'current' || !Array.isArray(fingerprint.paths) || typeof fingerprint.fingerprintSha256 !== 'string') return { status: 'unavailable', reason: fingerprint?.reason || 'No trustworthy workspace fingerprint was captured.' }; - const current = await workspaceFingerprint(cwd, fingerprint.paths); - if (current.status !== 'current') return { status: 'unavailable', reason: current.reason }; - return current.fingerprintSha256 === fingerprint.fingerprintSha256 - ? { status: 'current' } - : { status: 'stale', reason: 'One or more declared freshness paths changed after capture.' }; -} - -function baselineHash(receipt) { return hashWithout(receipt, 'baselineSha256'); } -async function loadBaseline(files) { - if (!(await exists(files.baseline))) return { format: FORMAT, status: 'unavailable', reason: 'Legacy run has no baseline receipt.', legacy: true }; - const receipt = await readJson(files.baseline, 'Baseline receipt'); - if (receipt.format !== FORMAT || !['green', 'pre-existing-failure', 'unavailable'].includes(receipt.status) || typeof receipt.baselineSha256 !== 'string' || baselineHash(receipt) !== receipt.baselineSha256) throw new KernelError('Baseline receipt is corrupt.', 'CORRUPT_BASELINE'); - return receipt; -} -async function baselinePreflight(cwd, runId) { - const { files, state } = await verifyRun(cwd, runId); await requireCanonicalActiveRun(cwd, state, runId); - if (await exists(files.baseline)) throw new KernelError('A baseline receipt already exists.', 'BASELINE_EXISTS'); - if (state.currentContractVersion !== null || state.phase !== 'understand') throw new KernelError('Baseline must be recorded before the contract is frozen.', 'BASELINE_TOO_LATE'); - const evidence = await loadEvidence(files); - if ((evidence.entries || []).length > 0) throw new KernelError('Baseline must be recorded before evidence capture.', 'BASELINE_TOO_LATE'); - return { files, state }; -} -export async function recordUnavailableBaseline(cwd, runId, reason) { - if (!nonEmptyText(reason)) throw new KernelError('Unavailable baseline requires a reason.', 'INVALID_BASELINE'); - return withWorkspaceLock(cwd, `record-baseline:${safeRunId(runId)}`, async () => { - const { files } = await baselinePreflight(cwd, runId); const receipt = { format: FORMAT, status: 'unavailable', reason: redact(reason), capturedAt: new Date().toISOString(), provenance: { kind: 'manual-attestation' } }; - receipt.baselineSha256 = baselineHash(receipt); await writeJsonAtomic(files.baseline, receipt); return receipt; - }); -} - -function capturePolicy(options = {}) { - const timeoutMs = Number(options.timeoutMs ?? CAPTURE_TIMEOUT_MS); const terminationGraceMs = Number(options.terminationGraceMs ?? CAPTURE_TERMINATION_GRACE_MS); - if (!Number.isInteger(timeoutMs) || timeoutMs < CAPTURE_TIMEOUT_MIN_MS || timeoutMs > CAPTURE_TIMEOUT_MAX_MS) throw new KernelError(`timeoutMs must be an integer from ${CAPTURE_TIMEOUT_MIN_MS} to ${CAPTURE_TIMEOUT_MAX_MS}.`, 'INVALID_TIMEOUT'); - if (!Number.isInteger(terminationGraceMs) || terminationGraceMs < 10 || terminationGraceMs > 5000) throw new KernelError('terminationGraceMs must be an integer from 10 to 5000.', 'INVALID_TIMEOUT'); - return { timeoutMs, terminationGraceMs }; -} - -async function runTaskkill(pid) { - const killer = spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { shell: false, windowsHide: true, stdio: 'ignore' }); - return Promise.race([ - new Promise((resolve) => { killer.once('error', () => resolve(false)); killer.once('close', (code) => resolve(code === 0)); }), - sleep(2000).then(() => { killer.kill(); return false; }), - ]); -} - -async function terminateProcessTree(child, isClosed, graceMs) { - const result = { - method: process.platform === 'win32' ? 'taskkill-tree' : 'posix-process-group', - scope: process.platform === 'win32' ? 'taskkill-reported-tree' : 'original-process-group', - termSent: false, - killSent: false, - directChildFallback: false, - rootExitObserved: isClosed(), - detachedDescendantsCovered: false, - succeeded: false, - observation: isClosed() ? 'Root process closed before tree termination could be observed.' : 'Termination not yet attempted.', - }; - if (!child.pid || isClosed()) return result; - if (process.platform === 'win32') { - result.termSent = true; result.succeeded = await runTaskkill(child.pid); - result.rootExitObserved = isClosed(); - if (result.succeeded) result.observation = 'taskkill /T reported successful process-tree termination.'; - else if (!isClosed()) { - result.directChildFallback = true; result.killSent = child.kill(); result.rootExitObserved = isClosed(); - result.observation = result.killSent - ? 'taskkill /T failed; a direct-child kill was attempted, so tree cleanup is unconfirmed.' - : 'taskkill /T failed and the direct-child fallback was not accepted; tree cleanup is unconfirmed.'; - } else result.observation = 'taskkill /T failed; the root closed, but descendant cleanup is unconfirmed.'; - return result; - } - try { process.kill(-child.pid, 'SIGTERM'); result.termSent = true; } - catch (error) { - if (error.code === 'ESRCH') { - result.succeeded = true; result.rootExitObserved = isClosed(); result.observation = 'The original process group was already absent.'; return result; - } - result.observation = `SIGTERM to the original process group failed: ${error.code || 'unknown error'}.`; - } - await sleep(graceMs); - let groupAbsent = false; - try { process.kill(-child.pid, 0); } - catch (error) { if (error.code === 'ESRCH') groupAbsent = true; } - if (!groupAbsent) { - try { process.kill(-child.pid, 'SIGKILL'); result.killSent = true; } - catch (error) { - if (error.code === 'ESRCH') groupAbsent = true; - else { result.rootExitObserved = isClosed(); result.observation = `SIGKILL to the original process group failed: ${error.code || 'unknown error'}.`; return result; } - } - } - if (!groupAbsent) { - await sleep(Math.min(graceMs, 250)); - try { process.kill(-child.pid, 0); } - catch (error) { if (error.code === 'ESRCH') groupAbsent = true; } - } - result.rootExitObserved = isClosed(); result.succeeded = groupAbsent; - result.observation = groupAbsent - ? 'The original POSIX process group is absent after termination signals; detached descendants are outside this observation.' - : 'The original POSIX process group still exists or could not be observed as absent; cleanup is unconfirmed.'; - return result; -} - -async function settleCapturedOutput(capture, waitMs = 750) { - const timed = Symbol('output-timeout'); const result = await Promise.race([capture.promise, sleep(waitMs).then(() => timed)]); - if (result !== timed) return result; capture.force(); return capture.promise; -} - -async function runCapturedCommand(argv, intendedCwd, options = {}, missingMessage = 'capture requires a command after --.') { - if (!Array.isArray(argv) || argv.length === 0 || !argv.every((item) => typeof item === 'string' && item.length > 0)) throw new KernelError(missingMessage, 'MISSING_COMMAND'); - const policy = capturePolicy(options); - const child = spawn(argv[0], argv.slice(1), { cwd: intendedCwd.absolute, detached: process.platform !== 'win32', shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); - const stdoutCapture = captureOutput(child.stdout); const stderrCapture = captureOutput(child.stderr); let closed = false; let timer; - const closePromise = new Promise((resolve, reject) => { - child.once('error', (error) => reject(new KernelError(`Could not start capture command: ${error.message}`, 'CAPTURE_SPAWN_FAILED'))); - child.once('close', (exitCode, signal) => { closed = true; resolve({ exitCode: exitCode ?? null, signal: signal ?? null }); }); - }); - let result; let timedOut = false; let termination = { method: 'none', termSent: false, killSent: false, succeeded: true }; - try { - const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve({ timedOut: true }), policy.timeoutMs); }); - const first = await Promise.race([closePromise.then((value) => ({ timedOut: false, value })), deadline]); - if (!first.timedOut) result = first.value; - else { - timedOut = true; termination = await terminateProcessTree(child, () => closed, policy.terminationGraceMs); - const incomplete = Symbol('close-timeout'); const closedResult = await Promise.race([closePromise, sleep(policy.terminationGraceMs + 1000).then(() => incomplete)]); - if (closedResult === incomplete) { if (!closed) child.kill('SIGKILL'); result = { exitCode: null, signal: null }; termination.succeeded = false; } - else result = closedResult; - } - } finally { if (timer) clearTimeout(timer); } - const stdout = await settleCapturedOutput(stdoutCapture); const stderr = await settleCapturedOutput(stderrCapture); const sanitizedArgv = redactArgv(argv); - return { - success: !timedOut && result.exitCode === 0, - observed: timedOut ? `Capture timed out after ${policy.timeoutMs} ms. ${termination.observation}` : `Captured exit code ${result.exitCode}.`, - provenance: { - kind: 'captured-command', argv: sanitizedArgv, cwd: intendedCwd.relative, exitCode: result.exitCode, signal: result.signal, timedOut, timeoutMs: policy.timeoutMs, termination, - stdout: stdout.value, stderr: stderr.value, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, outputIncomplete: stdout.incomplete || stderr.incomplete, - capturedAt: new Date().toISOString(), - }, - }; -} - -export async function captureBaseline(cwd, runId, template, argv, requestedCwd = '.', options = {}) { - const initial = await baselinePreflight(cwd, runId); const expectedStateSha256 = initial.state.stateSha256; - const intendedCwd = await resolveContainedExisting(cwd, requestedCwd, 'baseline cwd', 'directory'); - const captured = await runCapturedCommand(argv, intendedCwd, options, 'baseline capture requires a command after --.'); - const paths = declaredFreshnessPaths(template?.freshnessPaths, []); const fingerprint = await workspaceFingerprint(cwd, paths); - return withWorkspaceLock(cwd, `capture-baseline:${safeRunId(runId)}`, async () => { - const { files, state } = await baselinePreflight(cwd, runId); - if (state.stateSha256 !== expectedStateSha256) throw new KernelError('The run changed while the baseline was being captured.', 'STALE_CAPTURE'); - const receipt = { - format: FORMAT, - status: captured.provenance.timedOut || captured.provenance.exitCode === null ? 'unavailable' : captured.success ? 'green' : 'pre-existing-failure', - observed: captured.observed, - capturedAt: captured.provenance.capturedAt, - provenance: captured.provenance, - workspaceFingerprint: fingerprint, - }; - if (receipt.status === 'unavailable') receipt.reason = captured.observed; - receipt.baselineSha256 = baselineHash(receipt); await writeJsonAtomic(files.baseline, receipt); return receipt; - }); -} - -export async function captureEvidence(cwd, runId, template, argv, requestedCwd = '.', options = {}) { - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); - await requireCanonicalActiveRun(cwd, state, runId); - const expectedSnapshot = { stateSha256: state.stateSha256, contractVersion: state.currentContractVersion, contractSha256: contract.contractSha256 }; - const intendedCwd = await resolveContainedExisting(cwd, requestedCwd, 'capture cwd', 'directory'); const relativeCwd = intendedCwd.relative; - if (nonEmptyText(template?.artifact)) { - try { await resolveContainedExisting(cwd, template.artifact, 'artifact', 'file'); } - catch (error) { if (error.code !== 'CAPTURE_ARTIFACT_MISSING') throw error; } - } - const captured = await runCapturedCommand(argv, { ...intendedCwd, relative: relativeCwd }, options, 'evidence capture requires a command after --.'); - const entry = redactValue(structuredClone(template)); const freshnessPaths = declaredFreshnessPaths(entry.freshnessPaths, nonEmptyText(entry.artifact) ? [entry.artifact] : []); delete entry.freshnessPaths; - entry.command = captured.provenance.argv.join(' '); entry.status = captured.success ? 'pass' : 'fail'; entry.observed = captured.observed; - entry.provenance = { ...captured.provenance, artifactHashes: await artifactHashes(cwd, entry, { required: entry.status === 'pass' }), workspaceFingerprint: await workspaceFingerprint(cwd, freshnessPaths) }; - return withWorkspaceLock(cwd, `capture-evidence:${safeRunId(runId)}`, () => recordEvidenceUnlocked(cwd, runId, entry, { allowCaptured: true, expectedSnapshot, transitionOptions: options })); -} - -export async function validateEvidence(cwd, runId) { - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidence = await loadEvidence(files); - const errors = []; const seen = new Set(); - for (const entry of evidence.entries || []) { - // Older versions remain an audit trail. They cannot satisfy the current contract, - // but an invalidated historical record is not a current validation error. - if (!entry || entry.contractVersion !== state.currentContractVersion) continue; - const key = `${entry.contractVersion}:${entry.evidenceId}`; - if (seen.has(key)) errors.push('Evidence contains a duplicate evidenceId for the current contract.'); - seen.add(key); - errors.push(...evidenceEntryErrors(entry, contract, state.currentContractVersion).map((message) => `${entry.evidenceId || 'evidence'}: ${message}`)); - } - return { ok: errors.length === 0, errors, entries: evidence.entries || [] }; -} - -function normalizedZone(zone) { return safeRelativePath(zone, 'zone').replace(/\/$/, '').toLowerCase(); } -function zonesOverlap(left, right) { return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); } - -export function validateExecution(execution, contract) { - const errors = []; const units = execution?.units; - if (!Array.isArray(units)) return { ok: false, errors: ['execution.units must be an array.'] }; - const unitMap = new Map(); const traceTargets = new Set([...ids(contract.obligations), ...allTargetIds(contract)]); - for (const unit of units) { - if (!unit || !validId(unit.unitId)) { errors.push('Each work unit needs a valid unitId.'); continue; } - if (unitMap.has(unit.unitId)) errors.push(`Duplicate work unit: ${unit.unitId}.`); else unitMap.set(unit.unitId, unit); - const refs = [...(unit.obligations || []), ...(unit.criteria || [])]; - if (refs.length === 0 || !refs.every((id) => traceTargets.has(id))) errors.push(`${unit.unitId} is untraced or references an unknown id.`); - if (!Array.isArray(unit.zone) || unit.zone.length === 0) errors.push(`${unit.unitId} needs a write zone.`); - else { try { unit.zone.forEach(normalizedZone); } catch (error) { errors.push(`${unit.unitId} has an unsafe zone.`); } } - } - for (const unit of units) for (const dependency of unit.dependsOn || []) if (!unitMap.has(dependency)) errors.push(`${unit.unitId} depends on unknown unit ${dependency}.`); - const visiting = new Set(); const visited = new Set(); const reaches = new Map(); - const visit = (id, stack = []) => { - if (visiting.has(id)) { errors.push(`Execution DAG contains a cycle: ${[...stack, id].join(' -> ')}.`); return new Set(); } - if (visited.has(id)) return reaches.get(id); - visiting.add(id); const result = new Set(); const unit = unitMap.get(id); - for (const dep of unit?.dependsOn || []) { result.add(dep); for (const nested of visit(dep, [...stack, id])) result.add(nested); } - visiting.delete(id); visited.add(id); reaches.set(id, result); return result; - }; - for (const id of unitMap.keys()) visit(id); - const list = [...unitMap.values()]; - for (let i = 0; i < list.length; i += 1) for (let j = i + 1; j < list.length; j += 1) { - const a = list[i]; const b = list[j]; const sequential = reaches.get(a.unitId)?.has(b.unitId) || reaches.get(b.unitId)?.has(a.unitId); - if (!sequential && (a.zone || []).some((x) => (b.zone || []).some((y) => zonesOverlap(normalizedZone(x), normalizedZone(y))))) errors.push(`Parallel units ${a.unitId} and ${b.unitId} have overlapping write zones.`); - } - return { ok: errors.length === 0, errors }; -} - -async function fileSha256OrNull(file) { - try { return sha256(await readFile(file)); } - catch (error) { if (error.code === 'ENOENT') return null; throw error; } -} - -async function validateAndSaveExecutionUnlocked(cwd, runId, execution, expectedExecutionSha256) { - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const result = validateExecution(execution, contract); - await requireCanonicalActiveRun(cwd, state, runId); - const currentExecutionSha256 = await fileSha256OrNull(files.execution); - if (expectedExecutionSha256 !== undefined && currentExecutionSha256 !== expectedExecutionSha256) throw new KernelError('Execution view changed after the caller snapshot.', 'STALE_EXECUTION'); - if (!result.ok) throw new KernelError('Execution validation failed.', 'INVALID_EXECUTION', result.errors); - await writeJsonAtomic(files.execution, redactValue(execution)); return { ...result, executionSha256: await fileSha256OrNull(files.execution) }; -} - -export async function validateAndSaveExecution(cwd, runId, execution, options = {}) { - const files = await verifiedLayout(cwd, safeRunId(runId)); - const expectedExecutionSha256 = Object.prototype.hasOwnProperty.call(options, 'expectedExecutionSha256') ? options.expectedExecutionSha256 : await fileSha256OrNull(files.execution); - if (expectedExecutionSha256 !== null && (typeof expectedExecutionSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedExecutionSha256))) throw new KernelError('expectedExecutionSha256 must identify the caller snapshot or be null.', 'INVALID_EXECUTION'); - return withWorkspaceLock(cwd, `save-execution:${safeRunId(runId)}`, () => validateAndSaveExecutionUnlocked(cwd, runId, execution, expectedExecutionSha256)); -} - -function classifyConfirmedExternalStatement(text) { - const match = /(?:^|[.!?]\s*)(?:(?:(?:i\s+am|i'm)\s+(?:(?:an?|the)\s+)?(?:owner|maintainer)\s+(?:and\s+)|я\s+(?:владелец|мейнтейнер)\s+и\s+)?(?:(?:(?:i\s+)?(?:confirm|approve|authorize)|(?:я\s+)?(?:подтверждаю|одобряю|разрешаю))(?=\s|[-:>])[\s:>\-]*)+)/u.exec(text); - if (!match) return { kind: 'none', text: '' }; - const tail = text.slice(match.index + match[0].length).trim(); - const lead = tail.replace(/^(?:(?:first|then|please|сначала|затем)\s*,?\s*)+/u, ''); - const readOnly = /\?\s*$/u.test(tail) - || /^(?:do\s+not|don't|never|не)(?=\s|[.,;:!?]|$)/u.test(lead) - || /^(?:explain|describe|tell|report|объясни|расскажи|опиши|сообщи)(?=\s|[.,;:!?]|$)/u.test(lead) - || /^(?:(?:the\s+)?docs?|documentation)\s+(?:say|says|state|states|contain|contains)\b|^(?:документац|документ)\S*\s+(?:говор|указыва|содерж)/u.test(lead) - || /\b(?:is|are)\s+(?:risky|dangerous|safe|documented)\b/u.test(tail) - || /(?:опасен|опасна|опасно|опасны|безопасен|безопасна|безопасно|безопасны)(?=\s|[.,;:!?]|$)/u.test(tail) - || /\b(?:pull\s+request|pr)\s+plan\b|\bplan\s+(?:for|of)\s+(?:a\s+)?(?:pull\s+request|pr)\b/u.test(tail); - if (readOnly) return { kind: 'read-only', text: tail }; - const actionList = /^(?:\b(?:push|merge)\b|\b(?:create|open|submit|prepare|creating)\s+(?:a\s+)?(?:pull\s+request|pr)\b|\bcreation\s+of\s+(?:a\s+)?(?:pull\s+request|pr)\b|создани\S*\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)|(?:см[её]рдж|смердж|смерж|запуш|пуш|отправ)\S*)/u.test(lead); - return { kind: actionList ? 'action-list' : 'none', text: tail }; -} - -function quotedSpansStripped(value) { - return value.replace(/["“”«»`][^"“”«»`]{0,500}["“”«»`]/gu, ' '); -} - -function translationFrame(value) { - return value - .replace(/\b(?:translate|translation)\b[\s\S]{0,80}?\b(?:this|the|it)\s+(?:sentence|phrase|word|paragraph|text)\s*[:\-–]?\s*[\s\S]*$/u, 'translate this sentence') - .replace(/\b(?:translate|translation)\b[\s\S]{0,80}?\b(?:sentence|phrase|word|paragraph|text)\s*[:\-–]\s*[\s\S]+$/u, 'translate this sentence') - .replace(/перевед\S*[\s\S]{0,80}?(?:это|этот|эту|данн\S*)?\s*(?:предложен|фраз|слов|абзац|текст)\s*[:\-–]?\s*[\s\S]*$/u, 'переведи это предложение'); -} - -function exampleListStripped(value) { - return value - .replace(/\b(?:delete|deploy|rotate)(?:\s*\/\s*(?:delete|deploy|rotate))+/gu, ' ') - .replace(/(?:^|[.!?]\s*)\d+\.\s+[\s\S]+$/u, ' '); -} - -function analysisFrame(value, { translation, meta } = {}) { - let frame = quotedSpansStripped(value); - if (translation) frame = translationFrame(frame); - if (meta) frame = exampleListStripped(frame); - return frame.replace(/\s+/gu, ' ').trim(); -} - -export function routeTask(input = {}) { - const normalizedText = String(input.text || input.intent || input.request || '').normalize('NFKC').toLocaleLowerCase().replace(/ё/g, 'е'); - const explicitInvocation = /^\s*(?:\$pinmind|@pinmind)(?=\s|[,:;.!?]|$)/u.test(normalizedText); - const text = normalizedText.replace(/^\s*(?:\$pinmind|@pinmind)(?=\s|[,:;.!?]|$)[,:;.!?]?\s*/u, ''); - const explicit = String(input.route || input.kind || '').trim().toLowerCase().replace(/[ _]/g, '-'); - const aliases = new Map([['review', 'audit'], ['audit', 'audit'], ['investigate', 'investigation'], ['investigation', 'investigation'], ['bug', 'investigation'], ['debug', 'investigation'], ['change', 'software-change'], ['software', 'software-change'], ['software-change', 'software-change'], ['operational', 'operational'], ['simple', 'simple'], ['spike', 'spike']]); - const explicitRoute = aliases.get(explicit); - const signalSet = new Set(); const mark = (condition, signal) => { if (condition) signalSet.add(signal); return condition; }; - if (explicitInvocation) mark(true, 'activation:explicit'); - if (explicit) mark(true, `explicit:${explicitRoute || 'unknown'}`); - const typoOnly = /\b(?:fix|correct)(?:\s+(?:the|a|this|that|one))?\s+typos?(?:\s+in\b|\s*:)|(?:исправ|поправ|паправ|пофикс)\S*[^\n]{0,40}опечат/u.test(text); - const colloquialMutate = mark(/(?:выпил|выпел|выреж|вырез|выкин|вычист|докрут|подкрут|допил|довед|додела)(?:и|йте|ите|ить|уть|ь|й|яй|ять)?(?=\s|[.,;:!?/]|$)|(?:дообучи|доучи|научи)\s+(?:его|ее|её|этот|данн\S*|скилл?|роутер|pinmind)|убер(?:и|ите)\s+(?:токен|подсчет|эту\s+функц|всю\s+эту\s+истори)|не\s+надо\s+считать\s+токен|давай\s+без\s+токен/u.test(text), 'intent:colloquial-mutate'); - const productDesire = mark(/(?:хочу|нужно|надо|нужна),?\s+чтобы\s+(?:это|он|она|скилл?|плагин|pinmind)|(?:хочу|нужно|надо|нужна)[^\n]{0,48}(?:штоб|чтоб[ы]?)[^\n]{0,72}(?:не\s+туп|понимал|распознав|понял|могг?|слышал)|чтобы\s+(?:он|она|скилл?|плагин|это)\s+(?:могг?|понимал|распознавал|стал|был)|(?:сделай|зделай|сделайте)\s+(?:из\s+(?:этого|него|pinmind)|(?:нормальн\S*|полноценн\S*|норм)\s+(?:скилл?|плагин|skill|plugin))|(?:сделай|зделай)[^\n]{0,64}(?:скилл?|skill|плагин|plugin)[^\n]{0,40}из\s+pinmind|(?:полноценн\S*|нормальн\S*)\s+(?:скилл?|плагин|skill|plugin)\s+для|(?:должен|должна|должно|пусть)\s+(?:точно\s+)?(?:понима|распознава|стать|стал)|хочу\s+(?:нормальн\S*|полноценн\S*)\s+(?:скилл?|плагин)|надо\s+чтобы\s+он\s+слышал/u.test(text), 'intent:product-desire'); - const evalHarness = mark(!/\?\s*$/u.test(text) && /(?:напиши|накидай|добав|продумай)\S*[^\n]{0,56}тест\S*|(?:прогон|прогони)\S*[^\n]{0,40}тест|версионир|доведи\s+до\s+релиза|сделай\s+релиз|(?:в\s+прошлом\s+чате)[^\n]{0,80}(?:докрут|поправ|додел|продолж|правил)/u.test(text), 'intent:eval-harness'); - const opinionRequest = mark(/(?:какие|каковы)\s+(?:теперь\s+)?мысли|что\s+думаешь|как\s+(?:он|оно|это)\s+сейчас|норм\s+или\s+нет|оцени\s+\S+\s+честно|что\s+не\s+так\s+в|ну\s+и\s+как\s+он|(? clause.trim()) - .filter((clause) => clause && externalVerb.test(clause) && !externalPlanStatementPattern.test(clause) && !externalActionNegationPattern.test(clause) && !externalExecutionNegationPattern.test(clause) && !externalReadOnlyDirectivePattern.test(clause)) - .join('. '); - const confirmedCreatePrAction = externalAuthorityActionList && /\b(?:create|open|submit|prepare|creating)\s+(?:a\s+)?(?:pull\s+request|pr)\b|\bcreation\s+of\s+(?:a\s+)?(?:pull\s+request|pr)\b|создани\S*\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)/u.test(externalAuthorityText); - const confirmedMergeAction = externalAuthorityActionList && /\bmerge\b[^\n,;]{0,80}\b(?:pr|pull\s+request)\b[^\n,;]{0,80}(?:\b(?:into|to)\s+|(?:в|на)\s+)(?:protected\s+)?(?:main|master)\b|(?:см[её]рдж|смердж|смерж)(?:и|ите|ить)[^\n,;]{0,80}(?:\b(?:pr|main|master|origin\/)\b|ветк)/u.test(externalAuthorityText); - const confirmedPushAction = externalAuthorityActionList && /\bpush\b[^\n,;]{0,80}(?:\b(?:branch(?:es)?|commits?|changes?|tags?|refs?|origin\/[a-z0-9._/-]+|origin\s+[a-z0-9._/-]+|remote\b)|ветк)|(?:запуш|пуш|отправ)\S*\s+(?:ветк|branch)/u.test(externalAuthorityText); - const createPrAction = confirmedCreatePrAction || /\b(?:create|open|submit|prepare)\s+(?:a\s+)?(?:pull\s+request|pr)\b|(?:создай|открой|отправь|подготовь|оформи)\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)/u.test(externalActionText); - const mergeAction = confirmedMergeAction || /(?:^|[.!?]\s*|\b(?:first|please|then|and|instead)\s*,?\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+)merge(?=\s+(?:(?:the\s+)?(?:pr|pull\s+request|this|it|#\d+)\b|(?:the|this|a)\s+(?:local\s+)?branch\b)|[^\n]{0,80}\b(?:into|to)\s+(?:(?:protected\s+)?(?:main|master)(?=\s*[.,;:!?]?$)|shared\s+branch|origin\/[a-z0-9._/-]+)|[.!?]?\s*$)|(?:см[её]рдж|смердж|смерж)(?:и|ите|ить)/u.test(externalActionText) || /(?:влей|слей)(?:те)?[^\n]{0,80}(?:\b(?:pr|main|master|branch|origin\/)\b|ветк)/u.test(externalActionText); - const colloquialRemotePush = /(?:запуш|пуш|залей)\S*[^\n]{0,48}(?:гитхаб|github|реп[уаые]|origin)|(?:закоммить|закоммич)\S*[^\n]{0,24}(?:запуш|пуш)/u.test(text); - const pushAction = confirmedPushAction || colloquialRemotePush || /(?:^|[.!?]\s*|\b(?:first|please|then|and|instead)\s*,?\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+)push\b(?=[^\n]{0,80}\b(?:branch(?:es)?|commits?|changes?|tags?|refs?|origin\/[a-z0-9._/-]+|origin\s+[a-z0-9._/-]+|remote\b|to\s+(?:main|master|protected|shared\s+branch|remote\s+branch)))|\bgit\s+push\b|(?:запуш|пуш|отправ)\S*\s+(?:ветк|branch)/u.test(externalActionText); - const externalActionRequested = createPrAction || mergeAction || pushAction; - const conditionalExternalAction = /^(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+(?:merge|push)\b/u.test(externalActionText); - const protectedBranchTarget = /\bprotected\s+(?:main|master|branch)\b|(?:защищенн|защищен)\S*\s+(?:main|master|ветк)/u.test(text); - const localRepoTarget = mergeAction && /\b(?:locally|local\s+(?:main|master|repository)|working\s+tree)\b|локальн\S*\s+(?:репозитор|main|master)|репозитор\S*\s+локальн/u.test(text); - const sharedBranchTarget = protectedBranchTarget || /\b(?:main|master|shared\s+branch|remote\s+branch|origin\/[a-z0-9._/-]+)\b|(?:общ|совместн|удаленн)\S*\s+ветк|(?:в|на)\s+(?:main|master)(?=\s|[.,;:!?]|$)/u.test(text); - const unresolvedExternalTarget = (mergeAction || pushAction) && !sharedBranchTarget && !localRepoTarget; - const explicitActionAuthority = externalActionRequested && !unresolvedExternalTarget && (/\b(?:i\s+am|i'm)\s+(?:(?:an?|the)\s+)?(?:owner|maintainer)\b[^\n]{0,100}\b(?:approve|authorize|confirm|allow)\b|\b(?:owner|maintainer)\b[^\n]{0,100}\b(?:approved|authorized|confirmed)\b|я\s+(?:владелец|мейнтейнер)[^\n]{0,100}(?:разрешаю|подтверждаю|одобряю)/u.test(text)); - const protectedBranchMutation = protectedBranchTarget && (mergeAction || pushAction); - const sharedBranchMutation = !protectedBranchTarget && !localRepoTarget && sharedBranchTarget && (mergeAction || pushAction); - if (createPrAction) mark(true, 'action:create-pr'); - if (mergeAction) mark(true, 'action:merge'); - if (pushAction) mark(true, 'action:push'); - if (explicitActionAuthority) mark(true, 'authority:explicit-action-target'); - if (protectedBranchTarget && externalActionRequested) mark(true, 'target:protected-branch'); else if (localRepoTarget) mark(true, 'target:local-repository'); else if (sharedBranchTarget && externalActionRequested) mark(true, 'target:shared-branch'); - if (unresolvedExternalTarget) mark(true, 'target:unresolved'); - if (protectedBranchMutation) mark(true, 'effect:protected-branch-mutation'); - else if (sharedBranchMutation) mark(true, 'effect:shared-branch-mutation'); - else if (localRepoTarget) mark(true, 'effect:local-mutation'); - else if (externalActionRequested) mark(true, 'effect:remote-collaboration'); - const effectScanText = (boundedTranslation || metaEvaluation) ? analysisText : text; - const productionContext = /\b(?:prod|production)\b|\blive\b[^\n]{0,60}\b(?:database|data|records?|credentials?|keys?)\b|\b(?:deploy|publish|release|roll\s*out|ship|migrate|wipe|delete|rotate|revoke)\b[^\n]{0,80}\blive\b|\blive\b[^\n]{0,80}\b(?:deploy|publish|release|roll\s*out|ship|migration|wipe|delete|rotation|revocation)\b|(? String(value).replace(/\s+/g, ' ').trim(); - const assumptions = boundaries.assumptions.length ? boundaries.assumptions.map((item) => `- ${line(item)}`).join('\n') : '- none'; - const outOfScope = boundaries.outOfScope.length ? boundaries.outOfScope.map((item) => `- ${line(item)}`).join('\n') : '- none'; - return `## Remaining boundaries\n\n### Assumptions requiring observation\n${assumptions}\n\n### Out of scope or deferred\n${outOfScope}`; -} - -export async function reportRun(cwd, runId, format = 'json') { - const { files, state } = await verifyRun(cwd, safeRunId(runId)); - const contract = state.currentContractVersion ? await currentContract(files, state) : null; - const evidence = await loadEvidence(files); const currentEntries = (evidence.entries || []).filter((entry) => entry.contractVersion === state.currentContractVersion); - const statuses = ['pass', 'fail', 'uncertain', 'pending-review', 'not-applicable']; - const counts = Object.fromEntries(statuses.map((status) => [status, currentEntries.filter((entry) => entry.status === status).length])); - const receipt = await usageForReport(files, state.createdAt); const baseline = await loadBaseline(files); - const remainingBoundaries = remainingBoundariesForReport(contract); - const summary = { format: FORMAT, runId, status: state.status, phase: state.phase, contract: contract ? { contractId: contract.contractId, version: state.currentContractVersion } : null, baseline: { status: baseline.status, observed: baseline.observed ?? null, reason: baseline.reason ?? null, capturedAt: baseline.capturedAt ?? null }, evidence: { total: currentEntries.length, counts }, remainingBoundaries, tokenUsage: publicUsage(receipt), updatedAt: state.updatedAt }; - if (format === 'json') return summary; - if (format !== 'md' && format !== 'markdown') throw new KernelError('Report format must be json or md.', 'INVALID_REPORT_FORMAT'); - const contractLine = contract ? `${contract.contractId} v${state.currentContractVersion}` : 'not frozen'; - return `# Pinmind run report\n\n- Run: ${runId}\n- Status: ${state.status}\n- Phase: ${state.phase}\n- Contract: ${contractLine}\n- Baseline: ${baseline.status}\n- Evidence: ${counts.pass}/${currentEntries.length} passing\n\n${renderRemainingBoundaries(remainingBoundaries)}\n\n${renderTokenUsage(receipt)}\n`; -} - -async function trustworthyPassingEvidence(cwd, entry, criticalTargets = new Set()) { - if (entry.status !== 'pass') return { trustworthy: false, freshness: 'unavailable' }; - if (entry.provenance?.kind === 'manual-attestation') return { trustworthy: !(entry.covers || []).some((id) => criticalTargets.has(id)) && nonEmptyText(entry.procedure) && !nonEmptyText(entry.command), freshness: 'unavailable' }; - if (entry.provenance?.kind !== 'captured-command') return { trustworthy: false, freshness: 'unavailable' }; - const captured = entry.provenance; - if (captured.exitCode !== 0 || !Array.isArray(captured.argv) || captured.argv.length === 0 || !nonEmptyText(captured.capturedAt)) return { trustworthy: false, freshness: 'unavailable' }; - let artifactCurrent = false; - if (nonEmptyText(entry.artifact)) { - const relative = safeRelativePath(entry.artifact, 'artifact'); const expected = captured.artifactHashes?.[relative]; - if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) return { trustworthy: false, freshness: 'unavailable' }; - try { const artifact = await resolveContainedExisting(cwd, relative, 'artifact', 'file'); if (sha256(await readFile(artifact.absolute)) !== expected) return { trustworthy: false, freshness: 'stale' }; artifactCurrent = true; } catch { return { trustworthy: false, freshness: 'stale' }; } - } - if (!nonEmptyText(entry.artifact) && !nonEmptyText(entry.reference)) return { trustworthy: false, freshness: 'unavailable' }; - const workspace = await assessWorkspaceFingerprint(cwd, captured.workspaceFingerprint); - if (workspace.status === 'stale') return { trustworthy: true, freshness: 'stale', reason: workspace.reason }; - if (workspace.status === 'current' || artifactCurrent) return { trustworthy: true, freshness: 'current' }; - return { trustworthy: true, freshness: 'unavailable', reason: workspace.reason }; -} - -async function finalVerifyUnlocked(cwd, runId) { - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidenceResult = await validateEvidence(cwd, runId); const errors = [...evidenceResult.errors]; - if (state.baselineRequired === true && !(await exists(files.baseline))) errors.push('Run requires an explicit baseline receipt.'); - const entries = evidenceResult.entries; - const contractTargets = [...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])]; - const targetsById = new Map(contractTargets.map((item) => [item.id, item])); - const criticalTargets = new Set(contractTargets.filter((item) => item.critical).map((item) => item.id)); - const requiredTargets = new Set([...(contract.invariants || []).map((item) => item.id), ...(contract.preservation || []).map((item) => item.id)]); - for (const obligation of contract.obligations || []) if (obligation.priority === 'must') { - const targets = [...(obligation.acceptance || []), ...(obligation.invariants || [])]; - if (targets.length === 0) errors.push(`${obligation.id} has no acceptance or invariant.`); - for (const target of targets) requiredTargets.add(target); - } - for (const target of requiredTargets) { - const targetDefinition = targetsById.get(target); const requiresFreshness = targetDefinition?.freshnessRequired === true; - for (const evidenceId of targetDefinition?.evidence || []) { - const matches = entries.filter((entry) => entry.contractVersion === state.currentContractVersion && entry.evidenceId === evidenceId && (entry.covers || []).includes(target)); - const assessments = await Promise.all(matches.map((entry) => trustworthyPassingEvidence(cwd, entry, criticalTargets))); - if (!assessments.some((item) => item.trustworthy && (!requiresFreshness || item.freshness === 'current'))) { - const freshness = assessments.find((item) => item.trustworthy)?.freshness; - errors.push(freshness && requiresFreshness ? `${target} has ${freshness} freshness for planned ${evidenceId}.` : `${target} lacks trustworthy passing evidence for planned ${evidenceId}.`); - } - } - } - if (await exists(files.execution)) { - const execution = await readJson(files.execution, 'Execution view'); const executionResult = validateExecution(execution, contract); errors.push(...executionResult.errors); - } - const baseline = await loadBaseline(files); - return { ok: errors.length === 0, verdict: errors.length === 0 ? 'pass' : 'fail', runId, contractVersion: state.currentContractVersion, baseline: { status: baseline.status, observed: baseline.observed ?? null, reason: baseline.reason ?? null }, errors }; -} - -export async function finalVerify(cwd, runId) { return finalVerifyUnlocked(cwd, runId); } - -async function finalizeRunUnlocked(cwd, runId, verification, options = {}) { - if (!verification?.ok) throw new KernelError('Cannot finalize a failed verification.', 'FINAL_GATE_FAILED', verification?.errors || []); - verification = await finalVerifyUnlocked(cwd, runId); - if (!verification.ok) throw new KernelError('Final verification changed before commit.', 'FINAL_GATE_FAILED', verification.errors); - const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidence = await loadEvidence(files); - await requireCanonicalActiveRun(cwd, state, runId); - const baseline = await loadBaseline(files); - const currentEntries = evidence.entries.filter((entry) => entry.contractVersion === state.currentContractVersion); - const passed = currentEntries.filter((entry) => entry.status === 'pass').length; - const statuses = ['pass', 'fail', 'uncertain', 'pending-review', 'not-applicable']; - const counts = statuses.map((status) => `- ${status}: ${currentEntries.filter((entry) => entry.status === status).length}`).join('\n'); - const attention = statuses.slice(1).map((status) => `- ${status}: ${currentEntries.filter((entry) => entry.status === status).map((entry) => entry.evidenceId).join(', ') || 'none'}`).join('\n'); - const manual = currentEntries.filter((entry) => entry.provenance?.kind === 'manual-attestation').map((entry) => entry.evidenceId).join(', ') || 'none'; - const captured = currentEntries.filter((entry) => entry.provenance?.kind === 'captured-command').map((entry) => entry.evidenceId).join(', ') || 'none'; - const completionBasis = manual === 'none' ? 'machine-captured evidence' : 'mixed machine-captured and manual/unreplayed evidence'; - const remainingBoundaries = remainingBoundariesForReport(contract); - let receipt; - const needsUsage = !(await exists(files.usage)); - if (!needsUsage) receipt = await loadUsage(files); - else { receipt = unavailableUsage(new Date().toISOString(), 'Authoritative token usage was not recorded for this run.'); receipt.usageSha256 = usageHash(receipt); } - const report = `# Pinmind final report\n\n- Run: ${runId}\n- Contract: ${contract.contractId} v${state.currentContractVersion}\n- Baseline: ${baseline.status}\n- Evidence: ${passed}/${currentEntries.length} passing\n\n## Evidence status counts\n${counts}\n\n## Current evidence requiring attention\n${attention}\n\n## Evidence provenance\n- machine-captured: ${captured}\n- manual/unreplayed: ${manual}\n\n${renderRemainingBoundaries(remainingBoundaries)}\n\n${renderTokenUsage(receipt)}\n\n- MUST evidence coverage: satisfied\n- Completion basis: ${completionBasis}\n- Replay note: stored commands were verified for captured provenance and artifact integrity, but were not replayed during finalization\n`; - const nextState = structuredClone(state); nextState.phase = 'finalize'; nextState.status = 'complete'; nextState.updatedAt = new Date().toISOString(); setStateHash(nextState); - const changes = []; - if (needsUsage) changes.push({ file: files.usage, content: jsonText(receipt) }); - changes.push({ file: files.final, content: report }, { file: files.state, content: jsonText(nextState) }, { file: files.active, content: null }); - await executeTransition(cwd, 'finalize', runId, changes, options); - return { ...verification, finalized: true, finalPath: files.final, tokenUsage: publicUsage(receipt) }; -} - -export async function finalizeRun(cwd, runId, verification, options = {}) { - return withWorkspaceLock(cwd, `finalize:${safeRunId(runId)}`, () => finalizeRunUnlocked(cwd, runId, verification, options)); -} - -export async function readInputJson(file) { return readJson(path.resolve(file)); } -const ROUTE_STDIN_MAX_BYTES = 1024 * 1024; -const ROUTE_STDIN_TIMEOUT_MS = 5000; -export async function readRouteInputJson(file, inputStream = process.stdin, options = {}) { - if (file !== '-') return readInputJson(file); - const maxBytes = options.maxBytes ?? ROUTE_STDIN_MAX_BYTES; const timeoutMs = options.timeoutMs ?? ROUTE_STDIN_TIMEOUT_MS; - const timeoutError = new KernelError(`Route standard input did not finish within ${timeoutMs}ms.`, 'ROUTE_INPUT_TIMEOUT'); - let timer; let timedOut = false; - try { - const consume = (async () => { - let input = ''; let bytes = 0; - inputStream.setEncoding('utf8'); - for await (const chunk of inputStream) { - bytes += Buffer.byteLength(chunk, 'utf8'); - if (bytes > maxBytes) { - inputStream.destroy?.(); - throw new KernelError(`Route standard input exceeds ${maxBytes} bytes.`, 'ROUTE_INPUT_TOO_LARGE'); - } - input += chunk; - } - return JSON.parse(input); - })(); - const deadline = new Promise((resolve, reject) => { - timer = setTimeout(() => { timedOut = true; inputStream.destroy?.(); reject(timeoutError); }, timeoutMs); - }); - return await Promise.race([consume, deadline]); - } catch (error) { - if (timedOut) throw timeoutError; - if (error instanceof KernelError) throw error; - throw new KernelError('Route standard input is unreadable or invalid JSON.', 'INVALID_JSON', [error.message]); - } finally { clearTimeout(timer); } -} -export async function readBrief(file) { return readFile(path.resolve(file), 'utf8'); } -export function generateRunId() { return `${new Date().toISOString().slice(0, 10)}-${randomUUID().slice(0, 8)}`; } +export { routeTask } from './route.mjs'; +export * from './state.mjs'; diff --git a/skills/pinmind/scripts/lib/route.mjs b/skills/pinmind/scripts/lib/route.mjs new file mode 100644 index 0000000..19a6b18 --- /dev/null +++ b/skills/pinmind/scripts/lib/route.mjs @@ -0,0 +1,221 @@ +// Speech-act router. Nouns such as "code" do not choose a route by themselves. +function classifyConfirmedExternalStatement(text) { + const match = /(?:^|[.!?]\s*)(?:(?:(?:i\s+am|i'm)\s+(?:(?:an?|the)\s+)?(?:owner|maintainer)\s+(?:and\s+)|я\s+(?:владелец|мейнтейнер)\s+и\s+)?(?:(?:(?:i\s+)?(?:confirm|approve|authorize)|(?:я\s+)?(?:подтверждаю|одобряю|разрешаю))(?=\s|[-:>])[\s:>\-]*)+)/u.exec(text); + if (!match) return { kind: 'none', text: '' }; + const tail = text.slice(match.index + match[0].length).trim(); + const lead = tail.replace(/^(?:(?:first|then|please|сначала|затем)\s*,?\s*)+/u, ''); + const readOnly = /\?\s*$/u.test(tail) + || /^(?:do\s+not|don't|never|не)(?=\s|[.,;:!?]|$)/u.test(lead) + || /^(?:explain|describe|tell|report|объясни|расскажи|опиши|сообщи)(?=\s|[.,;:!?]|$)/u.test(lead) + || /^(?:(?:the\s+)?docs?|documentation)\s+(?:say|says|state|states|contain|contains)\b|^(?:документац|документ)\S*\s+(?:говор|указыва|содерж)/u.test(lead) + || /\b(?:is|are)\s+(?:risky|dangerous|safe|documented)\b/u.test(tail) + || /(?:опасен|опасна|опасно|опасны|безопасен|безопасна|безопасно|безопасны)(?=\s|[.,;:!?]|$)/u.test(tail) + || /\b(?:pull\s+request|pr)\s+plan\b|\bplan\s+(?:for|of)\s+(?:a\s+)?(?:pull\s+request|pr)\b/u.test(tail); + if (readOnly) return { kind: 'read-only', text: tail }; + const actionList = /^(?:\b(?:push|merge)\b|\b(?:create|open|submit|prepare|creating)\s+(?:a\s+)?(?:pull\s+request|pr)\b|\bcreation\s+of\s+(?:a\s+)?(?:pull\s+request|pr)\b|создани\S*\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)|(?:см[её]рдж|смердж|смерж|запуш|пуш|отправ)\S*)/u.test(lead); + return { kind: actionList ? 'action-list' : 'none', text: tail }; +} + +function quotedSpansStripped(value) { + return value.replace(/["“”«»`][^"“”«»`]{0,500}["“”«»`]/gu, ' '); +} + +function translationFrame(value) { + return value + .replace(/\b(?:translate|translation)\b[\s\S]{0,80}?\b(?:this|the|it)\s+(?:sentence|phrase|word|paragraph|text)\s*[:\-–]?\s*[\s\S]*$/u, 'translate this sentence') + .replace(/\b(?:translate|translation)\b[\s\S]{0,80}?\b(?:sentence|phrase|word|paragraph|text)\s*[:\-–]\s*[\s\S]+$/u, 'translate this sentence') + .replace(/перевед\S*[\s\S]{0,80}?(?:это|этот|эту|данн\S*)?\s*(?:предложен|фраз|слов|абзац|текст)\s*[:\-–]?\s*[\s\S]*$/u, 'переведи это предложение'); +} + +function exampleListStripped(value) { + return value + .replace(/\b(?:delete|deploy|rotate)(?:\s*\/\s*(?:delete|deploy|rotate))+/gu, ' ') + .replace(/(?:^|[.!?]\s*)\d+\.\s+[\s\S]+$/u, ' '); +} + +function analysisFrame(value, { translation, meta } = {}) { + let frame = quotedSpansStripped(value); + if (translation) frame = translationFrame(frame); + if (meta) frame = exampleListStripped(frame); + return frame.replace(/\s+/gu, ' ').trim(); +} + +export function routeTask(input = {}) { + const normalizedText = String(input.text || input.intent || input.request || '').normalize('NFKC').toLocaleLowerCase().replace(/ё/g, 'е'); + const explicitInvocation = /^\s*(?:\$pinmind|@pinmind)(?=\s|[,:;.!?]|$)/u.test(normalizedText); + const text = normalizedText.replace(/^\s*(?:\$pinmind|@pinmind)(?=\s|[,:;.!?]|$)[,:;.!?]?\s*/u, ''); + const explicit = String(input.route || input.kind || '').trim().toLowerCase().replace(/[ _]/g, '-'); + const aliases = new Map([['review', 'audit'], ['audit', 'audit'], ['investigate', 'investigation'], ['investigation', 'investigation'], ['bug', 'investigation'], ['debug', 'investigation'], ['change', 'software-change'], ['software', 'software-change'], ['software-change', 'software-change'], ['operational', 'operational'], ['simple', 'simple'], ['spike', 'spike']]); + const explicitRoute = aliases.get(explicit); + const signalSet = new Set(); const mark = (condition, signal) => { if (condition) signalSet.add(signal); return condition; }; + if (explicitInvocation) mark(true, 'activation:explicit'); + if (explicit) mark(true, `explicit:${explicitRoute || 'unknown'}`); + const typoOnly = /\b(?:fix|correct)(?:\s+(?:the|a|this|that|one))?\s+typos?(?:\s+in\b|\s*:)|(?:исправ|поправ|паправ|пофикс)\S*[^\n]{0,40}опечат/u.test(text); + const colloquialMutate = mark(/(?:выпил|выпел|выреж|вырез|выкин|вычист|докрут|подкрут|допил|довед|додела)(?:и|йте|ите|ить|уть|ь|й|яй|ять)?(?=\s|[.,;:!?/]|$)|(?:дообучи|доучи|научи)\s+(?:его|ее|её|этот|данн\S*|скилл?|роутер|pinmind)|убер(?:и|ите)\s+(?:токен|подсчет|эту\s+функц|всю\s+эту\s+истори)|не\s+надо\s+считать\s+токен|давай\s+без\s+токен/u.test(text), 'intent:colloquial-mutate'); + const productDesire = mark(/(?:хочу|нужно|надо|нужна),?\s+чтобы\s+(?:это|он|она|скилл?|плагин|pinmind)|(?:хочу|нужно|надо|нужна)[^\n]{0,48}(?:штоб|чтоб[ы]?)[^\n]{0,72}(?:не\s+туп|понимал|распознав|понял|могг?|слышал)|чтобы\s+(?:он|она|скилл?|плагин|это)\s+(?:могг?|понимал|распознавал|стал)|(?:сделай|зделай|сделайте)\s+(?:из\s+(?:этого|него|pinmind)|(?:нормальн\S*|полноценн\S*|норм)\s+(?:скилл?|плагин|skill|plugin))|(?:сделай|зделай)[^\n]{0,64}(?:скилл?|skill|плагин|plugin)[^\n]{0,40}из\s+pinmind|(?:полноценн\S*|нормальн\S*)\s+(?:скилл?|плагин|skill|plugin)\s+для|(?:должен|должна|должно|пусть)\s+(?:точно\s+)?(?:понима|распознава|стать|стал)|хочу\s+(?:нормальн\S*|полноценн\S*)\s+(?:скилл?|плагин)|надо\s+чтобы\s+он\s+слышал/u.test(text), 'intent:product-desire'); + const evalHarness = mark(!/\?\s*$/u.test(text) && /(?:напиши|накидай|добав|продумай)\S*[^\n]{0,56}тест\S*|(?:прогон|прогони)\S*[^\n]{0,40}тест|версионир|доведи\s+до\s+релиза|сделай\s+релиз|(?:в\s+прошлом\s+чате)[^\n]{0,80}(?:докрут|поправ|додел|продолж|правил)/u.test(text), 'intent:eval-harness'); + const opinionRequest = mark(/(?:какие|каковы)\s+(?:теперь\s+)?мысли|что\s+думаешь|как\s+(?:он|оно|это)\s+сейчас|норм\s+или\s+нет|оцени\s+\S+\s+честно|что\s+не\s+так\s+в|ну\s+и\s+как\s+он|(? clause.trim()) + .filter((clause) => clause && externalVerb.test(clause) && !externalPlanStatementPattern.test(clause) && !externalActionNegationPattern.test(clause) && !externalExecutionNegationPattern.test(clause) && !externalReadOnlyDirectivePattern.test(clause)) + .join('. '); + const confirmedCreatePrAction = externalAuthorityActionList && /\b(?:create|open|submit|prepare|creating)\s+(?:a\s+)?(?:pull\s+request|pr)\b|\bcreation\s+of\s+(?:a\s+)?(?:pull\s+request|pr)\b|создани\S*\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)/u.test(externalAuthorityText); + const confirmedMergeAction = externalAuthorityActionList && /\bmerge\b[^\n,;]{0,80}\b(?:pr|pull\s+request)\b[^\n,;]{0,80}(?:\b(?:into|to)\s+|(?:в|на)\s+)(?:protected\s+)?(?:main|master)\b|(?:см[её]рдж|смердж|смерж)(?:и|ите|ить)[^\n,;]{0,80}(?:\b(?:pr|main|master|origin\/)\b|ветк)/u.test(externalAuthorityText); + const confirmedPushAction = externalAuthorityActionList && /\bpush\b[^\n,;]{0,80}(?:\b(?:branch(?:es)?|commits?|changes?|tags?|refs?|origin\/[a-z0-9._/-]+|origin\s+[a-z0-9._/-]+|remote\b)|ветк)|(?:запуш|пуш|отправ)\S*\s+(?:ветк|branch)/u.test(externalAuthorityText); + const createPrAction = confirmedCreatePrAction || /\b(?:create|open|submit|prepare)\s+(?:a\s+)?(?:pull\s+request|pr)\b|(?:создай|открой|отправь|подготовь|оформи)\s+(?:pull\s+request|pr)(?=\s|[.,;:!?]|$)/u.test(externalActionText); + const mergeAction = confirmedMergeAction || /(?:^|[.!?]\s*|\b(?:first|please|then|and|instead)\s*,?\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+)merge(?=\s+(?:(?:the\s+)?(?:pr|pull\s+request|this|it|#\d+)\b|(?:the|this|a)\s+(?:local\s+)?branch\b)|[^\n]{0,80}\b(?:into|to)\s+(?:(?:protected\s+)?(?:main|master)(?=\s*[.,;:!?]?$)|shared\s+branch|origin\/[a-z0-9._/-]+)|[.!?]?\s*$)|(?:см[её]рдж|смердж|смерж)(?:и|ите|ить)/u.test(externalActionText) || /(?:влей|слей)(?:те)?[^\n]{0,80}(?:\b(?:pr|main|master|branch|origin\/)\b|ветк)/u.test(externalActionText); + const colloquialRemotePush = /(?:запуш|пуш|залей)\S*[^\n]{0,48}(?:гитхаб|github|реп[уаые]|origin)|(?:закоммить|закоммич)\S*[^\n]{0,24}(?:запуш|пуш)/u.test(text); + const pushAction = confirmedPushAction || colloquialRemotePush || /(?:^|[.!?]\s*|\b(?:first|please|then|and|instead)\s*,?\s+|\b(?:can|could|would|will)\s+you\s+|\b(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+)push\b(?=[^\n]{0,80}\b(?:branch(?:es)?|commits?|changes?|tags?|refs?|origin\/[a-z0-9._/-]+|origin\s+[a-z0-9._/-]+|remote\b|to\s+(?:main|master|protected|shared\s+branch|remote\s+branch)))|\bgit\s+push\b|(?:запуш|пуш|отправ)\S*\s+(?:ветк|branch)/u.test(externalActionText); + const externalActionRequested = createPrAction || mergeAction || pushAction; + const conditionalExternalAction = /^(?:if|when|after|once|unless)\b[^\n,;]{0,100},?\s+(?:merge|push)\b/u.test(externalActionText); + const protectedBranchTarget = /\bprotected\s+(?:main|master|branch)\b|(?:защищенн|защищен)\S*\s+(?:main|master|ветк)/u.test(text); + const localRepoTarget = mergeAction && /\b(?:locally|local\s+(?:main|master|repository)|working\s+tree)\b|локальн\S*\s+(?:репозитор|main|master)|репозитор\S*\s+локальн/u.test(text); + const sharedBranchTarget = protectedBranchTarget || /\b(?:main|master|shared\s+branch|remote\s+branch|origin\/[a-z0-9._/-]+)\b|(?:общ|совместн|удаленн)\S*\s+ветк|(?:в|на)\s+(?:main|master)(?=\s|[.,;:!?]|$)/u.test(text); + const unresolvedExternalTarget = (mergeAction || pushAction) && !sharedBranchTarget && !localRepoTarget; + const explicitActionAuthority = externalActionRequested && !unresolvedExternalTarget && (/\b(?:i\s+am|i'm)\s+(?:(?:an?|the)\s+)?(?:owner|maintainer)\b[^\n]{0,100}\b(?:approve|authorize|confirm|allow)\b|\b(?:owner|maintainer)\b[^\n]{0,100}\b(?:approved|authorized|confirmed)\b|я\s+(?:владелец|мейнтейнер)[^\n]{0,100}(?:разрешаю|подтверждаю|одобряю)/u.test(text)); + const protectedBranchMutation = protectedBranchTarget && (mergeAction || pushAction); + const sharedBranchMutation = !protectedBranchTarget && !localRepoTarget && sharedBranchTarget && (mergeAction || pushAction); + if (createPrAction) mark(true, 'action:create-pr'); + if (mergeAction) mark(true, 'action:merge'); + if (pushAction) mark(true, 'action:push'); + if (explicitActionAuthority) mark(true, 'authority:explicit-action-target'); + if (protectedBranchTarget && externalActionRequested) mark(true, 'target:protected-branch'); else if (localRepoTarget) mark(true, 'target:local-repository'); else if (sharedBranchTarget && externalActionRequested) mark(true, 'target:shared-branch'); + if (unresolvedExternalTarget) mark(true, 'target:unresolved'); + if (protectedBranchMutation) mark(true, 'effect:protected-branch-mutation'); + else if (sharedBranchMutation) mark(true, 'effect:shared-branch-mutation'); + else if (localRepoTarget) mark(true, 'effect:local-mutation'); + else if (externalActionRequested) mark(true, 'effect:remote-collaboration'); + const effectScanText = (boundedTranslation || metaEvaluation) ? analysisText : text; + const productionContext = /\b(?:prod|production)\b|\blive\b[^\n]{0,60}\b(?:database|data|records?|credentials?|keys?)\b|\b(?:deploy|publish|release|roll\s*out|ship|migrate|wipe|delete|rotate|revoke)\b[^\n]{0,80}\blive\b|\blive\b[^\n]{0,80}\b(?:deploy|publish|release|roll\s*out|ship|migration|wipe|delete|rotation|revocation)\b|(? createHash('sha256').update(value).digest('hex'); +export const canonicalJson = (value) => JSON.stringify(sortValue(value)); + +function sortValue(value) { + if (Array.isArray(value)) return value.map(sortValue); + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortValue(value[key])])); + return value; +} + +export function hashWithout(object, key) { + const clone = structuredClone(object); + delete clone[key]; + return sha256(canonicalJson(clone)); +} + +export function safeRunId(value) { + if (typeof value !== 'string' || !/^[a-z0-9][a-z0-9-]{0,79}$/i.test(value) || value.includes('..')) { + throw new KernelError('Run id must contain only letters, digits, and hyphens.', 'UNSAFE_RUN_ID'); + } + return value; +} + +export function safeRelativePath(value, label = 'path') { + if (typeof value !== 'string' || !value.trim() || value.includes('\0')) throw new KernelError(`${label} is required.`, 'UNSAFE_PATH'); + const normalized = value.replace(/\\/g, '/'); + if (path.posix.isAbsolute(normalized) || /^[a-zA-Z]:\//.test(normalized) || normalized.split('/').includes('..')) { + throw new KernelError(`${label} must be a safe relative path.`, 'UNSAFE_PATH'); + } + return normalized.replace(/^\.\//, ''); +} + +export function redact(text) { + if (typeof text !== 'string') return text; + return text + .replace(/-----BEGIN(?: [A-Z]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z]+)? PRIVATE KEY-----/g, '[REDACTED PRIVATE KEY]') + .replace(/(\[\s*)(["'])((?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING))\2(\s*\]\s*=\s*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|`(?:\\.|[^`\\\r\n])*`|[^;,\r\n)\]}]+)/gi, '$1$2$3$2$4"[REDACTED]"') + .replace(/(["'])((?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING))\1(\s*:\s*)(?:"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])*'|[^,}\r\n]+)/gi, '$1$2$1$3"[REDACTED]"') + .replace(/(^|[^A-Za-z0-9_])((?:(?:export|set)[ \t]+|\$env:)?(?:[A-Za-z_][A-Za-z0-9_]*_)?(?:SECRET|PASSWORD|PASSWD|TOKEN|API_KEY|ACCESS_KEY|SECRET_KEY|PRIVATE_KEY|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING)[ \t]*[=:][ \t]*)[^\r\n]*/gim, '$1$2[REDACTED]') + .replace(/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/gi, '[REDACTED]') + .replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g, '[REDACTED]') + .replace(/\b(sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})\b/g, '[REDACTED]') + .replace(/\b(Authorization\s*[:=]\s*(?:(?:Basic|Bearer)\s+)?)[^\s,'"`]+/gi, '$1[REDACTED]') + .replace(/\b((?:Set-)?Cookie\s*:\s*)[^\r\n]+/gi, '$1[REDACTED]') + .replace(/\b((?:session(?:id)?|sid)\s*=\s*)[^;\s,'"`]+/gi, '$1[REDACTED]') + .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@') + .replace(/\b(Bearer\s+)[^\s,'"`]+/gi, '$1[REDACTED]') + .replace(/\b((?:api[_-]?key|secret|password|token)\s*[=:]\s*)[^\s,'"`]+/gi, '$1[REDACTED]'); +} + +export function redactValue(value, key = '') { + if (/(password|secret|token|api[_-]?key|authorization|cookie|session|sid|set-cookie)/i.test(key)) return '[REDACTED]'; + if (typeof value === 'string') return redact(value); + if (Array.isArray(value)) return value.map((item) => redactValue(item)); + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(v, k)])); + return value; +} + +const SAFE_EXECUTABLE_NAME = /^(?:bash|bun|cargo|cmake|cmd|deno|eslint|git|go|jest|make|meson|ninja|node(?:js)?|npm|npx|pnpm|powershell|pwsh|pytest|python(?:3(?:\.\d+)?)?|ruby|rustc|sh|true|tsc|vitest|yarn|zsh)(?:\.exe)?$/i; + +export function redactArgv(argv) { + if (!Array.isArray(argv)) return argv; + if (argv.length === 0) return []; + const executable = typeof argv[0] === 'string' ? path.basename(argv[0]) : ''; + const executableLabel = SAFE_EXECUTABLE_NAME.test(executable) ? executable : '[REDACTED EXECUTABLE]'; + return [executableLabel, ...argv.slice(1).map(() => '[REDACTED ARG]')]; +} + +async function exists(file) { try { await access(file); return true; } catch { return false; } } +async function readJson(file, label = 'JSON file') { + try { return JSON.parse(await readFile(file, 'utf8')); } + catch (error) { throw new KernelError(`${label} is unreadable or invalid JSON: ${file}`, 'INVALID_JSON', [error.message]); } +} +async function syncDirectory(directory) { + let handle; + try { + handle = await open(directory, 'r'); + await handle.sync(); + } catch (error) { + if (process.platform !== 'win32' || !['EISDIR', 'EPERM', 'EINVAL', 'ENOTSUP'].includes(error.code)) throw error; + } finally { await handle?.close(); } +} +async function ensureDirectoryDurable(directory) { + const before = await existingEntry(directory, 'write parent'); + if (before) { + if (before.isSymbolicLink() || !before.isDirectory()) throw unsafeStatePath('write parent', 'Expected a physical directory.'); + return; + } + const parent = path.dirname(directory); + if (parent !== directory) await ensureDirectoryDurable(parent); + try { await mkdir(directory); } + catch (error) { if (error.code !== 'EEXIST') throw error; } + await syncDirectory(parent); +} +async function ensureParentDirectory(file) { await ensureDirectoryDurable(path.dirname(file)); } +async function writeJsonAtomic(file, value) { + await ensureParentDirectory(file); + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + let handle; + try { + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); await handle.sync(); await handle.close(); handle = null; + await rename(temporary, file); await syncDirectory(path.dirname(file)); + } + catch (error) { try { await unlink(temporary); } catch {} throw error; } + finally { await handle?.close(); } +} +async function writeTextAtomic(file, value) { + await ensureParentDirectory(file); + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; + let handle; + try { + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(value, 'utf8'); await handle.sync(); await handle.close(); handle = null; + await rename(temporary, file); await syncDirectory(path.dirname(file)); + } + catch (error) { try { await unlink(temporary); } catch {} throw error; } + finally { await handle?.close(); } +} +async function unlinkDurable(file) { await unlink(file); await syncDirectory(path.dirname(file)); } +const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +export function layout(cwd, runId) { + const safe = safeRunId(runId); + const root = path.resolve(cwd, '.pinmind'); + const runs = path.join(root, 'runs'); + const run = path.join(runs, safe); + return { root, runs, run, lock: path.join(root, 'writer.lock'), transition: path.join(root, 'transition.json'), active: path.join(root, 'active.json'), brief: path.join(run, 'brief.md'), state: path.join(run, 'state.json'), baseline: path.join(run, 'baseline.json'), evidence: path.join(run, 'evidence.json'), final: path.join(run, 'final.md'), execution: path.join(run, 'execution.json'), contracts: path.join(run, 'contracts'), amendments: path.join(run, 'amendments') }; +} + +function unsafeStatePath(label, detail = '') { + return new KernelError(`Pinmind state ${label} must be a physical path inside the workspace.${detail ? ` ${detail}` : ''}`, 'UNSAFE_STATE_PATH'); +} + +async function existingEntry(candidate, label) { + try { return await lstat(candidate); } + catch (error) { + if (error.code === 'ENOENT') return null; + throw unsafeStatePath(label, error.message); + } +} + +async function verifyStateEntry(candidate, boundary, kind, label) { + const entry = await existingEntry(candidate, label); + if (!entry) return false; + const validKind = kind === 'directory' ? entry.isDirectory() : entry.isFile(); + if (entry.isSymbolicLink() || !validKind) throw unsafeStatePath(label, `Expected a regular ${kind}.`); + let resolved; + try { resolved = await realpath(candidate); } + catch (error) { throw unsafeStatePath(label, error.message); } + if (!isPhysicalDescendant(boundary, resolved)) throw unsafeStatePath(label, 'The resolved path escapes its state boundary.'); + return true; +} + +async function verifiedStateRoot(cwd, { create = false } = {}) { + let workspace; + try { workspace = await realpath(path.resolve(cwd)); } + catch (error) { throw unsafeStatePath('workspace', error.message); } + const workspaceEntry = await existingEntry(workspace, 'workspace'); + if (!workspaceEntry?.isDirectory() || workspaceEntry.isSymbolicLink()) throw unsafeStatePath('workspace', 'Expected a physical directory.'); + const root = path.join(workspace, '.pinmind'); + let exists = await verifyStateEntry(root, workspace, 'directory', 'root'); + if (!exists && create) { + try { await mkdir(root, { mode: 0o700 }); } + catch (error) { if (error.code !== 'EEXIST') throw unsafeStatePath('root', error.message); } + exists = await verifyStateEntry(root, workspace, 'directory', 'root'); + if (!exists) throw unsafeStatePath('root', 'The directory could not be created safely.'); + } + if (exists) { + await verifyStateEntry(path.join(root, 'active.json'), root, 'file', 'active pointer'); + await verifyStateEntry(path.join(root, 'writer.lock'), root, 'file', 'writer lock'); + await verifyStateEntry(path.join(root, 'transition.json'), root, 'file', 'transition journal'); + } + return { workspace, root, exists }; +} + +async function verifiedLayout(cwd, runId, { createRoot = false } = {}) { + const stateRoot = await verifiedStateRoot(cwd, { create: createRoot }); + const files = layout(stateRoot.workspace, runId); + if (!stateRoot.exists) return files; + if (!(await verifyStateEntry(files.runs, files.root, 'directory', 'runs directory'))) return files; + if (!(await verifyStateEntry(files.run, files.root, 'directory', 'run directory'))) return files; + await verifyStateEntry(files.contracts, files.run, 'directory', 'contracts directory'); + await verifyStateEntry(files.amendments, files.run, 'directory', 'amendments directory'); + for (const [label, file] of Object.entries({ brief: files.brief, state: files.state, baseline: files.baseline, evidence: files.evidence, final: files.final, execution: files.execution })) { + await verifyStateEntry(file, files.run, 'file', `${label} file`); + } + return files; +} + +async function inspectExistingLock(lockFile) { + let current; + try { current = await readJson(lockFile, 'Writer lock'); } + // A contender can observe the file after O_EXCL creation but before the owner + // finishes writing metadata. Retry boundedly instead of misclassifying that + // short initialization window as a stale lock. + catch { return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: false }; } + if (current?.hostname !== hostname() || !Number.isInteger(current?.pid) || current.pid < 1 || typeof current?.ownerId !== 'string') { + return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: true }; + } + try { process.kill(current.pid, 0); return { code: 'LOCK_HELD', terminal: false }; } + catch (error) { + if (error.code === 'ESRCH') return { code: 'LOCK_STALE_NEEDS_RECOVERY', terminal: true }; + return { code: 'LOCK_HELD', terminal: false }; + } +} + +async function acquireWorkspaceLock(cwd, operation, waitMs = LOCK_WAIT_MS) { + const { root } = await verifiedStateRoot(cwd, { create: true }); const lockFile = path.join(root, 'writer.lock'); + const ownerId = randomUUID(); const deadline = Date.now() + waitMs; + let heldCode = 'LOCK_HELD'; + while (true) { + try { + const handle = await open(lockFile, 'wx', 0o600); + const metadata = { format: FORMAT, ownerId, pid: process.pid, hostname: hostname(), operation, startedAt: new Date().toISOString() }; + let writeError; + try { await handle.writeFile(`${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); await handle.sync(); } + catch (error) { writeError = error; } + finally { await handle.close(); } + if (writeError) { try { await unlink(lockFile); } catch {} throw writeError; } + return { lockFile, ownerId }; + } catch (error) { + if (error.code !== 'EEXIST') throw new KernelError(`Could not acquire workspace writer lock: ${error.message}`, 'LOCK_FAILED'); + const status = await inspectExistingLock(lockFile); heldCode = status.code || heldCode; + if (status.terminal) throw new KernelError('The Pinmind workspace writer lock needs explicit recovery.', heldCode); + if (Date.now() >= deadline) throw new KernelError('The Pinmind workspace writer lock is held.', heldCode); + await sleep(LOCK_RETRY_MS); + } + } +} + +async function releaseWorkspaceLock(lock) { + try { + const current = await readJson(lock.lockFile, 'Writer lock'); + if (current.ownerId === lock.ownerId) await unlink(lock.lockFile); + } catch (error) { + if (error.code !== 'ENOENT') return; + } +} + +async function withWorkspaceLock(cwd, operation, action) { + const lock = await acquireWorkspaceLock(cwd, operation); + try { return await action(lock); } + finally { await releaseWorkspaceLock(lock); } +} + +function transitionHash(transition) { return hashWithout(transition, 'transitionSha256'); } +function jsonText(value) { return `${JSON.stringify(value, null, 2)}\n`; } +function transitionTargetAllowed(operation, runId, relative) { + const run = `.pinmind/runs/${runId}`; + const exact = { + init: new Set([`${run}/brief.md`, `${run}/state.json`, `${run}/evidence.json`, '.pinmind/active.json']), + freeze: new Set([`${run}/contracts/contract-v001.json`, `${run}/state.json`]), + evidence: new Set([`${run}/evidence.json`]), + finalize: new Set([`${run}/final.md`, `${run}/state.json`, '.pinmind/active.json']), + }; + if (exact[operation]?.has(relative)) return true; + if (operation !== 'amend') return false; + return relative === `${run}/evidence.json` || relative === `${run}/state.json` + || (relative.startsWith(`${run}/contracts/`) && /^contract-v\d{3,}\.json$/.test(relative.slice(`${run}/contracts/`.length))) + || (relative.startsWith(`${run}/amendments/`) && /^amendment-v\d{3,}\.json$/.test(relative.slice(`${run}/amendments/`.length))); +} +async function verifyPhysicalParentChain(workspace, target) { + const relative = path.relative(workspace, path.dirname(target)); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new KernelError('Transition target parent escapes the workspace.', 'TRANSITION_CONFLICT'); + let current = workspace; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); const entry = await existingEntry(current, 'transition target parent'); + if (!entry) return; + if (entry.isSymbolicLink() || !entry.isDirectory()) throw new KernelError('Transition target parent must be a physical directory.', 'TRANSITION_CONFLICT', [path.relative(workspace, current).replace(/\\/g, '/')]); + } +} +async function transitionTargetState(workspace, action) { + const absolute = path.resolve(workspace, action.path); const relative = path.relative(workspace, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new KernelError('Transition target escapes the workspace.', 'TRANSITION_CONFLICT'); + await verifyPhysicalParentChain(workspace, absolute); + const entry = await existingEntry(absolute, 'transition target'); + if (entry && (entry.isSymbolicLink() || !entry.isFile())) throw new KernelError('Transition target must be a physical file.', 'TRANSITION_CONFLICT', [action.path]); + const content = entry ? await readFile(absolute, 'utf8') : null; const currentSha256 = content === null ? null : sha256(content); + const matchesBefore = currentSha256 === action.beforeSha256; const matchesAfter = currentSha256 === action.afterSha256; + return { absolute, currentSha256, matchesBefore, matchesAfter }; +} +function validateTransitionShape(transition) { + const errors = []; + if (!transition || transition.format !== FORMAT || typeof transition.transitionId !== 'string') errors.push('invalid transition header'); + if (!TRANSITION_OPERATIONS.has(transition?.operation)) errors.push('invalid transition operation'); + try { safeRunId(transition?.runId); } catch { errors.push('invalid transition run id'); } + if (!Array.isArray(transition?.actions) || transition.actions.length === 0) errors.push('transition actions are required'); + const seen = new Set(); + for (const action of transition?.actions || []) { + if (!action || typeof action.path !== 'string' || seen.has(action.path)) errors.push('transition target paths must be unique strings'); + seen.add(action?.path); + if (!transitionTargetAllowed(transition.operation, transition.runId, action.path)) errors.push(`transition target is not allowed: ${action?.path}`); + if (action.beforeSha256 !== null && !/^[a-f0-9]{64}$/.test(action.beforeSha256 || '')) errors.push(`invalid before hash: ${action?.path}`); + if (action.afterSha256 !== null && !/^[a-f0-9]{64}$/.test(action.afterSha256 || '')) errors.push(`invalid after hash: ${action?.path}`); + if (action.afterContent !== null && typeof action.afterContent !== 'string') errors.push(`invalid post-image: ${action?.path}`); + if ((action.afterContent === null ? null : sha256(action.afterContent)) !== action.afterSha256) errors.push(`post-image hash mismatch: ${action?.path}`); + } + if (typeof transition?.transitionSha256 !== 'string' || transitionHash(transition) !== transition.transitionSha256) errors.push('transition hash mismatch'); + return errors; +} +async function inspectPendingTransition(cwd) { + const stateRoot = await verifiedStateRoot(cwd); const file = path.join(stateRoot.root, 'transition.json'); + if (!stateRoot.exists || !(await exists(file))) return null; + let transition; + try { transition = await readJson(file, 'Transition journal'); } + catch (error) { return { classification: 'transition-conflict', issues: [error.code || 'INVALID_JSON'], transition: null, summary: null }; } + const errors = validateTransitionShape(transition); + if (errors.length) return { classification: 'transition-conflict', issues: errors, transition, summary: { transitionId: transition.transitionId ?? null, operation: transition.operation ?? null, runId: transition.runId ?? null, transitionSha256: transition.transitionSha256 ?? null } }; + const states = []; + try { for (const action of transition.actions) states.push(await transitionTargetState(stateRoot.workspace, action)); } + catch (error) { return { classification: 'transition-conflict', issues: [error.message], transition, summary: { transitionId: transition.transitionId, operation: transition.operation, runId: transition.runId, transitionSha256: transition.transitionSha256 } }; } + const conflicts = transition.actions.filter((action, index) => !states[index].matchesBefore && !states[index].matchesAfter).map((action) => action.path); + return { + classification: conflicts.length ? 'transition-conflict' : 'transition-recovery-required', + issues: conflicts.map((target) => `unexpected target hash: ${target}`), transition, + summary: { transitionId: transition.transitionId, operation: transition.operation, runId: transition.runId, transitionSha256: transition.transitionSha256, applied: states.filter((state) => state.matchesAfter).length, total: states.length }, + }; +} +async function inspectWriterLockForRecovery(cwd) { + const stateRoot = await verifiedStateRoot(cwd); const lockFile = path.join(stateRoot.root, 'writer.lock'); + if (!stateRoot.exists || !(await exists(lockFile))) return { status: 'absent', lockSha256: null }; + const raw = await readFile(lockFile, 'utf8'); const lockSha256 = sha256(raw); let lock; + try { lock = JSON.parse(raw); } + catch { return { status: 'invalid', lockSha256 }; } + if (lock?.format !== FORMAT || typeof lock.ownerId !== 'string' || !Number.isInteger(lock.pid) || lock.pid < 1 || typeof lock.hostname !== 'string' || typeof lock.operation !== 'string') return { status: 'invalid', lockSha256 }; + const summary = { status: 'held', lockSha256, ownerId: lock.ownerId, pid: lock.pid, hostname: lock.hostname, operation: lock.operation }; + if (lock.hostname !== hostname()) return { ...summary, status: 'foreign' }; + try { process.kill(lock.pid, 0); return summary; } + catch (error) { + if (error.code === 'ESRCH') return { ...summary, status: 'stale-local' }; + return summary; + } +} +function transitionMatchesLock(transition, lock) { + const expected = { + init: [`init:${transition.runId}`], freeze: [`freeze-contract:${transition.runId}`], amend: [`amend-contract:${transition.runId}`], + evidence: [`record-evidence:${transition.runId}`, `capture-evidence:${transition.runId}`], finalize: [`finalize:${transition.runId}`], + }; + return (expected[transition.operation] || []).includes(lock.operation); +} +async function recoverExactStaleLock(cwd, pending, expectedLockSha256) { + if (typeof expectedLockSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedLockSha256)) throw new KernelError('An exact expected writer-lock hash is required.', 'EXPECTED_LOCK_HASH_REQUIRED'); + const lock = await inspectWriterLockForRecovery(cwd); + if (lock.lockSha256 !== expectedLockSha256) throw new KernelError('The writer lock changed after inspection.', 'LOCK_HASH_MISMATCH'); + if (lock.status !== 'stale-local') throw new KernelError('Only an explicitly matched dead local writer lock can be recovered.', 'LOCK_NOT_RECOVERABLE', [lock.status]); + if (!transitionMatchesLock(pending.transition, lock)) throw new KernelError('The stale writer lock does not own the prepared transition.', 'LOCK_TRANSITION_MISMATCH'); + const stateRoot = await verifiedStateRoot(cwd); const lockFile = path.join(stateRoot.root, 'writer.lock'); + if (sha256(await readFile(lockFile, 'utf8')) !== expectedLockSha256) throw new KernelError('The writer lock changed before removal.', 'LOCK_HASH_MISMATCH'); + await unlinkDurable(lockFile); return lock; +} +async function buildTransition(cwd, operation, runId, changes) { + const stateRoot = await verifiedStateRoot(cwd, { create: true }); const actions = []; + for (const change of changes) { + const absolute = path.resolve(change.file); const relative = path.relative(stateRoot.workspace, absolute).replace(/\\/g, '/'); + if (!transitionTargetAllowed(operation, runId, relative)) throw new KernelError('Transition target is outside the operation allowlist.', 'TRANSITION_TARGET_FORBIDDEN', [relative]); + const current = await existingEntry(absolute, 'transition target'); + if (current && (current.isSymbolicLink() || !current.isFile())) throw new KernelError('Transition target must be a physical file.', 'TRANSITION_CONFLICT', [relative]); + const beforeContent = current ? await readFile(absolute, 'utf8') : null; const afterContent = change.content; + actions.push({ path: relative, beforeSha256: beforeContent === null ? null : sha256(beforeContent), afterSha256: afterContent === null ? null : sha256(afterContent), afterContent }); + } + const now = new Date().toISOString(); + const transition = { format: FORMAT, transitionId: randomUUID(), operation, runId, createdAt: now, actions }; + transition.transitionSha256 = transitionHash(transition); return { stateRoot, transition }; +} +async function maybeInjectTransitionFault(options, step) { + if (typeof options?.onTransitionStep === 'function') await options.onTransitionStep(step); + if (options?.faultAfterStep === step) throw new KernelError(`Injected transition interruption after step ${step}.`, 'INJECTED_TRANSITION_CRASH', [step]); +} +async function applyTransitionActions(workspace, transition, options = {}) { + for (let index = 0; index < transition.actions.length; index += 1) { + const action = transition.actions[index]; const state = await transitionTargetState(workspace, action); + if (!state.matchesAfter) { + if (!state.matchesBefore) throw new KernelError('A transition target changed outside its prepared before/after states.', 'TRANSITION_CONFLICT', [action.path]); + if (action.afterContent === null) await unlinkDurable(state.absolute); else await writeTextAtomic(state.absolute, action.afterContent); + } + await maybeInjectTransitionFault(options, index + 1); + } +} +async function executeTransition(cwd, operation, runId, changes, options = {}) { + const pending = await inspectPendingTransition(cwd); + if (pending) throw new KernelError('A prepared transition requires explicit recovery before another mutation.', 'TRANSITION_RECOVERY_REQUIRED', [pending.summary || pending.issues]); + const { stateRoot, transition } = await buildTransition(cwd, operation, runId, changes); + const journal = path.join(stateRoot.root, 'transition.json'); await writeJsonAtomic(journal, transition); await maybeInjectTransitionFault(options, 0); + await applyTransitionActions(stateRoot.workspace, transition, options); + for (const action of transition.actions) if (!(await transitionTargetState(stateRoot.workspace, action)).matchesAfter) throw new KernelError('Transition post-image verification failed.', 'TRANSITION_CONFLICT', [action.path]); + await unlinkDurable(journal); + return { transitionId: transition.transitionId, transitionSha256: transition.transitionSha256 }; +} + +export async function recoverTransition(cwd, expectedTransitionSha256, options = {}) { + if (typeof expectedTransitionSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedTransitionSha256)) throw new KernelError('An exact expected transition hash is required.', 'EXPECTED_TRANSITION_HASH_REQUIRED'); + const preflight = await inspectPendingTransition(cwd); + if (preflight?.classification === 'transition-recovery-required' && preflight.transition.transitionSha256 !== expectedTransitionSha256) throw new KernelError('The prepared transition hash changed.', 'TRANSITION_HASH_MISMATCH'); + if (options.expectedLockSha256 !== undefined) { + if (!preflight || preflight.classification !== 'transition-recovery-required') throw new KernelError('No recoverable prepared transition owns the stale lock.', 'TRANSITION_CONFLICT', preflight?.issues || []); + await recoverExactStaleLock(cwd, preflight, options.expectedLockSha256); + } + return withWorkspaceLock(cwd, 'recover-transition', async () => { + const pending = await inspectPendingTransition(cwd); + if (!pending) throw new KernelError('No prepared transition exists.', 'NO_PENDING_TRANSITION'); + if (pending.classification !== 'transition-recovery-required') throw new KernelError('The prepared transition conflicts with current state.', 'TRANSITION_CONFLICT', pending.issues); + if (pending.transition.transitionSha256 !== expectedTransitionSha256) throw new KernelError('The prepared transition hash changed.', 'TRANSITION_HASH_MISMATCH'); + const stateRoot = await verifiedStateRoot(cwd); await applyTransitionActions(stateRoot.workspace, pending.transition); + for (const action of pending.transition.actions) if (!(await transitionTargetState(stateRoot.workspace, action)).matchesAfter) throw new KernelError('Transition recovery post-image verification failed.', 'TRANSITION_CONFLICT', [action.path]); + await unlinkDurable(path.join(stateRoot.root, 'transition.json')); + const reconciliation = await reconcileActiveRuns(cwd); + if (!reconciliation.ok) throw new KernelError('Transition applied but active-run reconciliation is not valid.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation]); + return { recovered: true, transitionId: pending.transition.transitionId, operation: pending.transition.operation, runId: pending.transition.runId, reconciliation }; + }); +} + +function stateHash(state) { return hashWithout(state, 'stateSha256'); } +function setStateHash(state) { state.stateSha256 = stateHash(state); return state; } + +export async function loadState(cwd, runId) { + const files = await verifiedLayout(cwd, runId); + const state = await readJson(files.state, 'Run state'); + if (state.format !== FORMAT || state.runId !== runId || typeof state.stateSha256 !== 'string' || stateHash(state) !== state.stateSha256) { + throw new KernelError(`Run state is corrupt: ${runId}`, 'CORRUPT_STATE'); + } + return { files, state }; +} + +function requireActiveRun(state, runId) { if (state.status !== 'active') throw new KernelError(`Run ${runId} is complete.`, 'RUN_COMPLETE'); } + +function reconciliationResult(classification, pointerRunId, activeRunIds, runIds, issues = [], pendingTransition = null) { + const ok = classification === 'clean-idle' || classification === 'canonical-active'; + const nextSafeSteps = { + 'clean-idle': 'No recovery action is required.', + 'canonical-active': `Resume only ${pointerRunId}; this diagnostic did not execute or replay task work.`, + 'orphan-active': 'Inspect the orphan run and restore a canonical pointer only through a future explicitly authorized repair.', + 'split-brain': 'Stop writers and inspect every listed active run; do not choose or rewrite an owner automatically.', + 'pointer-nonactive': 'Inspect the interrupted finalization boundary; do not delete the pointer automatically.', + 'pointer-missing-run': 'Inspect the pointer and workspace history; the referenced run is missing.', + 'pointer-diverged': 'Inspect the pointer and active run states; ownership is inconsistent.', + 'pointer-invalid': 'Inspect the invalid active pointer without replacing it automatically.', + 'run-corrupt': 'Inspect the listed managed run entries; reconciliation cannot trust corrupted state.', + 'transition-recovery-required': 'Run explicit hash-bound state recovery; this diagnostic did not apply post-images or replay task work.', + 'transition-conflict': 'Inspect the transition and target hashes; do not overwrite or delete the journal automatically.', + }; + return { ok, classification, pointerRunId, activeRunIds, managedRunCount: runIds.length, issues, pendingTransition, nextSafeStep: nextSafeSteps[classification] }; +} + +export async function reconcileActiveRuns(cwd) { + const stateRoot = await verifiedStateRoot(cwd); + if (!stateRoot.exists) return reconciliationResult('clean-idle', null, [], []); + const pending = await inspectPendingTransition(cwd); + if (pending) { + const result = reconciliationResult(pending.classification, null, [], [], pending.issues, pending.summary); + result.writerLock = await inspectWriterLockForRecovery(cwd); + if (result.writerLock.status === 'stale-local' && pending.classification === 'transition-recovery-required') result.nextSafeStep = 'Use explicit state recovery with both the printed transition hash and dead local writer-lock hash; no task work will be replayed.'; + return result; + } + const runsPath = path.join(stateRoot.root, 'runs'); + const hasRuns = await verifyStateEntry(runsPath, stateRoot.root, 'directory', 'runs directory'); + const runIds = []; const activeRunIds = []; const issues = []; + if (hasRuns) { + const entries = (await readdir(runsPath, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + if (entry.isSymbolicLink() || !entry.isDirectory()) { + issues.push(`${entry.name}: expected a physical run directory`); + continue; + } + let runId; + try { runId = safeRunId(entry.name); } + catch { issues.push(`${entry.name}: invalid run id`); continue; } + runIds.push(runId); + try { + const { state } = await loadState(cwd, runId); + if (state.status === 'active') activeRunIds.push(runId); + } catch (error) { + if (error.code === 'UNSAFE_STATE_PATH') throw error; + issues.push(`${runId}: ${error.code || 'INVALID_STATE'}`); + } + } + } + runIds.sort(); activeRunIds.sort(); + + const activePath = path.join(stateRoot.root, 'active.json'); + let pointerRunId = null; let pointerInvalid = false; + if (await exists(activePath)) { + try { + const pointer = await readJson(activePath, 'Active run pointer'); + if (pointer.format !== FORMAT) pointerInvalid = true; + else pointerRunId = safeRunId(pointer.runId); + } catch (error) { + if (error.code === 'UNSAFE_STATE_PATH') throw error; + pointerInvalid = true; + } + } + + if (issues.length) return reconciliationResult('run-corrupt', pointerRunId, activeRunIds, runIds, issues); + if (activeRunIds.length > 1) return reconciliationResult('split-brain', pointerRunId, activeRunIds, runIds); + if (pointerInvalid) return reconciliationResult('pointer-invalid', null, activeRunIds, runIds); + if (!pointerRunId) return activeRunIds.length === 0 + ? reconciliationResult('clean-idle', null, activeRunIds, runIds) + : reconciliationResult('orphan-active', null, activeRunIds, runIds); + if (!runIds.includes(pointerRunId)) return reconciliationResult('pointer-missing-run', pointerRunId, activeRunIds, runIds); + if (activeRunIds.length === 0) return reconciliationResult('pointer-nonactive', pointerRunId, activeRunIds, runIds); + if (activeRunIds[0] !== pointerRunId) return reconciliationResult('pointer-diverged', pointerRunId, activeRunIds, runIds); + return reconciliationResult('canonical-active', pointerRunId, activeRunIds, runIds); +} + +async function requireCanonicalActiveRun(cwd, state, runId) { + requireActiveRun(state, runId); + const reconciliation = await reconcileActiveRuns(cwd); + if (!reconciliation.ok || reconciliation.classification !== 'canonical-active') { + throw new KernelError('The active-run state is inconsistent and requires read-only reconciliation.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation.classification, ...reconciliation.activeRunIds]); + } + if (reconciliation.pointerRunId !== runId) throw new KernelError(`Run ${runId} is not the canonical active run.`, 'NOT_ACTIVE_RUN'); + return reconciliation; +} + +function ids(items) { return new Set((items || []).map((item) => item.id)); } +function requireArray(value, label, errors) { if (!Array.isArray(value)) errors.push(`${label} must be an array.`); } +function validId(value) { return typeof value === 'string' && /^[A-Z][A-Z0-9]*-\d{3,}$/.test(value); } +function validEvidenceId(value) { return typeof value === 'string' && /^EV-\d{3,}$/.test(value); } +function refsExist(refs, known) { return Array.isArray(refs) && refs.length > 0 && refs.every((id) => known.has(id)); } + +export function validateContract(contract, { expectedVersion } = {}) { + const errors = []; + if (!contract || typeof contract !== 'object' || Array.isArray(contract)) return { ok: false, errors: ['Contract must be an object.'] }; + for (const key of Object.keys(contract)) if (!CONTRACT_FIELDS.has(key)) errors.push(`Unknown top-level contract field: ${key}.`); + if (typeof contract.contractId !== 'string' || !contract.contractId.trim()) errors.push('contractId is required.'); + if (!Number.isInteger(contract.version) || contract.version < 1) errors.push('version must be a positive integer.'); + if (expectedVersion !== undefined && contract.version !== expectedVersion) errors.push(`version must be ${expectedVersion}.`); + if (!['draft', 'frozen'].includes(contract.status || 'draft')) errors.push('status must be draft or frozen.'); + if (typeof contract.intent !== 'string' || !contract.intent.trim()) errors.push('intent is required.'); + for (const name of ['obligations', 'acceptanceCriteria', 'invariants', 'preservation']) requireArray(contract[name], name, errors); + const collections = ['obligations', 'acceptanceCriteria', 'invariants', 'preservation', 'publicSeams', 'nonFunctional']; + const allIds = new Set(); + for (const collection of collections) for (const item of contract[collection] || []) { + if (!item || !validId(item.id) || !item.id.startsWith(`${PREFIXES[collection]}-`)) errors.push(`${collection} contains an invalid ${PREFIXES[collection]} id.`); + else if (allIds.has(item.id)) errors.push(`Duplicate id: ${item.id}.`); else allIds.add(item.id); + } + const acIds = ids(contract.acceptanceCriteria); const invIds = ids(contract.invariants); const targetIds = new Set([...acIds, ...invIds, ...ids(contract.preservation)]); + for (const obligation of contract.obligations || []) { + if (!PRIORITIES.has(obligation.priority)) errors.push(`${obligation.id || 'obligation'} has an invalid priority.`); + if (typeof obligation.statement !== 'string' || !obligation.statement.trim()) errors.push(`${obligation.id || 'obligation'} needs a statement.`); + if (!Array.isArray(obligation.sourceQuotes) || obligation.sourceQuotes.length === 0 || !obligation.sourceQuotes.every((q) => typeof q === 'string' && q.trim())) errors.push(`${obligation.id || 'obligation'} needs sourceQuotes.`); + const linked = [...(obligation.acceptance || []), ...(obligation.invariants || [])]; + if (obligation.priority === 'must' && (!linked.length || !linked.every((id) => acIds.has(id) || invIds.has(id)))) errors.push(`${obligation.id || 'must obligation'} needs traced acceptance or invariant.`); + } + for (const item of contract.acceptanceCriteria || []) { + const observableStatement = typeof item.statement === 'string' && item.statement.trim() && typeof item.observation === 'string' && item.observation.trim(); + const observableScenario = typeof item.given === 'string' && item.given.trim() && typeof item.when === 'string' && item.when.trim() && Array.isArray(item.then) && item.then.length > 0 && item.then.every((step) => typeof step === 'string' && step.trim()); + if (!observableStatement && !observableScenario) errors.push(`${item.id || 'criterion'} needs an observation with statement or given/when/then.`); + if (!Array.isArray(item.evidence) || item.evidence.length === 0 || !item.evidence.every(validEvidenceId)) errors.push(`${item.id || 'criterion'} needs planned EV evidence ids.`); + } + for (const item of [...(contract.invariants || []), ...(contract.preservation || [])]) { + if (typeof item.statement !== 'string' || !item.statement.trim()) errors.push(`${item.id || 'criterion'} needs a statement.`); + if (!Array.isArray(item.evidence) || item.evidence.length === 0 || !item.evidence.every(validEvidenceId)) errors.push(`${item.id || 'criterion'} needs planned EV evidence ids.`); + } + if (contract.boundaries && (!Array.isArray(contract.boundaries.allowed) || !Array.isArray(contract.boundaries.forbidden))) errors.push('boundaries must have allowed and forbidden arrays.'); + return { ok: errors.length === 0, errors, targetIds }; +} + +export async function verifyRun(cwd, runId) { + const { files, state } = await loadState(cwd, runId); + if (!(await exists(files.brief))) throw new KernelError('Immutable brief is missing.', 'CORRUPT_STATE'); + const briefHash = sha256(await readFile(files.brief, 'utf8')); + if (briefHash !== state.briefSha256) throw new KernelError('Immutable brief hash does not match state.', 'CORRUPT_STATE'); + for (const [version, expectedHash] of Object.entries(state.contractHashes || {})) { + const file = path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`); + await verifyStateEntry(file, files.contracts, 'file', `contract v${version}`); + const contract = await readJson(file, 'Frozen contract'); + if (contract.contractSha256 !== expectedHash || hashWithout(contract, 'contractSha256') !== expectedHash) throw new KernelError(`Frozen contract v${version} was changed.`, 'FROZEN_CONTRACT_CHANGED'); + } + return { files, state }; +} + +async function initRunUnlocked(cwd, runId, briefText, options = {}) { + const files = await verifiedLayout(cwd, runId, { createRoot: true }); + if (await exists(files.run)) throw new KernelError(`Run already exists: ${runId}`, 'RUN_EXISTS'); + const reconciliation = await reconcileActiveRuns(cwd); + if (reconciliation.classification === 'canonical-active') throw new KernelError(`An active run already exists: ${reconciliation.pointerRunId}`, 'ACTIVE_RUN_EXISTS'); + if (reconciliation.classification !== 'clean-idle') throw new KernelError('The active-run state is inconsistent and must be reconciled before initialization.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation.classification, ...reconciliation.activeRunIds]); + if (typeof briefText !== 'string' || !briefText.trim()) throw new KernelError('briefText is required.', 'INVALID_BRIEF'); + const brief = redact(briefText); + const now = new Date().toISOString(); + const state = setStateHash({ format: FORMAT, runId, status: 'active', phase: 'understand', baselineRequired: true, briefSha256: sha256(brief), contractHashes: {}, currentContractVersion: null, createdAt: now, updatedAt: now }); + const evidence = { format: FORMAT, entries: [] }; evidence.storeSha256 = evidenceStoreHash(evidence); + await executeTransition(cwd, 'init', runId, [ + { file: files.brief, content: brief }, + { file: files.state, content: jsonText(state) }, + { file: files.evidence, content: jsonText(evidence) }, + { file: files.active, content: jsonText({ format: FORMAT, runId, updatedAt: now }) }, + ], options); + return { runId, briefSha256: state.briefSha256 }; +} + +export async function initRun(cwd, runId, briefText, options = {}) { + return withWorkspaceLock(cwd, `init:${safeRunId(runId)}`, () => initRunUnlocked(cwd, runId, briefText, options)); +} + +async function currentContract(files, state) { + if (!state.currentContractVersion) throw new KernelError('No frozen contract exists.', 'NO_CONTRACT'); + const version = state.currentContractVersion; + const file = path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`); + await verifyStateEntry(file, files.contracts, 'file', `contract v${version}`); + return readJson(file, 'Frozen contract'); +} + +function normalizeForMatch(value) { return String(value).replace(/\s+/g, ' ').trim().toLocaleLowerCase(); } +async function validateSourceQuotes(files, contract, { authority = '', changedObligationIds = new Set() } = {}) { + const brief = normalizeForMatch(await readFile(files.brief, 'utf8')); + const authorityText = normalizeForMatch(redact(authority)); + const missing = (contract.obligations || []).flatMap((obligation) => (obligation.sourceQuotes || []).filter((quote) => { + const normalized = normalizeForMatch(quote); + return !brief.includes(normalized) && !(changedObligationIds.has(obligation.id) && authorityText.includes(normalized)); + }).map((quote) => `${obligation.id}: ${quote}`)); + if (missing.length) throw new KernelError('Contract sourceQuotes are not present in the sanitized brief.', 'SOURCE_QUOTE_NOT_IN_BRIEF', missing); +} + +function collectionMap(contract, collection) { return new Map((contract[collection] || []).map((item) => [item.id, item])); } +export function normativeDiff(previous, candidate) { + const changes = []; + for (const collection of Object.keys(PREFIXES)) { + const before = collectionMap(previous, collection); const after = collectionMap(candidate, collection); + for (const id of new Set([...before.keys(), ...after.keys()])) { + const prior = before.get(id); const next = after.get(id); + if (canonicalJson(prior) !== canonicalJson(next)) changes.push({ token: id, previous: prior ?? null, next: next ?? null }); + } + } + const top = { INTENT: 'intent', ACTORS: 'actors', BOUNDARIES: 'boundaries', ASSUMPTIONS: 'assumptions', 'OUT-OF-SCOPE': 'outOfScope' }; + for (const [token, property] of Object.entries(top)) if (canonicalJson(previous[property] ?? null) !== canonicalJson(candidate[property] ?? null)) changes.push({ token, previous: previous[property] ?? null, next: candidate[property] ?? null }); + return changes; +} + +function invalidationTargets(previous, changes) { + const broad = changes.some((change) => TOP_LEVEL_TOKENS.includes(change.token) || change.token.startsWith('SEAM-') || change.token.startsWith('NFR-')); + if (broad) return null; + const targets = new Set(); const requirements = collectionMap(previous, 'obligations'); + for (const change of changes) { + if (/^(AC|INV|PRES)-/.test(change.token)) targets.add(change.token); + if (change.token.startsWith('REQ-')) for (const requirement of [change.previous, change.next, requirements.get(change.token)]) for (const target of [...(requirement?.acceptance || []), ...(requirement?.invariants || [])]) targets.add(target); + } + return targets; +} + +async function freezeContractUnlocked(cwd, runId, candidate, options = {}) { + const { files, state } = await verifyRun(cwd, runId); + await requireCanonicalActiveRun(cwd, state, runId); + if (state.baselineRequired === true) { + if (!(await exists(files.baseline))) throw new KernelError('Record an explicit baseline before freezing a new run.', 'BASELINE_REQUIRED'); + await loadBaseline(files); + } + const expectedVersion = (state.currentContractVersion || 0) + 1; + if (state.currentContractVersion) throw new KernelError('A frozen contract already exists; use contract amend.', 'AMEND_REQUIRED'); + const validation = validateContract(candidate, { expectedVersion }); + if (!validation.ok) throw new KernelError('Contract validation failed.', 'INVALID_CONTRACT', validation.errors); + const contract = redactValue(structuredClone(candidate)); + await validateSourceQuotes(files, contract); + contract.status = 'frozen'; + contract.source = { briefPath: 'brief.md', briefSha256: state.briefSha256 }; + contract.contractSha256 = hashWithout(contract, 'contractSha256'); + const output = path.join(files.contracts, 'contract-v001.json'); + const nextState = structuredClone(state); nextState.currentContractVersion = 1; nextState.contractHashes['1'] = contract.contractSha256; nextState.phase = 'execute'; nextState.updatedAt = new Date().toISOString(); setStateHash(nextState); + await executeTransition(cwd, 'freeze', runId, [{ file: output, content: jsonText(contract) }, { file: files.state, content: jsonText(nextState) }], options); + return { version: 1, contractSha256: contract.contractSha256, path: output }; +} + +export async function freezeContract(cwd, runId, candidate, options = {}) { + return withWorkspaceLock(cwd, `freeze-contract:${safeRunId(runId)}`, () => freezeContractUnlocked(cwd, runId, candidate, options)); +} + +async function amendContractUnlocked(cwd, runId, candidate, reason, affected, authority, options = {}) { + const { files, state } = await verifyRun(cwd, runId); + await requireCanonicalActiveRun(cwd, state, runId); + const previous = await currentContract(files, state); + if (typeof reason !== 'string' || !reason.trim()) throw new KernelError('An amendment reason is required.', 'AMENDMENT_REASON_REQUIRED'); + if (typeof authority !== 'string' || !authority.trim()) throw new KernelError('An amendment authority is required.', 'AMENDMENT_AUTHORITY_REQUIRED'); + if (!Array.isArray(affected) || affected.length === 0) throw new KernelError('Affected contract ids are required.', 'AMENDMENT_AFFECTED_REQUIRED'); + const version = state.currentContractVersion + 1; + const validation = validateContract(candidate, { expectedVersion: version }); + if (!validation.ok) throw new KernelError('Contract validation failed.', 'INVALID_CONTRACT', validation.errors); + const contract = redactValue(structuredClone(candidate)); + const changes = normativeDiff(previous, contract); + await validateSourceQuotes(files, contract, { authority, changedObligationIds: new Set(changes.filter((change) => change.token.startsWith('REQ-')).map((change) => change.token)) }); + const actualTokens = new Set(changes.map((change) => change.token)); const suppliedTokens = new Set(affected); + if (changes.length === 0 || [...actualTokens].some((token) => !suppliedTokens.has(token)) || [...suppliedTokens].some((token) => !actualTokens.has(token))) { + throw new KernelError('Amendment affects must exactly cover the normative contract diff.', 'INVALID_AMENDMENT', [...actualTokens]); + } + contract.status = 'frozen'; contract.source = { briefPath: 'brief.md', briefSha256: state.briefSha256 }; contract.amends = previous.version; + contract.contractSha256 = hashWithout(contract, 'contractSha256'); + const evidence = await loadEvidence(files); + const now = new Date().toISOString(); + const targets = invalidationTargets(previous, changes); + for (const entry of evidence.entries || []) if (entry.contractVersion === previous.version && (targets === null || (entry.covers || []).some((id) => targets.has(id)))) { + entry.status = 'invalidated'; entry.invalidatedAt = now; entry.invalidatedBy = `contract-v${version}`; + } + evidence.storeSha256 = evidenceStoreHash(evidence); + const amendment = { format: FORMAT, fromVersion: previous.version, toVersion: version, reason: redact(reason), authority: redact(authority), affected: [...suppliedTokens], changes, createdAt: now }; + const nextState = structuredClone(state); nextState.currentContractVersion = version; nextState.contractHashes[String(version)] = contract.contractSha256; nextState.phase = 'execute'; nextState.updatedAt = now; setStateHash(nextState); + await executeTransition(cwd, 'amend', runId, [ + { file: path.join(files.contracts, `contract-v${String(version).padStart(3, '0')}.json`), content: jsonText(contract) }, + { file: files.evidence, content: jsonText(evidence) }, + { file: path.join(files.amendments, `amendment-v${String(version).padStart(3, '0')}.json`), content: jsonText(amendment) }, + { file: files.state, content: jsonText(nextState) }, + ], options); + return { version, changes: [...actualTokens], invalidatedEvidence: (evidence.entries || []).filter((entry) => entry.status === 'invalidated' && entry.invalidatedBy === `contract-v${version}`).map((entry) => entry.evidenceId) }; +} + +export async function amendContract(cwd, runId, candidate, reason, affected, authority, options = {}) { + return withWorkspaceLock(cwd, `amend-contract:${safeRunId(runId)}`, () => amendContractUnlocked(cwd, runId, candidate, reason, affected, authority, options)); +} + +function allTargetIds(contract) { return new Set([...ids(contract.acceptanceCriteria), ...ids(contract.invariants), ...ids(contract.preservation)]); } +function plannedEvidence(contract) { return new Set([...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])].flatMap((item) => item.evidence || [])); } + +function evidenceStoreHash(store) { return hashWithout(store, 'storeSha256'); } +async function loadEvidence(files) { + const store = await readJson(files.evidence, 'Evidence store'); + if (store.format !== FORMAT || !Array.isArray(store.entries) || typeof store.storeSha256 !== 'string' || evidenceStoreHash(store) !== store.storeSha256) { + throw new KernelError('Evidence store is corrupt.', 'CORRUPT_EVIDENCE'); + } + return store; +} +function nonEmptyText(value) { return typeof value === 'string' && value.trim().length > 0; } + +function evidenceEntryErrors(entry, contract, version) { + const errors = []; + if (!entry || typeof entry !== 'object' || !validEvidenceId(entry.evidenceId)) errors.push('evidenceId must use the EV-NNN prefix.'); + if (entry.contractVersion !== version) errors.push('Evidence must use the current contract version.'); + if (!Array.isArray(entry.covers) || entry.covers.length === 0 || !entry.covers.every((id) => allTargetIds(contract).has(id))) errors.push('Evidence covers unknown criteria.'); + if (!EVIDENCE_STATUSES.has(entry.status) || entry.status === 'invalidated') errors.push('Evidence status is invalid.'); + if (!EVIDENCE_TYPES.has(entry.type)) errors.push('Evidence type is invalid.'); + if (!nonEmptyText(entry.command) && !nonEmptyText(entry.procedure)) errors.push('Evidence needs a nonempty command or procedure.'); + if (!nonEmptyText(entry.observed)) errors.push('Evidence needs a nonempty observed result.'); + if (entry.status === 'pass' && !nonEmptyText(entry.artifact) && !nonEmptyText(entry.reference)) errors.push('Passing evidence needs an artifact or reference.'); + const criticalTargets = new Set([...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])].filter((item) => item.critical).map((item) => item.id)); + if (entry.status === 'pass' && (entry.covers || []).some((id) => criticalTargets.has(id)) && (!nonEmptyText(entry.sensitivity?.method) || !nonEmptyText(entry.sensitivity?.observed))) errors.push('Passing evidence for a critical target needs sensitivity.method and sensitivity.observed.'); + if (!plannedEvidence(contract).has(entry.evidenceId)) errors.push('Evidence id is not planned by the frozen contract.'); + if (entry.provenance?.kind === 'manual-attestation' && nonEmptyText(entry.command)) errors.push('Manual-attestation evidence must not contain a command.'); + if (entry.provenance && !['manual-attestation', 'captured-command'].includes(entry.provenance.kind)) errors.push('Evidence provenance kind is invalid.'); + return errors; +} + +async function recordEvidenceUnlocked(cwd, runId, record, { allowCaptured = false, expectedSnapshot, transitionOptions = {} } = {}) { + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); + await requireCanonicalActiveRun(cwd, state, runId); + if (expectedSnapshot && (state.stateSha256 !== expectedSnapshot.stateSha256 || state.currentContractVersion !== expectedSnapshot.contractVersion || contract.contractSha256 !== expectedSnapshot.contractSha256)) { + throw new KernelError('The run changed while evidence was being captured.', 'STALE_CAPTURE'); + } + const entry = redactValue(structuredClone(record)); const errors = evidenceEntryErrors(entry, contract, state.currentContractVersion); + if (entry.provenance?.kind === 'captured-command' && !allowCaptured) errors.push('Captured-command provenance may only be written by evidence capture.'); + if (errors.length) throw new KernelError('Evidence validation failed.', 'INVALID_EVIDENCE', errors); + entry.verifiedAt = entry.verifiedAt || new Date().toISOString(); + const evidence = await loadEvidence(files); + // Evidence is historical: a new version may intentionally reuse EV-001. + evidence.entries = (evidence.entries || []).filter((item) => item.evidenceId !== entry.evidenceId || item.contractVersion !== entry.contractVersion); + evidence.entries.push(entry); + evidence.storeSha256 = evidenceStoreHash(evidence); + await executeTransition(cwd, 'evidence', runId, [{ file: files.evidence, content: jsonText(evidence) }], transitionOptions); return entry; +} + +export async function recordEvidence(cwd, runId, record, options = {}) { + return withWorkspaceLock(cwd, `record-evidence:${safeRunId(runId)}`, () => recordEvidenceUnlocked(cwd, runId, record, { transitionOptions: options })); +} + +function captureOutput(stream, limit = 65536) { + let settled = false; let storedBytes = 0; let truncated = false; const chunks = []; let settle; + const promise = new Promise((resolve) => { settle = (incomplete = false) => { + if (settled) return; settled = true; + resolve({ value: redact(Buffer.concat(chunks).toString('utf8')), truncated, incomplete }); + }; }); + stream.on('data', (chunk) => { + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + const remaining = Math.max(0, limit - storedBytes); + if (remaining > 0) { const kept = value.subarray(0, remaining); chunks.push(kept); storedBytes += kept.length; } + if (value.length > remaining) truncated = true; + }); + stream.once('end', () => settle(false)); stream.once('close', () => { if (stream.readableEnded) settle(false); }); stream.once('error', () => settle(true)); + return { promise, force: () => { settle(true); stream.destroy(); } }; +} + +function isPhysicalDescendant(root, candidate) { + const relative = path.relative(root, candidate); + return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)); +} + +async function resolveContainedExisting(cwd, requested, label, kind) { + const relative = safeRelativePath(requested, label); let rootReal; let candidateReal; + try { rootReal = await realpath(path.resolve(cwd)); } + catch (error) { throw new KernelError(`Workspace path is unreadable: ${cwd}`, 'UNSAFE_PATH', [error.message]); } + try { candidateReal = await realpath(path.resolve(rootReal, relative)); } + catch (error) { + const code = kind === 'directory' ? 'CAPTURE_CWD_INVALID' : 'CAPTURE_ARTIFACT_MISSING'; + throw new KernelError(`${label} is missing or unreadable: ${relative}`, code, [error.message]); + } + if (!isPhysicalDescendant(rootReal, candidateReal)) throw new KernelError(`${label} escapes the physical workspace.`, 'UNSAFE_PATH'); + const information = await stat(candidateReal); + if (kind === 'directory' && !information.isDirectory()) throw new KernelError(`${label} must resolve to a directory.`, 'CAPTURE_CWD_INVALID'); + if (kind === 'file' && !information.isFile()) throw new KernelError(`${label} must resolve to a regular file.`, 'CAPTURE_ARTIFACT_MISSING'); + return { relative, absolute: candidateReal }; +} + +async function artifactHashes(cwd, entry, { required = true } = {}) { + if (!nonEmptyText(entry.artifact)) return {}; + try { + const artifact = await resolveContainedExisting(cwd, entry.artifact, 'artifact', 'file'); + return { [artifact.relative]: sha256(await readFile(artifact.absolute)) }; + } catch (error) { + if (!required && error.code === 'CAPTURE_ARTIFACT_MISSING') return {}; + throw error; + } +} + +function declaredFreshnessPaths(value, fallback = []) { + const input = value === undefined ? fallback : value; + if (!Array.isArray(input) || input.length > 64) throw new KernelError('freshnessPaths must contain at most 64 relative file paths.', 'INVALID_FRESHNESS_SCOPE'); + const paths = [...new Set(input.map((item) => safeRelativePath(item, 'freshness path')))]; + if (paths.length !== input.length) throw new KernelError('freshnessPaths must be unique.', 'INVALID_FRESHNESS_SCOPE'); + return paths; +} +async function gitHeadForFingerprint(cwd) { + return new Promise((resolve) => { + let output = ''; let settled = false; + const child = spawn('git', ['-C', cwd, 'rev-parse', 'HEAD'], { shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] }); + const finish = (value) => { if (!settled) { settled = true; resolve(value); } }; + const timer = setTimeout(() => { child.kill(); finish(null); }, 2000); + child.stdout.on('data', (chunk) => { if (output.length < 256) output += chunk.toString('utf8'); }); + child.once('error', () => { clearTimeout(timer); finish(null); }); + child.once('close', (code) => { clearTimeout(timer); const head = output.trim(); finish(code === 0 && /^[a-f0-9]{40,64}$/i.test(head) ? head.toLowerCase() : null); }); + }); +} +async function workspaceFingerprint(cwd, requestedPaths) { + const paths = declaredFreshnessPaths(requestedPaths); + if (paths.length === 0) return { kind: 'unavailable', status: 'unavailable', reason: 'No bounded freshness paths were declared.', paths: [] }; + const entries = []; + for (const relative of paths) { + try { + const lexical = path.resolve(cwd, relative); const lexicalEntry = await existingEntry(lexical, 'freshness path'); + if (lexicalEntry?.isSymbolicLink()) throw new KernelError('freshness path must be a physical regular file.', 'INVALID_FRESHNESS_SCOPE', [relative]); + const file = await resolveContainedExisting(cwd, relative, 'freshness path', 'file'); + entries.push({ path: file.relative, sha256: sha256(await readFile(file.absolute)) }); + } catch (error) { + if (error.code !== 'CAPTURE_ARTIFACT_MISSING') throw error; + return { kind: 'unavailable', status: 'unavailable', reason: `Declared freshness path is missing: ${relative}`, paths }; + } + } + const gitHead = await gitHeadForFingerprint(cwd); const fingerprintSha256 = sha256(canonicalJson({ entries })); + return { kind: gitHead ? 'git-paths-v1' : 'artifacts-v1', status: 'current', gitHead, paths, entries, fingerprintSha256 }; +} +async function assessWorkspaceFingerprint(cwd, fingerprint) { + if (!fingerprint || fingerprint.status !== 'current' || !Array.isArray(fingerprint.paths) || typeof fingerprint.fingerprintSha256 !== 'string') return { status: 'unavailable', reason: fingerprint?.reason || 'No trustworthy workspace fingerprint was captured.' }; + const current = await workspaceFingerprint(cwd, fingerprint.paths); + if (current.status !== 'current') return { status: 'unavailable', reason: current.reason }; + return current.fingerprintSha256 === fingerprint.fingerprintSha256 + ? { status: 'current' } + : { status: 'stale', reason: 'One or more declared freshness paths changed after capture.' }; +} + +function baselineHash(receipt) { return hashWithout(receipt, 'baselineSha256'); } +async function loadBaseline(files) { + if (!(await exists(files.baseline))) return { format: FORMAT, status: 'unavailable', reason: 'Legacy run has no baseline receipt.', legacy: true }; + const receipt = await readJson(files.baseline, 'Baseline receipt'); + if (receipt.format !== FORMAT || !['green', 'pre-existing-failure', 'unavailable'].includes(receipt.status) || typeof receipt.baselineSha256 !== 'string' || baselineHash(receipt) !== receipt.baselineSha256) throw new KernelError('Baseline receipt is corrupt.', 'CORRUPT_BASELINE'); + return receipt; +} +async function baselinePreflight(cwd, runId) { + const { files, state } = await verifyRun(cwd, runId); await requireCanonicalActiveRun(cwd, state, runId); + if (await exists(files.baseline)) throw new KernelError('A baseline receipt already exists.', 'BASELINE_EXISTS'); + if (state.currentContractVersion !== null || state.phase !== 'understand') throw new KernelError('Baseline must be recorded before the contract is frozen.', 'BASELINE_TOO_LATE'); + const evidence = await loadEvidence(files); + if ((evidence.entries || []).length > 0) throw new KernelError('Baseline must be recorded before evidence capture.', 'BASELINE_TOO_LATE'); + return { files, state }; +} +export async function recordUnavailableBaseline(cwd, runId, reason) { + if (!nonEmptyText(reason)) throw new KernelError('Unavailable baseline requires a reason.', 'INVALID_BASELINE'); + return withWorkspaceLock(cwd, `record-baseline:${safeRunId(runId)}`, async () => { + const { files } = await baselinePreflight(cwd, runId); const receipt = { format: FORMAT, status: 'unavailable', reason: redact(reason), capturedAt: new Date().toISOString(), provenance: { kind: 'manual-attestation' } }; + receipt.baselineSha256 = baselineHash(receipt); await writeJsonAtomic(files.baseline, receipt); return receipt; + }); +} + +function capturePolicy(options = {}) { + const timeoutMs = Number(options.timeoutMs ?? CAPTURE_TIMEOUT_MS); const terminationGraceMs = Number(options.terminationGraceMs ?? CAPTURE_TERMINATION_GRACE_MS); + if (!Number.isInteger(timeoutMs) || timeoutMs < CAPTURE_TIMEOUT_MIN_MS || timeoutMs > CAPTURE_TIMEOUT_MAX_MS) throw new KernelError(`timeoutMs must be an integer from ${CAPTURE_TIMEOUT_MIN_MS} to ${CAPTURE_TIMEOUT_MAX_MS}.`, 'INVALID_TIMEOUT'); + if (!Number.isInteger(terminationGraceMs) || terminationGraceMs < 10 || terminationGraceMs > 5000) throw new KernelError('terminationGraceMs must be an integer from 10 to 5000.', 'INVALID_TIMEOUT'); + return { timeoutMs, terminationGraceMs }; +} + +async function runTaskkill(pid) { + const killer = spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { shell: false, windowsHide: true, stdio: 'ignore' }); + return Promise.race([ + new Promise((resolve) => { killer.once('error', () => resolve(false)); killer.once('close', (code) => resolve(code === 0)); }), + sleep(2000).then(() => { killer.kill(); return false; }), + ]); +} + +async function terminateProcessTree(child, isClosed, graceMs) { + const result = { + method: process.platform === 'win32' ? 'taskkill-tree' : 'posix-process-group', + scope: process.platform === 'win32' ? 'taskkill-reported-tree' : 'original-process-group', + termSent: false, + killSent: false, + directChildFallback: false, + rootExitObserved: isClosed(), + detachedDescendantsCovered: false, + succeeded: false, + observation: isClosed() ? 'Root process closed before tree termination could be observed.' : 'Termination not yet attempted.', + }; + if (!child.pid || isClosed()) return result; + if (process.platform === 'win32') { + result.termSent = true; result.succeeded = await runTaskkill(child.pid); + result.rootExitObserved = isClosed(); + if (result.succeeded) result.observation = 'taskkill /T reported successful process-tree termination.'; + else if (!isClosed()) { + result.directChildFallback = true; result.killSent = child.kill(); result.rootExitObserved = isClosed(); + result.observation = result.killSent + ? 'taskkill /T failed; a direct-child kill was attempted, so tree cleanup is unconfirmed.' + : 'taskkill /T failed and the direct-child fallback was not accepted; tree cleanup is unconfirmed.'; + } else result.observation = 'taskkill /T failed; the root closed, but descendant cleanup is unconfirmed.'; + return result; + } + try { process.kill(-child.pid, 'SIGTERM'); result.termSent = true; } + catch (error) { + if (error.code === 'ESRCH') { + result.succeeded = true; result.rootExitObserved = isClosed(); result.observation = 'The original process group was already absent.'; return result; + } + result.observation = `SIGTERM to the original process group failed: ${error.code || 'unknown error'}.`; + } + await sleep(graceMs); + let groupAbsent = false; + try { process.kill(-child.pid, 0); } + catch (error) { if (error.code === 'ESRCH') groupAbsent = true; } + if (!groupAbsent) { + try { process.kill(-child.pid, 'SIGKILL'); result.killSent = true; } + catch (error) { + if (error.code === 'ESRCH') groupAbsent = true; + else { result.rootExitObserved = isClosed(); result.observation = `SIGKILL to the original process group failed: ${error.code || 'unknown error'}.`; return result; } + } + } + if (!groupAbsent) { + await sleep(Math.min(graceMs, 250)); + try { process.kill(-child.pid, 0); } + catch (error) { if (error.code === 'ESRCH') groupAbsent = true; } + } + result.rootExitObserved = isClosed(); result.succeeded = groupAbsent; + result.observation = groupAbsent + ? 'The original POSIX process group is absent after termination signals; detached descendants are outside this observation.' + : 'The original POSIX process group still exists or could not be observed as absent; cleanup is unconfirmed.'; + return result; +} + +async function settleCapturedOutput(capture, waitMs = 750) { + const timed = Symbol('output-timeout'); const result = await Promise.race([capture.promise, sleep(waitMs).then(() => timed)]); + if (result !== timed) return result; capture.force(); return capture.promise; +} + +async function runCapturedCommand(argv, intendedCwd, options = {}, missingMessage = 'capture requires a command after --.') { + if (!Array.isArray(argv) || argv.length === 0 || !argv.every((item) => typeof item === 'string' && item.length > 0)) throw new KernelError(missingMessage, 'MISSING_COMMAND'); + const policy = capturePolicy(options); + const child = spawn(argv[0], argv.slice(1), { cwd: intendedCwd.absolute, detached: process.platform !== 'win32', shell: false, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); + const stdoutCapture = captureOutput(child.stdout); const stderrCapture = captureOutput(child.stderr); let closed = false; let timer; + const closePromise = new Promise((resolve, reject) => { + child.once('error', (error) => reject(new KernelError(`Could not start capture command: ${error.message}`, 'CAPTURE_SPAWN_FAILED'))); + child.once('close', (exitCode, signal) => { closed = true; resolve({ exitCode: exitCode ?? null, signal: signal ?? null }); }); + }); + let result; let timedOut = false; let termination = { method: 'none', termSent: false, killSent: false, succeeded: true }; + try { + const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve({ timedOut: true }), policy.timeoutMs); }); + const first = await Promise.race([closePromise.then((value) => ({ timedOut: false, value })), deadline]); + if (!first.timedOut) result = first.value; + else { + timedOut = true; termination = await terminateProcessTree(child, () => closed, policy.terminationGraceMs); + const incomplete = Symbol('close-timeout'); const closedResult = await Promise.race([closePromise, sleep(policy.terminationGraceMs + 1000).then(() => incomplete)]); + if (closedResult === incomplete) { if (!closed) child.kill('SIGKILL'); result = { exitCode: null, signal: null }; termination.succeeded = false; } + else result = closedResult; + } + } finally { if (timer) clearTimeout(timer); } + const stdout = await settleCapturedOutput(stdoutCapture); const stderr = await settleCapturedOutput(stderrCapture); const sanitizedArgv = redactArgv(argv); + return { + success: !timedOut && result.exitCode === 0, + observed: timedOut ? `Capture timed out after ${policy.timeoutMs} ms. ${termination.observation}` : `Captured exit code ${result.exitCode}.`, + provenance: { + kind: 'captured-command', argv: sanitizedArgv, cwd: intendedCwd.relative, exitCode: result.exitCode, signal: result.signal, timedOut, timeoutMs: policy.timeoutMs, termination, + stdout: stdout.value, stderr: stderr.value, stdoutTruncated: stdout.truncated, stderrTruncated: stderr.truncated, outputIncomplete: stdout.incomplete || stderr.incomplete, + capturedAt: new Date().toISOString(), + }, + }; +} + +export async function captureBaseline(cwd, runId, template, argv, requestedCwd = '.', options = {}) { + const initial = await baselinePreflight(cwd, runId); const expectedStateSha256 = initial.state.stateSha256; + const intendedCwd = await resolveContainedExisting(cwd, requestedCwd, 'baseline cwd', 'directory'); + const captured = await runCapturedCommand(argv, intendedCwd, options, 'baseline capture requires a command after --.'); + const paths = declaredFreshnessPaths(template?.freshnessPaths, []); const fingerprint = await workspaceFingerprint(cwd, paths); + return withWorkspaceLock(cwd, `capture-baseline:${safeRunId(runId)}`, async () => { + const { files, state } = await baselinePreflight(cwd, runId); + if (state.stateSha256 !== expectedStateSha256) throw new KernelError('The run changed while the baseline was being captured.', 'STALE_CAPTURE'); + const receipt = { + format: FORMAT, + status: captured.provenance.timedOut || captured.provenance.exitCode === null ? 'unavailable' : captured.success ? 'green' : 'pre-existing-failure', + observed: captured.observed, + capturedAt: captured.provenance.capturedAt, + provenance: captured.provenance, + workspaceFingerprint: fingerprint, + }; + if (receipt.status === 'unavailable') receipt.reason = captured.observed; + receipt.baselineSha256 = baselineHash(receipt); await writeJsonAtomic(files.baseline, receipt); return receipt; + }); +} + +export async function captureEvidence(cwd, runId, template, argv, requestedCwd = '.', options = {}) { + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); + await requireCanonicalActiveRun(cwd, state, runId); + const expectedSnapshot = { stateSha256: state.stateSha256, contractVersion: state.currentContractVersion, contractSha256: contract.contractSha256 }; + const intendedCwd = await resolveContainedExisting(cwd, requestedCwd, 'capture cwd', 'directory'); const relativeCwd = intendedCwd.relative; + if (nonEmptyText(template?.artifact)) { + try { await resolveContainedExisting(cwd, template.artifact, 'artifact', 'file'); } + catch (error) { if (error.code !== 'CAPTURE_ARTIFACT_MISSING') throw error; } + } + const captured = await runCapturedCommand(argv, { ...intendedCwd, relative: relativeCwd }, options, 'evidence capture requires a command after --.'); + const entry = redactValue(structuredClone(template)); const freshnessPaths = declaredFreshnessPaths(entry.freshnessPaths, nonEmptyText(entry.artifact) ? [entry.artifact] : []); delete entry.freshnessPaths; + entry.command = captured.provenance.argv.join(' '); entry.status = captured.success ? 'pass' : 'fail'; entry.observed = captured.observed; + entry.provenance = { ...captured.provenance, artifactHashes: await artifactHashes(cwd, entry, { required: entry.status === 'pass' }), workspaceFingerprint: await workspaceFingerprint(cwd, freshnessPaths) }; + return withWorkspaceLock(cwd, `capture-evidence:${safeRunId(runId)}`, () => recordEvidenceUnlocked(cwd, runId, entry, { allowCaptured: true, expectedSnapshot, transitionOptions: options })); +} + +export async function validateEvidence(cwd, runId) { + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidence = await loadEvidence(files); + const errors = []; const seen = new Set(); + for (const entry of evidence.entries || []) { + // Older versions remain an audit trail. They cannot satisfy the current contract, + // but an invalidated historical record is not a current validation error. + if (!entry || entry.contractVersion !== state.currentContractVersion) continue; + const key = `${entry.contractVersion}:${entry.evidenceId}`; + if (seen.has(key)) errors.push('Evidence contains a duplicate evidenceId for the current contract.'); + seen.add(key); + errors.push(...evidenceEntryErrors(entry, contract, state.currentContractVersion).map((message) => `${entry.evidenceId || 'evidence'}: ${message}`)); + } + return { ok: errors.length === 0, errors, entries: evidence.entries || [] }; +} + +function normalizedZone(zone) { return safeRelativePath(zone, 'zone').replace(/\/$/, '').toLowerCase(); } +function zonesOverlap(left, right) { return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`); } + +export function validateExecution(execution, contract) { + const errors = []; const units = execution?.units; + if (!Array.isArray(units)) return { ok: false, errors: ['execution.units must be an array.'] }; + const unitMap = new Map(); const traceTargets = new Set([...ids(contract.obligations), ...allTargetIds(contract)]); + for (const unit of units) { + if (!unit || !validId(unit.unitId)) { errors.push('Each work unit needs a valid unitId.'); continue; } + if (unitMap.has(unit.unitId)) errors.push(`Duplicate work unit: ${unit.unitId}.`); else unitMap.set(unit.unitId, unit); + const refs = [...(unit.obligations || []), ...(unit.criteria || [])]; + if (refs.length === 0 || !refs.every((id) => traceTargets.has(id))) errors.push(`${unit.unitId} is untraced or references an unknown id.`); + if (!Array.isArray(unit.zone) || unit.zone.length === 0) errors.push(`${unit.unitId} needs a write zone.`); + else { try { unit.zone.forEach(normalizedZone); } catch (error) { errors.push(`${unit.unitId} has an unsafe zone.`); } } + } + for (const unit of units) for (const dependency of unit.dependsOn || []) if (!unitMap.has(dependency)) errors.push(`${unit.unitId} depends on unknown unit ${dependency}.`); + const visiting = new Set(); const visited = new Set(); const reaches = new Map(); + const visit = (id, stack = []) => { + if (visiting.has(id)) { errors.push(`Execution DAG contains a cycle: ${[...stack, id].join(' -> ')}.`); return new Set(); } + if (visited.has(id)) return reaches.get(id); + visiting.add(id); const result = new Set(); const unit = unitMap.get(id); + for (const dep of unit?.dependsOn || []) { result.add(dep); for (const nested of visit(dep, [...stack, id])) result.add(nested); } + visiting.delete(id); visited.add(id); reaches.set(id, result); return result; + }; + for (const id of unitMap.keys()) visit(id); + const list = [...unitMap.values()]; + for (let i = 0; i < list.length; i += 1) for (let j = i + 1; j < list.length; j += 1) { + const a = list[i]; const b = list[j]; const sequential = reaches.get(a.unitId)?.has(b.unitId) || reaches.get(b.unitId)?.has(a.unitId); + if (!sequential && (a.zone || []).some((x) => (b.zone || []).some((y) => zonesOverlap(normalizedZone(x), normalizedZone(y))))) errors.push(`Parallel units ${a.unitId} and ${b.unitId} have overlapping write zones.`); + } + return { ok: errors.length === 0, errors }; +} + +async function fileSha256OrNull(file) { + try { return sha256(await readFile(file)); } + catch (error) { if (error.code === 'ENOENT') return null; throw error; } +} + +async function validateAndSaveExecutionUnlocked(cwd, runId, execution, expectedExecutionSha256) { + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const result = validateExecution(execution, contract); + await requireCanonicalActiveRun(cwd, state, runId); + const currentExecutionSha256 = await fileSha256OrNull(files.execution); + if (expectedExecutionSha256 !== undefined && currentExecutionSha256 !== expectedExecutionSha256) throw new KernelError('Execution view changed after the caller snapshot.', 'STALE_EXECUTION'); + if (!result.ok) throw new KernelError('Execution validation failed.', 'INVALID_EXECUTION', result.errors); + await writeJsonAtomic(files.execution, redactValue(execution)); return { ...result, executionSha256: await fileSha256OrNull(files.execution) }; +} + +export async function validateAndSaveExecution(cwd, runId, execution, options = {}) { + const files = await verifiedLayout(cwd, safeRunId(runId)); + const expectedExecutionSha256 = Object.prototype.hasOwnProperty.call(options, 'expectedExecutionSha256') ? options.expectedExecutionSha256 : await fileSha256OrNull(files.execution); + if (expectedExecutionSha256 !== null && (typeof expectedExecutionSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(expectedExecutionSha256))) throw new KernelError('expectedExecutionSha256 must identify the caller snapshot or be null.', 'INVALID_EXECUTION'); + return withWorkspaceLock(cwd, `save-execution:${safeRunId(runId)}`, () => validateAndSaveExecutionUnlocked(cwd, runId, execution, expectedExecutionSha256)); +} + +export async function stateShow(cwd, requestedRunId) { + let runId = requestedRunId; + if (!runId) { + const { root } = await verifiedStateRoot(cwd); const activePath = path.join(root, 'active.json'); + if (!(await exists(activePath))) throw new KernelError('There is no active run.', 'NO_ACTIVE_RUN'); + const active = await readJson(activePath, 'Active run pointer'); runId = active.runId; + } + const verified = await verifyRun(cwd, safeRunId(runId)); + return { runId, status: verified.state.status, phase: verified.state.phase, currentContractVersion: verified.state.currentContractVersion, updatedAt: verified.state.updatedAt }; +} + +export async function stateResume(cwd, requestedRunId) { + const reconciliation = await reconcileActiveRuns(cwd); + if (reconciliation.classification === 'clean-idle') { + if (requestedRunId) { + const summary = await stateShow(cwd, requestedRunId); + if (summary.status !== 'active') throw new KernelError(`Run ${summary.runId} is complete.`, 'RUN_COMPLETE'); + } + throw new KernelError('There is no active run.', 'NO_ACTIVE_RUN'); + } + if (!reconciliation.ok || reconciliation.classification !== 'canonical-active') throw new KernelError('The active-run state is inconsistent and requires read-only reconciliation.', 'ACTIVE_RUN_INCONSISTENT', [reconciliation.classification, ...reconciliation.activeRunIds]); + if (requestedRunId && safeRunId(requestedRunId) !== reconciliation.pointerRunId) throw new KernelError(`Run ${requestedRunId} is not the canonical active run.`, 'NOT_ACTIVE_RUN'); + const summary = await stateShow(cwd, reconciliation.pointerRunId); + return { ...summary, resumePhase: summary.phase, message: `Resume ${summary.runId} at ${summary.phase}.` }; +} + +function remainingBoundariesForReport(contract) { + return { + assumptions: Array.isArray(contract?.assumptions) ? [...contract.assumptions] : [], + outOfScope: Array.isArray(contract?.outOfScope) ? [...contract.outOfScope] : [], + }; +} +function renderRemainingBoundaries(boundaries) { + const line = (value) => String(value).replace(/\s+/g, ' ').trim(); + const assumptions = boundaries.assumptions.length ? boundaries.assumptions.map((item) => `- ${line(item)}`).join('\n') : '- none'; + const outOfScope = boundaries.outOfScope.length ? boundaries.outOfScope.map((item) => `- ${line(item)}`).join('\n') : '- none'; + return `## Remaining boundaries\n\n### Assumptions requiring observation\n${assumptions}\n\n### Out of scope or deferred\n${outOfScope}`; +} + +export async function reportRun(cwd, runId, format = 'json') { + const { files, state } = await verifyRun(cwd, safeRunId(runId)); + const contract = state.currentContractVersion ? await currentContract(files, state) : null; + const evidence = await loadEvidence(files); const currentEntries = (evidence.entries || []).filter((entry) => entry.contractVersion === state.currentContractVersion); + const statuses = ['pass', 'fail', 'uncertain', 'pending-review', 'not-applicable']; + const counts = Object.fromEntries(statuses.map((status) => [status, currentEntries.filter((entry) => entry.status === status).length])); + const baseline = await loadBaseline(files); + const remainingBoundaries = remainingBoundariesForReport(contract); + const summary = { format: FORMAT, runId, status: state.status, phase: state.phase, contract: contract ? { contractId: contract.contractId, version: state.currentContractVersion } : null, baseline: { status: baseline.status, observed: baseline.observed ?? null, reason: baseline.reason ?? null, capturedAt: baseline.capturedAt ?? null }, evidence: { total: currentEntries.length, counts }, remainingBoundaries, updatedAt: state.updatedAt }; + if (format === 'json') return summary; + if (format !== 'md' && format !== 'markdown') throw new KernelError('Report format must be json or md.', 'INVALID_REPORT_FORMAT'); + const contractLine = contract ? `${contract.contractId} v${state.currentContractVersion}` : 'not frozen'; + return `# Pinmind run report\n\n- Run: ${runId}\n- Status: ${state.status}\n- Phase: ${state.phase}\n- Contract: ${contractLine}\n- Baseline: ${baseline.status}\n- Evidence: ${counts.pass}/${currentEntries.length} passing\n\n${renderRemainingBoundaries(remainingBoundaries)}\n`; +} + +async function trustworthyPassingEvidence(cwd, entry, criticalTargets = new Set()) { + if (entry.status !== 'pass') return { trustworthy: false, freshness: 'unavailable' }; + if (entry.provenance?.kind === 'manual-attestation') return { trustworthy: !(entry.covers || []).some((id) => criticalTargets.has(id)) && nonEmptyText(entry.procedure) && !nonEmptyText(entry.command), freshness: 'unavailable' }; + if (entry.provenance?.kind !== 'captured-command') return { trustworthy: false, freshness: 'unavailable' }; + const captured = entry.provenance; + if (captured.exitCode !== 0 || !Array.isArray(captured.argv) || captured.argv.length === 0 || !nonEmptyText(captured.capturedAt)) return { trustworthy: false, freshness: 'unavailable' }; + let artifactCurrent = false; + if (nonEmptyText(entry.artifact)) { + const relative = safeRelativePath(entry.artifact, 'artifact'); const expected = captured.artifactHashes?.[relative]; + if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)) return { trustworthy: false, freshness: 'unavailable' }; + try { const artifact = await resolveContainedExisting(cwd, relative, 'artifact', 'file'); if (sha256(await readFile(artifact.absolute)) !== expected) return { trustworthy: false, freshness: 'stale' }; artifactCurrent = true; } catch { return { trustworthy: false, freshness: 'stale' }; } + } + if (!nonEmptyText(entry.artifact) && !nonEmptyText(entry.reference)) return { trustworthy: false, freshness: 'unavailable' }; + const workspace = await assessWorkspaceFingerprint(cwd, captured.workspaceFingerprint); + if (workspace.status === 'stale') return { trustworthy: true, freshness: 'stale', reason: workspace.reason }; + if (workspace.status === 'current' || artifactCurrent) return { trustworthy: true, freshness: 'current' }; + return { trustworthy: true, freshness: 'unavailable', reason: workspace.reason }; +} + +async function finalVerifyUnlocked(cwd, runId) { + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidenceResult = await validateEvidence(cwd, runId); const errors = [...evidenceResult.errors]; + if (state.baselineRequired === true && !(await exists(files.baseline))) errors.push('Run requires an explicit baseline receipt.'); + const entries = evidenceResult.entries; + const contractTargets = [...(contract.acceptanceCriteria || []), ...(contract.invariants || []), ...(contract.preservation || [])]; + const targetsById = new Map(contractTargets.map((item) => [item.id, item])); + const criticalTargets = new Set(contractTargets.filter((item) => item.critical).map((item) => item.id)); + const requiredTargets = new Set([...(contract.invariants || []).map((item) => item.id), ...(contract.preservation || []).map((item) => item.id)]); + for (const obligation of contract.obligations || []) if (obligation.priority === 'must') { + const targets = [...(obligation.acceptance || []), ...(obligation.invariants || [])]; + if (targets.length === 0) errors.push(`${obligation.id} has no acceptance or invariant.`); + for (const target of targets) requiredTargets.add(target); + } + for (const target of requiredTargets) { + const targetDefinition = targetsById.get(target); const requiresFreshness = targetDefinition?.freshnessRequired === true; + for (const evidenceId of targetDefinition?.evidence || []) { + const matches = entries.filter((entry) => entry.contractVersion === state.currentContractVersion && entry.evidenceId === evidenceId && (entry.covers || []).includes(target)); + const assessments = await Promise.all(matches.map((entry) => trustworthyPassingEvidence(cwd, entry, criticalTargets))); + if (!assessments.some((item) => item.trustworthy && (!requiresFreshness || item.freshness === 'current'))) { + const freshness = assessments.find((item) => item.trustworthy)?.freshness; + errors.push(freshness && requiresFreshness ? `${target} has ${freshness} freshness for planned ${evidenceId}.` : `${target} lacks trustworthy passing evidence for planned ${evidenceId}.`); + } + } + } + if (await exists(files.execution)) { + const execution = await readJson(files.execution, 'Execution view'); const executionResult = validateExecution(execution, contract); errors.push(...executionResult.errors); + } + const baseline = await loadBaseline(files); + return { ok: errors.length === 0, verdict: errors.length === 0 ? 'pass' : 'fail', runId, contractVersion: state.currentContractVersion, baseline: { status: baseline.status, observed: baseline.observed ?? null, reason: baseline.reason ?? null }, errors }; +} + +export async function finalVerify(cwd, runId) { return finalVerifyUnlocked(cwd, runId); } + +async function finalizeRunUnlocked(cwd, runId, verification, options = {}) { + if (!verification?.ok) throw new KernelError('Cannot finalize a failed verification.', 'FINAL_GATE_FAILED', verification?.errors || []); + verification = await finalVerifyUnlocked(cwd, runId); + if (!verification.ok) throw new KernelError('Final verification changed before commit.', 'FINAL_GATE_FAILED', verification.errors); + const { files, state } = await verifyRun(cwd, runId); const contract = await currentContract(files, state); const evidence = await loadEvidence(files); + await requireCanonicalActiveRun(cwd, state, runId); + const baseline = await loadBaseline(files); + const currentEntries = evidence.entries.filter((entry) => entry.contractVersion === state.currentContractVersion); + const passed = currentEntries.filter((entry) => entry.status === 'pass').length; + const statuses = ['pass', 'fail', 'uncertain', 'pending-review', 'not-applicable']; + const counts = statuses.map((status) => `- ${status}: ${currentEntries.filter((entry) => entry.status === status).length}`).join('\n'); + const attention = statuses.slice(1).map((status) => `- ${status}: ${currentEntries.filter((entry) => entry.status === status).map((entry) => entry.evidenceId).join(', ') || 'none'}`).join('\n'); + const manual = currentEntries.filter((entry) => entry.provenance?.kind === 'manual-attestation').map((entry) => entry.evidenceId).join(', ') || 'none'; + const captured = currentEntries.filter((entry) => entry.provenance?.kind === 'captured-command').map((entry) => entry.evidenceId).join(', ') || 'none'; + const completionBasis = manual === 'none' ? 'machine-captured evidence' : 'mixed machine-captured and manual/unreplayed evidence'; + const remainingBoundaries = remainingBoundariesForReport(contract); + const report = `# Pinmind final report\n\n- Run: ${runId}\n- Contract: ${contract.contractId} v${state.currentContractVersion}\n- Baseline: ${baseline.status}\n- Evidence: ${passed}/${currentEntries.length} passing\n\n## Evidence status counts\n${counts}\n\n## Current evidence requiring attention\n${attention}\n\n## Evidence provenance\n- machine-captured: ${captured}\n- manual/unreplayed: ${manual}\n\n${renderRemainingBoundaries(remainingBoundaries)}\n\n- MUST evidence coverage: satisfied\n- Completion basis: ${completionBasis}\n- Replay note: stored commands were verified for captured provenance and artifact integrity, but were not replayed during finalization\n`; + const nextState = structuredClone(state); nextState.phase = 'finalize'; nextState.status = 'complete'; nextState.updatedAt = new Date().toISOString(); setStateHash(nextState); + const changes = [{ file: files.final, content: report }, { file: files.state, content: jsonText(nextState) }, { file: files.active, content: null }]; + await executeTransition(cwd, 'finalize', runId, changes, options); + return { ...verification, finalized: true, finalPath: files.final }; +} + +export async function finalizeRun(cwd, runId, verification, options = {}) { + return withWorkspaceLock(cwd, `finalize:${safeRunId(runId)}`, () => finalizeRunUnlocked(cwd, runId, verification, options)); +} + +export async function readInputJson(file) { return readJson(path.resolve(file)); } +const ROUTE_STDIN_MAX_BYTES = 1024 * 1024; +const ROUTE_STDIN_TIMEOUT_MS = 5000; +export async function readRouteInputJson(file, inputStream = process.stdin, options = {}) { + if (file !== '-') return readInputJson(file); + const maxBytes = options.maxBytes ?? ROUTE_STDIN_MAX_BYTES; const timeoutMs = options.timeoutMs ?? ROUTE_STDIN_TIMEOUT_MS; + const timeoutError = new KernelError(`Route standard input did not finish within ${timeoutMs}ms.`, 'ROUTE_INPUT_TIMEOUT'); + let timer; let timedOut = false; + try { + const consume = (async () => { + let input = ''; let bytes = 0; + inputStream.setEncoding('utf8'); + for await (const chunk of inputStream) { + bytes += Buffer.byteLength(chunk, 'utf8'); + if (bytes > maxBytes) { + inputStream.destroy?.(); + throw new KernelError(`Route standard input exceeds ${maxBytes} bytes.`, 'ROUTE_INPUT_TOO_LARGE'); + } + input += chunk; + } + return JSON.parse(input); + })(); + const deadline = new Promise((resolve, reject) => { + timer = setTimeout(() => { timedOut = true; inputStream.destroy?.(); reject(timeoutError); }, timeoutMs); + }); + return await Promise.race([consume, deadline]); + } catch (error) { + if (timedOut) throw timeoutError; + if (error instanceof KernelError) throw error; + throw new KernelError('Route standard input is unreadable or invalid JSON.', 'INVALID_JSON', [error.message]); + } finally { clearTimeout(timer); } +} +export async function readBrief(file) { return readFile(path.resolve(file), 'utf8'); } +export function generateRunId() { return `${new Date().toISOString().slice(0, 10)}-${randomUUID().slice(0, 8)}`; } diff --git a/skills/pinmind/scripts/pinmind.mjs b/skills/pinmind/scripts/pinmind.mjs index 48cd44d..664928b 100644 --- a/skills/pinmind/scripts/pinmind.mjs +++ b/skills/pinmind/scripts/pinmind.mjs @@ -3,7 +3,7 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { KernelError, amendContract, captureBaseline, captureEvidence, finalVerify, finalizeRun, freezeContract, generateRunId, initRun, readBrief, readInputJson, readRouteInputJson, reconcileActiveRuns, recoverTransition, - recordEvidence, recordUnavailableBaseline, recordUsage, reportRun, routeTask, stateResume, stateShow, validateAndSaveExecution, validateContract, validateEvidence, + recordEvidence, recordUnavailableBaseline, reportRun, routeTask, stateResume, stateShow, validateAndSaveExecution, validateContract, validateEvidence, } from './lib/core.mjs'; function parse(argv) { @@ -24,7 +24,7 @@ const COMMAND_FLAGS = new Map([ ['state recover', ['apply', 'expected-sha256', 'expected-lock-sha256']], ['report', ['run', 'format']], ['baseline capture', ['run', 'file', 'cwd', 'timeout-ms']], ['baseline unavailable', ['run', 'file']], ['contract validate', ['run', 'file']], ['contract freeze', ['run', 'file']], ['contract amend', ['run', 'file', 'reason', 'affects', 'authority']], ['execution validate', ['run', 'file']], ['evidence record', ['run', 'file']], ['evidence capture', ['run', 'file', 'cwd', 'timeout-ms']], ['evidence validate', ['run']], - ['usage record', ['run', 'file']], ['final check', ['run']], ['final verify', ['run']], ['finalize', ['run']], + ['final check', ['run']], ['final verify', ['run']], ['finalize', ['run']], ]); function validateInvocation(positionals, flags, commandArgv = []) { const [group, action, ...extra] = positionals; @@ -42,7 +42,7 @@ function validateInvocation(positionals, flags, commandArgv = []) { } function requireFlag(flags, name) { if (typeof flags[name] !== 'string') throw new KernelError(`--${name} is required.`, 'MISSING_ARGUMENT'); return flags[name]; } function print(value) { process.stdout.write(typeof value === 'string' ? `${value.replace(/\n?$/, '\n')}` : `${JSON.stringify(value, null, 2)}\n`); } -const usage = 'Usage: pinmind.mjs init|route|contract validate|contract freeze|contract amend|baseline capture --run RUN --file TEMPLATE [--cwd RELATIVE] -- COMMAND [ARGS...]|baseline unavailable --run RUN --file RECEIPT|execution validate|evidence record|evidence capture --run RUN --file TEMPLATE [--cwd RELATIVE] [--timeout-ms 50..300000] -- COMMAND [ARGS...]|evidence validate|usage record|report|state show|state resume|state reconcile --dry-run|state recover --apply --expected-sha256 HASH [--expected-lock-sha256 HASH]|final check|final verify|finalize'; +const usage = 'Usage: pinmind.mjs init|route|contract validate|contract freeze|contract amend|baseline capture --run RUN --file TEMPLATE [--cwd RELATIVE] -- COMMAND [ARGS...]|baseline unavailable --run RUN --file RECEIPT|execution validate|evidence record|evidence capture --run RUN --file TEMPLATE [--cwd RELATIVE] [--timeout-ms 50..300000] -- COMMAND [ARGS...]|evidence validate|report|state show|state resume|state reconcile --dry-run|state recover --apply --expected-sha256 HASH [--expected-lock-sha256 HASH]|final check|final verify|finalize'; function requirePassing(result, code, message) { if (!result.ok) throw new KernelError(message, code, result.errors); return result; } export async function main(argv = process.argv.slice(2), cwd = process.cwd()) { @@ -79,7 +79,6 @@ export async function main(argv = process.argv.slice(2), cwd = process.cwd()) { if (group === 'evidence' && action === 'record') return recordEvidence(cwd, run, await readInputJson(requireFlag(flags, 'file'))); if (group === 'evidence' && action === 'capture') return captureEvidence(cwd, run, await readInputJson(requireFlag(flags, 'file')), commandArgv, typeof flags.cwd === 'string' ? flags.cwd : '.', { timeoutMs: flags['timeout-ms'] === undefined ? undefined : Number(flags['timeout-ms']) }); if (group === 'evidence' && action === 'validate') return requirePassing(await validateEvidence(cwd, run), 'EVIDENCE_GATE_FAILED', 'Evidence validation gate failed.'); - if (group === 'usage' && action === 'record') return recordUsage(cwd, run, await readInputJson(requireFlag(flags, 'file'))); if (group === 'final' && action === 'check') return finalVerify(cwd, run); if (group === 'final' && action === 'verify') return finalizeRun(cwd, run, requirePassing(await finalVerify(cwd, run), 'FINAL_GATE_FAILED', 'Final verification gate failed.')); if (group === 'finalize' && action === undefined) return finalizeRun(cwd, run, requirePassing(await finalVerify(cwd, run), 'FINAL_GATE_FAILED', 'Final verification gate failed.')); diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index e01ed2b..57fc04a 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -10,7 +10,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import { KernelError, amendContract, captureBaseline, captureEvidence, finalVerify, finalizeRun, freezeContract, hashWithout, initRun, loadState, reconcileActiveRuns, recoverTransition, recordEvidence, recordUnavailableBaseline, redact, redactArgv, redactValue, - readRouteInputJson, recordUsage, reportRun, routeTask, safeRelativePath, stateResume, stateShow, validateAndSaveExecution, validateContract, validateEvidence, + readRouteInputJson, reportRun, routeTask, safeRelativePath, stateResume, stateShow, validateAndSaveExecution, validateContract, validateEvidence, } from '../skills/pinmind/scripts/lib/core.mjs'; import { main } from '../skills/pinmind/scripts/pinmind.mjs'; @@ -153,7 +153,7 @@ test('persistent state rejects symlinks at every root and run entry before touch const entries = [ ['.pinmind/active.json', 'file'], ['.pinmind/writer.lock', 'file'], ['.pinmind/runs/run-one/brief.md', 'file'], ['.pinmind/runs/run-one/state.json', 'file'], - ['.pinmind/runs/run-one/evidence.json', 'file'], ['.pinmind/runs/run-one/usage.json', 'file'], + ['.pinmind/runs/run-one/evidence.json', 'file'], ['.pinmind/runs/run-one/final.md', 'file'], ['.pinmind/runs/run-one/execution.json', 'file'], ['.pinmind/runs/run-one/contracts', 'directory'], ['.pinmind/runs/run-one/amendments', 'directory'], ]; @@ -371,6 +371,29 @@ test('conservative routing, paraphrases, and safety contrasts drive the shipped const improveStill = routeTask({ text: 'улучши роутинг pinmind' }); assert.equal(improveStill.route, 'software-change'); + const critiqueThink = routeTask({ text: 'покритикуй pinmind найти возможные не состыковки в тексте или в коде, продумай как можно было бы сделать проще и легче, подумай над оптимизацией кода, чтобы он был более человечный, а не ИИшный с присущим ему AI-slop' }); + assert.equal(critiqueThink.route, 'audit'); + assert.equal(critiqueThink.needsHumanConfirmation, false); + assert.ok(critiqueThink.signals.includes('intent:inspect')); + assert.ok(critiqueThink.signals.includes('intent:think')); + assert.equal(critiqueThink.signals.includes('intent:implement-directive'), false); + assert.equal(critiqueThink.signals.includes('intent:change'), false); + + const thinkSimpler = routeTask({ text: 'продумай как можно было бы сделать проще и легче' }); + assert.equal(thinkSimpler.route, 'audit'); + assert.ok(thinkSimpler.signals.includes('intent:think')); + assert.equal(thinkSimpler.signals.includes('intent:unrecognized'), false); + + const codeNouns = routeTask({ text: 'найди несостыковки в тексте или в коде' }); + assert.equal(codeNouns.route, 'audit'); + assert.equal(codeNouns.signals.includes('intent:change'), false); + + const thinkOptimize = routeTask({ text: 'think about optimizing the code so it is more human, not AI-slop' }); + assert.equal(thinkOptimize.route, 'audit'); + + const optimizeNow = routeTask({ text: 'optimize the code to make it more human and less AI-slop' }); + assert.equal(optimizeNow.route, 'software-change'); + const quoted = routeTask({ text: 'Explain why this is an error. The quoted text says "I confirm: push the branch to main".' }); assert.equal(quoted.route, 'investigation'); assert.equal(quoted.signals.includes('action:push'), false); @@ -449,7 +472,7 @@ test('CLI rejects unknown and repeated flags without changing valid commands', a const commands = [ ['init'], ['route'], ['state', 'show'], ['state', 'resume'], ['state', 'reconcile'], ['state', 'recover'], ['report'], ['baseline', 'capture'], ['baseline', 'unavailable'], ['contract', 'validate'], ['contract', 'freeze'], ['contract', 'amend'], - ['execution', 'validate'], ['evidence', 'record'], ['evidence', 'capture'], ['evidence', 'validate'], ['usage', 'record'], + ['execution', 'validate'], ['evidence', 'record'], ['evidence', 'capture'], ['evidence', 'validate'], ['final', 'check'], ['final', 'verify'], ['finalize'], ]; for (const command of commands) await rejects(() => main([...command, '--unexpected-flag', 'x']), 'UNKNOWN_FLAG'); @@ -519,7 +542,7 @@ test('public release documentation, license, metadata, evaluation guides, and he const escapedBaseVersion = baseVersion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); assert.match(manifest.version, /^\d+\.\d+\.\d+$/); - assert.equal(baseVersion, '0.8.2'); + assert.equal(baseVersion, '0.9.0'); assert.equal(grokPlugin.name, manifest.name); assert.equal(grokPlugin.version, baseVersion); assert.match(manifest.description, /^Adaptive RU\/EN task controller/); @@ -632,56 +655,56 @@ test('release installer commits a verified source and serializes concurrent swap releaseLock(); await held; await assert.rejects(access(installLock)); }); -test('usage defaults to unavailable and reports exact observed totals without estimates', async () => { +test('new runs do not create token-usage receipts and reports omit them', async () => { const cwd = await frozenRun(); - const initial = await reportRun(cwd, 'run-one', 'json'); assert.equal(initial.tokenUsage.status, 'unavailable'); assert.equal(initial.tokenUsage.totalTokens, null); assert.match(initial.tokenUsage.reason, /did not expose|not recorded/i); assert.deepEqual(initial.remainingBoundaries, { assumptions: ['Host rendering requires observation.'], outOfScope: ['Universal directory publication.'] }); - await recordUsage(cwd, 'run-one', { status: 'unavailable', source: 'host-unavailable', scope: 'task', capturedAt: '2026-08-16T17:59:00.000Z', reason: `Authorization=demo-credential-value ${syntheticProviderToken}` }); const unavailable = await reportRun(cwd, 'run-one', 'json'); assert.equal(JSON.stringify(unavailable).includes('demo-credential-value'), false); assert.equal(JSON.stringify(unavailable).includes(syntheticProviderToken), false); - const observed = await recordUsage(cwd, 'run-one', { status: 'actual', source: 'codex-sdk', scope: 'task', model: 'gpt-5.6', inputTokens: 1200, cachedInputTokens: 400, outputTokens: 300, reasoningOutputTokens: 50, capturedAt: '2026-08-16T18:00:00.000Z', reference: 'turn-123' }); - assert.equal(observed.totalTokens, 1500); assert.equal(observed.reference, 'turn-123'); - const report = await reportRun(cwd, 'run-one', 'json'); assert.equal(report.tokenUsage.totalTokens, 1500); assert.equal(report.tokenUsage.inputTokens, 1200); assert.equal(report.tokenUsage.outputTokens, 300); assert.equal(report.tokenUsage.cachedInputTokens, 400); assert.equal(report.tokenUsage.reasoningOutputTokens, 50); assert.equal('reference' in report.tokenUsage, false); - const markdown = await reportRun(cwd, 'run-one', 'md'); assert.match(markdown, /Total: 1,500/); assert.match(markdown, /Source: codex-sdk/); assert.match(markdown, /## Remaining boundaries/); assert.match(markdown, /Host rendering requires observation/); assert.match(markdown, /Universal directory publication/); assert.doesNotMatch(markdown, /saved|saving|сэконом/iu); + await assert.rejects(access(path.join(cwd, '.pinmind/runs/run-one/usage.json'))); + const json = await reportRun(cwd, 'run-one', 'json'); + assert.equal('tokenUsage' in json, false); + assert.deepEqual(json.remainingBoundaries, { assumptions: ['Host rendering requires observation.'], outOfScope: ['Universal directory publication.'] }); + const markdown = await reportRun(cwd, 'run-one', 'md'); + assert.match(markdown, /## Remaining boundaries/); + assert.match(markdown, /Host rendering requires observation/); + assert.match(markdown, /Universal directory publication/); + assert.doesNotMatch(markdown, /Token usage|tokenUsage|usage record/i); + assert.doesNotMatch(markdown, /saved|saving|сэконом/iu); }); -test('usage rejects malformed metadata or inconsistent counts and detects accidental receipt corruption', async () => { - const cwd = await frozenRun(); const base = { status: 'actual', source: 'app-server', scope: 'task', inputTokens: 100, outputTokens: 20, capturedAt: '2026-08-16T18:00:00.000Z' }; - await rejects(() => recordUsage(cwd, 'run-one', { ...base, inputTokens: -1 }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, totalTokens: 999 }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, cachedInputTokens: 101 }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, source: 'host-unavailable' }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, format: 2 }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, capturedAt: '1' }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, model: 'gpt-5.6\n- injected: true' }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, reference: 'Authorization=demo-credential-value' }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, reference: syntheticProviderToken }), 'INVALID_USAGE'); - await rejects(() => recordUsage(cwd, 'run-one', { ...base, reference: syntheticJwt }), 'INVALID_USAGE'); - await recordUsage(cwd, 'run-one', base); const file = path.join(cwd, '.pinmind/runs/run-one/usage.json'); const receipt = JSON.parse(await readFile(file, 'utf8')); receipt.outputTokens = 21; await writeFile(file, JSON.stringify(receipt)); - await rejects(() => reportRun(cwd, 'run-one', 'json'), 'CORRUPT_USAGE'); +test('usage record is not a kernel command', async () => { + const cwd = await frozenRun(); + await rejects(() => main(['usage', 'record', '--run', 'run-one', '--file', 'usage.json'], cwd), 'USAGE'); + assert.equal('recordUsage' in await import('../skills/pinmind/scripts/lib/core.mjs'), false); }); -test('report is read-only and CLI records authoritative usage', async () => { - const cwd = await frozenRun(); const usageFile = path.join(cwd, 'usage-input.json'); await writeFile(usageFile, JSON.stringify({ status: 'actual', source: 'codex-exec-json', scope: 'task', inputTokens: 77, outputTokens: 23, capturedAt: '2026-08-16T18:00:00.000Z' })); - const recorded = await main(['usage', 'record', '--run', 'run-one', '--file', usageFile], cwd); assert.equal(recorded.totalTokens, 100); - const run = path.join(cwd, '.pinmind/runs/run-one'); const canonical = ['brief.md', 'state.json', 'evidence.json', 'usage.json', 'contracts/contract-v001.json']; const before = await Promise.all(canonical.map((file) => readFile(path.join(run, file), 'utf8'))); const markdown = await main(['report', '--run', 'run-one', '--format', 'md'], cwd); const json = await main(['report', '--run', 'run-one', '--format', 'json'], cwd); const after = await Promise.all(canonical.map((file) => readFile(path.join(run, file), 'utf8'))); - assert.deepEqual(before, after); await assert.rejects(readFile(path.join(run, 'final.md'), 'utf8')); assert.match(markdown, /Total: 100/); assert.equal(json.tokenUsage.totalTokens, 100); +test('report is read-only and does not mention token usage', async () => { + const cwd = await frozenRun(); + const run = path.join(cwd, '.pinmind/runs/run-one'); + const canonical = ['brief.md', 'state.json', 'evidence.json', 'contracts/contract-v001.json']; + const before = await Promise.all(canonical.map((file) => readFile(path.join(run, file), 'utf8'))); + const markdown = await main(['report', '--run', 'run-one', '--format', 'md'], cwd); + const json = await main(['report', '--run', 'run-one', '--format', 'json'], cwd); + const after = await Promise.all(canonical.map((file) => readFile(path.join(run, file), 'utf8'))); + assert.deepEqual(before, after); + await assert.rejects(readFile(path.join(run, 'final.md'), 'utf8')); + assert.doesNotMatch(markdown, /Token usage/i); + assert.equal('tokenUsage' in json, false); }); -test('skill and plugin surface keep token accounting out of the controller', async () => { +test('skill and plugin surface have no token-accounting files or instructions', async () => { const skill = await readFile(fileURLToPath(new URL('../skills/pinmind/SKILL.md', import.meta.url)), 'utf8'); - const tokenUsage = await readFile(fileURLToPath(new URL('../skills/pinmind/references/token-usage.md', import.meta.url)), 'utf8'); const verification = await readFile(fileURLToPath(new URL('../skills/pinmind/references/verification.md', import.meta.url)), 'utf8'); const loop = await readFile(fileURLToPath(new URL('../skills/pinmind/references/loop.md', import.meta.url)), 'utf8'); const design = await readFile(fileURLToPath(new URL('../skills/pinmind/references/design-decisions.md', import.meta.url)), 'utf8'); const routeRef = await readFile(fileURLToPath(new URL('../skills/pinmind/references/route.md', import.meta.url)), 'utf8'); + const contractRef = await readFile(fileURLToPath(new URL('../skills/pinmind/references/contract.md', import.meta.url)), 'utf8'); + const cliRef = await readFile(fileURLToPath(new URL('../skills/pinmind/references/kernel-cli.md', import.meta.url)), 'utf8'); const simple = routeTask({ kind: 'simple', text: 'Привет' }); assert.equal(simple.route, 'simple'); - assert.match(skill, /Do not report token usage|не сообщай расход токенов|out of the skill/iu); - assert.doesNotMatch(skill, /Read \[token-usage\.md\]/); - assert.doesNotMatch(skill, /Token usage:/); - assert.match(tokenUsage, /out of the skill and plugin surface/i); - assert.doesNotMatch(tokenUsage, /must contain one `Token usage` line/i); - assert.match(verification, /out of the skill surface/i); - assert.doesNotMatch(verification, /Token usage: unavailable/i); - assert.match(loop, /out of the skill/i); - assert.match(design, /out of the skill and plugin surface/i); + await assert.rejects(access(fileURLToPath(new URL('../skills/pinmind/references/token-usage.md', import.meta.url)))); + assert.doesNotMatch(skill, /token usage|token-usage|token-accounting/i); + assert.doesNotMatch(verification, /token usage|token-usage/i); + assert.doesNotMatch(loop, /token usage|token-usage/i); + assert.doesNotMatch(design, /token usage|token-usage|token accounting/i); + assert.doesNotMatch(contractRef, /usage\.json|token usage/i); + assert.doesNotMatch(cliRef, /usage record|token-usage|token usage/i); assert.match(routeRef, /Fix a race condition.+\bsoftware-change\b/i); assert.doesNotMatch(routeRef, /Fix a race condition.+\binvestigation then software-change\b/i); assert.match(skill, /effect:external-side-effect/); @@ -742,12 +765,11 @@ test('final check is read-only, final verify stays compatible, and resume stays for (const [id, target] of [['EV-001', 'AC-001'], ['EV-002', 'INV-001'], ['EV-003', 'PRES-001'], ['EV-004', 'AC-002']]) { const file = path.join(cwd, `${id}.json`); await writeFile(file, JSON.stringify(evidence(id, 1, target, id === 'EV-004' ? { status: 'uncertain' } : {}))); await main(['evidence', 'record', '--run', 'first-run', '--file', file], cwd); } const canonical = ['.pinmind/active.json', '.pinmind/runs/first-run/state.json', '.pinmind/runs/first-run/evidence.json']; const beforeCheck = await Promise.all(canonical.map((file) => readFile(path.join(cwd, file), 'utf8'))); const firstCheck = await main(['final', 'check', '--run', 'first-run'], cwd); const secondCheck = await main(['final', 'check', '--run', 'first-run'], cwd); assert.equal(firstCheck.verdict, 'pass'); assert.deepEqual(secondCheck, firstCheck); assert.deepEqual(await Promise.all(canonical.map((file) => readFile(path.join(cwd, file), 'utf8'))), beforeCheck); await assert.rejects(access(path.join(cwd, '.pinmind/runs/first-run/final.md'))); - const final = await main(['final', 'verify', '--run', 'first-run'], cwd); const finalText = await readFile(final.finalPath, 'utf8'); assert.equal(final.finalized, true); assert.match(finalText, /- pass: 3/); assert.match(finalText, /- uncertain: EV-004/); assert.match(finalText, /manual\/unreplayed: EV-001, EV-002, EV-003, EV-004/); assert.match(finalText, /## Remaining boundaries/); assert.match(finalText, /Host rendering requires observation/); assert.match(finalText, /Universal directory publication/); assert.match(finalText, /MUST evidence coverage: satisfied/); assert.match(finalText, /## Token usage/); assert.match(finalText, /Status: unavailable/); assert.doesNotMatch(finalText, /saved|saving|сэконом/iu); assert.doesNotMatch(finalText, /MUST verdict: pass/); + const final = await main(['final', 'verify', '--run', 'first-run'], cwd); const finalText = await readFile(final.finalPath, 'utf8'); assert.equal(final.finalized, true); assert.match(finalText, /- pass: 3/); assert.match(finalText, /- uncertain: EV-004/); assert.match(finalText, /manual\/unreplayed: EV-001, EV-002, EV-003, EV-004/); assert.match(finalText, /## Remaining boundaries/); assert.match(finalText, /Host rendering requires observation/); assert.match(finalText, /Universal directory publication/); assert.match(finalText, /MUST evidence coverage: satisfied/); assert.doesNotMatch(finalText, /## Token usage/); assert.doesNotMatch(finalText, /saved|saving|сэконом/iu); assert.doesNotMatch(finalText, /MUST verdict: pass/); await rejects(() => stateResume(cwd), 'NO_ACTIVE_RUN'); assert.equal((await stateShow(cwd, 'first-run')).status, 'complete'); await rejects(() => stateResume(cwd, 'first-run'), 'RUN_COMPLETE'); await rejects(() => recordEvidence(cwd, 'first-run', evidence('EV-001', 1, 'AC-001')), 'RUN_COMPLETE'); await rejects(() => amendContract(cwd, 'first-run', contract(2, true), 'Clarification.', ['INTENT'], 'approved'), 'RUN_COMPLETE'); await rejects(() => validateAndSaveExecution(cwd, 'first-run', { units: [{ unitId: 'WU-001', obligations: ['REQ-001'], criteria: [], zone: ['src'] }] }), 'RUN_COMPLETE'); - const postTurn = await recordUsage(cwd, 'first-run', { status: 'actual', source: 'codex-sdk', scope: 'task', inputTokens: 10, outputTokens: 5, capturedAt: '2026-08-16T18:00:00.000Z' }); assert.equal(postTurn.totalTokens, 15); await initRun(cwd, 'second-run', 'User asked for behavior.'); }); @@ -839,7 +861,7 @@ test('transition recovery survives 100 seeded interruptions for every bounded li await seededTrials(100, 10, async (seed) => { const cwd = await workspace(); - await recoverInjectedTransition(cwd, (options) => initRun(cwd, 'run-one', 'User asked for behavior.', options), seed % 6); + await recoverInjectedTransition(cwd, (options) => initRun(cwd, 'run-one', 'User asked for behavior.', options), seed % 5); const state = (await loadState(cwd, 'run-one')).state; assert.equal(state.status, 'active'); assert.equal((await reconcileActiveRuns(cwd)).classification, 'canonical-active'); }); await seededTrials(100, 10, async (seed) => { @@ -860,10 +882,9 @@ test('transition recovery survives 100 seeded interruptions for every bounded li }); await seededTrials(100, 10, async (seed) => { const cwd = await clonePinmindWorkspace(finalizeDonor); - await unlink(path.join(cwd, '.pinmind/runs/run-one/usage.json')); - await recoverInjectedTransition(cwd, (options) => finalizeRun(cwd, 'run-one', verification, options), seed % 5); + await recoverInjectedTransition(cwd, (options) => finalizeRun(cwd, 'run-one', verification, options), seed % 4); const state = (await loadState(cwd, 'run-one')).state; assert.equal(state.status, 'complete'); assert.equal((await reconcileActiveRuns(cwd)).classification, 'clean-idle'); - await access(path.join(cwd, '.pinmind/runs/run-one/final.md')); await access(path.join(cwd, '.pinmind/runs/run-one/usage.json')); await assert.rejects(access(path.join(cwd, '.pinmind/active.json'))); + await access(path.join(cwd, '.pinmind/runs/run-one/final.md')); await assert.rejects(access(path.join(cwd, '.pinmind/runs/run-one/usage.json'))); await assert.rejects(access(path.join(cwd, '.pinmind/active.json'))); }); }); @@ -955,19 +976,7 @@ test('workspace writer lock serializes contract freeze and fails closed for a de const state = (await loadState(cwd, 'run-one')).state; assert.equal(state.currentContractVersion, 1); assert.equal(Object.keys(state.contractHashes).length, 1); }); -test('workspace writer lock rejects stale concurrent usage and execution replacements', async () => { - const usageCwd = await frozenRun(); - const initialUsage = JSON.parse(await readFile(path.join(usageCwd, '.pinmind/runs/run-one/usage.json'), 'utf8')); - const usageRecords = [ - { status: 'actual', source: 'codex-sdk', scope: 'task', inputTokens: 10, outputTokens: 2, capturedAt: '2026-08-16T18:00:00.000Z' }, - { status: 'actual', source: 'codex-sdk', scope: 'task', inputTokens: 20, outputTokens: 3, capturedAt: '2026-08-16T18:00:01.000Z' }, - ]; - const usageResults = await Promise.allSettled(usageRecords.map((record) => recordUsage(usageCwd, 'run-one', record, { expectedUsageSha256: initialUsage.usageSha256 }))); - assert.equal(usageResults.filter((result) => result.status === 'fulfilled').length, 1); - assert.equal(usageResults.find((result) => result.status === 'rejected')?.reason?.code, 'STALE_USAGE'); - const storedUsage = JSON.parse(await readFile(path.join(usageCwd, '.pinmind/runs/run-one/usage.json'), 'utf8')); - assert.ok([12, 23].includes(storedUsage.totalTokens)); - +test('workspace writer lock rejects stale concurrent execution replacements', async () => { const executionCwd = await frozenRun(); const executions = [ { units: [{ unitId: 'WU-001', obligations: ['REQ-001'], criteria: [], zone: ['src/a'] }] }, diff --git a/tests/release-verification.test.mjs b/tests/release-verification.test.mjs index decba08..f98ade8 100644 --- a/tests/release-verification.test.mjs +++ b/tests/release-verification.test.mjs @@ -31,7 +31,7 @@ test('canonical release manifest, workflow, plugin, and skill metadata validate' assert.equal(result.frozenInputs, 6); assert.equal(result.commands, EXPECTED_COMMANDS.length); assert.equal(result.inventory.testFiles.length, 5); - assert.equal(result.inventory.fixtureCases.routes, 447); + assert.equal(result.inventory.fixtureCases.routes, 455); assert.deepEqual(await validateWorkflow(root), { ok: true, nodeVersion: '24.19.0' }); const plugin = await validatePluginAndSkills(root); assert.equal(plugin.ok, true); assert.deepEqual(plugin.skills, ['pinmind']); });