From 22ce931f4222f2743ea29e52c47608cf4979417a Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 20:44:54 +0700 Subject: [PATCH 01/17] Add oxlint baseline validation --- .epic-loop/epics/set-up/decision-log.md | 5 +- ...=> linting-and-skill-validation-policy.md} | 38 +-- .../epics/set-up/docs/problem-framing.md | 13 +- .epic-loop/epics/set-up/implementation-log.md | 10 + .epic-loop/epics/set-up/risk-register.md | 3 +- .epic-loop/epics/set-up/state-of-epic.md | 15 +- .epic-loop/epics/set-up/tracker.md | 56 ++--- .oxlintrc.json | 51 ++++ package.json | 8 +- .../skills/epic-loop/scripts/lib/epics.mjs | 2 +- .../skills/epic-loop/scripts/lib/hooks.mjs | 1 - .../skills/epic-loop/scripts/lib/roadmap.mjs | 2 +- pnpm-lock.yaml | 224 +++++++++++++++++- scripts/self-update-skill.mjs | 2 +- tests/unit/hook-contracts.test.mjs | 2 +- 15 files changed, 338 insertions(+), 94 deletions(-) rename .epic-loop/epics/set-up/docs/{linting-and-language-policy.md => linting-and-skill-validation-policy.md} (87%) create mode 100644 .oxlintrc.json diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md index 117cfad..25cfc58 100644 --- a/.epic-loop/epics/set-up/decision-log.md +++ b/.epic-loop/epics/set-up/decision-log.md @@ -3,10 +3,11 @@ ## Active Decisions - 2026-07-07: Split skill repository checks into two implementation phases. Mechanical skill package invariants should be enforced by deterministic scripts, while semantic skill quality review should run through a headless `codex exec --ephemeral` workflow wrapped by a repository script that requires structured JSON output in an ignored directory and validates that schema before reporting findings. +- 2026-07-07: Accept current oxlint `max-lines` failures in `plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs` and `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs` as known baseline debt for now. Do not relax the targeted limits; continue implementation and track a Phase 1 refactor task to split those modules before final phase verification. +- 2026-07-07: Remove the repository English-only lexical validation phase. A real read-only audit found no Cyrillic, suspicious non-Latin scripts, or obvious non-English prose in the repository; remaining non-ASCII findings are punctuation and contract strings, so strict ASCII cleanup is not part of this epic. - 2026-07-07: Add targeted oxlint maintainability limits: source files may contain up to 600 lines, test files may contain up to 900 lines, and individual functions may contain up to 150 lines. Configure these limits to skip blank lines and comment-only lines. -- 2026-07-06: Use oxlint for JavaScript/ESM linting, Oxfmt (`oxfmt`) for formatting, and a small repository-owned Node.js script for the English-only lexical policy. The custom script should cover repo-specific language policy with explicit include/ignore patterns and allowlists. - 2026-07-06: Treat `pnpm run validate` as the durable aggregate validation entry point. New checks should be integrated there unless implementation finds a stronger existing convention. ## Historical Decisions -- None recorded yet. +- Superseded 2026-07-07: Use oxlint for JavaScript/ESM linting, Oxfmt (`oxfmt`) for formatting, and a small repository-owned Node.js script for the English-only lexical policy. The custom script should cover repo-specific language policy with explicit include/ignore patterns and allowlists. diff --git a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md b/.epic-loop/epics/set-up/docs/linting-and-skill-validation-policy.md similarity index 87% rename from .epic-loop/epics/set-up/docs/linting-and-language-policy.md rename to .epic-loop/epics/set-up/docs/linting-and-skill-validation-policy.md index 5f144dc..96e82f1 100644 --- a/.epic-loop/epics/set-up/docs/linting-and-language-policy.md +++ b/.epic-loop/epics/set-up/docs/linting-and-skill-validation-policy.md @@ -1,4 +1,4 @@ -# Linting And Language Policy +# Linting And Skill Validation Policy ## Baseline @@ -10,15 +10,16 @@ - `pnpm run validate:epic-loop` - platform doctor scripts for Codex and Claude Code. - Current `validate` performs `node --check` over selected scripts and runs `scripts/validate-epic-loop-package.mjs`. -- No oxlint, Oxfmt, EditorConfig, or spelling/language config is currently present. +- No oxlint, Oxfmt, or EditorConfig is currently present. ## Policy Direction -Use standard tooling for standard concerns and a small repository-owned script for project-specific language policy: +Use standard tooling for standard concerns and small repository-owned scripts for project-specific skill package validation: - oxlint for JavaScript/ESM source, tests, scripts, and package code. - Oxfmt (`oxfmt`) for formatting supported text/code/doc files. -- A custom Node.js validation script for English-only lexical usage, because the repository rule is product-specific and should be deterministic. +- Deterministic Node.js validation for mechanical skill package invariants. +- A separate AI-assisted review command for semantic skill quality signals. ## Proposed Oxlint Profile @@ -52,33 +53,6 @@ Observed current repository findings with oxlint 1.73.0: - `perf` adds a small number of warning candidates, including `no-await-in-loop`, `prefer-set-has`, and `oxc/no-map-spread`; these are better handled after the baseline passes. - `style` and `restriction` generate large noisy reports and should not block the initial linting setup. -## English-Only Check - -The check should inspect committed project text that humans maintain, while ignoring runtime/debug/generated surfaces. - -Candidate included surfaces: - -- `README.md`, `CHANGELOG.md`, `CLAUDE.md`, `AGENTS.md` -- `package.json` files -- `scripts/**/*.mjs` -- `tests/**/*.mjs` -- `packages/cli/src/**/*.mjs` -- `plugins/epic-loop/skills/epic-loop/**/*.md` -- `plugins/epic-loop/skills/epic-loop/**/*.mjs` -- `plugins/epic-loop/skills/epic-loop/**/*.yaml` - -Candidate ignored surfaces: - -- `node_modules/` -- lockfiles -- `.git/` -- `.epic-loop/epics/*/.runtime/` -- `.epic-loop/.runtime/` -- generated package output -- hook captures, prompt logs, progress logs, and transcript/debug traces - -The script should report filename, line, column, and offending token or short excerpt. It should support a small allowlist for legitimate non-dictionary or non-English tokens, but the default direction is to remove Russian or other non-English prose from committed source and docs. - ## Validation Contract `pnpm run validate` should become the single command that proves the repository is publishable: @@ -88,7 +62,7 @@ The script should report filename, line, column, and offending token or short ex - unit tests either run directly or remain available through a documented validation script - oxlint check runs - Oxfmt check runs -- English-only lexical check runs +- deterministic skill package checks run Formatting write commands should be separate from check commands so validation remains non-mutating. diff --git a/.epic-loop/epics/set-up/docs/problem-framing.md b/.epic-loop/epics/set-up/docs/problem-framing.md index cb4218d..437cc7b 100644 --- a/.epic-loop/epics/set-up/docs/problem-framing.md +++ b/.epic-loop/epics/set-up/docs/problem-framing.md @@ -2,21 +2,22 @@ ## Problem -Set up repository linting: add a linter and Oxfmt, plus additional repository checks such as enforcing English-only lexical usage in committed project content. +Set up repository linting, formatting, deterministic skill package validation, and AI-assisted skill quality review. ## Desired Outcome -- The repository has a single deterministic validation entry point that covers syntax checks, unit tests, linting, formatting, package validation, and repository-specific content checks. +- The repository has a single deterministic validation entry point that covers syntax checks, unit tests, linting, formatting, and package validation. - JavaScript/ESM source, tests, scripts, plugin skill files, and package metadata are checked by oxlint where supported. - Formatting is handled by Oxfmt and can be checked in CI/local validation without changing files unexpectedly. -- A dedicated lexical policy check fails when committed project content contains non-English prose or identifiers outside documented allowlists. +- Skill package invariants are checked by deterministic validation, while semantic skill quality review is available through an explicit AI-assisted command. ## Scope - Root package scripts and pnpm workflow. - oxlint configuration for the current ESM codebase. - Oxfmt configuration and check/write scripts. -- Repository-specific language policy tooling for English-only lexical usage. +- Repository-specific skill package validation. +- AI-assisted skill quality review with structured output. - Validation integration through `pnpm run validate`. - Focused tests for custom validation logic. @@ -24,7 +25,7 @@ Set up repository linting: add a linter and Oxfmt, plus additional repository ch - Rewriting large documentation sections for tone or style beyond what is needed to satisfy the checks. - Adding sample application code. -- Enforcing English inside runtime/debug artifacts, generated lockfiles, vendored dependencies, or files that are intentionally machine generated. +- Enforcing an English-only lexical policy or strict ASCII-only punctuation policy. - Adding CI provider configuration unless implementation discovers an existing CI surface that already calls `pnpm run validate`. ## Constraints @@ -32,10 +33,8 @@ Set up repository linting: add a linter and Oxfmt, plus additional repository ch - This repository publishes and validates the `epic-loop` plugin/skill package; changes should stay inside that product surface. - Prefer small deterministic Node.js scripts for repository-specific checks. - Do not commit local `.epic-loop` runtime/debug artifacts, hook captures, prompt logs, or target-project epic workspaces. -- The language check must have an explicit allowlist path for legitimate names, package identifiers, URLs, paths, code tokens, and technical terms. ## Open Questions -- Which file classes should be checked for English-only lexical usage on the first implementation pass: documentation/prose only, code identifiers/comments too, or every committed text file except explicit ignores? - Should formatting changes be applied immediately across the repository, or should the first implementation only add check scripts and report existing violations? - Should the public plugin package require the same checks under `packages/cli`, or should root validation delegate into that package only where needed? diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 2e4a1ce..3597f50 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -4,3 +4,13 @@ - Created epic workspace for `set-up`. - Initial mode: shaping. + +## 2026-07-07T13:21:01+00:00 - not-closed: oxlint baseline is partially implemented, but pnpm run lint and pnpm run validate fail on existing max-lines violations in plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs and plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs. Tests passed (pnpm run test:unit, 60/60). No commit made because the task is not honestly closed. + +- Task: Phase 1 Task 1 - Add oxlint configuration for the current Node.js ESM repository +- Verdict: not-closed: oxlint baseline is partially implemented, but pnpm run lint and pnpm run validate fail on existing max-lines violations in plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs and plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs. Tests passed (pnpm run test:unit, 60/60). No commit made because the task is not honestly closed. + +## 2026-07-07T13:44:34+00:00 - closed: oxlint baseline added with known baseline debt accepted by user. lint and validate now include oxlint and currently fail only on planned max-lines refactor debt in loop.mjs and hooks.mjs; unit tests pass. Refactor task added before Phase 1 verification. + +- Task: Phase 1 Task 1 - Add oxlint configuration for the current Node.js ESM repository +- Verdict: closed: oxlint baseline added with known baseline debt accepted by user. lint and validate now include oxlint and currently fail only on planned max-lines refactor debt in loop.mjs and hooks.mjs; unit tests pass. Refactor task added before Phase 1 verification. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 0e2db74..3351ccd 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -2,7 +2,6 @@ | Risk | Impact | Mitigation | Status | | --- | --- | --- | --- | -| English-only enforcement is too broad and flags code tokens, names, URLs, generated files, or runtime logs. | Validation becomes noisy and developers bypass it. | Start with explicit include/ignore lists, structured allowlists, and line-level evidence in failures. | open | | Adding Oxfmt formats too much in one task and obscures behavioral changes. | Review becomes harder and task commits lose focus. | Keep configuration/check integration separate from optional repository-wide formatting cleanup. | open | -| oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. | open | +| oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. Current accepted baseline debt: `loop.mjs` and `hooks.mjs` exceed the accepted `max-lines: 600` source limit and are tracked for Phase 1 refactor before phase verification. | mitigation-planned | | AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index e96c6a8..92418f3 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -1,23 +1,24 @@ # State Of Epic -Epic: Linting And English Checks +Epic: Linting And Skill Checks Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 -Current mode: shaping +Current mode: implementation Active phase: Phase 1 - Tooling Baseline -Active task: TBD +Active task: Phase 1 Task 2 - Add Oxfmt configuration and non-mutating format validation ## Current State - Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Oxfmt configuration. -- The roadmap is ready for implementation once the user confirms the implementation loop. -- The central product requirement is adding linting, Oxfmt formatting, deterministic English-only lexical checks, deterministic skill package checks, and AI-assisted semantic skill review. +- Phase 1 Task 1 is closed with accepted baseline debt: oxlint configuration, scripts, dependency, validation integration, and narrow baseline fixes are present; existing `max-lines` failures in `loop.mjs` and `hooks.mjs` are explicitly accepted for now and tracked as a Phase 1 refactor task before phase verification. +- The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. +- The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- None recorded. +- `pnpm run lint` and `pnpm run validate` currently fail until the planned Phase 1 refactor splits `loop.mjs` and `hooks.mjs` below the accepted `max-lines` limit. ## Next Action -- Confirm whether to start implementation in this session. +- Continue with Phase 1 Task 2: add Oxfmt configuration and non-mutating format validation. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index b16a541..35c7db1 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -1,6 +1,6 @@ # Tracker -Epic: Linting And English Checks +Epic: Linting And Skill Checks ## Task Statuses @@ -26,25 +26,31 @@ Epic: Linting And English Checks ### Phase 1: Tooling Baseline -- Phase status: todo +- Phase status: doing -- [ ] Kind: implementation | Status: todo | Add oxlint configuration for the current Node.js ESM repository. +- [x] Kind: implementation | Status: done | Add oxlint configuration for the current Node.js ESM repository. - Outcome: JavaScript source, scripts, tests, and plugin package code are linted consistently with the repository's ESM/Node style. - Surface: `package.json`, oxlint config, scripts/tests/plugin source under root and `packages/cli`. - - Acceptance: A lint script exists, runs without false positives on the intended source set, enforces targeted `max-lines` and `max-lines-per-function` limits, and is included in aggregate validation. - - Docs: `docs/linting-and-language-policy.md`. + - Acceptance: A lint script exists, is included in aggregate validation, enforces targeted `max-lines` and `max-lines-per-function` limits, and reports only accepted baseline `max-lines` debt for `loop.mjs` and `hooks.mjs`, which is tracked as a follow-up refactor task before phase verification. + - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: todo | Add Oxfmt configuration and non-mutating format validation. +- [ ] Kind: implementation | Status: doing | Add Oxfmt configuration and non-mutating format validation. - Outcome: Repository formatting is standardized and can be checked without modifying files during validation. - Surface: `package.json`, Oxfmt config/ignore files, supported source and documentation files. - Acceptance: Format check and format write scripts exist; aggregate validation uses the check script only; ignored runtime/generated paths are explicit. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. + +- [ ] Kind: implementation | Status: todo | Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines. + - Outcome: Existing source files that exceed the accepted oxlint source-file limit are split into smaller modules without changing hook routing or implementation-loop behavior. + - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs`, `plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs`, any extracted helper modules under the same `lib/` boundary, and related unit tests. + - Acceptance: `pnpm run lint` no longer reports `max-lines` errors for `hooks.mjs` or `loop.mjs`; behavior covered by existing hook/loop tests remains unchanged. + - Docs: `docs/linting-and-skill-validation-policy.md`. - [ ] Kind: verification | Status: todo | Verify lint and format tooling through the repository validation path. - Outcome: The first phase tooling is proven through the same command future contributors will run. - Surface: Local pnpm scripts, oxlint, Oxfmt, existing syntax/package validation. - Acceptance: Run `pnpm run validate` and any focused lint/format scripts; evidence includes exit codes and any required follow-up fixes, with no generated runtime artifacts committed. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. ### Phase 2: Deterministic Skill Package Checks @@ -54,19 +60,19 @@ Epic: Linting And English Checks - Outcome: Maintained skill packages fail validation when their file shape, frontmatter, naming, references, or generated-artifact boundaries violate portable skill package rules. - Surface: `scripts/validate-skills.mjs` or `scripts/validate-epic-loop-package.mjs`, `package.json` scripts, skill package files under `plugins/epic-loop/skills`. - Acceptance: The validator checks `SKILL.md` presence, YAML frontmatter, required `name` and `description`, kebab-case name constraints, directory-name match, description length, `SKILL.md` line budget, direct reference links, long-reference table of contents, forward-slash paths, script syntax, and ignored runtime/debug artifact absence. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. - [ ] Kind: implementation | Status: todo | Add focused tests or fixtures for deterministic skill package validation. - Outcome: The deterministic skill validator has regression coverage for accepted package shape and representative failure cases. - Surface: `tests/unit`, validator fixtures/helpers, package validation scripts. - Acceptance: Tests cover valid skill metadata, invalid names, missing descriptions, long entrypoint files, missing table of contents for long references, Windows-style paths, and generated artifact detection. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. - [ ] Kind: verification | Status: todo | Verify deterministic skill package checks through aggregate validation. - Outcome: Skill package mechanical validation is proven as part of the standard repository validation path. - Surface: `pnpm run validate`, focused validator command, unit tests, current plugin skill package. - Acceptance: Run focused validator tests, run the deterministic skill validator, and run `pnpm run validate` successfully; evidence includes exit codes and any required package cleanup. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. ### Phase 3: AI-Assisted Skill Quality Review @@ -76,38 +82,16 @@ Epic: Linting And English Checks - Outcome: Semantic skill quality review can be launched as a normal script command while internally using `codex exec` in non-interactive mode. - Surface: `package.json` scripts, AI review runner script, repo-local review skill or prompt, ignored `.validation-output/skill-review/` output path, JSON schema validation. - Acceptance: `pnpm run review:skills:ai` invokes `codex exec --ephemeral`, requires a structured JSON report, validates the report schema, prints stable findings, and exits non-zero for blocking findings, malformed JSON, missing output, unknown schema versions, or failed Codex execution. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. - [ ] Kind: implementation | Status: todo | Define the AI skill quality review rubric and finding schema. - Outcome: The model-backed review evaluates skill semantics consistently instead of producing free-form prose. - Surface: Review skill or prompt file, JSON schema fixture, review runner tests, skill policy docs. - Acceptance: The rubric covers invocation quality, trigger boundaries, progressive disclosure, task-local reference organization, degree of freedom, script/dependency safety concerns, and actionable recommendations with path and line evidence where possible. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. - [ ] Kind: verification | Status: todo | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. - Acceptance: Prove the runner accepts valid reports, rejects malformed or missing reports, prints findings deterministically, keeps generated artifacts ignored, and documents whether the AI-backed command is excluded from or included in `pnpm run validate`. - - Docs: `docs/linting-and-language-policy.md`. - -### Phase 4: Repository Language Policy - -- Phase status: todo - -- [ ] Kind: implementation | Status: todo | Implement deterministic English-only lexical validation for committed project content. - - Outcome: Validation fails with actionable diagnostics when maintained project files contain non-English prose or disallowed lexical tokens. - - Surface: Small Node.js validation script, package scripts, include/ignore policy, allowlist data if needed. - - Acceptance: The script reports path, line, column, and offending token/excerpt; runtime/debug/generated paths are ignored; legitimate technical tokens have an explicit allowlist path. - - Docs: `docs/linting-and-language-policy.md`. - -- [ ] Kind: implementation | Status: todo | Add focused tests or fixtures for the English-only lexical validator. - - Outcome: The custom repository policy has regression coverage for accepted English text, rejected non-English text, ignores, and allowlisted terms. - - Surface: `tests/unit`, validator script fixtures/helpers, package validation scripts. - - Acceptance: Tests cover pass and fail cases with deterministic assertions and run under the existing Node test runner. - - Docs: `docs/linting-and-language-policy.md`. - -- [ ] Kind: verification | Status: todo | Verify aggregate validation catches language policy violations and accepts the cleaned repository. - - Outcome: The language policy is proven both by focused tests and by aggregate validation. - - Surface: Validator script, test runner, `pnpm run validate`, temporary fixture or controlled failing input. - - Acceptance: Run focused validator tests, prove a controlled non-English sample fails with the expected diagnostic, remove any temporary sample, and run `pnpm run validate` successfully. - - Docs: `docs/linting-and-language-policy.md`. + - Docs: `docs/linting-and-skill-validation-policy.md`. diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..11b841e --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,51 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "node", "promise"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "style": "off", + "restriction": "off", + "pedantic": "off", + "perf": "off", + "nursery": "off" + }, + "rules": { + "no-console": "off", + "no-underscore-dangle": "off", + "node/no-sync": "off", + "unicorn/no-array-sort": "off", + "unicorn/no-process-exit": "off", + "max-lines": [ + "error", + { + "max": 600, + "skipBlankLines": true, + "skipComments": true + } + ], + "max-lines-per-function": [ + "error", + { + "max": 150, + "skipBlankLines": true, + "skipComments": true + } + ] + }, + "overrides": [ + { + "files": ["tests/**/*.mjs"], + "rules": { + "max-lines": [ + "error", + { + "max": 900, + "skipBlankLines": true, + "skipComments": true + } + ] + } + } + ] +} diff --git a/package.json b/package.json index fab9d17..2b9f4a5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "type": "module", "scripts": { "test:unit": "node --test \"tests/unit/**/*.test.mjs\"", - "validate": "for f in scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/lib/*.mjs; do node --check \"$f\" || exit 1; done && node scripts/validate-epic-loop-package.mjs", + "lint": "oxlint scripts plugins/epic-loop/skills/epic-loop/scripts tests packages/cli/src packages/cli/scripts", + "validate": "for f in scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/lib/*.mjs; do node --check \"$f\" || exit 1; done && pnpm run lint && node scripts/validate-epic-loop-package.mjs", "validate:epic-loop": "pnpm run validate", "doctor:epic-loop:codex": "node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex --json", "doctor:epic-loop:claude-code": "node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform claude-code --json", @@ -31,5 +32,8 @@ "automation", "hooks" ], - "packageManager": "pnpm@11.9.0" + "packageManager": "pnpm@11.9.0", + "devDependencies": { + "oxlint": "^1.72.0" + } } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index e0382f6..36ec3f8 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -451,7 +451,7 @@ function writeEpicRuntimeMode(root, slug, mode) { runtime = JSON.parse(fs.readFileSync(runtimePath, "utf8")); } catch (error) { const message = error instanceof Error ? error.message : String(error); - throw new Error(`Cannot read runtime state: ${message}`); + throw new Error(`Cannot read runtime state: ${message}`, { cause: error }); } if (!runtime || typeof runtime !== "object" || Array.isArray(runtime)) { diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index b752c10..eb13e27 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -16,7 +16,6 @@ import { nowIso, platformConfigPath, platformSetupCommand, - readRuntimePlatform, requireRuntimePlatform, readJson, readJsonStrict, diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs index e4aa798..fbbf3d1 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs @@ -349,7 +349,7 @@ function formatTask(task) { } function createPhaseFromHeading(heading, fallbackNumber) { - const match = heading.match(/^Phase\s+(\d+)\s*[:\-]\s*(.+)$/iu); + const match = heading.match(/^Phase\s+(\d+)\s*[:-]\s*(.+)$/iu); const number = match ? Number(match[1]) : fallbackNumber; const title = match ? match[2].trim() : heading.trim(); return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b60ae1..b3e74f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,4 +6,226 @@ settings: importers: - .: {} + .: + devDependencies: + oxlint: + specifier: ^1.72.0 + version: 1.72.0 + +packages: + + '@oxlint/binding-android-arm-eabi@1.72.0': + resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.72.0': + resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.72.0': + resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.72.0': + resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.72.0': + resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.72.0': + resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.72.0': + resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.72.0': + resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.72.0': + resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.72.0': + resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + oxlint@1.72.0: + resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + +snapshots: + + '@oxlint/binding-android-arm-eabi@1.72.0': + optional: true + + '@oxlint/binding-android-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.72.0': + optional: true + + '@oxlint/binding-darwin-x64@1.72.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.72.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.72.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.72.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.72.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.72.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.72.0': + optional: true + + oxlint@1.72.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.72.0 + '@oxlint/binding-android-arm64': 1.72.0 + '@oxlint/binding-darwin-arm64': 1.72.0 + '@oxlint/binding-darwin-x64': 1.72.0 + '@oxlint/binding-freebsd-x64': 1.72.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 + '@oxlint/binding-linux-arm-musleabihf': 1.72.0 + '@oxlint/binding-linux-arm64-gnu': 1.72.0 + '@oxlint/binding-linux-arm64-musl': 1.72.0 + '@oxlint/binding-linux-ppc64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-gnu': 1.72.0 + '@oxlint/binding-linux-riscv64-musl': 1.72.0 + '@oxlint/binding-linux-s390x-gnu': 1.72.0 + '@oxlint/binding-linux-x64-gnu': 1.72.0 + '@oxlint/binding-linux-x64-musl': 1.72.0 + '@oxlint/binding-openharmony-arm64': 1.72.0 + '@oxlint/binding-win32-arm64-msvc': 1.72.0 + '@oxlint/binding-win32-ia32-msvc': 1.72.0 + '@oxlint/binding-win32-x64-msvc': 1.72.0 diff --git a/scripts/self-update-skill.mjs b/scripts/self-update-skill.mjs index 4857405..1b2ad6e 100644 --- a/scripts/self-update-skill.mjs +++ b/scripts/self-update-skill.mjs @@ -35,6 +35,6 @@ async function assertDirectory(dir, label) { throw new Error("not a directory"); } } catch (error) { - throw new Error(`Cannot read ${label} at ${dir}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Cannot read ${label} at ${dir}: ${error instanceof Error ? error.message : String(error)}`, { cause: error }); } } diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 3ae993d..e599d22 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -31,7 +31,7 @@ function writeSessionBinding(root, slug, sessionId) { { ...runtime, implementation_loop: { - ...(runtime.implementation_loop ?? {}), + ...runtime.implementation_loop, driver_session_id: sessionId, }, mode: "implementation", From 18c83bd2671ac29c5d856e3679b6bf328fc3e5de Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 20:52:30 +0700 Subject: [PATCH 02/17] Add Oxfmt format validation --- .epic-loop/epics/set-up/decision-log.md | 1 + .epic-loop/epics/set-up/implementation-log.md | 5 + .epic-loop/epics/set-up/state-of-epic.md | 5 +- .epic-loop/epics/set-up/tracker.md | 6 +- .oxfmtrc.json | 19 ++ package.json | 5 +- packages/cli/scripts/build.mjs | 14 +- packages/cli/src/cli.mjs | 18 +- packages/cli/src/epics.mjs | 33 +-- packages/cli/src/project-root.mjs | 6 +- .../skills/epic-loop/scripts/lib/common.mjs | 30 +-- .../skills/epic-loop/scripts/lib/epics.mjs | 12 +- .../skills/epic-loop/scripts/lib/hooks.mjs | 6 +- .../skills/epic-loop/scripts/lib/loop.mjs | 77 +++--- pnpm-lock.yaml | 225 ++++++++++++++++++ scripts/self-update-skill.mjs | 13 +- tests/unit/cli-contracts.test.mjs | 44 ++-- tests/unit/hook-contracts.test.mjs | 19 +- 18 files changed, 395 insertions(+), 143 deletions(-) create mode 100644 .oxfmtrc.json diff --git a/.epic-loop/epics/set-up/decision-log.md b/.epic-loop/epics/set-up/decision-log.md index 25cfc58..08c7543 100644 --- a/.epic-loop/epics/set-up/decision-log.md +++ b/.epic-loop/epics/set-up/decision-log.md @@ -4,6 +4,7 @@ - 2026-07-07: Split skill repository checks into two implementation phases. Mechanical skill package invariants should be enforced by deterministic scripts, while semantic skill quality review should run through a headless `codex exec --ephemeral` workflow wrapped by a repository script that requires structured JSON output in an ignored directory and validates that schema before reporting findings. - 2026-07-07: Accept current oxlint `max-lines` failures in `plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs` and `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs` as known baseline debt for now. Do not relax the targeted limits; continue implementation and track a Phase 1 refactor task to split those modules before final phase verification. +- 2026-07-07: Scope Oxfmt formatting to JS/MJS and JSON config/source surfaces. Exclude markdown from the formatter command because `oxfmt@0.57.0` rewrote inline-code spacing and template placeholder emphasis unsafely in repository docs/templates during verification. - 2026-07-07: Remove the repository English-only lexical validation phase. A real read-only audit found no Cyrillic, suspicious non-Latin scripts, or obvious non-English prose in the repository; remaining non-ASCII findings are punctuation and contract strings, so strict ASCII cleanup is not part of this epic. - 2026-07-07: Add targeted oxlint maintainability limits: source files may contain up to 600 lines, test files may contain up to 900 lines, and individual functions may contain up to 150 lines. Configure these limits to skip blank lines and comment-only lines. - 2026-07-06: Treat `pnpm run validate` as the durable aggregate validation entry point. New checks should be integrated there unless implementation finds a stronger existing convention. diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 3597f50..f95fac1 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -14,3 +14,8 @@ - Task: Phase 1 Task 1 - Add oxlint configuration for the current Node.js ESM repository - Verdict: closed: oxlint baseline added with known baseline debt accepted by user. lint and validate now include oxlint and currently fail only on planned max-lines refactor debt in loop.mjs and hooks.mjs; unit tests pass. Refactor task added before Phase 1 verification. + +## 2026-07-07T13:51:29+00:00 - closed: added oxfmt dependency, .oxfmtrc.json, format:check/format:write scripts, and validate integration. format:check passes; unit tests pass. pnpm run validate still fails only on accepted oxlint max-lines debt in hooks.mjs and loop.mjs. Markdown formatting excluded because oxfmt@0.57.0 produced unsafe markdown/template churn. + +- Task: Phase 1 Task 2 - Add Oxfmt configuration and non-mutating format validation +- Verdict: closed: added oxfmt dependency, .oxfmtrc.json, format:check/format:write scripts, and validate integration. format:check passes; unit tests pass. pnpm run validate still fails only on accepted oxlint max-lines debt in hooks.mjs and loop.mjs. Markdown formatting excluded because oxfmt@0.57.0 produced unsafe markdown/template churn. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 92418f3..5ca1c1f 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,12 +5,13 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 1 - Tooling Baseline -Active task: Phase 1 Task 2 - Add Oxfmt configuration and non-mutating format validation +Active task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines ## Current State - Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Oxfmt configuration. - Phase 1 Task 1 is closed with accepted baseline debt: oxlint configuration, scripts, dependency, validation integration, and narrow baseline fixes are present; existing `max-lines` failures in `loop.mjs` and `hooks.mjs` are explicitly accepted for now and tracked as a Phase 1 refactor task before phase verification. +- Phase 1 Task 2 is closed: Oxfmt config and check/write scripts are present; format check is non-mutating and included in validation; markdown formatting is intentionally excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn during verification. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -21,4 +22,4 @@ Active task: Phase 1 Task 2 - Add Oxfmt configuration and non-mutating format va ## Next Action -- Continue with Phase 1 Task 2: add Oxfmt configuration and non-mutating format validation. +- Continue with Phase 1 Task 3: refactor oversized `hooks.mjs` and `loop.mjs` modules to satisfy oxlint `max-lines`. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 35c7db1..a730d5a 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -34,13 +34,13 @@ Epic: Linting And Skill Checks - Acceptance: A lint script exists, is included in aggregate validation, enforces targeted `max-lines` and `max-lines-per-function` limits, and reports only accepted baseline `max-lines` debt for `loop.mjs` and `hooks.mjs`, which is tracked as a follow-up refactor task before phase verification. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: doing | Add Oxfmt configuration and non-mutating format validation. +- [x] Kind: implementation | Status: done | Add Oxfmt configuration and non-mutating format validation. - Outcome: Repository formatting is standardized and can be checked without modifying files during validation. - Surface: `package.json`, Oxfmt config/ignore files, supported source and documentation files. - - Acceptance: Format check and format write scripts exist; aggregate validation uses the check script only; ignored runtime/generated paths are explicit. + - Acceptance: Format check and format write scripts exist; aggregate validation uses the check script only; ignored runtime/generated paths are explicit; markdown is excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: todo | Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines. +- [ ] Kind: implementation | Status: doing | Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines. - Outcome: Existing source files that exceed the accepted oxlint source-file limit are split into smaller modules without changing hook routing or implementation-loop behavior. - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs`, `plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs`, any extracted helper modules under the same `lib/` boundary, and related unit tests. - Acceptance: `pnpm run lint` no longer reports `max-lines` errors for `hooks.mjs` or `loop.mjs`; behavior covered by existing hook/loop tests remains unchanged. diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..8ac13d2 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [ + "node_modules/**", + "dist/**", + "build/**", + "coverage/**", + ".cache/**", + "temp/**", + ".claude/**", + ".codex/**", + ".epic-loop/**", + "**/.runtime/**", + ".epic-loop/.runtime/**", + ".epic-loop/epics/*/.runtime/**" + ], + "printWidth": 180, + "sortPackageJson": false +} diff --git a/package.json b/package.json index 2b9f4a5..31758a8 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "scripts": { "test:unit": "node --test \"tests/unit/**/*.test.mjs\"", "lint": "oxlint scripts plugins/epic-loop/skills/epic-loop/scripts tests packages/cli/src packages/cli/scripts", - "validate": "for f in scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/lib/*.mjs; do node --check \"$f\" || exit 1; done && pnpm run lint && node scripts/validate-epic-loop-package.mjs", + "format:check": "oxfmt --check package.json .oxlintrc.json .oxfmtrc.json \"scripts/*.mjs\" \"tests/**/*.mjs\" \"packages/cli/src/**/*.mjs\" \"packages/cli/scripts/**/*.mjs\" \"plugins/epic-loop/skills/epic-loop/scripts/**/*.mjs\"", + "format:write": "oxfmt --write package.json .oxlintrc.json .oxfmtrc.json \"scripts/*.mjs\" \"tests/**/*.mjs\" \"packages/cli/src/**/*.mjs\" \"packages/cli/scripts/**/*.mjs\" \"plugins/epic-loop/skills/epic-loop/scripts/**/*.mjs\"", + "validate": "for f in scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/lib/*.mjs; do node --check \"$f\" || exit 1; done && pnpm run lint && pnpm run format:check && node scripts/validate-epic-loop-package.mjs", "validate:epic-loop": "pnpm run validate", "doctor:epic-loop:codex": "node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex --json", "doctor:epic-loop:claude-code": "node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform claude-code --json", @@ -34,6 +36,7 @@ ], "packageManager": "pnpm@11.9.0", "devDependencies": { + "oxfmt": "^0.57.0", "oxlint": "^1.72.0" } } diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 4a8629c..bcc3f4f 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -1,15 +1,15 @@ -import { build } from 'esbuild'; -import { chmodSync } from 'node:fs'; +import { build } from "esbuild"; +import { chmodSync } from "node:fs"; -const outfile = 'dist/epic-loop.mjs'; +const outfile = "dist/epic-loop.mjs"; await build({ - entryPoints: ['src/cli.mjs'], + entryPoints: ["src/cli.mjs"], bundle: true, - platform: 'node', - format: 'esm', + platform: "node", + format: "esm", outfile, - banner: { js: '#!/usr/bin/env node' }, + banner: { js: "#!/usr/bin/env node" }, }); chmodSync(outfile, 0o755); diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index fa1a6a9..e1c9ed1 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -1,16 +1,16 @@ -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import path from 'node:path'; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; -import { findProjectRoot } from './project-root.mjs'; -import { listEpics } from './epics.mjs'; +import { findProjectRoot } from "./project-root.mjs"; +import { listEpics } from "./epics.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')); +const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); const args = process.argv.slice(2); -if (args.includes('--version') || args.includes('-v')) { +if (args.includes("--version") || args.includes("-v")) { console.log(`epic-loop v${pkg.version}`); process.exit(0); } @@ -18,7 +18,7 @@ if (args.includes('--version') || args.includes('-v')) { const projectRoot = findProjectRoot(process.cwd()); if (!projectRoot) { - console.error('No epic-loop project found (no .epic-loop directory in this or any parent directory).'); + console.error("No epic-loop project found (no .epic-loop directory in this or any parent directory)."); process.exit(1); } @@ -36,7 +36,7 @@ for (const epic of epics) { if (epic.implementationLoop) { const { currentRole, nextRole, status } = epic.implementationLoop; - line += ` | loop: ${status ?? 'unknown'} (current: ${currentRole ?? '-'}, next: ${nextRole ?? '-'})`; + line += ` | loop: ${status ?? "unknown"} (current: ${currentRole ?? "-"}, next: ${nextRole ?? "-"})`; } console.log(line); diff --git a/packages/cli/src/epics.mjs b/packages/cli/src/epics.mjs index 7d7fe51..60669c9 100644 --- a/packages/cli/src/epics.mjs +++ b/packages/cli/src/epics.mjs @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; function readJsonSafe(filePath) { if (!existsSync(filePath)) { @@ -7,7 +7,7 @@ function readJsonSafe(filePath) { } try { - return JSON.parse(readFileSync(filePath, 'utf8')); + return JSON.parse(readFileSync(filePath, "utf8")); } catch { return null; } @@ -18,7 +18,7 @@ function readTitleFromState(statePath) { return null; } - const match = readFileSync(statePath, 'utf8').match(/^Epic:\s*(.+)$/mu); + const match = readFileSync(statePath, "utf8").match(/^Epic:\s*(.+)$/mu); return match?.[1]?.trim() || null; } @@ -27,32 +27,33 @@ function readModeFromState(statePath) { return null; } - const match = readFileSync(statePath, 'utf8').match(/^Current mode:\s*(.+)$/mu); + const match = readFileSync(statePath, "utf8").match(/^Current mode:\s*(.+)$/mu); return match?.[1]?.trim() || null; } function readEpicSummary(epicsDir, slug) { const epicDir = path.join(epicsDir, slug); - const statePath = path.join(epicDir, 'state-of-epic.md'); - const runtime = readJsonSafe(path.join(epicDir, '.runtime', 'runtime-state.json')) ?? {}; + const statePath = path.join(epicDir, "state-of-epic.md"); + const runtime = readJsonSafe(path.join(epicDir, ".runtime", "runtime-state.json")) ?? {}; const title = runtime.title || readTitleFromState(statePath) || slug; - const mode = runtime.mode || readModeFromState(statePath) || 'unknown'; + const mode = runtime.mode || readModeFromState(statePath) || "unknown"; const loop = runtime.implementation_loop ?? null; - const implementationLoop = mode === 'implementation' && loop - ? { - currentRole: loop.current_role ?? null, - nextRole: loop.next_role ?? null, - status: loop.status ?? null, - } - : null; + const implementationLoop = + mode === "implementation" && loop + ? { + currentRole: loop.current_role ?? null, + nextRole: loop.next_role ?? null, + status: loop.status ?? null, + } + : null; return { slug, title, mode, implementationLoop }; } export function listEpics(projectRoot) { - const epicsDir = path.join(projectRoot, '.epic-loop', 'epics'); + const epicsDir = path.join(projectRoot, ".epic-loop", "epics"); if (!existsSync(epicsDir)) { return []; diff --git a/packages/cli/src/project-root.mjs b/packages/cli/src/project-root.mjs index 113353d..bf5cdb4 100644 --- a/packages/cli/src/project-root.mjs +++ b/packages/cli/src/project-root.mjs @@ -1,11 +1,11 @@ -import { existsSync } from 'node:fs'; -import path from 'node:path'; +import { existsSync } from "node:fs"; +import path from "node:path"; export function findProjectRoot(startDir) { let dir = path.resolve(startDir); while (true) { - if (existsSync(path.join(dir, '.epic-loop'))) { + if (existsSync(path.join(dir, ".epic-loop"))) { return dir; } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs index b277809..cf18713 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs @@ -18,7 +18,10 @@ export function nowIso() { } export function eventTimestamp(date = new Date()) { - return date.toISOString().replace(/[-:]/gu, "").replace(/\.\d{3}Z$/u, "Z"); + return date + .toISOString() + .replace(/[-:]/gu, "") + .replace(/\.\d{3}Z$/u, "Z"); } export function slugify(value) { @@ -32,18 +35,15 @@ export function slugify(value) { return slug; } - const fallback = new Date().toISOString().replace(/[-:T.]/gu, "").slice(0, 14); + const fallback = new Date() + .toISOString() + .replace(/[-:T.]/gu, "") + .slice(0, 14); return `epic-${fallback}`; } export function epicSlugify(value) { - return slugify(value) - .split("-") - .filter(Boolean) - .slice(0, 2) - .join("-") - .slice(0, 30) - .replace(/-+$/u, ""); + return slugify(value).split("-").filter(Boolean).slice(0, 2).join("-").slice(0, 30).replace(/-+$/u, ""); } export function titleFromDescription(description) { @@ -58,9 +58,7 @@ export function titleFromDescription(description) { return "Untitled Epic"; } - return words - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); + return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" "); } export function expandHome(value) { @@ -322,13 +320,7 @@ export function writeHookCapture(projectRoot, payload) { } export function writeClaudeHookCapture(projectRoot, payload) { - if ( - !payload || - typeof payload !== "object" || - typeof payload.session_id !== "string" || - typeof payload.cwd !== "string" || - typeof payload.transcript_path !== "string" - ) { + if (!payload || typeof payload !== "object" || typeof payload.session_id !== "string" || typeof payload.cwd !== "string" || typeof payload.transcript_path !== "string") { return; } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index 36ec3f8..58ceab5 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -338,7 +338,10 @@ export function unbindSession(flags = {}) { const runtime = readJson(runtimePath, {}); const normalizedRuntime = runtime && typeof runtime === "object" && !Array.isArray(runtime) ? runtime : {}; const mode = typeof normalizedRuntime.mode === "string" ? normalizedRuntime.mode : typeof binding.mode === "string" ? binding.mode : "unknown"; - const loop = normalizedRuntime.implementation_loop && typeof normalizedRuntime.implementation_loop === "object" && !Array.isArray(normalizedRuntime.implementation_loop) ? normalizedRuntime.implementation_loop : {}; + const loop = + normalizedRuntime.implementation_loop && typeof normalizedRuntime.implementation_loop === "object" && !Array.isArray(normalizedRuntime.implementation_loop) + ? normalizedRuntime.implementation_loop + : {}; sessions[sessionId] = { ...binding, @@ -385,7 +388,12 @@ function resolveAutoBindSlug(root, flags = {}) { return flags.slug.trim(); } - const rawPath = typeof flags.path === "string" && flags.path.trim() ? flags.path.trim() : typeof flags["epic-path"] === "string" && flags["epic-path"].trim() ? flags["epic-path"].trim() : null; + const rawPath = + typeof flags.path === "string" && flags.path.trim() + ? flags.path.trim() + : typeof flags["epic-path"] === "string" && flags["epic-path"].trim() + ? flags["epic-path"].trim() + : null; if (!rawPath) { throw new Error("Missing --slug or --path."); } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index eb13e27..fc7e597 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -576,7 +576,7 @@ function inspectAndRepairRuntimeState(root, slug, roadmap, bindingMode, result) return; } - const mode = typeof strict.value.mode === "string" && MODES.includes(strict.value.mode) ? strict.value.mode : bindingMode ?? null; + const mode = typeof strict.value.mode === "string" && MODES.includes(strict.value.mode) ? strict.value.mode : (bindingMode ?? null); if (!mode) { result.invalid.push({ path: runtimePath, @@ -842,9 +842,7 @@ function doctorClaudeCode(root, platformConfig, flags = {}) { console.log(`Hook target exists: ${fs.existsSync(HOOK_SCRIPT_PATH) ? "yes" : "no"}`); console.log(`Hook target readable: ${scriptReadable.ok ? "yes" : `no (${scriptReadable.reason})`}`); console.log( - `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP: ${ - blockCap.ready ? `${blockCap.value}${blockCap.recommended ? "" : " (accepted with warning)"}` : `setup-required (${blockCap.reason})` - }`, + `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP: ${blockCap.ready ? `${blockCap.value}${blockCap.recommended ? "" : " (accepted with warning)"}` : `setup-required (${blockCap.reason})`}`, ); if (blockCap.warning) { console.log(`Warning: ${blockCap.warning}`); diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs index 2d1df48..3f40196 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs @@ -58,21 +58,25 @@ export function startImplementationLoop(projectRoot, { sessionId, slug }) { }); } - const nextLoop = withClaudeBlockCapMetadata(projectRoot, { - ...loop, - current_role: null, - active_turn_started_at: null, - active_turn_stopped_at: null, - driver_session_id: sessionId, - iteration: Number.isFinite(loop.iteration) ? loop.iteration : 0, - last_reason: "implementation-start", - last_session_id: sessionId, - last_transition_at: timestamp, - last_transition_by: "bind-session", - next_role: "manager", - prompt_file: null, - status: "running", - }, timestamp); + const nextLoop = withClaudeBlockCapMetadata( + projectRoot, + { + ...loop, + current_role: null, + active_turn_started_at: null, + active_turn_stopped_at: null, + driver_session_id: sessionId, + iteration: Number.isFinite(loop.iteration) ? loop.iteration : 0, + last_reason: "implementation-start", + last_session_id: sessionId, + last_transition_at: timestamp, + last_transition_by: "bind-session", + next_role: "manager", + prompt_file: null, + status: "running", + }, + timestamp, + ); writeJson(runtimePath, { ...runtime, @@ -129,7 +133,7 @@ export function setNextRole(flags = {}) { prompt_file: promptFile, status, }, - implementation_submode: role === "idle" ? runtime.implementation_submode ?? "techlead" : role, + implementation_submode: role === "idle" ? (runtime.implementation_submode ?? "techlead") : role, updated_at: timestamp, }); @@ -257,8 +261,7 @@ export function maybeBuildImplementationContinuation(projectRoot, payload, bindi // note. With an uncapped run (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=0) roles chain automatically, // so appending it to every prompt would misinform the agent. const platformPrompt = platform === "claude-code" && capProximityRoute ? appendClaudeManualContinueNote(prompt) : prompt; - const promptFile = - role === "manager" ? MANAGER_PROMPT_TEMPLATE_PATH : role === "engineer" ? loop.prompt_file ?? null : TECHLEAD_PROMPT_TEMPLATE_PATH; + const promptFile = role === "manager" ? MANAGER_PROMPT_TEMPLATE_PATH : role === "engineer" ? (loop.prompt_file ?? null) : TECHLEAD_PROMPT_TEMPLATE_PATH; const followingRole = role === "engineer" ? "techlead" : role === "manager" ? "techlead" : WAITING_FOR_TURN_TRANSITION; const nextLoop = incrementClaudeBlockCount( @@ -689,7 +692,9 @@ function hasOpenTurn(loop) { } function renderTemplate(template, values) { - return Object.entries(values).reduce((result, [key, value]) => result.replaceAll(`-<<*{{${key}}}*>>-`, value), template).trim(); + return Object.entries(values) + .reduce((result, [key, value]) => result.replaceAll(`-<<*{{${key}}}*>>-`, value), template) + .trim(); } function recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp) { @@ -949,15 +954,7 @@ function appendProgressMarkdown(filePath, entry) { ensureMarkdownFile(filePath, "# Implementation Progress Log\n"); fs.appendFileSync( filePath, - [ - "", - `## ${entry.timestamp ?? nowIso()} | ${entry.action ?? "event"}`, - "", - progressSummary(entry), - "", - ...formatProgressDetails(entry), - "", - ].join("\n"), + ["", `## ${entry.timestamp ?? nowIso()} | ${entry.action ?? "event"}`, "", progressSummary(entry), "", ...formatProgressDetails(entry), ""].join("\n"), "utf8", ); } @@ -1077,12 +1074,7 @@ function formatRoleCommands(commands) { } return commands.map((command) => { - const parts = [ - `${command.timestamp}`, - `current=${command.current_role ?? "unknown"}`, - `next=${command.next_role ?? "unknown"}`, - `reason=${command.reason ?? "n/a"}`, - ]; + const parts = [`${command.timestamp}`, `current=${command.current_role ?? "unknown"}`, `next=${command.next_role ?? "unknown"}`, `reason=${command.reason ?? "n/a"}`]; if (command.prompt_file) { parts.push(`prompt=${command.prompt_file}`); } @@ -1141,7 +1133,7 @@ function formatFieldValue(key, value) { } if (typeof value === "string") { - return value ? `\`${value}\`` : "`\"\"`"; + return value ? `\`${value}\`` : '`""`'; } if (typeof value === "number" || typeof value === "boolean") { @@ -1192,11 +1184,22 @@ function groupNestedDurations(events) { } function firstEventTimestamp(events) { - return events.map((event) => event.timestamp).filter(Boolean).sort()[0] ?? null; + return ( + events + .map((event) => event.timestamp) + .filter(Boolean) + .sort()[0] ?? null + ); } function lastEventTimestamp(events) { - return events.map((event) => event.timestamp).filter(Boolean).sort().at(-1) ?? null; + return ( + events + .map((event) => event.timestamp) + .filter(Boolean) + .sort() + .at(-1) ?? null + ); } function durationMsBetween(start, end) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3e74f6..40774df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,137 @@ importers: .: devDependencies: + oxfmt: + specifier: ^0.57.0 + version: 0.57.0 oxlint: specifier: ^1.72.0 version: 1.72.0 packages: + '@oxfmt/binding-android-arm-eabi@0.57.0': + resolution: {integrity: sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.57.0': + resolution: {integrity: sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.57.0': + resolution: {integrity: sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.57.0': + resolution: {integrity: sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.57.0': + resolution: {integrity: sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + resolution: {integrity: sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + resolution: {integrity: sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + resolution: {integrity: sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.57.0': + resolution: {integrity: sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + resolution: {integrity: sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + resolution: {integrity: sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + resolution: {integrity: sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + resolution: {integrity: sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.57.0': + resolution: {integrity: sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.57.0': + resolution: {integrity: sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.57.0': + resolution: {integrity: sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + resolution: {integrity: sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + resolution: {integrity: sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.57.0': + resolution: {integrity: sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxlint/binding-android-arm-eabi@1.72.0': resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -136,6 +261,19 @@ packages: cpu: [x64] os: [win32] + oxfmt@0.57.0: + resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + oxlint@1.72.0: resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -149,8 +287,69 @@ packages: vite-plus: optional: true + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + snapshots: + '@oxfmt/binding-android-arm-eabi@0.57.0': + optional: true + + '@oxfmt/binding-android-arm64@0.57.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.57.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.57.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.57.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.57.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.57.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.57.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.57.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.57.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.57.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.57.0': + optional: true + '@oxlint/binding-android-arm-eabi@1.72.0': optional: true @@ -208,6 +407,30 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.72.0': optional: true + oxfmt@0.57.0: + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.57.0 + '@oxfmt/binding-android-arm64': 0.57.0 + '@oxfmt/binding-darwin-arm64': 0.57.0 + '@oxfmt/binding-darwin-x64': 0.57.0 + '@oxfmt/binding-freebsd-x64': 0.57.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.57.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.57.0 + '@oxfmt/binding-linux-arm64-gnu': 0.57.0 + '@oxfmt/binding-linux-arm64-musl': 0.57.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.57.0 + '@oxfmt/binding-linux-riscv64-musl': 0.57.0 + '@oxfmt/binding-linux-s390x-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-gnu': 0.57.0 + '@oxfmt/binding-linux-x64-musl': 0.57.0 + '@oxfmt/binding-openharmony-arm64': 0.57.0 + '@oxfmt/binding-win32-arm64-msvc': 0.57.0 + '@oxfmt/binding-win32-ia32-msvc': 0.57.0 + '@oxfmt/binding-win32-x64-msvc': 0.57.0 + oxlint@1.72.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 @@ -229,3 +452,5 @@ snapshots: '@oxlint/binding-win32-arm64-msvc': 1.72.0 '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 + + tinypool@2.1.0: {} diff --git a/scripts/self-update-skill.mjs b/scripts/self-update-skill.mjs index 1b2ad6e..77a2918 100644 --- a/scripts/self-update-skill.mjs +++ b/scripts/self-update-skill.mjs @@ -5,17 +5,10 @@ import process from "node:process"; const root = process.cwd(); const source = path.join(root, "plugins", "epic-loop", "skills", "epic-loop"); -const codexRoot = process.env.CODEX_HOME - ? path.resolve(process.env.CODEX_HOME) - : path.join(root, ".codex"); -const claudeRoot = process.env.CLAUDE_HOME - ? path.resolve(process.env.CLAUDE_HOME) - : path.join(root, ".claude"); +const codexRoot = process.env.CODEX_HOME ? path.resolve(process.env.CODEX_HOME) : path.join(root, ".codex"); +const claudeRoot = process.env.CLAUDE_HOME ? path.resolve(process.env.CLAUDE_HOME) : path.join(root, ".claude"); -const destinations = [ - path.join(codexRoot, "skills", "epic-loop"), - path.join(claudeRoot, "skills", "epic-loop"), -]; +const destinations = [path.join(codexRoot, "skills", "epic-loop"), path.join(claudeRoot, "skills", "epic-loop")]; await assertDirectory(source, "source skill"); diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 69069c4..934e846 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -88,15 +88,7 @@ test("doctor repairs structured epic compatibility without reading state markdow fs.mkdirSync(path.join(legacyRoot, ".runtime"), { recursive: true }); fs.writeFileSync( path.join(legacyRoot, "state-of-epic.md"), - [ - "# State Of Epic", - "", - "Epic: Markdown Must Not Drive Mode", - "Slug: `legacy`", - "Current mode: review", - "Active phase: Phase 9 - Markdown Only", - "", - ].join("\n"), + ["# State Of Epic", "", "Epic: Markdown Must Not Drive Mode", "Slug: `legacy`", "Current mode: review", "Active phase: Phase 9 - Markdown Only", ""].join("\n"), "utf8", ); fs.writeFileSync( @@ -124,17 +116,20 @@ test("doctor repairs structured epic compatibility without reading state markdow "utf8", ); - fs.mkdirSync(path.join(root, ".epic-loop", "epics", emptySlug, ".runtime"), { recursive: true }); + fs.mkdirSync(path.join(root, ".epic-loop", "epics", emptySlug, ".runtime"), { + recursive: true, + }); const doctor = runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"]); assertSuccess(doctor); const status = JSON.parse(doctor.stdout); assert.equal(status.epicCompatibility.ready, true); assert.equal(status.epicCompatibility.checked, 2); - assert.deepEqual( - status.epicCompatibility.repaired.map((repair) => `${repair.slug}:${repair.type}`).sort(), - ["empty:created-roadmap-state", "empty:created-runtime-state", "legacy:created-runtime-state"], - ); + assert.deepEqual(status.epicCompatibility.repaired.map((repair) => `${repair.slug}:${repair.type}`).sort(), [ + "empty:created-roadmap-state", + "empty:created-runtime-state", + "legacy:created-runtime-state", + ]); const legacyRuntime = readJsonFile(path.join(legacyRoot, ".runtime", "runtime-state.json")); assert.equal(legacyRuntime.mode, "shaping"); @@ -164,10 +159,7 @@ test("doctor repairs structured epic compatibility without reading state markdow ); const hook = runHook(root, userPromptPayload(root, "session-legacy")); - assert.equal( - JSON.parse(hook.stdout).hookSpecificOutput.additionalContext, - "[epic-loop] epic=legacy mode=shaping — follow epic-loop skill mode rules", - ); + assert.equal(JSON.parse(hook.stdout).hookSpecificOutput.additionalContext, "[epic-loop] epic=legacy mode=shaping — follow epic-loop skill mode rules"); } finally { fs.rmSync(root, { force: true, recursive: true }); } @@ -396,8 +388,14 @@ test("install-hooks adds Claude Code project settings without damaging unrelated } const stopCommands = settings.hooks.Stop[0].hooks.map((hook) => hook.command); - assert.deepEqual(stopCommands.filter((command) => command === "echo unrelated"), ["echo unrelated"]); - assert.equal(stopCommands.some((command) => command === "node /old/epic-loop/hook.mjs"), false); + assert.deepEqual( + stopCommands.filter((command) => command === "echo unrelated"), + ["echo unrelated"], + ); + assert.equal( + stopCommands.some((command) => command === "node /old/epic-loop/hook.mjs"), + false, + ); const secondInstall = runNodeScript("install-hooks.mjs", ["--root", root]); assertSuccess(secondInstall); @@ -509,7 +507,7 @@ test("bind-session current lookup uses fresh Claude Code hook captures", () => { const transcriptPath = path.join(root, "transcript.jsonl"); try { - fs.writeFileSync(transcriptPath, "{\"type\":\"assistant\",\"message\":{\"content\":\"ready\"}}\n", "utf8"); + fs.writeFileSync(transcriptPath, '{"type":"assistant","message":{"content":"ready"}}\n', "utf8"); assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Bind Claude current project", "--slug", slug, "--no-gitignore"])); assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "claude-code", "--json"])); @@ -773,7 +771,7 @@ test("auto-bind-session supports fresh Claude Code UserPromptSubmit captures", ( const transcriptPath = path.join(root, "transcript.jsonl"); try { - fs.writeFileSync(transcriptPath, "{\"type\":\"assistant\",\"message\":{\"content\":\"ready\"}}\n", "utf8"); + fs.writeFileSync(transcriptPath, '{"type":"assistant","message":{"content":"ready"}}\n', "utf8"); assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Auto Claude project", "--slug", slug, "--no-gitignore"])); assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "claude-code", "--json"])); @@ -835,7 +833,7 @@ test("platform-aware CLIs reject missing or invalid runtime platform config", () assert.match(installMissing.stderr, /doctor\.mjs --platform codex\|claude-code --json/u); fs.mkdirSync(path.join(root, ".epic-loop", ".runtime"), { recursive: true }); - fs.writeFileSync(path.join(root, ".epic-loop", ".runtime", "platform.json"), "{\"platform\":\"auto\"}\n", "utf8"); + fs.writeFileSync(path.join(root, ".epic-loop", ".runtime", "platform.json"), '{"platform":"auto"}\n', "utf8"); const doctorMissing = runNodeScript("doctor.mjs", ["--root", root, "--json"]); assert.equal(doctorMissing.status, 1); diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index e599d22..5e3d673 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -282,7 +282,7 @@ test("Claude Code unbound hook payload exits without epic-loop runtime records", const transcriptPath = path.join(root, "transcript.jsonl"); try { - fs.writeFileSync(transcriptPath, "{\"type\":\"assistant\",\"message\":{\"content\":\"done\"}}\n", "utf8"); + fs.writeFileSync(transcriptPath, '{"type":"assistant","message":{"content":"done"}}\n', "utf8"); assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "claude-code", "--json"])); const result = runNodeScript("hook.mjs", ["--root", root], { @@ -378,10 +378,7 @@ test("Claude Code synthetic implementation flow binds current capture and routes const techleadContinuation = JSON.parse(techleadStop.stdout); assert.equal(techleadContinuation.decision, "block"); assert.match(techleadContinuation.reason, /techlead turn 2/u); - assert.match( - fs.readFileSync(path.join(root, ".epic-loop", "epics", slug, ".runtime", "latest-manager-report.md"), "utf8"), - /Manager report from transcript/u, - ); + assert.match(fs.readFileSync(path.join(root, ".epic-loop", "epics", slug, ".runtime", "latest-manager-report.md"), "utf8"), /Manager report from transcript/u); const techleadRuntime = readJsonFile(runtimePath); assert.equal(techleadRuntime.implementation_loop.current_role, "techlead"); assert.equal(techleadRuntime.implementation_loop.next_role, "awaiting-transition"); @@ -463,10 +460,18 @@ test("Claude Code bound Stop captures latest assistant transcript report", () => transcriptPath, [ JSON.stringify({ role: "user", content: "ignored user text" }), - JSON.stringify({ message: { role: "assistant", content: [{ type: "text", text: "Older assistant report" }] } }), + JSON.stringify({ + message: { + role: "assistant", + content: [{ type: "text", text: "Older assistant report" }], + }, + }), "{malformed-json", JSON.stringify({ type: "assistant", message: { content: "Middle assistant report" } }), - JSON.stringify({ role: "assistant", content: [{ text: "Latest assistant" }, { type: "text", text: "report" }] }), + JSON.stringify({ + role: "assistant", + content: [{ text: "Latest assistant" }, { type: "text", text: "report" }], + }), "", ].join("\n"), "utf8", From 2dbf51513ef09e5dd58a5720101bc0ef28232104 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 20:57:52 +0700 Subject: [PATCH 03/17] Split hook support helpers --- .epic-loop/epics/set-up/implementation-log.md | 5 + .epic-loop/epics/set-up/state-of-epic.md | 5 +- .../scripts/lib/hook-compatibility.mjs | 191 +++++ .../epic-loop/scripts/lib/hook-config.mjs | 452 ++++++++++++ .../skills/epic-loop/scripts/lib/hooks.mjs | 652 +----------------- 5 files changed, 665 insertions(+), 640 deletions(-) create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/lib/hook-compatibility.mjs create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/lib/hook-config.mjs diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index f95fac1..9f8c3e0 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -19,3 +19,8 @@ - Task: Phase 1 Task 2 - Add Oxfmt configuration and non-mutating format validation - Verdict: closed: added oxfmt dependency, .oxfmtrc.json, format:check/format:write scripts, and validate integration. format:check passes; unit tests pass. pnpm run validate still fails only on accepted oxlint max-lines debt in hooks.mjs and loop.mjs. Markdown formatting excluded because oxfmt@0.57.0 produced unsafe markdown/template churn. + +## 2026-07-07T13:57:42+00:00 - checkpoint: split hooks.mjs into hook-config.mjs and hook-compatibility.mjs helpers without changing public hook exports or runtime behavior. hooks.mjs is now below the oxlint max-lines limit; pnpm run test:unit and pnpm run format:check pass. pnpm run lint still fails only on known loop.mjs max-lines debt, so Task 3 remains open. + +- Task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines +- Verdict: checkpoint: split hooks.mjs into hook-config.mjs and hook-compatibility.mjs helpers without changing public hook exports or runtime behavior. hooks.mjs is now below the oxlint max-lines limit; pnpm run test:unit and pnpm run format:check pass. pnpm run lint still fails only on known loop.mjs max-lines debt, so Task 3 remains open. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 5ca1c1f..cc1174c 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -12,14 +12,15 @@ Active task: Phase 1 Task 3 - Refactor oversized hook and implementation loop mo - Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Oxfmt configuration. - Phase 1 Task 1 is closed with accepted baseline debt: oxlint configuration, scripts, dependency, validation integration, and narrow baseline fixes are present; existing `max-lines` failures in `loop.mjs` and `hooks.mjs` are explicitly accepted for now and tracked as a Phase 1 refactor task before phase verification. - Phase 1 Task 2 is closed: Oxfmt config and check/write scripts are present; format check is non-mutating and included in validation; markdown formatting is intentionally excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn during verification. +- Phase 1 Task 3 is in progress: `hooks.mjs` has been split below the oxlint source-file line limit; `loop.mjs` remains the only known `max-lines` failure. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- `pnpm run lint` and `pnpm run validate` currently fail until the planned Phase 1 refactor splits `loop.mjs` and `hooks.mjs` below the accepted `max-lines` limit. +- `pnpm run lint` and `pnpm run validate` currently fail until the planned Phase 1 refactor splits `loop.mjs` below the accepted `max-lines` limit. ## Next Action -- Continue with Phase 1 Task 3: refactor oversized `hooks.mjs` and `loop.mjs` modules to satisfy oxlint `max-lines`. +- Continue with Phase 1 Task 3: refactor oversized `loop.mjs` to satisfy oxlint `max-lines`. diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-compatibility.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-compatibility.mjs new file mode 100644 index 0000000..e27a105 --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-compatibility.mjs @@ -0,0 +1,191 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { MODES, epicsRoot, nowIso, readJson, readJsonStrict, roadmapStatePath, runtimeStatePath, sessionRoot, writeJson } from "./common.mjs"; +import { createInitialRoadmapState } from "./roadmap.mjs"; + +export function inspectAndRepairEpicCompatibility(root) { + const epicsDir = epicsRoot(root); + const result = { + checked: 0, + invalid: [], + repaired: [], + ready: true, + }; + + if (!fs.existsSync(epicsDir)) { + return result; + } + + const bindingModes = readActiveBindingModes(root); + + for (const entry of fs.readdirSync(epicsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const slug = entry.name; + result.checked += 1; + const roadmap = inspectAndRepairRoadmapState(root, slug, result); + inspectAndRepairRuntimeState(root, slug, roadmap, bindingModes.get(slug), result); + } + + result.ready = result.invalid.length === 0; + return result; +} + +function inspectAndRepairRoadmapState(root, slug, result) { + const roadmapPath = roadmapStatePath(root, slug); + const strict = readJsonStrict(roadmapPath); + + if (strict.error) { + result.invalid.push({ + path: roadmapPath, + reason: strict.error, + slug, + type: "roadmap-state", + }); + return null; + } + + if (strict.exists && isPlainObject(strict.value)) { + return strict.value; + } + + const roadmap = createInitialRoadmapState({ slug, title: slug }); + writeJson(roadmapPath, roadmap); + result.repaired.push({ + path: roadmapPath, + slug, + type: "created-roadmap-state", + }); + return roadmap; +} + +function inspectAndRepairRuntimeState(root, slug, roadmap, bindingMode, result) { + const runtimePath = runtimeStatePath(root, slug); + const strict = readJsonStrict(runtimePath); + + if (strict.error) { + result.invalid.push({ + path: runtimePath, + reason: strict.error, + slug, + type: "runtime-state", + }); + return; + } + + if (!strict.exists) { + writeJson(runtimePath, buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode)); + result.repaired.push({ + path: runtimePath, + slug, + type: "created-runtime-state", + }); + return; + } + + if (!isPlainObject(strict.value)) { + result.invalid.push({ + path: runtimePath, + reason: "runtime state must be an object", + slug, + type: "runtime-state", + }); + return; + } + + const mode = typeof strict.value.mode === "string" && MODES.includes(strict.value.mode) ? strict.value.mode : (bindingMode ?? null); + if (!mode) { + result.invalid.push({ + path: runtimePath, + reason: "missing mode", + slug, + type: "runtime-state", + }); + return; + } + + if (strict.value.mode !== mode) { + writeJson(runtimePath, { + ...strict.value, + mode, + updated_at: nowIso(), + }); + result.repaired.push({ + path: runtimePath, + slug, + type: "repaired-runtime-mode", + }); + } +} + +function buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode) { + const timestamp = nowIso(); + const normalizedRoadmap = isPlainObject(roadmap) ? roadmap : createInitialRoadmapState({ slug, title: slug }); + + return { + active_phase: formatRoadmapPhase(normalizedRoadmap, normalizedRoadmap.active_phase_id), + active_task: formatRoadmapTask(normalizedRoadmap, normalizedRoadmap.active_task_id), + created_at: timestamp, + description: null, + execution_brief: null, + implementation_submode: "techlead", + mode: bindingMode ?? "shaping", + slug, + title: typeof normalizedRoadmap.title === "string" && normalizedRoadmap.title.trim() ? normalizedRoadmap.title.trim() : slug, + updated_at: timestamp, + }; +} + +function readActiveBindingModes(root) { + const bindingsPath = path.join(sessionRoot(root), "session-bindings.json"); + const bindings = readJson(bindingsPath, {}); + const sessions = isPlainObject(bindings?.sessions) ? bindings.sessions : {}; + const modes = new Map(); + + for (const binding of Object.values(sessions)) { + if (!isPlainObject(binding) || binding.active !== true || typeof binding.epic_slug !== "string" || !MODES.includes(binding.mode)) { + continue; + } + + modes.set(binding.epic_slug, binding.mode); + } + + return modes; +} + +function formatRoadmapPhase(roadmap, phaseId) { + const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; + const phase = phases.find((candidate) => candidate?.id === phaseId) ?? phases[0]; + if (!phase) { + return null; + } + + const index = phases.indexOf(phase); + const number = index >= 0 ? index + 1 : 1; + const title = typeof phase.title === "string" && phase.title.trim() ? phase.title.trim() : `Phase ${number}`; + return `Phase ${number} - ${title}`; +} + +function formatRoadmapTask(roadmap, taskId) { + if (typeof taskId !== "string" || !taskId) { + return null; + } + + const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; + for (const phase of phases) { + const tasks = Array.isArray(phase?.tasks) ? phase.tasks : []; + const task = tasks.find((candidate) => candidate?.id === taskId); + if (task) { + return typeof task.title === "string" && task.title.trim() ? task.title.trim() : task.id; + } + } + + return null; +} + +function isPlainObject(value) { + return value && typeof value === "object" && !Array.isArray(value); +} diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-config.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-config.mjs new file mode 100644 index 0000000..53eb425 --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hook-config.mjs @@ -0,0 +1,452 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { CODEX_CONFIG_RELATIVE_PATH, CODEX_HOOKS_RELATIVE_PATH, HOOK_EVENTS, canWritePath, readJsonStrict, shellQuote } from "./common.mjs"; + +export const CLAUDE_SETTINGS_RELATIVE_PATH = path.join(".claude", "settings.json"); + +const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SCRIPTS_DIR = path.dirname(LIB_DIR); + +export const HOOK_SCRIPT_PATH = path.join(SCRIPTS_DIR, "hook.mjs"); +const INSTALL_HOOKS_SCRIPT_PATH = path.join(SCRIPTS_DIR, "install-hooks.mjs"); + +export function buildHookCommand() { + return `node ${shellQuote(HOOK_SCRIPT_PATH)}`; +} + +function buildClaudeHookCommand(root) { + return `${buildHookCommand()} --root ${shellQuote(root)}`; +} + +export function buildInstallHooksCommand(extraArgs = "") { + return `node ${shellQuote(INSTALL_HOOKS_SCRIPT_PATH)}${extraArgs}`; +} + +function isEpicLoopHookCommand(command) { + return typeof command === "string" && /epic[-_]loop/u.test(command) && /hook\.mjs|epic-loop\.mjs|epic_loop\.py|\bhook\b/u.test(command); +} + +function parseCodexHooksFeature(configPath) { + if (!fs.existsSync(configPath)) { + return { + exists: false, + value: null, + }; + } + + const lines = fs.readFileSync(configPath, "utf8").split(/\r?\n/u); + let currentTable = ""; + + for (const rawLine of lines) { + const line = rawLine.replace(/#.*/u, "").trim(); + if (!line) { + continue; + } + + const tableMatch = line.match(/^\[([^\]]+)\]$/u); + if (tableMatch) { + currentTable = tableMatch[1] ?? ""; + continue; + } + + if (currentTable !== "features") { + continue; + } + + const featureMatch = line.match(/^(?:hooks|codex_hooks)\s*=\s*(true|false)\s*$/u); + if (featureMatch) { + return { + exists: true, + value: featureMatch[1] === "true", + }; + } + } + + return { + exists: true, + value: null, + }; +} + +export function inspectCodexHooksFeature(root) { + const localPath = path.join(root, CODEX_CONFIG_RELATIVE_PATH); + const globalPath = path.join(process.env.HOME ?? "", ".codex", "config.toml"); + const local = parseCodexHooksFeature(localPath); + const global = parseCodexHooksFeature(globalPath); + + if (local.value !== null) { + return { + enabled: local.value, + scope: "project", + source: localPath, + }; + } + + if (global.value !== null) { + return { + enabled: global.value, + scope: "global", + source: globalPath, + }; + } + + return { + enabled: null, + scope: null, + source: null, + }; +} + +function normalizeHookDocument(document) { + return document && typeof document === "object" && !Array.isArray(document) ? document : {}; +} + +export function buildHooksDocument(existingDocument) { + const normalizedDocument = normalizeHookDocument(existingDocument); + const hooks = normalizedDocument.hooks && typeof normalizedDocument.hooks === "object" && !Array.isArray(normalizedDocument.hooks) ? normalizedDocument.hooks : {}; + const command = buildHookCommand(); + const changes = []; + + for (const eventName of HOOK_EVENTS) { + const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; + let changedEvent = false; + let exactInstalled = false; + + const normalizedEntries = entries.map((entry) => { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { + return entry; + } + + const nextHooks = []; + + for (const hook of entry.hooks) { + if (!hook || typeof hook !== "object") { + nextHooks.push(hook); + continue; + } + + if (hook.command === command) { + if (exactInstalled) { + changedEvent = true; + continue; + } + exactInstalled = true; + nextHooks.push(hook); + continue; + } + + if (isEpicLoopHookCommand(hook.command)) { + changedEvent = true; + if (!exactInstalled) { + exactInstalled = true; + nextHooks.push({ + ...hook, + command, + timeout: 30, + type: "command", + }); + } + continue; + } + + nextHooks.push(hook); + } + + return { + ...entry, + hooks: nextHooks, + }; + }); + + if (!exactInstalled) { + normalizedEntries.push({ + hooks: [ + { + command, + timeout: 30, + type: "command", + }, + ], + }); + changedEvent = true; + } + + if (changedEvent) { + changes.push(eventName); + } + + hooks[eventName] = normalizedEntries; + } + + normalizedDocument.hooks = hooks; + + return { + changes, + command, + document: normalizedDocument, + }; +} + +export function buildClaudeSettingsDocument(existingDocument, root) { + const normalizedDocument = normalizeHookDocument(existingDocument); + const hooks = normalizedDocument.hooks && typeof normalizedDocument.hooks === "object" && !Array.isArray(normalizedDocument.hooks) ? normalizedDocument.hooks : {}; + const command = buildClaudeHookCommand(root); + const changes = []; + + for (const eventName of HOOK_EVENTS) { + const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; + let changedEvent = false; + let exactInstalled = false; + + const normalizedEntries = entries.map((entry) => { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { + return entry; + } + + const nextHooks = []; + + for (const hook of entry.hooks) { + if (!hook || typeof hook !== "object") { + nextHooks.push(hook); + continue; + } + + if (hook.command === command) { + if (exactInstalled) { + changedEvent = true; + continue; + } + exactInstalled = true; + nextHooks.push(hook); + continue; + } + + if (isEpicLoopHookCommand(hook.command)) { + changedEvent = true; + if (!exactInstalled) { + exactInstalled = true; + nextHooks.push({ + ...hook, + command, + timeout: 30, + type: "command", + }); + } + continue; + } + + nextHooks.push(hook); + } + + return { + ...entry, + hooks: nextHooks, + }; + }); + + if (!exactInstalled) { + normalizedEntries.push({ + matcher: "", + hooks: [ + { + command, + timeout: 30, + type: "command", + }, + ], + }); + changedEvent = true; + } + + if (changedEvent) { + changes.push(eventName); + } + + hooks[eventName] = normalizedEntries; + } + + normalizedDocument.hooks = hooks; + + return { + changes, + command, + document: normalizedDocument, + }; +} + +export function inspectHookConfig(root) { + const hooksPath = path.join(root, CODEX_HOOKS_RELATIVE_PATH); + const strict = readJsonStrict(hooksPath); + const writable = canWritePath(hooksPath); + const command = buildHookCommand(); + + if (strict.error) { + return { + command, + exists: strict.exists, + hooksPath, + invalid: true, + missingEvents: HOOK_EVENTS, + ready: false, + staleEvents: [], + writable, + }; + } + + const document = normalizeHookDocument(strict.value); + const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks) ? document.hooks : {}; + const missingEvents = []; + const staleEvents = []; + + for (const eventName of HOOK_EVENTS) { + const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; + const commands = entries.flatMap((entry) => { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { + return []; + } + return entry.hooks.map((hook) => hook?.command).filter((value) => typeof value === "string"); + }); + + if (!commands.includes(command)) { + missingEvents.push(eventName); + } + + if (commands.some((value) => isEpicLoopHookCommand(value) && value !== command)) { + staleEvents.push(eventName); + } + } + + return { + command, + exists: strict.exists, + hooksPath, + invalid: false, + missingEvents, + ready: missingEvents.length === 0 && staleEvents.length === 0, + staleEvents, + writable, + }; +} + +export function inspectClaudeHookConfig(root) { + const settingsPath = path.join(root, CLAUDE_SETTINGS_RELATIVE_PATH); + const strict = readJsonStrict(settingsPath); + const writable = canWritePath(settingsPath); + const command = buildClaudeHookCommand(root); + + if (strict.error) { + return { + command, + error: strict.error, + exists: strict.exists, + invalid: true, + missingEvents: HOOK_EVENTS, + path: settingsPath, + ready: false, + staleEvents: [], + writable, + }; + } + + const document = normalizeHookDocument(strict.value); + const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks) ? document.hooks : {}; + const missingEvents = []; + const staleEvents = []; + + for (const eventName of HOOK_EVENTS) { + const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; + const commands = entries.flatMap((entry) => { + if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { + return []; + } + return entry.hooks.map((hook) => hook?.command).filter((value) => typeof value === "string"); + }); + + if (!commands.includes(command)) { + missingEvents.push(eventName); + } + + if (commands.some((value) => isEpicLoopHookCommand(value) && value !== command)) { + staleEvents.push(eventName); + } + } + + return { + command, + error: null, + exists: strict.exists, + invalid: false, + missingEvents, + path: settingsPath, + ready: missingEvents.length === 0 && staleEvents.length === 0, + staleEvents, + writable, + }; +} + +export function inspectClaudeStopHookBlockCap(env = process.env) { + const envVar = "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP"; + const rawValue = env[envVar]; + + if (rawValue === undefined || rawValue === "") { + return { + envVar, + rawValue: rawValue ?? null, + ready: false, + reason: "missing", + recommended: false, + value: null, + warning: null, + }; + } + + if (!/^\d+$/u.test(String(rawValue))) { + return { + envVar, + rawValue, + ready: false, + reason: "invalid", + recommended: false, + value: null, + warning: null, + }; + } + + const value = Number(rawValue); + + if (value !== 0 && value < 20) { + return { + envVar, + rawValue, + ready: false, + reason: "below-minimum", + recommended: false, + value, + warning: null, + }; + } + + if (value >= 20 && value <= 50) { + return { + envVar, + rawValue, + ready: true, + reason: null, + recommended: false, + value, + warning: "Loop mode may stop early and require manual continuation when CLAUDE_CODE_STOP_HOOK_BLOCK_CAP is between 20 and 50.", + }; + } + + return { + envVar, + rawValue, + ready: true, + reason: null, + recommended: true, + value, + warning: null, + }; +} diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index fc7e597..a997bf5 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -1,15 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { - CODEX_CONFIG_RELATIVE_PATH, CODEX_HOOKS_RELATIVE_PATH, - HOOK_EVENTS, - MODES, canReadPath, canWritePath, - epicsRoot, epicRuntimeRoot, eventTimestamp, formatList, @@ -20,40 +15,30 @@ import { readJson, readJsonStrict, resolveRoot, - roadmapStatePath, runtimeStatePath, sessionRoot, - shellQuote, slugify, writeHookCapture, writeClaudeHookCapture, writeJson, writeRuntimePlatform, } from "./common.mjs"; +import { + CLAUDE_SETTINGS_RELATIVE_PATH, + HOOK_SCRIPT_PATH, + buildClaudeSettingsDocument, + buildHookCommand, + buildHooksDocument, + buildInstallHooksCommand, + inspectClaudeHookConfig, + inspectClaudeStopHookBlockCap, + inspectCodexHooksFeature, + inspectHookConfig, +} from "./hook-config.mjs"; +import { inspectAndRepairEpicCompatibility } from "./hook-compatibility.mjs"; import { markInterruptedTurnIfNeeded, maybeBuildImplementationContinuation } from "./loop.mjs"; -import { createInitialRoadmapState } from "./roadmap.mjs"; - -const CLAUDE_SETTINGS_RELATIVE_PATH = path.join(".claude", "settings.json"); -const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); -const SCRIPTS_DIR = path.dirname(LIB_DIR); -const HOOK_SCRIPT_PATH = path.join(SCRIPTS_DIR, "hook.mjs"); -const INSTALL_HOOKS_SCRIPT_PATH = path.join(SCRIPTS_DIR, "install-hooks.mjs"); - -export function buildHookCommand() { - return `node ${shellQuote(HOOK_SCRIPT_PATH)}`; -} - -function buildClaudeHookCommand(root) { - return `${buildHookCommand()} --root ${shellQuote(root)}`; -} -function buildInstallHooksCommand(extraArgs = "") { - return `node ${shellQuote(INSTALL_HOOKS_SCRIPT_PATH)}${extraArgs}`; -} - -function isEpicLoopHookCommand(command) { - return typeof command === "string" && /epic[-_]loop/u.test(command) && /hook\.mjs|epic-loop\.mjs|epic_loop\.py|\bhook\b/u.test(command); -} +export { buildHookCommand }; function eventFilename(payload) { const eventName = slugify(payload.hook_event_name ?? "unknown"); @@ -61,615 +46,6 @@ function eventFilename(payload) { return `${eventTimestamp()}-${eventName}-${turnId}.json`; } -function parseCodexHooksFeature(configPath) { - if (!fs.existsSync(configPath)) { - return { - exists: false, - value: null, - }; - } - - const lines = fs.readFileSync(configPath, "utf8").split(/\r?\n/u); - let currentTable = ""; - - for (const rawLine of lines) { - const line = rawLine.replace(/#.*/u, "").trim(); - if (!line) { - continue; - } - - const tableMatch = line.match(/^\[([^\]]+)\]$/u); - if (tableMatch) { - currentTable = tableMatch[1] ?? ""; - continue; - } - - if (currentTable !== "features") { - continue; - } - - const featureMatch = line.match(/^(?:hooks|codex_hooks)\s*=\s*(true|false)\s*$/u); - if (featureMatch) { - return { - exists: true, - value: featureMatch[1] === "true", - }; - } - } - - return { - exists: true, - value: null, - }; -} - -function inspectCodexHooksFeature(root) { - const localPath = path.join(root, CODEX_CONFIG_RELATIVE_PATH); - const globalPath = path.join(process.env.HOME ?? "", ".codex", "config.toml"); - const local = parseCodexHooksFeature(localPath); - const global = parseCodexHooksFeature(globalPath); - - if (local.value !== null) { - return { - enabled: local.value, - scope: "project", - source: localPath, - }; - } - - if (global.value !== null) { - return { - enabled: global.value, - scope: "global", - source: globalPath, - }; - } - - return { - enabled: null, - scope: null, - source: null, - }; -} - -function normalizeHookDocument(document) { - return document && typeof document === "object" && !Array.isArray(document) ? document : {}; -} - -function buildHooksDocument(existingDocument) { - const normalizedDocument = normalizeHookDocument(existingDocument); - const hooks = normalizedDocument.hooks && typeof normalizedDocument.hooks === "object" && !Array.isArray(normalizedDocument.hooks) ? normalizedDocument.hooks : {}; - const command = buildHookCommand(); - const changes = []; - - for (const eventName of HOOK_EVENTS) { - const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; - let changedEvent = false; - let exactInstalled = false; - - const normalizedEntries = entries.map((entry) => { - if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { - return entry; - } - - const nextHooks = []; - - for (const hook of entry.hooks) { - if (!hook || typeof hook !== "object") { - nextHooks.push(hook); - continue; - } - - if (hook.command === command) { - if (exactInstalled) { - changedEvent = true; - continue; - } - exactInstalled = true; - nextHooks.push(hook); - continue; - } - - if (isEpicLoopHookCommand(hook.command)) { - changedEvent = true; - if (!exactInstalled) { - exactInstalled = true; - nextHooks.push({ - ...hook, - command, - timeout: 30, - type: "command", - }); - } - continue; - } - - nextHooks.push(hook); - } - - return { - ...entry, - hooks: nextHooks, - }; - }); - - if (!exactInstalled) { - normalizedEntries.push({ - hooks: [ - { - command, - timeout: 30, - type: "command", - }, - ], - }); - changedEvent = true; - } - - if (changedEvent) { - changes.push(eventName); - } - - hooks[eventName] = normalizedEntries; - } - - normalizedDocument.hooks = hooks; - - return { - changes, - command, - document: normalizedDocument, - }; -} - -function buildClaudeSettingsDocument(existingDocument, root) { - const normalizedDocument = normalizeHookDocument(existingDocument); - const hooks = normalizedDocument.hooks && typeof normalizedDocument.hooks === "object" && !Array.isArray(normalizedDocument.hooks) ? normalizedDocument.hooks : {}; - const command = buildClaudeHookCommand(root); - const changes = []; - - for (const eventName of HOOK_EVENTS) { - const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; - let changedEvent = false; - let exactInstalled = false; - - const normalizedEntries = entries.map((entry) => { - if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { - return entry; - } - - const nextHooks = []; - - for (const hook of entry.hooks) { - if (!hook || typeof hook !== "object") { - nextHooks.push(hook); - continue; - } - - if (hook.command === command) { - if (exactInstalled) { - changedEvent = true; - continue; - } - exactInstalled = true; - nextHooks.push(hook); - continue; - } - - if (isEpicLoopHookCommand(hook.command)) { - changedEvent = true; - if (!exactInstalled) { - exactInstalled = true; - nextHooks.push({ - ...hook, - command, - timeout: 30, - type: "command", - }); - } - continue; - } - - nextHooks.push(hook); - } - - return { - ...entry, - hooks: nextHooks, - }; - }); - - if (!exactInstalled) { - normalizedEntries.push({ - matcher: "", - hooks: [ - { - command, - timeout: 30, - type: "command", - }, - ], - }); - changedEvent = true; - } - - if (changedEvent) { - changes.push(eventName); - } - - hooks[eventName] = normalizedEntries; - } - - normalizedDocument.hooks = hooks; - - return { - changes, - command, - document: normalizedDocument, - }; -} - -function inspectHookConfig(root) { - const hooksPath = path.join(root, CODEX_HOOKS_RELATIVE_PATH); - const strict = readJsonStrict(hooksPath); - const writable = canWritePath(hooksPath); - const command = buildHookCommand(); - - if (strict.error) { - return { - command, - exists: strict.exists, - hooksPath, - invalid: true, - missingEvents: HOOK_EVENTS, - ready: false, - staleEvents: [], - writable, - }; - } - - const document = normalizeHookDocument(strict.value); - const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks) ? document.hooks : {}; - const missingEvents = []; - const staleEvents = []; - - for (const eventName of HOOK_EVENTS) { - const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; - const commands = entries.flatMap((entry) => { - if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { - return []; - } - return entry.hooks.map((hook) => hook?.command).filter((value) => typeof value === "string"); - }); - - if (!commands.includes(command)) { - missingEvents.push(eventName); - } - - if (commands.some((value) => isEpicLoopHookCommand(value) && value !== command)) { - staleEvents.push(eventName); - } - } - - return { - command, - exists: strict.exists, - hooksPath, - invalid: false, - missingEvents, - ready: missingEvents.length === 0 && staleEvents.length === 0, - staleEvents, - writable, - }; -} - -function inspectClaudeHookConfig(root) { - const settingsPath = path.join(root, CLAUDE_SETTINGS_RELATIVE_PATH); - const strict = readJsonStrict(settingsPath); - const writable = canWritePath(settingsPath); - const command = buildClaudeHookCommand(root); - - if (strict.error) { - return { - command, - error: strict.error, - exists: strict.exists, - invalid: true, - missingEvents: HOOK_EVENTS, - path: settingsPath, - ready: false, - staleEvents: [], - writable, - }; - } - - const document = normalizeHookDocument(strict.value); - const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks) ? document.hooks : {}; - const missingEvents = []; - const staleEvents = []; - - for (const eventName of HOOK_EVENTS) { - const entries = Array.isArray(hooks[eventName]) ? hooks[eventName] : []; - const commands = entries.flatMap((entry) => { - if (!entry || typeof entry !== "object" || !Array.isArray(entry.hooks)) { - return []; - } - return entry.hooks.map((hook) => hook?.command).filter((value) => typeof value === "string"); - }); - - if (!commands.includes(command)) { - missingEvents.push(eventName); - } - - if (commands.some((value) => isEpicLoopHookCommand(value) && value !== command)) { - staleEvents.push(eventName); - } - } - - return { - command, - error: null, - exists: strict.exists, - invalid: false, - missingEvents, - path: settingsPath, - ready: missingEvents.length === 0 && staleEvents.length === 0, - staleEvents, - writable, - }; -} - -function inspectClaudeStopHookBlockCap(env = process.env) { - const envVar = "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP"; - const rawValue = env[envVar]; - - if (rawValue === undefined || rawValue === "") { - return { - envVar, - rawValue: rawValue ?? null, - ready: false, - reason: "missing", - recommended: false, - value: null, - warning: null, - }; - } - - if (!/^\d+$/u.test(String(rawValue))) { - return { - envVar, - rawValue, - ready: false, - reason: "invalid", - recommended: false, - value: null, - warning: null, - }; - } - - const value = Number(rawValue); - - if (value !== 0 && value < 20) { - return { - envVar, - rawValue, - ready: false, - reason: "below-minimum", - recommended: false, - value, - warning: null, - }; - } - - if (value >= 20 && value <= 50) { - return { - envVar, - rawValue, - ready: true, - reason: null, - recommended: false, - value, - warning: "Loop mode may stop early and require manual continuation when CLAUDE_CODE_STOP_HOOK_BLOCK_CAP is between 20 and 50.", - }; - } - - return { - envVar, - rawValue, - ready: true, - reason: null, - recommended: true, - value, - warning: null, - }; -} - -function inspectAndRepairEpicCompatibility(root) { - const epicsDir = epicsRoot(root); - const result = { - checked: 0, - invalid: [], - repaired: [], - ready: true, - }; - - if (!fs.existsSync(epicsDir)) { - return result; - } - - const bindingModes = readActiveBindingModes(root); - - for (const entry of fs.readdirSync(epicsDir, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - - const slug = entry.name; - result.checked += 1; - const roadmap = inspectAndRepairRoadmapState(root, slug, result); - inspectAndRepairRuntimeState(root, slug, roadmap, bindingModes.get(slug), result); - } - - result.ready = result.invalid.length === 0; - return result; -} - -function inspectAndRepairRoadmapState(root, slug, result) { - const roadmapPath = roadmapStatePath(root, slug); - const strict = readJsonStrict(roadmapPath); - - if (strict.error) { - result.invalid.push({ - path: roadmapPath, - reason: strict.error, - slug, - type: "roadmap-state", - }); - return null; - } - - if (strict.exists && isPlainObject(strict.value)) { - return strict.value; - } - - const roadmap = createInitialRoadmapState({ slug, title: slug }); - writeJson(roadmapPath, roadmap); - result.repaired.push({ - path: roadmapPath, - slug, - type: "created-roadmap-state", - }); - return roadmap; -} - -function inspectAndRepairRuntimeState(root, slug, roadmap, bindingMode, result) { - const runtimePath = runtimeStatePath(root, slug); - const strict = readJsonStrict(runtimePath); - - if (strict.error) { - result.invalid.push({ - path: runtimePath, - reason: strict.error, - slug, - type: "runtime-state", - }); - return; - } - - if (!strict.exists) { - writeJson(runtimePath, buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode)); - result.repaired.push({ - path: runtimePath, - slug, - type: "created-runtime-state", - }); - return; - } - - if (!isPlainObject(strict.value)) { - result.invalid.push({ - path: runtimePath, - reason: "runtime state must be an object", - slug, - type: "runtime-state", - }); - return; - } - - const mode = typeof strict.value.mode === "string" && MODES.includes(strict.value.mode) ? strict.value.mode : (bindingMode ?? null); - if (!mode) { - result.invalid.push({ - path: runtimePath, - reason: "missing mode", - slug, - type: "runtime-state", - }); - return; - } - - if (strict.value.mode !== mode) { - writeJson(runtimePath, { - ...strict.value, - mode, - updated_at: nowIso(), - }); - result.repaired.push({ - path: runtimePath, - slug, - type: "repaired-runtime-mode", - }); - } -} - -function buildRuntimeStateFromStructuredData(slug, roadmap, bindingMode) { - const timestamp = nowIso(); - const normalizedRoadmap = isPlainObject(roadmap) ? roadmap : createInitialRoadmapState({ slug, title: slug }); - - return { - active_phase: formatRoadmapPhase(normalizedRoadmap, normalizedRoadmap.active_phase_id), - active_task: formatRoadmapTask(normalizedRoadmap, normalizedRoadmap.active_task_id), - created_at: timestamp, - description: null, - execution_brief: null, - implementation_submode: "techlead", - mode: bindingMode ?? "shaping", - slug, - title: typeof normalizedRoadmap.title === "string" && normalizedRoadmap.title.trim() ? normalizedRoadmap.title.trim() : slug, - updated_at: timestamp, - }; -} - -function readActiveBindingModes(root) { - const bindingsPath = path.join(sessionRoot(root), "session-bindings.json"); - const bindings = readJson(bindingsPath, {}); - const sessions = isPlainObject(bindings?.sessions) ? bindings.sessions : {}; - const modes = new Map(); - - for (const binding of Object.values(sessions)) { - if (!isPlainObject(binding) || binding.active !== true || typeof binding.epic_slug !== "string" || !MODES.includes(binding.mode)) { - continue; - } - - modes.set(binding.epic_slug, binding.mode); - } - - return modes; -} - -function formatRoadmapPhase(roadmap, phaseId) { - const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; - const phase = phases.find((candidate) => candidate?.id === phaseId) ?? phases[0]; - if (!phase) { - return null; - } - - const index = phases.indexOf(phase); - const number = index >= 0 ? index + 1 : 1; - const title = typeof phase.title === "string" && phase.title.trim() ? phase.title.trim() : `Phase ${number}`; - return `Phase ${number} - ${title}`; -} - -function formatRoadmapTask(roadmap, taskId) { - if (typeof taskId !== "string" || !taskId) { - return null; - } - - const phases = Array.isArray(roadmap.phases) ? roadmap.phases : []; - for (const phase of phases) { - const tasks = Array.isArray(phase?.tasks) ? phase.tasks : []; - const task = tasks.find((candidate) => candidate?.id === taskId); - if (task) { - return typeof task.title === "string" && task.title.trim() ? task.title.trim() : task.id; - } - } - - return null; -} - -function isPlainObject(value) { - return value && typeof value === "object" && !Array.isArray(value); -} - export function doctor(flags = {}) { const root = resolveRoot(flags.root); if (typeof flags.platform !== "string") { From 61154c93e4da15f2bdab1469202210da19101464 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:03:49 +0700 Subject: [PATCH 04/17] Split implementation loop helpers --- .epic-loop/epics/set-up/implementation-log.md | 5 + .epic-loop/epics/set-up/state-of-epic.md | 8 +- .epic-loop/epics/set-up/tracker.md | 4 +- .../epic-loop/scripts/lib/loop-artifacts.mjs | 535 ++++++++++++ .../epic-loop/scripts/lib/loop-claude-cap.mjs | 144 ++++ .../epic-loop/scripts/lib/loop-prompts.mjs | 99 +++ .../skills/epic-loop/scripts/lib/loop.mjs | 795 +----------------- 7 files changed, 818 insertions(+), 772 deletions(-) create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/lib/loop-artifacts.mjs create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/lib/loop-claude-cap.mjs create mode 100644 plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 9f8c3e0..7e95eb6 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -24,3 +24,8 @@ - Task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines - Verdict: checkpoint: split hooks.mjs into hook-config.mjs and hook-compatibility.mjs helpers without changing public hook exports or runtime behavior. hooks.mjs is now below the oxlint max-lines limit; pnpm run test:unit and pnpm run format:check pass. pnpm run lint still fails only on known loop.mjs max-lines debt, so Task 3 remains open. + +## 2026-07-07T14:03:37+00:00 - closed: split loop.mjs into loop-prompts.mjs, loop-claude-cap.mjs, and loop-artifacts.mjs after the earlier hooks.mjs split. hooks.mjs and loop.mjs are both below the oxlint max-lines limit. pnpm run validate passes and pnpm run test:unit passes 60/60. Task 3 is closed; Phase 1 verification remains active next. + +- Task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines +- Verdict: closed: split loop.mjs into loop-prompts.mjs, loop-claude-cap.mjs, and loop-artifacts.mjs after the earlier hooks.mjs split. hooks.mjs and loop.mjs are both below the oxlint max-lines limit. pnpm run validate passes and pnpm run test:unit passes 60/60. Task 3 is closed; Phase 1 verification remains active next. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index cc1174c..28c6f5e 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,22 +5,22 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 1 - Tooling Baseline -Active task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines +Active task: Phase 1 Task 4 - Verify lint and format tooling through the repository validation path ## Current State - Initial shaping captured the repository baseline: Node.js ESM, pnpm, existing syntax/package validation, and no current oxlint/Oxfmt configuration. - Phase 1 Task 1 is closed with accepted baseline debt: oxlint configuration, scripts, dependency, validation integration, and narrow baseline fixes are present; existing `max-lines` failures in `loop.mjs` and `hooks.mjs` are explicitly accepted for now and tracked as a Phase 1 refactor task before phase verification. - Phase 1 Task 2 is closed: Oxfmt config and check/write scripts are present; format check is non-mutating and included in validation; markdown formatting is intentionally excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn during verification. -- Phase 1 Task 3 is in progress: `hooks.mjs` has been split below the oxlint source-file line limit; `loop.mjs` remains the only known `max-lines` failure. +- Phase 1 Task 3 is closed: `hooks.mjs` and `loop.mjs` are split below the oxlint source-file line limit; `pnpm run validate` passes after the refactor. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- `pnpm run lint` and `pnpm run validate` currently fail until the planned Phase 1 refactor splits `loop.mjs` below the accepted `max-lines` limit. +- None currently known for Phase 1 tooling verification. ## Next Action -- Continue with Phase 1 Task 3: refactor oversized `loop.mjs` to satisfy oxlint `max-lines`. +- Continue with Phase 1 Task 4: verify lint and format tooling through the repository validation path. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index a730d5a..58c1052 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -40,13 +40,13 @@ Epic: Linting And Skill Checks - Acceptance: Format check and format write scripts exist; aggregate validation uses the check script only; ignored runtime/generated paths are explicit; markdown is excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: doing | Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines. +- [x] Kind: implementation | Status: done | Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines. - Outcome: Existing source files that exceed the accepted oxlint source-file limit are split into smaller modules without changing hook routing or implementation-loop behavior. - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs`, `plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs`, any extracted helper modules under the same `lib/` boundary, and related unit tests. - Acceptance: `pnpm run lint` no longer reports `max-lines` errors for `hooks.mjs` or `loop.mjs`; behavior covered by existing hook/loop tests remains unchanged. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: todo | Verify lint and format tooling through the repository validation path. +- [ ] Kind: verification | Status: doing | Verify lint and format tooling through the repository validation path. - Outcome: The first phase tooling is proven through the same command future contributors will run. - Surface: Local pnpm scripts, oxlint, Oxfmt, existing syntax/package validation. - Acceptance: Run `pnpm run validate` and any focused lint/format scripts; evidence includes exit codes and any required follow-up fixes, with no generated runtime artifacts committed. diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-artifacts.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-artifacts.mjs new file mode 100644 index 0000000..3449532 --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-artifacts.mjs @@ -0,0 +1,535 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { ensureDir, epicRuntimeRoot, nowIso, readRuntimePlatform } from "./common.mjs"; + +const PROGRESS_FIELD_LABELS = { + current_iteration: "Current iteration", + current_role: "Current role", + duration_ms: "Duration", + ended_at: "Ended at", + last_reason: "Last reason", + next_role: "Next role", + phase: "Phase", + prompt_file: "Prompt file", + reason: "Reason", + role: "Role", + session_id: "Session", + slug: "Slug", + started_at: "Started at", + status: "Status", + stop_hook_active: "Stop hook active", + task: "Task", + turn_id: "Turn", +}; + +export function appendLoopLog(projectRoot, entry) { + const slug = entry.slug; + if (!slug) { + return; + } + + const executionPath = executionDir(projectRoot, slug); + appendJsonLine(path.join(executionPath, "progress-log.jsonl"), entry); + appendProgressMarkdown(path.join(executionPath, "progress-log.md"), entry); + rebuildProgressReport(projectRoot, slug); +} + +export function appendPromptLog(projectRoot, entry) { + const promptLogJsonlPath = path.join(executionDir(projectRoot, entry.slug), "prompt-log.jsonl"); + const promptLogMarkdownPath = path.join(executionDir(projectRoot, entry.slug), "prompt-log.md"); + appendJsonLine(promptLogJsonlPath, entry); + appendPromptMarkdown(promptLogMarkdownPath, entry); +} + +function appendPromptMarkdown(filePath, entry) { + ensureMarkdownFile(filePath, "# Implementation Prompt Log\n"); + fs.appendFileSync( + filePath, + [ + "", + `## ${entry.timestamp} | turn ${entry.iteration} | ${entry.role}`, + "", + `- Session: \`${entry.session_id ?? "unknown"}\``, + `- Turn: \`${entry.turn_id ?? "unknown"}\``, + `- Prompt source: \`${entry.prompt_file ?? "inline"}\``, + "", + "````text", + entry.prompt, + "````", + "", + ].join("\n"), + "utf8", + ); +} + +export function appendRoleReportIfPresent(projectRoot, slug, loop, payload, timestamp, reportRole) { + const message = readAssistantReportMessage(projectRoot, payload); + if (!message) { + return null; + } + + const executionPath = executionDir(projectRoot, slug); + const latestReportPath = path.join(executionPath, `latest-${reportRole}-report.md`); + const report = { + iteration: Number.isFinite(loop.iteration) ? loop.iteration : null, + message, + role: reportRole, + session_id: payload.session_id ?? null, + slug, + timestamp, + turn_id: payload.turn_id ?? null, + }; + + appendJsonLine(path.join(executionPath, `${reportRole}-reports.jsonl`), report); + appendRoleReportMarkdown(path.join(executionPath, `${reportRole}-reports.md`), reportRole, report); + writeText(latestReportPath, formatEngineerReport(report)); + + return { + latest_report_path: path.relative(projectRoot, latestReportPath), + timestamp, + }; +} + +function readAssistantReportMessage(projectRoot, payload) { + const platform = readRuntimePlatform(projectRoot).platform; + + if (platform === "claude-code") { + const payloadMessage = typeof payload.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; + return payloadMessage || readLatestClaudeAssistantMessage(payload.transcript_path); + } + + if (platform === "codex") { + return typeof payload.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; + } + + return ""; +} + +function readLatestClaudeAssistantMessage(transcriptPath) { + if (typeof transcriptPath !== "string" || !transcriptPath) { + return ""; + } + + let content; + try { + content = fs.readFileSync(transcriptPath, "utf8"); + } catch { + return ""; + } + + let latestMessage = ""; + for (const line of content.split(/\r?\n/u)) { + if (!line.trim()) { + continue; + } + + const item = readJsonLine(line); + if (!item || !isAssistantTranscriptItem(item)) { + continue; + } + + const message = extractTranscriptText(item.message?.content ?? item.content ?? item.text); + if (message) { + latestMessage = message; + } + } + + return latestMessage; +} + +function isAssistantTranscriptItem(item) { + return item?.role === "assistant" || item?.type === "assistant" || item?.message?.role === "assistant" || item?.message?.type === "assistant"; +} + +function extractTranscriptText(value) { + if (typeof value === "string") { + return value.trim(); + } + + if (Array.isArray(value)) { + return value + .map((item) => extractTranscriptText(typeof item === "string" ? item : (item?.text ?? item?.content))) + .filter(Boolean) + .join("\n") + .trim(); + } + + if (value && typeof value === "object") { + return extractTranscriptText(value.text ?? value.content); + } + + return ""; +} + +function appendRoleReportMarkdown(filePath, reportRole, report) { + ensureMarkdownFile(filePath, `# ${capitalizeRole(reportRole)} Reports\n`); + fs.appendFileSync(filePath, `\n${formatEngineerReport(report)}`, "utf8"); +} + +function formatEngineerReport(report) { + return [ + `## ${report.timestamp} | turn ${report.iteration ?? "?"}`, + "", + `- Session: \`${report.session_id ?? "unknown"}\``, + `- Turn: \`${report.turn_id ?? "unknown"}\``, + "", + "````text", + report.message, + "````", + "", + ].join("\n"); +} + +function capitalizeRole(role) { + return role.charAt(0).toUpperCase() + role.slice(1); +} + +function appendProgressMarkdown(filePath, entry) { + ensureMarkdownFile(filePath, "# Implementation Progress Log\n"); + fs.appendFileSync( + filePath, + ["", `## ${entry.timestamp ?? nowIso()} | ${entry.action ?? "event"}`, "", progressSummary(entry), "", ...formatProgressDetails(entry), ""].join("\n"), + "utf8", + ); +} + +export function rebuildProgressMarkdown(projectRoot, slug) { + const executionPath = executionDir(projectRoot, slug); + const markdownPath = path.join(executionPath, "progress-log.md"); + const events = readJsonLines(path.join(executionPath, "progress-log.jsonl")); + + writeText(markdownPath, "# Implementation Progress Log\n"); + for (const event of events) { + appendProgressMarkdown(markdownPath, event); + } +} + +export function rebuildProgressReport(projectRoot, slug) { + const executionPath = executionDir(projectRoot, slug); + const events = readJsonLines(path.join(executionPath, "progress-log.jsonl")); + const promptEvents = readJsonLines(path.join(executionPath, "prompt-log.jsonl")); + const reportPath = path.join(executionPath, "progress-report.md"); + const firstTimestamp = firstEventTimestamp(events); + const lastTimestamp = lastEventTimestamp(events); + const completedTurns = events.filter((event) => event.action === "turn-stop"); + const interruptedTurns = events.filter((event) => event.action === "turn-interrupted"); + const endedTurns = [...completedTurns, ...interruptedTurns].sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? ""))); + const roleCommands = events.filter((event) => event.action === "role-command"); + const activeMs = sum(endedTurns.map((event) => Number(event.duration_ms) || 0)); + const elapsedMs = firstTimestamp && lastTimestamp ? Math.max(0, Date.parse(lastTimestamp) - Date.parse(firstTimestamp)) : 0; + const idleMs = Math.max(0, elapsedMs - activeMs); + const byRole = groupDurations(endedTurns, (event) => event.role ?? "unknown"); + const byPhase = groupNestedDurations(endedTurns); + const openTurns = collectOpenTurns(events); + const generatedAt = nowIso(); + + writeText( + reportPath, + [ + "# Implementation Progress Report", + "", + `Generated: ${generatedAt}`, + "", + "## Work Window", + "", + `- First event: ${firstTimestamp ?? "n/a"}`, + `- Last event: ${lastTimestamp ?? "n/a"}`, + `- Elapsed wall time: ${formatDuration(elapsedMs)}`, + `- Active turn time: ${formatDuration(activeMs)}`, + `- Observed idle or paused time: ${formatDuration(idleMs)}`, + `- Completed turns: ${completedTurns.length}`, + `- Interrupted turns: ${interruptedTurns.length}`, + `- Prompt entries: ${promptEvents.length}`, + "", + "## Time By Role", + "", + ...formatRoleDurations(byRole), + "", + "## Phases And Tasks", + "", + ...formatPhaseDurations(byPhase), + "", + "## Role Commands", + "", + ...formatRoleCommands(roleCommands), + "", + "## Open Turns", + "", + ...formatOpenTurns(openTurns), + "", + ].join("\n"), + ); +} + +function formatRoleDurations(byRole) { + const entries = Object.entries(byRole); + if (entries.length === 0) { + return ["- No completed turns yet."]; + } + + return entries.map(([role, item]) => `- ${role}: ${formatDuration(item.durationMs)} across ${item.turns} turn${item.turns === 1 ? "" : "s"}`); +} + +function formatPhaseDurations(byPhase) { + const lines = []; + const entries = Object.entries(byPhase); + + if (entries.length === 0) { + return ["- No completed turns yet."]; + } + + for (const [phase, phaseData] of entries) { + lines.push(`### ${phase}`); + lines.push(""); + lines.push(`- Active time: ${formatDuration(phaseData.durationMs)}`); + lines.push(`- Turns: ${phaseData.turns}`); + lines.push(""); + + for (const [task, taskData] of Object.entries(phaseData.tasks)) { + lines.push(`#### ${task}`); + lines.push(""); + lines.push(`- Active time: ${formatDuration(taskData.durationMs)}`); + lines.push(`- Turns: ${taskData.turns}`); + for (const turn of taskData.turnsList) { + lines.push( + `- Turn ${turn.iteration ?? "?"} | ${turn.role ?? "unknown"} | ${turn.session_id ?? "unknown"} | ${turn.started_at ?? "?"} -> ${turn.ended_at ?? "?"} | ${formatDuration(Number(turn.duration_ms) || 0)}`, + ); + } + lines.push(""); + } + } + + return lines; +} + +function formatRoleCommands(commands) { + if (commands.length === 0) { + return ["- No role commands recorded yet."]; + } + + return commands.map((command) => { + const parts = [`${command.timestamp}`, `current=${command.current_role ?? "unknown"}`, `next=${command.next_role ?? "unknown"}`, `reason=${command.reason ?? "n/a"}`]; + if (command.prompt_file) { + parts.push(`prompt=${command.prompt_file}`); + } + return `- ${parts.join(" | ")}`; + }); +} + +function formatOpenTurns(openTurns) { + if (openTurns.length === 0) { + return ["- No open turns."]; + } + + return openTurns.map((turn) => `- Turn ${turn.iteration ?? "?"} | ${turn.role ?? "unknown"} | started ${turn.timestamp}`); +} + +function progressSummary(entry) { + switch (entry.action) { + case "loop-start": + return `Loop started. Next role: \`${entry.next_role ?? "unknown"}\`.`; + case "role-command": + return `Role command set next role to \`${entry.next_role ?? "unknown"}\`${entry.reason ? `: ${entry.reason}.` : "."}`; + case "turn-start": + return `Turn ${entry.iteration ?? "?"} started for \`${entry.role ?? "unknown"}\`.`; + case "turn-stop": + return `Turn ${entry.iteration ?? "?"} stopped after ${formatDuration(Number(entry.duration_ms) || 0)}.`; + case "turn-interrupted": + return `Turn ${entry.iteration ?? "?"} was interrupted after ${formatDuration(Number(entry.duration_ms) || 0)}.`; + case "skip": + return `Continuation skipped: ${entry.reason ?? "no reason recorded"}.`; + default: + return "Progress event recorded."; + } +} + +function formatProgressDetails(entry) { + return Object.entries(entry) + .filter(([key, value]) => key !== "action" && key !== "timestamp" && value !== undefined) + .map(([key, value]) => `- ${formatFieldName(key)}: ${formatFieldValue(key, value)}`); +} + +function formatFieldName(key) { + if (PROGRESS_FIELD_LABELS[key]) { + return PROGRESS_FIELD_LABELS[key]; + } + + return key.replace(/_/gu, " ").replace(/\b\w/gu, (letter) => letter.toUpperCase()); +} + +function formatFieldValue(key, value) { + if (value === null) { + return "`null`"; + } + + if (key === "duration_ms" && typeof value === "number") { + return `${formatDuration(value)} (${value} ms)`; + } + + if (typeof value === "string") { + return value ? `\`${value}\`` : '`""`'; + } + + if (typeof value === "number" || typeof value === "boolean") { + return `\`${String(value)}\``; + } + + return `\`${JSON.stringify(value)}\``; +} + +function collectOpenTurns(events) { + const starts = events.filter((event) => event.action === "turn-start"); + const endedKeys = new Set(events.filter((event) => event.action === "turn-stop" || event.action === "turn-interrupted").map(turnKey)); + return starts.filter((event) => !endedKeys.has(turnKey(event))); +} + +function groupDurations(events, keyFn) { + const groups = {}; + for (const event of events) { + const key = keyFn(event); + const current = groups[key] ?? { durationMs: 0, turns: 0 }; + current.durationMs += Number(event.duration_ms) || 0; + current.turns += 1; + groups[key] = current; + } + return groups; +} + +function groupNestedDurations(events) { + const groups = {}; + + for (const event of events) { + const phase = event.phase || "Unassigned phase"; + const task = event.task || "Unassigned task"; + const durationMs = Number(event.duration_ms) || 0; + const phaseData = groups[phase] ?? { durationMs: 0, tasks: {}, turns: 0 }; + const taskData = phaseData.tasks[task] ?? { durationMs: 0, turns: 0, turnsList: [] }; + + phaseData.durationMs += durationMs; + phaseData.turns += 1; + taskData.durationMs += durationMs; + taskData.turns += 1; + taskData.turnsList.push(event); + phaseData.tasks[task] = taskData; + groups[phase] = phaseData; + } + + return groups; +} + +function firstEventTimestamp(events) { + return ( + events + .map((event) => event.timestamp) + .filter(Boolean) + .sort()[0] ?? null + ); +} + +function lastEventTimestamp(events) { + return ( + events + .map((event) => event.timestamp) + .filter(Boolean) + .sort() + .at(-1) ?? null + ); +} + +export function durationMsBetween(start, end) { + const startMs = Date.parse(start ?? ""); + const endMs = Date.parse(end ?? ""); + if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { + return null; + } + return Math.max(0, endMs - startMs); +} + +function formatDuration(ms) { + if (!Number.isFinite(ms) || ms <= 0) { + return "0s"; + } + + const seconds = Math.round(ms / 1000); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const restSeconds = seconds % 60; + const parts = []; + + if (hours > 0) { + parts.push(`${hours}h`); + } + if (minutes > 0) { + parts.push(`${minutes}m`); + } + if (restSeconds > 0 || parts.length === 0) { + parts.push(`${restSeconds}s`); + } + + return parts.join(" "); +} + +function readJsonLines(filePath) { + if (!fs.existsSync(filePath)) { + return []; + } + + return fs + .readFileSync(filePath, "utf8") + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function readJsonLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} + +function appendJsonLine(filePath, value) { + ensureDir(path.dirname(filePath)); + fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); +} + +function ensureMarkdownFile(filePath, header) { + if (fs.existsSync(filePath)) { + return; + } + writeText(filePath, `${header.trim()}\n`); +} + +function writeText(filePath, text) { + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, text, "utf8"); +} + +export function executionDir(projectRoot, slug) { + return epicRuntimeRoot(projectRoot, slug); +} + +function sum(values) { + return values.reduce((acc, value) => acc + value, 0); +} + +function turnKey(event) { + return `${event.iteration ?? "?"}:${event.role ?? "unknown"}`; +} + +export function countLines(filePath) { + if (!fs.existsSync(filePath)) { + return 0; + } + + return fs.readFileSync(filePath, "utf8").split(/\r?\n/u).filter(Boolean).length; +} diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-claude-cap.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-claude-cap.mjs new file mode 100644 index 0000000..18620da --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-claude-cap.mjs @@ -0,0 +1,144 @@ +import { readRuntimePlatform, runtimeStatePath, writeJson } from "./common.mjs"; + +const CLAUDE_BLOCK_CAP_ENV = "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP"; +const CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING = 1; + +export function ensureClaudeBlockCapMetadata(projectRoot, slug, runtime, loop, timestamp) { + const nextLoop = withClaudeBlockCapMetadata(projectRoot, loop, timestamp); + + if (nextLoop === loop) { + return { loop, runtime }; + } + + const nextRuntime = { + ...runtime, + implementation_loop: nextLoop, + updated_at: timestamp, + }; + + writeJson(runtimeStatePath(projectRoot, slug), nextRuntime); + return { + loop: nextLoop, + runtime: nextRuntime, + }; +} + +export function withClaudeBlockCapMetadata(projectRoot, loop, timestamp) { + if (readRuntimePlatform(projectRoot).platform !== "claude-code") { + return loop; + } + + const existing = normalizeObject(loop.claude_code_stop_hook_block_cap); + if (existing.recorded_at) { + return loop; + } + + return { + ...loop, + claude_code_stop_hook_block_cap: { + ...readClaudeBlockCapEnv(), + consecutive_blocks: Number.isFinite(existing.consecutive_blocks) ? existing.consecutive_blocks : 0, + last_block_at: existing.last_block_at ?? null, + proximity_remaining: CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING, + proximity_routed_at: existing.proximity_routed_at ?? null, + recorded_at: timestamp, + }, + }; +} + +export function isClaudeBlockCapExhausted(loop) { + const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); + if (cap.uncapped === true || cap.finite !== true || !Number.isFinite(cap.value)) { + return false; + } + + const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; + return cap.value - consecutiveBlocks <= 0; +} + +export function resetClaudeBlockCountForTurn(projectRoot, slug, runtime, loop, timestamp) { + const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); + const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; + + if (consecutiveBlocks === 0 && !cap.proximity_routed_at) { + return { loop, runtime }; + } + + const nextLoop = { + ...loop, + claude_code_stop_hook_block_cap: { + ...cap, + consecutive_blocks: 0, + proximity_routed_at: null, + }, + }; + const nextRuntime = { + ...runtime, + implementation_loop: nextLoop, + updated_at: timestamp, + }; + + writeJson(runtimeStatePath(projectRoot, slug), nextRuntime); + return { loop: nextLoop, runtime: nextRuntime }; +} + +export function getClaudeBlockCapProximityRoute(loop, timestamp) { + const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); + if (cap.uncapped === true || cap.finite !== true || !Number.isFinite(cap.value)) { + return null; + } + + const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; + const remainingBlocks = cap.value - consecutiveBlocks; + if (remainingBlocks > CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING || cap.proximity_routed_at) { + return null; + } + + return { + reason: [ + `${CLAUDE_BLOCK_CAP_ENV}-proximity`, + `The implementation loop is at ${consecutiveBlocks}/${cap.value} consecutive Claude Code Stop-hook block continuations.`, + "Route to manager before Claude Code forces the run to stop.", + `Tell the user the loop is stopping because it is close to ${CLAUDE_BLOCK_CAP_ENV}.`, + "Tell the user to manually ask the agent to continue loop mode when ready.", + ].join(" "), + routed_at: timestamp, + }; +} + +export function incrementClaudeBlockCount(loop, platform, timestamp, capProximityRoute) { + if (platform !== "claude-code") { + return loop; + } + + const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); + const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; + + return { + ...loop, + claude_code_stop_hook_block_cap: { + ...cap, + consecutive_blocks: consecutiveBlocks + 1, + last_block_at: timestamp, + proximity_routed_at: capProximityRoute?.routed_at ?? cap.proximity_routed_at ?? null, + }, + }; +} + +function readClaudeBlockCapEnv() { + const rawValue = process.env[CLAUDE_BLOCK_CAP_ENV] ?? null; + const numericValue = typeof rawValue === "string" && /^\d+$/u.test(rawValue) ? Number(rawValue) : null; + + return { + env_var: CLAUDE_BLOCK_CAP_ENV, + finite: numericValue !== null && numericValue > 0, + raw_value: rawValue, + uncapped: numericValue === 0, + valid: numericValue !== null, + value: numericValue, + }; +} + +function normalizeObject(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs new file mode 100644 index 0000000..72e17db --- /dev/null +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs @@ -0,0 +1,99 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SKILL_DIR = path.dirname(path.dirname(LIB_DIR)); +export const MANAGER_PROMPT_TEMPLATE_PATH = path.join(SKILL_DIR, "assets", "templates", "implementation-manager-prompt.md"); +export const TECHLEAD_PROMPT_TEMPLATE_PATH = path.join(SKILL_DIR, "assets", "templates", "implementation-techlead-prompt.md"); +const LATEST_ENGINEER_REPORT_RELATIVE_PATH = ".runtime/latest-engineer-report.md"; +const LATEST_MANAGER_REPORT_RELATIVE_PATH = ".runtime/latest-manager-report.md"; +const CLAUDE_MANUAL_CONTINUE_NOTE = [ + "", + "## Claude Code Continuation Note", + "", + "This run is approaching `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`, so the implementation loop will pause after this turn instead of chaining into the next role automatically.", + "Tell the user that when they are ready to resume they should send: `continue loop mode`.", +].join("\n"); + +export function buildTechleadPrompt(slug, iteration) { + const promptPath = `.epic-loop/epics/${slug}/.runtime/current-engineer-prompt.md`; + const latestEngineerReportPath = `.epic-loop/epics/${slug}/${LATEST_ENGINEER_REPORT_RELATIVE_PATH}`; + + return renderTemplate(fs.readFileSync(TECHLEAD_PROMPT_TEMPLATE_PATH, "utf8"), { + EngineerPromptPath: promptPath, + EpicSlug: slug, + Iteration: String(iteration), + LatestEngineerReportPath: latestEngineerReportPath, + SkillDir: SKILL_DIR, + }); +} + +export function buildManagerPrompt(slug, iteration, housekeepingReason) { + const latestManagerReportPath = `.epic-loop/epics/${slug}/${LATEST_MANAGER_REPORT_RELATIVE_PATH}`; + + return renderTemplate(fs.readFileSync(MANAGER_PROMPT_TEMPLATE_PATH, "utf8"), { + EpicSlug: slug, + HousekeepingReason: housekeepingReason ?? "implementation-housekeeping", + Iteration: String(iteration), + LatestManagerReportPath: latestManagerReportPath, + SkillDir: SKILL_DIR, + }); +} + +export function buildEngineerPrompt(projectRoot, loop, iteration) { + const promptFile = loop.prompt_file; + const absolutePromptPath = promptFile ? path.resolve(projectRoot, promptFile) : null; + const promptText = absolutePromptPath && fs.existsSync(absolutePromptPath) ? fs.readFileSync(absolutePromptPath, "utf8").trim() : ""; + + return [ + `Focused implementation task ${iteration}.`, + "", + "Execute the task brief below. Keep the work narrow and do not widen the scope.", + "", + promptText ? "## Task Brief" : "## Task Brief Missing", + "", + promptText || "No task brief was found. Report that the brief is missing and stop.", + "", + "## Report", + "", + "When finished, reply with a concise factual report:", + "", + "- changed files", + "- implemented behavior", + "- verification run and results", + "- blockers, gaps, or follow-up notes", + ].join("\n"); +} + +export function normalizePromptFile(root, slug, value) { + if (typeof value !== "string" || value.trim().length === 0) { + return null; + } + + const relative = value.trim(); + if (path.isAbsolute(relative)) { + return path.relative(root, relative); + } + + const normalized = path.normalize(relative); + if (normalized.startsWith("..")) { + throw new Error(`Prompt file must stay inside the project: ${relative}`); + } + + if (!normalized.startsWith(`.epic-loop${path.sep}epics${path.sep}${slug}${path.sep}`) && !normalized.startsWith(`.epic-loop/epics/${slug}/`)) { + throw new Error(`Prompt file must be inside .epic-loop/epics/${slug}/.`); + } + + return normalized; +} + +export function appendClaudeManualContinueNote(prompt) { + return `${prompt.trim()}\n${CLAUDE_MANUAL_CONTINUE_NOTE}`; +} + +function renderTemplate(template, values) { + return Object.entries(values) + .reduce((result, [key, value]) => result.replaceAll(`-<<*{{${key}}}*>>-`, value), template) + .trim(); +} diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs index 3f40196..1e002da 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs @@ -1,46 +1,38 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { ensureDir, epicRuntimeRoot, epicsRoot, nowIso, readJson, readRuntimePlatform, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; +import { epicsRoot, nowIso, readJson, readRuntimePlatform, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; +import { + appendLoopLog, + appendPromptLog, + appendRoleReportIfPresent, + countLines, + durationMsBetween, + executionDir, + rebuildProgressMarkdown, + rebuildProgressReport, +} from "./loop-artifacts.mjs"; +import { + ensureClaudeBlockCapMetadata, + getClaudeBlockCapProximityRoute, + incrementClaudeBlockCount, + isClaudeBlockCapExhausted, + resetClaudeBlockCountForTurn, + withClaudeBlockCapMetadata, +} from "./loop-claude-cap.mjs"; +import { + MANAGER_PROMPT_TEMPLATE_PATH, + TECHLEAD_PROMPT_TEMPLATE_PATH, + appendClaudeManualContinueNote, + buildEngineerPrompt, + buildManagerPrompt, + buildTechleadPrompt, + normalizePromptFile, +} from "./loop-prompts.mjs"; import { readRoadmapSummary } from "./roadmap.mjs"; const LOOP_ROLES = ["manager", "techlead", "engineer", "idle"]; const WAITING_FOR_TURN_TRANSITION = "awaiting-transition"; -const LIB_DIR = path.dirname(fileURLToPath(import.meta.url)); -const SKILL_DIR = path.dirname(path.dirname(LIB_DIR)); -const MANAGER_PROMPT_TEMPLATE_PATH = path.join(SKILL_DIR, "assets", "templates", "implementation-manager-prompt.md"); -const TECHLEAD_PROMPT_TEMPLATE_PATH = path.join(SKILL_DIR, "assets", "templates", "implementation-techlead-prompt.md"); -const LATEST_ENGINEER_REPORT_RELATIVE_PATH = ".runtime/latest-engineer-report.md"; -const LATEST_MANAGER_REPORT_RELATIVE_PATH = ".runtime/latest-manager-report.md"; -const CLAUDE_BLOCK_CAP_ENV = "CLAUDE_CODE_STOP_HOOK_BLOCK_CAP"; -const CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING = 1; -const CLAUDE_MANUAL_CONTINUE_NOTE = [ - "", - "## Claude Code Continuation Note", - "", - "This run is approaching `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP`, so the implementation loop will pause after this turn instead of chaining into the next role automatically.", - "Tell the user that when they are ready to resume they should send: `continue loop mode`.", -].join("\n"); -const PROGRESS_FIELD_LABELS = { - current_iteration: "Current iteration", - current_role: "Current role", - duration_ms: "Duration", - ended_at: "Ended at", - last_reason: "Last reason", - next_role: "Next role", - phase: "Phase", - prompt_file: "Prompt file", - reason: "Reason", - role: "Role", - session_id: "Session", - slug: "Slug", - started_at: "Started at", - status: "Status", - stop_hook_active: "Stop hook active", - task: "Task", - turn_id: "Turn", -}; export function startImplementationLoop(projectRoot, { sessionId, slug }) { const timestamp = nowIso(); @@ -256,7 +248,7 @@ export function maybeBuildImplementationContinuation(projectRoot, payload, bindi ? buildManagerPrompt(slug, iteration, capProximityRoute?.reason ?? loop.last_reason ?? null) : role === "techlead" ? buildTechleadPrompt(slug, iteration) - : buildEngineerPrompt(projectRoot, slug, loop, iteration); + : buildEngineerPrompt(projectRoot, loop, iteration); // Only the final housekeeping turn before a finite-cap pause needs the manual-continue // note. With an uncapped run (CLAUDE_CODE_STOP_HOOK_BLOCK_CAP=0) roles chain automatically, // so appending it to every prompt would misinform the agent. @@ -417,78 +409,6 @@ export function rebuildProgressArtifacts(flags = {}) { console.log(`Rebuilt implementation progress artifacts for ${slug}.`); } -function buildTechleadPrompt(slug, iteration) { - const promptPath = `.epic-loop/epics/${slug}/.runtime/current-engineer-prompt.md`; - const latestEngineerReportPath = `.epic-loop/epics/${slug}/${LATEST_ENGINEER_REPORT_RELATIVE_PATH}`; - - return renderTemplate(fs.readFileSync(TECHLEAD_PROMPT_TEMPLATE_PATH, "utf8"), { - EngineerPromptPath: promptPath, - EpicSlug: slug, - Iteration: String(iteration), - LatestEngineerReportPath: latestEngineerReportPath, - SkillDir: SKILL_DIR, - }); -} - -function buildManagerPrompt(slug, iteration, housekeepingReason) { - const latestManagerReportPath = `.epic-loop/epics/${slug}/${LATEST_MANAGER_REPORT_RELATIVE_PATH}`; - - return renderTemplate(fs.readFileSync(MANAGER_PROMPT_TEMPLATE_PATH, "utf8"), { - EpicSlug: slug, - HousekeepingReason: housekeepingReason ?? "implementation-housekeeping", - Iteration: String(iteration), - LatestManagerReportPath: latestManagerReportPath, - SkillDir: SKILL_DIR, - }); -} - -function buildEngineerPrompt(projectRoot, slug, loop, iteration) { - const promptFile = loop.prompt_file; - const absolutePromptPath = promptFile ? path.resolve(projectRoot, promptFile) : null; - const promptText = absolutePromptPath && fs.existsSync(absolutePromptPath) ? fs.readFileSync(absolutePromptPath, "utf8").trim() : ""; - - return [ - `Focused implementation task ${iteration}.`, - "", - "Execute the task brief below. Keep the work narrow and do not widen the scope.", - "", - promptText ? "## Task Brief" : "## Task Brief Missing", - "", - promptText || "No task brief was found. Report that the brief is missing and stop.", - "", - "## Report", - "", - "When finished, reply with a concise factual report:", - "", - "- changed files", - "- implemented behavior", - "- verification run and results", - "- blockers, gaps, or follow-up notes", - ].join("\n"); -} - -function normalizePromptFile(root, slug, value) { - if (typeof value !== "string" || value.trim().length === 0) { - return null; - } - - const relative = value.trim(); - if (path.isAbsolute(relative)) { - return path.relative(root, relative); - } - - const normalized = path.normalize(relative); - if (normalized.startsWith("..")) { - throw new Error(`Prompt file must stay inside the project: ${relative}`); - } - - if (!normalized.startsWith(`.epic-loop${path.sep}epics${path.sep}${slug}${path.sep}`) && !normalized.startsWith(`.epic-loop/epics/${slug}/`)) { - throw new Error(`Prompt file must be inside .epic-loop/epics/${slug}/.`); - } - - return normalized; -} - function mergeEpicStateIntoRuntime(projectRoot, slug, runtime) { const summary = readRoadmapStateSummary(projectRoot, slug) ?? readEpicStateSummary(projectRoot, slug); @@ -499,142 +419,6 @@ function mergeEpicStateIntoRuntime(projectRoot, slug, runtime) { }; } -function ensureClaudeBlockCapMetadata(projectRoot, slug, runtime, loop, timestamp) { - const nextLoop = withClaudeBlockCapMetadata(projectRoot, loop, timestamp); - - if (nextLoop === loop) { - return { loop, runtime }; - } - - const nextRuntime = { - ...runtime, - implementation_loop: nextLoop, - updated_at: timestamp, - }; - - writeJson(runtimeStatePath(projectRoot, slug), nextRuntime); - return { - loop: nextLoop, - runtime: nextRuntime, - }; -} - -function withClaudeBlockCapMetadata(projectRoot, loop, timestamp) { - if (readRuntimePlatform(projectRoot).platform !== "claude-code") { - return loop; - } - - const existing = normalizeObject(loop.claude_code_stop_hook_block_cap); - if (existing.recorded_at) { - return loop; - } - - return { - ...loop, - claude_code_stop_hook_block_cap: { - ...readClaudeBlockCapEnv(), - consecutive_blocks: Number.isFinite(existing.consecutive_blocks) ? existing.consecutive_blocks : 0, - last_block_at: existing.last_block_at ?? null, - proximity_remaining: CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING, - proximity_routed_at: existing.proximity_routed_at ?? null, - recorded_at: timestamp, - }, - }; -} - -function readClaudeBlockCapEnv() { - const rawValue = process.env[CLAUDE_BLOCK_CAP_ENV] ?? null; - const numericValue = typeof rawValue === "string" && /^\d+$/u.test(rawValue) ? Number(rawValue) : null; - - return { - env_var: CLAUDE_BLOCK_CAP_ENV, - finite: numericValue !== null && numericValue > 0, - raw_value: rawValue, - uncapped: numericValue === 0, - valid: numericValue !== null, - value: numericValue, - }; -} - -function isClaudeBlockCapExhausted(loop) { - const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); - if (cap.uncapped === true || cap.finite !== true || !Number.isFinite(cap.value)) { - return false; - } - - const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; - return cap.value - consecutiveBlocks <= 0; -} - -function resetClaudeBlockCountForTurn(projectRoot, slug, runtime, loop, timestamp) { - const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); - const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; - - if (consecutiveBlocks === 0 && !cap.proximity_routed_at) { - return { loop, runtime }; - } - - const nextLoop = { - ...loop, - claude_code_stop_hook_block_cap: { - ...cap, - consecutive_blocks: 0, - proximity_routed_at: null, - }, - }; - const nextRuntime = { - ...runtime, - implementation_loop: nextLoop, - updated_at: timestamp, - }; - - writeJson(runtimeStatePath(projectRoot, slug), nextRuntime); - return { loop: nextLoop, runtime: nextRuntime }; -} - -function getClaudeBlockCapProximityRoute(loop, timestamp) { - const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); - if (cap.uncapped === true || cap.finite !== true || !Number.isFinite(cap.value)) { - return null; - } - - const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; - const remainingBlocks = cap.value - consecutiveBlocks; - if (remainingBlocks > CLAUDE_BLOCK_CAP_PROXIMITY_REMAINING || cap.proximity_routed_at) { - return null; - } - - return { - reason: [ - `${CLAUDE_BLOCK_CAP_ENV}-proximity`, - `The implementation loop is at ${consecutiveBlocks}/${cap.value} consecutive Claude Code Stop-hook block continuations.`, - "Route to manager before Claude Code forces the run to stop.", - `Tell the user the loop is stopping because it is close to ${CLAUDE_BLOCK_CAP_ENV}.`, - "Tell the user to manually ask the agent to continue loop mode when ready.", - ].join(" "), - routed_at: timestamp, - }; -} - -function incrementClaudeBlockCount(loop, platform, timestamp, capProximityRoute) { - if (platform !== "claude-code") { - return loop; - } - - const cap = normalizeObject(loop.claude_code_stop_hook_block_cap); - const consecutiveBlocks = Number.isFinite(cap.consecutive_blocks) ? cap.consecutive_blocks : 0; - - return { - ...loop, - claude_code_stop_hook_block_cap: { - ...cap, - consecutive_blocks: consecutiveBlocks + 1, - last_block_at: timestamp, - proximity_routed_at: capProximityRoute?.routed_at ?? cap.proximity_routed_at ?? null, - }, - }; -} - function readEpicStateSummary(projectRoot, slug) { const statePath = path.join(epicsRoot(projectRoot), slug, "state-of-epic.md"); if (!fs.existsSync(statePath)) { @@ -671,18 +455,6 @@ function readStateLine(text, label) { return value; } -function appendLoopLog(projectRoot, entry) { - const slug = entry.slug; - if (!slug) { - return; - } - - const executionPath = executionDir(projectRoot, slug); - appendJsonLine(path.join(executionPath, "progress-log.jsonl"), entry); - appendProgressMarkdown(path.join(executionPath, "progress-log.md"), entry); - rebuildProgressReport(projectRoot, slug); -} - function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } @@ -691,12 +463,6 @@ function hasOpenTurn(loop) { return Boolean(loop.current_role && loop.active_turn_started_at && !loop.active_turn_stopped_at && loop.status === "running"); } -function renderTemplate(template, values) { - return Object.entries(values) - .reduce((result, [key, value]) => result.replaceAll(`-<<*{{${key}}}*>>-`, value), template) - .trim(); -} - function recordTurnStopIfNeeded(projectRoot, slug, runtime, loop, payload, timestamp) { if (!loop.current_role || !loop.active_turn_started_at || loop.active_turn_stopped_at) { return { loop, runtime }; @@ -795,506 +561,3 @@ function parseInterruptDuration(flags) { return durationMs; } - -function appendPromptLog(projectRoot, entry) { - const promptLogJsonlPath = path.join(executionDir(projectRoot, entry.slug), "prompt-log.jsonl"); - const promptLogMarkdownPath = path.join(executionDir(projectRoot, entry.slug), "prompt-log.md"); - appendJsonLine(promptLogJsonlPath, entry); - appendPromptMarkdown(promptLogMarkdownPath, entry); -} - -function appendPromptMarkdown(filePath, entry) { - ensureMarkdownFile(filePath, "# Implementation Prompt Log\n"); - fs.appendFileSync( - filePath, - [ - "", - `## ${entry.timestamp} | turn ${entry.iteration} | ${entry.role}`, - "", - `- Session: \`${entry.session_id ?? "unknown"}\``, - `- Turn: \`${entry.turn_id ?? "unknown"}\``, - `- Prompt source: \`${entry.prompt_file ?? "inline"}\``, - "", - "````text", - entry.prompt, - "````", - "", - ].join("\n"), - "utf8", - ); -} - -function appendRoleReportIfPresent(projectRoot, slug, loop, payload, timestamp, reportRole) { - const message = readAssistantReportMessage(projectRoot, payload); - if (!message) { - return null; - } - - const executionPath = executionDir(projectRoot, slug); - const latestReportPath = path.join(executionPath, `latest-${reportRole}-report.md`); - const report = { - iteration: Number.isFinite(loop.iteration) ? loop.iteration : null, - message, - role: reportRole, - session_id: payload.session_id ?? null, - slug, - timestamp, - turn_id: payload.turn_id ?? null, - }; - - appendJsonLine(path.join(executionPath, `${reportRole}-reports.jsonl`), report); - appendRoleReportMarkdown(path.join(executionPath, `${reportRole}-reports.md`), reportRole, report); - writeText(latestReportPath, formatEngineerReport(report)); - - return { - latest_report_path: path.relative(projectRoot, latestReportPath), - timestamp, - }; -} - -function readAssistantReportMessage(projectRoot, payload) { - const platform = readRuntimePlatform(projectRoot).platform; - - if (platform === "claude-code") { - const payloadMessage = typeof payload.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; - return payloadMessage || readLatestClaudeAssistantMessage(payload.transcript_path); - } - - if (platform === "codex") { - return typeof payload.last_assistant_message === "string" ? payload.last_assistant_message.trim() : ""; - } - - return ""; -} - -function appendClaudeManualContinueNote(prompt) { - return `${prompt.trim()}\n${CLAUDE_MANUAL_CONTINUE_NOTE}`; -} - -function readLatestClaudeAssistantMessage(transcriptPath) { - if (typeof transcriptPath !== "string" || !transcriptPath) { - return ""; - } - - let content; - try { - content = fs.readFileSync(transcriptPath, "utf8"); - } catch { - return ""; - } - - let latestMessage = ""; - for (const line of content.split(/\r?\n/u)) { - if (!line.trim()) { - continue; - } - - const item = readJsonLine(line); - if (!item || !isAssistantTranscriptItem(item)) { - continue; - } - - const message = extractTranscriptText(item.message?.content ?? item.content ?? item.text); - if (message) { - latestMessage = message; - } - } - - return latestMessage; -} - -function isAssistantTranscriptItem(item) { - return item?.role === "assistant" || item?.type === "assistant" || item?.message?.role === "assistant" || item?.message?.type === "assistant"; -} - -function extractTranscriptText(value) { - if (typeof value === "string") { - return value.trim(); - } - - if (Array.isArray(value)) { - return value - .map((item) => extractTranscriptText(typeof item === "string" ? item : (item?.text ?? item?.content))) - .filter(Boolean) - .join("\n") - .trim(); - } - - if (value && typeof value === "object") { - return extractTranscriptText(value.text ?? value.content); - } - - return ""; -} - -function appendRoleReportMarkdown(filePath, reportRole, report) { - ensureMarkdownFile(filePath, `# ${capitalizeRole(reportRole)} Reports\n`); - fs.appendFileSync(filePath, `\n${formatEngineerReport(report)}`, "utf8"); -} - -function formatEngineerReport(report) { - return [ - `## ${report.timestamp} | turn ${report.iteration ?? "?"}`, - "", - `- Session: \`${report.session_id ?? "unknown"}\``, - `- Turn: \`${report.turn_id ?? "unknown"}\``, - "", - "````text", - report.message, - "````", - "", - ].join("\n"); -} - -function capitalizeRole(role) { - return role.charAt(0).toUpperCase() + role.slice(1); -} - -function appendProgressMarkdown(filePath, entry) { - ensureMarkdownFile(filePath, "# Implementation Progress Log\n"); - fs.appendFileSync( - filePath, - ["", `## ${entry.timestamp ?? nowIso()} | ${entry.action ?? "event"}`, "", progressSummary(entry), "", ...formatProgressDetails(entry), ""].join("\n"), - "utf8", - ); -} - -function rebuildProgressMarkdown(projectRoot, slug) { - const executionPath = executionDir(projectRoot, slug); - const markdownPath = path.join(executionPath, "progress-log.md"); - const events = readJsonLines(path.join(executionPath, "progress-log.jsonl")); - - writeText(markdownPath, "# Implementation Progress Log\n"); - for (const event of events) { - appendProgressMarkdown(markdownPath, event); - } -} - -function rebuildProgressReport(projectRoot, slug) { - const executionPath = executionDir(projectRoot, slug); - const events = readJsonLines(path.join(executionPath, "progress-log.jsonl")); - const promptEvents = readJsonLines(path.join(executionPath, "prompt-log.jsonl")); - const reportPath = path.join(executionPath, "progress-report.md"); - const firstTimestamp = firstEventTimestamp(events); - const lastTimestamp = lastEventTimestamp(events); - const completedTurns = events.filter((event) => event.action === "turn-stop"); - const interruptedTurns = events.filter((event) => event.action === "turn-interrupted"); - const endedTurns = [...completedTurns, ...interruptedTurns].sort((a, b) => String(a.timestamp ?? "").localeCompare(String(b.timestamp ?? ""))); - const roleCommands = events.filter((event) => event.action === "role-command"); - const activeMs = sum(endedTurns.map((event) => Number(event.duration_ms) || 0)); - const elapsedMs = firstTimestamp && lastTimestamp ? Math.max(0, Date.parse(lastTimestamp) - Date.parse(firstTimestamp)) : 0; - const idleMs = Math.max(0, elapsedMs - activeMs); - const byRole = groupDurations(endedTurns, (event) => event.role ?? "unknown"); - const byPhase = groupNestedDurations(endedTurns); - const openTurns = collectOpenTurns(events); - const generatedAt = nowIso(); - - writeText( - reportPath, - [ - "# Implementation Progress Report", - "", - `Generated: ${generatedAt}`, - "", - "## Work Window", - "", - `- First event: ${firstTimestamp ?? "n/a"}`, - `- Last event: ${lastTimestamp ?? "n/a"}`, - `- Elapsed wall time: ${formatDuration(elapsedMs)}`, - `- Active turn time: ${formatDuration(activeMs)}`, - `- Observed idle or paused time: ${formatDuration(idleMs)}`, - `- Completed turns: ${completedTurns.length}`, - `- Interrupted turns: ${interruptedTurns.length}`, - `- Prompt entries: ${promptEvents.length}`, - "", - "## Time By Role", - "", - ...formatRoleDurations(byRole), - "", - "## Phases And Tasks", - "", - ...formatPhaseDurations(byPhase), - "", - "## Role Commands", - "", - ...formatRoleCommands(roleCommands), - "", - "## Open Turns", - "", - ...formatOpenTurns(openTurns), - "", - ].join("\n"), - ); -} - -function formatRoleDurations(byRole) { - const entries = Object.entries(byRole); - if (entries.length === 0) { - return ["- No completed turns yet."]; - } - - return entries.map(([role, item]) => `- ${role}: ${formatDuration(item.durationMs)} across ${item.turns} turn${item.turns === 1 ? "" : "s"}`); -} - -function formatPhaseDurations(byPhase) { - const lines = []; - const entries = Object.entries(byPhase); - - if (entries.length === 0) { - return ["- No completed turns yet."]; - } - - for (const [phase, phaseData] of entries) { - lines.push(`### ${phase}`); - lines.push(""); - lines.push(`- Active time: ${formatDuration(phaseData.durationMs)}`); - lines.push(`- Turns: ${phaseData.turns}`); - lines.push(""); - - for (const [task, taskData] of Object.entries(phaseData.tasks)) { - lines.push(`#### ${task}`); - lines.push(""); - lines.push(`- Active time: ${formatDuration(taskData.durationMs)}`); - lines.push(`- Turns: ${taskData.turns}`); - for (const turn of taskData.turnsList) { - lines.push( - `- Turn ${turn.iteration ?? "?"} | ${turn.role ?? "unknown"} | ${turn.session_id ?? "unknown"} | ${turn.started_at ?? "?"} -> ${turn.ended_at ?? "?"} | ${formatDuration(Number(turn.duration_ms) || 0)}`, - ); - } - lines.push(""); - } - } - - return lines; -} - -function formatRoleCommands(commands) { - if (commands.length === 0) { - return ["- No role commands recorded yet."]; - } - - return commands.map((command) => { - const parts = [`${command.timestamp}`, `current=${command.current_role ?? "unknown"}`, `next=${command.next_role ?? "unknown"}`, `reason=${command.reason ?? "n/a"}`]; - if (command.prompt_file) { - parts.push(`prompt=${command.prompt_file}`); - } - return `- ${parts.join(" | ")}`; - }); -} - -function formatOpenTurns(openTurns) { - if (openTurns.length === 0) { - return ["- No open turns."]; - } - - return openTurns.map((turn) => `- Turn ${turn.iteration ?? "?"} | ${turn.role ?? "unknown"} | started ${turn.timestamp}`); -} - -function progressSummary(entry) { - switch (entry.action) { - case "loop-start": - return `Loop started. Next role: \`${entry.next_role ?? "unknown"}\`.`; - case "role-command": - return `Role command set next role to \`${entry.next_role ?? "unknown"}\`${entry.reason ? `: ${entry.reason}.` : "."}`; - case "turn-start": - return `Turn ${entry.iteration ?? "?"} started for \`${entry.role ?? "unknown"}\`.`; - case "turn-stop": - return `Turn ${entry.iteration ?? "?"} stopped after ${formatDuration(Number(entry.duration_ms) || 0)}.`; - case "turn-interrupted": - return `Turn ${entry.iteration ?? "?"} was interrupted after ${formatDuration(Number(entry.duration_ms) || 0)}.`; - case "skip": - return `Continuation skipped: ${entry.reason ?? "no reason recorded"}.`; - default: - return "Progress event recorded."; - } -} - -function formatProgressDetails(entry) { - return Object.entries(entry) - .filter(([key, value]) => key !== "action" && key !== "timestamp" && value !== undefined) - .map(([key, value]) => `- ${formatFieldName(key)}: ${formatFieldValue(key, value)}`); -} - -function formatFieldName(key) { - if (PROGRESS_FIELD_LABELS[key]) { - return PROGRESS_FIELD_LABELS[key]; - } - - return key.replace(/_/gu, " ").replace(/\b\w/gu, (letter) => letter.toUpperCase()); -} - -function formatFieldValue(key, value) { - if (value === null) { - return "`null`"; - } - - if (key === "duration_ms" && typeof value === "number") { - return `${formatDuration(value)} (${value} ms)`; - } - - if (typeof value === "string") { - return value ? `\`${value}\`` : '`""`'; - } - - if (typeof value === "number" || typeof value === "boolean") { - return `\`${String(value)}\``; - } - - return `\`${JSON.stringify(value)}\``; -} - -function collectOpenTurns(events) { - const starts = events.filter((event) => event.action === "turn-start"); - const endedKeys = new Set(events.filter((event) => event.action === "turn-stop" || event.action === "turn-interrupted").map(turnKey)); - return starts.filter((event) => !endedKeys.has(turnKey(event))); -} - -function groupDurations(events, keyFn) { - const groups = {}; - for (const event of events) { - const key = keyFn(event); - const current = groups[key] ?? { durationMs: 0, turns: 0 }; - current.durationMs += Number(event.duration_ms) || 0; - current.turns += 1; - groups[key] = current; - } - return groups; -} - -function groupNestedDurations(events) { - const groups = {}; - - for (const event of events) { - const phase = event.phase || "Unassigned phase"; - const task = event.task || "Unassigned task"; - const durationMs = Number(event.duration_ms) || 0; - const phaseData = groups[phase] ?? { durationMs: 0, tasks: {}, turns: 0 }; - const taskData = phaseData.tasks[task] ?? { durationMs: 0, turns: 0, turnsList: [] }; - - phaseData.durationMs += durationMs; - phaseData.turns += 1; - taskData.durationMs += durationMs; - taskData.turns += 1; - taskData.turnsList.push(event); - phaseData.tasks[task] = taskData; - groups[phase] = phaseData; - } - - return groups; -} - -function firstEventTimestamp(events) { - return ( - events - .map((event) => event.timestamp) - .filter(Boolean) - .sort()[0] ?? null - ); -} - -function lastEventTimestamp(events) { - return ( - events - .map((event) => event.timestamp) - .filter(Boolean) - .sort() - .at(-1) ?? null - ); -} - -function durationMsBetween(start, end) { - const startMs = Date.parse(start ?? ""); - const endMs = Date.parse(end ?? ""); - if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) { - return null; - } - return Math.max(0, endMs - startMs); -} - -function formatDuration(ms) { - if (!Number.isFinite(ms) || ms <= 0) { - return "0s"; - } - - const seconds = Math.round(ms / 1000); - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const restSeconds = seconds % 60; - const parts = []; - - if (hours > 0) { - parts.push(`${hours}h`); - } - if (minutes > 0) { - parts.push(`${minutes}m`); - } - if (restSeconds > 0 || parts.length === 0) { - parts.push(`${restSeconds}s`); - } - - return parts.join(" "); -} - -function readJsonLines(filePath) { - if (!fs.existsSync(filePath)) { - return []; - } - - return fs - .readFileSync(filePath, "utf8") - .split(/\r?\n/u) - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line); - } catch { - return null; - } - }) - .filter(Boolean); -} - -function readJsonLine(line) { - try { - return JSON.parse(line); - } catch { - return null; - } -} - -function appendJsonLine(filePath, value) { - ensureDir(path.dirname(filePath)); - fs.appendFileSync(filePath, `${JSON.stringify(value)}\n`, "utf8"); -} - -function ensureMarkdownFile(filePath, header) { - if (fs.existsSync(filePath)) { - return; - } - writeText(filePath, `${header.trim()}\n`); -} - -function writeText(filePath, text) { - ensureDir(path.dirname(filePath)); - fs.writeFileSync(filePath, text, "utf8"); -} - -function executionDir(projectRoot, slug) { - return epicRuntimeRoot(projectRoot, slug); -} - -function sum(values) { - return values.reduce((acc, value) => acc + value, 0); -} - -function turnKey(event) { - return `${event.iteration ?? "?"}:${event.role ?? "unknown"}`; -} - -function countLines(filePath) { - if (!fs.existsSync(filePath)) { - return 0; - } - - return fs.readFileSync(filePath, "utf8").split(/\r?\n/u).filter(Boolean).length; -} From 32c680b7ceee912eadbcda943f357f1ad5f32a4c Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:05:56 +0700 Subject: [PATCH 05/17] Record Phase 1 tooling verification --- .epic-loop/epics/set-up/implementation-log.md | 5 +++++ .epic-loop/epics/set-up/state-of-epic.md | 8 +++++--- .epic-loop/epics/set-up/tracker.md | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 7e95eb6..1b22355 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -29,3 +29,8 @@ - Task: Phase 1 Task 3 - Refactor oversized hook and implementation loop modules to satisfy oxlint max-lines - Verdict: closed: split loop.mjs into loop-prompts.mjs, loop-claude-cap.mjs, and loop-artifacts.mjs after the earlier hooks.mjs split. hooks.mjs and loop.mjs are both below the oxlint max-lines limit. pnpm run validate passes and pnpm run test:unit passes 60/60. Task 3 is closed; Phase 1 verification remains active next. + +## 2026-07-07T14:05:45+00:00 - closed: Phase 1 verification passed. pnpm run lint passed with no max-lines failures; pnpm run format:check passed without format:write; pnpm run test:unit passed 60/60; pnpm run validate passed syntax checks, oxlint, Oxfmt check, and package validation. git status --short was clean after verification, with no generated runtime/debug artifacts. + +- Task: Phase 1 Task 4 - Verify lint and format tooling through the repository validation path +- Verdict: closed: Phase 1 verification passed. pnpm run lint passed with no max-lines failures; pnpm run format:check passed without format:write; pnpm run test:unit passed 60/60; pnpm run validate passed syntax checks, oxlint, Oxfmt check, and package validation. git status --short was clean after verification, with no generated runtime/debug artifacts. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 28c6f5e..8adee85 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 1 - Tooling Baseline -Active task: Phase 1 Task 4 - Verify lint and format tooling through the repository validation path +Active task: Phase 1 closure housekeeping ## Current State @@ -13,14 +13,16 @@ Active task: Phase 1 Task 4 - Verify lint and format tooling through the reposit - Phase 1 Task 1 is closed with accepted baseline debt: oxlint configuration, scripts, dependency, validation integration, and narrow baseline fixes are present; existing `max-lines` failures in `loop.mjs` and `hooks.mjs` are explicitly accepted for now and tracked as a Phase 1 refactor task before phase verification. - Phase 1 Task 2 is closed: Oxfmt config and check/write scripts are present; format check is non-mutating and included in validation; markdown formatting is intentionally excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn during verification. - Phase 1 Task 3 is closed: `hooks.mjs` and `loop.mjs` are split below the oxlint source-file line limit; `pnpm run validate` passes after the refactor. +- Phase 1 Task 4 is closed: `pnpm run lint`, `pnpm run format:check`, `pnpm run test:unit`, and `pnpm run validate` all pass, and verification left no generated runtime/debug artifacts in the working tree. +- Phase 1 is complete and awaiting mandatory phase-closure housekeeping before Phase 2 begins. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- None currently known for Phase 1 tooling verification. +- None currently known. ## Next Action -- Continue with Phase 1 Task 4: verify lint and format tooling through the repository validation path. +- Run phase-closure housekeeping for Phase 1 before starting Phase 2. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 58c1052..ea26f4f 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -26,7 +26,7 @@ Epic: Linting And Skill Checks ### Phase 1: Tooling Baseline -- Phase status: doing +- Phase status: done - [x] Kind: implementation | Status: done | Add oxlint configuration for the current Node.js ESM repository. - Outcome: JavaScript source, scripts, tests, and plugin package code are linted consistently with the repository's ESM/Node style. @@ -46,7 +46,7 @@ Epic: Linting And Skill Checks - Acceptance: `pnpm run lint` no longer reports `max-lines` errors for `hooks.mjs` or `loop.mjs`; behavior covered by existing hook/loop tests remains unchanged. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: doing | Verify lint and format tooling through the repository validation path. +- [x] Kind: verification | Status: done | Verify lint and format tooling through the repository validation path. - Outcome: The first phase tooling is proven through the same command future contributors will run. - Surface: Local pnpm scripts, oxlint, Oxfmt, existing syntax/package validation. - Acceptance: Run `pnpm run validate` and any focused lint/format scripts; evidence includes exit codes and any required follow-up fixes, with no generated runtime artifacts committed. From 074f6cfb4321cd73ce0ee6e60ca6eb526904642f Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:12:10 +0700 Subject: [PATCH 06/17] Validate mechanical skill package invariants --- .epic-loop/epics/set-up/implementation-log.md | 5 + .epic-loop/epics/set-up/state-of-epic.md | 10 +- .epic-loop/epics/set-up/tracker.md | 6 +- .../epic-loop/references/artifact-model.md | 8 + .../references/hooks-and-session-routing.md | 11 + .../references/implementation-cycle.md | 17 ++ .../references/implementation-manager-role.md | 11 + .../implementation-techlead-role.md | 16 ++ .../epic-loop/references/shaping-mode.md | 8 + scripts/validate-epic-loop-package.mjs | 223 +++++++++++++++++- 10 files changed, 297 insertions(+), 18 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 1b22355..8a90d3d 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -34,3 +34,8 @@ - Task: Phase 1 Task 4 - Verify lint and format tooling through the repository validation path - Verdict: closed: Phase 1 verification passed. pnpm run lint passed with no max-lines failures; pnpm run format:check passed without format:write; pnpm run test:unit passed 60/60; pnpm run validate passed syntax checks, oxlint, Oxfmt check, and package validation. git status --short was clean after verification, with no generated runtime/debug artifacts. + +## 2026-07-07T14:12:15+00:00 - closed: extended scripts/validate-epic-loop-package.mjs with offline mechanical Agent Skills checks for SKILL.md frontmatter/name/description shape, entrypoint line budget, direct markdown links, long-reference tables of contents, bundled script syntax/extensions, and runtime/debug artifact absence. Added minimal Contents sections to long reference docs so the maintained package satisfies the new invariant. Verification: node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 60/60; pnpm run validate passed. Residual risk: dedicated validator fixtures/tests are intentionally deferred to Phase 2 Task 2. Commit: recorded in the task-owned commit containing this closure note. + +- Task: Phase 2 Task 1 - Add deterministic skill package validation for mechanical Agent Skills invariants +- Verdict: closed: extended scripts/validate-epic-loop-package.mjs with offline mechanical Agent Skills checks for SKILL.md frontmatter/name/description shape, entrypoint line budget, direct markdown links, long-reference tables of contents, bundled script syntax/extensions, and runtime/debug artifact absence. Added minimal Contents sections to long reference docs so the maintained package satisfies the new invariant. Verification: node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 60/60; pnpm run validate passed. Residual risk: dedicated validator fixtures/tests are intentionally deferred to Phase 2 Task 2. Commit: recorded in the task-owned commit containing this closure note. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 8adee85..4cb0a30 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -4,8 +4,8 @@ Epic: Linting And Skill Checks Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation -Active phase: Phase 1 - Tooling Baseline -Active task: Phase 1 closure housekeeping +Active phase: Phase 2 - Deterministic Skill Package Checks +Active task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic skill package validation ## Current State @@ -14,7 +14,9 @@ Active task: Phase 1 closure housekeeping - Phase 1 Task 2 is closed: Oxfmt config and check/write scripts are present; format check is non-mutating and included in validation; markdown formatting is intentionally excluded because `oxfmt@0.57.0` produced unsafe markdown/template churn during verification. - Phase 1 Task 3 is closed: `hooks.mjs` and `loop.mjs` are split below the oxlint source-file line limit; `pnpm run validate` passes after the refactor. - Phase 1 Task 4 is closed: `pnpm run lint`, `pnpm run format:check`, `pnpm run test:unit`, and `pnpm run validate` all pass, and verification left no generated runtime/debug artifacts in the working tree. -- Phase 1 is complete and awaiting mandatory phase-closure housekeeping before Phase 2 begins. +- Phase 1 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. +- Phase 2 Task 1 is closed: deterministic skill package validation now checks mechanical Agent Skills invariants for the maintained `epic-loop` package through the existing package validator and aggregate validation path. +- Phase 2 is active: the next implementation slice should add focused tests or fixtures for deterministic skill package validation. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -25,4 +27,4 @@ Active task: Phase 1 closure housekeeping ## Next Action -- Run phase-closure housekeeping for Phase 1 before starting Phase 2. +- Continue with Phase 2 Task 2: add focused tests or fixtures for deterministic skill package validation. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index ea26f4f..958212b 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -54,15 +54,15 @@ Epic: Linting And Skill Checks ### Phase 2: Deterministic Skill Package Checks -- Phase status: todo +- Phase status: doing -- [ ] Kind: implementation | Status: todo | Add deterministic skill package validation for mechanical Agent Skills invariants. +- [x] Kind: implementation | Status: done | Add deterministic skill package validation for mechanical Agent Skills invariants. - Outcome: Maintained skill packages fail validation when their file shape, frontmatter, naming, references, or generated-artifact boundaries violate portable skill package rules. - Surface: `scripts/validate-skills.mjs` or `scripts/validate-epic-loop-package.mjs`, `package.json` scripts, skill package files under `plugins/epic-loop/skills`. - Acceptance: The validator checks `SKILL.md` presence, YAML frontmatter, required `name` and `description`, kebab-case name constraints, directory-name match, description length, `SKILL.md` line budget, direct reference links, long-reference table of contents, forward-slash paths, script syntax, and ignored runtime/debug artifact absence. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: todo | Add focused tests or fixtures for deterministic skill package validation. +- [ ] Kind: implementation | Status: doing | Add focused tests or fixtures for deterministic skill package validation. - Outcome: The deterministic skill validator has regression coverage for accepted package shape and representative failure cases. - Surface: `tests/unit`, validator fixtures/helpers, package validation scripts. - Acceptance: Tests cover valid skill metadata, invalid names, missing descriptions, long entrypoint files, missing table of contents for long references, Windows-style paths, and generated artifact detection. diff --git a/plugins/epic-loop/skills/epic-loop/references/artifact-model.md b/plugins/epic-loop/skills/epic-loop/references/artifact-model.md index d8e17f4..23dee4c 100644 --- a/plugins/epic-loop/skills/epic-loop/references/artifact-model.md +++ b/plugins/epic-loop/skills/epic-loop/references/artifact-model.md @@ -1,5 +1,13 @@ # Epic Artifact Model +## Contents + +- [Workspace](#workspace) +- [Required Files](#required-files) +- [Runtime Files](#runtime-files) +- [File Ownership Guidance](#file-ownership-guidance) +- [Compaction Contract](#compaction-contract) + ## Workspace Store each epic under: diff --git a/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md b/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md index 0c73ae2..13aa9bb 100644 --- a/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md +++ b/plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md @@ -1,5 +1,16 @@ # Hooks And Session Routing +## Contents + +- [Goal](#goal) +- [Local Config](#local-config) +- [Installer Behavior](#installer-behavior) +- [Hook Payload](#hook-payload) +- [Project-Local State](#project-local-state) +- [Binding Sessions](#binding-sessions) +- [What Hooks Can And Cannot Do](#what-hooks-can-and-cannot-do) +- [Parallel Safety](#parallel-safety) + ## Goal Epic-loop hooks must be project-local and session-aware. Parallel sessions in the same project must never route events only by cwd or epic slug. diff --git a/plugins/epic-loop/skills/epic-loop/references/implementation-cycle.md b/plugins/epic-loop/skills/epic-loop/references/implementation-cycle.md index 89dc672..91ff5d1 100644 --- a/plugins/epic-loop/skills/epic-loop/references/implementation-cycle.md +++ b/plugins/epic-loop/skills/epic-loop/references/implementation-cycle.md @@ -1,5 +1,22 @@ # Implementation Manager / Techlead / Engineer Cycle +## Contents + +- [Core Rule](#core-rule) +- [Canonical Runtime Behavior](#canonical-runtime-behavior) +- [Role References](#role-references) +- [Cycle Entry](#cycle-entry) +- [Turn Order](#turn-order) +- [Techlead Turn Expectations](#techlead-turn-expectations) +- [Engineer Turn Expectations](#engineer-turn-expectations) +- [Manager Turn Expectations](#manager-turn-expectations) +- [Closure Discipline](#closure-discipline) +- [Phase Closure Discipline](#phase-closure-discipline) +- [Reset Ladder](#reset-ladder) +- [Exit Conditions](#exit-conditions) +- [Prompt-Writing Rule](#prompt-writing-rule) +- [Commit Discipline](#commit-discipline) + ## Core Rule Implementation alternates between: diff --git a/plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md b/plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md index b0837ec..e43ba5a 100644 --- a/plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md +++ b/plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md @@ -2,6 +2,17 @@ Counterpart role: [implementation-techlead-role.md](implementation-techlead-role.md). Cycle overview: [implementation-cycle.md](implementation-cycle.md). +## Contents + +- [Identity](#identity) +- [When Manager Runs](#when-manager-runs) +- [Responsibilities](#responsibilities) +- [Required Reads](#required-reads) +- [Implementation-Start Housekeeping](#implementation-start-housekeeping) +- [Compaction Rules](#compaction-rules) +- [Archive And Stub Format](#archive-and-stub-format) +- [Handoff Constraint](#handoff-constraint) + ## Identity The manager owns housekeeping inside implementation mode. diff --git a/plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md b/plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md index f2cfc96..cec1911 100644 --- a/plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md +++ b/plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md @@ -4,6 +4,22 @@ Canonical live prompt: [../assets/templates/implementation-techlead-prompt.md](. Counterpart roles: [implementation-manager-role.md](implementation-manager-role.md), [implementation-engineer-role.md](implementation-engineer-role.md). Cycle overview: [implementation-cycle.md](implementation-cycle.md). +## Contents + +- [Identity](#identity) +- [Two Layers Of Responsibility](#two-layers-of-responsibility) +- [Required Reads](#required-reads) +- [Forbidden Runtime Surfaces](#forbidden-runtime-surfaces) +- [Housekeeping Gate](#housekeeping-gate) +- [How Techlead Reviews Work](#how-techlead-reviews-work) +- [Challenge-Driven Review](#challenge-driven-review) +- [Task Closure Standard](#task-closure-standard) +- [Phase Closure Standard](#phase-closure-standard) +- [Reset And Detour Judgment](#reset-and-detour-judgment) +- [Commit Discipline](#commit-discipline) +- [Engineer Prompt Contract](#engineer-prompt-contract) +- [Role Handoff](#role-handoff) + ## Identity The techlead is the governing loop for implementation mode. diff --git a/plugins/epic-loop/skills/epic-loop/references/shaping-mode.md b/plugins/epic-loop/skills/epic-loop/references/shaping-mode.md index 3bb9702..bda2932 100644 --- a/plugins/epic-loop/skills/epic-loop/references/shaping-mode.md +++ b/plugins/epic-loop/skills/epic-loop/references/shaping-mode.md @@ -1,5 +1,13 @@ # Epic Shaping Mode +## Contents + +- [Goal](#goal) +- [Flow](#flow) +- [Agent Responsibility](#agent-responsibility) +- [Task Authoring Guardrail](#task-authoring-guardrail) +- [Re-Entrant Use](#re-entrant-use) + ## Goal Use shaping mode to turn discussion into a durable epic: framing, docs, decisions, risks, phases, and goal-oriented tasks. diff --git a/scripts/validate-epic-loop-package.mjs b/scripts/validate-epic-loop-package.mjs index 0e55fd2..b803e64 100644 --- a/scripts/validate-epic-loop-package.mjs +++ b/scripts/validate-epic-loop-package.mjs @@ -1,10 +1,16 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { spawnSync } from "node:child_process"; const root = process.cwd(); const pluginRoot = path.join(root, "plugins", "epic-loop"); const skillRoot = path.join(pluginRoot, "skills", "epic-loop"); +const skillEntrypoint = path.join(skillRoot, "SKILL.md"); +const skillName = path.basename(skillRoot); +const skillEntrypointLineBudget = 500; +const referenceTocLineThreshold = 100; +const referenceTocSearchLimit = 40; const requiredFiles = [ ".agents/plugins/marketplace.json", @@ -62,11 +68,8 @@ if (plugin) { const skill = readText("plugins/epic-loop/skills/epic-loop/SKILL.md"); if (skill) { - const frontmatter = skill.match(/^---\n([\s\S]*?)\n---/u)?.[1] ?? ""; - expectYamlField(frontmatter, "name", "epic-loop"); - if (!/^description:\s*.+$/mu.test(frontmatter)) { - errors.push("SKILL.md frontmatter must include a non-empty description."); - } + validateSkillEntrypoint(skill); + validateSkillLinks(skill); if (skill.includes(".agents/skills/epic-loop")) { errors.push("Packaged SKILL.md must not reference the legacy .agents/skills/epic-loop path."); } @@ -78,6 +81,10 @@ if (skill) { } } +validateReferenceTablesOfContents(); +validateSkillScripts(); +validateNoRuntimeArtifacts(); + for (const relativePath of listFiles(skillRoot)) { const content = fs.readFileSync(relativePath, "utf8"); const displayPath = path.relative(root, relativePath); @@ -148,10 +155,178 @@ function rejectIncludes(actual, rejected, label) { } } -function expectYamlField(frontmatter, field, expected) { - const pattern = new RegExp(`^${field}:\\s*"?${escapeRegExp(expected)}"?\\s*$`, "mu"); - if (!pattern.test(frontmatter)) { - errors.push(`SKILL.md frontmatter ${field} must be ${JSON.stringify(expected)}.`); +function validateSkillEntrypoint(content) { + const parsed = parseSkillFrontmatter(content); + const displayPath = path.relative(root, skillEntrypoint); + + if (!parsed) { + errors.push(`${displayPath} must start with YAML frontmatter delimited by --- lines.`); + return; + } + + const { frontmatter, body } = parsed; + const name = frontmatter.name; + const description = frontmatter.description; + + if (typeof name !== "string" || name.length === 0) { + errors.push(`${displayPath} frontmatter must include a non-empty name.`); + } else { + if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u.test(name)) { + errors.push(`${displayPath} frontmatter name must be 1-64 characters of lowercase kebab-case.`); + } + if (name.includes("--")) { + errors.push(`${displayPath} frontmatter name must not contain consecutive hyphens.`); + } + if (name !== skillName) { + errors.push(`${displayPath} frontmatter name must match skill directory ${JSON.stringify(skillName)}.`); + } + } + + if (typeof description !== "string" || description.trim().length === 0) { + errors.push(`${displayPath} frontmatter must include a non-empty description.`); + } else { + validateSkillDescription(description, displayPath); + } + + const bodyLineCount = countLines(body); + if (bodyLineCount > skillEntrypointLineBudget) { + errors.push(`${displayPath} body must stay at or below ${skillEntrypointLineBudget} lines, got ${bodyLineCount}.`); + } +} + +function parseSkillFrontmatter(content) { + const match = content.match(/^---\r?\n(?[\s\S]*?)\r?\n---\r?\n?(?[\s\S]*)$/u); + if (!match?.groups) { + return null; + } + + const frontmatter = {}; + for (const [index, line] of match.groups.frontmatter.split(/\r?\n/u).entries()) { + if (line.trim().length === 0) { + continue; + } + const fieldMatch = line.match(/^(?[A-Za-z0-9_-]+):\s*(?.*)$/u); + if (!fieldMatch?.groups) { + errors.push(`${path.relative(root, skillEntrypoint)} frontmatter line ${index + 2} is not a simple key/value field.`); + continue; + } + frontmatter[fieldMatch.groups.key] = unquoteYamlScalar(fieldMatch.groups.value.trim()); + } + + return { + body: match.groups.body, + frontmatter, + }; +} + +function unquoteYamlScalar(value) { + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + return value; +} + +function validateSkillDescription(description, displayPath) { + if (description.length > 1024) { + errors.push(`${displayPath} frontmatter description must be at most 1024 characters, got ${description.length}.`); + } + if (/<\/?[A-Za-z][^>]*>/u.test(description)) { + errors.push(`${displayPath} frontmatter description must not contain XML-like tags.`); + } + if (!/\b(use|when|asks?|requested?|trigger|work|reading|editing|creating|adding|switching|resuming)\b/iu.test(description)) { + errors.push(`${displayPath} frontmatter description must include concrete trigger wording, not only a generic label.`); + } +} + +function validateSkillLinks(content) { + const displayPath = path.relative(root, skillEntrypoint); + const linkPattern = /(?[^)\s]+)(?:\s+"[^"]*")?\)/gu; + + for (const match of content.matchAll(linkPattern)) { + const target = match.groups?.target; + if (!target || isExternalMarkdownTarget(target) || target.startsWith("#")) { + continue; + } + if (target.includes("\\")) { + errors.push(`${displayPath} markdown link target must use forward slashes: ${target}`); + continue; + } + + const targetWithoutAnchor = target.split("#")[0]; + const absoluteTarget = path.resolve(skillRoot, targetWithoutAnchor); + if (!isInsidePath(skillRoot, absoluteTarget)) { + errors.push(`${displayPath} markdown link target must stay inside the skill package: ${target}`); + continue; + } + if (!fs.existsSync(absoluteTarget)) { + errors.push(`${displayPath} markdown link target does not exist: ${target}`); + } + } +} + +function isExternalMarkdownTarget(target) { + return /^[a-z][a-z0-9+.-]*:/iu.test(target); +} + +function validateReferenceTablesOfContents() { + for (const absolutePath of listFiles(path.join(skillRoot, "references"))) { + if (!absolutePath.endsWith(".md")) { + continue; + } + const content = fs.readFileSync(absolutePath, "utf8"); + const lineCount = countLines(content); + if (lineCount <= referenceTocLineThreshold) { + continue; + } + + const topLines = content.split(/\r?\n/u).slice(0, referenceTocSearchLimit).join("\n"); + if (!/^## (?:Contents|Table of Contents|TOC)\s*$/mu.test(topLines) || !/^\s*- \[[^\]]+\]\(#[^)]+\)\s*$/mu.test(topLines)) { + errors.push(`${path.relative(root, absolutePath)} is ${lineCount} lines and must include a table of contents near the top.`); + } + } +} + +function validateSkillScripts() { + const scriptsRoot = path.join(skillRoot, "scripts"); + for (const absolutePath of listAllFiles(scriptsRoot)) { + const displayPath = path.relative(root, absolutePath); + if (!absolutePath.endsWith(".mjs")) { + errors.push(`${displayPath} must use the .mjs extension for bundled skill scripts.`); + continue; + } + + const result = spawnSync(process.execPath, ["--check", absolutePath], { + encoding: "utf8", + }); + if (result.status !== 0) { + const output = `${result.stderr}${result.stdout}`.trim(); + errors.push(`${displayPath} failed node --check${output ? `: ${output.split(/\r?\n/u)[0]}` : "."}`); + } + } +} + +function validateNoRuntimeArtifacts() { + const runtimeArtifactNames = new Set([ + ".runtime", + ".validation-output", + "hook-events", + "latest-engineer-report.md", + "latest-manager-report.md", + "prompt-log.jsonl", + "prompt-log.md", + "progress-log.jsonl", + "progress-log.md", + "progress-report.md", + "session-bindings.json", + "sessions", + ]); + + for (const absolutePath of listEntries(skillRoot)) { + const name = path.basename(absolutePath); + const displayPath = path.relative(root, absolutePath); + if (runtimeArtifactNames.has(name) || /\.(log|tmp)$/u.test(name)) { + errors.push(`${displayPath} looks like a runtime/debug artifact and must not be committed in the skill package.`); + } } } @@ -172,6 +347,32 @@ function listFiles(dir) { }); } -function escapeRegExp(value) { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +function listAllFiles(dir) { + return listEntries(dir).filter((entry) => fs.statSync(entry).isFile()); +} + +function listEntries(dir) { + if (!fs.existsSync(dir)) { + return []; + } + + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + return [entryPath, ...listEntries(entryPath)]; + } + if (entry.isFile()) { + return [entryPath]; + } + return []; + }); +} + +function isInsidePath(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function countLines(content) { + return content.length === 0 ? 0 : content.split(/\r?\n/u).length; } From 2dfa69f92838eb7f13da7de37b6c3ae2390cf48b Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:17:20 +0700 Subject: [PATCH 07/17] Cover skill package validation invariants --- .epic-loop/epics/set-up/implementation-log.md | 5 + .epic-loop/epics/set-up/state-of-epic.md | 7 +- .epic-loop/epics/set-up/tracker.md | 4 +- scripts/validate-epic-loop-package.mjs | 286 ++++++++++-------- tests/unit/skill-package-validation.test.mjs | 197 ++++++++++++ 5 files changed, 372 insertions(+), 127 deletions(-) create mode 100644 tests/unit/skill-package-validation.test.mjs diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 8a90d3d..3220845 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -39,3 +39,8 @@ - Task: Phase 2 Task 1 - Add deterministic skill package validation for mechanical Agent Skills invariants - Verdict: closed: extended scripts/validate-epic-loop-package.mjs with offline mechanical Agent Skills checks for SKILL.md frontmatter/name/description shape, entrypoint line budget, direct markdown links, long-reference tables of contents, bundled script syntax/extensions, and runtime/debug artifact absence. Added minimal Contents sections to long reference docs so the maintained package satisfies the new invariant. Verification: node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 60/60; pnpm run validate passed. Residual risk: dedicated validator fixtures/tests are intentionally deferred to Phase 2 Task 2. Commit: recorded in the task-owned commit containing this closure note. + +## 2026-07-07T14:17:13+00:00 - closed: refactored scripts/validate-epic-loop-package.mjs into an importable validateEpicLoopPackage({ root }) helper while preserving CLI success and failure output. Added tests/unit/skill-package-validation.test.mjs with temporary fixture coverage for the maintained package, invalid name/directory mismatch, missing description, entrypoint line budget, missing long-reference table of contents, backslash markdown links, runtime artifact detection, and CLI failure diagnostics. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 68/68; pnpm run validate passed. Residual risk: Phase 2 aggregate verification remains as the next task. + +- Task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic skill package validation +- Verdict: closed: refactored scripts/validate-epic-loop-package.mjs into an importable validateEpicLoopPackage({ root }) helper while preserving CLI success and failure output. Added tests/unit/skill-package-validation.test.mjs with temporary fixture coverage for the maintained package, invalid name/directory mismatch, missing description, entrypoint line budget, missing long-reference table of contents, backslash markdown links, runtime artifact detection, and CLI failure diagnostics. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 68/68; pnpm run validate passed. Residual risk: Phase 2 aggregate verification remains as the next task. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 4cb0a30..1e69e2b 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 2 - Deterministic Skill Package Checks -Active task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic skill package validation +Active task: Phase 2 Task 3 - Verify deterministic skill package checks through aggregate validation ## Current State @@ -16,7 +16,8 @@ Active task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic sk - Phase 1 Task 4 is closed: `pnpm run lint`, `pnpm run format:check`, `pnpm run test:unit`, and `pnpm run validate` all pass, and verification left no generated runtime/debug artifacts in the working tree. - Phase 1 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. - Phase 2 Task 1 is closed: deterministic skill package validation now checks mechanical Agent Skills invariants for the maintained `epic-loop` package through the existing package validator and aggregate validation path. -- Phase 2 is active: the next implementation slice should add focused tests or fixtures for deterministic skill package validation. +- Phase 2 Task 2 is closed: deterministic skill package validation now has focused unit coverage for valid package behavior and representative invalid mechanical invariants. +- Phase 2 is active: the next implementation slice should verify deterministic skill package checks through the aggregate validation path. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -27,4 +28,4 @@ Active task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic sk ## Next Action -- Continue with Phase 2 Task 2: add focused tests or fixtures for deterministic skill package validation. +- Continue with Phase 2 Task 3: verify deterministic skill package checks through aggregate validation. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 958212b..3ff92cd 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -62,13 +62,13 @@ Epic: Linting And Skill Checks - Acceptance: The validator checks `SKILL.md` presence, YAML frontmatter, required `name` and `description`, kebab-case name constraints, directory-name match, description length, `SKILL.md` line budget, direct reference links, long-reference table of contents, forward-slash paths, script syntax, and ignored runtime/debug artifact absence. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: doing | Add focused tests or fixtures for deterministic skill package validation. +- [x] Kind: implementation | Status: done | Add focused tests or fixtures for deterministic skill package validation. - Outcome: The deterministic skill validator has regression coverage for accepted package shape and representative failure cases. - Surface: `tests/unit`, validator fixtures/helpers, package validation scripts. - Acceptance: Tests cover valid skill metadata, invalid names, missing descriptions, long entrypoint files, missing table of contents for long references, Windows-style paths, and generated artifact detection. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: todo | Verify deterministic skill package checks through aggregate validation. +- [ ] Kind: verification | Status: doing | Verify deterministic skill package checks through aggregate validation. - Outcome: Skill package mechanical validation is proven as part of the standard repository validation path. - Surface: `pnpm run validate`, focused validator command, unit tests, current plugin skill package. - Acceptance: Run focused validator tests, run the deterministic skill validator, and run `pnpm run validate` successfully; evidence includes exit codes and any required package cleanup. diff --git a/scripts/validate-epic-loop-package.mjs b/scripts/validate-epic-loop-package.mjs index b803e64..05b4879 100644 --- a/scripts/validate-epic-loop-package.mjs +++ b/scripts/validate-epic-loop-package.mjs @@ -1,17 +1,9 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { pathToFileURL } from "node:url"; import { spawnSync } from "node:child_process"; -const root = process.cwd(); -const pluginRoot = path.join(root, "plugins", "epic-loop"); -const skillRoot = path.join(pluginRoot, "skills", "epic-loop"); -const skillEntrypoint = path.join(skillRoot, "SKILL.md"); -const skillName = path.basename(skillRoot); -const skillEntrypointLineBudget = 500; -const referenceTocLineThreshold = 100; -const referenceTocSearchLimit = 40; - const requiredFiles = [ ".agents/plugins/marketplace.json", "plugins/epic-loop/.codex-plugin/plugin.json", @@ -21,146 +13,181 @@ const requiredFiles = [ "plugins/epic-loop/skills/epic-loop/assets/templates/implementation-techlead-prompt.md", ]; -const errors = []; +const skillEntrypointLineBudget = 500; +const referenceTocLineThreshold = 100; +const referenceTocSearchLimit = 40; + +export function validateEpicLoopPackage(options = {}) { + const root = options.root ?? process.cwd(); + const pluginRoot = path.join(root, "plugins", "epic-loop"); + const skillRoot = path.join(pluginRoot, "skills", "epic-loop"); + const skillEntrypoint = path.join(skillRoot, "SKILL.md"); + const skillName = path.basename(skillRoot); + const nodeExecutable = options.nodeExecutable ?? process.execPath; + const errors = []; + const context = { + errors, + nodeExecutable, + pluginRoot, + root, + skillEntrypoint, + skillName, + skillRoot, + }; -for (const relativePath of requiredFiles) { - if (!fs.existsSync(path.join(root, relativePath))) { - errors.push(`Missing required file: ${relativePath}`); + for (const relativePath of requiredFiles) { + if (!fs.existsSync(path.join(root, relativePath))) { + errors.push(`Missing required file: ${relativePath}`); + } } -} -const marketplace = readJson(".agents/plugins/marketplace.json"); -const plugin = readJson("plugins/epic-loop/.codex-plugin/plugin.json"); + const marketplace = readJson(context, ".agents/plugins/marketplace.json"); + const plugin = readJson(context, "plugins/epic-loop/.codex-plugin/plugin.json"); -if (marketplace) { - expectEqual(marketplace.name, "epic-loop", "marketplace name"); + if (marketplace) { + validateMarketplace(context, marketplace); + } + + if (plugin) { + validatePlugin(context, plugin); + } + + const skill = readText(context, "plugins/epic-loop/skills/epic-loop/SKILL.md"); + if (skill) { + validateSkill(context, skill); + } + + validateReferenceTablesOfContents(context); + validateSkillScripts(context); + validateNoRuntimeArtifacts(context); + validatePackagedText(context); + validatePluginText(context); + + return errors; +} + +function validateMarketplace(context, marketplace) { + expectEqual(context, marketplace.name, "epic-loop", "marketplace name"); const marketplaceDescription = marketplace.interface?.description; if (typeof marketplaceDescription === "string") { - expectIncludes(marketplaceDescription, "Codex or Claude Code hooks", "marketplace interface.description"); - rejectIncludes(marketplaceDescription, "driven by Codex hooks", "marketplace interface.description"); + expectIncludes(context, marketplaceDescription, "Codex or Claude Code hooks", "marketplace interface.description"); + rejectIncludes(context, marketplaceDescription, "driven by Codex hooks", "marketplace interface.description"); } else { - errors.push("marketplace interface.description must be a non-empty string."); + context.errors.push("marketplace interface.description must be a non-empty string."); } const entry = Array.isArray(marketplace.plugins) ? marketplace.plugins.find((item) => item?.name === "epic-loop") : null; if (!entry) { - errors.push("marketplace.json must include an epic-loop plugin entry."); - } else { - expectEqual(entry.source?.source, "local", "marketplace epic-loop source.source"); - expectEqual(entry.source?.path, "./plugins/epic-loop", "marketplace epic-loop source.path"); - expectEqual(entry.policy?.installation, "AVAILABLE", "marketplace epic-loop policy.installation"); - expectEqual(entry.policy?.authentication, "ON_INSTALL", "marketplace epic-loop policy.authentication"); + context.errors.push("marketplace.json must include an epic-loop plugin entry."); + return; } + + expectEqual(context, entry.source?.source, "local", "marketplace epic-loop source.source"); + expectEqual(context, entry.source?.path, "./plugins/epic-loop", "marketplace epic-loop source.path"); + expectEqual(context, entry.policy?.installation, "AVAILABLE", "marketplace epic-loop policy.installation"); + expectEqual(context, entry.policy?.authentication, "ON_INSTALL", "marketplace epic-loop policy.authentication"); } -if (plugin) { - expectEqual(plugin.name, "epic-loop", "plugin name"); - expectEqual(plugin.skills, "./skills/", "plugin skills path"); +function validatePlugin(context, plugin) { + expectEqual(context, plugin.name, "epic-loop", "plugin name"); + expectEqual(context, plugin.skills, "./skills/", "plugin skills path"); if (!plugin.version || typeof plugin.version !== "string") { - errors.push("plugin version must be a non-empty string."); + context.errors.push("plugin version must be a non-empty string."); } - expectIncludes(plugin.description, "Codex or Claude Code hooks", "plugin description"); - rejectIncludes(plugin.description, "driven by Codex hooks", "plugin description"); - expectIncludes(plugin.interface?.longDescription, "Codex or Claude Code hooks", "plugin interface.longDescription"); - rejectIncludes(plugin.interface?.longDescription, "driven by Codex hooks", "plugin interface.longDescription"); + expectIncludes(context, plugin.description, "Codex or Claude Code hooks", "plugin description"); + rejectIncludes(context, plugin.description, "driven by Codex hooks", "plugin description"); + expectIncludes(context, plugin.interface?.longDescription, "Codex or Claude Code hooks", "plugin interface.longDescription"); + rejectIncludes(context, plugin.interface?.longDescription, "driven by Codex hooks", "plugin interface.longDescription"); } -const skill = readText("plugins/epic-loop/skills/epic-loop/SKILL.md"); -if (skill) { - validateSkillEntrypoint(skill); - validateSkillLinks(skill); +function validateSkill(context, skill) { + validateSkillEntrypoint(context, skill); + validateSkillLinks(context, skill); if (skill.includes(".agents/skills/epic-loop")) { - errors.push("Packaged SKILL.md must not reference the legacy .agents/skills/epic-loop path."); + context.errors.push("Packaged SKILL.md must not reference the legacy .agents/skills/epic-loop path."); } if (!skill.includes("")) { - errors.push("Packaged SKILL.md should use for install-independent commands."); + context.errors.push("Packaged SKILL.md should use for install-independent commands."); } if (!skill.includes("project-local `.claude/settings.json`")) { - errors.push("Packaged SKILL.md must document project-local `.claude/settings.json` as the supported Claude Code install target."); + context.errors.push("Packaged SKILL.md must document project-local `.claude/settings.json` as the supported Claude Code install target."); } } -validateReferenceTablesOfContents(); -validateSkillScripts(); -validateNoRuntimeArtifacts(); - -for (const relativePath of listFiles(skillRoot)) { - const content = fs.readFileSync(relativePath, "utf8"); - const displayPath = path.relative(root, relativePath); - if (content.includes(".agents/skills/epic-loop")) { - errors.push(`${displayPath} references the legacy .agents/skills/epic-loop path.`); - } - if (content.includes("templates/implementation-") && !content.includes("assets/templates/implementation-")) { - errors.push(`${displayPath} references implementation templates outside assets/templates.`); - } - if (content.includes("${CLAUDE_PLUGIN_ROOT}")) { - errors.push(`${displayPath} references ${"${CLAUDE_PLUGIN_ROOT}"} before bundled Claude Code hooks are supported.`); +function validatePackagedText(context) { + for (const relativePath of listFiles(context.skillRoot)) { + const content = fs.readFileSync(relativePath, "utf8"); + const displayPath = path.relative(context.root, relativePath); + if (content.includes(".agents/skills/epic-loop")) { + context.errors.push(`${displayPath} references the legacy .agents/skills/epic-loop path.`); + } + if (content.includes("templates/implementation-") && !content.includes("assets/templates/implementation-")) { + context.errors.push(`${displayPath} references implementation templates outside assets/templates.`); + } + if (content.includes("${CLAUDE_PLUGIN_ROOT}")) { + context.errors.push(`${displayPath} references ${"${CLAUDE_PLUGIN_ROOT}"} before bundled Claude Code hooks are supported.`); + } } } -for (const absolutePath of listFiles(pluginRoot)) { - const displayPath = path.relative(root, absolutePath); - if (displayPath.endsWith("hooks/hooks.json")) { - errors.push(`${displayPath} is a bundled Claude Code hook asset; the supported Claude Code install target is project-local .claude/settings.json.`); - } - const content = fs.readFileSync(absolutePath, "utf8"); - if (content.includes("${CLAUDE_PLUGIN_ROOT}")) { - errors.push(`${displayPath} references ${"${CLAUDE_PLUGIN_ROOT}"} before bundled Claude Code hooks are supported.`); +function validatePluginText(context) { + for (const absolutePath of listFiles(context.pluginRoot)) { + const displayPath = path.relative(context.root, absolutePath); + if (displayPath.endsWith("hooks/hooks.json")) { + context.errors.push(`${displayPath} is a bundled Claude Code hook asset; the supported Claude Code install target is project-local .claude/settings.json.`); + } + const content = fs.readFileSync(absolutePath, "utf8"); + if (content.includes("${CLAUDE_PLUGIN_ROOT}")) { + context.errors.push(`${displayPath} references ${"${CLAUDE_PLUGIN_ROOT}"} before bundled Claude Code hooks are supported.`); + } } } -if (errors.length > 0) { - console.error(errors.map((error) => `- ${error}`).join("\n")); - process.exit(1); -} - -console.log("epic-loop package validation passed."); - -function readJson(relativePath) { - const absolutePath = path.join(root, relativePath); +function readJson(context, relativePath) { + const absolutePath = path.join(context.root, relativePath); try { return JSON.parse(fs.readFileSync(absolutePath, "utf8")); } catch (error) { - errors.push(`Invalid JSON in ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); + context.errors.push(`Invalid JSON in ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); return null; } } -function readText(relativePath) { - const absolutePath = path.join(root, relativePath); +function readText(context, relativePath) { + const absolutePath = path.join(context.root, relativePath); try { return fs.readFileSync(absolutePath, "utf8"); } catch (error) { - errors.push(`Cannot read ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); + context.errors.push(`Cannot read ${relativePath}: ${error instanceof Error ? error.message : String(error)}`); return null; } } -function expectEqual(actual, expected, label) { +function expectEqual(context, actual, expected, label) { if (actual !== expected) { - errors.push(`${label} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`); + context.errors.push(`${label} must be ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`); } } -function expectIncludes(actual, expected, label) { +function expectIncludes(context, actual, expected, label) { if (typeof actual !== "string" || !actual.includes(expected)) { - errors.push(`${label} must include ${JSON.stringify(expected)}.`); + context.errors.push(`${label} must include ${JSON.stringify(expected)}.`); } } -function rejectIncludes(actual, rejected, label) { +function rejectIncludes(context, actual, rejected, label) { if (typeof actual === "string" && actual.includes(rejected)) { - errors.push(`${label} must not include ${JSON.stringify(rejected)}.`); + context.errors.push(`${label} must not include ${JSON.stringify(rejected)}.`); } } -function validateSkillEntrypoint(content) { - const parsed = parseSkillFrontmatter(content); - const displayPath = path.relative(root, skillEntrypoint); +function validateSkillEntrypoint(context, content) { + const parsed = parseSkillFrontmatter(context, content); + const displayPath = path.relative(context.root, context.skillEntrypoint); if (!parsed) { - errors.push(`${displayPath} must start with YAML frontmatter delimited by --- lines.`); + context.errors.push(`${displayPath} must start with YAML frontmatter delimited by --- lines.`); return; } @@ -169,32 +196,32 @@ function validateSkillEntrypoint(content) { const description = frontmatter.description; if (typeof name !== "string" || name.length === 0) { - errors.push(`${displayPath} frontmatter must include a non-empty name.`); + context.errors.push(`${displayPath} frontmatter must include a non-empty name.`); } else { if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u.test(name)) { - errors.push(`${displayPath} frontmatter name must be 1-64 characters of lowercase kebab-case.`); + context.errors.push(`${displayPath} frontmatter name must be 1-64 characters of lowercase kebab-case.`); } if (name.includes("--")) { - errors.push(`${displayPath} frontmatter name must not contain consecutive hyphens.`); + context.errors.push(`${displayPath} frontmatter name must not contain consecutive hyphens.`); } - if (name !== skillName) { - errors.push(`${displayPath} frontmatter name must match skill directory ${JSON.stringify(skillName)}.`); + if (name !== context.skillName) { + context.errors.push(`${displayPath} frontmatter name must match skill directory ${JSON.stringify(context.skillName)}.`); } } if (typeof description !== "string" || description.trim().length === 0) { - errors.push(`${displayPath} frontmatter must include a non-empty description.`); + context.errors.push(`${displayPath} frontmatter must include a non-empty description.`); } else { - validateSkillDescription(description, displayPath); + validateSkillDescription(context, description, displayPath); } const bodyLineCount = countLines(body); if (bodyLineCount > skillEntrypointLineBudget) { - errors.push(`${displayPath} body must stay at or below ${skillEntrypointLineBudget} lines, got ${bodyLineCount}.`); + context.errors.push(`${displayPath} body must stay at or below ${skillEntrypointLineBudget} lines, got ${bodyLineCount}.`); } } -function parseSkillFrontmatter(content) { +function parseSkillFrontmatter(context, content) { const match = content.match(/^---\r?\n(?[\s\S]*?)\r?\n---\r?\n?(?[\s\S]*)$/u); if (!match?.groups) { return null; @@ -207,7 +234,7 @@ function parseSkillFrontmatter(content) { } const fieldMatch = line.match(/^(?[A-Za-z0-9_-]+):\s*(?.*)$/u); if (!fieldMatch?.groups) { - errors.push(`${path.relative(root, skillEntrypoint)} frontmatter line ${index + 2} is not a simple key/value field.`); + context.errors.push(`${path.relative(context.root, context.skillEntrypoint)} frontmatter line ${index + 2} is not a simple key/value field.`); continue; } frontmatter[fieldMatch.groups.key] = unquoteYamlScalar(fieldMatch.groups.value.trim()); @@ -226,20 +253,20 @@ function unquoteYamlScalar(value) { return value; } -function validateSkillDescription(description, displayPath) { +function validateSkillDescription(context, description, displayPath) { if (description.length > 1024) { - errors.push(`${displayPath} frontmatter description must be at most 1024 characters, got ${description.length}.`); + context.errors.push(`${displayPath} frontmatter description must be at most 1024 characters, got ${description.length}.`); } if (/<\/?[A-Za-z][^>]*>/u.test(description)) { - errors.push(`${displayPath} frontmatter description must not contain XML-like tags.`); + context.errors.push(`${displayPath} frontmatter description must not contain XML-like tags.`); } if (!/\b(use|when|asks?|requested?|trigger|work|reading|editing|creating|adding|switching|resuming)\b/iu.test(description)) { - errors.push(`${displayPath} frontmatter description must include concrete trigger wording, not only a generic label.`); + context.errors.push(`${displayPath} frontmatter description must include concrete trigger wording, not only a generic label.`); } } -function validateSkillLinks(content) { - const displayPath = path.relative(root, skillEntrypoint); +function validateSkillLinks(context, content) { + const displayPath = path.relative(context.root, context.skillEntrypoint); const linkPattern = /(?[^)\s]+)(?:\s+"[^"]*")?\)/gu; for (const match of content.matchAll(linkPattern)) { @@ -248,18 +275,18 @@ function validateSkillLinks(content) { continue; } if (target.includes("\\")) { - errors.push(`${displayPath} markdown link target must use forward slashes: ${target}`); + context.errors.push(`${displayPath} markdown link target must use forward slashes: ${target}`); continue; } const targetWithoutAnchor = target.split("#")[0]; - const absoluteTarget = path.resolve(skillRoot, targetWithoutAnchor); - if (!isInsidePath(skillRoot, absoluteTarget)) { - errors.push(`${displayPath} markdown link target must stay inside the skill package: ${target}`); + const absoluteTarget = path.resolve(context.skillRoot, targetWithoutAnchor); + if (!isInsidePath(context.skillRoot, absoluteTarget)) { + context.errors.push(`${displayPath} markdown link target must stay inside the skill package: ${target}`); continue; } if (!fs.existsSync(absoluteTarget)) { - errors.push(`${displayPath} markdown link target does not exist: ${target}`); + context.errors.push(`${displayPath} markdown link target does not exist: ${target}`); } } } @@ -268,8 +295,8 @@ function isExternalMarkdownTarget(target) { return /^[a-z][a-z0-9+.-]*:/iu.test(target); } -function validateReferenceTablesOfContents() { - for (const absolutePath of listFiles(path.join(skillRoot, "references"))) { +function validateReferenceTablesOfContents(context) { + for (const absolutePath of listFiles(path.join(context.skillRoot, "references"))) { if (!absolutePath.endsWith(".md")) { continue; } @@ -281,31 +308,31 @@ function validateReferenceTablesOfContents() { const topLines = content.split(/\r?\n/u).slice(0, referenceTocSearchLimit).join("\n"); if (!/^## (?:Contents|Table of Contents|TOC)\s*$/mu.test(topLines) || !/^\s*- \[[^\]]+\]\(#[^)]+\)\s*$/mu.test(topLines)) { - errors.push(`${path.relative(root, absolutePath)} is ${lineCount} lines and must include a table of contents near the top.`); + context.errors.push(`${path.relative(context.root, absolutePath)} is ${lineCount} lines and must include a table of contents near the top.`); } } } -function validateSkillScripts() { - const scriptsRoot = path.join(skillRoot, "scripts"); +function validateSkillScripts(context) { + const scriptsRoot = path.join(context.skillRoot, "scripts"); for (const absolutePath of listAllFiles(scriptsRoot)) { - const displayPath = path.relative(root, absolutePath); + const displayPath = path.relative(context.root, absolutePath); if (!absolutePath.endsWith(".mjs")) { - errors.push(`${displayPath} must use the .mjs extension for bundled skill scripts.`); + context.errors.push(`${displayPath} must use the .mjs extension for bundled skill scripts.`); continue; } - const result = spawnSync(process.execPath, ["--check", absolutePath], { + const result = spawnSync(context.nodeExecutable, ["--check", absolutePath], { encoding: "utf8", }); if (result.status !== 0) { const output = `${result.stderr}${result.stdout}`.trim(); - errors.push(`${displayPath} failed node --check${output ? `: ${output.split(/\r?\n/u)[0]}` : "."}`); + context.errors.push(`${displayPath} failed node --check${output ? `: ${output.split(/\r?\n/u)[0]}` : "."}`); } } } -function validateNoRuntimeArtifacts() { +function validateNoRuntimeArtifacts(context) { const runtimeArtifactNames = new Set([ ".runtime", ".validation-output", @@ -321,11 +348,11 @@ function validateNoRuntimeArtifacts() { "sessions", ]); - for (const absolutePath of listEntries(skillRoot)) { + for (const absolutePath of listEntries(context.skillRoot)) { const name = path.basename(absolutePath); - const displayPath = path.relative(root, absolutePath); + const displayPath = path.relative(context.root, absolutePath); if (runtimeArtifactNames.has(name) || /\.(log|tmp)$/u.test(name)) { - errors.push(`${displayPath} looks like a runtime/debug artifact and must not be committed in the skill package.`); + context.errors.push(`${displayPath} looks like a runtime/debug artifact and must not be committed in the skill package.`); } } } @@ -376,3 +403,18 @@ function isInsidePath(parentPath, childPath) { function countLines(content) { return content.length === 0 ? 0 : content.split(/\r?\n/u).length; } + +function runCli() { + const errors = validateEpicLoopPackage(); + + if (errors.length > 0) { + console.error(errors.map((error) => `- ${error}`).join("\n")); + process.exit(1); + } + + console.log("epic-loop package validation passed."); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli(); +} diff --git a/tests/unit/skill-package-validation.test.mjs b/tests/unit/skill-package-validation.test.mjs new file mode 100644 index 0000000..f09d073 --- /dev/null +++ b/tests/unit/skill-package-validation.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; + +import { validateEpicLoopPackage } from "../../scripts/validate-epic-loop-package.mjs"; +import { makeTempRoot, repoRoot } from "./test-utils.mjs"; + +const validatorScript = path.join(repoRoot, "scripts", "validate-epic-loop-package.mjs"); + +function writeFile(root, relativePath, content) { + const absolutePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, content, "utf8"); +} + +function writeJson(root, relativePath, value) { + writeFile(root, relativePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function validSkillMarkdown(overrides = {}) { + const name = overrides.name ?? "epic-loop"; + const description = overrides.description ?? "Use this skill when working with epic-loop workspaces, implementation routing, and durable epic artifacts."; + const bodyLines = overrides.bodyLines ?? [ + "# Epic Loop", + "", + "Use `` commands for runtime operations.", + "", + "Document project-local `.claude/settings.json` for Claude Code hooks.", + "", + "Read [Guide](references/guide.md) for details.", + ]; + + return [`---`, `name: ${name}`, `description: ${description}`, `---`, ...bodyLines].join("\n"); +} + +function validFixture(root) { + writeJson(root, ".agents/plugins/marketplace.json", { + interface: { + description: "Codex or Claude Code hooks for epic-loop workspace automation.", + }, + name: "epic-loop", + plugins: [ + { + name: "epic-loop", + policy: { + authentication: "ON_INSTALL", + installation: "AVAILABLE", + }, + source: { + path: "./plugins/epic-loop", + source: "local", + }, + }, + ], + }); + writeJson(root, "plugins/epic-loop/.codex-plugin/plugin.json", { + description: "Codex or Claude Code hooks for epic-loop workspace automation.", + interface: { + longDescription: "Codex or Claude Code hooks for durable epic-loop workspace automation.", + }, + name: "epic-loop", + skills: "./skills/", + version: "1.0.0", + }); + writeFile(root, "plugins/epic-loop/skills/epic-loop/SKILL.md", `${validSkillMarkdown()}\n`); + writeFile(root, "plugins/epic-loop/skills/epic-loop/agents/openai.yaml", "name: epic-loop\n"); + writeFile(root, "plugins/epic-loop/skills/epic-loop/assets/templates/implementation-manager-prompt.md", "Manager template.\n"); + writeFile(root, "plugins/epic-loop/skills/epic-loop/assets/templates/implementation-techlead-prompt.md", "Techlead template.\n"); + writeFile(root, "plugins/epic-loop/skills/epic-loop/references/guide.md", "# Guide\n\nShort reference.\n"); + writeFile(root, "plugins/epic-loop/skills/epic-loop/scripts/ok.mjs", "console.log('ok');\n"); +} + +function withFixture(testName, callback) { + const root = makeTempRoot(`skill-validation-${testName}-`); + try { + validFixture(root); + callback(root); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +} + +function assertValidationError(root, expectedPattern) { + const errors = validateEpicLoopPackage({ root }); + assert.match(errors.join("\n"), expectedPattern); +} + +test("skill package validator accepts the maintained repository package and CLI success output", () => { + assert.deepEqual(validateEpicLoopPackage({ root: repoRoot }), []); + + const result = spawnSync(process.execPath, [validatorScript], { + cwd: repoRoot, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), "epic-loop package validation passed."); +}); + +test("skill package validator rejects invalid skill names and directory-name mismatches", () => { + withFixture("name", (root) => { + writeFile( + root, + "plugins/epic-loop/skills/epic-loop/SKILL.md", + `${validSkillMarkdown({ + name: "InvalidName", + })}\n`, + ); + + assertValidationError(root, /SKILL\.md frontmatter name must be 1-64 characters of lowercase kebab-case/u); + assertValidationError(root, /SKILL\.md frontmatter name must match skill directory "epic-loop"/u); + }); +}); + +test("skill package validator rejects missing descriptions", () => { + withFixture("description", (root) => { + writeFile( + root, + "plugins/epic-loop/skills/epic-loop/SKILL.md", + `${validSkillMarkdown({ + description: "", + })}\n`, + ); + + assertValidationError(root, /SKILL\.md frontmatter must include a non-empty description/u); + }); +}); + +test("skill package validator rejects over-budget entrypoint bodies", () => { + withFixture("line-budget", (root) => { + const bodyLines = [ + "# Epic Loop", + "Use `` commands for runtime operations.", + "Document project-local `.claude/settings.json` for Claude Code hooks.", + "Read [Guide](references/guide.md) for details.", + ...Array.from({ length: 501 }, (_, index) => `Instruction line ${index + 1}.`), + ]; + writeFile(root, "plugins/epic-loop/skills/epic-loop/SKILL.md", `${validSkillMarkdown({ bodyLines })}\n`); + + assertValidationError(root, /SKILL\.md body must stay at or below 500 lines/u); + }); +}); + +test("skill package validator rejects long references without a table of contents", () => { + withFixture("toc", (root) => { + writeFile( + root, + "plugins/epic-loop/skills/epic-loop/references/guide.md", + ["# Guide", "", ...Array.from({ length: 105 }, (_, index) => `Reference line ${index + 1}.`)].join("\n"), + ); + + assertValidationError(root, /references\/guide\.md is \d+ lines and must include a table of contents near the top/u); + }); +}); + +test("skill package validator rejects backslash markdown link targets", () => { + withFixture("backslash-link", (root) => { + writeFile( + root, + "plugins/epic-loop/skills/epic-loop/SKILL.md", + `${validSkillMarkdown({ + bodyLines: [ + "# Epic Loop", + "Use `` commands for runtime operations.", + "Document project-local `.claude/settings.json` for Claude Code hooks.", + "Read [Guide](references\\guide.md) for details.", + ], + })}\n`, + ); + + assertValidationError(root, /SKILL\.md markdown link target must use forward slashes: references\\guide\.md/u); + }); +}); + +test("skill package validator rejects runtime artifacts in the skill package", () => { + withFixture("runtime-artifact", (root) => { + writeFile(root, "plugins/epic-loop/skills/epic-loop/.runtime/prompt-log.md", "debug trace\n"); + + assertValidationError(root, /\.runtime looks like a runtime\/debug artifact/u); + }); +}); + +test("skill package validator CLI prints actionable path diagnostics on failure", () => { + withFixture("cli-failure", (root) => { + writeFile(root, "plugins/epic-loop/skills/epic-loop/.runtime/prompt-log.md", "debug trace\n"); + + const result = spawnSync(process.execPath, [validatorScript], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /^- plugins\/epic-loop\/skills\/epic-loop\/\.runtime looks like a runtime\/debug artifact/mu); + }); +}); From 86efeaa73de94b1d10212749cb583be311990971 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:18:51 +0700 Subject: [PATCH 08/17] Record deterministic validation verification --- .epic-loop/epics/set-up/implementation-log.md | 5 +++++ .epic-loop/epics/set-up/state-of-epic.md | 7 ++++--- .epic-loop/epics/set-up/tracker.md | 4 ++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 3220845..87ff415 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -44,3 +44,8 @@ - Task: Phase 2 Task 2 - Add focused tests or fixtures for deterministic skill package validation - Verdict: closed: refactored scripts/validate-epic-loop-package.mjs into an importable validateEpicLoopPackage({ root }) helper while preserving CLI success and failure output. Added tests/unit/skill-package-validation.test.mjs with temporary fixture coverage for the maintained package, invalid name/directory mismatch, missing description, entrypoint line budget, missing long-reference table of contents, backslash markdown links, runtime artifact detection, and CLI failure diagnostics. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed; pnpm run lint passed; pnpm run format:check passed; pnpm run test:unit passed 68/68; pnpm run validate passed. Residual risk: Phase 2 aggregate verification remains as the next task. + +## 2026-07-07T14:18:42+00:00 - closed: deterministic skill package checks verified through focused and aggregate validation paths. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed with expected success output; pnpm run test:unit passed 68/68; pnpm run lint passed; pnpm run format:check passed; pnpm run validate passed; git status --short was clean after verification. No generated runtime/debug artifacts appeared. Phase 2 is complete pending mandatory phase-closure housekeeping. + +- Task: Phase 2 Task 3 - Verify deterministic skill package checks through aggregate validation +- Verdict: closed: deterministic skill package checks verified through focused and aggregate validation paths. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed with expected success output; pnpm run test:unit passed 68/68; pnpm run lint passed; pnpm run format:check passed; pnpm run validate passed; git status --short was clean after verification. No generated runtime/debug artifacts appeared. Phase 2 is complete pending mandatory phase-closure housekeeping. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 1e69e2b..dacd594 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 2 - Deterministic Skill Package Checks -Active task: Phase 2 Task 3 - Verify deterministic skill package checks through aggregate validation +Active task: Phase 2 closure housekeeping ## Current State @@ -17,7 +17,8 @@ Active task: Phase 2 Task 3 - Verify deterministic skill package checks through - Phase 1 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. - Phase 2 Task 1 is closed: deterministic skill package validation now checks mechanical Agent Skills invariants for the maintained `epic-loop` package through the existing package validator and aggregate validation path. - Phase 2 Task 2 is closed: deterministic skill package validation now has focused unit coverage for valid package behavior and representative invalid mechanical invariants. -- Phase 2 is active: the next implementation slice should verify deterministic skill package checks through the aggregate validation path. +- Phase 2 Task 3 is closed: focused validator tests, the deterministic package validator, full unit tests, lint, format check, and aggregate validation all pass, and verification left no generated runtime/debug artifacts. +- Phase 2 is complete; mandatory phase-closure housekeeping is the next implementation-loop step. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -28,4 +29,4 @@ Active task: Phase 2 Task 3 - Verify deterministic skill package checks through ## Next Action -- Continue with Phase 2 Task 3: verify deterministic skill package checks through aggregate validation. +- Run Phase 2 phase-closure housekeeping before starting Phase 3. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 3ff92cd..ef216dc 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -54,7 +54,7 @@ Epic: Linting And Skill Checks ### Phase 2: Deterministic Skill Package Checks -- Phase status: doing +- Phase status: done - [x] Kind: implementation | Status: done | Add deterministic skill package validation for mechanical Agent Skills invariants. - Outcome: Maintained skill packages fail validation when their file shape, frontmatter, naming, references, or generated-artifact boundaries violate portable skill package rules. @@ -68,7 +68,7 @@ Epic: Linting And Skill Checks - Acceptance: Tests cover valid skill metadata, invalid names, missing descriptions, long entrypoint files, missing table of contents for long references, Windows-style paths, and generated artifact detection. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: doing | Verify deterministic skill package checks through aggregate validation. +- [x] Kind: verification | Status: done | Verify deterministic skill package checks through aggregate validation. - Outcome: Skill package mechanical validation is proven as part of the standard repository validation path. - Surface: `pnpm run validate`, focused validator command, unit tests, current plugin skill package. - Acceptance: Run focused validator tests, run the deterministic skill validator, and run `pnpm run validate` successfully; evidence includes exit codes and any required package cleanup. From b74c1b5f1a2ce191663fbded60a9d439b0c048d9 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:28:07 +0700 Subject: [PATCH 09/17] Add AI skill review runner --- .epic-loop/epics/set-up/implementation-log.md | 21 ++ .epic-loop/epics/set-up/risk-register.md | 1 + .epic-loop/epics/set-up/state-of-epic.md | 12 +- .epic-loop/epics/set-up/tracker.md | 10 +- .gitignore | 1 + package.json | 1 + scripts/review-skills-ai.mjs | 310 ++++++++++++++++++ tests/unit/skill-review-ai.test.mjs | 159 +++++++++ 8 files changed, 508 insertions(+), 7 deletions(-) create mode 100644 scripts/review-skills-ai.mjs create mode 100644 tests/unit/skill-review-ai.test.mjs diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 87ff415..4f0ceb7 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -49,3 +49,24 @@ - Task: Phase 2 Task 3 - Verify deterministic skill package checks through aggregate validation - Verdict: closed: deterministic skill package checks verified through focused and aggregate validation paths. Verification: node --test tests/unit/skill-package-validation.test.mjs passed 8/8; node scripts/validate-epic-loop-package.mjs passed with expected success output; pnpm run test:unit passed 68/68; pnpm run lint passed; pnpm run format:check passed; pnpm run validate passed; git status --short was clean after verification. No generated runtime/debug artifacts appeared. Phase 2 is complete pending mandatory phase-closure housekeeping. + +## 2026-07-07T14:27:57+00:00 - closed: added headless Codex skill review runner with schema-validated JSON output, stable diagnostics, ignored .validation-output storage, package script integration, and focused unit coverage. Live review produced a valid blocking finding, proving non-zero behavior; the finding is tracked as the next Phase 3 correction task. + +- Task: Phase 3 Task 1 - Add a headless Codex skill review runner with structured JSON output +- Verdict: closed: added headless Codex skill review runner with schema-validated JSON output, stable diagnostics, ignored .validation-output storage, package script integration, and focused unit coverage. Live review produced a valid blocking finding, proving non-zero behavior; the finding is tracked as the next Phase 3 correction task. +- Changed: + - .gitignore ignores .validation-output + - package.json adds review:skills:ai + - scripts/review-skills-ai.mjs wraps codex exec and validates reports + - tests/unit/skill-review-ai.test.mjs covers schema, formatting, blocking policy, and mocked CLI behavior + - tracker/state/risk artifacts record closure and the next correction task +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 6/6 + - mocked pnpm run review:skills:ai passed + - pnpm run lint passed + - pnpm run format:check passed + - pnpm run test:unit passed 74/74 + - pnpm run validate passed + - live pnpm run review:skills:ai generated ignored latest.json and exited 1 on a valid error finding +- Residual risk: AI-backed review output is model-dependent and the first live run found a real hook contract issue in hooks.mjs; follow-up correction is active before rubric expansion +- Next move: Implement focused correction for unbound hook capture persistence, then continue the Phase 3 rubric task. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 3351ccd..66582d9 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -5,3 +5,4 @@ | Adding Oxfmt formats too much in one task and obscures behavioral changes. | Review becomes harder and task commits lose focus. | Keep configuration/check integration separate from optional repository-wide formatting cleanup. | open | | oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. Current accepted baseline debt: `loop.mjs` and `hooks.mjs` exceed the accepted `max-lines: 600` source limit and are tracked for Phase 1 refactor before phase verification. | mitigation-planned | | AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | +| AI review found that unbound hook invocations persist raw capture data before the binding gate. | Hook behavior conflicts with the repository contract that unbound sessions produce no epic-loop records and may expose prompt/transcript metadata. | Add a focused correction task that preserves reliable `bind-session --current` behavior while preventing raw unbound capture persistence. | active | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index dacd594..fbd58fa 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -4,8 +4,8 @@ Epic: Linting And Skill Checks Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation -Active phase: Phase 2 - Deterministic Skill Package Checks -Active task: Phase 2 closure housekeeping +Active phase: Phase 3 - AI-Assisted Skill Quality Review +Active task: Phase 3 Task 2 - Fix unbound hook capture persistence surfaced by AI skill review ## Current State @@ -18,15 +18,17 @@ Active task: Phase 2 closure housekeeping - Phase 2 Task 1 is closed: deterministic skill package validation now checks mechanical Agent Skills invariants for the maintained `epic-loop` package through the existing package validator and aggregate validation path. - Phase 2 Task 2 is closed: deterministic skill package validation now has focused unit coverage for valid package behavior and representative invalid mechanical invariants. - Phase 2 Task 3 is closed: focused validator tests, the deterministic package validator, full unit tests, lint, format check, and aggregate validation all pass, and verification left no generated runtime/debug artifacts. -- Phase 2 is complete; mandatory phase-closure housekeeping is the next implementation-loop step. +- Phase 2 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. +- Phase 3 Task 1 is closed: `pnpm run review:skills:ai` now wraps `codex exec --ephemeral`, requires schema-valid JSON in `.validation-output/skill-review/latest.json`, prints stable path-oriented diagnostics, exits non-zero for blocking findings, and remains separate from `pnpm run validate`. +- The first live AI review produced a valid blocking finding: unbound hook invocations currently persist raw capture data before the binding gate in `hooks.mjs`. That issue is now tracked as the active Phase 3 correction task before rubric expansion. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- None currently known. +- None currently blocking implementation progress; the AI review finding is tracked as active work. ## Next Action -- Run Phase 2 phase-closure housekeeping before starting Phase 3. +- Continue with Phase 3 Task 2: fix unbound hook capture persistence surfaced by AI skill review. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index ef216dc..18d3007 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -76,14 +76,20 @@ Epic: Linting And Skill Checks ### Phase 3: AI-Assisted Skill Quality Review -- Phase status: todo +- Phase status: doing -- [ ] Kind: implementation | Status: todo | Add a headless Codex skill review runner with structured JSON output. +- [x] Kind: implementation | Status: done | Add a headless Codex skill review runner with structured JSON output. - Outcome: Semantic skill quality review can be launched as a normal script command while internally using `codex exec` in non-interactive mode. - Surface: `package.json` scripts, AI review runner script, repo-local review skill or prompt, ignored `.validation-output/skill-review/` output path, JSON schema validation. - Acceptance: `pnpm run review:skills:ai` invokes `codex exec --ephemeral`, requires a structured JSON report, validates the report schema, prints stable findings, and exits non-zero for blocking findings, malformed JSON, missing output, unknown schema versions, or failed Codex execution. - Docs: `docs/linting-and-skill-validation-policy.md`. +- [ ] Kind: implementation | Status: doing | Fix unbound hook capture persistence surfaced by AI skill review. + - Outcome: Unbound hook invocations do not persist raw hook payloads or prompt/transcript metadata, while current-session binding remains reliable. + - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs`, hook capture/session-binding helpers, and focused hook/unit tests. + - Acceptance: Unbound sessions remain silent/no-op for runtime records except any deliberately minimal, non-sensitive binding handshake needed by `bind-session --current`; bound hook routing and implementation-loop continuation behavior remain covered by tests. + - Docs: `docs/linting-and-skill-validation-policy.md`. + - [ ] Kind: implementation | Status: todo | Define the AI skill quality review rubric and finding schema. - Outcome: The model-backed review evaluates skill semantics consistently instead of producing free-form prose. - Surface: Review skill or prompt file, JSON schema fixture, review runner tests, skill policy docs. diff --git a/.gitignore b/.gitignore index 216a3dd..3202c58 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ dist/ build/ .cache/ temp/ +.validation-output/ .claude .codex diff --git a/package.json b/package.json index 31758a8..d0a9459 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "oxlint scripts plugins/epic-loop/skills/epic-loop/scripts tests packages/cli/src packages/cli/scripts", "format:check": "oxfmt --check package.json .oxlintrc.json .oxfmtrc.json \"scripts/*.mjs\" \"tests/**/*.mjs\" \"packages/cli/src/**/*.mjs\" \"packages/cli/scripts/**/*.mjs\" \"plugins/epic-loop/skills/epic-loop/scripts/**/*.mjs\"", "format:write": "oxfmt --write package.json .oxlintrc.json .oxfmtrc.json \"scripts/*.mjs\" \"tests/**/*.mjs\" \"packages/cli/src/**/*.mjs\" \"packages/cli/scripts/**/*.mjs\" \"plugins/epic-loop/skills/epic-loop/scripts/**/*.mjs\"", + "review:skills:ai": "node scripts/review-skills-ai.mjs", "validate": "for f in scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/*.mjs plugins/epic-loop/skills/epic-loop/scripts/lib/*.mjs; do node --check \"$f\" || exit 1; done && pnpm run lint && pnpm run format:check && node scripts/validate-epic-loop-package.mjs", "validate:epic-loop": "pnpm run validate", "doctor:epic-loop:codex": "node plugins/epic-loop/skills/epic-loop/scripts/doctor.mjs --platform codex --json", diff --git a/scripts/review-skills-ai.mjs b/scripts/review-skills-ai.mjs new file mode 100644 index 0000000..675dadf --- /dev/null +++ b/scripts/review-skills-ai.mjs @@ -0,0 +1,310 @@ +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; + +const schemaVersion = 1; +const allowedStatuses = new Set(["pass", "fail", "needs-review"]); +const allowedSeverities = new Set(["error", "warning", "info"]); +const outputDirRelative = ".validation-output/skill-review"; +const latestReportRelative = `${outputDirRelative}/latest.json`; + +export function reviewOutputPaths(root = process.cwd()) { + const outputDir = path.resolve(root, outputDirRelative); + const reportPath = path.resolve(root, latestReportRelative); + + if (!isInsidePath(outputDir, reportPath)) { + throw new Error(`${latestReportRelative} must stay inside ${outputDirRelative}.`); + } + + return { + outputDir, + reportPath, + }; +} + +export function validateSkillReviewReport(report, root = process.cwd()) { + const errors = []; + + if (!isPlainObject(report)) { + return ["review report must be a JSON object."]; + } + + if (report.schemaVersion !== schemaVersion) { + errors.push(`schemaVersion must be ${schemaVersion}.`); + } + if (!allowedStatuses.has(report.status)) { + errors.push('status must be one of "pass", "fail", or "needs-review".'); + } + if (typeof report.summary !== "string" || report.summary.trim().length === 0) { + errors.push("summary must be a non-empty string."); + } + if (!Array.isArray(report.findings)) { + errors.push("findings must be an array."); + return errors; + } + + for (const [index, finding] of report.findings.entries()) { + validateFinding(errors, finding, index, root); + } + + return errors; +} + +export function blockingReviewReasons(report) { + const reasons = []; + if (report.status === "fail") { + reasons.push("report status is fail."); + } + + const errorFindings = Array.isArray(report.findings) ? report.findings.filter((finding) => finding?.severity === "error") : []; + if (errorFindings.length > 0) { + reasons.push(`${errorFindings.length} error finding(s) present.`); + } + + return reasons; +} + +export function formatSkillReviewReport(report) { + const lines = [`AI skill review status: ${report.status}`, `Summary: ${report.summary.trim()}`]; + + if (report.findings.length === 0) { + lines.push("Findings: none."); + return lines; + } + + lines.push("Findings:"); + for (const finding of report.findings) { + const lineSuffix = Number.isInteger(finding.line) && finding.line > 0 ? `:${finding.line}` : ""; + lines.push(`- [${finding.severity}] ${finding.code} ${finding.path}${lineSuffix} - ${finding.message}`); + lines.push(` Recommendation: ${finding.recommendation}`); + } + + return lines; +} + +export function buildSkillReviewPrompt(reportPath) { + const normalizedReportPath = reportPath.split(path.sep).join("/"); + return [ + "You are reviewing the maintained epic-loop Agent Skill package for semantic quality.", + "", + "Inspect these repository paths:", + "- plugins/epic-loop/skills/epic-loop/SKILL.md", + "- plugins/epic-loop/skills/epic-loop/references/", + "- plugins/epic-loop/skills/epic-loop/scripts/", + "- .epic-loop/epics/set-up/docs/linting-and-skill-validation-policy.md", + "", + "Review only semantic skill-quality concerns that deterministic scripts cannot prove:", + "- invocation quality and trigger boundaries", + "- progressive disclosure", + "- task-local reference organization", + "- degree of freedom in instructions", + "- bundled script and external dependency safety concerns", + "", + "Do not edit tracked source files. Write exactly one JSON report to:", + normalizedReportPath, + "", + "The report must match this schema:", + JSON.stringify( + { + findings: [ + { + code: "skill.description.too-broad", + line: 2, + message: "Description can trigger outside epic-loop work.", + path: "plugins/epic-loop/skills/epic-loop/SKILL.md", + recommendation: "Narrow the trigger wording to explicit epic-loop workspace operations.", + severity: "error", + }, + ], + schemaVersion, + status: "pass", + summary: "Short review summary.", + }, + null, + 2, + ), + "", + 'Allowed status values: "pass", "fail", "needs-review".', + 'Allowed severity values: "error", "warning", "info".', + 'Use "fail" when any error finding is present.', + "Return a short final message after writing the file.", + ].join("\n"); +} + +export function runSkillReview(options = {}) { + const root = options.root ?? process.cwd(); + const codexCommand = options.codexCommand ?? "codex"; + const mockReportPath = options.mockReportPath ?? null; + const { outputDir, reportPath } = reviewOutputPaths(root); + + fs.mkdirSync(outputDir, { recursive: true }); + fs.rmSync(reportPath, { force: true }); + + if (mockReportPath) { + fs.copyFileSync(path.resolve(root, mockReportPath), reportPath); + } else { + const codexResult = spawnSync(codexCommand, ["exec", "--ephemeral", "--cd", root, "--sandbox", "workspace-write", "-"], { + cwd: root, + encoding: "utf8", + input: buildSkillReviewPrompt(path.relative(root, reportPath)), + maxBuffer: 10 * 1024 * 1024, + }); + if (codexResult.status !== 0) { + return { + exitCode: 1, + lines: [ + `codex exec failed with exit code ${codexResult.status ?? "unknown"}.`, + ...firstOutputLines("stderr", codexResult.stderr), + ...firstOutputLines("stdout", codexResult.stdout), + ], + }; + } + } + + const reportResult = readReport(reportPath); + if (!reportResult.ok) { + return { + exitCode: 1, + lines: [reportResult.error], + }; + } + + const schemaErrors = validateSkillReviewReport(reportResult.report, root); + if (schemaErrors.length > 0) { + return { + exitCode: 1, + lines: schemaErrors.map((error) => `Invalid review report: ${error}`), + }; + } + + const blockingReasons = blockingReviewReasons(reportResult.report); + return { + exitCode: blockingReasons.length > 0 ? 1 : 0, + lines: [...formatSkillReviewReport(reportResult.report), ...blockingReasons.map((reason) => `Blocking: ${reason}`)], + }; +} + +function validateFinding(errors, finding, index, root) { + const label = `findings[${index}]`; + if (!isPlainObject(finding)) { + errors.push(`${label} must be an object.`); + return; + } + if (!allowedSeverities.has(finding.severity)) { + errors.push(`${label}.severity must be one of "error", "warning", or "info".`); + } + for (const field of ["code", "path", "message", "recommendation"]) { + if (typeof finding[field] !== "string" || finding[field].trim().length === 0) { + errors.push(`${label}.${field} must be a non-empty string.`); + } + } + if (finding.line !== undefined && finding.line !== null && (!Number.isInteger(finding.line) || finding.line <= 0)) { + errors.push(`${label}.line must be a positive integer when present.`); + } + if (typeof finding.path === "string" && !isRepositoryRelativePath(finding.path, root)) { + errors.push(`${label}.path must be a repository-relative path.`); + } +} + +function readReport(reportPath) { + let content; + try { + content = fs.readFileSync(reportPath, "utf8"); + } catch (error) { + return { + error: `Missing review report: ${path.relative(process.cwd(), reportPath)} (${error instanceof Error ? error.message : String(error)})`, + ok: false, + }; + } + + try { + return { + ok: true, + report: JSON.parse(content), + }; + } catch (error) { + return { + error: `Malformed review report JSON: ${error instanceof Error ? error.message : String(error)}`, + ok: false, + }; + } +} + +function firstOutputLines(label, value) { + const lines = String(value ?? "") + .trim() + .split(/\r?\n/u) + .filter(Boolean) + .slice(0, 10); + return lines.map((line) => `${label}: ${line}`); +} + +function isPlainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isInsidePath(parentPath, childPath) { + const relativePath = path.relative(parentPath, childPath); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function isRepositoryRelativePath(value, root) { + if (value.includes("\\") || path.isAbsolute(value) || value.includes("\0")) { + return false; + } + const absolutePath = path.resolve(root, value); + return isInsidePath(root, absolutePath); +} + +function parseArgs(args) { + const options = {}; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--") { + continue; + } + if (arg === "--mock-report") { + index += 1; + if (!args[index]) { + throw new Error("--mock-report requires a file path."); + } + options.mockReportPath = args[index]; + continue; + } + if (arg === "--codex-command") { + index += 1; + if (!args[index]) { + throw new Error("--codex-command requires a command."); + } + options.codexCommand = args[index]; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return options; +} + +function runCli() { + let options; + try { + options = parseArgs(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } + + const result = runSkillReview(options); + const output = result.lines.join("\n"); + if (result.exitCode === 0) { + console.log(output); + } else { + console.error(output); + } + process.exit(result.exitCode); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + runCli(); +} diff --git a/tests/unit/skill-review-ai.test.mjs b/tests/unit/skill-review-ai.test.mjs new file mode 100644 index 0000000..47bd188 --- /dev/null +++ b/tests/unit/skill-review-ai.test.mjs @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; + +import { blockingReviewReasons, formatSkillReviewReport, reviewOutputPaths, validateSkillReviewReport } from "../../scripts/review-skills-ai.mjs"; +import { makeTempRoot, repoRoot } from "./test-utils.mjs"; + +const runnerScript = path.join(repoRoot, "scripts", "review-skills-ai.mjs"); + +function validReport(overrides = {}) { + return { + findings: [], + schemaVersion: 1, + status: "pass", + summary: "Skill review passed.", + ...overrides, + }; +} + +function writeReport(root, relativePath, report) { + const absolutePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); +} + +function withTempRoot(prefix, callback) { + const root = makeTempRoot(prefix); + try { + callback(root); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +} + +test("AI skill review report schema accepts a valid pass report", () => { + assert.deepEqual(validateSkillReviewReport(validReport(), repoRoot), []); +}); + +test("AI skill review report schema rejects invalid top-level and finding fields", () => { + const errors = validateSkillReviewReport( + validReport({ + findings: [ + { + code: "", + line: 0, + message: "", + path: "../outside.md", + recommendation: "", + severity: "blocker", + }, + ], + schemaVersion: 2, + status: "unknown", + summary: "", + }), + repoRoot, + ); + + assert.match(errors.join("\n"), /schemaVersion must be 1/u); + assert.match(errors.join("\n"), /status must be one of/u); + assert.match(errors.join("\n"), /summary must be a non-empty string/u); + assert.match(errors.join("\n"), /findings\[0\]\.severity/u); + assert.match(errors.join("\n"), /findings\[0\]\.code/u); + assert.match(errors.join("\n"), /findings\[0\]\.line/u); + assert.match(errors.join("\n"), /findings\[0\]\.path must be a repository-relative path/u); +}); + +test("AI skill review output formatting is stable and path-oriented", () => { + const lines = formatSkillReviewReport( + validReport({ + findings: [ + { + code: "skill.progressive-disclosure.too-broad", + line: 12, + message: "Entrypoint carries too much conditional detail.", + path: "plugins/epic-loop/skills/epic-loop/SKILL.md", + recommendation: "Move conditional detail into a direct reference.", + severity: "warning", + }, + ], + status: "needs-review", + summary: "One warning found.", + }), + ); + + assert.deepEqual(lines, [ + "AI skill review status: needs-review", + "Summary: One warning found.", + "Findings:", + "- [warning] skill.progressive-disclosure.too-broad plugins/epic-loop/skills/epic-loop/SKILL.md:12 - Entrypoint carries too much conditional detail.", + " Recommendation: Move conditional detail into a direct reference.", + ]); +}); + +test("AI skill review blocking policy treats fail status and error findings as non-zero", () => { + assert.deepEqual(blockingReviewReasons(validReport()), []); + assert.deepEqual(blockingReviewReasons(validReport({ status: "fail" })), ["report status is fail."]); + assert.deepEqual( + blockingReviewReasons( + validReport({ + findings: [ + { + code: "skill.description.too-broad", + message: "Description can trigger outside this package.", + path: "plugins/epic-loop/skills/epic-loop/SKILL.md", + recommendation: "Narrow the description.", + severity: "error", + }, + ], + }), + ), + ["1 error finding(s) present."], + ); +}); + +test("AI skill review CLI accepts a mocked valid report and writes under ignored output", () => { + withTempRoot("skill-review-pass-", (root) => { + writeReport(root, "mock-pass.json", validReport()); + + const result = spawnSync(process.execPath, [runnerScript, "--mock-report", "mock-pass.json"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /AI skill review status: pass/u); + assert.equal(fs.existsSync(reviewOutputPaths(root).reportPath), true); + }); +}); + +test("AI skill review CLI rejects a mocked report with error findings", () => { + withTempRoot("skill-review-fail-", (root) => { + writeReport( + root, + "mock-fail.json", + validReport({ + findings: [ + { + code: "skill.description.too-broad", + message: "Description can trigger outside this package.", + path: "plugins/epic-loop/skills/epic-loop/SKILL.md", + recommendation: "Narrow the description.", + severity: "error", + }, + ], + }), + ); + + const result = spawnSync(process.execPath, [runnerScript, "--mock-report", "mock-fail.json"], { + cwd: root, + encoding: "utf8", + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Blocking: 1 error finding\(s\) present\./u); + }); +}); From 9783c42da995ba2b3ebbb6986fdff3d1e460bfae Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:34:24 +0700 Subject: [PATCH 10/17] Stop persisting raw unbound hook captures --- .epic-loop/epics/set-up/implementation-log.md | 20 +++++ .epic-loop/epics/set-up/risk-register.md | 2 +- .epic-loop/epics/set-up/state-of-epic.md | 8 +- .epic-loop/epics/set-up/tracker.md | 4 +- .../skills/epic-loop/scripts/lib/common.mjs | 85 ++++++++++++++----- .../skills/epic-loop/scripts/lib/epics.mjs | 5 +- .../skills/epic-loop/scripts/lib/hooks.mjs | 5 +- tests/unit/cli-contracts.test.mjs | 27 ++++++ tests/unit/hook-contracts.test.mjs | 28 +++++- 9 files changed, 150 insertions(+), 34 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 4f0ceb7..8103e61 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -70,3 +70,23 @@ - live pnpm run review:skills:ai generated ignored latest.json and exited 1 on a valid error finding - Residual risk: AI-backed review output is model-dependent and the first live run found a real hook contract issue in hooks.mjs; follow-up correction is active before rubric expansion - Next move: Implement focused correction for unbound hook capture persistence, then continue the Phase 3 rubric task. + +## 2026-07-07T14:34:13+00:00 - closed: replaced raw pre-binding hook capture with a minimal current-session handshake so unbound hooks no longer persist raw payloads, prompt text, or transcript paths. bind-session --current remains covered for Codex and Claude Code; bound hook event persistence still occurs after the binding gate. + +- Task: Phase 3 Task 2 - Fix unbound hook capture persistence surfaced by AI skill review +- Verdict: closed: replaced raw pre-binding hook capture with a minimal current-session handshake so unbound hooks no longer persist raw payloads, prompt text, or transcript paths. bind-session --current remains covered for Codex and Claude Code; bound hook event persistence still occurs after the binding gate. +- Changed: + - plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs writes and reads minimal capture handshakes + - plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs accepts Claude Code handshake captures for current-session binding + - plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs documents the pre-binding handshake boundary + - tests/unit/hook-contracts.test.mjs and tests/unit/cli-contracts.test.mjs assert no sensitive raw capture and preserved current binding +- Verification: + - node --test tests/unit/hook-contracts.test.mjs passed 15/15 + - node --test tests/unit/cli-contracts.test.mjs passed 19/19 + - pnpm run test:unit passed 74/74 + - pnpm run lint passed + - pnpm run format:check passed + - pnpm run validate passed +- Residual risk: A minimal handshake is still written before binding because current-session binding needs session identity; it deliberately excludes prompt text, transcript paths, and raw hook payloads. +- Commit: task-owned commit containing this closure note +- Next move: Continue with Phase 3 Task 3: define the AI skill quality review rubric and finding schema. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 66582d9..c4604e1 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -5,4 +5,4 @@ | Adding Oxfmt formats too much in one task and obscures behavioral changes. | Review becomes harder and task commits lose focus. | Keep configuration/check integration separate from optional repository-wide formatting cleanup. | open | | oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. Current accepted baseline debt: `loop.mjs` and `hooks.mjs` exceed the accepted `max-lines: 600` source limit and are tracked for Phase 1 refactor before phase verification. | mitigation-planned | | AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | -| AI review found that unbound hook invocations persist raw capture data before the binding gate. | Hook behavior conflicts with the repository contract that unbound sessions produce no epic-loop records and may expose prompt/transcript metadata. | Add a focused correction task that preserves reliable `bind-session --current` behavior while preventing raw unbound capture persistence. | active | +| AI review found that unbound hook invocations persist raw capture data before the binding gate. | Hook behavior conflicts with the repository contract that unbound sessions produce no epic-loop records and may expose prompt/transcript metadata. | Corrected by replacing raw pre-binding capture with a minimal current-session handshake and focused hook/session tests. | mitigated | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index fbd58fa..55cf33c 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 2 - Fix unbound hook capture persistence surfaced by AI skill review +Active task: Phase 3 Task 3 - Define the AI skill quality review rubric and finding schema ## Current State @@ -20,15 +20,15 @@ Active task: Phase 3 Task 2 - Fix unbound hook capture persistence surfaced by A - Phase 2 Task 3 is closed: focused validator tests, the deterministic package validator, full unit tests, lint, format check, and aggregate validation all pass, and verification left no generated runtime/debug artifacts. - Phase 2 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. - Phase 3 Task 1 is closed: `pnpm run review:skills:ai` now wraps `codex exec --ephemeral`, requires schema-valid JSON in `.validation-output/skill-review/latest.json`, prints stable path-oriented diagnostics, exits non-zero for blocking findings, and remains separate from `pnpm run validate`. -- The first live AI review produced a valid blocking finding: unbound hook invocations currently persist raw capture data before the binding gate in `hooks.mjs`. That issue is now tracked as the active Phase 3 correction task before rubric expansion. +- Phase 3 Task 2 is closed: unbound hook capture now stores only a minimal current-session handshake before the binding gate and no longer persists raw payloads, prompt text, or transcript paths; `bind-session --current` remains covered for Codex and Claude Code. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. ## Blockers -- None currently blocking implementation progress; the AI review finding is tracked as active work. +- None currently blocking implementation progress. ## Next Action -- Continue with Phase 3 Task 2: fix unbound hook capture persistence surfaced by AI skill review. +- Continue with Phase 3 Task 3: define the AI skill quality review rubric and finding schema. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 18d3007..f899fa3 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -84,13 +84,13 @@ Epic: Linting And Skill Checks - Acceptance: `pnpm run review:skills:ai` invokes `codex exec --ephemeral`, requires a structured JSON report, validates the report schema, prints stable findings, and exits non-zero for blocking findings, malformed JSON, missing output, unknown schema versions, or failed Codex execution. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: doing | Fix unbound hook capture persistence surfaced by AI skill review. +- [x] Kind: implementation | Status: done | Fix unbound hook capture persistence surfaced by AI skill review. - Outcome: Unbound hook invocations do not persist raw hook payloads or prompt/transcript metadata, while current-session binding remains reliable. - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs`, hook capture/session-binding helpers, and focused hook/unit tests. - Acceptance: Unbound sessions remain silent/no-op for runtime records except any deliberately minimal, non-sensitive binding handshake needed by `bind-session --current`; bound hook routing and implementation-loop continuation behavior remain covered by tests. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: todo | Define the AI skill quality review rubric and finding schema. +- [ ] Kind: implementation | Status: doing | Define the AI skill quality review rubric and finding schema. - Outcome: The model-backed review evaluates skill semantics consistently instead of producing free-form prose. - Surface: Review skill or prompt file, JSON schema fixture, review runner tests, skill policy docs. - Acceptance: The rubric covers invocation quality, trigger boundaries, progressive disclosure, task-local reference organization, degree of freedom, script/dependency safety concerns, and actionable recommendations with path and line evidence where possible. diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs index cf18713..273f83d 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs @@ -305,14 +305,15 @@ export function roadmapStatePath(projectRoot, slug) { } export function writeHookCapture(projectRoot, payload) { - if (!payload || typeof payload !== "object" || typeof payload.session_id !== "string" || typeof payload.cwd !== "string") { + const handshake = hookHandshake(payload); + if (!handshake) { return; } try { writeJson(path.join(projectRoot, CODEX_HOOK_CAPTURE_RELATIVE_PATH), { capturedAt: nowIso(), - payload, + handshake, }); } catch { // Best effort: capturing the live session must never break the hook itself. @@ -320,14 +321,15 @@ export function writeHookCapture(projectRoot, payload) { } export function writeClaudeHookCapture(projectRoot, payload) { - if (!payload || typeof payload !== "object" || typeof payload.session_id !== "string" || typeof payload.cwd !== "string" || typeof payload.transcript_path !== "string") { + const handshake = hookHandshake(payload); + if (!handshake || typeof payload.transcript_path !== "string") { return; } try { writeJson(path.join(projectRoot, CLAUDE_HOOK_CAPTURE_RELATIVE_PATH), { capturedAt: nowIso(), - payload, + handshake, }); } catch { // Best effort: capturing the live session must never break the hook itself. @@ -338,19 +340,19 @@ export function readCurrentCodexSession(projectRoot) { const candidates = []; const capturePath = path.join(projectRoot, CODEX_HOOK_CAPTURE_RELATIVE_PATH); const capture = readJson(capturePath, null); - const payload = capture && typeof capture === "object" && capture.payload && typeof capture.payload === "object" ? capture.payload : null; + const capturedSession = readCapturedHookSession(capture); - if (payload && payload.cwd === projectRoot && typeof payload.session_id === "string") { + if (capturedSession && capturedSession.cwd === projectRoot && typeof capturedSession.session_id === "string") { const capturedMs = parseDateMs(capture.capturedAt); const captureCandidate = { captured_at: capture.capturedAt ?? null, - hook_event_name: payload.hook_event_name ?? null, - prompt: payload.prompt ?? null, - session_id: payload.session_id, + hook_event_name: capturedSession.hook_event_name ?? null, + prompt: null, + session_id: capturedSession.session_id, source: "hook-capture", - transcript_path: payload.transcript_path ?? null, - turn_id: payload.turn_id ?? null, - updated_at_ms: getMtimeMs(payload.transcript_path) ?? capturedMs ?? 0, + transcript_path: capturedSession.transcript_path ?? null, + turn_id: capturedSession.turn_id ?? null, + updated_at_ms: getMtimeMs(capturedSession.transcript_path) ?? capturedMs ?? 0, }; // Codex passes the real session id to the hook on stdin, so a fresh capture is @@ -375,9 +377,14 @@ export function readCurrentCodexSession(projectRoot) { export function readCurrentClaudeSession(projectRoot) { const capturePath = path.join(projectRoot, CLAUDE_HOOK_CAPTURE_RELATIVE_PATH); const capture = readJson(capturePath, null); - const payload = capture && typeof capture === "object" && capture.payload && typeof capture.payload === "object" ? capture.payload : null; - - if (!payload || payload.cwd !== projectRoot || typeof payload.session_id !== "string" || typeof payload.transcript_path !== "string") { + const capturedSession = readCapturedHookSession(capture); + + if ( + !capturedSession || + capturedSession.cwd !== projectRoot || + typeof capturedSession.session_id !== "string" || + (capturedSession.capture_kind === "legacy-payload" && typeof capturedSession.transcript_path !== "string") + ) { return null; } @@ -388,16 +395,52 @@ export function readCurrentClaudeSession(projectRoot) { return { captured_at: capture.capturedAt ?? null, - hook_event_name: payload.hook_event_name ?? null, - prompt: payload.prompt ?? null, - session_id: payload.session_id, + capture_kind: capturedSession.capture_kind, + hook_event_name: capturedSession.hook_event_name ?? null, + prompt: null, + session_id: capturedSession.session_id, source: "claude-hook-capture", - transcript_path: payload.transcript_path, - turn_id: payload.turn_id ?? null, - updated_at_ms: getMtimeMs(payload.transcript_path) ?? capturedMs, + transcript_path: capturedSession.transcript_path ?? null, + turn_id: capturedSession.turn_id ?? null, + updated_at_ms: getMtimeMs(capturedSession.transcript_path) ?? capturedMs, + }; +} + +function hookHandshake(payload) { + if (!payload || typeof payload !== "object" || typeof payload.session_id !== "string" || typeof payload.cwd !== "string") { + return null; + } + + return { + cwd: payload.cwd, + hook_event_name: typeof payload.hook_event_name === "string" ? payload.hook_event_name : null, + session_id: payload.session_id, + turn_id: typeof payload.turn_id === "string" ? payload.turn_id : null, }; } +function readCapturedHookSession(capture) { + if (!capture || typeof capture !== "object") { + return null; + } + + if (capture.handshake && typeof capture.handshake === "object") { + return { + ...capture.handshake, + capture_kind: "handshake", + }; + } + + if (capture.payload && typeof capture.payload === "object") { + return { + ...capture.payload, + capture_kind: "legacy-payload", + }; + } + + return null; +} + function findLatestCodexTranscriptSession(projectRoot) { const sessionsRoot = path.join(process.env.HOME ?? "", ".codex", "sessions"); const searchRoots = recentSessionDateRoots(sessionsRoot); diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index 58ceab5..ce001eb 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -412,7 +412,10 @@ function isAutoBindableCurrentSession(currentSession, platform) { } if (platform === "claude-code") { - return currentSession.source === "claude-hook-capture" && typeof currentSession.transcript_path === "string" && currentSession.transcript_path.length > 0; + return ( + currentSession.source === "claude-hook-capture" && + (currentSession.capture_kind === "handshake" || (typeof currentSession.transcript_path === "string" && currentSession.transcript_path.length > 0)) + ); } return currentSession.source === "hook-capture"; diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index a997bf5..9ae255e 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -368,9 +368,8 @@ export function handleHook(rawInput, flags = {}) { const sessionId = String(payload.session_id ?? "no-session"); const platform = requireRuntimePlatform(projectRoot); - // Record the live session on every event, before the binding gate. This is the - // source `bind-session --current` reads to attach the real session id; without it - // binding falls back to an mtime guess that misfires across parallel sessions. + // Record only a minimal binding handshake before the binding gate. Raw hook + // payloads are persisted only for sessions that already opted into epic-loop. if (platform === "codex") { writeHookCapture(projectRoot, payload); } else if (platform === "claude-code") { diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 934e846..5c12a3e 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -470,8 +470,10 @@ test("bind-session current lookup requires explicit platform selection", () => { test("bind-session current lookup preserves Codex hook capture behavior", () => { const root = makeTempRoot("bind-codex-current-"); const slug = "bind-codex"; + const transcriptPath = path.join(root, "codex-transcript.jsonl"); try { + fs.writeFileSync(transcriptPath, '{"type":"assistant","message":{"content":"ready"}}\n', "utf8"); assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Bind Codex current project", "--slug", slug, "--no-gitignore"])); assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); @@ -479,12 +481,25 @@ test("bind-session current lookup preserves Codex hook capture behavior", () => input: JSON.stringify({ cwd: root, hook_event_name: "Stop", + prompt: "sensitive codex prompt", session_id: "codex-current-session", + transcript_path: transcriptPath, turn_id: "turn-current", }), }); assertSuccess(capture); + const handshake = readJsonFile(path.join(root, ".codex", "tmp", "last-hook-capture.json")); + assert.deepEqual(handshake.handshake, { + cwd: root, + hook_event_name: "Stop", + session_id: "codex-current-session", + turn_id: "turn-current", + }); + assert.equal(handshake.payload, undefined); + assert.equal(JSON.stringify(handshake).includes("sensitive codex prompt"), false); + assert.equal(JSON.stringify(handshake).includes(transcriptPath), false); + const bind = runNodeScript("bind-session.mjs", ["--root", root, "--current", "--slug", slug, "--mode", "implementation"]); assertSuccess(bind); assert.match(bind.stdout, /Active implementation session for bind-codex: codex-current-session/u); @@ -515,6 +530,7 @@ test("bind-session current lookup uses fresh Claude Code hook captures", () => { input: JSON.stringify({ cwd: root, hook_event_name: "Stop", + prompt: "sensitive claude prompt", session_id: "claude-current-session", stop_hook_active: false, transcript_path: transcriptPath, @@ -522,6 +538,17 @@ test("bind-session current lookup uses fresh Claude Code hook captures", () => { }); assertSuccess(capture); + const handshake = readJsonFile(path.join(root, ".epic-loop", ".runtime", "claude-code-last-hook-capture.json")); + assert.deepEqual(handshake.handshake, { + cwd: root, + hook_event_name: "Stop", + session_id: "claude-current-session", + turn_id: null, + }); + assert.equal(handshake.payload, undefined); + assert.equal(JSON.stringify(handshake).includes("sensitive claude prompt"), false); + assert.equal(JSON.stringify(handshake).includes(transcriptPath), false); + const bind = runNodeScript("bind-session.mjs", ["--root", root, "--current", "--slug", slug, "--mode", "implementation"]); assertSuccess(bind); assert.match(bind.stdout, /Active implementation session for bind-claude: claude-current-session/u); diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 5e3d673..7183413 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -70,14 +70,18 @@ function writeOpenImplementationTurn(root, slug, role = "engineer") { test("hook CLI captures unbound sessions without writing epic-loop runtime records", () => { const root = makeTempRoot("hook-unbound-"); + const transcriptPath = path.join(root, "transcript.jsonl"); const payload = { cwd: root, hook_event_name: "Stop", + prompt: "sensitive prompt text", session_id: "session-unbound", + transcript_path: transcriptPath, turn_id: "turn-1", }; try { + fs.writeFileSync(transcriptPath, '{"type":"assistant","message":{"content":"ready"}}\n', "utf8"); assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); const result = runNodeScript("hook.mjs", ["--root", root], { @@ -86,7 +90,16 @@ test("hook CLI captures unbound sessions without writing epic-loop runtime recor assertSuccess(result); assert.equal(result.stdout, ""); - assert.equal(fs.existsSync(path.join(root, ".codex", "tmp", "last-hook-capture.json")), true); + const capture = readJsonFile(path.join(root, ".codex", "tmp", "last-hook-capture.json")); + assert.deepEqual(capture.handshake, { + cwd: root, + hook_event_name: "Stop", + session_id: "session-unbound", + turn_id: "turn-1", + }); + assert.equal(capture.payload, undefined); + assert.equal(JSON.stringify(capture).includes("sensitive prompt text"), false); + assert.equal(JSON.stringify(capture).includes(transcriptPath), false); assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")), false); assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "hook-events")), false); } finally { @@ -277,7 +290,7 @@ test("non-driver UserPromptSubmit does not interrupt an open implementation turn } }); -test("Claude Code unbound hook payload exits without epic-loop runtime records", () => { +test("Claude Code unbound hook payload records only a minimal current-session handshake", () => { const root = makeTempRoot("hook-claude-unbound-"); const transcriptPath = path.join(root, "transcript.jsonl"); @@ -289,6 +302,7 @@ test("Claude Code unbound hook payload exits without epic-loop runtime records", input: JSON.stringify({ cwd: root, hook_event_name: "Stop", + prompt: "sensitive claude prompt", session_id: "claude-session-unbound", stop_hook_active: false, transcript_path: transcriptPath, @@ -298,6 +312,16 @@ test("Claude Code unbound hook payload exits without epic-loop runtime records", assertSuccess(result); assert.equal(result.stdout, ""); assert.equal(fs.existsSync(path.join(root, ".codex", "tmp", "last-hook-capture.json")), false); + const capture = readJsonFile(path.join(root, ".epic-loop", ".runtime", "claude-code-last-hook-capture.json")); + assert.deepEqual(capture.handshake, { + cwd: root, + hook_event_name: "Stop", + session_id: "claude-session-unbound", + turn_id: null, + }); + assert.equal(capture.payload, undefined); + assert.equal(JSON.stringify(capture).includes("sensitive claude prompt"), false); + assert.equal(JSON.stringify(capture).includes(transcriptPath), false); assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")), false); assert.equal(fs.existsSync(path.join(root, ".epic-loop", ".runtime", "hook-events")), false); } finally { From eef666cdadcd3fd49b274aa890e568d1ea77c5ce Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:37:55 +0700 Subject: [PATCH 11/17] Define AI skill review rubric --- .epic-loop/epics/set-up/implementation-log.md | 19 +++++ .epic-loop/epics/set-up/state-of-epic.md | 5 +- .epic-loop/epics/set-up/tracker.md | 4 +- scripts/review-skills-ai.mjs | 80 +++++++++++++++++-- tests/unit/skill-review-ai.test.mjs | 45 ++++++++++- 5 files changed, 142 insertions(+), 11 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 8103e61..f99b2d6 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -90,3 +90,22 @@ - Residual risk: A minimal handshake is still written before binding because current-session binding needs session identity; it deliberately excludes prompt text, transcript paths, and raw hook payloads. - Commit: task-owned commit containing this closure note - Next move: Continue with Phase 3 Task 3: define the AI skill quality review rubric and finding schema. + +## 2026-07-07T14:37:44+00:00 - closed: AI skill review prompt now uses explicit repository-owned rubric and finding schema guidance without changing schema version, exit policy, dependencies, or aggregate validation policy. + +- Task: Phase 3 Task 3 - Define the AI skill quality review rubric and finding schema +- Verdict: closed: AI skill review prompt now uses explicit repository-owned rubric and finding schema guidance without changing schema version, exit policy, dependencies, or aggregate validation policy. +- Changed: + - scripts/review-skills-ai.mjs exports skillReviewRubric and skillReviewFindingSchema and builds the model prompt from them + - tests/unit/skill-review-ai.test.mjs verifies required rubric dimensions, stable finding fields, and existing mocked report behavior +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 7/7 + - pnpm run test:unit passed 75/75 + - pnpm run lint passed + - pnpm run format:check initially failed on tests/unit/skill-review-ai.test.mjs + - pnpm run format:write fixed formatting + - pnpm run format:check passed + - pnpm run validate passed +- Residual risk: Live review was not rerun because behavior changed only in prompt/rubric text; final Phase 3 verification remains next. +- Commit: task-owned commit containing this closure note +- Next move: Continue with Phase 3 Task 4: verify the AI-assisted review command behaves like a deterministic script boundary. diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 55cf33c..7c176a8 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 3 - Define the AI skill quality review rubric and finding schema +Active task: Phase 3 Task 4 - Verify the AI-assisted review command behaves like a deterministic script boundary ## Current State @@ -21,6 +21,7 @@ Active task: Phase 3 Task 3 - Define the AI skill quality review rubric and find - Phase 2 is complete; mandatory phase-closure housekeeping ran and found no compaction need or blockers. - Phase 3 Task 1 is closed: `pnpm run review:skills:ai` now wraps `codex exec --ephemeral`, requires schema-valid JSON in `.validation-output/skill-review/latest.json`, prints stable path-oriented diagnostics, exits non-zero for blocking findings, and remains separate from `pnpm run validate`. - Phase 3 Task 2 is closed: unbound hook capture now stores only a minimal current-session handshake before the binding gate and no longer persists raw payloads, prompt text, or transcript paths; `bind-session --current` remains covered for Codex and Claude Code. +- Phase 3 Task 3 is closed: AI skill review prompt construction now uses explicit repository-owned rubric and finding schema guidance, with focused tests proving required review dimensions and stable finding fields. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -31,4 +32,4 @@ Active task: Phase 3 Task 3 - Define the AI skill quality review rubric and find ## Next Action -- Continue with Phase 3 Task 3: define the AI skill quality review rubric and finding schema. +- Continue with Phase 3 Task 4: verify the AI-assisted review command behaves like a deterministic script boundary. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index f899fa3..a3b044c 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -90,13 +90,13 @@ Epic: Linting And Skill Checks - Acceptance: Unbound sessions remain silent/no-op for runtime records except any deliberately minimal, non-sensitive binding handshake needed by `bind-session --current`; bound hook routing and implementation-loop continuation behavior remain covered by tests. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: implementation | Status: doing | Define the AI skill quality review rubric and finding schema. +- [x] Kind: implementation | Status: done | Define the AI skill quality review rubric and finding schema. - Outcome: The model-backed review evaluates skill semantics consistently instead of producing free-form prose. - Surface: Review skill or prompt file, JSON schema fixture, review runner tests, skill policy docs. - Acceptance: The rubric covers invocation quality, trigger boundaries, progressive disclosure, task-local reference organization, degree of freedom, script/dependency safety concerns, and actionable recommendations with path and line evidence where possible. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: todo | Verify the AI-assisted review command behaves like a deterministic script boundary. +- [ ] Kind: verification | Status: doing | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. - Acceptance: Prove the runner accepts valid reports, rejects malformed or missing reports, prints findings deterministically, keeps generated artifacts ignored, and documents whether the AI-backed command is excluded from or included in `pnpm run validate`. diff --git a/scripts/review-skills-ai.mjs b/scripts/review-skills-ai.mjs index 675dadf..fd62c0b 100644 --- a/scripts/review-skills-ai.mjs +++ b/scripts/review-skills-ai.mjs @@ -10,6 +10,71 @@ const allowedSeverities = new Set(["error", "warning", "info"]); const outputDirRelative = ".validation-output/skill-review"; const latestReportRelative = `${outputDirRelative}/latest.json`; +export const skillReviewRubric = [ + { + code: "invocation-quality", + title: "Invocation Quality", + guidance: "Check whether the skill description and entrypoint give reliable implicit and explicit invocation signals for real epic-loop work.", + }, + { + code: "trigger-boundaries", + title: "Trigger Boundaries", + guidance: "Check whether the skill has clear non-trigger boundaries so it does not activate for unrelated planning, plugin, or repository tasks.", + }, + { + code: "progressive-disclosure", + title: "Progressive Disclosure", + guidance: "Check whether SKILL.md stays concise and sends conditional or mode-specific detail into direct references instead of overloading the entrypoint.", + }, + { + code: "task-local-reference-organization", + title: "Task-Local Reference Organization", + guidance: "Check whether referenced files are organized around task-local context and avoid forcing broad manual reads for narrow operations.", + }, + { + code: "instruction-degree-of-freedom", + title: "Instruction Degree Of Freedom", + guidance: "Check whether fragile operations use exact scripts and constraints while judgment-heavy work leaves appropriate implementation freedom.", + }, + { + code: "script-dependency-safety", + title: "Bundled Script And Dependency Safety", + guidance: "Check bundled scripts, external tools, MCP expectations, file access, and generated-output handling for safety concerns deterministic checks cannot prove.", + }, + { + code: "actionable-evidence", + title: "Actionable Evidence", + guidance: "Check whether every finding can cite a repository-relative path, a line when available, a stable code, and a concrete recommendation.", + }, +]; + +export const skillReviewFindingSchema = [ + { + field: "severity", + guidance: 'Required. One of "error", "warning", or "info"; use "error" only for blocking semantic quality or safety issues.', + }, + { + field: "code", + guidance: "Required. Stable non-empty string suitable for tracking repeated findings across runs.", + }, + { + field: "path", + guidance: "Required. Repository-relative path using forward slashes; do not use absolute paths.", + }, + { + field: "line", + guidance: "Optional only when line evidence is genuinely unavailable; otherwise use a positive integer.", + }, + { + field: "message", + guidance: "Required. Non-empty concise statement of the observed issue.", + }, + { + field: "recommendation", + guidance: "Required. Non-empty concrete next action for a maintainer.", + }, +]; + export function reviewOutputPaths(root = process.cwd()) { const outputDir = path.resolve(root, outputDirRelative); const reportPath = path.resolve(root, latestReportRelative); @@ -95,12 +160,10 @@ export function buildSkillReviewPrompt(reportPath) { "- plugins/epic-loop/skills/epic-loop/scripts/", "- .epic-loop/epics/set-up/docs/linting-and-skill-validation-policy.md", "", - "Review only semantic skill-quality concerns that deterministic scripts cannot prove:", - "- invocation quality and trigger boundaries", - "- progressive disclosure", - "- task-local reference organization", - "- degree of freedom in instructions", - "- bundled script and external dependency safety concerns", + "Review only semantic skill-quality concerns that deterministic scripts cannot prove.", + "", + "Use this repository-owned rubric:", + ...skillReviewRubric.flatMap((item) => [`- ${item.title} (${item.code}): ${item.guidance}`]), "", "Do not edit tracked source files. Write exactly one JSON report to:", normalizedReportPath, @@ -129,6 +192,11 @@ export function buildSkillReviewPrompt(reportPath) { 'Allowed status values: "pass", "fail", "needs-review".', 'Allowed severity values: "error", "warning", "info".', 'Use "fail" when any error finding is present.', + "", + "Finding field guidance:", + ...skillReviewFindingSchema.flatMap((item) => [`- ${item.field}: ${item.guidance}`]), + "", + "Prefer path and line evidence for every finding. Use repository-relative paths with forward slashes.", "Return a short final message after writing the file.", ].join("\n"); } diff --git a/tests/unit/skill-review-ai.test.mjs b/tests/unit/skill-review-ai.test.mjs index 47bd188..66aacfe 100644 --- a/tests/unit/skill-review-ai.test.mjs +++ b/tests/unit/skill-review-ai.test.mjs @@ -4,7 +4,15 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import { test } from "node:test"; -import { blockingReviewReasons, formatSkillReviewReport, reviewOutputPaths, validateSkillReviewReport } from "../../scripts/review-skills-ai.mjs"; +import { + blockingReviewReasons, + buildSkillReviewPrompt, + formatSkillReviewReport, + reviewOutputPaths, + skillReviewFindingSchema, + skillReviewRubric, + validateSkillReviewReport, +} from "../../scripts/review-skills-ai.mjs"; import { makeTempRoot, repoRoot } from "./test-utils.mjs"; const runnerScript = path.join(repoRoot, "scripts", "review-skills-ai.mjs"); @@ -38,6 +46,41 @@ test("AI skill review report schema accepts a valid pass report", () => { assert.deepEqual(validateSkillReviewReport(validReport(), repoRoot), []); }); +test("AI skill review prompt includes the repository-owned rubric and schema guidance", () => { + const prompt = buildSkillReviewPrompt(".validation-output/skill-review/latest.json"); + const requiredRubricCodes = [ + "invocation-quality", + "trigger-boundaries", + "progressive-disclosure", + "task-local-reference-organization", + "instruction-degree-of-freedom", + "script-dependency-safety", + "actionable-evidence", + ]; + const requiredFindingFields = ["severity", "code", "path", "line", "message", "recommendation"]; + + for (const code of requiredRubricCodes) { + assert.equal( + skillReviewRubric.some((item) => item.code === code), + true, + `missing rubric code ${code}`, + ); + assert.match(prompt, new RegExp(code, "u")); + } + + for (const field of requiredFindingFields) { + assert.equal( + skillReviewFindingSchema.some((item) => item.field === field), + true, + `missing finding field ${field}`, + ); + assert.match(prompt, new RegExp(`- ${field}:`, "u")); + } + + assert.match(prompt, /repository-relative paths with forward slashes/u); + assert.match(prompt, /Use "fail" when any error finding is present/u); +}); + test("AI skill review report schema rejects invalid top-level and finding fields", () => { const errors = validateSkillReviewReport( validReport({ From 4fd8eb05155586c2501cdf0b57db6183348d9648 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:46:52 +0700 Subject: [PATCH 12/17] Validate absolute prompt-file boundaries --- .epic-loop/epics/set-up/implementation-log.md | 35 +++++++++++++++ .epic-loop/epics/set-up/risk-register.md | 1 + .epic-loop/epics/set-up/state-of-epic.md | 6 ++- .epic-loop/epics/set-up/tracker.md | 6 +++ .../epic-loop/scripts/lib/loop-prompts.mjs | 7 +-- tests/unit/cli-contracts.test.mjs | 44 +++++++++++++++++++ 6 files changed, 92 insertions(+), 7 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index f99b2d6..07ee004 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -109,3 +109,38 @@ - Residual risk: Live review was not rerun because behavior changed only in prompt/rubric text; final Phase 3 verification remains next. - Commit: task-owned commit containing this closure note - Next move: Continue with Phase 3 Task 4: verify the AI-assisted review command behaves like a deterministic script boundary. + +## 2026-07-07T14:42:52+00:00 - not-closed: deterministic boundary checks passed for focused tests and controlled mock reports, but live review produced a schema-valid error finding script.prompt-file.absolute-path-bypass in loop-prompts.mjs. Phase 3 verification is blocked until that finding is corrected and verification is rerun. + +- Task: Phase 3 Task 5 - Verify the AI-assisted review command behaves like a deterministic script boundary +- Verdict: not-closed: deterministic boundary checks passed for focused tests and controlled mock reports, but live review produced a schema-valid error finding script.prompt-file.absolute-path-bypass in loop-prompts.mjs. Phase 3 verification is blocked until that finding is corrected and verification is rerun. +- Changed: + - No product files changed during verification + - tracker/state/risk now record the active prompt-file boundary correction before verification can close. +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 7/7 + - mocked valid report exited 0 + - malformed report exited 1 + - missing-output substitute exited 1 + - mocked error report exited 1 with stable blocking diagnostics + - live pnpm run review:skills:ai exited 1 with schema_errors=0 status=fail error_findings=1 +- Residual risk: Live AI review remains intentionally blocking on semantic error findings; generated .validation-output/skill-review/latest.json is ignored and untracked. +- Next move: Implement focused prompt-file absolute path boundary correction, then rerun Phase 3 AI review verification. + +## 2026-07-07T14:46:43+00:00 - closed: absolute prompt-file values now normalize to project-relative paths before the standard project and active-epic boundary checks run. + +- Task: Phase 3 Task 4 - Fix prompt-file absolute path boundary surfaced by AI review +- Verdict: closed: absolute prompt-file values now normalize to project-relative paths before the standard project and active-epic boundary checks run. +- Changed: + - plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs normalizes absolute prompt paths through the existing boundary checks + - tests/unit/cli-contracts.test.mjs covers accepted active-epic absolute paths and rejected outside-project, outside-epic, and other-epic absolute paths + - tracker/state/risk artifacts mark the correction closed and verification active again. +- Verification: + - node --test tests/unit/cli-contracts.test.mjs passed 20/20 + - pnpm run test:unit passed 76/76 + - pnpm run lint passed + - pnpm run format:check passed after format:write + - pnpm run validate passed. +- Residual risk: Live AI review was intentionally not rerun in the correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. +- Commit: task-owned commit containing this closure note +- Next move: Rerun Phase 3 AI review command verification after the prompt-file boundary correction. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index c4604e1..e566b5b 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -6,3 +6,4 @@ | oxlint configuration or rule coverage does not match every existing ESM script/test pattern. | Validation blocks on tooling friction or misses a rule the project expects. | Keep the initial oxlint config focused, verify against all maintained source sets, and add repository-owned checks only for gaps that matter. Current accepted baseline debt: `loop.mjs` and `hooks.mjs` exceed the accepted `max-lines: 600` source limit and are tracked for Phase 1 refactor before phase verification. | mitigation-planned | | AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | | AI review found that unbound hook invocations persist raw capture data before the binding gate. | Hook behavior conflicts with the repository contract that unbound sessions produce no epic-loop records and may expose prompt/transcript metadata. | Corrected by replacing raw pre-binding capture with a minimal current-session handshake and focused hook/session tests. | mitigated | +| AI review found that absolute `--prompt-file` values can bypass project and epic boundary checks. | A crafted absolute path could route implementation prompts from outside the active epic workspace. | Corrected by converting absolute prompt paths to project-relative paths before applying the same project and active-epic boundary checks as relative paths, with focused CLI tests for accepted and rejected cases. | mitigated | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 7c176a8..fc7c616 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 4 - Verify the AI-assisted review command behaves like a deterministic script boundary +Active task: Phase 3 Task 5 - Verify the AI-assisted review command behaves like a deterministic script boundary ## Current State @@ -22,6 +22,8 @@ Active task: Phase 3 Task 4 - Verify the AI-assisted review command behaves like - Phase 3 Task 1 is closed: `pnpm run review:skills:ai` now wraps `codex exec --ephemeral`, requires schema-valid JSON in `.validation-output/skill-review/latest.json`, prints stable path-oriented diagnostics, exits non-zero for blocking findings, and remains separate from `pnpm run validate`. - Phase 3 Task 2 is closed: unbound hook capture now stores only a minimal current-session handshake before the binding gate and no longer persists raw payloads, prompt text, or transcript paths; `bind-session --current` remains covered for Codex and Claude Code. - Phase 3 Task 3 is closed: AI skill review prompt construction now uses explicit repository-owned rubric and finding schema guidance, with focused tests proving required review dimensions and stable finding fields. +- Phase 3 Task 4 is closed: absolute `--prompt-file` values are converted to project-relative paths before the normal project and active-epic boundary checks run, with focused CLI coverage for accepted active-epic paths and rejected outside-project/outside-epic/other-epic paths. +- Phase 3 verification is active again after the prompt-file boundary correction and needs a fresh deterministic-boundary pass including live AI review. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -32,4 +34,4 @@ Active task: Phase 3 Task 4 - Verify the AI-assisted review command behaves like ## Next Action -- Continue with Phase 3 Task 4: verify the AI-assisted review command behaves like a deterministic script boundary. +- Continue with Phase 3 Task 5: rerun AI-assisted review command verification after the prompt-file boundary correction. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index a3b044c..a9a138b 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -96,6 +96,12 @@ Epic: Linting And Skill Checks - Acceptance: The rubric covers invocation quality, trigger boundaries, progressive disclosure, task-local reference organization, degree of freedom, script/dependency safety concerns, and actionable recommendations with path and line evidence where possible. - Docs: `docs/linting-and-skill-validation-policy.md`. +- [x] Kind: implementation | Status: done | Fix prompt-file absolute path boundary surfaced by AI review. + - Outcome: Absolute `--prompt-file` values are normalized through the same project and epic path boundary checks as relative prompt files. + - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs`, `set-next-role.mjs` behavior, and focused CLI/unit tests. + - Acceptance: Absolute prompt paths outside the project or outside `.epic-loop/epics//` are rejected; absolute prompt paths inside the active epic runtime prompt location are accepted and stored as normalized project-relative paths. + - Docs: `docs/linting-and-skill-validation-policy.md`. + - [ ] Kind: verification | Status: doing | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs index 72e17db..fb5a4ee 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop-prompts.mjs @@ -71,11 +71,8 @@ export function normalizePromptFile(root, slug, value) { return null; } - const relative = value.trim(); - if (path.isAbsolute(relative)) { - return path.relative(root, relative); - } - + const rawPath = value.trim(); + const relative = path.isAbsolute(rawPath) ? path.relative(root, rawPath) : rawPath; const normalized = path.normalize(relative); if (normalized.startsWith("..")) { throw new Error(`Prompt file must stay inside the project: ${relative}`); diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 5c12a3e..811dd18 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -448,6 +448,50 @@ test("task and role handoff CLIs update public files through process contracts", } }); +test("set-next-role validates absolute prompt-file paths through the normal epic boundary", () => { + const root = makeTempRoot("prompt-boundary-"); + const outsideRoot = makeTempRoot("prompt-boundary-outside-"); + const slug = "prompt-boundary"; + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Prompt boundary project", "--slug", slug, "--no-gitignore"])); + + const brief = runNodeScript("write-engineer-brief.mjs", ["--root", root, "--slug", slug, "--stdin"], { + input: "Implement a prompt boundary test.\n", + }); + assertSuccess(brief); + + const promptPath = path.join(root, ".epic-loop", "epics", slug, ".runtime", "current-engineer-prompt.md"); + const accepted = runNodeScript("set-next-role.mjs", ["--root", root, "--slug", slug, "--role", "engineer", "--prompt-file", promptPath]); + assertSuccess(accepted); + + const runtime = readJsonFile(path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(runtime.implementation_loop.prompt_file, path.join(".epic-loop", "epics", slug, ".runtime", "current-engineer-prompt.md")); + + const outsidePrompt = path.join(outsideRoot, "prompt.md"); + fs.writeFileSync(outsidePrompt, "outside\n", "utf8"); + const outsideProject = runNodeScript("set-next-role.mjs", ["--root", root, "--slug", slug, "--role", "engineer", "--prompt-file", outsidePrompt]); + assert.equal(outsideProject.status, 1); + assert.match(outsideProject.stderr, /Prompt file must stay inside the project/u); + + const insideProjectPrompt = path.join(root, "prompt.md"); + fs.writeFileSync(insideProjectPrompt, "inside project\n", "utf8"); + const outsideEpic = runNodeScript("set-next-role.mjs", ["--root", root, "--slug", slug, "--role", "engineer", "--prompt-file", insideProjectPrompt]); + assert.equal(outsideEpic.status, 1); + assert.match(outsideEpic.stderr, new RegExp(`Prompt file must be inside \\.epic-loop/epics/${slug}/\\.`, "u")); + + const otherEpicPrompt = path.join(root, ".epic-loop", "epics", "other-epic", ".runtime", "current-engineer-prompt.md"); + fs.mkdirSync(path.dirname(otherEpicPrompt), { recursive: true }); + fs.writeFileSync(otherEpicPrompt, "other epic\n", "utf8"); + const wrongEpic = runNodeScript("set-next-role.mjs", ["--root", root, "--slug", slug, "--role", "engineer", "--prompt-file", otherEpicPrompt]); + assert.equal(wrongEpic.status, 1); + assert.match(wrongEpic.stderr, new RegExp(`Prompt file must be inside \\.epic-loop/epics/${slug}/\\.`, "u")); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + fs.rmSync(outsideRoot, { force: true, recursive: true }); + } +}); + test("bind-session current lookup requires explicit platform selection", () => { const root = makeTempRoot("bind-platform-"); From 405b5b8b46282f7c6090c6a98899d097395fa216 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 21:52:47 +0700 Subject: [PATCH 13/17] Narrow epic-loop skill activation guidance --- .epic-loop/epics/set-up/implementation-log.md | 34 +++++++++++++++++++ .epic-loop/epics/set-up/risk-register.md | 2 ++ .epic-loop/epics/set-up/state-of-epic.md | 8 +++-- .epic-loop/epics/set-up/tracker.md | 6 ++++ plugins/epic-loop/skills/epic-loop/SKILL.md | 4 +-- 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 07ee004..250bcd9 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -144,3 +144,37 @@ - Residual risk: Live AI review was intentionally not rerun in the correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. - Commit: task-owned commit containing this closure note - Next move: Rerun Phase 3 AI review command verification after the prompt-file boundary correction. + +## 2026-07-07T14:50:50+00:00 - not-closed: focused and mocked command-boundary checks passed, but live AI review returned a schema-valid fail report with two error findings, so Phase 3 verification remains blocked. + +- Task: Phase 3 Task 6 - Verify the AI-assisted review command behaves like a deterministic script boundary +- Verdict: not-closed: focused and mocked command-boundary checks passed, but live AI review returned a schema-valid fail report with two error findings, so Phase 3 verification remains blocked. +- Changed: + - No tracked product files changed during verification + - tracker/state/risk now record the active correction task for trigger-boundary and parallel-session instruction findings. +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 7/7 + - mocked valid report exited 0 + - malformed report exited 1 + - missing-output substitute exited 1 + - mocked error report exited 1 with stable blocking diagnostics + - live pnpm run review:skills:ai exited 1 with schemaVersion=1, status=fail, and two error findings. +- Residual risk: Live AI review remains intentionally blocking on semantic error findings; .validation-output/skill-review/latest.json is ignored and untracked. +- Next move: Implement focused instruction correction for trigger-boundaries.package-name-too-broad and parallel-sessions.mode-support-conflict, then rerun Phase 3 verification. + +## 2026-07-07T14:52:38+00:00 - closed: maintained skill instructions now address the two live AI review error findings around package-name trigger breadth and same-epic parallel-mode consistency. + +- Task: Phase 3 Task 5 - Align skill trigger and parallel-session instructions surfaced by AI review +- Verdict: closed: maintained skill instructions now address the two live AI review error findings around package-name trigger breadth and same-epic parallel-mode consistency. +- Changed: + - plugins/epic-loop/skills/epic-loop/SKILL.md narrows the frontmatter trigger to explicit epic-loop runtime/workspace work and hook context + - SKILL.md Parallel Work now matches the shared-mode model from references/parallel-sessions.md + - tracker/state/risk artifacts mark the correction closed and verification active again. +- Verification: + - node scripts/validate-epic-loop-package.mjs passed + - pnpm run format:check passed + - pnpm run validate passed + - git status --short --ignored .validation-output showed .validation-output/ ignored. +- Residual risk: Live AI review was intentionally not rerun in this correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. +- Commit: task-owned commit containing this closure note +- Next move: Rerun Phase 3 AI review command verification after the instruction correction. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index e566b5b..2ef9a7b 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -7,3 +7,5 @@ | AI-assisted skill review depends on model behavior, Codex auth/config, and structured output discipline. | CI or local review can become flaky, expensive, or hard to interpret. | Keep the AI review behind a script boundary, require schema-valid JSON output, treat malformed/missing output as failure, write artifacts to an ignored directory, and exclude it from `pnpm run validate` unless explicitly opted in. | open | | AI review found that unbound hook invocations persist raw capture data before the binding gate. | Hook behavior conflicts with the repository contract that unbound sessions produce no epic-loop records and may expose prompt/transcript metadata. | Corrected by replacing raw pre-binding capture with a minimal current-session handshake and focused hook/session tests. | mitigated | | AI review found that absolute `--prompt-file` values can bypass project and epic boundary checks. | A crafted absolute path could route implementation prompts from outside the active epic workspace. | Corrected by converting absolute prompt paths to project-relative paths before applying the same project and active-epic boundary checks as relative paths, with focused CLI tests for accepted and rejected cases. | mitigated | +| AI review found that the skill frontmatter trigger is too broad for ordinary package-name mentions. | The runtime skill may activate during unrelated package/plugin development or repository review work. | Corrected by narrowing the description trigger wording to explicit epic-loop runtime/workspace operations and hook context rather than ordinary package-name mentions. | mitigated | +| AI review found inconsistent same-epic parallel-session mode guidance. | Agents may follow conflicting instructions about whether different modes can work on the same epic concurrently. | Corrected by aligning `SKILL.md` with the reference's shared-mode model: same-epic different-mode sessions are unsupported and implementation edits belong to the driver. | mitigated | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index fc7c616..8396e55 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 5 - Verify the AI-assisted review command behaves like a deterministic script boundary +Active task: Phase 3 Task 6 - Verify the AI-assisted review command behaves like a deterministic script boundary ## Current State @@ -23,7 +23,9 @@ Active task: Phase 3 Task 5 - Verify the AI-assisted review command behaves like - Phase 3 Task 2 is closed: unbound hook capture now stores only a minimal current-session handshake before the binding gate and no longer persists raw payloads, prompt text, or transcript paths; `bind-session --current` remains covered for Codex and Claude Code. - Phase 3 Task 3 is closed: AI skill review prompt construction now uses explicit repository-owned rubric and finding schema guidance, with focused tests proving required review dimensions and stable finding fields. - Phase 3 Task 4 is closed: absolute `--prompt-file` values are converted to project-relative paths before the normal project and active-epic boundary checks run, with focused CLI coverage for accepted active-epic paths and rejected outside-project/outside-epic/other-epic paths. -- Phase 3 verification is active again after the prompt-file boundary correction and needs a fresh deterministic-boundary pass including live AI review. +- Phase 3 verification rerun proved the AI review command's deterministic script boundary through focused tests, controlled mock reports, missing-output handling, ignored output, and live `codex exec` execution. +- Phase 3 Task 5 is closed: the maintained skill frontmatter trigger no longer broadly activates on ordinary `epic-loop` package-name mentions, and same-epic parallel-session guidance now matches the shared-mode model in the parallel-sessions reference. +- Phase 3 verification is active again and needs a fresh AI-assisted review command rerun after the trigger-boundary and parallel-session instruction correction. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -34,4 +36,4 @@ Active task: Phase 3 Task 5 - Verify the AI-assisted review command behaves like ## Next Action -- Continue with Phase 3 Task 5: rerun AI-assisted review command verification after the prompt-file boundary correction. +- Continue with Phase 3 Task 6: rerun AI-assisted review command verification after the trigger-boundary and parallel-session instruction correction. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index a9a138b..7282572 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -102,6 +102,12 @@ Epic: Linting And Skill Checks - Acceptance: Absolute prompt paths outside the project or outside `.epic-loop/epics//` are rejected; absolute prompt paths inside the active epic runtime prompt location are accepted and stored as normalized project-relative paths. - Docs: `docs/linting-and-skill-validation-policy.md`. +- [x] Kind: implementation | Status: done | Align skill trigger and parallel-session instructions surfaced by AI review. + - Outcome: The maintained `epic-loop` skill instructions no longer contain the two live AI review blocking issues around package-name trigger breadth and same-epic parallel-mode consistency. + - Surface: `plugins/epic-loop/skills/epic-loop/SKILL.md` and `plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md` if needed. + - Acceptance: The frontmatter trigger wording activates the skill for explicit epic-loop runtime/workspace work without broadly triggering on ordinary package/plugin development by name; same-epic parallel-session guidance is consistent between the entrypoint and reference docs. + - Docs: `docs/linting-and-skill-validation-policy.md`. + - [ ] Kind: verification | Status: doing | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. diff --git a/plugins/epic-loop/skills/epic-loop/SKILL.md b/plugins/epic-loop/skills/epic-loop/SKILL.md index ebb8385..c84b17d 100644 --- a/plugins/epic-loop/skills/epic-loop/SKILL.md +++ b/plugins/epic-loop/skills/epic-loop/SKILL.md @@ -1,6 +1,6 @@ --- name: epic-loop -description: Use this skill for work inside an epic-loop workspace: reading or editing `.epic-loop/` artifacts; adding, editing, or closing epic tasks, research tasks, or phases; switching shaping, implementation, or review modes; resuming an epic by slug; detaching the current session when the user says `unbind epic` or asks to work outside the epic; or when hook context includes `[epic-loop] epic=... mode=...`. Also trigger on the `epic-loop` CLI/package by name. +description: Use this skill when the user explicitly asks to run the epic-loop runtime or work inside an epic-loop workspace: reading or editing `.epic-loop/` artifacts; adding, editing, or closing epic tasks, research tasks, or phases; switching shaping, implementation, or review modes; resuming an epic by slug; detaching the current session when the user says `unbind epic` or asks to work outside the epic; or when hook context includes `[epic-loop] epic=... mode=...`. --- # Epic Loop @@ -339,7 +339,7 @@ Review findings should become docs corrections, follow-up tasks, a new implement ## Parallel Work -One session may be in only one mode at a time, but multiple sessions may work on the same epic in different modes. Avoid conflicting writes by treating artifacts as mode-owned when possible: +One session may be in only one mode at a time. An epic has one shared runtime mode, so same-epic different-mode sessions are not supported; multiple sessions on the same epic share that mode, and in implementation mode only the driver edits implementation artifacts. Avoid conflicting writes by treating artifacts as mode-owned when possible: - Shaping owns future docs, roadmap changes, open questions, and reset/baseline transitions when the reset ladder is invoked. - Implementation owns active task status, implementation log, verification notes, and task-local briefs. From 055cd17fc7e2ecab0e6f6906b17674f820508d71 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 22:01:38 +0700 Subject: [PATCH 14/17] Validate epic slug path boundaries --- .epic-loop/epics/set-up/implementation-log.md | 39 +++++++++++++++++++ .epic-loop/epics/set-up/risk-register.md | 1 + .epic-loop/epics/set-up/state-of-epic.md | 8 ++-- .epic-loop/epics/set-up/tracker.md | 6 +++ .../skills/epic-loop/scripts/lib/common.mjs | 34 +++++++++++++++- .../skills/epic-loop/scripts/lib/epics.mjs | 11 +++--- .../scripts/lib/implementation-log.mjs | 6 +-- .../skills/epic-loop/scripts/lib/loop.mjs | 4 +- .../skills/epic-loop/scripts/lib/roadmap.mjs | 8 ++-- .../epic-loop/scripts/lib/summaries.mjs | 4 +- tests/unit/common-paths.test.mjs | 39 +++++++++++++++++++ 11 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 tests/unit/common-paths.test.mjs diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 250bcd9..0534051 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -178,3 +178,42 @@ - Residual risk: Live AI review was intentionally not rerun in this correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. - Commit: task-owned commit containing this closure note - Next move: Rerun Phase 3 AI review command verification after the instruction correction. + +## 2026-07-07T14:57:16+00:00 - not-closed: focused and mocked command-boundary checks passed, but live AI review returned a schema-valid fail report with one error finding, so Phase 3 verification remains blocked. + +- Task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like a deterministic script boundary +- Verdict: not-closed: focused and mocked command-boundary checks passed, but live AI review returned a schema-valid fail report with one error finding, so Phase 3 verification remains blocked. +- Changed: + - No tracked product files changed during verification + - tracker/state/risk now record the active correction task for script.slug.path-boundary. +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 7/7 + - mocked valid report exited 0 + - malformed report exited 1 + - missing-output substitute exited 1 + - mocked error report exited 1 with stable blocking diagnostics + - live pnpm run review:skills:ai exited 1 with schemaVersion=1, status=fail, and one error finding. +- Residual risk: Live AI review remains intentionally blocking on semantic error findings; .validation-output/skill-review/latest.json is ignored and untracked. test:unit and validate were intentionally not run after the live stop condition. +- Next move: Implement focused correction for script.slug.path-boundary, then rerun Phase 3 verification. + +## 2026-07-07T15:01:26+00:00 - closed: epic slug path construction now rejects separators, dot segments, empty/non-kebab slugs, and traversal attempts before returning runtime or artifact paths. + +- Task: Phase 3 Task 7 - Add central epic slug path boundary validation surfaced by AI review +- Verdict: closed: epic slug path construction now rejects separators, dot segments, empty/non-kebab slugs, and traversal attempts before returning runtime or artifact paths. +- Changed: + - plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs adds validateEpicSlug and epicRoot + - slug-based runtime and artifact path consumers now route through central validation + - tests/unit/common-paths.test.mjs covers valid path preservation and invalid slug rejection + - tracker/state/risk artifacts mark the correction closed and verification active again. +- Verification: + - node --test tests/unit/common-paths.test.mjs passed 2/2 + - pnpm run test:unit passed 78/78 + - pnpm run lint passed + - pnpm run format:check initially failed on the new test file + - pnpm run format:write fixed formatting + - pnpm run format:check passed + - pnpm run validate passed + - .validation-output/ remained ignored. +- Residual risk: Live AI review was intentionally not rerun in this correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. +- Commit: task-owned commit containing this closure note +- Next move: Rerun Phase 3 AI review command verification after the slug path boundary correction. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 2ef9a7b..2e74a79 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -9,3 +9,4 @@ | AI review found that absolute `--prompt-file` values can bypass project and epic boundary checks. | A crafted absolute path could route implementation prompts from outside the active epic workspace. | Corrected by converting absolute prompt paths to project-relative paths before applying the same project and active-epic boundary checks as relative paths, with focused CLI tests for accepted and rejected cases. | mitigated | | AI review found that the skill frontmatter trigger is too broad for ordinary package-name mentions. | The runtime skill may activate during unrelated package/plugin development or repository review work. | Corrected by narrowing the description trigger wording to explicit epic-loop runtime/workspace operations and hook context rather than ordinary package-name mentions. | mitigated | | AI review found inconsistent same-epic parallel-session mode guidance. | Agents may follow conflicting instructions about whether different modes can work on the same epic concurrently. | Corrected by aligning `SKILL.md` with the reference's shared-mode model: same-epic different-mode sessions are unsupported and implementation edits belong to the driver. | mitigated | +| AI review found that user-provided epic slugs can be joined into filesystem paths without central boundary validation. | A crafted slug with separators or dot segments could escape `.epic-loop/epics/` when runtime or artifact paths are constructed. | Corrected by adding central `validateEpicSlug()` and `epicRoot()` helpers, routing slug-based epic paths through them, and adding focused invalid-slug/path tests. | mitigated | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index 8396e55..c22a375 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 6 - Verify the AI-assisted review command behaves like a deterministic script boundary +Active task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like a deterministic script boundary ## Current State @@ -25,7 +25,9 @@ Active task: Phase 3 Task 6 - Verify the AI-assisted review command behaves like - Phase 3 Task 4 is closed: absolute `--prompt-file` values are converted to project-relative paths before the normal project and active-epic boundary checks run, with focused CLI coverage for accepted active-epic paths and rejected outside-project/outside-epic/other-epic paths. - Phase 3 verification rerun proved the AI review command's deterministic script boundary through focused tests, controlled mock reports, missing-output handling, ignored output, and live `codex exec` execution. - Phase 3 Task 5 is closed: the maintained skill frontmatter trigger no longer broadly activates on ordinary `epic-loop` package-name mentions, and same-epic parallel-session guidance now matches the shared-mode model in the parallel-sessions reference. -- Phase 3 verification is active again and needs a fresh AI-assisted review command rerun after the trigger-boundary and parallel-session instruction correction. +- Phase 3 verification rerun again proved the AI review command's deterministic script boundary through focused tests, controlled mock reports, missing-output handling, ignored output, and live `codex exec` execution. +- Phase 3 Task 7 is closed: epic slug path construction now goes through central slug validation and `epicRoot()` resolution, with focused tests covering valid paths and invalid separator/dot/traversal/non-kebab slugs. +- Phase 3 verification is active again and needs a fresh AI-assisted review command rerun after the slug path boundary correction. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -36,4 +38,4 @@ Active task: Phase 3 Task 6 - Verify the AI-assisted review command behaves like ## Next Action -- Continue with Phase 3 Task 6: rerun AI-assisted review command verification after the trigger-boundary and parallel-session instruction correction. +- Continue with Phase 3 Task 8: rerun AI-assisted review command verification after the slug path boundary correction. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index 7282572..cf5a99f 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -108,6 +108,12 @@ Epic: Linting And Skill Checks - Acceptance: The frontmatter trigger wording activates the skill for explicit epic-loop runtime/workspace work without broadly triggering on ordinary package/plugin development by name; same-epic parallel-session guidance is consistent between the entrypoint and reference docs. - Docs: `docs/linting-and-skill-validation-policy.md`. +- [x] Kind: implementation | Status: done | Add central epic slug path boundary validation surfaced by AI review. + - Outcome: User-provided epic slugs can no longer escape `.epic-loop/epics/` through path separators, dot segments, or resolved-path traversal when runtime and artifact paths are constructed. + - Surface: `plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs`, path helper call sites as needed, and focused unit/CLI tests. + - Acceptance: Slug/path helpers reject path separators, `.`/`..` segments, empty or invalid slugs, and any resolved epic path outside `.epic-loop/epics/`; existing valid slug behavior remains unchanged. + - Docs: `docs/linting-and-skill-validation-policy.md`. + - [ ] Kind: verification | Status: doing | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs index 273f83d..83ca486 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs @@ -292,8 +292,40 @@ export function epicsRoot(projectRoot) { return path.join(epicLoopRoot(projectRoot), "epics"); } +export function validateEpicSlug(slug) { + if (typeof slug !== "string" || slug.length === 0) { + throw new Error("Invalid epic slug: expected a non-empty lowercase kebab-case path segment."); + } + if (slug !== slug.trim()) { + throw new Error(`Invalid epic slug "${slug}": leading or trailing whitespace is not allowed.`); + } + if (path.isAbsolute(slug) || slug.includes("/") || slug.includes("\\")) { + throw new Error(`Invalid epic slug "${slug}": path separators are not allowed.`); + } + if (slug === "." || slug === ".." || slug.includes("..")) { + throw new Error(`Invalid epic slug "${slug}": dot segments are not allowed.`); + } + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug)) { + throw new Error(`Invalid epic slug "${slug}": expected lowercase kebab-case letters and numbers.`); + } + return slug; +} + +export function epicRoot(projectRoot, slug) { + const safeSlug = validateEpicSlug(slug); + const root = epicsRoot(projectRoot); + const epicPath = path.join(root, safeSlug); + const relative = path.relative(path.resolve(root), path.resolve(epicPath)); + + if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Epic path must stay inside .epic-loop/epics/: ${slug}`); + } + + return epicPath; +} + export function epicRuntimeRoot(projectRoot, slug) { - return path.join(epicsRoot(projectRoot), slug, ".runtime"); + return path.join(epicRoot(projectRoot, slug), ".runtime"); } export function runtimeStatePath(projectRoot, slug) { diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index ce001eb..523dee5 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -5,6 +5,7 @@ import { CURRENT_SESSION_CAPTURE_TTL_MS, MODES, appendGitignore, + epicRoot, epicRuntimeRoot, epicSlugify, epicsRoot, @@ -37,7 +38,7 @@ export function initEpic(flags = {}) { throw new Error(`Invalid --mode "${mode}". Expected one of: ${MODES.join(", ")}.`); } - const epicDir = path.join(epicsRoot(root), slug); + const epicDir = epicRoot(root, slug); ensureDir(path.join(epicDir, "docs")); ensureDir(epicRuntimeRoot(root, slug)); @@ -176,7 +177,7 @@ export function setEpicMode(flags = {}) { throw new Error(`Invalid --mode "${mode}". Expected one of: ${MODES.join(", ")}.`); } - const epicDir = path.join(epicsRoot(root), slug); + const epicDir = epicRoot(root, slug); if (!fs.existsSync(epicDir)) { throw new Error(`Epic not found: ${epicDir}`); } @@ -194,7 +195,7 @@ export function status(flags = {}, positionals = []) { throw new Error("Missing epic slug."); } - const epicDir = path.join(epicsRoot(root), slug); + const epicDir = epicRoot(root, slug); const statePath = path.join(epicDir, "state-of-epic.md"); const runtimePath = runtimeStatePath(root, slug); @@ -237,7 +238,7 @@ export function bindSession(flags = {}) { throw new Error(`Invalid --mode "${mode}". Expected one of: ${MODES.join(", ")}.`); } - const epicDir = path.join(epicsRoot(root), slug); + const epicDir = epicRoot(root, slug); if (!fs.existsSync(epicDir)) { throw new Error(`Epic not found: ${epicDir}`); } @@ -287,7 +288,7 @@ export function autoBindSession(flags = {}) { const currentPlatform = requireRuntimePlatform(root); const currentSession = flags.current ? (currentPlatform === "claude-code" ? readCurrentClaudeSession(root) : readCurrentCodexSession(root)) : null; - const epicDir = path.join(epicsRoot(root), slug); + const epicDir = epicRoot(root, slug); if (!fs.existsSync(epicDir)) { throw new Error(`Epic not found: ${epicDir}`); } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/implementation-log.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/implementation-log.mjs index 104b7b4..d7cc8ec 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/implementation-log.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/implementation-log.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { ensureDir, epicRuntimeRoot, epicsRoot, nowIso, requireFlag, resolveRoot } from "./common.mjs"; +import { ensureDir, epicRoot, epicRuntimeRoot, nowIso, requireFlag, resolveRoot } from "./common.mjs"; export function appendImplementationLog(flags = {}) { const root = resolveRoot(flags.root); @@ -19,7 +19,7 @@ export function appendImplementationLog(flags = {}) { }; appendJsonLine(path.join(epicRuntimeRoot(root, slug), "implementation-log.jsonl"), entry); - appendMarkdown(path.join(epicsRoot(root), slug, "implementation-log.md"), entry); + appendMarkdown(path.join(epicRoot(root, slug), "implementation-log.md"), entry); console.log(`Appended implementation log entry for ${slug}.`); } @@ -27,7 +27,7 @@ export function readImplementationLogTail(flags = {}) { const root = resolveRoot(flags.root); const slug = requireFlag(flags, "slug"); const count = Number(flags.last ?? 3); - const filePath = path.join(epicsRoot(root), slug, "implementation-log.md"); + const filePath = path.join(epicRoot(root, slug), "implementation-log.md"); if (!fs.existsSync(filePath)) { return; diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs index 1e002da..3ed705a 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/loop.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { epicsRoot, nowIso, readJson, readRuntimePlatform, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; +import { epicRoot, epicsRoot, nowIso, readJson, readRuntimePlatform, requireFlag, resolveRoot, runtimeStatePath, writeJson } from "./common.mjs"; import { appendLoopLog, appendPromptLog, @@ -420,7 +420,7 @@ function mergeEpicStateIntoRuntime(projectRoot, slug, runtime) { } function readEpicStateSummary(projectRoot, slug) { - const statePath = path.join(epicsRoot(projectRoot), slug, "state-of-epic.md"); + const statePath = path.join(epicRoot(projectRoot, slug), "state-of-epic.md"); if (!fs.existsSync(statePath)) { return {}; } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs index fbbf3d1..1bd1294 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/roadmap.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { ensureDir, epicsRoot, nowIso, readJson, requireFlag, resolveRoot, roadmapStatePath, runtimeStatePath, slugify, writeJson } from "./common.mjs"; +import { ensureDir, epicRoot, nowIso, readJson, requireFlag, resolveRoot, roadmapStatePath, runtimeStatePath, slugify, writeJson } from "./common.mjs"; export const TASK_STATUSES = ["todo", "doing", "need-review", "blocked", "partially-satisfied", "deferred", "reset-required", "done"]; export const TASK_KINDS = ["implementation", "verification", "review", "follow-up", "architecture-reset", "documentation-only"]; @@ -192,7 +192,7 @@ export function addFollowUpTask(flags = {}) { } export function renderTrackerMarkdown(projectRoot, slug, roadmap = ensureRoadmapState(projectRoot, slug)) { - const trackerPath = path.join(epicsRoot(projectRoot), slug, "tracker.md"); + const trackerPath = path.join(epicRoot(projectRoot, slug), "tracker.md"); ensureDir(path.dirname(trackerPath)); fs.writeFileSync(trackerPath, trackerMarkdown(roadmap), "utf8"); } @@ -211,7 +211,7 @@ function writeRoadmapState(projectRoot, slug, roadmap) { function syncActiveState(projectRoot, slug, roadmap) { const activePhase = displayPhase(findPhase(roadmap, roadmap.active_phase_id)); const activeTask = displayTask(roadmap, findTask(roadmap, roadmap.active_task_id)); - const statePath = path.join(epicsRoot(projectRoot), slug, "state-of-epic.md"); + const statePath = path.join(epicRoot(projectRoot, slug), "state-of-epic.md"); if (fs.existsSync(statePath)) { let text = fs.readFileSync(statePath, "utf8"); @@ -240,7 +240,7 @@ function replaceStateLine(text, label, value) { } function importRoadmapFromTracker(projectRoot, slug, { title } = {}) { - const trackerPath = path.join(epicsRoot(projectRoot), slug, "tracker.md"); + const trackerPath = path.join(epicRoot(projectRoot, slug), "tracker.md"); if (!fs.existsSync(trackerPath)) { return createInitialRoadmapState({ slug, title: title || slug }); } diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/summaries.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/summaries.mjs index 8b738a4..a735b9f 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/summaries.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/summaries.mjs @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { epicRuntimeRoot, epicsRoot, readJson, requireFlag, resolveRoot, runtimeStatePath } from "./common.mjs"; +import { epicRoot, epicRuntimeRoot, readJson, requireFlag, resolveRoot, runtimeStatePath } from "./common.mjs"; import { readRoadmapSummary } from "./roadmap.mjs"; export function roleSummary(flags = {}) { @@ -11,7 +11,7 @@ export function roleSummary(flags = {}) { const roadmap = readRoadmapSummary(root, slug); const latestReportPath = path.join(epicRuntimeRoot(root, slug), "latest-engineer-report.md"); const latestManagerReportPath = path.join(epicRuntimeRoot(root, slug), "latest-manager-report.md"); - const statePath = path.join(epicsRoot(root), slug, "state-of-epic.md"); + const statePath = path.join(epicRoot(root, slug), "state-of-epic.md"); const summary = { slug, diff --git a/tests/unit/common-paths.test.mjs b/tests/unit/common-paths.test.mjs new file mode 100644 index 0000000..e1c9800 --- /dev/null +++ b/tests/unit/common-paths.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import { epicRoot, epicRuntimeRoot, roadmapStatePath, runtimeStatePath, validateEpicSlug } from "../../plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs"; + +test("epic path helpers preserve valid slug paths inside the epics root", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "epic-loop-common-paths-")); + const slug = "demo-epic"; + + try { + assert.equal(validateEpicSlug(slug), slug); + assert.equal(epicRoot(root, slug), path.join(root, ".epic-loop", "epics", slug)); + assert.equal(epicRuntimeRoot(root, slug), path.join(root, ".epic-loop", "epics", slug, ".runtime")); + assert.equal(runtimeStatePath(root, slug), path.join(root, ".epic-loop", "epics", slug, ".runtime", "runtime-state.json")); + assert.equal(roadmapStatePath(root, slug), path.join(root, ".epic-loop", "epics", slug, ".runtime", "roadmap-state.json")); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + +test("epic path helpers reject invalid or escaping slugs before returning paths", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "epic-loop-common-paths-")); + const invalidSlugs = ["", " ", ".", "..", "../outside", "nested/epic", "nested\\epic", "/absolute", "demo..epic", "Demo", "demo_epic", "demo-"]; + + try { + for (const slug of invalidSlugs) { + assert.throws(() => validateEpicSlug(slug), /Invalid epic slug/u, slug); + assert.throws(() => epicRoot(root, slug), /Invalid epic slug/u, slug); + assert.throws(() => epicRuntimeRoot(root, slug), /Invalid epic slug/u, slug); + assert.throws(() => runtimeStatePath(root, slug), /Invalid epic slug/u, slug); + assert.throws(() => roadmapStatePath(root, slug), /Invalid epic slug/u, slug); + } + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); From 99b84c7d7cdcf844665725881ce0f78e97b6038b Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 7 Jul 2026 22:06:30 +0700 Subject: [PATCH 15/17] Record AI review boundary verification --- .epic-loop/epics/set-up/implementation-log.md | 21 +++++++++++++++++++ .epic-loop/epics/set-up/risk-register.md | 1 + .epic-loop/epics/set-up/state-of-epic.md | 7 ++++--- .epic-loop/epics/set-up/tracker.md | 4 ++-- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.epic-loop/epics/set-up/implementation-log.md b/.epic-loop/epics/set-up/implementation-log.md index 0534051..8b81c23 100644 --- a/.epic-loop/epics/set-up/implementation-log.md +++ b/.epic-loop/epics/set-up/implementation-log.md @@ -217,3 +217,24 @@ - Residual risk: Live AI review was intentionally not rerun in this correction slice; Phase 3 verification remains next and will rerun the AI-assisted command boundary checks. - Commit: task-owned commit containing this closure note - Next move: Rerun Phase 3 AI review command verification after the slug path boundary correction. + +## 2026-07-07T15:06:20+00:00 - closed: AI-assisted review command boundary is verified; live review produced schemaVersion=1 status=needs-review with zero error findings, and deterministic validation remains green. + +- Task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like a deterministic script boundary +- Verdict: closed: AI-assisted review command boundary is verified; live review produced schemaVersion=1 status=needs-review with zero error findings, and deterministic validation remains green. +- Changed: + - No product files changed during verification + - tracker/state/risk artifacts mark Phase 3 verification and Phase 3 complete, and record remaining warning-level AI review findings as non-blocking follow-up context. +- Verification: + - node --test tests/unit/skill-review-ai.test.mjs passed 7/7 + - mocked valid report exited 0 + - malformed report exited 1 + - missing-output substitute exited 1 + - mocked error report exited 1 with stable blocking diagnostics + - live pnpm run review:skills:ai exited 0 with schemaVersion=1 status=needs-review errorFindings=0 warningFindings=4 + - pnpm run test:unit passed 78/78 + - pnpm run validate passed + - .validation-output/ remained ignored and git status was clean before artifact closure updates. +- Residual risk: Live AI review warnings remain non-blocking: workspace-read trigger breadth, broad re-entry reads, entrypoint/reference duplication, and mutating doctor readiness disclosure. +- Commit: task-owned commit containing this closure note +- Next move: Run mandatory Phase 3 closure housekeeping. diff --git a/.epic-loop/epics/set-up/risk-register.md b/.epic-loop/epics/set-up/risk-register.md index 2e74a79..e2a1a06 100644 --- a/.epic-loop/epics/set-up/risk-register.md +++ b/.epic-loop/epics/set-up/risk-register.md @@ -10,3 +10,4 @@ | AI review found that the skill frontmatter trigger is too broad for ordinary package-name mentions. | The runtime skill may activate during unrelated package/plugin development or repository review work. | Corrected by narrowing the description trigger wording to explicit epic-loop runtime/workspace operations and hook context rather than ordinary package-name mentions. | mitigated | | AI review found inconsistent same-epic parallel-session mode guidance. | Agents may follow conflicting instructions about whether different modes can work on the same epic concurrently. | Corrected by aligning `SKILL.md` with the reference's shared-mode model: same-epic different-mode sessions are unsupported and implementation edits belong to the driver. | mitigated | | AI review found that user-provided epic slugs can be joined into filesystem paths without central boundary validation. | A crafted slug with separators or dot segments could escape `.epic-loop/epics/` when runtime or artifact paths are constructed. | Corrected by adding central `validateEpicSlug()` and `epicRoot()` helpers, routing slug-based epic paths through them, and adding focused invalid-slug/path tests. | mitigated | +| Live AI review still reports warning-level skill quality concerns after Phase 3 closure. | Entry-point trigger/read-scope/detail and doctor mutability concerns may still deserve future cleanup, but they do not block the deterministic AI review command boundary. | Keep them as non-blocking follow-up context: broad workspace-read trigger, broad re-entry reads, entrypoint/reference duplication, and mutating doctor readiness disclosure. | open | diff --git a/.epic-loop/epics/set-up/state-of-epic.md b/.epic-loop/epics/set-up/state-of-epic.md index c22a375..1dc0870 100644 --- a/.epic-loop/epics/set-up/state-of-epic.md +++ b/.epic-loop/epics/set-up/state-of-epic.md @@ -5,7 +5,7 @@ Slug: `set-up` Created: 2026-07-06T02:54:26+00:00 Current mode: implementation Active phase: Phase 3 - AI-Assisted Skill Quality Review -Active task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like a deterministic script boundary +Active task: Phase 3 complete - phase-closure housekeeping pending ## Current State @@ -27,7 +27,8 @@ Active task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like - Phase 3 Task 5 is closed: the maintained skill frontmatter trigger no longer broadly activates on ordinary `epic-loop` package-name mentions, and same-epic parallel-session guidance now matches the shared-mode model in the parallel-sessions reference. - Phase 3 verification rerun again proved the AI review command's deterministic script boundary through focused tests, controlled mock reports, missing-output handling, ignored output, and live `codex exec` execution. - Phase 3 Task 7 is closed: epic slug path construction now goes through central slug validation and `epicRoot()` resolution, with focused tests covering valid paths and invalid separator/dot/traversal/non-kebab slugs. -- Phase 3 verification is active again and needs a fresh AI-assisted review command rerun after the slug path boundary correction. +- Phase 3 Task 8 is closed: the AI-assisted review command boundary is verified through focused tests, controlled mock reports, live `codex exec`, ignored output handling, full unit tests, and aggregate validation. +- Phase 3 is complete: live AI review returned `needs-review` with zero error findings, and remaining warnings are non-blocking follow-up context rather than phase blockers. - The central product requirement is adding linting, Oxfmt formatting, deterministic skill package checks, and AI-assisted semantic skill review. - Skill repository checks are split into deterministic script validation for mechanical invariants and a headless `codex exec` review runner that produces schema-validated JSON findings in an ignored output directory. - The repository language policy phase was removed during shaping after a real repository audit found no evidence of non-English committed prose; strict ASCII punctuation cleanup is intentionally out of scope for this epic. @@ -38,4 +39,4 @@ Active task: Phase 3 Task 8 - Verify the AI-assisted review command behaves like ## Next Action -- Continue with Phase 3 Task 8: rerun AI-assisted review command verification after the slug path boundary correction. +- Run mandatory Phase 3 closure housekeeping before continuing implementation or exiting the loop. diff --git a/.epic-loop/epics/set-up/tracker.md b/.epic-loop/epics/set-up/tracker.md index cf5a99f..5bbbcdd 100644 --- a/.epic-loop/epics/set-up/tracker.md +++ b/.epic-loop/epics/set-up/tracker.md @@ -76,7 +76,7 @@ Epic: Linting And Skill Checks ### Phase 3: AI-Assisted Skill Quality Review -- Phase status: doing +- Phase status: done - [x] Kind: implementation | Status: done | Add a headless Codex skill review runner with structured JSON output. - Outcome: Semantic skill quality review can be launched as a normal script command while internally using `codex exec` in non-interactive mode. @@ -114,7 +114,7 @@ Epic: Linting And Skill Checks - Acceptance: Slug/path helpers reject path separators, `.`/`..` segments, empty or invalid slugs, and any resolved epic path outside `.epic-loop/epics/`; existing valid slug behavior remains unchanged. - Docs: `docs/linting-and-skill-validation-policy.md`. -- [ ] Kind: verification | Status: doing | Verify the AI-assisted review command behaves like a deterministic script boundary. +- [x] Kind: verification | Status: done | Verify the AI-assisted review command behaves like a deterministic script boundary. - Outcome: The AI-backed command is usable in maintainer workflows without ambiguous output handling. - Surface: `pnpm run review:skills:ai`, controlled valid and invalid JSON outputs, current skill package, ignored output directory. - Acceptance: Prove the runner accepts valid reports, rejects malformed or missing reports, prints findings deterministically, keeps generated artifacts ignored, and documents whether the AI-backed command is excluded from or included in `pnpm run validate`. From d5df5572afaf5f5260c8b17b54825354c7377ae2 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Wed, 8 Jul 2026 12:42:43 +0700 Subject: [PATCH 16/17] Validate session ids for runtime path segments --- .../skills/epic-loop/scripts/lib/common.mjs | 21 +++++++++++ .../skills/epic-loop/scripts/lib/epics.mjs | 7 ++-- .../skills/epic-loop/scripts/lib/hooks.mjs | 7 ++-- tests/unit/cli-contracts.test.mjs | 30 ++++++++++++++++ tests/unit/common-paths.test.mjs | 20 ++++++++++- tests/unit/hook-contracts.test.mjs | 35 +++++++++++++++++++ 6 files changed, 113 insertions(+), 7 deletions(-) diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs index 83ca486..2863e27 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; +import { Buffer } from "node:buffer"; export const HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "Stop"]; export const MODES = ["shaping", "implementation", "review"]; @@ -336,6 +337,26 @@ export function roadmapStatePath(projectRoot, slug) { return path.join(epicRuntimeRoot(projectRoot, slug), "roadmap-state.json"); } +const ENCODED_SESSION_ID_PREFIX = "encoded-session-"; +const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; + +export function sessionPathSegment(sessionId) { + const value = String(sessionId ?? ""); + if (value.length === 0) { + throw new Error("Invalid session id: expected a non-empty string."); + } + + if (isSafeSessionPathSegment(value)) { + return value; + } + + return `${ENCODED_SESSION_ID_PREFIX}${Buffer.from(value, "utf8").toString("base64url")}`; +} + +function isSafeSessionPathSegment(value) { + return value !== "." && value !== ".." && !value.startsWith(ENCODED_SESSION_ID_PREFIX) && SAFE_SESSION_ID_PATTERN.test(value); +} + export function writeHookCapture(projectRoot, payload) { const handshake = hookHandshake(payload); if (!handshake) { diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs index 523dee5..de4e1d8 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/epics.mjs @@ -19,6 +19,7 @@ import { resolveRoot, roadmapStatePath, runtimeStatePath, + sessionPathSegment, sessionRoot, titleFromDescription, writeJson, @@ -261,7 +262,7 @@ export function bindSession(flags = {}) { normalizedBindings.sessions = sessions; writeJson(bindingsPath, normalizedBindings); - const sessionDir = path.join(epicRuntimeRoot(root, slug), "sessions", sessionId); + const sessionDir = path.join(epicRuntimeRoot(root, slug), "sessions", sessionPathSegment(sessionId)); ensureDir(sessionDir); writeJson(path.join(sessionDir, "binding.json"), { bound_at: boundAt, @@ -371,7 +372,7 @@ export function unbindSession(flags = {}) { }); } - const sessionDir = path.join(epicRuntimeRoot(root, epicSlug), "sessions", sessionId); + const sessionDir = path.join(epicRuntimeRoot(root, epicSlug), "sessions", sessionPathSegment(sessionId)); ensureDir(sessionDir); writeJson(path.join(sessionDir, "unbind.json"), { epic_slug: epicSlug, @@ -441,7 +442,7 @@ function writeMemberBinding(root, slug, currentSession, reason) { normalizedBindings.sessions = sessions; writeJson(bindingsPath, normalizedBindings); - const sessionDir = path.join(epicRuntimeRoot(root, slug), "sessions", currentSession.session_id); + const sessionDir = path.join(epicRuntimeRoot(root, slug), "sessions", sessionPathSegment(currentSession.session_id)); ensureDir(sessionDir); writeJson(path.join(sessionDir, "binding.json"), { bound_at: boundAt, diff --git a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs index 9ae255e..a69c34f 100644 --- a/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs +++ b/plugins/epic-loop/skills/epic-loop/scripts/lib/hooks.mjs @@ -16,6 +16,7 @@ import { readJsonStrict, resolveRoot, runtimeStatePath, + sessionPathSegment, sessionRoot, slugify, writeHookCapture, @@ -386,7 +387,7 @@ export function handleHook(rawInput, flags = {}) { captured_at: nowIso(), payload, }; - const eventPath = path.join(sessionRoot(projectRoot), "hook-events", sessionId, eventFilename(payload)); + const eventPath = path.join(sessionRoot(projectRoot), "hook-events", sessionPathSegment(sessionId), eventFilename(payload)); writeJson(eventPath, eventRecord); writeJson(path.join(sessionRoot(projectRoot), "last-hook-event.json"), eventRecord); @@ -402,7 +403,7 @@ export function handleHook(rawInput, flags = {}) { function updateSessionState(projectRoot, payload, eventPath) { const sessionId = String(payload.session_id ?? "no-session"); - const statePath = path.join(sessionRoot(projectRoot), "sessions", `${sessionId}.json`); + const statePath = path.join(sessionRoot(projectRoot), "sessions", `${sessionPathSegment(sessionId)}.json`); const existingState = readJson(statePath, {}); const state = existingState && typeof existingState === "object" && !Array.isArray(existingState) ? existingState : {}; const turnIds = Array.isArray(state.turn_ids) ? state.turn_ids : []; @@ -452,7 +453,7 @@ function mirrorBoundEvent(projectRoot, payload, eventRecord, binding) { return; } - const targetDir = path.join(epicRuntimeRoot(projectRoot, String(binding.epic_slug)), "sessions", sessionId); + const targetDir = path.join(epicRuntimeRoot(projectRoot, String(binding.epic_slug)), "sessions", sessionPathSegment(sessionId)); const targetEventPath = path.join(targetDir, eventFilename(payload)); writeJson(targetEventPath, eventRecord); writeJson(path.join(targetDir, "last-hook-event.json"), eventRecord); diff --git a/tests/unit/cli-contracts.test.mjs b/tests/unit/cli-contracts.test.mjs index 811dd18..1fa4746 100644 --- a/tests/unit/cli-contracts.test.mjs +++ b/tests/unit/cli-contracts.test.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; +import { sessionPathSegment } from "../../plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs"; import { assertSuccess, makeTempRoot, readJsonFile, runNodeScript } from "./test-utils.mjs"; function runHook(root, payload) { @@ -685,6 +686,35 @@ test("bind-session preserves explicit session-id binding on Claude Code", () => } }); +test("bind-session and unbind-session store unsafe session ids under safe path segments", () => { + const root = makeTempRoot("bind-session-path-safe-"); + const slug = "bind-safe"; + const unsafeSessionId = "../outside"; + const safeSegment = sessionPathSegment(unsafeSessionId); + const runtimeRoot = path.join(root, ".epic-loop", "epics", slug, ".runtime"); + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Bind unsafe session id project", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + + const bind = runNodeScript("bind-session.mjs", ["--root", root, "--session-id", unsafeSessionId, "--slug", slug, "--mode", "shaping"]); + assertSuccess(bind); + + const bindings = readJsonFile(path.join(root, ".epic-loop", ".runtime", "session-bindings.json")); + assert.equal(bindings.sessions[unsafeSessionId].active, true); + assert.equal(bindings.sessions[unsafeSessionId].source, "explicit-session-id"); + assert.equal(fs.existsSync(path.join(runtimeRoot, "outside", "binding.json")), false); + assert.equal(fs.existsSync(path.join(runtimeRoot, "sessions", safeSegment, "binding.json")), true); + + const unbind = runNodeScript("unbind-session.mjs", ["--root", root, "--session-id", unsafeSessionId]); + assertSuccess(unbind); + assert.equal(fs.existsSync(path.join(runtimeRoot, "outside", "unbind.json")), false); + assert.equal(fs.existsSync(path.join(runtimeRoot, "sessions", safeSegment, "unbind.json")), true); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + test("auto-bind-session binds a resumed Codex shaping epic from a fresh UserPromptSubmit capture", () => { const root = makeTempRoot("auto-bind-codex-shaping-"); const slug = "auto-codex"; diff --git a/tests/unit/common-paths.test.mjs b/tests/unit/common-paths.test.mjs index e1c9800..ecb8acd 100644 --- a/tests/unit/common-paths.test.mjs +++ b/tests/unit/common-paths.test.mjs @@ -4,7 +4,14 @@ import os from "node:os"; import path from "node:path"; import { test } from "node:test"; -import { epicRoot, epicRuntimeRoot, roadmapStatePath, runtimeStatePath, validateEpicSlug } from "../../plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs"; +import { + epicRoot, + epicRuntimeRoot, + roadmapStatePath, + runtimeStatePath, + sessionPathSegment, + validateEpicSlug, +} from "../../plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs"; test("epic path helpers preserve valid slug paths inside the epics root", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "epic-loop-common-paths-")); @@ -37,3 +44,14 @@ test("epic path helpers reject invalid or escaping slugs before returning paths" fs.rmSync(root, { force: true, recursive: true }); } }); + +test("session path segments preserve simple ids and encode unsafe ids", () => { + assert.equal(sessionPathSegment("session-bound"), "session-bound"); + assert.equal(sessionPathSegment("019f3c93-fbeb-74d0-ad69-804fe90b1f37"), "019f3c93-fbeb-74d0-ad69-804fe90b1f37"); + assert.notEqual(sessionPathSegment("../outside"), "../outside"); + assert.notEqual(sessionPathSegment("nested/session"), "nested/session"); + assert.notEqual(sessionPathSegment("encoded-session-reserved"), "encoded-session-reserved"); + assert.match(sessionPathSegment("../outside"), /^encoded-session-[A-Za-z0-9_-]+$/u); + assert.doesNotMatch(sessionPathSegment("../outside"), /[/.\\]/u); + assert.throws(() => sessionPathSegment(""), /Invalid session id/u); +}); diff --git a/tests/unit/hook-contracts.test.mjs b/tests/unit/hook-contracts.test.mjs index 7183413..3a51a9d 100644 --- a/tests/unit/hook-contracts.test.mjs +++ b/tests/unit/hook-contracts.test.mjs @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { test } from "node:test"; +import { sessionPathSegment } from "../../plugins/epic-loop/skills/epic-loop/scripts/lib/common.mjs"; import { assertSuccess, makeTempRoot, readJsonFile, runNodeScript } from "./test-utils.mjs"; function writeSessionBinding(root, slug, sessionId) { @@ -165,6 +166,40 @@ test("hook CLI builds a deterministic bound Stop continuation", () => { } }); +test("bound hook events store unsafe session ids under safe path segments", () => { + const root = makeTempRoot("hook-session-path-safe-"); + const slug = "hook-safe"; + const sessionId = "../escaped-session"; + const safeSegment = sessionPathSegment(sessionId); + const runtimeRoot = path.join(root, ".epic-loop", ".runtime"); + const epicRuntimeRoot = path.join(root, ".epic-loop", "epics", slug, ".runtime"); + + try { + assertSuccess(runNodeScript("init-epic.mjs", ["--root", root, "--description", "Hook unsafe session id", "--slug", slug, "--no-gitignore"])); + assertSuccess(runNodeScript("doctor.mjs", ["--root", root, "--platform", "codex", "--json"])); + writeSessionBinding(root, slug, sessionId); + + const result = runNodeScript("hook.mjs", ["--root", root], { + input: JSON.stringify({ + cwd: root, + hook_event_name: "Stop", + session_id: sessionId, + turn_id: "turn-bound", + }), + }); + + assertSuccess(result); + assert.equal(fs.existsSync(path.join(runtimeRoot, "escaped-session")), false); + assert.equal(fs.existsSync(path.join(runtimeRoot, "escaped-session.json")), false); + assert.equal(fs.existsSync(path.join(runtimeRoot, "hook-events", safeSegment)), true); + assert.equal(fs.existsSync(path.join(runtimeRoot, "sessions", `${safeSegment}.json`)), true); + assert.equal(fs.existsSync(path.join(epicRuntimeRoot, "escaped-session")), false); + assert.equal(fs.existsSync(path.join(epicRuntimeRoot, "sessions", safeSegment, "last-hook-event.json")), true); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); + test("bound Stop continuation only runs for the implementation driver", () => { const root = makeTempRoot("hook-driver-stop-"); const slug = "driver-routing"; From d6ff6e7eeb3c9173a340ff6664de58f1c1240b52 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Wed, 8 Jul 2026 18:27:23 +0700 Subject: [PATCH 17/17] Create focused AI lint epic --- .epic-loop/epics/ai-lint/decision-log.md | 12 ++ .../epics/ai-lint/docs/problem-framing.md | 48 +++++ .../ai-lint/docs/semantic-lint-contract.md | 182 ++++++++++++++++++ .../epics/ai-lint/implementation-log.md | 12 ++ .epic-loop/epics/ai-lint/risk-register.md | 8 + .epic-loop/epics/ai-lint/state-of-epic.md | 22 +++ .epic-loop/epics/ai-lint/tracker.md | 73 +++++++ 7 files changed, 357 insertions(+) create mode 100644 .epic-loop/epics/ai-lint/decision-log.md create mode 100644 .epic-loop/epics/ai-lint/docs/problem-framing.md create mode 100644 .epic-loop/epics/ai-lint/docs/semantic-lint-contract.md create mode 100644 .epic-loop/epics/ai-lint/implementation-log.md create mode 100644 .epic-loop/epics/ai-lint/risk-register.md create mode 100644 .epic-loop/epics/ai-lint/state-of-epic.md create mode 100644 .epic-loop/epics/ai-lint/tracker.md diff --git a/.epic-loop/epics/ai-lint/decision-log.md b/.epic-loop/epics/ai-lint/decision-log.md new file mode 100644 index 0000000..8488f6e --- /dev/null +++ b/.epic-loop/epics/ai-lint/decision-log.md @@ -0,0 +1,12 @@ +# Decision Log + +## Active Decisions + +- Keep one command named `review:skills:ai`; do not split broad review and focused lint into separate package scripts. +- Convert the command behavior toward fixed semantic lint checks instead of open-ended model review. +- Do not rely on installed `skill-creator` or other local skills at runtime. Use their principles as design input, then encode the relevant rules in repo-owned prompts/check definitions. +- Treat deterministic checks as the CI-style validation path. AI review remains a maintainer workflow command outside `pnpm run validate`. + +## Historical Decisions + +- None recorded yet. diff --git a/.epic-loop/epics/ai-lint/docs/problem-framing.md b/.epic-loop/epics/ai-lint/docs/problem-framing.md new file mode 100644 index 0000000..6e1b9be --- /dev/null +++ b/.epic-loop/epics/ai-lint/docs/problem-framing.md @@ -0,0 +1,48 @@ +# Epic Problem Framing + +## Problem + +Replace the broad AI skill review with focused semantic lint checks for epic-loop skill drift and degradation, based on the maintainer discussion about unstable model-backed review findings. + +## Desired Outcome + +- `pnpm run review:skills:ai` remains the single maintainer-facing AI review command. +- The command becomes a focused semantic lint boundary, not a broad code-review-like reviewer. +- The model evaluates a fixed repository-owned check catalog with stable check ids, target files, pass/fail criteria, and severity policy. +- The output is stable enough for maintainer workflow: repeated runs may vary in wording, but not in check identity, severity ownership, or scope. +- AI checks focus on semantic instruction contracts that deterministic tools cannot reliably verify. + +## Scope + +- Refactor the AI review prompt/schema to use fixed checks instead of free-form findings. +- Define a small initial semantic check catalog for the maintained `epic-loop` skill package. +- Preserve the existing command name: `review:skills:ai`. +- Keep deterministic validation separate from AI-backed validation; do not add AI review to `pnpm run validate`. +- Use skill-building principles from `skill-creator` as design input, but make the command self-contained and repo-owned. +- Add mocked tests for report validation, check formatting, blocking policy, and command-level behavior. +- Verify live `codex exec` behavior after the fixed-check boundary is implemented. + +## Non-Scope + +- Do not split the command into separate broad-review and lint commands. +- Do not depend on installed user/system skills being available inside `codex exec`. +- Do not make AI review a CI gate or deterministic validation replacement. +- Do not ask the model to perform general script security review, broad code review, or free-form skill critique. +- Do not fix unrelated semantic warnings unless they are part of the fixed check catalog and fail the new criteria. +- Do not change runtime hook behavior except where a fixed check reveals a confirmed narrow bug that gets scheduled as a separate implementation task. + +## Constraints + +- Generated artifacts must stay under ignored `.validation-output/skill-review/`. +- The runner must continue to validate model output deterministically before deciding pass/fail. +- Check ids and severity defaults are owned by repository code, not invented by the model. +- The model should return evidence for predefined checks, not create arbitrary finding categories. +- `status: fail` should be reserved for fixed blocking checks with confirmed semantic contract failure. +- Warning-level findings should support maintainer triage without blocking by default. +- Any live Codex review variability must be managed by schema design, check catalog boundaries, and tests. + +## Open Questions + +- Should the first implementation preserve the top-level `findings` array for backward compatibility, or migrate to a `checks` array with derived findings? +- Should blocking require a single failed `error` check, or should repeated live-run consensus be introduced later? +- Should broad advisory review remain available only through a separate manual prompt outside repository scripts? diff --git a/.epic-loop/epics/ai-lint/docs/semantic-lint-contract.md b/.epic-loop/epics/ai-lint/docs/semantic-lint-contract.md new file mode 100644 index 0000000..9e54218 --- /dev/null +++ b/.epic-loop/epics/ai-lint/docs/semantic-lint-contract.md @@ -0,0 +1,182 @@ +# Focused AI Semantic Lint Contract + +## Context + +The first AI-backed `review:skills:ai` runner behaved like a broad semantic reviewer. Three live runs produced useful signals, but the results were unstable: + +- The same trigger-boundary concern appeared as both `error` and `warning`. +- A real session-id path-boundary bug appeared in one run and was missed in later runs. +- Warning-level concerns were phrased under different codes across runs. +- The command was validating JSON shape deterministically, but the model owned too much of the review scope, code taxonomy, and severity policy. + +The maintainer decision is to keep a single `review:skills:ai` command and narrow it into a focused semantic lint boundary. + +## Design Goal + +The command should check fixed semantic contracts that ordinary deterministic tooling cannot verify well. It should not behave like a general code review. + +The model should answer predefined questions with evidence. The repository should own: + +- check ids +- target files +- pass/fail criteria +- severity defaults +- blocking policy +- output schema +- generated-output location + +## Non-Goals + +- Do not split the package scripts into separate broad-review and lint commands. +- Do not ask the model to inspect every possible script safety concern. +- Do not rely on local installed skills such as `skill-creator` being active inside `codex exec`. +- Do not include AI review in `pnpm run validate`. +- Do not treat a single live AI run as deterministic proof of package quality. + +## Skill-Creation Principles To Encode + +Use these principles as repo-owned check guidance, not as a runtime dependency on any installed skill: + +- `SKILL.md` frontmatter controls trigger decisions and must be precise. +- `SKILL.md` should stay concise and route detailed mode behavior into references. +- References should be loaded conditionally and task-locally. +- Fragile operations need exact scripts and guardrails. +- Judgment-heavy work should preserve appropriate degrees of freedom. +- Validation integrity matters: tests should not leak the expected answer into the model context. + +## Initial Fixed Checks + +### `skill.description.trigger-boundary` + +Target files: + +- `plugins/epic-loop/skills/epic-loop/SKILL.md` + +Question: + +Does the skill description allow activation for useful `.epic-loop/` artifact understanding/editing while avoiding implied permission to run lifecycle commands, bind sessions, mutate runtime state, or start implementation unless the user intent or hook context asks for that? + +Expected pass: + +- Mentions active epic-loop workspace or artifact work clearly enough to trigger the skill for real epic artifacts. +- Does not imply ordinary package/source review of the plugin grants permission to run lifecycle/runtime actions. +- Preserves the contract that implementation starts only after explicit confirmation. + +Default severity on fail: `warning`, unless the wording directly permits implementation start or runtime mutation without user intent. + +### `skill.reentry.mode-conditional-orientation` + +Target files: + +- `plugins/epic-loop/skills/epic-loop/SKILL.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-engineer-role.md` + +Question: + +Does the entrypoint avoid forcing broad artifact reads for every non-trivial turn, and does implementation mode orient from `role-summary.mjs` plus selective reads? + +Expected pass: + +- Normal orientation is mode-conditional. +- Implementation mode uses `role-summary.mjs` as the default entrypoint. +- Logs, decision registers, risk registers, and runtime/debug artifacts are read only when the mode decision needs them. + +Default severity on fail: `warning`. + +### `skill.lifecycle.explicit-implementation-start` + +Target files: + +- `plugins/epic-loop/skills/epic-loop/SKILL.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-cycle.md` + +Question: + +Does the skill consistently require explicit current-session user confirmation before binding a session and starting implementation mode? + +Expected pass: + +- Slug-only resume is orientation, not implementation permission. +- `bind-session --current --mode implementation` appears only after explicit confirmation. +- After binding, the agent stops and lets the trusted hook continue the loop. + +Default severity on fail: `error`. + +### `skill.parallel-sessions.mode-consistency` + +Target files: + +- `plugins/epic-loop/skills/epic-loop/SKILL.md` +- `plugins/epic-loop/skills/epic-loop/references/parallel-sessions.md` +- `plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md` + +Question: + +Do the entrypoint and references describe same-epic parallel sessions consistently? + +Expected pass: + +- Many sessions may be members of the same epic. +- A session has only one active mode at a time. +- Implementation has one exclusive driver while other members remain observers. +- Same-epic session behavior does not conflict between `SKILL.md` and references. + +Default severity on fail: `error` for contradictions that can cause conflicting writes; otherwise `warning`. + +### `skill.runtime-artifacts.normal-flow-boundary` + +Target files: + +- `plugins/epic-loop/skills/epic-loop/SKILL.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-techlead-role.md` +- `plugins/epic-loop/skills/epic-loop/references/implementation-manager-role.md` +- `plugins/epic-loop/skills/epic-loop/references/hooks-and-session-routing.md` + +Question: + +Do normal operating instructions keep agents away from `.runtime/**` debug artifacts unless a specific runtime/debug task requires them? + +Expected pass: + +- Human-facing artifacts are distinguished from runtime/debug artifacts. +- Normal implementation roles avoid prompt/progress logs, hook events, session files, and raw runtime traces. +- Runtime writes are routed through scripts/hooks rather than manual file edits. + +Default severity on fail: `warning`, or `error` if instructions direct agents to inspect sensitive/raw runtime payloads by default. + +## Output Shape Direction + +Prefer a report shape centered on checks: + +```json +{ + "schemaVersion": 2, + "status": "pass", + "summary": "Focused semantic lint completed.", + "checks": [ + { + "id": "skill.lifecycle.explicit-implementation-start", + "status": "pass", + "severity": "error", + "evidence": [], + "message": "Implementation start requires explicit confirmation.", + "recommendation": null + } + ] +} +``` + +The runner may derive path-oriented findings from failed checks for display, but the model should not invent arbitrary finding codes. + +## Verification Expectations + +- Mocked valid report exits `0`. +- Mocked warning report exits `0` with stable diagnostics. +- Mocked failed `error` check exits non-zero. +- Unknown check ids fail schema validation. +- Missing checks fail schema validation. +- Malformed JSON and missing output remain non-zero. +- `pnpm run validate` remains green and non-AI-backed. +- Live repeated runs are compared by check id and status, not by free-form finding text. diff --git a/.epic-loop/epics/ai-lint/implementation-log.md b/.epic-loop/epics/ai-lint/implementation-log.md new file mode 100644 index 0000000..ad7d193 --- /dev/null +++ b/.epic-loop/epics/ai-lint/implementation-log.md @@ -0,0 +1,12 @@ +# Implementation Log + +## 2026-07-08T11:25:43+00:00 - Epic Workspace Initialized + +- Created epic workspace for `ai-lint`. +- Initial mode: shaping. + +## 2026-07-08T11:25:43+00:00 - Initial Shaping Context Captured + +- Captured maintainer decision to replace broad AI skill review behavior with focused fixed semantic checks. +- Added `docs/semantic-lint-contract.md` with initial check catalog, non-goals, output-shape direction, and verification expectations. +- Marked the first shaping documentation task done; next action is review of the proposed check catalog before implementation starts. diff --git a/.epic-loop/epics/ai-lint/risk-register.md b/.epic-loop/epics/ai-lint/risk-register.md new file mode 100644 index 0000000..485f5ee --- /dev/null +++ b/.epic-loop/epics/ai-lint/risk-register.md @@ -0,0 +1,8 @@ +# Risk Register + +| Risk | Impact | Mitigation | Status | +| --- | --- | --- | --- | +| Model output remains unstable even with fixed checks. | The command stays noisy and cannot support reliable maintainer triage. | Restrict check ids, severities, target files, and output schema; verify with repeated live runs. | open | +| Check catalog becomes too broad and recreates a general review. | The command drifts back into subjective review findings. | Keep the initial catalog small and reject free-form model-created codes. | open | +| Fixed checks miss real script safety bugs. | AI review may appear more reliable than it is. | Keep script safety in deterministic tests where possible; use AI only for semantic instruction contracts. | open | +| Installed skill availability differs across maintainer environments. | Review behavior becomes environment-dependent. | Do not require `skill-creator` or other installed skills inside `codex exec`; bake needed guidance into repo-owned prompt/checks. | open | diff --git a/.epic-loop/epics/ai-lint/state-of-epic.md b/.epic-loop/epics/ai-lint/state-of-epic.md new file mode 100644 index 0000000..a575cc1 --- /dev/null +++ b/.epic-loop/epics/ai-lint/state-of-epic.md @@ -0,0 +1,22 @@ +# State Of Epic + +Epic: Focused AI Skill Review Checks +Slug: `ai-lint` +Created: 2026-07-08T11:25:43+00:00 +Active phase: Phase 1 - Shape The Epic +Active task: Phase 1 Task 2 - Review the fixed semantic check catalog + +## Current State + +- This epic follows the completed `set-up` epic and owns the next iteration of `pnpm run review:skills:ai`. +- Maintainer discussion established that the current AI review is too broad and unstable for lint-like drift detection. +- The desired direction is to keep one AI review command, but convert it from an open-ended semantic review into a focused semantic lint with fixed checks. +- The fixed checks should cover instruction drift and degradation that ordinary deterministic linters cannot catch. + +## Blockers + +- None recorded. + +## Next Action + +- Review `docs/problem-framing.md` and `docs/semantic-lint-contract.md`, then refine or approve the roadmap before implementation starts. diff --git a/.epic-loop/epics/ai-lint/tracker.md b/.epic-loop/epics/ai-lint/tracker.md new file mode 100644 index 0000000..c2ac223 --- /dev/null +++ b/.epic-loop/epics/ai-lint/tracker.md @@ -0,0 +1,73 @@ +# Tracker + +Epic: Focused AI Skill Review Checks + +## Task Statuses + +- todo +- doing +- need-review +- blocked +- partially-satisfied +- deferred +- reset-required +- done + +## Task Kinds + +- implementation +- verification +- review +- follow-up +- architecture-reset +- documentation-only + +## Active Roadmap + +### Phase 1: Fixed Semantic Check Contract + +- Phase status: todo + +- [x] Kind: documentation-only | Status: done | Define the fixed semantic AI lint contract. + - Outcome: The AI review command has an explicit check catalog and severity policy before implementation changes begin. + - Surface: `.epic-loop/epics/ai-lint/docs/semantic-lint-contract.md`, `.epic-loop/epics/ai-lint/decision-log.md`. + - Acceptance: The doc names the initial check ids, target files, pass/fail criteria, evidence requirements, and non-goals. + - Docs: `docs/semantic-lint-contract.md`, `docs/problem-framing.md`. + +- [ ] Kind: verification | Status: todo | Review the check catalog against the maintainer discussion. + - Outcome: The first implementation slice is grounded in the desired lint-like behavior, not broad review behavior. + - Surface: `docs/semantic-lint-contract.md`. + - Acceptance: The catalog explicitly excludes general code review, model-created finding codes, and installed-skill runtime dependency. + - Docs: `docs/semantic-lint-contract.md`. + +### Phase 2: Runner And Schema Refactor + +- Phase status: todo + +- [ ] Kind: implementation | Status: todo | Refactor `review:skills:ai` to evaluate fixed semantic checks. + - Outcome: The runner prompt and report schema use repository-owned check ids instead of free-form model findings. + - Surface: `scripts/review-skills-ai.mjs`, `tests/unit/skill-review-ai.test.mjs`. + - Acceptance: Mocked valid, failing, malformed, and missing-output paths still behave deterministically; the model cannot invent blocking codes outside the fixed catalog. + - Docs: `docs/semantic-lint-contract.md`. + +- [ ] Kind: implementation | Status: todo | Add focused tests for fixed-check parsing, severity policy, and stable diagnostics. + - Outcome: The AI boundary is protected by deterministic unit tests without invoking live Codex. + - Surface: `tests/unit/skill-review-ai.test.mjs`, optional small helper modules under `scripts/`. + - Acceptance: Tests prove pass, warning, blocking, unknown-check, and malformed-report behavior. + - Docs: `docs/semantic-lint-contract.md`. + +- [ ] Kind: verification | Status: todo | Verify deterministic validation remains separate from AI review. + - Outcome: The aggregate validation path remains stable and non-AI-backed. + - Surface: `package.json`, `scripts/review-skills-ai.mjs`, `.validation-output/skill-review/`. + - Acceptance: `pnpm run validate` passes and does not invoke `review:skills:ai`; generated AI reports remain ignored. + - Docs: `docs/problem-framing.md`. + +### Phase 3: Live Stability Verification + +- Phase status: todo + +- [ ] Kind: verification | Status: todo | Run repeated live AI review checks and compare normalized results. + - Outcome: Maintainers understand whether the fixed-check boundary reduces model jitter enough for the intended workflow. + - Surface: `pnpm run review:skills:ai`, `.validation-output/skill-review/latest.json`. + - Acceptance: At least three sequential live runs are compared by check id, status, severity, and evidence; any remaining jitter is recorded as a risk or follow-up. + - Docs: `docs/semantic-lint-contract.md`, `risk-register.md`.