From e8244d9ced25e150659f8685293804c65200e449 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 25 Jun 2026 16:45:53 -0700 Subject: [PATCH 001/197] feat(control-pane): serve 3D agent-airspace viz + /api/proximity feed (#2320) Adds the Layer 4 observability view to the control pane: a self-contained, dependency-free 3D point-cloud of the agent airspace (positions from the proximity embedding, sized by working set, colored by collision risk, links for converging pairs) plus an XSS-safe advisory panel that polls every 5s. - proximity-viz.js: renderProximityVizHtml() (canvas projection, no external JS) - server.js: GET /proximity (page) + GET /api/proximity (snapshot.proximity feed) - test: asserts both routes serve and the feed carries positions/links/advisories --- scripts/lib/control-pane/proximity-viz.js | 191 ++++++++++++++++++++++ scripts/lib/control-pane/server.js | 20 +++ tests/scripts/control-pane.test.js | 43 +++++ 3 files changed, 254 insertions(+) create mode 100644 scripts/lib/control-pane/proximity-viz.js diff --git a/scripts/lib/control-pane/proximity-viz.js b/scripts/lib/control-pane/proximity-viz.js new file mode 100644 index 000000000..2780e5bcc --- /dev/null +++ b/scripts/lib/control-pane/proximity-viz.js @@ -0,0 +1,191 @@ +'use strict'; + +/** + * Self-contained 3D "agent airspace" visualization, served by the control pane. + * + * Renders each agent as a point in code-space (positions from the proximity + * embedding), sized by working-set size and colored by collision risk, with + * links between converging pairs (amber = transmit advisory, red = steer). The + * scene auto-rotates so you can read the cloud. Dependency-free: a hand-rolled + * 3D2D projection on a , no external scripts (CSP/offline friendly). + * + * This is the operator/Enterprise view of Layer 4: multi-agent observability: + * literally watch the swarm and watch one agent steer away from a collision. + */ + +function renderProximityVizHtml() { + return ` + + + + +ECC Agent Airspace + + + +
+

ECC - Agent Airspace

+ connecting... +
+
+
+ +
+
clear
+
traffic advisory (transmit)
+
resolution (steer)
+
+
+
+

Advisories

+
No advisories - airspace clear.
+
+
+ + +`; +} + +module.exports = { renderProximityVizHtml }; diff --git a/scripts/lib/control-pane/server.js b/scripts/lib/control-pane/server.js index b7b27cd25..81f8a99ed 100644 --- a/scripts/lib/control-pane/server.js +++ b/scripts/lib/control-pane/server.js @@ -8,6 +8,7 @@ const { spawn } = require('child_process'); const { buildControlPaneAction } = require('./actions'); const { buildControlPaneSnapshot, resolveControlPaneConfig } = require('./state'); const { renderControlPaneHtml } = require('./ui'); +const { renderProximityVizHtml } = require('./proximity-viz'); const { claimWorkItem, moveWorkItem } = require('./work-item-mutations'); // Run a single write against the local work-item store, then close it. Kept @@ -265,6 +266,25 @@ function createControlPaneServer(options = {}) { return; } + // 3D agent-airspace visualization (Layer 4 observability). + if (req.method === 'GET' && requestUrl.pathname === '/proximity') { + sendText(res, 200, renderProximityVizHtml(), 'text/html; charset=utf-8'); + return; + } + + if (req.method === 'GET' && requestUrl.pathname === '/api/proximity') { + const snapshot = await buildControlPaneSnapshot({ + repoRoot, + dbPath: resolvedConfig.dbPath, + stateDbPath: resolvedConfig.stateDbPath, + config: resolvedConfig, + allowActions, + includeProximity: true + }); + sendJson(res, 200, snapshot.proximity || { enabled: true, advisories: [], positions: [], links: [], counts: {} }); + return; + } + const actionMatch = requestUrl.pathname.match(/^\/api\/actions\/([^/]+)$/); if (req.method === 'POST' && actionMatch) { if (!allowActions) { diff --git a/tests/scripts/control-pane.test.js b/tests/scripts/control-pane.test.js index 7e6df8070..ab0673e24 100644 --- a/tests/scripts/control-pane.test.js +++ b/tests/scripts/control-pane.test.js @@ -226,6 +226,49 @@ async function runTests() { passed++; else failed++; + if ( + await test('serves the 3D agent-airspace page and the proximity JSON feed', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-proximity-')); + const dbPath = path.join(tempDir, 'ecc2.db'); + + try { + await writeMinimalDatabase(dbPath); + const app = await createControlPaneServer({ + host: '127.0.0.1', + port: 0, + dbPath, + repoRoot: REPO_ROOT, + allowActions: false + }); + + await app.listen(); + try { + // The Enterprise/Pro 3D observability view: a self-contained HTML page. + const page = await fetchLocal(`${app.url}/proximity`); + assert.strictEqual(page.status, 200); + assert.ok((page.headers.get('content-type') || '').includes('text/html')); + const html = await page.text(); + assert.ok(html.includes('Agent Airspace'), 'page is titled Agent Airspace'); + assert.ok(html.includes(' r.json()); + assert.ok(Array.isArray(prox.positions), 'positions array present'); + assert.ok(Array.isArray(prox.links), 'links array present'); + assert.ok(Array.isArray(prox.advisories), 'advisories array present'); + assert.ok(prox.counts && typeof prox.counts === 'object', 'counts present'); + } finally { + await app.close(); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }) + ) + passed++; + else failed++; + if ( await test('serves health, asset, not-found, invalid body, and read-only action responses', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-control-pane-routes-')); From e3f467989a2c446cd8d0137893215e1249689c77 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Fri, 26 Jun 2026 05:17:32 +0530 Subject: [PATCH 002/197] fix(clv2): escape $HOME before pgrep -f in migrate-homunculus.sh (#2339) * fix(clv2): escape $HOME before pgrep -f in migrate-homunculus.sh pgrep -f treats its argument as an extended regular expression, but the running-observer guard interpolated $HOME unescaped. Paths containing regex metacharacters (e.g. /home/user.name, /home/c++dev, /home/user (work)) made the match over-broad or invalid, causing either a false negative (live observer missed, migration proceeds and risks registry corruption) or a false positive (migration blocked unnecessarily). Escape the ERE metacharacters in $HOME via sed before building the pattern so the home prefix is matched literally while the trailing .*observer-loop\.sh regex is preserved. Portable across BSD and GNU sed. Fixes #2301 * test(clv2): add regression test for migrate-homunculus.sh $HOME escaping Guards the #2301 fix: extracts the script's sed escaping command and asserts the resulting pgrep -f pattern matches the literal home path while no longer over-matching a regex-expanded decoy (HOME=/home/user.name must not match /home/userXname). Also pins that the guard uses escaped_home rather than $HOME directly. Follows the existing clv2 shell-test convention in tests/hooks/observe-entrypoint-allowlist.test.js. Refs #2301 * test(clv2): skip migrate-homunculus escaping test on Windows The test relies on POSIX bash/sed/grep -E semantics, which differ on the Windows CI runners. Guard with the same process.platform === 'win32' early exit used by tests/hooks/observe-subdirectory-detection.test.js so the bash-dependent assertions only run on POSIX platforms. Refs #2301 --- .../scripts/migrate-homunculus.sh | 8 +- .../migrate-homunculus-home-escape.test.js | 141 ++++++++++++++++++ 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 tests/hooks/migrate-homunculus-home-escape.test.js diff --git a/skills/continuous-learning-v2/scripts/migrate-homunculus.sh b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh index 9358fc7b4..b6c19cacd 100755 --- a/skills/continuous-learning-v2/scripts/migrate-homunculus.sh +++ b/skills/continuous-learning-v2/scripts/migrate-homunculus.sh @@ -20,7 +20,13 @@ if [ ! -d "$OLD" ]; then fi if command -v pgrep >/dev/null 2>&1; then - if pgrep -f "${HOME}.*observer-loop\\.sh" >/dev/null 2>&1; then + # pgrep -f treats its argument as an extended regular expression, so $HOME + # must be escaped before interpolation. Without this, regex metacharacters in + # the path (e.g. /home/user.name, /home/c++dev, /home/user (work)) would make + # the match over-broad or invalid, causing false negatives (observer missed, + # migration proceeds unsafely) or false positives (migration blocked). + escaped_home="$(printf '%s' "$HOME" | sed 's/[]\.[(){}+*?|^$]/\\&/g')" + if pgrep -f "${escaped_home}.*observer-loop\\.sh" >/dev/null 2>&1; then echo "Refusing to migrate: observer-loop.sh is running." >&2 echo "Exit all Claude Code sessions, then re-run." >&2 exit 1 diff --git a/tests/hooks/migrate-homunculus-home-escape.test.js b/tests/hooks/migrate-homunculus-home-escape.test.js new file mode 100644 index 000000000..b28e8a841 --- /dev/null +++ b/tests/hooks/migrate-homunculus-home-escape.test.js @@ -0,0 +1,141 @@ +/** + * Regression test for migrate-homunculus.sh $HOME escaping (#2301). + * + * The running-observer guard in migrate-homunculus.sh builds a `pgrep -f` + * pattern from $HOME. `pgrep -f` treats its argument as an extended regular + * expression, so an unescaped $HOME containing regex metacharacters (e.g. + * /home/user.name, /home/c++dev, /home/user (work)) made the match over-broad + * or invalid. That caused either a false negative (a live observer-loop.sh is + * missed and the migration proceeds unsafely) or a false positive (an unrelated + * process matches and the migration is blocked). + * + * The fix escapes the ERE metacharacters in $HOME before interpolation. This + * test pins that behavior by extracting the exact `sed` escaping command from + * the script (so it tests the real implementation, not a copy), then asserting + * that, for HOME values containing metacharacters: + * (a) the escaped pattern matches the literal home path, and + * (b) the escaped pattern does NOT over-match a decoy path that the + * unescaped (regex-expanded) form would have matched. + * + * Run with: node tests/hooks/migrate-homunculus-home-escape.test.js + */ + +'use strict'; + +// migrate-homunculus.sh and this test's assertions rely on POSIX bash, sed, and +// grep -E semantics. Skip on Windows, matching the repo convention for +// bash-dependent clv2 tests (see tests/hooks/observe-subdirectory-detection.test.js). +if (process.platform === 'win32') { + console.log('Skipping bash-dependent migrate-homunculus tests on Windows'); + process.exit(0); +} + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const scriptPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'scripts', + 'migrate-homunculus.sh' +); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed++; + } +} + +const scriptSource = fs.readFileSync(scriptPath, 'utf8'); + +// Extract the exact sed escaping command from the script so this test verifies +// the real implementation. Expected form: +// escaped_home="$(printf '%s' "$HOME" | sed 's/.../\\&/g')" +const sedMatch = scriptSource.match( + /escaped_home="\$\(printf '%s' "\$HOME" \| (sed '[^']*')\)"/ +); + +// Build the pgrep pattern exactly as the script does: ${escaped_home} followed +// by the literal observer-loop.sh regex tail. +function buildPattern(home) { + assert.ok( + sedMatch, + 'could not locate the escaped_home sed command in migrate-homunculus.sh; ' + + 'the fix for #2301 must escape $HOME before pgrep -f' + ); + const sedCmd = sedMatch[1]; + const res = spawnSync( + 'bash', + ['-c', `printf '%s' "$1" | ${sedCmd}`, 'bash', home], + { encoding: 'utf8' } + ); + assert.strictEqual( + res.status, + 0, + `sed escaping failed for HOME=${home}: ${res.stderr}` + ); + return `${res.stdout}.*observer-loop\\.sh`; +} + +// grep -E uses the same ERE engine as pgrep -f. Return true if cmdline matches. +function ereMatches(pattern, cmdline) { + const res = spawnSync('grep', ['-E', pattern], { + input: cmdline, + encoding: 'utf8', + }); + return res.status === 0; +} + +console.log('\n=== migrate-homunculus.sh $HOME escaping (#2301) ===\n'); + +test('the running-observer guard no longer interpolates $HOME unescaped', () => { + assert.ok( + !/pgrep -f "\$\{HOME\}/.test(scriptSource), + 'pgrep -f must not use ${HOME} directly; it must use the escaped value' + ); + assert.ok( + /pgrep -f "\$\{escaped_home\}/.test(scriptSource), + 'pgrep -f must use the escaped_home value built from $HOME' + ); +}); + +const problemHomes = ['/home/user.name', '/home/c++dev', '/home/user (work)', '/tmp/h[x]']; + +for (const home of problemHomes) { + test(`escaped pattern matches the literal home ${home}`, () => { + const pattern = buildPattern(home); + const cmdline = `/bin/bash ${home}/.local/share/ecc-homunculus/observer-loop.sh`; + assert.ok( + ereMatches(pattern, cmdline), + `expected escaped pattern to match the literal observer cmdline for HOME=${home}` + ); + }); +} + +test('escaped "." does not over-match a different path (#2301 false positive)', () => { + const pattern = buildPattern('/home/user.name'); + // The unescaped form ("." as any-char) would match /home/userXname; the + // escaped form must not. + const decoy = '/bin/bash /home/userXname/observer-loop.sh'; + assert.ok( + !ereMatches(pattern, decoy), + 'escaped pattern must not over-match /home/userXname when HOME=/home/user.name' + ); +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); From 2bc924faf2f8e893bfe0af86b1931283693c30ae Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Thu, 25 Jun 2026 16:47:35 -0700 Subject: [PATCH 003/197] fix(clv2): harden registry writes and project deletion (#2294, #2297) (#2323) Two security-priority fixes in continuous-learning-v2/scripts/instinct-cli.py: - #2294: _write_registry wrote projects.json without the advisory lock that _update_registry holds, so concurrent 'projects delete/gc/merge' could race an observe-time update and corrupt the registry. Extract the lock into a shared _registry_lock() context manager and use it in both writers. - #2297: _remove_project_storage called shutil.rmtree on PROJECTS_DIR/project_id with no containment check. Add defense-in-depth: resolve the path and refuse to delete anything that is not strictly inside PROJECTS_DIR (or is the root itself), so a relaxed validator or future caller can never cause an arbitrary-directory delete. Adds 5 pytest regression tests (atomic write under lock, contained delete, missing-dir no-op, traversal refused, root refused). Node integration suite (tests/scripts/instinct-cli-projects.test.js) green 9/9. --- .../scripts/instinct-cli.py | 70 ++++++++++++------- .../scripts/test_parse_instinct.py | 40 +++++++++++ 2 files changed, 86 insertions(+), 24 deletions(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 8cbbc9437..13bc467bc 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -27,6 +27,7 @@ import ipaddress import socket import urllib.parse import urllib.request +from contextlib import contextmanager from pathlib import Path from datetime import datetime, timedelta, timezone from collections import defaultdict @@ -394,22 +395,36 @@ def detect_project() -> dict: } +@contextmanager +def _registry_lock(): + """Serialize registry read-modify-write across concurrent sessions. + + Acquires the same advisory lock for every registry writer (``_update_registry`` + and ``_write_registry``) so ``projects delete/gc/merge`` cannot interleave with + a concurrent observe-time update and corrupt ``projects.json``. No-op on + platforms without ``fcntl`` (Windows). + """ + REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) + lock_path = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.lock" + lock_fd = None + try: + if _HAS_FCNTL: + lock_fd = open(lock_path, "w") + fcntl.flock(lock_fd, fcntl.LOCK_EX) + yield + finally: + if lock_fd is not None: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + lock_fd.close() + + def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None: """Update the projects.json registry. Uses file locking (where available) to prevent concurrent sessions from overwriting each other's updates. """ - REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) - lock_path = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.lock" - lock_fd = None - - try: - # Acquire advisory lock to serialize read-modify-write - if _HAS_FCNTL: - lock_fd = open(lock_path, "w") - fcntl.flock(lock_fd, fcntl.LOCK_EX) - + with _registry_lock(): try: with open(REGISTRY_FILE, encoding="utf-8") as f: registry = json.load(f) @@ -429,10 +444,6 @@ def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None: f.flush() os.fsync(f.fileno()) os.replace(tmp_file, REGISTRY_FILE) - finally: - if lock_fd is not None: - fcntl.flock(lock_fd, fcntl.LOCK_UN) - lock_fd.close() def load_registry() -> dict: @@ -445,15 +456,19 @@ def load_registry() -> dict: def _write_registry(registry: dict) -> None: - """Write the project registry atomically.""" - REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) - tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}" - with open(tmp_file, "w", encoding="utf-8") as f: - json.dump(registry, f, indent=2) - f.write("\n") - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_file, REGISTRY_FILE) + """Write the project registry atomically. + + Holds the same advisory lock as ``_update_registry`` so concurrent + ``projects delete/gc/merge`` and observe-time updates cannot corrupt the file. + """ + with _registry_lock(): + tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}" + with open(tmp_file, "w", encoding="utf-8") as f: + json.dump(registry, f, indent=2) + f.write("\n") + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_file, REGISTRY_FILE) def _validate_project_id(project_id: str) -> bool: @@ -573,7 +588,14 @@ def _project_counts(project_id: str) -> dict: def _remove_project_storage(project_id: str) -> None: - project_dir = PROJECTS_DIR / project_id + # Defense-in-depth: resolve and confirm the target is contained within + # PROJECTS_DIR before recursively deleting, even though callers validate the + # project id. A relaxed validator or a future caller must never be able to + # turn this into an arbitrary-directory delete. + projects_root = PROJECTS_DIR.resolve() + project_dir = (PROJECTS_DIR / project_id).resolve() + if project_dir == projects_root or projects_root not in project_dir.parents: + raise ValueError(f"refusing to remove {project_dir}: escapes {projects_root}") if project_dir.exists(): shutil.rmtree(project_dir) diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index ea5e910d6..225dcb053 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -46,6 +46,8 @@ load_registry = _mod.load_registry _validate_instinct_id = _mod._validate_instinct_id _validate_import_url = _mod._validate_import_url _update_registry = _mod._update_registry +_write_registry = _mod._write_registry +_remove_project_storage = _mod._remove_project_storage _confidence_bar = _mod._confidence_bar @@ -1043,3 +1045,41 @@ def test_update_registry_atomic_replaces_file(patch_globals): assert "abc123" in data leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*")) assert leftovers == [] + + +def test_write_registry_atomic_no_tmp_leftovers(patch_globals): + # Issue #2294: _write_registry now holds the registry lock like + # _update_registry. It must still write atomically with no stray tmp files. + tree = patch_globals + _write_registry({"keep": {"name": "demo", "root": "/repo", "remote": ""}}) + data = json.loads(tree["registry_file"].read_text()) + assert data == {"keep": {"name": "demo", "root": "/repo", "remote": ""}} + leftovers = list(tree["registry_file"].parent.glob(".projects.json.tmp.*")) + assert leftovers == [] + + +def test_remove_project_storage_deletes_contained_dir(patch_globals): + tree = patch_globals + target = tree["projects_dir"] / "proj-1" + (target / "instincts").mkdir(parents=True) + (target / "instincts" / "x.md").write_text("hi", encoding="utf-8") + _remove_project_storage("proj-1") + assert not target.exists() + + +def test_remove_project_storage_missing_dir_is_noop(patch_globals): + # No raise when the contained dir simply does not exist. + _remove_project_storage("never-created") + + +def test_remove_project_storage_blocks_traversal(patch_globals): + # Issue #2297: defense-in-depth — a traversal id must be refused even when a + # caller skips _validate_project_id, so this can never delete outside + # PROJECTS_DIR. + with pytest.raises(ValueError): + _remove_project_storage("../../etc") + + +def test_remove_project_storage_blocks_root_itself(patch_globals): + with pytest.raises(ValueError): + _remove_project_storage(".") From 1031d312ccd16925c903174293b4cec4ecba1001 Mon Sep 17 00:00:00 2001 From: JongHyeok Park Date: Tue, 30 Jun 2026 07:50:41 +0900 Subject: [PATCH 004/197] feat(workflows): add orch-review native Workflow pilot (#2363) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(workflows): add orch-review native Workflow pilot Port orch-pipeline Phase 5 (Review) to a native Claude Code Workflow script. The gated outer loop stays in the main conversation; this script owns only the autonomous review+verify segment between the two human gates: 1. Review — reviewers fan out in parallel: ecc:code-reviewer always, ecc:-reviewer when args.language maps, ecc:security-reviewer when the orch-pipeline security trigger matches the diff/paths. 2. Dedup — merge findings across dimensions keyed on the normalized evidence snippet, since independent reviewers flag the same line. 3. Verify — each unique CRITICAL/HIGH finding goes to an independent adversarial verifier; MEDIUM/LOW pass through as advisory. The Review->Verify barrier is deliberate: deduping before verification stops the verifier running N times on the same bug (local testing: 11 raw findings collapsed to 4 unique, ~halving verifier cost). Existing ECC reviewer subagents are reused via agentType; reviewer output is validated by JSON schema. args is accepted as an object or a JSON-encoded string. - workflows/orch-review.workflow.js — the workflow script - workflows/README.md — invocation contract, returns shape, follow-ups CI lint is scoped to scripts/ and tests/, so the script (validated with node --check) and the README (passes markdownlint) are untouched. * fix(workflows): fail closed on invalid args and lost review dimensions Addresses the two safety findings from the PR bot review: 1. Lost review dimension (Greptile P1 / CodeRabbit Major): a reviewer agent that returns null or rejects was silently dropped by filter(Boolean), so an unreviewed security dimension could still return APPROVE. Each dimension's outcome is now captured; failures land in failedDimensions and force CHANGES_REQUESTED (incomplete). 2. Invalid args (CodeRabbit Major): an empty diff returned APPROVE and bad JSON / non-array changedFiles threw inconsistently. Input is now validated up front and rejected with a clear error — the gate fails closed instead of approving an unreviewed payload. Docs (header contract + README) updated for the new return fields (incomplete, failedDimensions, stats.failed). Remaining bot nits (evidence minLength, verify-label collision, verified->confirmed rename, contract drift) deferred as follow-ups. * fix(workflows): address remaining orch-review review nits Follow-up to the bot review (deferred items from the safety pass): - evidence: require minLength 1 in the schema, and fall back to a title+line dedup key when evidence is empty, so empty-evidence findings in one file no longer collapse onto a single key and drop (CodeRabbit). - verify label: include a slice of the normalized evidence so two CRITICAL/HIGH findings from the same file get distinct labels and do not alias under resumability (Greptile). - stats.verified -> stats.confirmed to match the "confirmed" wording used in the log and avoid ambiguity vs the refuted count (Greptile); header contract and README updated to match. Verified by running the workflow on a synthetic vulnerable diff: dedup 12 raw -> 5 unique, stats.confirmed populated, fail-closed fields (incomplete/failedDimensions) intact. * fix(workflows): harden verify stage and diff-only verification Addresses the second-round bot review: - Verify stage now has the same failure guard as the review stage: a rejected verifier no longer nulls out its slot (which crashed the later filter). A null return is treated as unconfirmed; a rejection keeps the finding as blocking (fail closed) so an unverifiable CRITICAL is never silently demoted to advisory (CodeRabbit @221). - verifyPrompt now instructs the skeptic to judge solely from the provided diff text and not to refute merely because the referenced file is absent from the working tree (the diff may be an unapplied PR). Fixes the false-refute seen when testing on a synthetic diff. CodeRabbit @81 (evidence minLength) was already addressed in the prior commit; this is a stale re-post on the unresolved thread. * fix(workflows): keep unverifiable blockers blocking; stop leaking error text Second-round bot review (CodeRabbit): - @218 Treat a null/failed verifier as `unverified`, not refuted. A terminal verifier failure or skip no longer demotes a CRITICAL/HIGH to advisory; it stays in `blocking` tagged "could not be verified" (fail closed). Only a genuine isReal=false verdict is refuted. Adds stats.unverified. - @189 Do not return raw subagent error text. Review/verify failures now log the raw message for operators and return only a bounded label (failedDimensions[].error = "review agent failed"). Stale re-posts this round (@81 evidence minLength, @224 verify guard) were already fixed in prior commits. * docs(workflows): enumerate bounded failedDimensions.error labels CodeRabbit (trivial): the public contract implied callers get human-readable error text, but the implementation returns only bounded labels. Enumerate them in the README returns block. --- workflows/README.md | 59 +++++++ workflows/orch-review.workflow.js | 254 ++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 workflows/README.md create mode 100644 workflows/orch-review.workflow.js diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 000000000..b67ed2e0f --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,59 @@ +# ECC native workflows (pilot) + +Scripts in this directory are [Claude Code **Workflow** tool](https://docs.claude.com/en/docs/claude-code) scripts — deterministic, multi-agent orchestration that runs in the background and fans out to subagents. + +This is a **pilot**: ECC's orchestration (`orch-*`, `multi-*`, GAN/Santa loops) is currently hand-rolled on top of the `Task`/Agent tool. These scripts port the autonomous, fan-out-heavy segments to the native engine, which gives us barrier-free pipelining, automatic concurrency capping, structured-output validation, and resumability for free. + +## `orch-review.workflow.js` + +A native port of **orch-pipeline Phase 5 (Review)**. + +The gated outer loop (Gate 1 after Plan, Gate 2 before Commit) **stays in the main conversation** — native workflows run autonomously in the background and cannot pause for interactive approval. This script owns only the segment *between* the gates: + +1. **Review** — one reviewer agent per dimension, in parallel: + - `ecc:code-reviewer` (correctness & quality) — always + - the matching `ecc:-reviewer` — when `args.language` maps to one + - `ecc:security-reviewer` — only when the orch-pipeline security trigger matches the diff/paths +2. **Dedup** — independent reviewers routinely flag the same line, so findings are merged across dimensions keyed on the normalized `evidence` snippet (titles and line numbers drift per reviewer; the offending code does not). Each surviving finding records which `dimensions` reported it and keeps the strictest severity. +3. **Verify** — every *unique* `CRITICAL`/`HIGH` finding is handed to an independent adversarial verifier that defaults to *refuted* on uncertainty. `MEDIUM`/`LOW` pass through as advisory. + +The Review→Verify barrier is deliberate: deduping before verification is exactly the case the Workflow guidance calls a justified barrier — it stops the verifier running N times on the same bug (in local testing, 11 raw findings collapsed to 4 unique, roughly halving verifier cost). + +### Invocation + +The main loop computes the diff, then calls the Workflow tool: + +```jsonc +Workflow({ + scriptPath: "workflows/orch-review.workflow.js", + args: { + diff: "", // required + language: "typescript", // optional — selects a language reviewer + changedFiles: ["src/auth.ts"] // optional — feeds the security trigger + } +}) +``` + +Invalid input throws (the gate **fails closed**): a missing/empty `diff`, malformed JSON, or a non-array `changedFiles` is rejected with a clear error rather than silently approving an unreviewed payload. + +### Returns + +```jsonc +{ + "verdict": "APPROVE" | "CHANGES_REQUESTED", // CHANGES_REQUESTED if any blocker OR a dimension failed + "incomplete": false, // true when one or more review dimensions failed to run + "failedDimensions": [ /* { dimension, error } — error is a bounded label, never raw subagent text: + "agent returned null (terminal failure or skip)" | "review agent failed" */ ], + "blocking": [ /* confirmed CRITICAL/HIGH + unverifiable ones — must clear before Gate 2 */ ], + "advisory": [ /* MEDIUM/LOW + adversarially-refuted findings */ ], + "stats": { "dimensions": 3, "failed": 0, "raw": 11, "unique": 4, "confirmed": 4, "unverified": 0, "refuted": 0 } +} +``` + +The main loop presents `blocking` at Gate 2; the human still approves the commit. The gate fails closed at every stage: if a reviewer dies the dimension is recorded in `failedDimensions` (verdict never a clean `APPROVE`), and if a *verifier* dies or returns null the blocker is kept in `blocking` (tagged "could not be verified") rather than demoted to advisory — an unreviewed security dimension or an unverifiable CRITICAL must not pass as approved. + +## Not in this PR (follow-ups) + +- A `/orch-review` command + skill trigger (plus the mirrored i18n docs and surface tests ECC requires for a new command surface). +- Installer / manifest wiring so the script ships to `~/.claude/` on install. +- Porting the **Research** sweep and **Plan** judge-panel segments next. diff --git a/workflows/orch-review.workflow.js b/workflows/orch-review.workflow.js new file mode 100644 index 000000000..209f834ec --- /dev/null +++ b/workflows/orch-review.workflow.js @@ -0,0 +1,254 @@ +export const meta = { + name: 'orch-review', + description: + 'ECC Review phase as a native Claude Code workflow: multi-dimension review (quality + language + conditional security) then adversarial verification of every CRITICAL/HIGH finding. Returns blocking + advisory findings for Gate 2.', + phases: [ + { title: 'Review', detail: 'one reviewer agent per dimension, in parallel' }, + { title: 'Verify', detail: 'adversarially refute each CRITICAL/HIGH finding' } + ] +}; + +// --------------------------------------------------------------------------- +// Pilot port of orch-pipeline Phase 5 (Review). The gated outer loop stays in +// the main conversation; this script owns only the autonomous, fan-out-heavy +// review+verify segment between the two human gates. +// +// Caller contract — pass `args` (the main loop computes the diff and language): +// { +// diff: string, // unified `git diff` text to review (required) +// language?: string, // e.g. "typescript" — selects a language reviewer +// changedFiles?: string[], // paths touched, used for the security trigger +// } +// Invalid input (missing/empty diff, bad JSON, non-array changedFiles) throws — +// the gate fails closed rather than silently approving an unreviewed payload. +// +// Returns: +// { verdict: 'APPROVE' | 'CHANGES_REQUESTED', // CHANGES_REQUESTED if any blocker OR a dimension failed +// incomplete: boolean, // true when one or more review dimensions failed to run +// failedDimensions: { dimension, error }[], +// blocking: Finding[], // confirmed CRITICAL/HIGH + unverifiable ones — must clear before Gate 2 +// advisory: Finding[], // MEDIUM/LOW + refuted findings, informational +// stats: { dimensions, failed, raw, unique, confirmed, unverified, refuted } } +// --------------------------------------------------------------------------- + +// Language → ECC reviewer agent. Mirrors the agents present in agents/. +const LANGUAGE_REVIEWER = { + typescript: 'ecc:typescript-reviewer', + javascript: 'ecc:typescript-reviewer', + python: 'ecc:python-reviewer', + go: 'ecc:go-reviewer', + rust: 'ecc:rust-reviewer', + java: 'ecc:java-reviewer', + kotlin: 'ecc:kotlin-reviewer', + swift: 'ecc:swift-reviewer', + php: 'ecc:php-reviewer', + csharp: 'ecc:csharp-reviewer', + fsharp: 'ecc:fsharp-reviewer', + react: 'ecc:react-reviewer', + vue: 'ecc:vue-reviewer', + flutter: 'ecc:flutter-reviewer', + dart: 'ecc:flutter-reviewer', + django: 'ecc:django-reviewer', + fastapi: 'ecc:fastapi-reviewer', + cpp: 'ecc:cpp-reviewer' +}; + +// orch-pipeline security trigger: auth/authz, user input, db queries, fs paths, +// external calls, crypto, secrets. Matched against the diff text + file paths. +const SECURITY_TRIGGER = + /\b(auth|login|password|passwd|token|secret|credential|api[_-]?key|session|jwt|oauth|cookie|sql|query|exec|eval|crypto|cipher|hash|hmac|sign|fs\.|readFile|writeFile|fetch|axios|request|subprocess|os\.system)\b/i; + +// A reviewer agent must emit findings in this shape — validated at the tool layer. +const FINDINGS_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['verdict', 'findings'], + properties: { + verdict: { type: 'string', enum: ['APPROVE', 'CHANGES_REQUESTED'] }, + findings: { + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['title', 'severity', 'file', 'evidence'], + properties: { + title: { type: 'string' }, + severity: { type: 'string', enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] }, + file: { type: 'string' }, + line: { type: ['integer', 'null'] }, + evidence: { type: 'string', minLength: 1, description: 'the offending snippet or exact location' }, + proof: { type: 'string', description: 'why it is a real problem (required for HIGH/CRITICAL)' }, + fix: { type: 'string', description: 'concrete suggested remediation' } + } + } + } + } +}; + +// Independent skeptic verdict for one finding. +const VERDICT_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['isReal', 'confidence', 'reasoning'], + properties: { + isReal: { type: 'boolean', description: 'true only if the finding genuinely holds against the diff' }, + confidence: { type: 'number', minimum: 0, maximum: 1 }, + reasoning: { type: 'string' } + } +}; + +const SEVERITY_RANK = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; +const isBlocking = f => f.severity === 'CRITICAL' || f.severity === 'HIGH'; +const normalize = s => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); + +function reviewPrompt(dimensionLabel, diff) { + return [ + `You are reviewing a unified diff along the "${dimensionLabel}" dimension.`, + 'Apply your standard checklist. Only report issues you are >80% sure are real problems.', + 'For any CRITICAL or HIGH finding you MUST supply concrete `evidence` and a `proof` of impact; if you cannot, demote it or drop it.', + 'Returning zero findings with verdict APPROVE is an acceptable and expected outcome for clean diffs.', + '', + 'DIFF:', + diff + ].join('\n'); +} + +function verifyPrompt(finding, diff) { + return [ + 'You are an independent skeptic. Try to REFUTE the finding below by checking it against the diff text provided here — and ONLY that text.', + 'The diff may be unapplied (a proposed PR), so the referenced file may not exist on disk yet. Do NOT refute a finding merely because the file is absent from the working tree; judge solely from the diff content.', + 'Default to isReal=false when you are uncertain or cannot locate supporting evidence in the diff text.', + '', + `Finding (${finding.severity}) in ${finding.file}: ${finding.title}`, + `Claimed evidence: ${finding.evidence}`, + finding.proof ? `Claimed proof: ${finding.proof}` : '', + '', + 'DIFF:', + diff + ].join('\n'); +} + +// --- main ----------------------------------------------------------------- + +// `args` arrives verbatim. Accept a JSON-encoded string too, so the workflow +// works whether the caller passes an object or a stringified payload. +// Fail CLOSED on invalid input: a review gate must never silently APPROVE a +// payload it could not actually review. +let input; +try { + input = typeof args === 'string' ? JSON.parse(args) : (args ?? {}); +} catch { + throw new Error('orch-review: args must be an object or valid JSON'); +} +if (typeof input !== 'object' || input === null) { + throw new Error('orch-review: args must be an object'); +} +if (typeof input.diff !== 'string' || input.diff.trim() === '') { + throw new Error('orch-review: args.diff must be a non-empty unified diff'); +} +if (input.changedFiles != null && !Array.isArray(input.changedFiles)) { + throw new Error('orch-review: args.changedFiles must be an array of paths'); +} + +const diff = input.diff; +const haystack = `${diff}\n${(input.changedFiles || []).join('\n')}`; + +// Build the review dimensions. Quality always runs; language + security are conditional. +const dimensions = [{ key: 'quality', label: 'correctness & quality', agentType: 'ecc:code-reviewer' }]; + +const langReviewer = input.language && LANGUAGE_REVIEWER[String(input.language).toLowerCase()]; +if (langReviewer) { + dimensions.push({ key: `lang:${input.language}`, label: `${input.language} idioms & pitfalls`, agentType: langReviewer }); +} + +if (SECURITY_TRIGGER.test(haystack)) { + dimensions.push({ key: 'security', label: 'security (OWASP, secrets, injection)', agentType: 'ecc:security-reviewer' }); + log('Security trigger matched — adding security-reviewer dimension.'); +} + +log(`Reviewing across ${dimensions.length} dimension(s): ${dimensions.map(d => d.key).join(', ')}`); + +// Stage 1 — every dimension reviews in parallel. This is a deliberate BARRIER: +// independent reviewers routinely flag the same line, so we need the full set +// before we can dedup. Verifying first and deduping later would waste verifier +// calls on duplicates (e.g. one SQL-injection bug reported by all 3 dimensions). +// A reviewer can fail two ways: agent() returns null on a terminal error/skip, +// or the thunk rejects. Capture both per-dimension so a lost dimension is never +// silently dropped — an unreviewed security dimension must not pass as APPROVE. +const reviews = await parallel( + dimensions.map( + d => () => + agent(reviewPrompt(d.label, diff), { agentType: d.agentType, phase: 'Review', label: `review:${d.key}`, schema: FINDINGS_SCHEMA }) + .then(r => (r === null ? { dim: d.key, ok: false, error: 'agent returned null (terminal failure or skip)', findings: [] } : { dim: d.key, ok: true, findings: r.findings || [] })) + // Log the raw error for operators; never return provider/runtime internals to the caller. + .catch(err => { + log(`Review dimension ${d.key} failed: ${String((err && err.message) || err)}`); + return { dim: d.key, ok: false, error: 'review agent failed', findings: [] }; + }) + ) +); + +const failedDimensions = reviews.filter(r => r && !r.ok).map(r => ({ dimension: r.dim, error: r.error })); +if (failedDimensions.length > 0) { + log(`WARNING: ${failedDimensions.length} review dimension(s) failed: ${failedDimensions.map(f => f.dimension).join(', ')}. Verdict will fail closed.`); +} + +// Dedup across dimensions. The evidence snippet (the offending code) is the most +// stable key — titles are phrased differently and line numbers drift per reviewer. +const tagged = reviews.filter(r => r && r.ok).flatMap(r => r.findings.map(f => ({ ...f, dimension: r.dim }))); +const byKey = new Map(); +for (const f of tagged) { + // Prefer the evidence snippet; fall back to title+line so empty-evidence + // findings in the same file don't all collapse onto one `${file}::` key. + const evidenceKey = normalize(f.evidence); + const key = evidenceKey ? `${f.file}::${evidenceKey}` : `${f.file}::${normalize(f.title)}::${f.line ?? 'na'}`; + const prev = byKey.get(key); + if (!prev) { + byKey.set(key, { ...f, dimensions: [f.dimension] }); + } else { + if (!prev.dimensions.includes(f.dimension)) prev.dimensions.push(f.dimension); + if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[prev.severity]) prev.severity = f.severity; // keep the strictest + } +} +const unique = [...byKey.values()]; +log(`Reviews returned ${tagged.length} findings → ${unique.length} unique after dedup.`); + +// Stage 2 — adversarially verify each unique CRITICAL/HIGH. MEDIUM/LOW are advisory. +const advisory = unique.filter(f => !isBlocking(f)); +const verified = await parallel( + unique.filter(isBlocking).map( + f => () => + agent(verifyPrompt(f, diff), { phase: 'Verify', label: `verify:${f.file}:${normalize(f.evidence).slice(0, 40)}`, schema: VERDICT_SCHEMA }) + // A null return (terminal failure/skip) or a rejection means we could NOT + // verify the finding. Mark it `unverified` rather than refuted so it stays + // blocking (fail closed) — an unverifiable CRITICAL must never be demoted + // to advisory just because the verifier did not run. + .then(v => (v ? { ...f, verdict: v } : { ...f, unverified: true, verdict: { isReal: false, confidence: 0, reasoning: 'verifier returned null (terminal failure or skip)' } })) + .catch(err => { + log(`Verifier failed for ${f.file}: ${String((err && err.message) || err)}`); + return { ...f, unverified: true, verdict: { isReal: false, confidence: 0, reasoning: 'verifier error' } }; + }) + ) +); + +const verifiedClean = verified.filter(Boolean); +const confirmed = verifiedClean.filter(f => !f.unverified && f.verdict && f.verdict.isReal); +const unverified = verifiedClean.filter(f => f.unverified); +const refuted = verifiedClean.filter(f => !f.unverified && !(f.verdict && f.verdict.isReal)); + +// Unverifiable blockers stay in `blocking` (fail closed), tagged so the human +// at Gate 2 knows they were not independently confirmed. +const blocking = [...confirmed, ...unverified.map(f => ({ ...f, note: 'could not be verified — kept as blocking' }))]; + +log(`Done: ${confirmed.length} confirmed, ${unverified.length} unverified (kept blocking), ${refuted.length} refuted, ${advisory.length} advisory.`); + +// Fail closed: APPROVE only when every dimension ran AND nothing blocks. +const incomplete = failedDimensions.length > 0; +return { + verdict: blocking.length > 0 || incomplete ? 'CHANGES_REQUESTED' : 'APPROVE', + incomplete, + failedDimensions, + blocking, + advisory: [...advisory, ...refuted.map(f => ({ ...f, note: 'refuted by adversarial verifier' }))], + stats: { dimensions: dimensions.length, failed: failedDimensions.length, raw: tagged.length, unique: unique.length, confirmed: confirmed.length, unverified: unverified.length, refuted: refuted.length } +}; From d65d3e0880a12f99a7b2b2fb018b320f6a0dd9fd Mon Sep 17 00:00:00 2001 From: Daniel Nguyen Date: Tue, 30 Jun 2026 08:50:44 +1000 Subject: [PATCH 005/197] Update yarn.lock (#2342) --- .gitleaksignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitleaksignore diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 000000000..8ca2689ce --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +# Example API key in documentation (not a real secret) +docs/es/skills/api-design/SKILL.md:generic-api-key:306 From 909ae2f67e4edaed4ba457dcc0f3b891566a07b4 Mon Sep 17 00:00:00 2001 From: Gabriel Pitrella Date: Mon, 29 Jun 2026 19:50:46 -0300 Subject: [PATCH 006/197] Add memxus configuration to mcp-servers.json (#2355) * Add memxus configuration to mcp-servers.json Added configuration for Memxus service with API key placeholder and description. * Revise description in mcp-servers.json Updated the description to include a note about reviewing stored memories to prevent prompt-injection. * Update description in mcp-servers.json Update description in mcp-servers.json --- mcp-configs/mcp-servers.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mcp-configs/mcp-servers.json b/mcp-configs/mcp-servers.json index 9a3d05efc..464f028f1 100644 --- a/mcp-configs/mcp-servers.json +++ b/mcp-configs/mcp-servers.json @@ -122,6 +122,14 @@ "args": ["-y", "@magicuidesign/mcp@latest"], "description": "Magic UI components" }, + "memxus": { + "type": "http", + "url": "https://mcp.memxus.com/mcp", + "headers": { + "Authorization": "Bearer YOUR_MEMXUS_API_KEY_HERE" + }, + "description": "Universal persistent memory across Claude Code, Cursor, Gemini CLI and any AI tool — save context once, auto-recalled in every session. Note: review stored memories before use in production agents to avoid prompt-injection via memory-poisoning. Free at memxus.com" + }, "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/your/projects"], From 85dfb07576bb3299aff838403f04809e8bf03162 Mon Sep 17 00:00:00 2001 From: Angad Singh Thind Date: Mon, 29 Jun 2026 23:50:49 +0100 Subject: [PATCH 007/197] Fix for docs: Scope Decision Guide table duplicated in SKILL.md and observer.md with minor drift (#2366) #2306 Co-authored-by: angadsingh7666 --- package-lock.json | 8 +- package.json | 2 +- .../continuous-learning-v2/agents/observer.md | 11 +- yarn.lock | 3811 +++++++---------- 4 files changed, 1548 insertions(+), 2284 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7df5df5c1..85171319e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@iarna/toml": "^2.2.5", - "ajv": "^8.18.0", + "ajv": "^8.20.0", "sql.js": "^1.14.1" }, "bin": { @@ -535,9 +535,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", diff --git a/package.json b/package.json index b96fc5b4f..45d000a13 100644 --- a/package.json +++ b/package.json @@ -358,7 +358,7 @@ }, "dependencies": { "@iarna/toml": "^2.2.5", - "ajv": "^8.18.0", + "ajv": "^8.20.0", "sql.js": "^1.14.1" }, "devDependencies": { diff --git a/skills/continuous-learning-v2/agents/observer.md b/skills/continuous-learning-v2/agents/observer.md index e03845e5d..f29bcb0fd 100644 --- a/skills/continuous-learning-v2/agents/observer.md +++ b/skills/continuous-learning-v2/agents/observer.md @@ -121,16 +121,7 @@ Validate and sanitize all user input before processing. When creating instincts, determine scope based on these heuristics: -| Pattern Type | Scope | Examples | -|-------------|-------|---------| -| Language/framework conventions | **project** | "Use React hooks", "Follow Django REST patterns" | -| File structure preferences | **project** | "Tests in `__tests__`/", "Components in src/components/" | -| Code style | **project** | "Use functional style", "Prefer dataclasses" | -| Error handling strategies | **project** (usually) | "Use Result type for errors" | -| Security practices | **global** | "Validate user input", "Sanitize SQL" | -| General best practices | **global** | "Write tests first", "Always handle errors" | -| Tool workflow preferences | **global** | "Grep before Edit", "Read before Write" | -| Git practices | **global** | "Conventional commits", "Small focused commits" | +> **Scope Decision Guide** – See the canonical table in `skills/continuous-learning-v2/SKILL.md` (lines 271‑282). **When in doubt, default to `scope: project`** — it's safer to be project-specific and promote later than to contaminate the global space. diff --git a/yarn.lock b/yarn.lock index 538807a72..c831d6db9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,2269 +1,1542 @@ -# This file is generated by running "yarn install" inside your project. -# Manual changes might be lost - proceed with caution! - -__metadata: - version: 8 - cacheKey: 10c0 - -"@bcoe/v8-coverage@npm:^1.0.1": - version: 1.0.2 - resolution: "@bcoe/v8-coverage@npm:1.0.2" - checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 - languageName: node - linkType: hard - -"@eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.1 - resolution: "@eslint-community/eslint-utils@npm:4.9.1" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 - languageName: node - linkType: hard - -"@eslint-community/regexpp@npm:^4.12.1": - version: 4.12.2 - resolution: "@eslint-community/regexpp@npm:4.12.2" - checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.21.1": - version: 0.21.1 - resolution: "@eslint/config-array@npm:0.21.1" - dependencies: - "@eslint/object-schema": "npm:^2.1.7" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.2" - checksum: 10c0/2f657d4edd6ddcb920579b72e7a5b127865d4c3fb4dda24f11d5c4f445a93ca481aebdbd6bf3291c536f5d034458dbcbb298ee3b698bc6c9dd02900fe87eec3c - languageName: node - linkType: hard - -"@eslint/config-helpers@npm:^0.4.2": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" - dependencies: - "@eslint/core": "npm:^0.17.0" - checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 - languageName: node - linkType: hard - -"@eslint/core@npm:^0.17.0": - version: 0.17.0 - resolution: "@eslint/core@npm:0.17.0" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/9a580f2246633bc752298e7440dd942ec421860d1946d0801f0423830e67887e4aeba10ab9a23d281727a978eb93d053d1922a587d502942a713607f40ed704e - languageName: node - linkType: hard - -"@eslint/eslintrc@npm:^3.3.1": - version: 3.3.3 - resolution: "@eslint/eslintrc@npm:3.3.3" - dependencies: - ajv: "npm:^6.12.4" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.1" - minimatch: "npm:^3.1.2" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/532c7acc7ddd042724c28b1f020bd7bf148fcd4653bb44c8314168b5f772508c842ce4ee070299cac51c5c5757d2124bdcfcef5551c8c58ff9986e3e17f2260d - languageName: node - linkType: hard - -"@eslint/js@npm:9.39.2, @eslint/js@npm:^9.39.2": - version: 9.39.2 - resolution: "@eslint/js@npm:9.39.2" - checksum: 10c0/00f51c52b04ac79faebfaa65a9652b2093b9c924e945479f1f3945473f78aee83cbc76c8d70bbffbf06f7024626575b16d97b66eab16182e1d0d39daff2f26f5 - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^2.1.7": - version: 2.1.7 - resolution: "@eslint/object-schema@npm:2.1.7" - checksum: 10c0/936b6e499853d1335803f556d526c86f5fe2259ed241bc665000e1d6353828edd913feed43120d150adb75570cae162cf000b5b0dfc9596726761c36b82f4e87 - languageName: node - linkType: hard - -"@eslint/plugin-kit@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/plugin-kit@npm:0.4.1" - dependencies: - "@eslint/core": "npm:^0.17.0" - levn: "npm:^0.4.1" - checksum: 10c0/51600f78b798f172a9915dffb295e2ffb44840d583427bc732baf12ecb963eb841b253300e657da91d890f4b323d10a1bd12934bf293e3018d8bb66fdce5217b - languageName: node - linkType: hard - -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 10c0/aa4e0152171c07879b458d0e8a704b8c3a89a8c0541726c6b65b81e84fd8b7564b5d6c633feadc6598307d34564bd53294b533491424e8e313d7ab6c7bc5dc67 - languageName: node - linkType: hard - -"@humanfs/node@npm:^0.16.6": - version: 0.16.7 - resolution: "@humanfs/node@npm:0.16.7" - dependencies: - "@humanfs/core": "npm:^0.19.1" - "@humanwhocodes/retry": "npm:^0.4.0" - checksum: 10c0/9f83d3cf2cfa37383e01e3cdaead11cd426208e04c44adcdd291aa983aaf72d7d3598844d2fe9ce54896bb1bf8bd4b56883376611c8905a19c44684642823f30 - languageName: node - linkType: hard - -"@humanwhocodes/module-importer@npm:^1.0.1": - version: 1.0.1 - resolution: "@humanwhocodes/module-importer@npm:1.0.1" - checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.4.0, @humanwhocodes/retry@npm:^0.4.2": - version: 0.4.3 - resolution: "@humanwhocodes/retry@npm:0.4.3" - checksum: 10c0/3775bb30087d4440b3f7406d5a057777d90e4b9f435af488a4923ef249e93615fb78565a85f173a186a076c7706a81d0d57d563a2624e4de2c5c9c66c486ce42 - languageName: node - linkType: hard - -"@iarna/toml@npm:^2.2.5": - version: 2.2.5 - resolution: "@iarna/toml@npm:2.2.5" - checksum: 10c0/d095381ad4554aca233b7cf5a91f243ef619e5e15efd3157bc640feac320545450d14b394aebbf6f02a2047437ced778ae598d5879a995441ab7b6c0b2c2f201 - languageName: node - linkType: hard - -"@isaacs/fs-minipass@npm:^4.0.0": - version: 4.0.1 - resolution: "@isaacs/fs-minipass@npm:4.0.1" - dependencies: - minipass: "npm:^7.0.4" - checksum: 10c0/c25b6dc1598790d5b55c0947a9b7d111cfa92594db5296c3b907e2f533c033666f692a3939eadac17b1c7c40d362d0b0635dc874cbfe3e70db7c2b07cc97a5d2 - languageName: node - linkType: hard - -"@istanbuljs/schema@npm:^0.1.2, @istanbuljs/schema@npm:^0.1.3": - version: 0.1.3 - resolution: "@istanbuljs/schema@npm:0.1.3" - checksum: 10c0/61c5286771676c9ca3eb2bd8a7310a9c063fb6e0e9712225c8471c582d157392c88f5353581c8c9adbe0dff98892317d2fdfc56c3499aa42e0194405206a963a - languageName: node - linkType: hard - -"@jridgewell/resolve-uri@npm:^3.1.0": - version: 3.1.2 - resolution: "@jridgewell/resolve-uri@npm:3.1.2" - checksum: 10c0/d502e6fb516b35032331406d4e962c21fe77cdf1cbdb49c6142bcbd9e30507094b18972778a6e27cbad756209cfe34b1a27729e6fa08a2eb92b33943f680cf1e - languageName: node - linkType: hard - -"@jridgewell/sourcemap-codec@npm:^1.4.14": - version: 1.5.5 - resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" - checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 - languageName: node - linkType: hard - -"@jridgewell/trace-mapping@npm:^0.3.12": - version: 0.3.31 - resolution: "@jridgewell/trace-mapping@npm:0.3.31" - dependencies: - "@jridgewell/resolve-uri": "npm:^3.1.0" - "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4" - conditions: os=linux & cpu=arm64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4" - conditions: os=linux & cpu=x64 - languageName: node - linkType: hard - -"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4": - version: 3.0.4 - resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@opencode-ai/plugin@npm:^1.16.2": - version: 1.17.3 - resolution: "@opencode-ai/plugin@npm:1.17.3" - dependencies: - "@opencode-ai/sdk": "npm:1.17.3" - effect: "npm:4.0.0-beta.74" - zod: "npm:4.1.8" - peerDependencies: - "@opentui/core": ">=0.3.4" - "@opentui/keymap": ">=0.3.4" - "@opentui/solid": ">=0.3.4" - peerDependenciesMeta: - "@opentui/core": - optional: true - "@opentui/keymap": - optional: true - "@opentui/solid": - optional: true - checksum: 10c0/c78d3915ca1e479d638230d4f4a2f439163691c45e88284a6862f53ca349916845bf97ea08b40d59acb00b9af7522d2b45f399b420cfb17cfc2a3db3b02653cc - languageName: node - linkType: hard - -"@opencode-ai/sdk@npm:1.17.3": - version: 1.17.3 - resolution: "@opencode-ai/sdk@npm:1.17.3" - dependencies: - cross-spawn: "npm:7.0.6" - checksum: 10c0/5de73b708545623640a03bafd0618961777201c82d542570eca65fe43a485e095ce74d687042cb290fa9a8c3c480e2ed2dba7b02a951ea2f0f3c68cdc7208560 - languageName: node - linkType: hard - -"@standard-schema/spec@npm:^1.1.0": - version: 1.1.0 - resolution: "@standard-schema/spec@npm:1.1.0" - checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 - languageName: node - linkType: hard - -"@types/debug@npm:^4.0.0": - version: 4.1.12 - resolution: "@types/debug@npm:4.1.12" - dependencies: - "@types/ms": "npm:*" - checksum: 10c0/5dcd465edbb5a7f226e9a5efd1f399c6172407ef5840686b73e3608ce135eeca54ae8037dcd9f16bdb2768ac74925b820a8b9ecc588a58ca09eca6acabe33e2f - languageName: node - linkType: hard - -"@types/estree@npm:^1.0.6": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 - languageName: node - linkType: hard - -"@types/istanbul-lib-coverage@npm:^2.0.1": - version: 2.0.6 - resolution: "@types/istanbul-lib-coverage@npm:2.0.6" - checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 - languageName: node - linkType: hard - -"@types/json-schema@npm:^7.0.15": - version: 7.0.15 - resolution: "@types/json-schema@npm:7.0.15" - checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db - languageName: node - linkType: hard - -"@types/katex@npm:^0.16.0": - version: 0.16.8 - resolution: "@types/katex@npm:0.16.8" - checksum: 10c0/0661609353f4f5e62bd2dc78da99e842761c6474b19f2268b195bbe9dbf20e6f766a31155d79eec2e7c3eff4e7eba4b30f4f519e9c6a11c75bb45e257a2ddb69 - languageName: node - linkType: hard - -"@types/ms@npm:*": - version: 2.1.0 - resolution: "@types/ms@npm:2.1.0" - checksum: 10c0/5ce692ffe1549e1b827d99ef8ff71187457e0eb44adbae38fdf7b9a74bae8d20642ee963c14516db1d35fa2652e65f47680fdf679dcbde52bbfadd021f497225 - languageName: node - linkType: hard - -"@types/node@npm:25.9.2": - version: 25.9.2 - resolution: "@types/node@npm:25.9.2" - dependencies: - undici-types: "npm:>=7.24.0 <7.24.7" - checksum: 10c0/f14c0d56361febb985eccc45cf0834ee6e2f07c4389a636f3e1a55ebde320077a80bface18c9afd3092f5fa295925502c1a9d55f805efa813f634aa9c941cbac - languageName: node - linkType: hard - -"@types/unist@npm:^2.0.0": - version: 2.0.11 - resolution: "@types/unist@npm:2.0.11" - checksum: 10c0/24dcdf25a168f453bb70298145eb043cfdbb82472db0bc0b56d6d51cd2e484b9ed8271d4ac93000a80da568f2402e9339723db262d0869e2bf13bc58e081768d - languageName: node - linkType: hard - -"abbrev@npm:^5.0.0": - version: 5.0.0 - resolution: "abbrev@npm:5.0.0" - checksum: 10c0/8e88f5c798ea4562d28c5a3e9ad69e3879890bc5d695d8f2dffb8609be4c890aacc8f80ef4553fdd2c6a62d70c2ce8bc57b38074e383beb7487bdafa9ed42ea5 - languageName: node - linkType: hard - -"acorn-jsx@npm:^5.3.2": - version: 5.3.2 - resolution: "acorn-jsx@npm:5.3.2" - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 - languageName: node - linkType: hard - -"acorn@npm:^8.15.0": - version: 8.15.0 - resolution: "acorn@npm:8.15.0" - bin: - acorn: bin/acorn - checksum: 10c0/dec73ff59b7d6628a01eebaece7f2bdb8bb62b9b5926dcad0f8931f2b8b79c2be21f6c68ac095592adb5adb15831a3635d9343e6a91d028bbe85d564875ec3ec - languageName: node - linkType: hard - -"ajv@npm:^6.12.4": - version: 6.14.0 - resolution: "ajv@npm:6.14.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - fast-json-stable-stringify: "npm:^2.0.0" - json-schema-traverse: "npm:^0.4.1" - uri-js: "npm:^4.2.2" - checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 - languageName: node - linkType: hard - -"ajv@npm:^8.18.0": - version: 8.18.0 - resolution: "ajv@npm:8.18.0" - dependencies: - fast-deep-equal: "npm:^3.1.3" - fast-uri: "npm:^3.0.1" - json-schema-traverse: "npm:^1.0.0" - require-from-string: "npm:^2.0.2" - checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f - languageName: node - linkType: hard - -"ansi-regex@npm:^5.0.1": - version: 5.0.1 - resolution: "ansi-regex@npm:5.0.1" - checksum: 10c0/9a64bb8627b434ba9327b60c027742e5d17ac69277960d041898596271d992d4d52ba7267a63ca10232e29f6107fc8a835f6ce8d719b88c5f8493f8254813737 - languageName: node - linkType: hard - -"ansi-regex@npm:^6.0.1": - version: 6.2.2 - resolution: "ansi-regex@npm:6.2.2" - checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f - languageName: node - linkType: hard - -"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": - version: 4.3.0 - resolution: "ansi-styles@npm:4.3.0" - dependencies: - color-convert: "npm:^2.0.1" - checksum: 10c0/895a23929da416f2bd3de7e9cb4eabd340949328ab85ddd6e484a637d8f6820d485f53933446f5291c3b760cbc488beb8e88573dd0f9c7daf83dccc8fe81b041 - languageName: node - linkType: hard - -"argparse@npm:^2.0.1": - version: 2.0.1 - resolution: "argparse@npm:2.0.1" - checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e - languageName: node - linkType: hard - -"balanced-match@npm:^1.0.0": - version: 1.0.2 - resolution: "balanced-match@npm:1.0.2" - checksum: 10c0/9308baf0a7e4838a82bbfd11e01b1cb0f0cf2893bc1676c27c2a8c0e70cbae1c59120c3268517a8ae7fb6376b4639ef81ca22582611dbee4ed28df945134aaee - languageName: node - linkType: hard - -"balanced-match@npm:^4.0.2": - version: 4.0.4 - resolution: "balanced-match@npm:4.0.4" - checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b - languageName: node - linkType: hard - -"brace-expansion@npm:^1.1.7": - version: 1.1.14 - resolution: "brace-expansion@npm:1.1.14" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/b6fdac832bc4e36a753658c9ed052c2e1a2be221763b002df25d1efbf7d21724334e726a6cd5eadc72a4b19ec3efb632d629cc003bc9c62f7af7a7915ffa4385 - languageName: node - linkType: hard - -"brace-expansion@npm:^5.0.5": - version: 5.0.6 - resolution: "brace-expansion@npm:5.0.6" - dependencies: - balanced-match: "npm:^4.0.2" - checksum: 10c0/8c919869b90f61d533b341d3340be5ee4413232ea89b8246cbc2f38eb014f1d8182785c98a006eaf6111d02dc9eeffefdc240d5ac158625b2ed084dccd4bbf9b - languageName: node - linkType: hard - -"c8@npm:^11.0.0": - version: 11.0.0 - resolution: "c8@npm:11.0.0" - dependencies: - "@bcoe/v8-coverage": "npm:^1.0.1" - "@istanbuljs/schema": "npm:^0.1.3" - find-up: "npm:^5.0.0" - foreground-child: "npm:^3.1.1" - istanbul-lib-coverage: "npm:^3.2.0" - istanbul-lib-report: "npm:^3.0.1" - istanbul-reports: "npm:^3.1.6" - test-exclude: "npm:^8.0.0" - v8-to-istanbul: "npm:^9.0.0" - yargs: "npm:^17.7.2" - yargs-parser: "npm:^21.1.1" - peerDependencies: - monocart-coverage-reports: ^2 - peerDependenciesMeta: - monocart-coverage-reports: - optional: true - bin: - c8: bin/c8.js - checksum: 10c0/94b0cf8756715ca8fedb9331c61ebda0c5bbd63c5eeea523d18904af790f6f197a02f547c066fa2d8d0544bb9f9547a6a67d653f3575953139c74ca915771963 - languageName: node - linkType: hard - -"callsites@npm:^3.0.0": - version: 3.1.0 - resolution: "callsites@npm:3.1.0" - checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 - languageName: node - linkType: hard - -"chalk@npm:^4.0.0": - version: 4.1.2 - resolution: "chalk@npm:4.1.2" - dependencies: - ansi-styles: "npm:^4.1.0" - supports-color: "npm:^7.1.0" - checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 - languageName: node - linkType: hard - -"character-entities-legacy@npm:^3.0.0": - version: 3.0.0 - resolution: "character-entities-legacy@npm:3.0.0" - checksum: 10c0/ec4b430af873661aa754a896a2b55af089b4e938d3d010fad5219299a6b6d32ab175142699ee250640678cd64bdecd6db3c9af0b8759ab7b155d970d84c4c7d1 - languageName: node - linkType: hard - -"character-entities@npm:^2.0.0": - version: 2.0.2 - resolution: "character-entities@npm:2.0.2" - checksum: 10c0/b0c645a45bcc90ff24f0e0140f4875a8436b8ef13b6bcd31ec02cfb2ca502b680362aa95386f7815bdc04b6464d48cf191210b3840d7c04241a149ede591a308 - languageName: node - linkType: hard - -"character-reference-invalid@npm:^2.0.0": - version: 2.0.1 - resolution: "character-reference-invalid@npm:2.0.1" - checksum: 10c0/2ae0dec770cd8659d7e8b0ce24392d83b4c2f0eb4a3395c955dce5528edd4cc030a794cfa06600fcdd700b3f2de2f9b8e40e309c0011c4180e3be64a0b42e6a1 - languageName: node - linkType: hard - -"chownr@npm:^3.0.0": - version: 3.0.0 - resolution: "chownr@npm:3.0.0" - checksum: 10c0/43925b87700f7e3893296c8e9c56cc58f926411cce3a6e5898136daaf08f08b9a8eb76d37d3267e707d0dcc17aed2e2ebdf5848c0c3ce95cf910a919935c1b10 - languageName: node - linkType: hard - -"cliui@npm:^8.0.1": - version: 8.0.1 - resolution: "cliui@npm:8.0.1" - dependencies: - string-width: "npm:^4.2.0" - strip-ansi: "npm:^6.0.1" - wrap-ansi: "npm:^7.0.0" - checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 - languageName: node - linkType: hard - -"color-convert@npm:^2.0.1": - version: 2.0.1 - resolution: "color-convert@npm:2.0.1" - dependencies: - color-name: "npm:~1.1.4" - checksum: 10c0/37e1150172f2e311fe1b2df62c6293a342ee7380da7b9cfdba67ea539909afbd74da27033208d01d6d5cfc65ee7868a22e18d7e7648e004425441c0f8a15a7d7 - languageName: node - linkType: hard - -"color-name@npm:~1.1.4": - version: 1.1.4 - resolution: "color-name@npm:1.1.4" - checksum: 10c0/a1a3f914156960902f46f7f56bc62effc6c94e84b2cae157a526b1c1f74b677a47ec602bf68a61abfa2b42d15b7c5651c6dbe72a43af720bc588dff885b10f95 - languageName: node - linkType: hard - -"commander@npm:^8.3.0": - version: 8.3.0 - resolution: "commander@npm:8.3.0" - checksum: 10c0/8b043bb8322ea1c39664a1598a95e0495bfe4ca2fad0d84a92d7d1d8d213e2a155b441d2470c8e08de7c4a28cf2bc6e169211c49e1b21d9f7edc6ae4d9356060 - languageName: node - linkType: hard - -"commander@npm:~14.0.3": - version: 14.0.3 - resolution: "commander@npm:14.0.3" - checksum: 10c0/755652564bbf56ff2ff083313912b326450d3f8d8c85f4b71416539c9a05c3c67dbd206821ca72635bf6b160e2afdefcb458e86b317827d5cb333b69ce7f1a24 - languageName: node - linkType: hard - -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - -"convert-source-map@npm:^2.0.0": - version: 2.0.0 - resolution: "convert-source-map@npm:2.0.0" - checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b - languageName: node - linkType: hard - -"cross-spawn@npm:7.0.6, cross-spawn@npm:^7.0.6": - version: 7.0.6 - resolution: "cross-spawn@npm:7.0.6" - dependencies: - path-key: "npm:^3.1.0" - shebang-command: "npm:^2.0.0" - which: "npm:^2.0.1" - checksum: 10c0/053ea8b2135caff68a9e81470e845613e374e7309a47731e81639de3eaeb90c3d01af0e0b44d2ab9d50b43467223b88567dfeb3262db942dc063b9976718ffc1 - languageName: node - linkType: hard - -"debug@npm:^4.0.0, debug@npm:^4.3.1, debug@npm:^4.3.2": - version: 4.4.3 - resolution: "debug@npm:4.4.3" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 - languageName: node - linkType: hard - -"decode-named-character-reference@npm:^1.0.0": - version: 1.3.0 - resolution: "decode-named-character-reference@npm:1.3.0" - dependencies: - character-entities: "npm:^2.0.0" - checksum: 10c0/787f4c87f3b82ea342aa7c2d7b1882b6fb9511bb77f72ae44dcaabea0470bacd1e9c6a0080ab886545019fa0cb3a7109573fad6b61a362844c3a0ac52b36e4bb - languageName: node - linkType: hard - -"deep-extend@npm:^0.6.0, deep-extend@npm:~0.6.0": - version: 0.6.0 - resolution: "deep-extend@npm:0.6.0" - checksum: 10c0/1c6b0abcdb901e13a44c7d699116d3d4279fdb261983122a3783e7273844d5f2537dc2e1c454a23fcf645917f93fbf8d07101c1d03c015a87faa662755212566 - languageName: node - linkType: hard - -"deep-is@npm:^0.1.3": - version: 0.1.4 - resolution: "deep-is@npm:0.1.4" - checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c - languageName: node - linkType: hard - -"dequal@npm:^2.0.0": - version: 2.0.3 - resolution: "dequal@npm:2.0.3" - checksum: 10c0/f98860cdf58b64991ae10205137c0e97d384c3a4edc7f807603887b7c4b850af1224a33d88012009f150861cbee4fa2d322c4cc04b9313bee312e47f6ecaa888 - languageName: node - linkType: hard - -"detect-libc@npm:^2.0.1": - version: 2.1.2 - resolution: "detect-libc@npm:2.1.2" - checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 - languageName: node - linkType: hard - -"devlop@npm:^1.0.0": - version: 1.1.0 - resolution: "devlop@npm:1.1.0" - dependencies: - dequal: "npm:^2.0.0" - checksum: 10c0/e0928ab8f94c59417a2b8389c45c55ce0a02d9ac7fd74ef62d01ba48060129e1d594501b77de01f3eeafc7cb00773819b0df74d96251cf20b31c5b3071f45c0e - languageName: node - linkType: hard - -"ecc-universal@workspace:.": - version: 0.0.0-use.local - resolution: "ecc-universal@workspace:." - dependencies: - "@eslint/js": "npm:^9.39.2" - "@iarna/toml": "npm:^2.2.5" - "@opencode-ai/plugin": "npm:^1.16.2" - "@types/node": "npm:25.9.2" - ajv: "npm:^8.18.0" - c8: "npm:^11.0.0" - eslint: "npm:^9.39.2" - globals: "npm:^17.4.0" - markdownlint-cli: "npm:^0.48.0" - sql.js: "npm:^1.14.1" - typescript: "npm:^6.0.3" - bin: - ecc: scripts/ecc.js - ecc-control-pane: scripts/control-pane.js - ecc-install: scripts/install-apply.js - languageName: unknown - linkType: soft - -"effect@npm:4.0.0-beta.74": - version: 4.0.0-beta.74 - resolution: "effect@npm:4.0.0-beta.74" - dependencies: - "@standard-schema/spec": "npm:^1.1.0" - fast-check: "npm:^4.8.0" - find-my-way-ts: "npm:^0.1.6" - ini: "npm:^7.0.0" - kubernetes-types: "npm:^1.30.0" - msgpackr: "npm:^2.0.1" - multipasta: "npm:^0.2.7" - toml: "npm:^4.1.1" - uuid: "npm:^14.0.0" - yaml: "npm:^2.9.0" - checksum: 10c0/3dfc7ce7b58bbe9e8459ea9eba0abdcef7d9e7082643c64f4cc5165632777aa163f20d7b5f86372f819e6b60154e44cea125abb71be01da667a330c10a9b5892 - languageName: node - linkType: hard - -"emoji-regex@npm:^8.0.0": - version: 8.0.0 - resolution: "emoji-regex@npm:8.0.0" - checksum: 10c0/b6053ad39951c4cf338f9092d7bfba448cdfd46fe6a2a034700b149ac9ffbc137e361cbd3c442297f86bed2e5f7576c1b54cc0a6bf8ef5106cc62f496af35010 - languageName: node - linkType: hard - -"entities@npm:^4.4.0": - version: 4.5.0 - resolution: "entities@npm:4.5.0" - checksum: 10c0/5b039739f7621f5d1ad996715e53d964035f75ad3b9a4d38c6b3804bb226e282ffeae2443624d8fdd9c47d8e926ae9ac009c54671243f0c3294c26af7cc85250 - languageName: node - linkType: hard - -"env-paths@npm:^2.2.0": - version: 2.2.1 - resolution: "env-paths@npm:2.2.1" - checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 - languageName: node - linkType: hard - -"escalade@npm:^3.1.1": - version: 3.2.0 - resolution: "escalade@npm:3.2.0" - checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 - languageName: node - linkType: hard - -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 - languageName: node - linkType: hard - -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10c0/407f6c600204d0f3705bd557f81bd0189e69cd7996f408f8971ab5779c0af733d1af2f1412066b40ee1588b085874fc37a2333986c6521669cdbdd36ca5058e0 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^3.4.3": - version: 3.4.3 - resolution: "eslint-visitor-keys@npm:3.4.3" - checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 - languageName: node - linkType: hard - -"eslint@npm:^9.39.2": - version: 9.39.2 - resolution: "eslint@npm:9.39.2" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.8.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.1" - "@eslint/config-helpers": "npm:^0.4.2" - "@eslint/core": "npm:^0.17.0" - "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.39.2" - "@eslint/plugin-kit": "npm:^0.4.1" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.2" - "@types/estree": "npm:^1.0.6" - ajv: "npm:^6.12.4" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.4.0" - eslint-visitor-keys: "npm:^4.2.1" - espree: "npm:^10.4.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.2" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10c0/bb88ca8fd16bb7e1ac3e13804c54d41c583214460c0faa7b3e7c574e69c5600c7122295500fb4b0c06067831111db740931e98da1340329527658e1cf80073d3 - languageName: node - linkType: hard - -"espree@npm:^10.0.1, espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" - dependencies: - acorn: "npm:^8.15.0" - acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b - languageName: node - linkType: hard - -"esquery@npm:^1.5.0": - version: 1.7.0 - resolution: "esquery@npm:1.7.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 - languageName: node - linkType: hard - -"esrecurse@npm:^4.3.0": - version: 4.3.0 - resolution: "esrecurse@npm:4.3.0" - dependencies: - estraverse: "npm:^5.2.0" - checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 - languageName: node - linkType: hard - -"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": - version: 5.3.0 - resolution: "estraverse@npm:5.3.0" - checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 - languageName: node - linkType: hard - -"esutils@npm:^2.0.2": - version: 2.0.3 - resolution: "esutils@npm:2.0.3" - checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 - languageName: node - linkType: hard - -"exponential-backoff@npm:^3.1.1": - version: 3.1.3 - resolution: "exponential-backoff@npm:3.1.3" - checksum: 10c0/77e3ae682b7b1f4972f563c6dbcd2b0d54ac679e62d5d32f3e5085feba20483cf28bd505543f520e287a56d4d55a28d7874299941faf637e779a1aa5994d1267 - languageName: node - linkType: hard - -"fast-check@npm:^4.8.0": - version: 4.8.0 - resolution: "fast-check@npm:4.8.0" - dependencies: - pure-rand: "npm:^8.0.0" - checksum: 10c0/f72556a29db4ff386a8b6e50d420b06c7e5eaafff7db5560a99136c57d8d4777998155eb02d1bbeff396f575cc0b1442c8a1c4ddb798c4a919b542de1a1904ff - languageName: node - linkType: hard - -"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": - version: 3.1.3 - resolution: "fast-deep-equal@npm:3.1.3" - checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 - languageName: node - linkType: hard - -"fast-json-stable-stringify@npm:^2.0.0": - version: 2.1.0 - resolution: "fast-json-stable-stringify@npm:2.1.0" - checksum: 10c0/7f081eb0b8a64e0057b3bb03f974b3ef00135fbf36c1c710895cd9300f13c94ba809bb3a81cf4e1b03f6e5285610a61abbd7602d0652de423144dfee5a389c9b - languageName: node - linkType: hard - -"fast-levenshtein@npm:^2.0.6": - version: 2.0.6 - resolution: "fast-levenshtein@npm:2.0.6" - checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 - languageName: node - linkType: hard - -"fast-uri@npm:^3.0.1": - version: 3.1.2 - resolution: "fast-uri@npm:3.1.2" - checksum: 10c0/5b35641895959f3f7ab7a7b1b5542bded159346f25ec9f256817b206d50b64eda5828e90d605a2e2fc645c90519a7259c2bab2c942ee728c88b88e5be21b090d - languageName: node - linkType: hard - -"fdir@npm:^6.5.0": - version: 6.5.0 - resolution: "fdir@npm:6.5.0" - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f - languageName: node - linkType: hard - -"file-entry-cache@npm:^8.0.0": - version: 8.0.0 - resolution: "file-entry-cache@npm:8.0.0" - dependencies: - flat-cache: "npm:^4.0.0" - checksum: 10c0/9e2b5938b1cd9b6d7e3612bdc533afd4ac17b2fc646569e9a8abbf2eb48e5eb8e316bc38815a3ef6a1b456f4107f0d0f055a614ca613e75db6bf9ff4d72c1638 - languageName: node - linkType: hard - -"find-my-way-ts@npm:^0.1.6": - version: 0.1.6 - resolution: "find-my-way-ts@npm:0.1.6" - checksum: 10c0/16ad4b15275b56ee0ec361d0c61afbdff4c75bd0ac04112f6910f188cb1058096ba63529c2363914da6bb60266aa4def1025af04af26368ff87eb0df52f2862f - languageName: node - linkType: hard - -"find-up@npm:^5.0.0": - version: 5.0.0 - resolution: "find-up@npm:5.0.0" - dependencies: - locate-path: "npm:^6.0.0" - path-exists: "npm:^4.0.0" - checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a - languageName: node - linkType: hard - -"flat-cache@npm:^4.0.0": - version: 4.0.1 - resolution: "flat-cache@npm:4.0.1" - dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.4" - checksum: 10c0/2c59d93e9faa2523e4fda6b4ada749bed432cfa28c8e251f33b25795e426a1c6dbada777afb1f74fcfff33934fdbdea921ee738fcc33e71adc9d6eca984a1cfc - languageName: node - linkType: hard - -"flatted@npm:^3.2.9": - version: 3.4.2 - resolution: "flatted@npm:3.4.2" - checksum: 10c0/a65b67aae7172d6cdf63691be7de6c5cd5adbdfdfe2e9da1a09b617c9512ed794037741ee53d93114276bff3f93cd3b0d97d54f9b316e1e4885dde6e9ffdf7ed - languageName: node - linkType: hard - -"foreground-child@npm:^3.1.1": - version: 3.3.1 - resolution: "foreground-child@npm:3.3.1" - dependencies: - cross-spawn: "npm:^7.0.6" - signal-exit: "npm:^4.0.1" - checksum: 10c0/8986e4af2430896e65bc2788d6679067294d6aee9545daefc84923a0a4b399ad9c7a3ea7bd8c0b2b80fdf4a92de4c69df3f628233ff3224260e9c1541a9e9ed3 - languageName: node - linkType: hard - -"get-caller-file@npm:^2.0.5": - version: 2.0.5 - resolution: "get-caller-file@npm:2.0.5" - checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde - languageName: node - linkType: hard - -"get-east-asian-width@npm:^1.3.0": - version: 1.4.0 - resolution: "get-east-asian-width@npm:1.4.0" - checksum: 10c0/4e481d418e5a32061c36fbb90d1b225a254cc5b2df5f0b25da215dcd335a3c111f0c2023ffda43140727a9cafb62dac41d022da82c08f31083ee89f714ee3b83 - languageName: node - linkType: hard - -"glob-parent@npm:^6.0.2": - version: 6.0.2 - resolution: "glob-parent@npm:6.0.2" - dependencies: - is-glob: "npm:^4.0.3" - checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 - languageName: node - linkType: hard - -"glob@npm:^13.0.6": - version: 13.0.6 - resolution: "glob@npm:13.0.6" - dependencies: - minimatch: "npm:^10.2.2" - minipass: "npm:^7.1.3" - path-scurry: "npm:^2.0.2" - checksum: 10c0/269c236f11a9b50357fe7a8c6aadac667e01deb5242b19c84975628f05f4438d8ee1354bb62c5d6c10f37fd59911b54d7799730633a2786660d8c69f1d18120a - languageName: node - linkType: hard - -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d - languageName: node - linkType: hard - -"globals@npm:^17.4.0": - version: 17.4.0 - resolution: "globals@npm:17.4.0" - checksum: 10c0/2be9e8c2b9035836f13d420b22f0247a328db82967d3bebfc01126d888ed609305f06c05895914e969653af5c6ba35fd7a0920f3e6c869afa60666c810630feb - languageName: node - linkType: hard - -"graceful-fs@npm:^4.2.6": - version: 4.2.11 - resolution: "graceful-fs@npm:4.2.11" - checksum: 10c0/386d011a553e02bc594ac2ca0bd6d9e4c22d7fa8cfbfc448a6d148c59ea881b092db9dbe3547ae4b88e55f1b01f7c4a2ecc53b310c042793e63aa44cf6c257f2 - languageName: node - linkType: hard - -"has-flag@npm:^4.0.0": - version: 4.0.0 - resolution: "has-flag@npm:4.0.0" - checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1 - languageName: node - linkType: hard - -"html-escaper@npm:^2.0.0": - version: 2.0.2 - resolution: "html-escaper@npm:2.0.2" - checksum: 10c0/208e8a12de1a6569edbb14544f4567e6ce8ecc30b9394fcaa4e7bb1e60c12a7c9a1ed27e31290817157e8626f3a4f29e76c8747030822eb84a6abb15c255f0a0 - languageName: node - linkType: hard - -"ignore@npm:^5.2.0": - version: 5.3.2 - resolution: "ignore@npm:5.3.2" - checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 - languageName: node - linkType: hard - -"ignore@npm:~7.0.5": - version: 7.0.5 - resolution: "ignore@npm:7.0.5" - checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d - languageName: node - linkType: hard - -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec - languageName: node - linkType: hard - -"imurmurhash@npm:^0.1.4": - version: 0.1.4 - resolution: "imurmurhash@npm:0.1.4" - checksum: 10c0/8b51313850dd33605c6c9d3fd9638b714f4c4c40250cff658209f30d40da60f78992fb2df5dabee4acf589a6a82bbc79ad5486550754bd9ec4e3fc0d4a57d6a6 - languageName: node - linkType: hard - -"ini@npm:^7.0.0": - version: 7.0.0 - resolution: "ini@npm:7.0.0" - checksum: 10c0/7520cae38bd5587e1cbca4637bb5fc0e157f38e0816522756456010ef703772d0018f9f4987cb9977bf3b10c1feccaad7800744dd1f5d85fb435e58a1baa9754 - languageName: node - linkType: hard - -"ini@npm:~4.1.0": - version: 4.1.3 - resolution: "ini@npm:4.1.3" - checksum: 10c0/0d27eff094d5f3899dd7c00d0c04ea733ca03a8eb6f9406ce15daac1a81de022cb417d6eaff7e4342451ffa663389c565ffc68d6825eaf686bf003280b945764 - languageName: node - linkType: hard - -"is-alphabetical@npm:^2.0.0": - version: 2.0.1 - resolution: "is-alphabetical@npm:2.0.1" - checksum: 10c0/932367456f17237533fd1fc9fe179df77957271020b83ea31da50e5cc472d35ef6b5fb8147453274ffd251134472ce24eb6f8d8398d96dee98237cdb81a6c9a7 - languageName: node - linkType: hard - -"is-alphanumerical@npm:^2.0.0": - version: 2.0.1 - resolution: "is-alphanumerical@npm:2.0.1" - dependencies: - is-alphabetical: "npm:^2.0.0" - is-decimal: "npm:^2.0.0" - checksum: 10c0/4b35c42b18e40d41378293f82a3ecd9de77049b476f748db5697c297f686e1e05b072a6aaae2d16f54d2a57f85b00cbbe755c75f6d583d1c77d6657bd0feb5a2 - languageName: node - linkType: hard - -"is-decimal@npm:^2.0.0": - version: 2.0.1 - resolution: "is-decimal@npm:2.0.1" - checksum: 10c0/8085dd66f7d82f9de818fba48b9e9c0429cb4291824e6c5f2622e96b9680b54a07a624cfc663b24148b8e853c62a1c987cfe8b0b5a13f5156991afaf6736e334 - languageName: node - linkType: hard - -"is-extglob@npm:^2.1.1": - version: 2.1.1 - resolution: "is-extglob@npm:2.1.1" - checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912 - languageName: node - linkType: hard - -"is-fullwidth-code-point@npm:^3.0.0": - version: 3.0.0 - resolution: "is-fullwidth-code-point@npm:3.0.0" - checksum: 10c0/bb11d825e049f38e04c06373a8d72782eee0205bda9d908cc550ccb3c59b99d750ff9537982e01733c1c94a58e35400661f57042158ff5e8f3e90cf936daf0fc - languageName: node - linkType: hard - -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3": - version: 4.0.3 - resolution: "is-glob@npm:4.0.3" - dependencies: - is-extglob: "npm:^2.1.1" - checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a - languageName: node - linkType: hard - -"is-hexadecimal@npm:^2.0.0": - version: 2.0.1 - resolution: "is-hexadecimal@npm:2.0.1" - checksum: 10c0/3eb60fe2f1e2bbc760b927dcad4d51eaa0c60138cf7fc671803f66353ad90c301605b502c7ea4c6bb0548e1c7e79dfd37b73b632652e3b76030bba603a7e9626 - languageName: node - linkType: hard - -"isexe@npm:^2.0.0": - version: 2.0.0 - resolution: "isexe@npm:2.0.0" - checksum: 10c0/228cfa503fadc2c31596ab06ed6aa82c9976eec2bfd83397e7eaf06d0ccf42cd1dfd6743bf9aeb01aebd4156d009994c5f76ea898d2832c1fe342da923ca457d - languageName: node - linkType: hard - -"isexe@npm:^4.0.0": - version: 4.0.0 - resolution: "isexe@npm:4.0.0" - checksum: 10c0/5884815115bceac452877659a9c7726382531592f43dc29e5d48b7c4100661aed54018cb90bd36cb2eaeba521092570769167acbb95c18d39afdccbcca06c5ce - languageName: node - linkType: hard - -"istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.0": - version: 3.2.2 - resolution: "istanbul-lib-coverage@npm:3.2.2" - checksum: 10c0/6c7ff2106769e5f592ded1fb418f9f73b4411fd5a084387a5410538332b6567cd1763ff6b6cadca9b9eb2c443cce2f7ea7d7f1b8d315f9ce58539793b1e0922b - languageName: node - linkType: hard - -"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": - version: 3.0.1 - resolution: "istanbul-lib-report@npm:3.0.1" - dependencies: - istanbul-lib-coverage: "npm:^3.0.0" - make-dir: "npm:^4.0.0" - supports-color: "npm:^7.1.0" - checksum: 10c0/84323afb14392de8b6a5714bd7e9af845cfbd56cfe71ed276cda2f5f1201aea673c7111901227ee33e68e4364e288d73861eb2ed48f6679d1e69a43b6d9b3ba7 - languageName: node - linkType: hard - -"istanbul-reports@npm:^3.1.6": - version: 3.2.0 - resolution: "istanbul-reports@npm:3.2.0" - dependencies: - html-escaper: "npm:^2.0.0" - istanbul-lib-report: "npm:^3.0.0" - checksum: 10c0/d596317cfd9c22e1394f22a8d8ba0303d2074fe2e971887b32d870e4b33f8464b10f8ccbe6847808f7db485f084eba09e6c2ed706b3a978e4b52f07085b8f9bc - languageName: node - linkType: hard - -"js-yaml@npm:>=4.2.0": - version: 4.2.0 - resolution: "js-yaml@npm:4.2.0" - dependencies: - argparse: "npm:^2.0.1" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/1916456c118746603b067d74bbcbb0445d9a1d5e474ad4ae775e7b20525bed902e01d9d97dd0c81fcd8d4f596162309d0eb057f4aa38f3e9647f14075e9dea45 - languageName: node - linkType: hard - -"json-buffer@npm:3.0.1": - version: 3.0.1 - resolution: "json-buffer@npm:3.0.1" - checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 - languageName: node - linkType: hard - -"json-schema-traverse@npm:^0.4.1": - version: 0.4.1 - resolution: "json-schema-traverse@npm:0.4.1" - checksum: 10c0/108fa90d4cc6f08243aedc6da16c408daf81793bf903e9fd5ab21983cda433d5d2da49e40711da016289465ec2e62e0324dcdfbc06275a607fe3233fde4942ce - languageName: node - linkType: hard - -"json-schema-traverse@npm:^1.0.0": - version: 1.0.0 - resolution: "json-schema-traverse@npm:1.0.0" - checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 - languageName: node - linkType: hard - -"json-stable-stringify-without-jsonify@npm:^1.0.1": - version: 1.0.1 - resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" - checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 - languageName: node - linkType: hard - -"jsonc-parser@npm:~3.3.1": - version: 3.3.1 - resolution: "jsonc-parser@npm:3.3.1" - checksum: 10c0/269c3ae0a0e4f907a914bf334306c384aabb9929bd8c99f909275ebd5c2d3bc70b9bcd119ad794f339dec9f24b6a4ee9cd5a8ab2e6435e730ad4075388fc2ab6 - languageName: node - linkType: hard - -"jsonpointer@npm:~5.0.1": - version: 5.0.1 - resolution: "jsonpointer@npm:5.0.1" - checksum: 10c0/89929e58b400fcb96928c0504fcf4fc3f919d81e9543ceb055df125538470ee25290bb4984251e172e6ef8fcc55761eb998c118da763a82051ad89d4cb073fe7 - languageName: node - linkType: hard - -"katex@npm:^0.16.0": - version: 0.16.28 - resolution: "katex@npm:0.16.28" - dependencies: - commander: "npm:^8.3.0" - bin: - katex: cli.js - checksum: 10c0/9c6e100ecb10c8e8315ab1d6ae16642b91e05642d821158149be520d629c3b47f30d8475fa8978d2d765a1d8e1bd66ab6afffe3a0409265de520edccab346b3e - languageName: node - linkType: hard - -"keyv@npm:^4.5.4": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e - languageName: node - linkType: hard - -"kubernetes-types@npm:^1.30.0": - version: 1.30.0 - resolution: "kubernetes-types@npm:1.30.0" - checksum: 10c0/de3641e4f50cfc123c4102a73c12932e1db8e51783c7cae4ea8ad3561bd56fab0f1c2346801f84a4c36aae8cea0b25d21e9514cc0fcecd4d64b1314043263076 - languageName: node - linkType: hard - -"levn@npm:^0.4.1": - version: 0.4.1 - resolution: "levn@npm:0.4.1" - dependencies: - prelude-ls: "npm:^1.2.1" - type-check: "npm:~0.4.0" - checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e - languageName: node - linkType: hard - -"linkify-it@npm:^5.0.1": - version: 5.0.1 - resolution: "linkify-it@npm:5.0.1" - dependencies: - uc.micro: "npm:^2.0.0" - checksum: 10c0/d06d04f1ed03be131740fc900a5e74ea1f49886b052213599e306d469d5ffe2303db76dd8f771de9f28e2b0b38852de22ec46ae597d245f8b66439b0ceb19b10 - languageName: node - linkType: hard - -"locate-path@npm:^6.0.0": - version: 6.0.0 - resolution: "locate-path@npm:6.0.0" - dependencies: - p-locate: "npm:^5.0.0" - checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 - languageName: node - linkType: hard - -"lodash.merge@npm:^4.6.2": - version: 4.6.2 - resolution: "lodash.merge@npm:4.6.2" - checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 - languageName: node - linkType: hard - -"lru-cache@npm:^11.0.0": - version: 11.2.7 - resolution: "lru-cache@npm:11.2.7" - checksum: 10c0/549cdb59488baa617135fc12159cafb1a97f91079f35093bb3bcad72e849fc64ace636d244212c181dfdf1a99bbfa90757ff303f98561958ee4d0f885d9bd5f7 - languageName: node - linkType: hard - -"make-dir@npm:^4.0.0": - version: 4.0.0 - resolution: "make-dir@npm:4.0.0" - dependencies: - semver: "npm:^7.5.3" - checksum: 10c0/69b98a6c0b8e5c4fe9acb61608a9fbcfca1756d910f51e5dbe7a9e5cfb74fca9b8a0c8a0ffdf1294a740826c1ab4871d5bf3f62f72a3049e5eac6541ddffed68 - languageName: node - linkType: hard - -"markdown-it@npm:>=14.2.0": - version: 14.2.0 - resolution: "markdown-it@npm:14.2.0" - dependencies: - argparse: "npm:^2.0.1" - entities: "npm:^4.4.0" - linkify-it: "npm:^5.0.1" - mdurl: "npm:^2.0.0" - punycode.js: "npm:^2.3.1" - uc.micro: "npm:^2.1.0" - bin: - markdown-it: bin/markdown-it.mjs - checksum: 10c0/1d3a50061d2fe4efbcf317aac853dbee6892ed6f5a217570eead723f2ef2dd1c9baaeef5a687cd283480c45c2d20724a73e84a9ed72843cf7b3b719067af40ef - languageName: node - linkType: hard - -"markdownlint-cli@npm:^0.48.0": - version: 0.48.0 - resolution: "markdownlint-cli@npm:0.48.0" - dependencies: - commander: "npm:~14.0.3" - deep-extend: "npm:~0.6.0" - ignore: "npm:~7.0.5" - js-yaml: "npm:~4.1.1" - jsonc-parser: "npm:~3.3.1" - jsonpointer: "npm:~5.0.1" - markdown-it: "npm:~14.1.1" - markdownlint: "npm:~0.40.0" - minimatch: "npm:~10.2.4" - run-con: "npm:~1.3.2" - smol-toml: "npm:~1.6.0" - tinyglobby: "npm:~0.2.15" - bin: - markdownlint: markdownlint.js - checksum: 10c0/dc4da23adeb3a5b466bdce1be8aad58daf9b1be5be7de082d1ca22a6842e85000327ac592df038a9c89ef397bedb0ffd5c6c345fc245f9017572a24db25fac20 - languageName: node - linkType: hard - -"markdownlint@npm:~0.40.0": - version: 0.40.0 - resolution: "markdownlint@npm:0.40.0" - dependencies: - micromark: "npm:4.0.2" - micromark-core-commonmark: "npm:2.0.3" - micromark-extension-directive: "npm:4.0.0" - micromark-extension-gfm-autolink-literal: "npm:2.1.0" - micromark-extension-gfm-footnote: "npm:2.1.0" - micromark-extension-gfm-table: "npm:2.1.1" - micromark-extension-math: "npm:3.1.0" - micromark-util-types: "npm:2.0.2" - string-width: "npm:8.1.0" - checksum: 10c0/1543fcf4a433bc54e0e565cb1c8111e5e3d0df3742df0cc840d470bced21a1e3b5593e4e380ad0d8d5e490d9b399699d48aeabed33719f3fbdc6d00128138f20 - languageName: node - linkType: hard - -"mdurl@npm:^2.0.0": - version: 2.0.0 - resolution: "mdurl@npm:2.0.0" - checksum: 10c0/633db522272f75ce4788440669137c77540d74a83e9015666a9557a152c02e245b192edc20bc90ae953bbab727503994a53b236b4d9c99bdaee594d0e7dd2ce0 - languageName: node - linkType: hard - -"micromark-core-commonmark@npm:2.0.3, micromark-core-commonmark@npm:^2.0.0": - version: 2.0.3 - resolution: "micromark-core-commonmark@npm:2.0.3" - dependencies: - decode-named-character-reference: "npm:^1.0.0" - devlop: "npm:^1.0.0" - micromark-factory-destination: "npm:^2.0.0" - micromark-factory-label: "npm:^2.0.0" - micromark-factory-space: "npm:^2.0.0" - micromark-factory-title: "npm:^2.0.0" - micromark-factory-whitespace: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-chunked: "npm:^2.0.0" - micromark-util-classify-character: "npm:^2.0.0" - micromark-util-html-tag-name: "npm:^2.0.0" - micromark-util-normalize-identifier: "npm:^2.0.0" - micromark-util-resolve-all: "npm:^2.0.0" - micromark-util-subtokenize: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/bd4a794fdc9e88dbdf59eaf1c507ddf26e5f7ddf4e52566c72239c0f1b66adbcd219ba2cd42350debbe24471434d5f5e50099d2b3f4e5762ca222ba8e5b549ee - languageName: node - linkType: hard - -"micromark-extension-directive@npm:4.0.0": - version: 4.0.0 - resolution: "micromark-extension-directive@npm:4.0.0" - dependencies: - devlop: "npm:^1.0.0" - micromark-factory-space: "npm:^2.0.0" - micromark-factory-whitespace: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - parse-entities: "npm:^4.0.0" - checksum: 10c0/b4aef0f44339543466ae186130a4514985837b6b12d0c155bd1162e740f631e58f0883a39d0c723206fa0ff53a9b579965c79116f902236f6f123c3340b5fefb - languageName: node - linkType: hard - -"micromark-extension-gfm-autolink-literal@npm:2.1.0": - version: 2.1.0 - resolution: "micromark-extension-gfm-autolink-literal@npm:2.1.0" - dependencies: - micromark-util-character: "npm:^2.0.0" - micromark-util-sanitize-uri: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/84e6fbb84ea7c161dfa179665dc90d51116de4c28f3e958260c0423e5a745372b7dcbc87d3cde98213b532e6812f847eef5ae561c9397d7f7da1e59872ef3efe - languageName: node - linkType: hard - -"micromark-extension-gfm-footnote@npm:2.1.0": - version: 2.1.0 - resolution: "micromark-extension-gfm-footnote@npm:2.1.0" - dependencies: - devlop: "npm:^1.0.0" - micromark-core-commonmark: "npm:^2.0.0" - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-normalize-identifier: "npm:^2.0.0" - micromark-util-sanitize-uri: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/d172e4218968b7371b9321af5cde8c77423f73b233b2b0fcf3ff6fd6f61d2e0d52c49123a9b7910612478bf1f0d5e88c75a3990dd68f70f3933fe812b9f77edc - languageName: node - linkType: hard - -"micromark-extension-gfm-table@npm:2.1.1": - version: 2.1.1 - resolution: "micromark-extension-gfm-table@npm:2.1.1" - dependencies: - devlop: "npm:^1.0.0" - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/04bc00e19b435fa0add62cd029d8b7eb6137522f77832186b1d5ef34544a9bd030c9cf85e92ddfcc5c31f6f0a58a43d4b96dba4fc21316037c734630ee12c912 - languageName: node - linkType: hard - -"micromark-extension-math@npm:3.1.0": - version: 3.1.0 - resolution: "micromark-extension-math@npm:3.1.0" - dependencies: - "@types/katex": "npm:^0.16.0" - devlop: "npm:^1.0.0" - katex: "npm:^0.16.0" - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/56e6f2185a4613f9d47e7e98cf8605851c990957d9229c942b005e286c8087b61dc9149448d38b2f8be6d42cc6a64aad7e1f2778ddd86fbbb1a2f48a3ca1872f - languageName: node - linkType: hard - -"micromark-factory-destination@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-factory-destination@npm:2.0.1" - dependencies: - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/bbafcf869cee5bf511161354cb87d61c142592fbecea051000ff116068dc85216e6d48519d147890b9ea5d7e2864a6341c0c09d9948c203bff624a80a476023c - languageName: node - linkType: hard - -"micromark-factory-label@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-factory-label@npm:2.0.1" - dependencies: - devlop: "npm:^1.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/0137716b4ecb428114165505e94a2f18855c8bbea21b07a8b5ce514b32a595ed789d2b967125718fc44c4197ceaa48f6609d58807a68e778138d2e6b91b824e8 - languageName: node - linkType: hard - -"micromark-factory-space@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-factory-space@npm:2.0.1" - dependencies: - micromark-util-character: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/f9ed43f1c0652d8d898de0ac2be3f77f776fffe7dd96bdbba1e02d7ce33d3853c6ff5daa52568fc4fa32cdf3a62d86b85ead9b9189f7211e1d69ff2163c450fb - languageName: node - linkType: hard - -"micromark-factory-title@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-factory-title@npm:2.0.1" - dependencies: - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/e72fad8d6e88823514916890099a5af20b6a9178ccf78e7e5e05f4de99bb8797acb756257d7a3a57a53854cb0086bf8aab15b1a9e9db8982500dd2c9ff5948b6 - languageName: node - linkType: hard - -"micromark-factory-whitespace@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-factory-whitespace@npm:2.0.1" - dependencies: - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/20a1ec58698f24b766510a309b23a10175034fcf1551eaa9da3adcbed3e00cd53d1ebe5f030cf873f76a1cec3c34eb8c50cc227be3344caa9ed25d56cf611224 - languageName: node - linkType: hard - -"micromark-util-character@npm:^2.0.0": - version: 2.1.1 - resolution: "micromark-util-character@npm:2.1.1" - dependencies: - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/d3fe7a5e2c4060fc2a076f9ce699c82a2e87190a3946e1e5eea77f563869b504961f5668d9c9c014724db28ac32fa909070ea8b30c3a39bd0483cc6c04cc76a1 - languageName: node - linkType: hard - -"micromark-util-chunked@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-chunked@npm:2.0.1" - dependencies: - micromark-util-symbol: "npm:^2.0.0" - checksum: 10c0/b68c0c16fe8106949537bdcfe1be9cf36c0ccd3bc54c4007003cb0984c3750b6cdd0fd77d03f269a3382b85b0de58bde4f6eedbe7ecdf7244759112289b1ab56 - languageName: node - linkType: hard - -"micromark-util-classify-character@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-classify-character@npm:2.0.1" - dependencies: - micromark-util-character: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/8a02e59304005c475c332f581697e92e8c585bcd45d5d225a66c1c1b14ab5a8062705188c2ccec33cc998d33502514121478b2091feddbc751887fc9c290ed08 - languageName: node - linkType: hard - -"micromark-util-combine-extensions@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-combine-extensions@npm:2.0.1" - dependencies: - micromark-util-chunked: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/f15e282af24c8372cbb10b9b0b3e2c0aa681fea0ca323a44d6bc537dc1d9382c819c3689f14eaa000118f5a163245358ce6276b2cda9a84439cdb221f5d86ae7 - languageName: node - linkType: hard - -"micromark-util-decode-numeric-character-reference@npm:^2.0.0": - version: 2.0.2 - resolution: "micromark-util-decode-numeric-character-reference@npm:2.0.2" - dependencies: - micromark-util-symbol: "npm:^2.0.0" - checksum: 10c0/9c8a9f2c790e5593ffe513901c3a110e9ec8882a08f466da014112a25e5059b51551ca0aeb7ff494657d86eceb2f02ee556c6558b8d66aadc61eae4a240da0df - languageName: node - linkType: hard - -"micromark-util-encode@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-encode@npm:2.0.1" - checksum: 10c0/b2b29f901093845da8a1bf997ea8b7f5e061ffdba85070dfe14b0197c48fda64ffcf82bfe53c90cf9dc185e69eef8c5d41cae3ba918b96bc279326921b59008a - languageName: node - linkType: hard - -"micromark-util-html-tag-name@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-html-tag-name@npm:2.0.1" - checksum: 10c0/ae80444db786fde908e9295f19a27a4aa304171852c77414516418650097b8afb401961c9edb09d677b06e97e8370cfa65638dde8438ebd41d60c0a8678b85b9 - languageName: node - linkType: hard - -"micromark-util-normalize-identifier@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-normalize-identifier@npm:2.0.1" - dependencies: - micromark-util-symbol: "npm:^2.0.0" - checksum: 10c0/5299265fa360769fc499a89f40142f10a9d4a5c3dd8e6eac8a8ef3c2e4a6570e4c009cf75ea46dce5ee31c01f25587bde2f4a5cc0a935584ae86dd857f2babbd - languageName: node - linkType: hard - -"micromark-util-resolve-all@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-resolve-all@npm:2.0.1" - dependencies: - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/bb6ca28764696bb479dc44a2d5b5fe003e7177aeae1d6b0d43f24cc223bab90234092d9c3ce4a4d2b8df095ccfd820537b10eb96bb7044d635f385d65a4c984a - languageName: node - linkType: hard - -"micromark-util-sanitize-uri@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-sanitize-uri@npm:2.0.1" - dependencies: - micromark-util-character: "npm:^2.0.0" - micromark-util-encode: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - checksum: 10c0/60e92166e1870fd4f1961468c2651013ff760617342918e0e0c3c4e872433aa2e60c1e5a672bfe5d89dc98f742d6b33897585cf86ae002cda23e905a3c02527c - languageName: node - linkType: hard - -"micromark-util-subtokenize@npm:^2.0.0": - version: 2.1.0 - resolution: "micromark-util-subtokenize@npm:2.1.0" - dependencies: - devlop: "npm:^1.0.0" - micromark-util-chunked: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/bee69eece4393308e657c293ba80d92ebcb637e5f55e21dcf9c3fa732b91a8eda8ac248d76ff375e675175bfadeae4712e5158ef97eef1111789da1ce7ab5067 - languageName: node - linkType: hard - -"micromark-util-symbol@npm:^2.0.0": - version: 2.0.1 - resolution: "micromark-util-symbol@npm:2.0.1" - checksum: 10c0/f2d1b207771e573232436618e78c5e46cd4b5c560dd4a6d63863d58018abbf49cb96ec69f7007471e51434c60de3c9268ef2bf46852f26ff4aacd10f9da16fe9 - languageName: node - linkType: hard - -"micromark-util-types@npm:2.0.2, micromark-util-types@npm:^2.0.0": - version: 2.0.2 - resolution: "micromark-util-types@npm:2.0.2" - checksum: 10c0/c8c15b96c858db781c4393f55feec10004bf7df95487636c9a9f7209e51002a5cca6a047c5d2a5dc669ff92da20e57aaa881e81a268d9ccadb647f9dce305298 - languageName: node - linkType: hard - -"micromark@npm:4.0.2": - version: 4.0.2 - resolution: "micromark@npm:4.0.2" - dependencies: - "@types/debug": "npm:^4.0.0" - debug: "npm:^4.0.0" - decode-named-character-reference: "npm:^1.0.0" - devlop: "npm:^1.0.0" - micromark-core-commonmark: "npm:^2.0.0" - micromark-factory-space: "npm:^2.0.0" - micromark-util-character: "npm:^2.0.0" - micromark-util-chunked: "npm:^2.0.0" - micromark-util-combine-extensions: "npm:^2.0.0" - micromark-util-decode-numeric-character-reference: "npm:^2.0.0" - micromark-util-encode: "npm:^2.0.0" - micromark-util-normalize-identifier: "npm:^2.0.0" - micromark-util-resolve-all: "npm:^2.0.0" - micromark-util-sanitize-uri: "npm:^2.0.0" - micromark-util-subtokenize: "npm:^2.0.0" - micromark-util-symbol: "npm:^2.0.0" - micromark-util-types: "npm:^2.0.0" - checksum: 10c0/07462287254219d6eda6eac8a3cebaff2994e0575499e7088027b825105e096e4f51e466b14b2a81b71933a3b6c48ee069049d87bc2c2127eee50d9cc69e8af6 - languageName: node - linkType: hard - -"minimatch@npm:^10.2.2, minimatch@npm:~10.2.4": - version: 10.2.5 - resolution: "minimatch@npm:10.2.5" - dependencies: - brace-expansion: "npm:^5.0.5" - checksum: 10c0/6bb058bd6324104b9ec2f763476a35386d05079c1f5fe4fbf1f324a25237cd4534d6813ecd71f48208f4e635c1221899bef94c3c89f7df55698fe373aaae20fd - languageName: node - linkType: hard - -"minimatch@npm:^3.1.2": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - -"minimist@npm:^1.2.8": - version: 1.2.8 - resolution: "minimist@npm:1.2.8" - checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 - languageName: node - linkType: hard - -"minipass@npm:^7.0.4, minipass@npm:^7.1.2, minipass@npm:^7.1.3": - version: 7.1.3 - resolution: "minipass@npm:7.1.3" - checksum: 10c0/539da88daca16533211ea5a9ee98dc62ff5742f531f54640dd34429e621955e91cc280a91a776026264b7f9f6735947629f920944e9c1558369e8bf22eb33fbb - languageName: node - linkType: hard - -"minizlib@npm:^3.1.0": - version: 3.1.0 - resolution: "minizlib@npm:3.1.0" - dependencies: - minipass: "npm:^7.1.2" - checksum: 10c0/5aad75ab0090b8266069c9aabe582c021ae53eb33c6c691054a13a45db3b4f91a7fb1bd79151e6b4e9e9a86727b522527c0a06ec7d45206b745d54cd3097bcec - languageName: node - linkType: hard - -"ms@npm:^2.1.3": - version: 2.1.3 - resolution: "ms@npm:2.1.3" - checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48 - languageName: node - linkType: hard - -"msgpackr-extract@npm:^3.0.4": - version: 3.0.4 - resolution: "msgpackr-extract@npm:3.0.4" - dependencies: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.4" - "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.4" - node-gyp: "npm:latest" - node-gyp-build-optional-packages: "npm:5.2.2" - dependenciesMeta: - "@msgpackr-extract/msgpackr-extract-darwin-arm64": - optional: true - "@msgpackr-extract/msgpackr-extract-darwin-x64": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-arm64": - optional: true - "@msgpackr-extract/msgpackr-extract-linux-x64": - optional: true - "@msgpackr-extract/msgpackr-extract-win32-x64": - optional: true - bin: - download-msgpackr-prebuilds: bin/download-prebuilds.js - checksum: 10c0/582a9d17abbf3019e600e948736695056280ce401fd0235ee2474e95f9952208b9f6cce4d0e355b03b7d3c5630e6c3d11fe5fc27fdedb2311cce48de464338d8 - languageName: node - linkType: hard - -"msgpackr@npm:^2.0.1": - version: 2.0.4 - resolution: "msgpackr@npm:2.0.4" - dependencies: - msgpackr-extract: "npm:^3.0.4" - dependenciesMeta: - msgpackr-extract: - optional: true - checksum: 10c0/b72e8de59ce82a29cd5ca6d93ceec589367dd742a75fb04b6d26d6e0c5863f1000d0b27979469f18ca4554f3ccabe620524013268d5387415bcab495cca4ae73 - languageName: node - linkType: hard - -"multipasta@npm:^0.2.7": - version: 0.2.7 - resolution: "multipasta@npm:0.2.7" - checksum: 10c0/15917ac88aeefa5b8afac44b90d1e9d0d0ec7148b51e0766f07a69a220ecebcb6404539a856c45aa85a3d7fe517bc58febe81437146705f17ecd2961dc0b9fa5 - languageName: node - linkType: hard - -"natural-compare@npm:^1.4.0": - version: 1.4.0 - resolution: "natural-compare@npm:1.4.0" - checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 - languageName: node - linkType: hard - -"node-gyp-build-optional-packages@npm:5.2.2": - version: 5.2.2 - resolution: "node-gyp-build-optional-packages@npm:5.2.2" - dependencies: - detect-libc: "npm:^2.0.1" - bin: - node-gyp-build-optional-packages: bin.js - node-gyp-build-optional-packages-optional: optional.js - node-gyp-build-optional-packages-test: build-test.js - checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b - languageName: node - linkType: hard - -"node-gyp@npm:latest": - version: 13.0.0 - resolution: "node-gyp@npm:13.0.0" - dependencies: - env-paths: "npm:^2.2.0" - exponential-backoff: "npm:^3.1.1" - graceful-fs: "npm:^4.2.6" - nopt: "npm:^10.0.0" - proc-log: "npm:^7.0.0" - semver: "npm:^7.3.5" - tar: "npm:^7.5.4" - tinyglobby: "npm:^0.2.12" - undici: "npm:^6.25.0" - which: "npm:^7.0.0" - bin: - node-gyp: bin/node-gyp.js - checksum: 10c0/e7525c427db2d16aa368b8947187de83083d2a8dda23e3e096a71c22ae637ac5bb8ed7cf6c871f1b9118cd2729dbfee4ff3a4245e2b79226900227b15831b492 - languageName: node - linkType: hard - -"nopt@npm:^10.0.0": - version: 10.0.1 - resolution: "nopt@npm:10.0.1" - dependencies: - abbrev: "npm:^5.0.0" - bin: - nopt: bin/nopt.js - checksum: 10c0/980d89257f9587f3e1f77877ddbf905d6aa3b738ec33e49a4fa1a059a0dd82eb28063982b150654a7ae9de386f2ead60e56172db7d37cf56de545f7392a2a26a - languageName: node - linkType: hard - -"optionator@npm:^0.9.3": - version: 0.9.4 - resolution: "optionator@npm:0.9.4" - dependencies: - deep-is: "npm:^0.1.3" - fast-levenshtein: "npm:^2.0.6" - levn: "npm:^0.4.1" - prelude-ls: "npm:^1.2.1" - type-check: "npm:^0.4.0" - word-wrap: "npm:^1.2.5" - checksum: 10c0/4afb687a059ee65b61df74dfe87d8d6815cd6883cb8b3d5883a910df72d0f5d029821f37025e4bccf4048873dbdb09acc6d303d27b8f76b1a80dd5a7d5334675 - languageName: node - linkType: hard - -"p-limit@npm:^3.0.2": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a - languageName: node - linkType: hard - -"p-locate@npm:^5.0.0": - version: 5.0.0 - resolution: "p-locate@npm:5.0.0" - dependencies: - p-limit: "npm:^3.0.2" - checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a - languageName: node - linkType: hard - -"parent-module@npm:^1.0.0": - version: 1.0.1 - resolution: "parent-module@npm:1.0.1" - dependencies: - callsites: "npm:^3.0.0" - checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 - languageName: node - linkType: hard - -"parse-entities@npm:^4.0.0": - version: 4.0.2 - resolution: "parse-entities@npm:4.0.2" - dependencies: - "@types/unist": "npm:^2.0.0" - character-entities-legacy: "npm:^3.0.0" - character-reference-invalid: "npm:^2.0.0" - decode-named-character-reference: "npm:^1.0.0" - is-alphanumerical: "npm:^2.0.0" - is-decimal: "npm:^2.0.0" - is-hexadecimal: "npm:^2.0.0" - checksum: 10c0/a13906b1151750b78ed83d386294066daf5fb559e08c5af9591b2d98cc209123103016a01df776f65f8219ad26652d6d6b210d0974d452049cddfc53a8916c34 - languageName: node - linkType: hard - -"path-exists@npm:^4.0.0": - version: 4.0.0 - resolution: "path-exists@npm:4.0.0" - checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b - languageName: node - linkType: hard - -"path-key@npm:^3.1.0": - version: 3.1.1 - resolution: "path-key@npm:3.1.1" - checksum: 10c0/748c43efd5a569c039d7a00a03b58eecd1d75f3999f5a28303d75f521288df4823bc057d8784eb72358b2895a05f29a070bc9f1f17d28226cc4e62494cc58c4c - languageName: node - linkType: hard - -"path-scurry@npm:^2.0.2": - version: 2.0.2 - resolution: "path-scurry@npm:2.0.2" - dependencies: - lru-cache: "npm:^11.0.0" - minipass: "npm:^7.1.2" - checksum: 10c0/b35ad37cf6557a87fd057121ce2be7695380c9138d93e87ae928609da259ea0a170fac6f3ef1eb3ece8a068e8b7f2f3adf5bb2374cf4d4a57fe484954fcc9482 - languageName: node - linkType: hard - -"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 - languageName: node - linkType: hard - -"prelude-ls@npm:^1.2.1": - version: 1.2.1 - resolution: "prelude-ls@npm:1.2.1" - checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd - languageName: node - linkType: hard - -"proc-log@npm:^7.0.0": - version: 7.0.0 - resolution: "proc-log@npm:7.0.0" - checksum: 10c0/b89c2d862604f35fec795477b0c7e376feab3ba0d4f4d291c4e959567442697cf451ac557d0623c1cc38af45a78128b983410f397a10c5d3a67f76c33de4754b - languageName: node - linkType: hard - -"punycode.js@npm:^2.3.1": - version: 2.3.1 - resolution: "punycode.js@npm:2.3.1" - checksum: 10c0/1d12c1c0e06127fa5db56bd7fdf698daf9a78104456a6b67326877afc21feaa821257b171539caedd2f0524027fa38e67b13dd094159c8d70b6d26d2bea4dfdb - languageName: node - linkType: hard - -"punycode@npm:^2.1.0": - version: 2.3.1 - resolution: "punycode@npm:2.3.1" - checksum: 10c0/14f76a8206bc3464f794fb2e3d3cc665ae416c01893ad7a02b23766eb07159144ee612ad67af5e84fa4479ccfe67678c4feb126b0485651b302babf66f04f9e9 - languageName: node - linkType: hard - -"pure-rand@npm:^8.0.0": - version: 8.4.0 - resolution: "pure-rand@npm:8.4.0" - checksum: 10c0/6414bbc1c6f45fb774173431c7205e79783b77cfae0e2145e741b6999363554dbd2f4210d2a5bc08683e0b2f6823198c9308766b1d0911e1dccd7beb8842f860 - languageName: node - linkType: hard - -"require-directory@npm:^2.1.1": - version: 2.1.1 - resolution: "require-directory@npm:2.1.1" - checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 - languageName: node - linkType: hard - -"require-from-string@npm:^2.0.2": - version: 2.0.2 - resolution: "require-from-string@npm:2.0.2" - checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 - languageName: node - linkType: hard - -"resolve-from@npm:^4.0.0": - version: 4.0.0 - resolution: "resolve-from@npm:4.0.0" - checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 - languageName: node - linkType: hard - -"run-con@npm:~1.3.2": - version: 1.3.2 - resolution: "run-con@npm:1.3.2" - dependencies: - deep-extend: "npm:^0.6.0" - ini: "npm:~4.1.0" - minimist: "npm:^1.2.8" - strip-json-comments: "npm:~3.1.1" - bin: - run-con: cli.js - checksum: 10c0/b0bdd3083cf9f188e72df8905a1a40a1478e2a7437b0312ab1b824e058129388b811705ee7874e9a707e5de0e8fb8eb790da3aa0a23375323feecd1da97d5cf6 - languageName: node - linkType: hard - -"semver@npm:^7.3.5": - version: 7.8.4 - resolution: "semver@npm:7.8.4" - bin: - semver: bin/semver.js - checksum: 10c0/81b7c296fd7927b80f67fa516b75fa1017caac8167795320de28e76ccbc6f7f01763c30ecd10d6a0d8fd089708ab0548a5aebb94b0870e99c2a2b4600a46389b - languageName: node - linkType: hard - -"semver@npm:^7.5.3": - version: 7.7.4 - resolution: "semver@npm:7.7.4" - bin: - semver: bin/semver.js - checksum: 10c0/5215ad0234e2845d4ea5bb9d836d42b03499546ddafb12075566899fc617f68794bb6f146076b6881d755de17d6c6cc73372555879ec7dce2c2feee947866ad2 - languageName: node - linkType: hard - -"shebang-command@npm:^2.0.0": - version: 2.0.0 - resolution: "shebang-command@npm:2.0.0" - dependencies: - shebang-regex: "npm:^3.0.0" - checksum: 10c0/a41692e7d89a553ef21d324a5cceb5f686d1f3c040759c50aab69688634688c5c327f26f3ecf7001ebfd78c01f3c7c0a11a7c8bfd0a8bc9f6240d4f40b224e4e - languageName: node - linkType: hard - -"shebang-regex@npm:^3.0.0": - version: 3.0.0 - resolution: "shebang-regex@npm:3.0.0" - checksum: 10c0/1dbed0726dd0e1152a92696c76c7f06084eb32a90f0528d11acd764043aacf76994b2fb30aa1291a21bd019d6699164d048286309a278855ee7bec06cf6fb690 - languageName: node - linkType: hard - -"signal-exit@npm:^4.0.1": - version: 4.1.0 - resolution: "signal-exit@npm:4.1.0" - checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 - languageName: node - linkType: hard - -"smol-toml@npm:~1.6.0": - version: 1.6.1 - resolution: "smol-toml@npm:1.6.1" - checksum: 10c0/511a78722f99c7616fdb46af708de3d7e81434b5a3d58061166da73f28bfc6cae4f0cd04683f60515b9c490cd10152fce72287c960b337419c0299cc1f0f2a22 - languageName: node - linkType: hard - -"sql.js@npm:^1.14.1": - version: 1.14.1 - resolution: "sql.js@npm:1.14.1" - checksum: 10c0/3491b7642b8b6d89926e4cf1807c01697df7e3f7283b94aaebc026e6c38aaf9496065e9daf25de3109e51df835150d4f795f5249f22a1d3e6a3bb1f2e32c0710 - languageName: node - linkType: hard - -"string-width@npm:8.1.0": - version: 8.1.0 - resolution: "string-width@npm:8.1.0" - dependencies: - get-east-asian-width: "npm:^1.3.0" - strip-ansi: "npm:^7.1.0" - checksum: 10c0/749b5d0dab2532b4b6b801064230f4da850f57b3891287023117ab63a464ad79dd208f42f793458f48f3ad121fe2e1f01dd525ff27ead957ed9f205e27406593 - languageName: node - linkType: hard - -"string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": - version: 4.2.3 - resolution: "string-width@npm:4.2.3" - dependencies: - emoji-regex: "npm:^8.0.0" - is-fullwidth-code-point: "npm:^3.0.0" - strip-ansi: "npm:^6.0.1" - checksum: 10c0/1e525e92e5eae0afd7454086eed9c818ee84374bb80328fc41217ae72ff5f065ef1c9d7f72da41de40c75fa8bb3dee63d92373fd492c84260a552c636392a47b - languageName: node - linkType: hard - -"strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": - version: 6.0.1 - resolution: "strip-ansi@npm:6.0.1" - dependencies: - ansi-regex: "npm:^5.0.1" - checksum: 10c0/1ae5f212a126fe5b167707f716942490e3933085a5ff6c008ab97ab2f272c8025d3aa218b7bd6ab25729ca20cc81cddb252102f8751e13482a5199e873680952 - languageName: node - linkType: hard - -"strip-ansi@npm:^7.1.0": - version: 7.1.2 - resolution: "strip-ansi@npm:7.1.2" - dependencies: - ansi-regex: "npm:^6.0.1" - checksum: 10c0/0d6d7a023de33368fd042aab0bf48f4f4077abdfd60e5393e73c7c411e85e1b3a83507c11af2e656188511475776215df9ca589b4da2295c9455cc399ce1858b - languageName: node - linkType: hard - -"strip-json-comments@npm:^3.1.1, strip-json-comments@npm:~3.1.1": - version: 3.1.1 - resolution: "strip-json-comments@npm:3.1.1" - checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd - languageName: node - linkType: hard - -"supports-color@npm:^7.1.0": - version: 7.2.0 - resolution: "supports-color@npm:7.2.0" - dependencies: - has-flag: "npm:^4.0.0" - checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124 - languageName: node - linkType: hard - -"tar@npm:^7.5.4": - version: 7.5.16 - resolution: "tar@npm:7.5.16" - dependencies: - "@isaacs/fs-minipass": "npm:^4.0.0" - chownr: "npm:^3.0.0" - minipass: "npm:^7.1.2" - minizlib: "npm:^3.1.0" - yallist: "npm:^5.0.0" - checksum: 10c0/4f37f3c4bd2ca2755fd736a5df1d573c1a868ec1b1e893346aeafa95ac510f9e2fd1469420bd866cc7904799e5bd4ac62b5d4f03fe27747d6e1e373b44505c5c - languageName: node - linkType: hard - -"test-exclude@npm:^8.0.0": - version: 8.0.0 - resolution: "test-exclude@npm:8.0.0" - dependencies: - "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^13.0.6" - minimatch: "npm:^10.2.2" - checksum: 10c0/f2b613cb5ddc05d1357892f5da965a6f7af42b19a6b2fc30c9b93cb74adf5059a3a9f29818adb75c96c1747b3934caac90a9058f73ce0640ea101de828a11600 - languageName: node - linkType: hard - -"tinyglobby@npm:^0.2.12": - version: 0.2.17 - resolution: "tinyglobby@npm:0.2.17" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.4" - checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c - languageName: node - linkType: hard - -"tinyglobby@npm:~0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 - languageName: node - linkType: hard - -"toml@npm:^4.1.1": - version: 4.1.1 - resolution: "toml@npm:4.1.1" - checksum: 10c0/077bc02ac1ce82091ea073f675d7e2a1df487d1b18bbc7e653daba4956d545954b7095e979b8792f0837339b901ee190ad4464342e5e377c36bbdeca8903e079 - languageName: node - linkType: hard - -"type-check@npm:^0.4.0, type-check@npm:~0.4.0": - version: 0.4.0 - resolution: "type-check@npm:0.4.0" - dependencies: - prelude-ls: "npm:^1.2.1" - checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 - languageName: node - linkType: hard - -"typescript@npm:^6.0.3": - version: 6.0.3 - resolution: "typescript@npm:6.0.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 - languageName: node - linkType: hard - -"typescript@patch:typescript@npm%3A^6.0.3#optional!builtin": - version: 6.0.3 - resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df - languageName: node - linkType: hard - -"uc.micro@npm:^2.0.0, uc.micro@npm:^2.1.0": - version: 2.1.0 - resolution: "uc.micro@npm:2.1.0" - checksum: 10c0/8862eddb412dda76f15db8ad1c640ccc2f47cdf8252a4a30be908d535602c8d33f9855dfcccb8b8837855c1ce1eaa563f7fa7ebe3c98fd0794351aab9b9c55fa - languageName: node - linkType: hard - -"undici-types@npm:>=7.24.0 <7.24.7": - version: 7.24.6 - resolution: "undici-types@npm:7.24.6" - checksum: 10c0/d9cd8befb643ac904615c280a095ba4240531f6bb4a5e75a22a7483630ca8d3f1016d2ab6ace6ceda1f63b3a2db2fe037fafe121d6917a0187573aa548ff78ca - languageName: node - linkType: hard - -"undici@npm:^6.25.0": - version: 6.27.0 - resolution: "undici@npm:6.27.0" - checksum: 10c0/f88c3dae3957dbf9d93cb481440aced317bd3c4941b5914fea5efba516d51138988cdb5c76006f0bb1337e41d56c3443351055d492e73af2428521c37ba2a76f - languageName: node - linkType: hard - -"uri-js@npm:^4.2.2": - version: 4.4.1 - resolution: "uri-js@npm:4.4.1" - dependencies: - punycode: "npm:^2.1.0" - checksum: 10c0/4ef57b45aa820d7ac6496e9208559986c665e49447cb072744c13b66925a362d96dd5a46c4530a6b8e203e5db5fe849369444440cb22ecfc26c679359e5dfa3c - languageName: node - linkType: hard - -"uuid@npm:^14.0.0": - version: 14.0.0 - resolution: "uuid@npm:14.0.0" - bin: - uuid: dist-node/bin/uuid - checksum: 10c0/a57ae7794c45005c1a9208989196c5baf79a7679c30f43c1bee9033a2c4d113a2cea216fa6fcc9663b08b0d55635df1a7c6eb7e7f3d21c3e50688c698fa39a50 - languageName: node - linkType: hard - -"v8-to-istanbul@npm:^9.0.0": - version: 9.3.0 - resolution: "v8-to-istanbul@npm:9.3.0" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.12" - "@types/istanbul-lib-coverage": "npm:^2.0.1" - convert-source-map: "npm:^2.0.0" - checksum: 10c0/968bcf1c7c88c04df1ffb463c179558a2ec17aa49e49376120504958239d9e9dad5281aa05f2a78542b8557f2be0b0b4c325710262f3b838b40d703d5ed30c23 - languageName: node - linkType: hard - -"which@npm:^2.0.1": - version: 2.0.2 - resolution: "which@npm:2.0.2" - dependencies: - isexe: "npm:^2.0.0" - bin: - node-which: ./bin/node-which - checksum: 10c0/66522872a768b60c2a65a57e8ad184e5372f5b6a9ca6d5f033d4b0dc98aff63995655a7503b9c0a2598936f532120e81dd8cc155e2e92ed662a2b9377cc4374f - languageName: node - linkType: hard - -"which@npm:^7.0.0": - version: 7.0.0 - resolution: "which@npm:7.0.0" - dependencies: - isexe: "npm:^4.0.0" - bin: - node-which: bin/which.js - checksum: 10c0/ca0b54f198f78bbc4b7c02e34bda8d335cb352e0adb4cbca1c37b1a957af3a879a82c4c27ca6525bc942f548d8b64f816ef6528360af9f3de55ffb9b979b620d - languageName: node - linkType: hard - -"word-wrap@npm:^1.2.5": - version: 1.2.5 - resolution: "word-wrap@npm:1.2.5" - checksum: 10c0/e0e4a1ca27599c92a6ca4c32260e8a92e8a44f4ef6ef93f803f8ed823f486e0889fc0b93be4db59c8d51b3064951d25e43d434e95dc8c960cc3a63d65d00ba20 - languageName: node - linkType: hard - -"wrap-ansi@npm:^7.0.0": - version: 7.0.0 - resolution: "wrap-ansi@npm:7.0.0" - dependencies: - ansi-styles: "npm:^4.0.0" - string-width: "npm:^4.1.0" - strip-ansi: "npm:^6.0.0" - checksum: 10c0/d15fc12c11e4cbc4044a552129ebc75ee3f57aa9c1958373a4db0292d72282f54373b536103987a4a7594db1ef6a4f10acf92978f79b98c49306a4b58c77d4da - languageName: node - linkType: hard - -"y18n@npm:^5.0.5": - version: 5.0.8 - resolution: "y18n@npm:5.0.8" - checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 - languageName: node - linkType: hard - -"yallist@npm:^5.0.0": - version: 5.0.0 - resolution: "yallist@npm:5.0.0" - checksum: 10c0/a499c81ce6d4a1d260d4ea0f6d49ab4da09681e32c3f0472dee16667ed69d01dae63a3b81745a24bd78476ec4fcf856114cb4896ace738e01da34b2c42235416 - languageName: node - linkType: hard - -"yaml@npm:^2.9.0": - version: 2.9.0 - resolution: "yaml@npm:2.9.0" - bin: - yaml: bin.mjs - checksum: 10c0/f340718df45e97a9551b9bf9dac61c80050bc464513b710debfb5067c380c8472e3b67809cffacb4ab5ffb5e66ef9310816c88b05f371cec60abfedd8c88e0a2 - languageName: node - linkType: hard - -"yargs-parser@npm:^21.1.1": - version: 21.1.1 - resolution: "yargs-parser@npm:21.1.1" - checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 - languageName: node - linkType: hard - -"yargs@npm:^17.7.2": - version: 17.7.2 - resolution: "yargs@npm:17.7.2" - dependencies: - cliui: "npm:^8.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.3" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^21.1.1" - checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 - languageName: node - linkType: hard - -"yocto-queue@npm:^0.1.0": - version: 0.1.0 - resolution: "yocto-queue@npm:0.1.0" - checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f - languageName: node - linkType: hard - -"zod@npm:4.1.8": - version: 4.1.8 - resolution: "zod@npm:4.1.8" - checksum: 10c0/5eae39da09d7bd0564a30dfd2348811e4e2e7dd15955d8f3444f8e196f35e5422b1482eda234b722fafb0738f4a8b718adb042b860936bfdd2cc19cdbdac8a9a - languageName: node - linkType: hard +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@bcoe/v8-coverage@^1.0.1": + version "1.0.2" + resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz" + integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== + +"@eslint-community/eslint-utils@^4.8.0": + version "4.9.1" + resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.1": + version "4.12.2" + resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.3" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== + dependencies: + ajv "^6.12.4" + debug "^4.3.2" + espree "^10.0.1" + globals "^14.0.0" + ignore "^5.2.0" + import-fresh "^3.2.1" + js-yaml "^4.1.1" + minimatch "^3.1.2" + strip-json-comments "^3.1.1" + +"@eslint/js@^9.39.2", "@eslint/js@9.39.2": + version "9.39.2" + resolved "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz" + integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== + +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== + dependencies: + "@eslint/core" "^0.17.0" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@iarna/toml@^2.2.5": + version "2.2.5" + resolved "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz" + integrity sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg== + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.5" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + +"@jridgewell/trace-mapping@^0.3.12": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": + version "3.0.4" + resolved "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz" + integrity sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== + +"@opencode-ai/plugin@^1.16.2": + version "1.17.3" + resolved "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.3.tgz" + integrity sha512-Qz1ADiWxxXwuetXs6FE2T0kQmPXM6F8XDXE73SdC/oBZFYg7Oc1nf74GaEGhrvqQSMYm4kR6dHNF2jPVKn4eFw== + dependencies: + "@opencode-ai/sdk" "1.17.3" + effect "4.0.0-beta.74" + zod "4.1.8" + +"@opencode-ai/sdk@1.17.3": + version "1.17.3" + resolved "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.3.tgz" + integrity sha512-oXrEjOuP3+J9pPNw3cmOnRma/xiVQ4WIIvGd6YkhPQgqqi2PnD/b1qfNY0AMead3QfNhKwKdDM4QFJdN2LpByg== + dependencies: + cross-spawn "7.0.6" + +"@standard-schema/spec@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz" + integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== + +"@types/debug@^4.0.0": + version "4.1.12" + resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/katex@^0.16.0": + version "0.16.8" + resolved "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz" + integrity sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg== + +"@types/ms@*": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + +"@types/node@25.9.2": + version "25.9.2" + resolved "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz" + integrity sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw== + dependencies: + undici-types ">=7.24.0 <7.24.7" + +"@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +ajv@^6.12.4: + version "6.14.0" + resolved "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ajv@^8.20.0: + version "8.20.0" + resolved "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz" + integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.2.2" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +brace-expansion@^1.1.7: + version "1.1.14" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz" + integrity sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^5.0.5: + version "5.0.6" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz" + integrity sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g== + dependencies: + balanced-match "^4.0.2" + +c8@^11.0.0: + version "11.0.0" + resolved "https://registry.npmjs.org/c8/-/c8-11.0.0.tgz" + integrity sha512-e/uRViGHSVIJv7zsaDKM7VRn2390TgHXqUSvYwPHBQaU6L7E9L0n9JbdkwdYPvshDT0KymBmmlwSpms3yBaMNg== + dependencies: + "@bcoe/v8-coverage" "^1.0.1" + "@istanbuljs/schema" "^0.1.3" + find-up "^5.0.0" + foreground-child "^3.1.1" + istanbul-lib-coverage "^3.2.0" + istanbul-lib-report "^3.0.1" + istanbul-reports "^3.1.6" + test-exclude "^8.0.0" + v8-to-istanbul "^9.0.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + +character-reference-invalid@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz" + integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +commander@^8.3.0: + version "8.3.0" + resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz" + integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== + +commander@~14.0.3: + version "14.0.3" + resolved "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz" + integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cross-spawn@^7.0.6, cross-spawn@7.0.6: + version "7.0.6" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.0.0, debug@^4.3.1, debug@^4.3.2: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +decode-named-character-reference@^1.0.0: + version "1.3.0" + resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz" + integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q== + dependencies: + character-entities "^2.0.0" + +deep-extend@^0.6.0, deep-extend@~0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz" + integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +detect-libc@^2.0.1: + version "2.1.2" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +effect@4.0.0-beta.74: + version "4.0.0-beta.74" + resolved "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.74.tgz" + integrity sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA== + dependencies: + "@standard-schema/spec" "^1.1.0" + fast-check "^4.8.0" + find-my-way-ts "^0.1.6" + ini "^7.0.0" + kubernetes-types "^1.30.0" + msgpackr "^2.0.1" + multipasta "^0.2.7" + toml "^4.1.1" + uuid "^14.0.0" + yaml "^2.9.0" + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +escalade@^3.1.1: + version "3.2.0" + resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^9.39.2: + version "9.39.2" + resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz" + integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.39.2" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.12.4" + chalk "^4.0.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + lodash.merge "^4.6.2" + minimatch "^3.1.2" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +esquery@^1.5.0: + version "1.7.0" + resolved "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +fast-check@^4.8.0: + version "4.8.0" + resolved "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz" + integrity sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg== + dependencies: + pure-rand "^8.0.0" + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fast-uri@^3.0.1: + version "3.1.2" + resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz" + integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +find-my-way-ts@^0.1.6: + version "0.1.6" + resolved "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz" + integrity sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA== + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.2" + resolved "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + +foreground-child@^3.1.1: + version "3.3.1" + resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-east-asian-width@^1.3.0: + version "1.4.0" + resolved "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz" + integrity sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +glob@^13.0.6: + version "13.0.6" + resolved "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz" + integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== + dependencies: + minimatch "^10.2.2" + minipass "^7.1.3" + path-scurry "^2.0.2" + +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^17.4.0: + version "17.4.0" + resolved "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz" + integrity sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@~7.0.5: + version "7.0.5" + resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + +import-fresh@^3.2.1: + version "3.3.1" + resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz" + integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +ini@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz" + integrity sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w== + +ini@~4.1.0: + version "4.1.3" + resolved "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz" + integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== + +is-alphabetical@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== + +is-alphanumerical@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz" + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== + dependencies: + is-alphabetical "^2.0.0" + is-decimal "^2.0.0" + +is-decimal@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz" + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-hexadecimal@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz" + integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-report@^3.0.0, istanbul-lib-report@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-reports@^3.1.6: + version "3.2.0" + resolved "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +js-yaml@>=4.2.0: + version "4.2.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz" + integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== + dependencies: + argparse "^2.0.1" + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +jsonc-parser@~3.3.1: + version "3.3.1" + resolved "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + +jsonpointer@~5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz" + integrity sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ== + +katex@^0.16.0: + version "0.16.28" + resolved "https://registry.npmjs.org/katex/-/katex-0.16.28.tgz" + integrity sha512-YHzO7721WbmAL6Ov1uzN/l5mY5WWWhJBSW+jq4tkfZfsxmo1hu6frS0EOswvjBUnWE6NtjEs48SFn5CQESRLZg== + dependencies: + commander "^8.3.0" + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +kubernetes-types@^1.30.0: + version "1.30.0" + resolved "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz" + integrity sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q== + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +linkify-it@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz" + integrity sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg== + dependencies: + uc.micro "^2.0.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash.merge@^4.6.2: + version "4.6.2" + resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" + integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== + +lru-cache@^11.0.0: + version "11.2.7" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz" + integrity sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA== + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +markdown-it@>=14.2.0: + version "14.2.0" + resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz" + integrity sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ== + dependencies: + argparse "^2.0.1" + entities "^4.4.0" + linkify-it "^5.0.1" + mdurl "^2.0.0" + punycode.js "^2.3.1" + uc.micro "^2.1.0" + +markdownlint-cli@^0.48.0: + version "0.48.0" + resolved "https://registry.npmjs.org/markdownlint-cli/-/markdownlint-cli-0.48.0.tgz" + integrity sha512-NkZQNu2E0Q5qLEEHwWj674eYISTLD4jMHkBzDobujXd1kv+yCxi8jOaD/rZoQNW1FBBMMGQpuW5So8B51N/e0A== + dependencies: + commander "~14.0.3" + deep-extend "~0.6.0" + ignore "~7.0.5" + js-yaml "~4.1.1" + jsonc-parser "~3.3.1" + jsonpointer "~5.0.1" + markdown-it "~14.1.1" + markdownlint "~0.40.0" + minimatch "~10.2.4" + run-con "~1.3.2" + smol-toml "~1.6.0" + tinyglobby "~0.2.15" + +markdownlint@~0.40.0: + version "0.40.0" + resolved "https://registry.npmjs.org/markdownlint/-/markdownlint-0.40.0.tgz" + integrity sha512-UKybllYNheWac61Ia7T6fzuQNDZimFIpCg2w6hHjgV1Qu0w1TV0LlSgryUGzM0bkKQCBhy2FDhEELB73Kb0kAg== + dependencies: + micromark "4.0.2" + micromark-core-commonmark "2.0.3" + micromark-extension-directive "4.0.0" + micromark-extension-gfm-autolink-literal "2.1.0" + micromark-extension-gfm-footnote "2.1.0" + micromark-extension-gfm-table "2.1.1" + micromark-extension-math "3.1.0" + micromark-util-types "2.0.2" + string-width "8.1.0" + +mdurl@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz" + integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== + +micromark-core-commonmark@^2.0.0, micromark-core-commonmark@2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-directive@4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz" + integrity sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + parse-entities "^4.0.0" + +micromark-extension-gfm-autolink-literal@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz" + integrity sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-footnote@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz" + integrity sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw== + dependencies: + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-gfm-table@2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz" + integrity sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg== + dependencies: + devlop "^1.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-extension-math@3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz" + integrity sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg== + dependencies: + "@types/katex" "^0.16.0" + devlop "^1.0.0" + katex "^0.16.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0, micromark-util-types@2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@4.0.2: + version "4.0.2" + resolved "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +minimatch@^10.2.2: + version "10.2.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimatch@^3.1.2: + version "3.1.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== + dependencies: + brace-expansion "^1.1.7" + +minimatch@~10.2.4: + version "10.2.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz" + integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + dependencies: + brace-expansion "^5.0.5" + +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +minipass@^7.1.2, minipass@^7.1.3: + version "7.1.3" + resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz" + integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +msgpackr-extract@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz" + integrity sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw== + dependencies: + node-gyp-build-optional-packages "5.2.2" + optionalDependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.4" + +msgpackr@^2.0.1: + version "2.0.4" + resolved "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.4.tgz" + integrity sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA== + optionalDependencies: + msgpackr-extract "^3.0.4" + +multipasta@^0.2.7: + version "0.2.7" + resolved "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz" + integrity sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +node-gyp-build-optional-packages@5.2.2: + version "5.2.2" + resolved "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz" + integrity sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw== + dependencies: + detect-libc "^2.0.1" + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + +parse-entities@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz" + integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw== + dependencies: + "@types/unist" "^2.0.0" + character-entities-legacy "^3.0.0" + character-reference-invalid "^2.0.0" + decode-named-character-reference "^1.0.0" + is-alphanumerical "^2.0.0" + is-decimal "^2.0.0" + is-hexadecimal "^2.0.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz" + integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" + +"picomatch@^3 || ^4", picomatch@^4.0.3: + version "4.0.4" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +punycode.js@^2.3.1: + version "2.3.1" + resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz" + integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA== + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +pure-rand@^8.0.0: + version "8.4.0" + resolved "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz" + integrity sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + +run-con@~1.3.2: + version "1.3.2" + resolved "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz" + integrity sha512-CcfE+mYiTcKEzg0IqS08+efdnH0oJ3zV0wSUFBNrMHMuxCtXvBCLzCJHatwuXDcu/RlhjTziTo/a1ruQik6/Yg== + dependencies: + deep-extend "^0.6.0" + ini "~4.1.0" + minimist "^1.2.8" + strip-json-comments "~3.1.1" + +semver@^7.5.3: + version "7.7.4" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +smol-toml@~1.6.0: + version "1.6.1" + resolved "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz" + integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== + +sql.js@^1.14.1: + version "1.14.1" + resolved "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz" + integrity sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A== + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@8.1.0: + version "8.1.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" + integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg== + dependencies: + get-east-asian-width "^1.3.0" + strip-ansi "^7.1.0" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.1.0: + version "7.1.2" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +test-exclude@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz" + integrity sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^13.0.6" + minimatch "^10.2.2" + +tinyglobby@~0.2.15: + version "0.2.15" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +toml@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz" + integrity sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript@^6.0.3: + version "6.0.3" + resolved "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +uc.micro@^2.0.0, uc.micro@^2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz" + integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== + +"undici-types@>=7.24.0 <7.24.7": + version "7.24.6" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz" + integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +uuid@^14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz" + integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg== + +v8-to-istanbul@^9.0.0: + version "9.3.0" + resolved "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz" + integrity sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yaml@^2.9.0: + version "2.9.0" + resolved "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz" + integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA== + +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@4.1.8: + version "4.1.8" + resolved "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz" + integrity sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ== From 8973d0f6c53c0eb522a1f9007847e6706d15ed58 Mon Sep 17 00:00:00 2001 From: quadcent Date: Tue, 30 Jun 2026 00:50:52 +0200 Subject: [PATCH 008/197] fix(llm): align Claude provider with current Anthropic API (#2133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace invalid default model IDs (e.g. claude-sonnet-4-7) with current claude-sonnet-4-6, claude-opus-4-8, and claude-haiku-4-5. Route system messages to the API system field, enable ephemeral prompt caching, omit temperature for Opus 4.7/4.8, and surface cache usage metrics. Update the CLI model picker to match. Co-authored-by: Vladimir Đuranović Co-authored-by: Cursor --- src/llm/cli/selector.py | 6 +- src/llm/providers/claude.py | 114 +++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 50 deletions(-) diff --git a/src/llm/cli/selector.py b/src/llm/cli/selector.py index 4419b2bc6..87b513e21 100644 --- a/src/llm/cli/selector.py +++ b/src/llm/cli/selector.py @@ -105,9 +105,9 @@ def interactive_select( if models_per_provider is None: models_per_provider = { "claude": [ - ("claude-opus-4-5", "Claude Opus 4.5 - Most capable"), - ("claude-sonnet-4-7", "Claude Sonnet 4.7 - Balanced"), - ("claude-haiku-4-7", "Claude Haiku 4.7 - Fast"), + ("claude-opus-4-8", "Claude Opus 4.8 - Most capable"), + ("claude-sonnet-4-6", "Claude Sonnet 4.6 - Balanced"), + ("claude-haiku-4-5", "Claude Haiku 4.5 - Fast"), ], "openai": [ ("gpt-4o", "GPT-4o - Most capable"), diff --git a/src/llm/providers/claude.py b/src/llm/providers/claude.py index 2383db4e2..55cce8951 100644 --- a/src/llm/providers/claude.py +++ b/src/llm/providers/claude.py @@ -13,7 +13,14 @@ from llm.core.interface import ( LLMProvider, RateLimitError, ) -from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall +from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType, Role, ToolCall + +_DEFAULT_MODEL = "claude-sonnet-4-6" +_OPUS_ADAPTIVE_ONLY_PREFIXES = ("claude-opus-4-7", "claude-opus-4-8") + + +def _uses_adaptive_thinking_only(model: str) -> bool: + return any(model.startswith(prefix) for prefix in _OPUS_ADAPTIVE_ONLY_PREFIXES) class ClaudeProvider(LLMProvider): @@ -23,77 +30,90 @@ class ClaudeProvider(LLMProvider): self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url) self._models = [ ModelInfo( - name="claude-opus-4-5", + name="claude-opus-4-8", provider=ProviderType.CLAUDE, supports_tools=True, supports_vision=True, - max_tokens=8192, - context_window=200000, + max_tokens=64000, + context_window=1_000_000, ), ModelInfo( - name="claude-sonnet-4-7", + name="claude-sonnet-4-6", provider=ProviderType.CLAUDE, supports_tools=True, supports_vision=True, - max_tokens=8192, - context_window=200000, + max_tokens=64000, + context_window=1_000_000, ), ModelInfo( - name="claude-haiku-4-7", + name="claude-haiku-4-5", provider=ProviderType.CLAUDE, supports_tools=True, - supports_vision=False, - max_tokens=4096, - context_window=200000, + supports_vision=True, + max_tokens=16000, + context_window=200_000, ), ] def generate(self, input: LLMInput) -> LLMOutput: try: + model = input.model or _DEFAULT_MODEL + system_parts = [msg.content for msg in input.messages if msg.role == Role.SYSTEM] + api_messages = [ + msg.to_dict() for msg in input.messages if msg.role not in (Role.SYSTEM,) + ] + params: dict[str, Any] = { - "model": input.model or "claude-sonnet-4-7", - "messages": [msg.to_dict() for msg in input.messages], - "temperature": input.temperature, + "model": model, + "messages": api_messages, + "max_tokens": input.max_tokens if input.max_tokens else 16000, + "cache_control": {"type": "ephemeral"}, } - if input.max_tokens: - params["max_tokens"] = input.max_tokens - else: - params["max_tokens"] = 8192 # required by Anthropic API - if input.tools: - params["tools"] = [tool.to_anthropic_tool() for tool in input.tools] + if system_parts: + params["system"] = "\n\n".join(system_parts) + if input.tools: + params["tools"] = [tool.to_anthropic_tool() for tool in input.tools] + if not _uses_adaptive_thinking_only(model): + params["temperature"] = input.temperature + if _uses_adaptive_thinking_only(model): + params["thinking"] = {"type": "adaptive"} response = self.client.messages.create(**params) - text_parts: list[str] = [] - tool_calls: list[ToolCall] = [] - for block in response.content or []: - block_type = getattr(block, "type", None) - if block_type == "text": - text = getattr(block, "text", "") - if text: - text_parts.append(text) - elif block_type == "tool_use": - raw_arguments = getattr(block, "input", {}) - arguments = ( - raw_arguments.copy() - if isinstance(raw_arguments, dict) - else getattr(raw_arguments, "__dict__", {}).copy() - ) - tool_calls.append( - ToolCall( - id=getattr(block, "id", ""), - name=getattr(block, "name", ""), - arguments=arguments, - ) - ) - - return LLMOutput( - content="".join(text_parts), - tool_calls=tool_calls or None, + text_parts: list[str] = [] + tool_calls: list[ToolCall] = [] + for block in response.content or []: + block_type = getattr(block, "type", None) + if block_type == "text": + text = getattr(block, "text", "") + if text: + text_parts.append(text) + elif block_type == "tool_use": + raw_arguments = getattr(block, "input", {}) + arguments = ( + raw_arguments.copy() + if isinstance(raw_arguments, dict) + else getattr(raw_arguments, "__dict__", {}).copy() + ) + tool_calls.append( + ToolCall( + id=getattr(block, "id", ""), + name=getattr(block, "name", ""), + arguments=arguments, + ) + ) + + return LLMOutput( + content="".join(text_parts), + tool_calls=tool_calls or None, model=response.model, usage={ "input_tokens": response.usage.input_tokens, "output_tokens": response.usage.output_tokens, + "cache_creation_input_tokens": getattr( + response.usage, "cache_creation_input_tokens", 0 + ), + "cache_read_input_tokens": getattr(response.usage, "cache_read_input_tokens", 0), }, stop_reason=response.stop_reason, ) @@ -114,4 +134,4 @@ class ClaudeProvider(LLMProvider): return bool(self.client.api_key) def get_default_model(self) -> str: - return "claude-sonnet-4-7" + return _DEFAULT_MODEL From 9896644dabc38771d80207905c6a311ab37e3cc2 Mon Sep 17 00:00:00 2001 From: Yang Cheng Date: Tue, 30 Jun 2026 06:50:55 +0800 Subject: [PATCH 009/197] fix(release): derive approval gate paths from version (#2383) Co-authored-by: jan --- scripts/release-approval-gate.js | 69 ++++++++++++------- tests/scripts/release-approval-gate.test.js | 76 ++++++++++++++------- 2 files changed, 98 insertions(+), 47 deletions(-) diff --git a/scripts/release-approval-gate.js b/scripts/release-approval-gate.js index a1c9e0ded..18d81caf0 100644 --- a/scripts/release-approval-gate.js +++ b/scripts/release-approval-gate.js @@ -5,13 +5,8 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const RELEASE = '2.0.0-rc.1'; -const RELEASE_DIR = `docs/releases/${RELEASE}`; const SCHEMA_VERSION = 'ecc.release-approval-gate.v1'; const SCRIPT_PATH = 'scripts/release-approval-gate.js'; -const OWNER_PACKET_PATH = `${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`; -const URL_LEDGER_PATH = `${RELEASE_DIR}/release-url-ledger-2026-05-19.md`; -const PREVIEW_MANIFEST_PATH = `${RELEASE_DIR}/preview-pack-manifest.md`; const REQUIRED_COMMAND = 'npm run release:approval-gate -- --format json'; const REQUIRED_DECISIONS = [ @@ -87,20 +82,19 @@ const REQUIRED_URL_SURFACES = [ }, ]; -const ANNOUNCEMENT_FILES = [ - `${RELEASE_DIR}/release-notes.md`, - `${RELEASE_DIR}/x-thread.md`, - `${RELEASE_DIR}/linkedin-post.md`, - `${RELEASE_DIR}/article-outline.md`, - `${RELEASE_DIR}/partner-sponsor-talks-pack.md`, - 'docs/business/social-launch-copy.md', +const ANNOUNCEMENT_FILE_NAMES = [ + 'release-notes.md', + 'x-thread.md', + 'linkedin-post.md', + 'article-outline.md', + 'partner-sponsor-talks-pack.md', ]; function usage() { console.log([ 'Usage: node scripts/release-approval-gate.js [--format ] [--root ]', '', - 'Final approval gate for ECC 2.0 rc.1 publication and outbound actions.', + 'Final approval gate for the release version declared by package.json.', '', 'Options:', ' --format Output format (default: text)', @@ -195,6 +189,32 @@ function safeParseJson(text) { } } +function resolveRelease(packageJson, options = {}) { + if (typeof options.release === 'string' && options.release.trim()) { + return options.release.trim(); + } + + return typeof packageJson.version === 'string' ? packageJson.version.trim() : ''; +} + +function releaseDirFor(release) { + return `docs/releases/${release}`; +} + +function releasePathsFor(release) { + const releaseDir = releaseDirFor(release); + + return { + ownerPacketPath: `${releaseDir}/owner-approval-packet-2026-05-19.md`, + urlLedgerPath: `${releaseDir}/release-url-ledger-2026-05-19.md`, + previewManifestPath: `${releaseDir}/preview-pack-manifest.md`, + announcementFiles: [ + ...ANNOUNCEMENT_FILE_NAMES.map(fileName => `${releaseDir}/${fileName}`), + 'docs/business/social-launch-copy.md', + ], + }; +} + function normalizeLabel(value) { return String(value) .replace(/[`*_]/g, '') @@ -366,11 +386,13 @@ function topActionsForChecks(checks) { function buildReport(options = {}) { const rootDir = path.resolve(options.root || process.cwd()); const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; + const release = resolveRelease(packageJson, options); + const releasePaths = releasePathsFor(release); const packageScripts = packageJson.scripts || {}; const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : []; - const ownerPacket = readText(rootDir, OWNER_PACKET_PATH); - const ledger = readText(rootDir, URL_LEDGER_PATH); - const manifest = readText(rootDir, PREVIEW_MANIFEST_PATH); + const ownerPacket = readText(rootDir, releasePaths.ownerPacketPath); + const ledger = readText(rootDir, releasePaths.urlLedgerPath); + const manifest = readText(rootDir, releasePaths.previewManifestPath); const decisions = parseDecisionRegister(ownerPacket); const missingDecisions = []; @@ -388,11 +410,11 @@ function buildReport(options = {}) { .filter(surface => !ledger.includes(surface.label)) .map(surface => surface.label); const urlBlockers = ledgerBlockers(ledger); - const announcementOffenders = findAnnouncementOffenders(rootDir, ANNOUNCEMENT_FILES); + const announcementOffenders = findAnnouncementOffenders(rootDir, releasePaths.announcementFiles); const commandListedIn = [ - ownerPacket.includes(REQUIRED_COMMAND) ? OWNER_PACKET_PATH : '', - ledger.includes(REQUIRED_COMMAND) ? URL_LEDGER_PATH : '', - manifest.includes(REQUIRED_COMMAND) ? PREVIEW_MANIFEST_PATH : '', + ownerPacket.includes(REQUIRED_COMMAND) ? releasePaths.ownerPacketPath : '', + ledger.includes(REQUIRED_COMMAND) ? releasePaths.urlLedgerPath : '', + manifest.includes(REQUIRED_COMMAND) ? releasePaths.previewManifestPath : '', ].filter(Boolean); const checks = [ @@ -440,7 +462,7 @@ function buildReport(options = {}) { 'announcement-copy-finalized', announcementOffenders.length === 0 ? 'pass' : 'fail', announcementOffenders.length === 0 - ? `${ANNOUNCEMENT_FILES.length} launch/outbound copy files have no placeholders or private paths` + ? `${releasePaths.announcementFiles.length} launch/outbound copy files have no placeholders or private paths` : `offenders: ${announcementOffenders.map(item => `${item.path}:${item.line}`).join(', ')}`, 'Replace placeholders with live URLs and remove private local paths from launch/outbound copy.' ), @@ -465,7 +487,7 @@ function buildReport(options = {}) { return { schema_version: SCHEMA_VERSION, - release: RELEASE, + release, ready: failed.length === 0, digest, summary: { @@ -543,11 +565,12 @@ if (require.main === module) { } module.exports = { - ANNOUNCEMENT_FILES, + ANNOUNCEMENT_FILE_NAMES, REQUIRED_COMMAND, REQUIRED_DECISIONS, REQUIRED_URL_SURFACES, buildReport, + releasePathsFor, parseArgs, renderText, }; diff --git a/tests/scripts/release-approval-gate.test.js b/tests/scripts/release-approval-gate.test.js index bc064ff3f..a06559410 100644 --- a/tests/scripts/release-approval-gate.test.js +++ b/tests/scripts/release-approval-gate.test.js @@ -15,7 +15,12 @@ const { renderText, } = require(SCRIPT); -const RELEASE_DIR = 'docs/releases/2.0.0-rc.1'; +const CURRENT_RELEASE = require(path.join(__dirname, '..', '..', 'package.json')).version; +const RC_RELEASE = '2.0.0-rc.1'; + +function releaseDirFor(release) { + return `docs/releases/${release}`; +} function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); @@ -31,14 +36,14 @@ function writeFile(rootDir, relativePath, content) { fs.writeFileSync(targetPath, content); } -function approvedPacketContent(overrides = {}) { +function approvedPacketContent(overrides = {}, release = CURRENT_RELEASE) { const decisions = new Map(REQUIRED_DECISIONS.map(decision => [decision.label, 'approve'])); for (const [label, value] of Object.entries(overrides)) { decisions.set(label, value); } return [ - '# ECC v2.0.0-rc.1 Owner Approval Packet', + `# ECC v${release} Owner Approval Packet`, '', '## Decision Register', '', @@ -58,16 +63,16 @@ function approvedPacketContent(overrides = {}) { ].join('\n'); } -function finalLedgerContent(extra = '') { +function finalLedgerContent(extra = '', release = CURRENT_RELEASE) { return [ - '# ECC v2.0.0-rc.1 Release URL Ledger', + `# ECC v${release} Release URL Ledger`, '', '## Final Published URLs', '', '| Surface | URL | Verification |', '| --- | --- | --- |', ...REQUIRED_URL_SURFACES.map(surface => ( - `| ${surface.label} | ${surface.exampleUrl} | readback from final release commit |` + `| ${surface.label} | ${surface.exampleUrl.split(RC_RELEASE).join(release)} | readback from final release commit |` )), '', '## Final Verification Commands', @@ -80,9 +85,9 @@ function finalLedgerContent(extra = '') { ].join('\n'); } -function manifestContent() { +function manifestContent(release = CURRENT_RELEASE) { return [ - '# ECC v2.0.0-rc.1 Preview Pack Manifest', + `# ECC v${release} Preview Pack Manifest`, '', '| Artifact | Role | Gate |', '| --- | --- | --- |', @@ -96,23 +101,26 @@ function manifestContent() { ].join('\n'); } -function seedRepo(rootDir, overrides = {}) { +function seedRepo(rootDir, overrides = {}, options = {}) { + const release = options.release || CURRENT_RELEASE; + const releaseDir = releaseDirFor(release); const files = { 'package.json': JSON.stringify({ + version: release, files: ['scripts/release-approval-gate.js'], scripts: { 'release:approval-gate': 'node scripts/release-approval-gate.js', }, }, null, 2), 'scripts/release-approval-gate.js': 'release approval gate script', - [`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent(), - [`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: finalLedgerContent(), - [`${RELEASE_DIR}/preview-pack-manifest.md`]: manifestContent(), - [`${RELEASE_DIR}/release-notes.md`]: 'Release notes with final URLs.', - [`${RELEASE_DIR}/x-thread.md`]: 'X post with final URLs.', - [`${RELEASE_DIR}/linkedin-post.md`]: 'LinkedIn post with final URLs.', - [`${RELEASE_DIR}/article-outline.md`]: 'Article outline with final URLs.', - [`${RELEASE_DIR}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.', + [`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({}, release), + [`${releaseDir}/release-url-ledger-2026-05-19.md`]: finalLedgerContent('', release), + [`${releaseDir}/preview-pack-manifest.md`]: manifestContent(release), + [`${releaseDir}/release-notes.md`]: 'Release notes with final URLs.', + [`${releaseDir}/x-thread.md`]: 'X post with final URLs.', + [`${releaseDir}/linkedin-post.md`]: 'LinkedIn post with final URLs.', + [`${releaseDir}/article-outline.md`]: 'Article outline with final URLs.', + [`${releaseDir}/partner-sponsor-talks-pack.md`]: 'Outbound copy with final URLs.', 'docs/business/social-launch-copy.md': 'Business launch copy with final URLs.', }; @@ -189,6 +197,7 @@ function runTests() { const report = buildReport({ root: rootDir }); assert.strictEqual(report.schema_version, 'ecc.release-approval-gate.v1'); + assert.strictEqual(report.release, CURRENT_RELEASE); assert.strictEqual(report.ready, true); assert.strictEqual(report.summary.failed, 0); assert.deepStrictEqual(report.top_actions, []); @@ -202,12 +211,27 @@ function runTests() { } })) passed++; else failed++; + if (test('release override keeps rc.1 approval fixtures testable', () => { + const rootDir = createTempDir('release-approval-rc-'); + + try { + seedRepo(rootDir, {}, { release: RC_RELEASE }); + const report = buildReport({ root: rootDir, release: RC_RELEASE }); + + assert.strictEqual(report.release, RC_RELEASE); + assert.strictEqual(report.ready, true); + } finally { + cleanup(rootDir); + } + })) passed++; else failed++; + if (test('deferred owner decisions keep the publication gate blocked', () => { const rootDir = createTempDir('release-approval-deferred-'); try { + const releaseDir = releaseDirFor(CURRENT_RELEASE); seedRepo(rootDir, { - [`${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({ + [`${releaseDir}/owner-approval-packet-2026-05-19.md`]: approvedPacketContent({ 'GitHub prerelease': 'defer', 'Sponsor, partner, consulting, conference, podcast outreach': 'block', }), @@ -230,15 +254,16 @@ function runTests() { const rootDir = createTempDir('release-approval-ledger-'); try { + const releaseDir = releaseDirFor(CURRENT_RELEASE); seedRepo(rootDir, { - [`${RELEASE_DIR}/release-url-ledger-2026-05-19.md`]: [ - '# ECC v2.0.0-rc.1 Release URL Ledger', + [`${releaseDir}/release-url-ledger-2026-05-19.md`]: [ + `# ECC v${CURRENT_RELEASE} Release URL Ledger`, '', '## Approval-Gated URLs', '', '| Surface | Intended URL or command | Gate before use |', '| --- | --- | --- |', - '| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v2.0.0-rc.1 | must return the prerelease |', + `| GitHub prerelease | https://github.com/affaan-m/ECC/releases/tag/v${CURRENT_RELEASE} | must return the prerelease |`, ].join('\n'), }); @@ -257,8 +282,9 @@ function runTests() { const rootDir = createTempDir('release-approval-copy-'); try { + const releaseDir = releaseDirFor(CURRENT_RELEASE); seedRepo(rootDir, { - [`${RELEASE_DIR}/x-thread.md`]: 'Ship copy with and /Users/affaan/raw-footage.', + [`${releaseDir}/x-thread.md`]: 'Ship copy with and /Users/affaan/raw-footage.', }); const report = buildReport({ root: rootDir }); @@ -266,7 +292,7 @@ function runTests() { assert.strictEqual(report.ready, false); assert.strictEqual(copy.status, 'fail'); - assert.ok(copy.evidence.includes(`${RELEASE_DIR}/x-thread.md:1`)); + assert.ok(copy.evidence.includes(`${releaseDir}/x-thread.md:1`)); } finally { cleanup(rootDir); } @@ -280,10 +306,12 @@ function runTests() { const stdout = run(['--format=json', `--root=${rootDir}`], { cwd: rootDir }); const parsed = JSON.parse(stdout); assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.release, CURRENT_RELEASE); + const releaseDir = releaseDirFor(CURRENT_RELEASE); writeFile( rootDir, - `${RELEASE_DIR}/owner-approval-packet-2026-05-19.md`, + `${releaseDir}/owner-approval-packet-2026-05-19.md`, approvedPacketContent({ 'Video upload': 'defer' }) ); const failedRun = runProcess(['--format=json', `--root=${rootDir}`], { cwd: rootDir }); From 73895b5d059e3e68cca0e1ca7aba451d54bac5e5 Mon Sep 17 00:00:00 2001 From: Yang Cheng Date: Tue, 30 Jun 2026 06:50:57 +0800 Subject: [PATCH 010/197] fix(release): derive video suite paths from version (#2384) Co-authored-by: jan --- scripts/release-video-suite.js | 41 ++++++++--- tests/scripts/release-video-suite.test.js | 83 ++++++++++++++++------- 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/scripts/release-video-suite.js b/scripts/release-video-suite.js index d4975d5a6..39f40d772 100644 --- a/scripts/release-video-suite.js +++ b/scripts/release-video-suite.js @@ -5,9 +5,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -const RELEASE = '2.0.0-rc.1'; const SCHEMA_VERSION = 'ecc.release-video-suite.v1'; -const VIDEO_MANIFEST_PATH = `docs/releases/${RELEASE}/video-suite-production.md`; const HYPERGROWTH_DOC_PATH = 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md'; const REQUIRED_DOC_MARKERS = [ @@ -320,7 +318,7 @@ function usage() { console.log([ 'Usage: node scripts/release-video-suite.js [options]', '', - 'Validates the ECC 2.0 release video production lane without committing raw media paths.', + 'Validates the ECC 2.0 release video production lane for the package.json release version without committing raw media paths.', '', 'Options:', ' --format Output format (default: text)', @@ -455,6 +453,28 @@ function safeParseJson(text) { } } +function resolveRelease(packageJson, options = {}) { + if (typeof options.release === 'string' && options.release.trim()) { + return options.release.trim(); + } + + return typeof packageJson.version === 'string' ? packageJson.version.trim() : ''; +} + +function releaseDirFor(release) { + return `docs/releases/${release}`; +} + +function releasePathsFor(release) { + const releaseDir = releaseDirFor(release); + + return { + videoManifestPath: `${releaseDir}/video-suite-production.md`, + previewManifestPath: `${releaseDir}/preview-pack-manifest.md`, + launchChecklistPath: `${releaseDir}/launch-checklist.md`, + }; +} + function lineNumberForIndex(text, index) { return text.slice(0, index).split('\n').length; } @@ -841,17 +861,19 @@ function buildReport(options = {}) { const suiteRoot = options.suiteRoot ? path.resolve(options.suiteRoot) : ''; const skipProbe = Boolean(options.skipProbe); const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {}; + const release = resolveRelease(packageJson, options); + const releasePaths = releasePathsFor(release); const packageScripts = packageJson.scripts || {}; const packageFiles = Array.isArray(packageJson.files) ? packageJson.files : []; - const manifest = readText(rootDir, VIDEO_MANIFEST_PATH); + const manifest = readText(rootDir, releasePaths.videoManifestPath); const hypergrowth = readText(rootDir, HYPERGROWTH_DOC_PATH); const missingDocMarkers = REQUIRED_DOC_MARKERS.filter(marker => !manifest.includes(marker)); const forbiddenPaths = scanForbiddenPaths(rootDir, [ - VIDEO_MANIFEST_PATH, + releasePaths.videoManifestPath, HYPERGROWTH_DOC_PATH, - `docs/releases/${RELEASE}/preview-pack-manifest.md`, - `docs/releases/${RELEASE}/launch-checklist.md`, + releasePaths.previewManifestPath, + releasePaths.launchChecklistPath, ]); const sourceAssets = inspectSourceAssets(sourceRoot, skipProbe); const suiteArtifacts = inspectSuiteArtifacts(suiteRoot, skipProbe); @@ -875,7 +897,7 @@ function buildReport(options = {}) { 'video-suite-manifest-present', manifest && missingDocMarkers.length === 0 ? 'pass' : 'fail', manifest && missingDocMarkers.length === 0 - ? `${VIDEO_MANIFEST_PATH} includes the required production markers` + ? `${releasePaths.videoManifestPath} includes the required production markers` : `missing markers: ${missingDocMarkers.join(', ') || 'manifest file missing'}`, 'Restore the video production manifest and required production markers.' ), @@ -960,7 +982,7 @@ function buildReport(options = {}) { return { schema_version: SCHEMA_VERSION, - release: RELEASE, + release, generatedAt: options.generatedAt || new Date().toISOString(), root: rootDir, sourceRootConfigured: Boolean(sourceRoot), @@ -1090,6 +1112,7 @@ module.exports = { REQUIRED_SOURCE_ASSETS, REQUIRED_SUITE_ARTIFACTS, buildReport, + releasePathsFor, parseArgs, renderText, summarizeReport, diff --git a/tests/scripts/release-video-suite.test.js b/tests/scripts/release-video-suite.test.js index cc1143991..ca793aec5 100644 --- a/tests/scripts/release-video-suite.test.js +++ b/tests/scripts/release-video-suite.test.js @@ -19,6 +19,13 @@ const { summarizeReport, } = require(SCRIPT); +const CURRENT_RELEASE = require(path.join(__dirname, '..', '..', 'package.json')).version; +const RC_RELEASE = '2.0.0-rc.1'; + +function releaseDirFor(release) { + return `docs/releases/${release}`; +} + function createTempDir(prefix) { return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); } @@ -33,31 +40,39 @@ function writeFile(rootDir, relativePath, content = 'fixture') { fs.writeFileSync(targetPath, content); } -function seedRepo(rootDir, overrides = {}) { +function videoManifestContent(extra = '') { + return [ + '# ECC 2.0 Video Suite Production Manifest', + 'ECC_VIDEO_SOURCE_ROOT', + 'ECC_VIDEO_RELEASE_SUITE_ROOT', + 'Primary launch video', + 'video-use compatible workflow', + 'Self-Eval Gate', + 'Do Not Publish If', + 'Do not commit raw footage, transcript JSON, or timeline exports', + extra, + ].join('\n'); +} + +function seedRepo(rootDir, overrides = {}, options = {}) { + const release = options.release || CURRENT_RELEASE; + const releaseDir = releaseDirFor(release); const files = { 'package.json': JSON.stringify({ name: 'ecc-universal', + version: release, files: ['scripts/release-video-suite.js'], scripts: { 'release:video-suite': 'node scripts/release-video-suite.js', }, }, null, 2), - 'docs/releases/2.0.0-rc.1/video-suite-production.md': [ - '# ECC 2.0 Video Suite Production Manifest', - 'ECC_VIDEO_SOURCE_ROOT', - 'ECC_VIDEO_RELEASE_SUITE_ROOT', - 'Primary launch video', - 'video-use compatible workflow', - 'Self-Eval Gate', - 'Do Not Publish If', - 'Do not commit raw footage, transcript JSON, or timeline exports', - ].join('\n'), + [`${releaseDir}/video-suite-production.md`]: videoManifestContent(), 'docs/releases/2.0.0/ecc-2-hypergrowth-release-command-center.md': [ 'Keep raw absolute paths out of public docs', 'Pick final video cuts, upload after approval, and attach public URLs', ].join('\n'), - 'docs/releases/2.0.0-rc.1/preview-pack-manifest.md': 'video-suite-production.md', - 'docs/releases/2.0.0-rc.1/launch-checklist.md': 'release video suite', + [`${releaseDir}/preview-pack-manifest.md`]: 'video-suite-production.md', + [`${releaseDir}/launch-checklist.md`]: 'release video suite', }; for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) { @@ -171,6 +186,7 @@ function runTests() { }); assert.strictEqual(report.schema_version, 'ecc.release-video-suite.v1'); + assert.strictEqual(report.release, CURRENT_RELEASE); assert.strictEqual(report.ready, true); assert.strictEqual(report.mediaPathsRedacted, true); assert.ok(report.checks.every(check => check.status === 'pass')); @@ -194,6 +210,33 @@ function runTests() { } })) passed++; else failed++; + if (test('release override keeps rc.1 video fixtures testable', () => { + const rootDir = createTempDir('release-video-rc-'); + const sourceRoot = createTempDir('release-video-source-'); + const suiteRoot = createTempDir('release-video-suite-'); + + try { + seedRepo(rootDir, {}, { release: RC_RELEASE }); + seedMedia(sourceRoot, suiteRoot); + + const report = buildReport({ + root: rootDir, + release: RC_RELEASE, + sourceRoot, + suiteRoot, + skipProbe: true, + generatedAt: '2026-05-19T00:00:00.000Z', + }); + + assert.strictEqual(report.release, RC_RELEASE); + assert.strictEqual(report.ready, true); + } finally { + cleanup(rootDir); + cleanup(sourceRoot); + cleanup(suiteRoot); + } + })) passed++; else failed++; + if (test('publish candidate videos require visual blank-frame QA', () => { const publishVideos = REQUIRED_PUBLISH_CANDIDATES.filter(candidate => candidate.kind === 'video'); @@ -231,18 +274,9 @@ function runTests() { const suiteRoot = createTempDir('release-video-suite-'); try { + const releaseDir = releaseDirFor(CURRENT_RELEASE); seedRepo(rootDir, { - 'docs/releases/2.0.0-rc.1/video-suite-production.md': [ - '# ECC 2.0 Video Suite Production Manifest', - 'ECC_VIDEO_SOURCE_ROOT', - 'ECC_VIDEO_RELEASE_SUITE_ROOT', - 'Primary launch video', - 'video-use compatible workflow', - 'Self-Eval Gate', - 'Do Not Publish If', - 'Do not commit raw footage, transcript JSON, or timeline exports', - '/Users/affoon/private-media', - ].join('\n'), + [`${releaseDir}/video-suite-production.md`]: videoManifestContent('/Users/affoon/private-media'), }); seedMedia(sourceRoot, suiteRoot); @@ -283,6 +317,7 @@ function runTests() { const parsed = JSON.parse(output); assert.strictEqual(parsed.ready, true); + assert.strictEqual(parsed.release, CURRENT_RELEASE); assert.strictEqual(parsed.sourceRootConfigured, true); assert.strictEqual(parsed.suiteRootConfigured, true); assert.strictEqual(parsed.sourceAssetSummary.present, REQUIRED_SOURCE_ASSETS.length); From 00b443eddb7ab9c752691d3bd6e1e41b74a697e8 Mon Sep 17 00:00:00 2001 From: Yang Cheng Date: Tue, 30 Jun 2026 06:51:00 +0800 Subject: [PATCH 011/197] ci: isolate OMP workflow verification (#2382) Co-authored-by: jan --- .github/workflows/ci.yml | 29 +++++++++++++++++++++----- .github/workflows/release.yml | 3 +++ .github/workflows/reusable-release.yml | 3 +++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b466158e..cd72b3a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -171,6 +171,28 @@ jobs: run: node scripts/ci/validate-no-personal-paths.js continue-on-error: false + python-tests: + name: Python Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: python -m pip install --upgrade pip && python -m pip install -e '.[dev]' + + - name: Run Python tests + run: python -m pytest tests/test_*.py -m "not integration" + security: name: Security Scan runs-on: ubuntu-latest @@ -246,8 +268,5 @@ jobs: - name: Install dependencies run: npm ci --ignore-scripts - - name: Run ESLint - run: npx eslint scripts/**/*.js tests/**/*.js - - - name: Run markdownlint - run: npx markdownlint "agents/**/*.md" "skills/**/*.md" "commands/**/*.md" "rules/**/*.md" + - name: Run lint + run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 36c973c32..4e2c18b6e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,9 @@ jobs: - name: Verify OpenCode package payload run: node tests/scripts/build-opencode.test.js + - name: Verify OMP adapter payload + run: node tests/omp/omp-plugin.test.js + - name: Validate version tag run: | if ! [[ "${REF_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 936e136ee..e3d1f02f6 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -62,6 +62,9 @@ jobs: - name: Verify OpenCode package payload run: node tests/scripts/build-opencode.test.js + - name: Verify OMP adapter payload + run: node tests/omp/omp-plugin.test.js + - name: Validate version tag env: INPUT_TAG: ${{ inputs.tag }} From acd078f59e0b81f88e5b69ab0554057c746faa1d Mon Sep 17 00:00:00 2001 From: Tahiti18 <35585881+Tahiti18@users.noreply.github.com> Date: Tue, 30 Jun 2026 01:54:49 +0300 Subject: [PATCH 012/197] fix(tests): resolve 10 failing tests on Windows (#2307) - resolve-formatter: stop findProjectRoot walk before os.homedir() to avoid mistaking global dotfiles (e.g. ~/.prettierrc) for a project root - instinct-cli-projects: detect python3/python binary at runtime; skip gracefully when Python 3 is unavailable instead of crashing with null status - command-registry: regenerate COMMAND-REGISTRY.json (was stale) Co-authored-by: Claude Sonnet 4.6 --- scripts/lib/resolve-formatter.js | 5 +++++ tests/scripts/instinct-cli-projects.test.js | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/scripts/lib/resolve-formatter.js b/scripts/lib/resolve-formatter.js index a118752f9..3fec591c9 100644 --- a/scripts/lib/resolve-formatter.js +++ b/scripts/lib/resolve-formatter.js @@ -9,6 +9,7 @@ 'use strict'; const fs = require('fs'); +const os = require('os'); const path = require('path'); // ── Caches (per-process, cleared on next hook invocation) ─────────── @@ -58,8 +59,12 @@ const FORMATTER_PACKAGES = { function findProjectRoot(startDir) { if (projectRootCache.has(startDir)) return projectRootCache.get(startDir); + const homeDir = os.homedir(); let dir = startDir; while (dir !== path.dirname(dir)) { + // Stop before checking the home directory to avoid treating global + // dotfiles (e.g. ~/.prettierrc) as a project root marker. + if (dir === homeDir) break; for (const marker of PROJECT_ROOT_MARKERS) { if (fs.existsSync(path.join(dir, marker))) { projectRootCache.set(startDir, dir); diff --git a/tests/scripts/instinct-cli-projects.test.js b/tests/scripts/instinct-cli-projects.test.js index d2d95161f..73289f73a 100644 --- a/tests/scripts/instinct-cli-projects.test.js +++ b/tests/scripts/instinct-cli-projects.test.js @@ -17,6 +17,23 @@ const cliPath = path.join( 'instinct-cli.py' ); +function detectPython3() { + for (const bin of ['python3', 'python']) { + const r = spawnSync(bin, ['--version'], { encoding: 'utf8' }); + if (r.status === 0 && /Python 3/.test(r.stdout + r.stderr)) return bin; + } + return null; +} + +const PYTHON3 = detectPython3(); +if (!PYTHON3) { + console.log('\n=== Testing instinct-cli.py projects maintenance ===\n'); + console.log(' - skipped: Python 3 not found in PATH'); + console.log('\nPassed: 0'); + console.log('Failed: 0'); + process.exit(0); +} + function test(name, fn) { try { fn(); @@ -101,7 +118,7 @@ function runGit(cwd, args) { } function runCli(root, args, options = {}) { - return spawnSync('python3', [cliPath, ...args], { + return spawnSync(PYTHON3, [cliPath, ...args], { cwd: options.cwd || repoRoot, encoding: 'utf8', env: { From b1d5d6366d932bf2f35cfc2e182b3d4d65ebbcb0 Mon Sep 17 00:00:00 2001 From: phobicdotno Date: Tue, 30 Jun 2026 00:54:52 +0200 Subject: [PATCH 013/197] fix(hooks): quote args when probing Windows .cmd MCP servers via shell (#2343) On Windows, when a bare-name MCP server command (e.g. codesys-mcp-sp21-plus) falls back to the .cmd candidate, the probe sets shell:true to work around Node 18.20+ CVE-2024-27980. However, passing an args array alongside shell:true causes Node to concatenate the tokens without quoting (DEP0190), so an arg containing a space (e.g. --codesys-path "C:\Program Files\...") is re-split by cmd.exe at every space boundary. The child process receives a truncated path, fails to launch, and the probe declares the server unavailable, falsely blocking every MCP tool call to that server. Fix: add a quoteWin() helper that double-quotes any token containing whitespace or cmd metacharacters. In the useShell branch, build a single properly-quoted command line string and pass it as the sole argument to spawn() with no separate args array. The else branch (shell:false, all non-.cmd commands) is unchanged. Regression test added: on Windows, creates a .cmd shim that echoes its first positional argument to stderr, probes it with a space-containing path arg, and asserts the probe succeeds and the arg was not split at the space boundary. Co-authored-by: Karstein Phobic Nyvold Kvistad --- scripts/hooks/mcp-health-check.js | 41 +++++++++++++--- tests/hooks/mcp-health-check.test.js | 71 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index b880d7d97..475e4aa73 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -338,6 +338,21 @@ function probeCommandServer(serverName, config) { // through shell mode. const UNSAFE_SHELL_CHARS = /[&|<>^%!()\s;]/; + // When spawning via cmd.exe (shell:true) on Windows, Node concatenates + // command + args WITHOUT quoting (DEP0190). An arg containing a space — + // such as a path under "C:\Program Files" — gets re-split by cmd.exe. + // Build a properly-quoted command line instead and pass it as a single + // string with no args array, so cmd.exe sees each token as one unit. + function quoteWin(token) { + // If the token has no characters that need quoting, return it as-is. + if (!/[\s"&|<>^%!();]/.test(token)) { + return token; + } + // Escape embedded double quotes by doubling them, then wrap in double + // quotes. cmd.exe uses "" as an escaped quote inside a quoted string. + return '"' + token.replace(/"/g, '""') + '"'; + } + function attempt(idx) { const tryCommand = candidates[idx]; const isLast = idx + 1 >= candidates.length; @@ -375,12 +390,26 @@ function probeCommandServer(serverName, config) { let child; try { - child = spawn(tryCommand, args, { - env: mergedEnv, - cwd: process.cwd(), - stdio: ['pipe', 'ignore', 'pipe'], - shell: useShell - }); + if (useShell) { + // Build a single quoted command line for cmd.exe. Passing an args + // array with shell:true causes Node to concatenate without quoting + // (DEP0190), which splits space-containing args (e.g. paths under + // "C:\Program Files") at every space boundary. + const quotedCmdline = [tryCommand, ...args].map(quoteWin).join(' '); + child = spawn(quotedCmdline, { + env: mergedEnv, + cwd: process.cwd(), + stdio: ['pipe', 'ignore', 'pipe'], + shell: true + }); + } else { + child = spawn(tryCommand, args, { + env: mergedEnv, + cwd: process.cwd(), + stdio: ['pipe', 'ignore', 'pipe'], + shell: false + }); + } } catch (error) { if ((error.code === 'ENOENT' || error.code === 'EINVAL') && !isLast) { retryNext(); diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 1fb56cfce..fa05fa670 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -1121,6 +1121,77 @@ async function runTests() { } })) passed++; else failed++; + // Windows-only: when a .cmd shim is probed via shell:true, args that contain + // spaces (e.g. paths under "C:\Program Files") must be passed as a single + // quoted token, not split by cmd.exe at every space boundary. + if (process.platform === 'win32') { + if (await asyncTest('windows: .cmd probe preserves space-containing args as single tokens', async () => { + const tempDir = createTempDir(); + const binDir = path.join(tempDir, 'bin'); + const configPath = path.join(tempDir, 'claude.json'); + const statePath = path.join(tempDir, 'mcp-health.json'); + + fs.mkdirSync(binDir, { recursive: true }); + + // This .cmd script writes its first argument verbatim to stderr and then + // keeps running so the probe can time out (= healthy). We inspect stderr + // to verify cmd.exe received the spaced path as one argument, not split. + const cmdPath = path.join(binDir, 'spacedarg.cmd'); + fs.writeFileSync( + cmdPath, + ['@echo off', 'echo ARG1=[%1] 1>&2', 'node -e "setInterval(()=>{},1000)"', ''].join('\r\n') + ); + + // A path containing a space — the canonical trigger for the DEP0190 bug. + const spacedPath = 'C:\\Program Files\\Some Server\\server.exe'; + + try { + writeConfig(configPath, { + mcpServers: { + spacedarg: { + command: 'spacedarg', + args: ['--codesys-path', spacedPath] + } + } + }); + + const input = { tool_name: 'mcp__spacedarg__ping', tool_input: {} }; + const result = runHook(input, { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_MCP_CONFIG_PATH: configPath, + ECC_MCP_HEALTH_STATE_PATH: statePath, + ECC_MCP_HEALTH_TIMEOUT_MS: '800', + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + }); + + assert.strictEqual( + result.code, + 0, + `Expected .cmd probe with spaced arg to succeed: ${hookFailureDetails(result, statePath)}` + ); + + const state = readState(statePath); + assert.strictEqual( + state.servers.spacedarg.status, + 'healthy', + 'Expected server with space-containing arg to be marked healthy' + ); + + // The .cmd echo writes its first positional (%1). If the path was split + // at the space, %1 would be "C:\Program" (no quotes, no "Files" part). + // If properly quoted, %1 is the full quoted path. + assert.ok( + !result.stderr.includes('ARG1=[C:\\Program]'), + `Space-containing arg was split by cmd.exe (DEP0190 bug still present). stderr: ${result.stderr}` + ); + } finally { + cleanupTempDir(tempDir); + } + })) passed++; else failed++; + } else { + console.log(' - skipped: windows: .cmd probe preserves space-containing args as single tokens (non-Windows)'); + } + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 88f6894267153f15783212e4643d0dff85fa7f23 Mon Sep 17 00:00:00 2001 From: "SSH._.WORLD" Date: Tue, 30 Jun 2026 07:54:55 +0900 Subject: [PATCH 014/197] fix(hooks): guard doc-file-warning stdin listeners behind require.main (#2358) * fix(hooks): guard doc-file-warning stdin listeners behind require.main doc-file-warning.js registered process.stdin data/end listeners at module scope while also exporting run(). run-with-flags.js require()s any hook that exports run() for its in-process fast path, so importing this hook attached stray stdin listeners to the dispatcher process, corrupting the PreToolUse stdout JSON contract. This is the exact failure run-with-flags' own SAFETY comment warns about, and 24 sibling hooks already guard against it. - Move the stdin entrypoint into main() and gate it behind require.main === module - pre-write-doc-warn.js now calls main() explicitly instead of relying on the import side effect - Add regression tests: require() attaches no stdin listeners, run()/main() stay exported, and the pre-write-doc-warn shim still warns * docs(hooks): add JSDoc for doc-file-warning main() entrypoint Satisfies the docstring-coverage pre-merge check; documents the stdin entrypoint and why it must not run on require(). --- scripts/hooks/doc-file-warning.js | 54 +++++++++++++++++----------- scripts/hooks/pre-write-doc-warn.js | 3 +- tests/hooks/doc-file-warning.test.js | 41 +++++++++++++++++++++ 3 files changed, 76 insertions(+), 22 deletions(-) diff --git a/scripts/hooks/doc-file-warning.js b/scripts/hooks/doc-file-warning.js index d510b33f5..40d0282ab 100644 --- a/scripts/hooks/doc-file-warning.js +++ b/scripts/hooks/doc-file-warning.js @@ -17,7 +17,6 @@ const path = require('path'); const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output'); const MAX_STDIN = 1024 * 1024; -let data = ''; // Known ad-hoc filenames that indicate impulse/scratch files (case-sensitive, uppercase only) const ADHOC_FILENAMES = /^(NOTES|TODO|SCRATCH|TEMP|DRAFT|BRAINSTORM|SPIKE|DEBUG|WIP)\.(md|txt)$/; @@ -70,27 +69,40 @@ function run(inputOrRaw, _options = {}) { return { exitCode: 0 }; } -module.exports = { run }; +/** + * Stdin entrypoint for direct/spawnSync execution: reads the hook payload from + * stdin (capped at MAX_STDIN), runs the policy, and writes the PreToolUse result + * to stdout. Must only run when invoked directly, never on require(), so the + * stdin listeners are not leaked into a parent that loads this hook in-process. + */ +function main() { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', c => { + if (data.length < MAX_STDIN) { + const remaining = MAX_STDIN - data.length; + data += c.substring(0, remaining); + } + }); -// Stdin fallback for spawnSync execution -process.stdin.setEncoding('utf8'); -process.stdin.on('data', c => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += c.substring(0, remaining); - } -}); + process.stdin.on('end', () => { + const result = run(data); -process.stdin.on('end', () => { - const result = run(data); + if (result.stderr) { + process.stderr.write(result.stderr + '\n'); + } - if (result.stderr) { - process.stderr.write(result.stderr + '\n'); - } + if (Object.prototype.hasOwnProperty.call(result, 'additionalContext')) { + process.stdout.write(buildPreToolUseAdditionalContext(result.additionalContext)); + } else { + process.stdout.write(data); + } + }); +} - if (Object.prototype.hasOwnProperty.call(result, 'additionalContext')) { - process.stdout.write(buildPreToolUseAdditionalContext(result.additionalContext)); - } else { - process.stdout.write(data); - } -}); +module.exports = { run, main }; + +// Stdin fallback for spawnSync execution — only when invoked directly, not via require() +if (require.main === module) { + main(); +} diff --git a/scripts/hooks/pre-write-doc-warn.js b/scripts/hooks/pre-write-doc-warn.js index ca515111b..856b51597 100644 --- a/scripts/hooks/pre-write-doc-warn.js +++ b/scripts/hooks/pre-write-doc-warn.js @@ -6,4 +6,5 @@ 'use strict'; -require('./doc-file-warning.js'); +// doc-file-warning.js guards its stdin entrypoint behind require.main; call main() explicitly. +require('./doc-file-warning.js').main(); diff --git a/tests/hooks/doc-file-warning.test.js b/tests/hooks/doc-file-warning.test.js index 2c0b3b6e9..fd2411327 100644 --- a/tests/hooks/doc-file-warning.test.js +++ b/tests/hooks/doc-file-warning.test.js @@ -218,6 +218,47 @@ function runTests() { assert.strictEqual(stdout, JSON.stringify(input)); }) ? passed++ : failed++); + // 11. Regression: requiring the hook in-process (run-with-flags fast path) must not + // attach module-scope stdin listeners to the dispatcher process. + (test('require() does not attach stdin listeners (in-process safe)', () => { + const resolved = require.resolve(script); + delete require.cache[resolved]; + const endBefore = process.stdin.listenerCount('end'); + const dataBefore = process.stdin.listenerCount('data'); + const mod = require(resolved); + assert.strictEqual(process.stdin.listenerCount('end'), endBefore, + 'require() must not attach a stdin "end" listener'); + assert.strictEqual(process.stdin.listenerCount('data'), dataBefore, + 'require() must not attach a stdin "data" listener'); + assert.strictEqual(typeof mod.run, 'function', 'run() must remain exported'); + assert.strictEqual(typeof mod.main, 'function', 'main() must be exported for entrypoints'); + }) ? passed++ : failed++); + + // 12. Regression: exported run() still classifies correctly in-process, no stdin needed. + (test('exported run() works in-process for warned and allowed files', () => { + delete require.cache[require.resolve(script)]; + const { run } = require(script); + const warned = run(JSON.stringify({ tool_input: { file_path: 'TODO.md' } })); + assert.ok(Array.isArray(warned.additionalContext) + && warned.additionalContext.join('\n').includes('WARNING'), + 'warned file should return additionalContext with WARNING'); + const allowed = run(JSON.stringify({ tool_input: { file_path: 'README.md' } })); + assert.ok(!('additionalContext' in allowed), 'allowed file should not return additionalContext'); + }) ? passed++ : failed++); + + // 13. Regression: pre-write-doc-warn.js backward-compat entrypoint still emits the warning. + (test('pre-write-doc-warn.js shim still warns via main()', () => { + const shim = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'pre-write-doc-warn.js'); + const r = spawnSync('node', [shim], { + encoding: 'utf8', + input: JSON.stringify({ tool_input: { file_path: 'TODO.md' } }), + timeout: 10000, + }); + assert.strictEqual(r.status || 0, 0, 'shim should exit 0'); + assert.ok(JSON.parse(r.stdout).hookSpecificOutput.additionalContext.includes('TODO.md'), + 'shim should still emit the ad-hoc filename warning'); + }) ? passed++ : failed++); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From 64797fd895c608b9c1f47f9b27b9e00b0aa6ecbf Mon Sep 17 00:00:00 2001 From: ChrisD Date: Mon, 29 Jun 2026 18:54:58 -0400 Subject: [PATCH 015/197] fix(windows): prefer PowerShell over bash to prevent zombie process accumulation (#2346) * fix(windows): prefer PowerShell over bash to prevent zombie process accumulation On Windows, ECC hook scripts were spawning bash.exe (MSYS2/Git Bash) on every tool use via findShellBinary(). These processes were not reaped by Windows, causing 40+ zombie bash.exe/conhost.exe processes per session with noticeable system lag. Changes to scripts/hooks/plugin-hook-bootstrap.js: - Add isPowerShellBin(bin) helper: basename-based detection so full paths like C:\Windows\...\powershell.exe are handled correctly - findShellBinary(): check BASH env var first (preserves escape hatch), then on win32 probe pwsh.exe -> powershell.exe -> bash.exe -> bash; use correct probe args per shell type; cache result in _cachedShell - findBashBinary(): separate cached bash-only finder used by spawnShell .sh fallback; skips PowerShell binaries even if BASH points to one - spawnShell(): use isPowerShellBin() to select -NoProfile -NonInteractive -File args for PowerShell; .sh scripts fall back to findBashBinary() with a skip-warning if no bash found on Windows observe-runner.js is intentionally unchanged: it always invokes observe.sh which is bash-only; routing it through PowerShell would silently break it. The observe.sh -> observe.js migration is tracked separately. Fixes #2345 * fix(windows): address CodeRabbit and Greptile review comments - Add timeout: 30000 to all spawnSync probe calls in findShellBinary and findBashBinary to prevent hangs on broken/stalled shell candidates - Add -ExecutionPolicy Bypass to PowerShell -File invocation to fix execution on machines with the default Restricted policy (Win10/11) - Add PowerShell availability skip guard to PS selection test (mirrors existing bash skip guard) - Fix no-bash test to keep PowerShell on PATH so the .sh fallback branch is actually exercised rather than hitting shell-unavailable early exit * test: add timeout to spawnSync probes in Windows test skip guards --------- Co-authored-by: Christopher J Diamond --- scripts/hooks/plugin-hook-bootstrap.js | 87 ++++++++++++++++++++- tests/hooks/plugin-hook-bootstrap.test.js | 95 +++++++++++++++++++++++ 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/scripts/hooks/plugin-hook-bootstrap.js b/scripts/hooks/plugin-hook-bootstrap.js index cc6724ee7..7a787ec2f 100644 --- a/scripts/hooks/plugin-hook-bootstrap.js +++ b/scripts/hooks/plugin-hook-bootstrap.js @@ -58,28 +58,73 @@ function resolveTarget(rootDir, relPath) { return resolvedTarget; } +let _cachedShell = undefined; +let _cachedBash = undefined; + +function isPowerShellBin(bin) { + const base = path.basename(bin).toLowerCase(); + return base === 'pwsh.exe' || base === 'pwsh' || base === 'powershell.exe' || base === 'powershell'; +} + function findShellBinary() { + if (_cachedShell !== undefined) return _cachedShell; + const candidates = []; + + // Explicit override always wins — check before any platform probing. + // Warning: setting BASH to a bash binary on Windows bypasses the PowerShell + // preference and may reintroduce bash.exe zombie accumulation. if (process.env.BASH && process.env.BASH.trim()) { candidates.push(process.env.BASH.trim()); } if (process.platform === 'win32') { - candidates.push('bash.exe', 'bash'); + // Prefer PowerShell on Windows — it is native and does not leave zombie + // bash.exe / conhost.exe processes the way MSYS2/Git Bash does. + // Note: PowerShell is only suitable for .ps1 scripts; callers that need + // to run .sh scripts (e.g. observe-runner.js) must not use this function. + candidates.push('pwsh.exe', 'powershell.exe', 'bash.exe', 'bash'); } else { candidates.push('bash', 'sh'); } + const psProbeArgs = ['-NoProfile', '-NonInteractive', '-Command', 'exit 0']; + const shProbeArgs = ['-c', ':']; + for (const candidate of candidates) { - const probe = spawnSync(candidate, ['-c', ':'], { + const probe = spawnSync(candidate, isPowerShellBin(candidate) ? psProbeArgs : shProbeArgs, { stdio: 'ignore', windowsHide: true, + timeout: 30000, }); if (!probe.error) { - return candidate; + _cachedShell = candidate; + return _cachedShell; } } + _cachedShell = null; + return null; +} + +function findBashBinary() { + if (_cachedBash !== undefined) return _cachedBash; + + const candidates = []; + if (process.env.BASH && process.env.BASH.trim() && !isPowerShellBin(process.env.BASH.trim())) { + candidates.push(process.env.BASH.trim()); + } + candidates.push('bash.exe', 'bash'); + + for (const candidate of candidates) { + const probe = spawnSync(candidate, ['-c', ':'], { stdio: 'ignore', windowsHide: true, timeout: 30000 }); + if (!probe.error) { + _cachedBash = candidate; + return _cachedBash; + } + } + + _cachedBash = null; return null; } @@ -100,6 +145,10 @@ function spawnNode(rootDir, relPath, raw, args) { }); } +// spawnShell is not used by any hook in the shipped hooks.json configuration +// (all hooks use 'node' mode). It is provided for third-party plugins that +// register shell-backed hooks. Plugins should supply .ps1 scripts on Windows +// and .sh scripts on Unix; mixing them will produce a skip with a stderr warning. function spawnShell(rootDir, relPath, raw, args) { const shell = findShellBinary(); if (!shell) { @@ -116,7 +165,37 @@ function spawnShell(rootDir, relPath, raw, args) { CLAUDE_PLUGIN_ROOT: rootDir, ECC_PLUGIN_ROOT: rootDir, }; - return spawnSync(shell, [resolveTarget(rootDir, relPath), ...args], { + const scriptPath = resolveTarget(rootDir, relPath); + const isPs = isPowerShellBin(shell); + + // PowerShell cannot interpret bash scripts — fall back to a bash candidate + // rather than silently failing the hook. + if (isPs && scriptPath.endsWith('.sh')) { + const bash = findBashBinary(); + if (!bash) { + return { + status: 0, + stdout: '', + stderr: '[Hook] .sh script requested but no bash binary found on Windows; skipping\n', + }; + } + return spawnSync(bash, [scriptPath, ...args], { + input: raw, + encoding: 'utf8', + env: hookEnv, + cwd: process.cwd(), + timeout: 30000, + windowsHide: true, + }); + } + + const shellArgs = isPs + // -ExecutionPolicy Bypass: default Windows policy (Restricted) blocks -File + // execution of .ps1 scripts; Bypass scopes only to this child process. + ? ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args] + : [scriptPath, ...args]; + + return spawnSync(shell, shellArgs, { input: raw, encoding: 'utf8', env: hookEnv, diff --git a/tests/hooks/plugin-hook-bootstrap.test.js b/tests/hooks/plugin-hook-bootstrap.test.js index b5a913d32..694e44004 100644 --- a/tests/hooks/plugin-hook-bootstrap.test.js +++ b/tests/hooks/plugin-hook-bootstrap.test.js @@ -292,6 +292,101 @@ process.exit(7); } })) passed++; else failed++; + // Windows-only: PowerShell preference and .sh fallback behaviour. + if (process.platform === 'win32') { + if (test('shell mode selects PowerShell when BASH is unset on Windows', () => { + // Skip if no PowerShell is available. + const psProbe = spawnSync('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 }); + const ps = psProbe.error + ? spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { stdio: 'ignore', timeout: 5000 }).error + ? null : 'powershell.exe' + : 'pwsh.exe'; + if (!ps) { + console.log(' SKIP: no PowerShell found'); + return; + } + + const root = createTempDir(); + try { + // UTF8 encoding set explicitly — PowerShell 5.1 defaults to UTF-16LE. + writeFile(root, path.join('scripts', 'hook.ps1'), [ + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '$OutputEncoding = [System.Text.Encoding]::UTF8', + '$input_data = [Console]::In.ReadToEnd()', + 'Write-Host -NoNewline ("ps1:" + $args[0] + ":" + $input_data)', + ].join('\n')); + + const result = run(['shell', path.join('scripts', 'hook.ps1'), 'arg'], { + root, + input: 'payload', + env: { BASH: '' }, + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, 'ps1:arg:payload'); + } finally { + cleanup(root); + } + })) passed++; else failed++; + + if (test('shell mode falls back to bash for .sh scripts when PowerShell is the resolved shell', () => { + // Skip if no bash is available (headless CI without Git for Windows). + const bashProbe = spawnSync('bash.exe', ['-c', ':'], { stdio: 'ignore', timeout: 5000 }); + if (bashProbe.error) { + console.log(' SKIP: bash.exe not found'); + return; + } + + const root = createTempDir(); + try { + writeFile(root, path.join('scripts', 'hook.sh'), [ + 'input=$(cat)', + 'printf "sh:%s:%s" "$1" "$input"', + '', + ].join('\n')); + + // Clear BASH so PowerShell is resolved first, but script is .sh. + const result = run(['shell', path.join('scripts', 'hook.sh'), 'arg'], { + root, + input: 'payload', + env: { BASH: '' }, + }); + + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, 'sh:arg:payload'); + } finally { + cleanup(root); + } + })) passed++; else failed++; + + if (test('shell mode emits skip warning for .sh script when no bash found on Windows', () => { + const root = createTempDir(); + try { + writeFile(root, path.join('scripts', 'hook.sh'), 'printf unreachable\n'); + + // Keep PowerShell on PATH so it is resolved as the shell, then strip + // bash candidates so the .sh fallback path hits the skip-warning branch. + const result = run(['shell', path.join('scripts', 'hook.sh')], { + root, + input: 'raw-input', + env: { BASH: '', PATH: process.env.SystemRoot + ? `${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0;${process.env.SystemRoot}\\System32` + : '' }, + }); + + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, 'raw-input'); + assert.ok( + result.stderr.includes('no bash binary found') || + result.stderr.includes('shell runtime unavailable'), + `unexpected stderr: ${result.stderr}` + ); + } finally { + cleanup(root); + } + })) passed++; else failed++; + } + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } From c2950121c9dd8ced1f920f0cbb340017fd6830a7 Mon Sep 17 00:00:00 2001 From: Hiroshi Tanaka Date: Tue, 30 Jun 2026 07:55:01 +0900 Subject: [PATCH 016/197] feat(session): LLM-powered session summary via claude -p (#2388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace mechanical text extraction in session-end.js and pre-compact.js with LLM-generated summaries using `claude -p`. Summaries now capture design decisions, resolved bugs, changed files, and carry-over context rather than just truncated user message snippets. - Add scripts/lib/llm-summary.js: generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel - Update scripts/hooks/session-end.js: trigger LLM when context < 20% or every 50 messages (env-configurable via ECC_LLM_SUMMARY_*) - Update scripts/hooks/pre-compact.js: generate LLM summary right before compaction and write it to the active session .tmp file - Add tests/lib/llm-summary.test.js: 18 unit tests - Update tests/hooks/hooks.test.js: 3 integration tests for new behaviour Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so Stop hooks fired by the claude -p subprocess do not re-enter summarisation. Requires no ANTHROPIC_API_KEY — reuses Claude Code's own authentication. Co-authored-by: Hiroshi Tanaka Co-authored-by: Claude Sonnet 4.6 --- scripts/hooks/pre-compact.js | 102 +++++++--- scripts/hooks/session-end.js | 58 +++--- scripts/lib/llm-summary.js | 176 +++++++++++++++++ tests/hooks/hooks.test.js | 360 ++++++++++++++++------------------ tests/lib/llm-summary.test.js | 199 +++++++++++++++++++ 5 files changed, 654 insertions(+), 241 deletions(-) create mode 100644 scripts/lib/llm-summary.js create mode 100644 tests/lib/llm-summary.test.js diff --git a/scripts/hooks/pre-compact.js b/scripts/hooks/pre-compact.js index 5ea468f5d..235b2b097 100644 --- a/scripts/hooks/pre-compact.js +++ b/scripts/hooks/pre-compact.js @@ -1,48 +1,100 @@ #!/usr/bin/env node /** - * PreCompact Hook - Save state before context compaction + * PreCompact Hook - Save LLM-generated summary before context compaction * * Cross-platform (Windows, macOS, Linux) * - * Runs before Claude compacts context, giving you a chance to - * preserve important state that might get lost in summarization. + * Runs before Claude compacts context. Generates a rich LLM summary of the + * current session and writes it to the active session .tmp file so that the + * next session start gets a high-quality summary even after lossy compaction. + * + * Falls back to a plain log entry when transcript_path is unavailable or the + * LLM call fails. */ const path = require('path'); -const { - getSessionsDir, - getDateTimeString, - getTimeString, - findFiles, - ensureDir, - appendFile, - log -} = require('../lib/utils'); +const fs = require('fs'); +const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils'); +const { generateSessionSummary } = require('../lib/llm-summary'); + +const SUMMARY_START_MARKER = ''; +const SUMMARY_END_MARKER = ''; + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const MAX_STDIN = 1024 * 1024; +let stdinData = ''; +process.stdin.setEncoding('utf8'); + +process.stdin.on('data', chunk => { + if (stdinData.length < MAX_STDIN) { + stdinData += chunk.substring(0, MAX_STDIN - stdinData.length); + } +}); + +process.stdin.on('end', () => { + main().catch(err => { + log(`[PreCompact] Error: ${err.message}`); + process.exit(0); + }); +}); async function main() { + let transcriptPath = null; + try { + const input = JSON.parse(stdinData); + if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) { + transcriptPath = input.transcript_path; + } + } catch { + // stdin not JSON or missing — proceed without transcript + } + const sessionsDir = getSessionsDir(); const compactionLog = path.join(sessionsDir, 'compaction-log.txt'); ensureDir(sessionsDir); - // Log compaction event with timestamp const timestamp = getDateTimeString(); appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`); - // If there's an active session file, note the compaction const sessions = findFiles(sessionsDir, '*-session.tmp'); - - if (sessions.length > 0) { - const activeSession = sessions[0].path; - const timeStr = getTimeString(); - appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`); + if (sessions.length === 0) { + log('[PreCompact] No active session file found'); + process.exit(0); + } + + const activeSession = sessions[0].path; + const timeStr = getTimeString(); + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`); + log('[PreCompact] No transcript available; logged compaction event only'); + process.exit(0); + } + + // Generate LLM summary right before compaction — most critical timing + log('[PreCompact] Generating LLM summary before compaction...'); + const llmSummary = generateSessionSummary(transcriptPath); + + if (!llmSummary) { + appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`); + log('[PreCompact] LLM summary unavailable; logged compaction event only'); + process.exit(0); + } + + const existing = readFile(activeSession); + if (existing && existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) { + const newBlock = `${SUMMARY_START_MARKER}\n${llmSummary}\n\n${SUMMARY_END_MARKER}`; + const updated = existing.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => newBlock); + writeFile(activeSession, updated); + log('[PreCompact] LLM summary written to session file before compaction'); + } else { + appendFile(activeSession, `\n---\n**[Compaction at ${timeStr}]**\n\n${llmSummary}\n`); + log('[PreCompact] LLM summary appended (no summary markers found)'); } - log('[PreCompact] State saved before compaction'); process.exit(0); } - -main().catch(err => { - console.error('[PreCompact] Error:', err.message); - process.exit(0); -}); diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 8d0d4a28e..c224371aa 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -11,20 +11,8 @@ const path = require('path'); const fs = require('fs'); -const { - getSessionsDir, - getDateString, - getTimeString, - getSessionIdShort, - sanitizeSessionId, - getProjectName, - ensureDir, - readFile, - writeFile, - runCommand, - stripAnsi, - log -} = require('../lib/utils'); +const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils'); +const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary'); const SUMMARY_START_MARKER = ''; const SUMMARY_END_MARKER = ''; @@ -55,11 +43,7 @@ function extractSessionSummary(transcriptPath) { if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') { // Support both direct content and nested message.content (Claude Code JSONL format) const rawContent = entry.message?.content ?? entry.content; - const text = typeof rawContent === 'string' - ? rawContent - : Array.isArray(rawContent) - ? rawContent.map(c => (c && c.text) || '').join(' ') - : ''; + const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; const cleaned = stripAnsi(text).trim(); if (cleaned) { userMessages.push(cleaned.slice(0, 200)); @@ -217,7 +201,9 @@ async function main() { shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase()); } } - if (!shortId) { shortId = getSessionIdShort(); } + if (!shortId) { + shortId = getSessionIdShort(); + } const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); const sessionMetadata = getSessionMetadata(); @@ -236,6 +222,26 @@ async function main() { } } + // Decide whether to call LLM for a richer summary. + // Triggers: context remaining < 20%, or every 50 user messages as a baseline. + let llmSummary = null; + if (transcriptPath && summary && fs.existsSync(transcriptPath)) { + const contextPct = getContextRemainingPct(transcriptPath); + const isContextLow = contextPct !== null && contextPct < getContextThreshold(); + const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10); + const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50; + const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0; + if (isContextLow || isPeriodicTurn) { + log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); + llmSummary = generateSessionSummary(transcriptPath); + if (llmSummary) { + log('[SessionEnd] LLM summary generated successfully'); + } else { + log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); + } + } + } + if (fs.existsSync(sessionFile)) { const existing = readFile(sessionFile); let updatedContent = existing; @@ -253,17 +259,14 @@ async function main() { // This keeps repeated Stop invocations idempotent and preserves // user-authored sections in the same session file. if (summary && updatedContent) { - const summaryBlock = buildSummaryBlock(summary); + const summaryBlock = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); // Use function replacers: summaryBlock embeds raw user-message text, and a // string replacement argument interprets $-sequences ($&, $$, $`, $', $n). // A $& in a user message would otherwise re-inject the entire matched block // and corrupt the persisted summary. A function replacer is treated literally. if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) { - updatedContent = updatedContent.replace( - new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), - () => summaryBlock - ); + updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); } else { // Migration path for files created before summary markers existed. updatedContent = updatedContent.replace( @@ -280,8 +283,9 @@ async function main() { log(`[SessionEnd] Updated session file: ${sessionFile}`); } else { // Create new session file - const summarySection = summary - ? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`` + const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null; + const summarySection = block + ? `${block}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`` : `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``; const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection} diff --git a/scripts/lib/llm-summary.js b/scripts/lib/llm-summary.js new file mode 100644 index 000000000..e7d5d56a4 --- /dev/null +++ b/scripts/lib/llm-summary.js @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * LLM-powered session summary generator + * + * Uses `claude -p` (Claude Code CLI) to generate rich, contextual session + * summaries from JSONL transcripts. Requires no API key — reuses Claude Code's + * own authentication. + * + * Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so any Stop + * hooks fired by the subprocess do NOT re-enter LLM summarization. + */ + +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); + +const MAX_TRANSCRIPT_CHARS = 7000; +const MAX_TURNS = 25; +const LLM_TIMEOUT_MS = 90000; + +function getLLMModel() { + return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku'; +} + +function getContextThreshold() { + const raw = parseInt(process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD || '20', 10); + return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 20; +} + +/** + * Extract the last MAX_TURNS user+assistant turns from a JSONL transcript. + * Returns null when the transcript is missing or has no parseable turns. + */ +function extractConversationText(transcriptPath) { + let content; + try { + content = fs.readFileSync(transcriptPath, 'utf8'); + } catch { + return null; + } + + const lines = content.split('\n').filter(Boolean); + const turns = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + const isUser = entry.type === 'user' || entry.message?.role === 'user'; + const isAssistant = entry.type === 'assistant'; + + if (isUser) { + const rawContent = entry.message?.content ?? entry.content; + const text = + typeof rawContent === 'string' + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .filter(c => c?.type === 'text') + .map(c => c.text) + .join(' ') + : ''; + const cleaned = text.replace(/\n+/g, ' ').trim(); + if (cleaned) { + turns.push({ role: 'User', text: cleaned.slice(0, 400) }); + } + } + + if (isAssistant && Array.isArray(entry.message?.content)) { + const textParts = entry.message.content + .filter(b => b?.type === 'text') + .map(b => b.text) + .join(' ') + .replace(/\n+/g, ' ') + .trim(); + if (textParts) { + turns.push({ role: 'Claude', text: textParts.slice(0, 600) }); + } + } + } catch { + // Skip unparseable lines + } + } + + if (turns.length === 0) return null; + + const recent = turns.slice(-MAX_TURNS); + const formatted = recent.map(t => `**${t.role}:** ${t.text}`).join('\n\n'); + return formatted.length > MAX_TRANSCRIPT_CHARS ? '...(前略)\n\n' + formatted.slice(-MAX_TRANSCRIPT_CHARS) : formatted; +} + +/** + * Read the context remaining percentage from a transcript's latest usage record. + * Returns null when unavailable. + */ +function getContextRemainingPct(transcriptPath) { + try { + const { readLatestContextTokens, resolveContextWindowTokens } = require('./transcript-context'); + const usage = readLatestContextTokens(transcriptPath); + if (!usage) return null; + const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model); + return Math.round((1 - usage.tokens / windowTokens) * 100); + } catch { + return null; + } +} + +/** + * Generate a session summary using `claude -p`. + * Returns the summary string, or null on failure or when recursion guard is active. + */ +function generateSessionSummary(transcriptPath) { + if (process.env.ECC_SKIP_LLM_SUMMARY) return null; + + const conversation = extractConversationText(transcriptPath); + if (!conversation) return null; + + const prompt = [ + 'Below is a conversation log from a Claude Code coding session.', + 'Create a summary to help the next session quickly understand the context.', + '', + '## Prioritize including', + '- Design decisions and technology choices made this session', + '- Bugs and problems solved', + '- Files changed or created, with a brief description of changes', + '- Unfinished tasks and work to continue in the next session', + '- Important context the next session needs to know', + '', + '## Conversation log', + conversation, + '', + '## Output format (Markdown only, no preamble)', + '', + '## Session Summary', + '', + '### Tasks', + '(main tasks worked on this session)', + '', + '### Decisions Made', + '(design decisions and technology choices)', + '', + '### Files Modified', + '(files changed or created)', + '', + '### Unresolved Issues', + '(unfinished tasks and work to continue)', + '', + '### Next Session Context', + '(important context for the next session)' + ].join('\n'); + + try { + const result = spawnSync('claude', ['--model', getLLMModel(), '-p'], { + input: prompt, + encoding: 'utf8', + env: { + ...process.env, + CLAUDECODE: '', + ECC_SKIP_LLM_SUMMARY: '1' + }, + timeout: LLM_TIMEOUT_MS, + shell: process.platform === 'win32' + }); + + if (result.error || result.status !== 0) { + return null; + } + + const output = (result.stdout || '').trim(); + return output || null; + } catch { + return null; + } +} + +module.exports = { generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel }; diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 146023544..046ca9042 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -33,19 +33,14 @@ function fromBashPath(filePath) { } try { - return execFileSync( - 'bash', - ['-lc', 'cygpath -w -- "$1"', 'bash', rawPath], - { stdio: ['ignore', 'pipe', 'ignore'] } - ) + return execFileSync('bash', ['-lc', 'cygpath -w -- "$1"', 'bash', rawPath], { stdio: ['ignore', 'pipe', 'ignore'] }) .toString() .trim(); } catch { // Fall back to common Git Bash path shapes when cygpath is unavailable. } - const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/) - || rawPath.match(/^\/\/([A-Za-z])\/(.*)$/); + const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/) || rawPath.match(/^\/\/([A-Za-z])\/(.*)$/); if (match) { return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`; } @@ -437,10 +432,7 @@ async function runTests() { // Create a real session file const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp'); - fs.writeFileSync( - sessionFile, - buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' }) - ); + fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' })); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -449,22 +441,10 @@ async function runTests() { }); assert.strictEqual(result.code, 0); const additionalContext = getSessionStartAdditionalContext(result.stdout); - assert.ok( - additionalContext.includes('HISTORICAL REFERENCE ONLY'), - 'Should wrap injected session with the stale-replay guard preamble' - ); - assert.ok( - additionalContext.includes('STALE-BY-DEFAULT'), - 'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS' - ); - assert.ok( - additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'), - 'Should delimit the prior-session summary with an explicit begin marker' - ); - assert.ok( - additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'), - 'Should delimit the prior-session summary with an explicit end marker' - ); + assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble'); + assert.ok(additionalContext.includes('STALE-BY-DEFAULT'), 'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS'); + assert.ok(additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit begin marker'); + assert.ok(additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit end marker'); assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); @@ -482,10 +462,7 @@ async function runTests() { fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp'); - fs.writeFileSync( - sessionFile, - buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' }) - ); + fs.writeFileSync(sessionFile, buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' })); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -514,10 +491,7 @@ async function runTests() { fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp'); - fs.writeFileSync( - sessionFile, - buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' }) - ); + fs.writeFileSync(sessionFile, buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' })); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -581,14 +555,8 @@ async function runTests() { fs.mkdirSync(legacyDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); - fs.writeFileSync( - canonicalFile, - buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' }) - ); - fs.writeFileSync( - legacyFile, - buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' }) - ); + fs.writeFileSync(canonicalFile, buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' })); + fs.writeFileSync(legacyFile, buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' })); fs.utimesSync(canonicalFile, canonicalTime, canonicalTime); fs.utimesSync(legacyFile, legacyTime, legacyTime); @@ -617,13 +585,7 @@ async function runTests() { fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp'); - fs.writeFileSync( - sessionFile, - buildSessionStartFixture( - 'I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K', - { title: '\x1b[H\x1b[2J\x1b[3J# Real Session' } - ) - ); + fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K', { title: '\x1b[H\x1b[2J\x1b[3J# Real Session' })); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -632,10 +594,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); const additionalContext = getSessionStartAdditionalContext(result.stdout); - assert.ok( - additionalContext.includes('HISTORICAL REFERENCE ONLY'), - 'Should wrap injected session with the stale-replay guard preamble' - ); + assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble'); assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text'); assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes'); } finally { @@ -657,11 +616,7 @@ async function runTests() { fs.writeFileSync(sessionFile, buildSessionStartFixture(RESUME_SESSION_SENTINEL)); try { - const result = await runScript( - path.join(scriptsDir, 'session-start.js'), - JSON.stringify({ hookName: 'SessionStart:resume' }), - { HOME: isoHome, USERPROFILE: isoHome } - ); + const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:resume' }), { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); const additionalContext = getSessionStartAdditionalContext(result.stdout); assert.ok(!additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should not inject a previous summary on resume'); @@ -686,11 +641,7 @@ async function runTests() { fs.writeFileSync(sessionFile, buildSessionStartFixture(CLI_RESUME_SESSION_SENTINEL)); try { - const result = await runScript( - path.join(scriptsDir, 'session-start.js'), - JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }), - { HOME: isoHome, USERPROFILE: isoHome } - ); + const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }), { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); const additionalContext = getSessionStartAdditionalContext(result.stdout); assert.ok(!additionalContext.includes(CLI_RESUME_SESSION_SENTINEL), 'Should not inject CLI resume session content'); @@ -714,20 +665,12 @@ async function runTests() { fs.writeFileSync(desktopFile, buildSessionStartFixture(`${DESKTOP_CLEAR_SESSION_SENTINEL}\n${CLI_CLEAR_SESSION_SENTINEL}`)); try { - const desktopResult = await runScript( - path.join(scriptsDir, 'session-start.js'), - JSON.stringify({ hookName: 'SessionStart:clear' }), - { HOME: isoHome, USERPROFILE: isoHome } - ); + const desktopResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:clear' }), { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(desktopResult.code, 0); const desktopContext = getSessionStartAdditionalContext(desktopResult.stdout); assert.ok(!desktopContext.includes(DESKTOP_CLEAR_SESSION_SENTINEL), 'Should not inject Desktop clear session content'); - const cliResult = await runScript( - path.join(scriptsDir, 'session-start.js'), - JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }), - { HOME: isoHome, USERPROFILE: isoHome } - ); + const cliResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }), { HOME: isoHome, USERPROFILE: isoHome }); assert.strictEqual(cliResult.code, 0); const cliContext = getSessionStartAdditionalContext(cliResult.stdout); assert.ok(!cliContext.includes(CLI_CLEAR_SESSION_SENTINEL), 'Should not inject CLI clear session content'); @@ -778,10 +721,13 @@ async function runTests() { fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); const sessionFile = path.join(sessionsDir, '2026-02-11-crossproj-session.tmp'); - fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, { - project: 'different-project', - worktree: path.join(os.tmpdir(), 'different-project') - })); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, { + project: 'different-project', + worktree: path.join(os.tmpdir(), 'different-project') + }) + ); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -808,9 +754,12 @@ async function runTests() { fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); const sessionFile = path.join(sessionsDir, '2026-02-11-crosswt-session.tmp'); - fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, { - worktree: path.join(os.tmpdir(), 'same-project-different-worktree') - })); + fs.writeFileSync( + sessionFile, + buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, { + worktree: path.join(os.tmpdir(), 'same-project-different-worktree') + }) + ); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -897,19 +846,11 @@ async function runTests() { 'Use for recurring flaky integration tests that need deterministic setup checks.', '', '## Solution', - 'Verify service readiness before running the test body.', - ].join('\n'), + 'Verify service readiness before running the test body.' + ].join('\n') ); fs.mkdirSync(path.join(learnedDir, 'debugging-pattern'), { recursive: true }); - fs.writeFileSync( - path.join(learnedDir, 'debugging-pattern', 'SKILL.md'), - [ - '# Debugging Pattern', - '', - '## Trigger', - 'Use when a CLI tool silently exits without a result payload.', - ].join('\n'), - ); + fs.writeFileSync(path.join(learnedDir, 'debugging-pattern', 'SKILL.md'), ['# Debugging Pattern', '', '## Trigger', 'Use when a CLI tool silently exits without a result payload.'].join('\n')); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -918,20 +859,11 @@ async function runTests() { }); assert.strictEqual(result.code, 0); const additionalContext = getSessionStartAdditionalContext(result.stdout); - assert.ok( - additionalContext.includes('Available learned skills'), - `Should inject learned skills into additionalContext, got: ${additionalContext}` - ); + assert.ok(additionalContext.includes('Available learned skills'), `Should inject learned skills into additionalContext, got: ${additionalContext}`); assert.ok(additionalContext.includes('testing-patterns'), 'Should include the learned skill slug'); - assert.ok( - additionalContext.includes('Use for recurring flaky integration tests'), - 'Should include the learned skill trigger text' - ); + assert.ok(additionalContext.includes('Use for recurring flaky integration tests'), 'Should include the learned skill trigger text'); assert.ok(additionalContext.includes('debugging-pattern'), 'Should include directory-style learned skills'); - assert.ok( - additionalContext.includes('CLI tool silently exits'), - 'Should summarize directory-style learned skill trigger text' - ); + assert.ok(additionalContext.includes('CLI tool silently exits'), 'Should summarize directory-style learned skill trigger text'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -1769,10 +1701,14 @@ async function runTests() { fs.mkdirSync(path.join(isolatedHome, '.claude'), { recursive: true }); const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } }); - const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, withPrependedPath(binDir, { - HOME: isolatedHome, - USERPROFILE: isolatedHome - })); + const result = await runScript( + path.join(scriptsDir, 'post-edit-format.js'), + stdinJson, + withPrependedPath(binDir, { + HOME: isolatedHome, + USERPROFILE: isolatedHome + }) + ); assert.strictEqual(result.code, 0, 'Should exit 0 for config-only repo'); const logEntries = readCommandLog(logFile); @@ -2463,12 +2399,8 @@ async function runTests() { assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher'); assert.strictEqual(postBash[0].id, 'post:bash:dispatcher'); - const preCommand = Array.isArray(preBash[0].hooks[0].command) - ? preBash[0].hooks[0].command.join(' ') - : preBash[0].hooks[0].command; - const postCommand = Array.isArray(postBash[0].hooks[0].command) - ? postBash[0].hooks[0].command.join(' ') - : postBash[0].hooks[0].command; + const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command; + const postCommand = Array.isArray(postBash[0].hooks[0].command) ? postBash[0].hooks[0].command.join(' ') : postBash[0].hooks[0].command; assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher'); assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher'); @@ -2500,11 +2432,7 @@ async function runTests() { for (const [eventName, hookArray] of Object.entries(hooks.hooks)) { for (const entry of hookArray) { for (const hook of entry.hooks) { - assert.strictEqual( - typeof hook.command, - 'string', - `${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`, - ); + assert.strictEqual(typeof hook.command, 'string', `${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`); } } } @@ -2523,10 +2451,7 @@ async function runTests() { for (const hook of entry.hooks) { const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command; if (typeof commandText === 'string' && commandText.startsWith('node -e ')) { - assert.ok( - !commandText.includes('\\"'), - `${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`, - ); + assert.ok(!commandText.includes('\\"'), `${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`); } } } @@ -2550,10 +2475,7 @@ async function runTests() { const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node')); const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx ')); const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/')); - assert.ok( - isNode || isNpx || isSkillScript, - `Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...` - ); + assert.ok(isNode || isNpx || isSkillScript, `Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`); } } } @@ -2576,10 +2498,7 @@ async function runTests() { assert.ok(sessionStartHook, 'Should define a SessionStart hook'); const commandText = sessionStartHook.command; assert.strictEqual(typeof sessionStartHook.command, 'string', 'SessionStart should use string command form for Claude Code compatibility'); - assert.ok( - commandText.includes('session-start-bootstrap.js'), - 'SessionStart should delegate to the extracted bootstrap script' - ); + assert.ok(commandText.includes('session-start-bootstrap.js'), 'SessionStart should delegate to the extracted bootstrap script'); assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion'); assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find'); @@ -2607,8 +2526,7 @@ async function runTests() { for (const hook of [...stopHooks, ...sessionEndHooks]) { const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command; assert.ok( - (Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || - (typeof hook.command === 'string' && hook.command.startsWith('node -e "')), + (Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')), 'Lifecycle hook should use inline node resolver' ); assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script'); @@ -2636,10 +2554,7 @@ async function runTests() { const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js'); const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js'); assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`); - assert.ok( - usesInlineResolver || usesPluginBootstrap, - `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...` - ); + assert.ok(usesInlineResolver || usesPluginBootstrap, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`); } } } @@ -2653,7 +2568,6 @@ async function runTests() { passed++; else failed++; - // plugin.json validation console.log('\nplugin.json Validation:'); @@ -3224,14 +3138,7 @@ async function runTests() { const [projectId, projectDir] = stdout.trim().split(/\r?\n/); const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json'); - const expectedProjectDir = path.join( - homeDir, - '.local', - 'share', - 'ecc-homunculus', - 'projects', - projectId - ); + const expectedProjectDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects', projectId); const projectMetadataPath = path.join(expectedProjectDir, 'project.json'); assert.ok(projectId, 'detect-project should emit a project id'); @@ -3249,11 +3156,7 @@ async function runTests() { assert.ok(registry[projectId], 'registry should contain the detected project'); assert.strictEqual(metadata.id, projectId, 'project.json should include the detected id'); assert.strictEqual(metadata.name, path.basename(repoDir), 'project.json should include the repo name'); - assert.strictEqual( - comparableMetadataRoot, - comparableRepoDir, - `project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})` - ); + assert.strictEqual(comparableMetadataRoot, comparableRepoDir, `project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})`); assert.strictEqual(metadata.remote, 'https://github.com/example/ecc-test.git', 'project.json should include the sanitized remote'); assert.ok(metadata.created_at, 'project.json should include created_at'); assert.ok(metadata.last_seen, 'project.json should include last_seen'); @@ -3304,10 +3207,7 @@ async function runTests() { const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus'); const projectsDir = path.join(homunculusDir, 'projects'); - assert.ok( - !fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, - 'observe.sh should not create a project-scoped directory for a non-git cwd' - ); + assert.ok(!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, 'observe.sh should not create a project-scoped directory for a non-git cwd'); const observationsPath = path.join(homunculusDir, 'observations.jsonl'); const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean); @@ -3338,7 +3238,10 @@ async function runTests() { passed++; else failed++; - if (SKIP_BASH) { console.log(" ⊘ observe.sh skips minimal hook profile (skipped on Windows)"); passed++; } else if ( + if (SKIP_BASH) { + console.log(' ⊘ observe.sh skips minimal hook profile (skipped on Windows)'); + passed++; + } else if ( await asyncTest('observe.sh skips minimal hook profile before project detection side effects', async () => { await assertObserveSkipBeforeProjectDetection({ name: 'minimal hook profile', @@ -3349,7 +3252,10 @@ async function runTests() { passed++; else failed++; - if (SKIP_BASH) { console.log(" ⊘ observe.sh skips cooperative skip env (skipped on Windows)"); passed++; } else if ( + if (SKIP_BASH) { + console.log(' ⊘ observe.sh skips cooperative skip env (skipped on Windows)'); + passed++; + } else if ( await asyncTest('observe.sh skips cooperative skip env before project detection side effects', async () => { await assertObserveSkipBeforeProjectDetection({ name: 'cooperative skip env', @@ -3360,7 +3266,10 @@ async function runTests() { passed++; else failed++; - if (SKIP_BASH) { console.log(" ⊘ observe.sh skips subagent payloads (skipped on Windows)"); passed++; } else if ( + if (SKIP_BASH) { + console.log(' ⊘ observe.sh skips subagent payloads (skipped on Windows)'); + passed++; + } else if ( await asyncTest('observe.sh skips subagent payloads before project detection side effects', async () => { await assertObserveSkipBeforeProjectDetection({ name: 'subagent payload', @@ -3372,7 +3281,10 @@ async function runTests() { passed++; else failed++; - if (SKIP_BASH) { console.log(" ⊘ observe.sh skips configured observer-session paths (skipped on Windows)"); passed++; } else if ( + if (SKIP_BASH) { + console.log(' ⊘ observe.sh skips configured observer-session paths (skipped on Windows)'); + passed++; + } else if ( await asyncTest('observe.sh skips configured observer-session paths before project detection side effects', async () => { await assertObserveSkipBeforeProjectDetection({ name: 'cwd skip path', @@ -4938,19 +4850,13 @@ async function runTests() { // Create session file 6.9 days old (should be INCLUDED by maxAge:7) const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp'); - fs.writeFileSync( - recentFile, - buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' }) - ); + fs.writeFileSync(recentFile, buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' })); const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000); fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo); // Create session file 8 days old (should be EXCLUDED by maxAge:7) const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp'); - fs.writeFileSync( - oldFile, - buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' }) - ); + fs.writeFileSync(oldFile, buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' })); const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000); fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo); @@ -4993,7 +4899,7 @@ async function runTests() { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome, - ECC_SESSION_RETENTION_DAYS: '30', + ECC_SESSION_RETENTION_DAYS: '30' }); assert.strictEqual(result.code, 0); @@ -5024,13 +4930,12 @@ async function runTests() { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome, - ECC_SESSION_RETENTION_DAYS: '0', + ECC_SESSION_RETENTION_DAYS: '0' }); assert.strictEqual(result.code, 0); assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=0'); - assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), - `Should log pruning disabled, stderr: ${result.stderr}`); + assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`); assert.ok(!result.stderr.includes('Pruned'), `Should not log any pruning, stderr: ${result.stderr}`); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); @@ -5056,13 +4961,12 @@ async function runTests() { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome, - ECC_SESSION_RETENTION_DAYS: 'off', + ECC_SESSION_RETENTION_DAYS: 'off' }); assert.strictEqual(result.code, 0); assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=off'); - assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), - `Should log pruning disabled, stderr: ${result.stderr}`); + assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -5087,16 +4991,13 @@ async function runTests() { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { HOME: isoHome, USERPROFILE: isoHome, - ECC_SESSION_RETENTION_DAYS: 'bogus-value', + ECC_SESSION_RETENTION_DAYS: 'bogus-value' }); assert.strictEqual(result.code, 0); - assert.ok(!fs.existsSync(expiredFile), - 'Should fall back to default 30-day retention and prune the 40-day-old file'); - assert.ok(result.stderr.includes('Pruned 1 expired session'), - `Should log pruning at default retention, stderr: ${result.stderr}`); - assert.ok(!result.stderr.includes('Pruning disabled'), - 'Should NOT treat garbage as opt-out'); + assert.ok(!fs.existsSync(expiredFile), 'Should fall back to default 30-day retention and prune the 40-day-old file'); + assert.ok(result.stderr.includes('Pruned 1 expired session'), `Should log pruning at default retention, stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Pruning disabled'), 'Should NOT treat garbage as opt-out'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -5118,18 +5019,12 @@ async function runTests() { // Create older session (2 days ago) const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp'); - fs.writeFileSync( - olderSession, - buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' }) - ); + fs.writeFileSync(olderSession, buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' })); fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000)); // Create newer session (1 day ago) const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp'); - fs.writeFileSync( - newerSession, - buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' }) - ); + fs.writeFileSync(newerSession, buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' })); fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000)); try { @@ -6152,6 +6047,93 @@ Some random content without the expected ### Context to Load section passed++; else failed++; + // ── Round 95: pre-compact.js — ECC_SKIP_LLM_SUMMARY guard ── + console.log('\nRound 95: pre-compact.js (transcript_path provided + ECC_SKIP_LLM_SUMMARY=1 — LLM skipped):'); + + if ( + await asyncTest('pre-compact falls back to compaction log entry when ECC_SKIP_LLM_SUMMARY=1', async () => { + const testDir = createTestDir(); + const sessionsDir = path.join(testDir, '.claude', 'session-data'); + fs.mkdirSync(sessionsDir, { recursive: true }); + + // Create a minimal session .tmp file + const sessionFile = path.join(sessionsDir, '2026-01-01-test-session.tmp'); + fs.writeFileSync(sessionFile, '# Session: 2026-01-01\n'); + + // Create a minimal transcript with one user message + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + const userEntry = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } }); + fs.writeFileSync(transcriptPath, userEntry + '\n'); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), stdinJson, { + HOME: testDir, + ECC_SKIP_LLM_SUMMARY: '1' + }); + + assert.strictEqual(result.code, 0, 'Should exit 0'); + // LLM was skipped → fallback log entry appended + assert.ok(result.stderr.includes('[PreCompact] LLM summary unavailable'), `stderr should report LLM unavailable, got: ${result.stderr}`); + // Session file should have the compaction event marker, not an LLM summary block + const content = fs.readFileSync(sessionFile, 'utf8'); + assert.ok(content.includes('Compaction occurred at'), `session file should contain compaction marker, got: ${content}`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + // ── Round 95: session-end.js — ECC_LLM_SUMMARY_INTERVAL controls trigger ── + console.log('\nRound 95: session-end.js (ECC_LLM_SUMMARY_INTERVAL — controls LLM trigger cadence):'); + + if ( + await asyncTest('session-end triggers LLM when totalMessages % interval === 0', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // 3 user messages → totalMessages=3; interval=3 → 3%3===0 → should trigger + const lines = [1, 2, 3].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } })); + fs.writeFileSync(transcriptPath, lines.join('\n') + '\n'); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir, + ECC_LLM_SUMMARY_INTERVAL: '3', + ECC_SKIP_LLM_SUMMARY: '1' // prevent actual claude -p invocation in tests + }); + + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should report LLM triggered, got: ${result.stderr}`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + + if ( + await asyncTest('session-end does NOT trigger LLM when totalMessages % interval !== 0', async () => { + const testDir = createTestDir(); + const transcriptPath = path.join(testDir, 'transcript.jsonl'); + + // 2 user messages → totalMessages=2; interval=3 → 2%3!==0 → should NOT trigger + const lines = [1, 2].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } })); + fs.writeFileSync(transcriptPath, lines.join('\n') + '\n'); + + const stdinJson = JSON.stringify({ transcript_path: transcriptPath }); + const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, { + HOME: testDir, + ECC_LLM_SUMMARY_INTERVAL: '3', + ECC_SKIP_LLM_SUMMARY: '1' + }); + + assert.strictEqual(result.code, 0, 'Should exit 0'); + assert.ok(!result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should NOT report LLM triggered, got: ${result.stderr}`); + cleanupTestDir(testDir); + }) + ) + passed++; + else failed++; + // Summary console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`); diff --git a/tests/lib/llm-summary.test.js b/tests/lib/llm-summary.test.js new file mode 100644 index 000000000..fe7543341 --- /dev/null +++ b/tests/lib/llm-summary.test.js @@ -0,0 +1,199 @@ +'use strict'; +/** + * Tests for scripts/lib/llm-summary.js + * + * Run with: node tests/lib/llm-summary.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel, generateSessionSummary } = require('../../scripts/lib/llm-summary'); + +console.log('=== Testing llm-summary.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +let seq = 0; +function writeTranscript(lines) { + seq++; + const p = path.join(os.tmpdir(), `llm-summary-test-${process.pid}-${seq}.jsonl`); + fs.writeFileSync(p, lines.join('\n') + '\n'); + return p; +} + +function userEntry(text) { + return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }); +} + +function assistantEntry(text) { + return JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + usage: { input_tokens: 1000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } + } + }); +} + +// --- getLLMModel --- +console.log('getLLMModel:'); + +test('returns haiku by default', () => { + const orig = process.env.ECC_LLM_SUMMARY_MODEL; + delete process.env.ECC_LLM_SUMMARY_MODEL; + assert.strictEqual(getLLMModel(), 'haiku'); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig; +}); + +test('reads ECC_LLM_SUMMARY_MODEL env var', () => { + const orig = process.env.ECC_LLM_SUMMARY_MODEL; + process.env.ECC_LLM_SUMMARY_MODEL = 'sonnet'; + assert.strictEqual(getLLMModel(), 'sonnet'); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig; + else delete process.env.ECC_LLM_SUMMARY_MODEL; +}); + +// --- getContextThreshold --- +console.log('\ngetContextThreshold:'); + +test('returns 20 by default', () => { + const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; + delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; + assert.strictEqual(getContextThreshold(), 20); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig; +}); + +test('reads ECC_LLM_SUMMARY_CONTEXT_THRESHOLD env var', () => { + const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; + process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '70'; + assert.strictEqual(getContextThreshold(), 70); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig; + else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; +}); + +test('falls back to 20 on invalid value', () => { + const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; + process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = 'notanumber'; + assert.strictEqual(getContextThreshold(), 20); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig; + else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; +}); + +test('falls back to 20 when value exceeds 100', () => { + const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; + process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '150'; + assert.strictEqual(getContextThreshold(), 20); + if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig; + else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD; +}); + +// --- extractConversationText --- +console.log('\nextractConversationText:'); + +test('returns null for missing file', () => { + assert.strictEqual(extractConversationText('/nonexistent/path.jsonl'), null); +}); + +test('returns null for empty transcript', () => { + const p = writeTranscript([]); + assert.strictEqual(extractConversationText(p), null); +}); + +test('extracts user and assistant turns', () => { + const p = writeTranscript([userEntry('Hello, can you help?'), assistantEntry('Sure, what do you need?')]); + const result = extractConversationText(p); + assert.ok(result.includes('User:')); + assert.ok(result.includes('Claude:')); + assert.ok(result.includes('Hello, can you help?')); +}); + +test('truncates user text to 400 chars', () => { + const p = writeTranscript([userEntry('x'.repeat(500))]); + const result = extractConversationText(p); + assert.ok(result !== null); + assert.ok(!result.includes('x'.repeat(401))); +}); + +test('skips unparseable lines gracefully', () => { + const p = writeTranscript(['not valid json', userEntry('valid message')]); + const result = extractConversationText(p); + assert.ok(result !== null); + assert.ok(result.includes('valid message')); +}); + +test('limits to last 25 turns', () => { + const lines = []; + for (let i = 0; i < 30; i++) lines.push(userEntry(`message ${i}`)); + const p = writeTranscript(lines); + const result = extractConversationText(p); + assert.ok(result.includes('message 29')); + assert.ok(!result.includes('message 4')); +}); + +test('collapses newlines to spaces', () => { + const p = writeTranscript([userEntry('line one\nline two')]); + const result = extractConversationText(p); + assert.ok(!result.includes('\nline two')); + assert.ok(result.includes('line one line two')); +}); + +// --- getContextRemainingPct --- +console.log('\ngetContextRemainingPct:'); + +test('returns null for missing file', () => { + assert.strictEqual(getContextRemainingPct('/nonexistent.jsonl'), null); +}); + +test('returns null for transcript with no usage data', () => { + const p = writeTranscript([userEntry('hi')]); + assert.strictEqual(getContextRemainingPct(p), null); +}); + +test('returns numeric percentage for transcript with usage data', () => { + const p = writeTranscript([assistantEntry('ok')]); + const pct = getContextRemainingPct(p); + assert.ok(typeof pct === 'number'); + assert.ok(pct >= 0 && pct <= 100); +}); + +// --- generateSessionSummary --- +console.log('\ngenerateSessionSummary:'); + +test('returns null when ECC_SKIP_LLM_SUMMARY is set', () => { + const orig = process.env.ECC_SKIP_LLM_SUMMARY; + process.env.ECC_SKIP_LLM_SUMMARY = '1'; + const p = writeTranscript([userEntry('test')]); + assert.strictEqual(generateSessionSummary(p), null); + if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig; + else delete process.env.ECC_SKIP_LLM_SUMMARY; +}); + +test('returns null for missing transcript (no conversation to summarize)', () => { + const orig = process.env.ECC_SKIP_LLM_SUMMARY; + delete process.env.ECC_SKIP_LLM_SUMMARY; + assert.strictEqual(generateSessionSummary('/nonexistent.jsonl'), null); + if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig; +}); + +// --- Results --- +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}`); +process.exit(failed > 0 ? 1 : 0); From 1ba1640bf7c36d5baf11b7676004d332c00c48be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:42 -0700 Subject: [PATCH 017/197] chore(deps): update anthropic requirement from >=0.25.0 to >=0.111.0 (#2329) Updates the requirements on [anthropic](https://github.com/anthropics/anthropic-sdk-python) to permit the latest version. - [Release notes](https://github.com/anthropics/anthropic-sdk-python/releases) - [Changelog](https://github.com/anthropics/anthropic-sdk-python/blob/main/CHANGELOG.md) - [Commits](https://github.com/anthropics/anthropic-sdk-python/compare/v0.25.0...v0.111.0) --- updated-dependencies: - dependency-name: anthropic dependency-version: 0.111.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ee13baa0d..adfc0f6e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ ] dependencies = [ - "anthropic>=0.25.0", + "anthropic>=0.111.0", "openai>=1.30.0", ] From e676d1da7fb576be34b9db1b49ea5269e50f3501 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:45 -0700 Subject: [PATCH 018/197] chore(deps): bump actions/checkout from 6.0.3 to 7.0.0 (#2328) Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.0. - [Release notes](https://github.com/actions/checkout/releases) - [Commits](https://github.com/actions/checkout/compare/v6.0.3...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 12 ++++++------ .../generator-generic-ossf-slsa3-publish.yml | 2 +- .github/workflows/maintenance.yml | 4 ++-- .github/workflows/release-announce.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/reusable-release.yml | 2 +- .github/workflows/reusable-test.yml | 2 +- .github/workflows/reusable-validate.yml | 2 +- .github/workflows/supply-chain-watch.yml | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd72b3a4f..5d9f6075e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -115,7 +115,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -178,7 +178,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -200,7 +200,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -227,7 +227,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false @@ -256,7 +256,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml index e31ddd0ec..508dc1bac 100644 --- a/.github/workflows/generator-generic-ossf-slsa3-publish.yml +++ b/.github/workflows/generator-generic-ossf-slsa3-publish.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@f4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 4573b5ef8..ea9a1b6af 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -15,7 +15,7 @@ jobs: name: Check Dependencies runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -28,7 +28,7 @@ jobs: name: Security Audit runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/.github/workflows/release-announce.yml b/.github/workflows/release-announce.yml index 676ae0f3c..27be162e6 100644 --- a/.github/workflows/release-announce.yml +++ b/.github/workflows/release-announce.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false - name: Announce release to Discord + Discussions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4e2c18b6e..21041bcb1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index e3d1f02f6..5ac66b2ed 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -41,7 +41,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 ref: ${{ inputs.tag }} diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index dd06ab3a2..5892bec8e 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/reusable-validate.yml b/.github/workflows/reusable-validate.yml index f2df118ac..2694dba44 100644 --- a/.github/workflows/reusable-validate.yml +++ b/.github/workflows/reusable-validate.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false diff --git a/.github/workflows/supply-chain-watch.yml b/.github/workflows/supply-chain-watch.yml index 951920fdd..3d75d09a6 100644 --- a/.github/workflows/supply-chain-watch.yml +++ b/.github/workflows/supply-chain-watch.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false From 891412c1260030abfed5afa14bda1beb11650f43 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:47 -0700 Subject: [PATCH 019/197] chore(deps): bump slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml (#2330) Bumps [slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml](https://github.com/slsa-framework/slsa-github-generator) from 1.4.0 to 2.1.0. - [Release notes](https://github.com/slsa-framework/slsa-github-generator/releases) - [Changelog](https://github.com/slsa-framework/slsa-github-generator/blob/main/CHANGELOG.md) - [Commits](https://github.com/slsa-framework/slsa-github-generator/compare/68bad40844440577b33778c9f29077a3388838e9...f7dd8c54c2067bafc12ca7a55595d5ee9b75204a) --- updated-dependencies: - dependency-name: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml dependency-version: 2.1.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/generator-generic-ossf-slsa3-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml index 508dc1bac..16d60aae0 100644 --- a/.github/workflows/generator-generic-ossf-slsa3-publish.yml +++ b/.github/workflows/generator-generic-ossf-slsa3-publish.yml @@ -93,7 +93,7 @@ jobs: id-token: write contents: write - uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@68bad40844440577b33778c9f29077a3388838e9 # v1.4.0 + uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 with: base64-subjects: ${{ needs.build.outputs.digests }} From 333e3bb017e36e1fc1959172580b99d92877ab2b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:50 -0700 Subject: [PATCH 020/197] chore(deps): bump cron from 0.16.0 to 0.17.0 in /ecc2 (#2333) Bumps [cron](https://github.com/zslayton/cron) from 0.16.0 to 0.17.0. - [Release notes](https://github.com/zslayton/cron/releases) - [Commits](https://github.com/zslayton/cron/commits) --- updated-dependencies: - dependency-name: cron dependency-version: 0.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ecc2/Cargo.lock | 19 ++++++++++++++----- ecc2/Cargo.toml | 2 +- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index dc2ccd70a..a5933c863 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -379,14 +379,14 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "cron" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97" +checksum = "a5dcd6f69605c2956916ce24e8af637b754964c9a83f4662d3a2361654cdba09" dependencies = [ "chrono", "once_cell", "phf", - "winnow", + "winnow 0.7.15", ] [[package]] @@ -2344,7 +2344,7 @@ dependencies = [ "toml_datetime", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2362,7 +2362,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] [[package]] @@ -2933,6 +2933,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/ecc2/Cargo.toml b/ecc2/Cargo.toml index a7ede9a68..1995913c7 100644 --- a/ecc2/Cargo.toml +++ b/ecc2/Cargo.toml @@ -47,7 +47,7 @@ libc = "0.2" # Time chrono = { version = "0.4", features = ["serde"] } -cron = "0.16" +cron = "0.17" # UUID for session IDs uuid = { version = "1", features = ["v4"] } From f54c0b242ff4bcb7a282817630b057324976d14a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:54 -0700 Subject: [PATCH 021/197] chore(deps-dev): update pytest requirement from >=8.0 to >=9.1.1 (#2324) Updates the requirements on [pytest](https://github.com/pytest-dev/pytest) to permit the latest version. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.0.0...9.1.1) --- updated-dependencies: - dependency-name: pytest dependency-version: 9.1.1 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index adfc0f6e9..0b0999aab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "pytest>=8.0", + "pytest>=9.1.1", "pytest-asyncio>=0.23", "pytest-cov>=4.1", "pytest-mock>=3.12", From 61fd5b2032e9a3665b037eab802c3c4ff84615ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:55:57 -0700 Subject: [PATCH 022/197] chore(deps-dev): update mypy requirement from >=1.10 to >=2.1.0 (#2326) Updates the requirements on [mypy](https://github.com/python/mypy) to permit the latest version. - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.10.0...v2.1.0) --- updated-dependencies: - dependency-name: mypy dependency-version: 2.1.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 0b0999aab..fb8e9232d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dev = [ "pytest-cov>=4.1", "pytest-mock>=3.12", "ruff>=0.4", - "mypy>=1.10", + "mypy>=2.1.0", ] [project.urls] From 723399fbf38147ee694f9098428369db623e2fed Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:02 -0700 Subject: [PATCH 023/197] chore(deps-dev): update pytest-cov requirement from >=4.1 to >=7.1.0 (#2332) Updates the requirements on [pytest-cov](https://github.com/pytest-dev/pytest-cov) to permit the latest version. - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v4.1.0...v7.1.0) --- updated-dependencies: - dependency-name: pytest-cov dependency-version: 7.1.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fb8e9232d..8b5864258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ dependencies = [ dev = [ "pytest>=9.1.1", "pytest-asyncio>=0.23", - "pytest-cov>=4.1", + "pytest-cov>=7.1.0", "pytest-mock>=3.12", "ruff>=0.4", "mypy>=2.1.0", From ad08352f60c8e5350791a720b88ca0f0917661aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:09 -0700 Subject: [PATCH 024/197] chore(deps): bump the actions-minor-and-patch group across 1 directory with 3 updates (#2325) Bumps the actions-minor-and-patch group with 3 updates in the / directory: [actions/setup-node](https://github.com/actions/setup-node), [pnpm/action-setup](https://github.com/pnpm/action-setup) and [softprops/action-gh-release](https://github.com/softprops/action-gh-release). Updates `actions/setup-node` from 6.3.0 to 6.4.0 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6.3.0...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e) Updates `pnpm/action-setup` from 6.0.8 to 6.0.9 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/0e279bb959325dab635dd2c09392533439d90093...0ebf47130e4866e96fce0953f49152a61190b271) Updates `softprops/action-gh-release` from 3.0.0 to 3.0.1 - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...718ea10b132b3b2eba29c1007bb80653f286566b) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-and-patch - dependency-name: pnpm/action-setup dependency-version: 6.0.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch - dependency-name: softprops/action-gh-release dependency-version: 3.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: actions-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/generator-generic-ossf-slsa3-publish.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/reusable-release.yml | 2 +- .github/workflows/reusable-test.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d9f6075e..03ab00b89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: # Package manager setup - name: Setup pnpm if: matrix.pm == 'pnpm' && matrix.node != '18.x' - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: # Keep an explicit pnpm major because this repo's packageManager is Yarn. version: 10 diff --git a/.github/workflows/generator-generic-ossf-slsa3-publish.yml b/.github/workflows/generator-generic-ossf-slsa3-publish.yml index 16d60aae0..4325bef94 100644 --- a/.github/workflows/generator-generic-ossf-slsa3-publish.yml +++ b/.github/workflows/generator-generic-ossf-slsa3-publish.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: "20.x" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21041bcb1..8f6990974 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,7 +140,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Create GitHub Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: body_path: release_body.md generate_release_notes: true diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml index 5ac66b2ed..ed82e9eda 100644 --- a/.github/workflows/reusable-release.yml +++ b/.github/workflows/reusable-release.yml @@ -157,7 +157,7 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Create GitHub Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: tag_name: ${{ inputs.tag }} body_path: release_body.md diff --git a/.github/workflows/reusable-test.yml b/.github/workflows/reusable-test.yml index 5892bec8e..cf09989ed 100644 --- a/.github/workflows/reusable-test.yml +++ b/.github/workflows/reusable-test.yml @@ -38,7 +38,7 @@ jobs: - name: Setup pnpm if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x' - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: # Keep an explicit pnpm major because this repo's packageManager is Yarn. version: 10 From 6d36d1e93368efae68cfb5ca204cae6ec92e9e49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:12 -0700 Subject: [PATCH 025/197] chore(deps): bump the cargo-minor-and-patch group across 1 directory with 3 updates (#2387) Bumps the cargo-minor-and-patch group with 3 updates in the /ecc2 directory: [ratatui](https://github.com/ratatui/ratatui), [anyhow](https://github.com/dtolnay/anyhow) and [uuid](https://github.com/uuid-rs/uuid). Updates `ratatui` from 0.30.1 to 0.30.2 - [Release notes](https://github.com/ratatui/ratatui/releases) - [Changelog](https://github.com/ratatui/ratatui/blob/main/CHANGELOG.md) - [Commits](https://github.com/ratatui/ratatui/compare/ratatui-v0.30.1...ratatui-v0.30.2) Updates `anyhow` from 1.0.102 to 1.0.103 - [Release notes](https://github.com/dtolnay/anyhow/releases) - [Commits](https://github.com/dtolnay/anyhow/compare/1.0.102...1.0.103) Updates `uuid` from 1.23.3 to 1.23.4 - [Release notes](https://github.com/uuid-rs/uuid/releases) - [Commits](https://github.com/uuid-rs/uuid/compare/v1.23.3...v1.23.4) --- updated-dependencies: - dependency-name: ratatui dependency-version: 0.30.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: anyhow dependency-version: 1.0.103 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch - dependency-name: uuid dependency-version: 1.23.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: cargo-minor-and-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ecc2/Cargo.lock | 60 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/ecc2/Cargo.lock b/ecc2/Cargo.lock index a5933c863..187ecc16e 100644 --- a/ecc2/Cargo.lock +++ b/ecc2/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" @@ -1654,14 +1654,15 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "ratatui" -version = "0.30.1" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", + "ratatui-termina", "ratatui-termwiz", "ratatui-widgets", "serde", @@ -1669,15 +1670,14 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ "bitflags 2.13.0", "compact_str", "critical-section", "hashbrown 0.17.1", - "indoc", "itertools", "kasuari", "lru", @@ -1692,9 +1692,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" dependencies = [ "cfg-if", "crossterm", @@ -1704,19 +1704,30 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.1" +name = "ratatui-termina" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" dependencies = [ "ratatui-core", "termwiz", @@ -1724,9 +1735,9 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" dependencies = [ "bitflags 2.13.0", "hashbrown 0.17.1", @@ -2149,6 +2160,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.0", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -2549,9 +2573,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "atomic", "getrandom 0.4.2", From 2159ed2fdee361bfa5e8caac6dcd76f042f930c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:56:15 -0700 Subject: [PATCH 026/197] chore(deps-dev): bump eslint from 9.39.2 to 10.6.0 (#2260) Bumps [eslint](https://github.com/eslint/eslint) from 9.39.2 to 10.6.0. - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v10.6.0) --- updated-dependencies: - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 311 ++++++++++++++++++++------------------------------- 2 files changed, 121 insertions(+), 192 deletions(-) diff --git a/package.json b/package.json index 45d000a13..a0732c7b5 100644 --- a/package.json +++ b/package.json @@ -366,7 +366,7 @@ "@opencode-ai/plugin": "^1.16.2", "@types/node": "25.9.2", "c8": "^11.0.0", - "eslint": "^9.39.2", + "eslint": "^10.6.0", "globals": "^17.4.0", "markdownlint-cli": "^0.48.0", "typescript": "^6.0.3" diff --git a/yarn.lock b/yarn.lock index c831d6db9..40b51769e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14,65 +14,50 @@ dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.12.1": +"@eslint-community/regexpp@^4.12.2": version "4.12.2" - resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.21.1": - version "0.21.1" - resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz" - integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== dependencies: - "@eslint/object-schema" "^2.1.7" + "@eslint/object-schema" "^3.0.5" debug "^4.3.1" - minimatch "^3.1.2" + minimatch "^10.2.4" -"@eslint/config-helpers@^0.4.2": - version "0.4.2" - resolved "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz" - integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== +"@eslint/config-helpers@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.6.0.tgz#ef9a36881d39dfd5dbeac22b0da997fabfb08b03" + integrity sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA== dependencies: - "@eslint/core" "^0.17.0" + "@eslint/core" "^1.2.1" -"@eslint/core@^0.17.0": - version "0.17.0" - resolved "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz" - integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== dependencies: "@types/json-schema" "^7.0.15" -"@eslint/eslintrc@^3.3.1": - version "3.3.3" - resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz" - integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== - dependencies: - ajv "^6.12.4" - debug "^4.3.2" - espree "^10.0.1" - globals "^14.0.0" - ignore "^5.2.0" - import-fresh "^3.2.1" - js-yaml "^4.1.1" - minimatch "^3.1.2" - strip-json-comments "^3.1.1" - -"@eslint/js@^9.39.2", "@eslint/js@9.39.2": +"@eslint/js@^9.39.2": version "9.39.2" resolved "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz" integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== -"@eslint/object-schema@^2.1.7": - version "2.1.7" - resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz" - integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== -"@eslint/plugin-kit@^0.4.1": - version "0.4.1" - resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz" - integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== +"@eslint/plugin-kit@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz#4b0962f3f2c7ce8bc98b3ecfe34525c09d2cb729" + integrity sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A== dependencies: - "@eslint/core" "^0.17.0" + "@eslint/core" "^1.2.1" levn "^0.4.1" "@humanfs/core@^0.19.1": @@ -126,11 +111,36 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz#22619f76a6b10ba78c8b74025b0d9754cad69cc7" + integrity sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ== + +"@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz#c2fc0573afe08b0cf213e66eef76842b121d1577" + integrity sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w== + +"@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz#4e3822f5522e18ed92611b894dc5db1bc882f39d" + integrity sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw== + +"@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz#27ec4bc7eb6c311c982a50f1a6e1e1414638a6f8" + integrity sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw== + "@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4": version "3.0.4" resolved "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz" integrity sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ== +"@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz#a1c79dcc9ae5f8c02aea8c2f144e5af6a822e5e8" + integrity sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ== + "@opencode-ai/plugin@^1.16.2": version "1.17.3" resolved "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.17.3.tgz" @@ -159,10 +169,15 @@ dependencies: "@types/ms" "*" -"@types/estree@^1.0.6": - version "1.0.8" - resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" @@ -201,15 +216,15 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: - version "8.15.0" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" - integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== +acorn@^8.16.0: + version "8.17.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" + integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== -ajv@^6.12.4: - version "6.14.0" - resolved "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz" - integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== dependencies: fast-deep-equal "^3.1.1" fast-json-stable-stringify "^2.0.0" @@ -236,7 +251,7 @@ ansi-regex@^6.0.1: resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== -ansi-styles@^4.0.0, ansi-styles@^4.1.0: +ansi-styles@^4.0.0: version "4.3.0" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -248,24 +263,11 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - balanced-match@^4.0.2: version "4.0.4" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -brace-expansion@^1.1.7: - version "1.1.14" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz" - integrity sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - brace-expansion@^5.0.5: version "5.0.6" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz" @@ -290,19 +292,6 @@ c8@^11.0.0: yargs "^17.7.2" yargs-parser "^21.1.1" -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -chalk@^4.0.0: - version "4.1.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - character-entities-legacy@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz" @@ -349,17 +338,12 @@ commander@~14.0.3: resolved "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz" integrity sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw== -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== -cross-spawn@^7.0.6, cross-spawn@7.0.6: +cross-spawn@7.0.6, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -445,11 +429,13 @@ escape-string-regexp@^4.0.0: resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-scope@^8.4.0: - version "8.4.0" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz" - integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" esrecurse "^4.3.0" estraverse "^5.2.0" @@ -458,37 +444,34 @@ eslint-visitor-keys@^3.4.3: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.2.1: - version "4.2.1" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" - integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== +eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== -"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^9.39.2: - version "9.39.2" - resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz" - integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== +eslint@^10.6.0: + version "10.6.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.6.0.tgz#e1b4059c582be950c7088c9b55f984738b243c27" + integrity sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg== dependencies: "@eslint-community/eslint-utils" "^4.8.0" - "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.21.1" - "@eslint/config-helpers" "^0.4.2" - "@eslint/core" "^0.17.0" - "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.39.2" - "@eslint/plugin-kit" "^0.4.1" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.6.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - ajv "^6.12.4" - chalk "^4.0.0" + ajv "^6.14.0" cross-spawn "^7.0.6" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.4.0" - eslint-visitor-keys "^4.2.1" - espree "^10.4.0" - esquery "^1.5.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" file-entry-cache "^8.0.0" @@ -498,23 +481,22 @@ eslint-visitor-keys@^4.2.1: imurmurhash "^0.1.4" is-glob "^4.0.0" json-stable-stringify-without-jsonify "^1.0.1" - lodash.merge "^4.6.2" - minimatch "^3.1.2" + minimatch "^10.2.4" natural-compare "^1.4.0" optionator "^0.9.3" -espree@^10.0.1, espree@^10.4.0: - version "10.4.0" - resolved "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz" - integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== dependencies: - acorn "^8.15.0" + acorn "^8.16.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.2.1" + eslint-visitor-keys "^5.0.1" -esquery@^1.5.0: +esquery@^1.7.0: version "1.7.0" - resolved "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -635,11 +617,6 @@ glob@^13.0.6: minipass "^7.1.3" path-scurry "^2.0.2" -globals@^14.0.0: - version "14.0.0" - resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" - integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== - globals@^17.4.0: version "17.4.0" resolved "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz" @@ -665,14 +642,6 @@ ignore@~7.0.5: resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -import-fresh@^3.2.1: - version "3.3.1" - resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz" - integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" @@ -755,7 +724,7 @@ istanbul-reports@^3.1.6: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -js-yaml@>=4.2.0: +js-yaml@>=4.2.0, js-yaml@~4.1.1: version "4.2.0" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz" integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== @@ -833,11 +802,6 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash.merge@^4.6.2: - version "4.6.2" - resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" - integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== - lru-cache@^11.0.0: version "11.2.7" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz" @@ -850,7 +814,7 @@ make-dir@^4.0.0: dependencies: semver "^7.5.3" -markdown-it@>=14.2.0: +markdown-it@>=14.2.0, markdown-it@~14.1.1: version "14.2.0" resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz" integrity sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ== @@ -900,7 +864,7 @@ mdurl@^2.0.0: resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -micromark-core-commonmark@^2.0.0, micromark-core-commonmark@2.0.3: +micromark-core-commonmark@2.0.3, micromark-core-commonmark@^2.0.0: version "2.0.3" resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz" integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== @@ -1117,7 +1081,7 @@ micromark-util-symbol@^2.0.0: resolved "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz" integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== -micromark-util-types@^2.0.0, micromark-util-types@2.0.2: +micromark-util-types@2.0.2, micromark-util-types@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz" integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== @@ -1145,21 +1109,7 @@ micromark@4.0.2: micromark-util-symbol "^2.0.0" micromark-util-types "^2.0.0" -minimatch@^10.2.2: - version "10.2.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== - dependencies: - brace-expansion "^5.0.5" - -minimatch@^3.1.2: - version "3.1.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz" - integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== - dependencies: - brace-expansion "^1.1.7" - -minimatch@~10.2.4: +minimatch@^10.2.2, minimatch@^10.2.4, minimatch@~10.2.4: version "10.2.5" resolved "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz" integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== @@ -1245,13 +1195,6 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - parse-entities@^4.0.0: version "4.0.2" resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz" @@ -1283,7 +1226,7 @@ path-scurry@^2.0.2: lru-cache "^11.0.0" minipass "^7.1.2" -"picomatch@^3 || ^4", picomatch@^4.0.3: +picomatch@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz" integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== @@ -1318,11 +1261,6 @@ require-from-string@^2.0.2: resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - run-con@~1.3.2: version "1.3.2" resolved "https://registry.npmjs.org/run-con/-/run-con-1.3.2.tgz" @@ -1365,24 +1303,6 @@ sql.js@^1.14.1: resolved "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz" integrity sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A== -string-width@^4.1.0, string-width@^4.2.0: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" @@ -1391,6 +1311,15 @@ string-width@8.1.0: get-east-asian-width "^1.3.0" strip-ansi "^7.1.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -1405,7 +1334,7 @@ strip-ansi@^7.1.0: dependencies: ansi-regex "^6.0.1" -strip-json-comments@^3.1.1, strip-json-comments@~3.1.1: +strip-json-comments@~3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== From 0cf17cc1b0c501361964243c375e807a5b9fdb57 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 29 Jun 2026 18:31:59 -0700 Subject: [PATCH 027/197] fix(ci): unbreak main after dependabot batch (checkout SHA + lint) (#2393) * fix(ci): track actions/checkout v7 SHA in supply-chain workflow test Dependabot #2328 bumped actions/checkout v6->v7, changing the pinned SHA in supply-chain-watch.yml; update the test's expected SHA to match. * Revert "feat(workflows): add orch-review native Workflow pilot (#2363)" This reverts commit 1031d312ccd16925c903174293b4cec4ecba1001. --- tests/ci/supply-chain-watch-workflow.test.js | 2 +- workflows/README.md | 59 ----- workflows/orch-review.workflow.js | 254 ------------------- 3 files changed, 1 insertion(+), 314 deletions(-) delete mode 100644 workflows/README.md delete mode 100644 workflows/orch-review.workflow.js diff --git a/tests/ci/supply-chain-watch-workflow.test.js b/tests/ci/supply-chain-watch-workflow.test.js index fa8198ae8..9b544a3c1 100644 --- a/tests/ci/supply-chain-watch-workflow.test.js +++ b/tests/ci/supply-chain-watch-workflow.test.js @@ -43,7 +43,7 @@ function run() { if (test('uses read-only permissions and non-persisting checkout credentials', () => { assert.match(source, /permissions:\r?\n\s+contents: read/); assert.doesNotMatch(source, /^\s+[A-Za-z-]+:\s*write\b/m); - assert.match(source, /uses: actions\/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10/); + assert.match(source, /uses: actions\/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0/); assert.match(source, /persist-credentials: false/); assert.doesNotMatch(source, /id-token:\s*write/); assert.doesNotMatch(source, /actions\/cache@/); diff --git a/workflows/README.md b/workflows/README.md deleted file mode 100644 index b67ed2e0f..000000000 --- a/workflows/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# ECC native workflows (pilot) - -Scripts in this directory are [Claude Code **Workflow** tool](https://docs.claude.com/en/docs/claude-code) scripts — deterministic, multi-agent orchestration that runs in the background and fans out to subagents. - -This is a **pilot**: ECC's orchestration (`orch-*`, `multi-*`, GAN/Santa loops) is currently hand-rolled on top of the `Task`/Agent tool. These scripts port the autonomous, fan-out-heavy segments to the native engine, which gives us barrier-free pipelining, automatic concurrency capping, structured-output validation, and resumability for free. - -## `orch-review.workflow.js` - -A native port of **orch-pipeline Phase 5 (Review)**. - -The gated outer loop (Gate 1 after Plan, Gate 2 before Commit) **stays in the main conversation** — native workflows run autonomously in the background and cannot pause for interactive approval. This script owns only the segment *between* the gates: - -1. **Review** — one reviewer agent per dimension, in parallel: - - `ecc:code-reviewer` (correctness & quality) — always - - the matching `ecc:-reviewer` — when `args.language` maps to one - - `ecc:security-reviewer` — only when the orch-pipeline security trigger matches the diff/paths -2. **Dedup** — independent reviewers routinely flag the same line, so findings are merged across dimensions keyed on the normalized `evidence` snippet (titles and line numbers drift per reviewer; the offending code does not). Each surviving finding records which `dimensions` reported it and keeps the strictest severity. -3. **Verify** — every *unique* `CRITICAL`/`HIGH` finding is handed to an independent adversarial verifier that defaults to *refuted* on uncertainty. `MEDIUM`/`LOW` pass through as advisory. - -The Review→Verify barrier is deliberate: deduping before verification is exactly the case the Workflow guidance calls a justified barrier — it stops the verifier running N times on the same bug (in local testing, 11 raw findings collapsed to 4 unique, roughly halving verifier cost). - -### Invocation - -The main loop computes the diff, then calls the Workflow tool: - -```jsonc -Workflow({ - scriptPath: "workflows/orch-review.workflow.js", - args: { - diff: "", // required - language: "typescript", // optional — selects a language reviewer - changedFiles: ["src/auth.ts"] // optional — feeds the security trigger - } -}) -``` - -Invalid input throws (the gate **fails closed**): a missing/empty `diff`, malformed JSON, or a non-array `changedFiles` is rejected with a clear error rather than silently approving an unreviewed payload. - -### Returns - -```jsonc -{ - "verdict": "APPROVE" | "CHANGES_REQUESTED", // CHANGES_REQUESTED if any blocker OR a dimension failed - "incomplete": false, // true when one or more review dimensions failed to run - "failedDimensions": [ /* { dimension, error } — error is a bounded label, never raw subagent text: - "agent returned null (terminal failure or skip)" | "review agent failed" */ ], - "blocking": [ /* confirmed CRITICAL/HIGH + unverifiable ones — must clear before Gate 2 */ ], - "advisory": [ /* MEDIUM/LOW + adversarially-refuted findings */ ], - "stats": { "dimensions": 3, "failed": 0, "raw": 11, "unique": 4, "confirmed": 4, "unverified": 0, "refuted": 0 } -} -``` - -The main loop presents `blocking` at Gate 2; the human still approves the commit. The gate fails closed at every stage: if a reviewer dies the dimension is recorded in `failedDimensions` (verdict never a clean `APPROVE`), and if a *verifier* dies or returns null the blocker is kept in `blocking` (tagged "could not be verified") rather than demoted to advisory — an unreviewed security dimension or an unverifiable CRITICAL must not pass as approved. - -## Not in this PR (follow-ups) - -- A `/orch-review` command + skill trigger (plus the mirrored i18n docs and surface tests ECC requires for a new command surface). -- Installer / manifest wiring so the script ships to `~/.claude/` on install. -- Porting the **Research** sweep and **Plan** judge-panel segments next. diff --git a/workflows/orch-review.workflow.js b/workflows/orch-review.workflow.js deleted file mode 100644 index 209f834ec..000000000 --- a/workflows/orch-review.workflow.js +++ /dev/null @@ -1,254 +0,0 @@ -export const meta = { - name: 'orch-review', - description: - 'ECC Review phase as a native Claude Code workflow: multi-dimension review (quality + language + conditional security) then adversarial verification of every CRITICAL/HIGH finding. Returns blocking + advisory findings for Gate 2.', - phases: [ - { title: 'Review', detail: 'one reviewer agent per dimension, in parallel' }, - { title: 'Verify', detail: 'adversarially refute each CRITICAL/HIGH finding' } - ] -}; - -// --------------------------------------------------------------------------- -// Pilot port of orch-pipeline Phase 5 (Review). The gated outer loop stays in -// the main conversation; this script owns only the autonomous, fan-out-heavy -// review+verify segment between the two human gates. -// -// Caller contract — pass `args` (the main loop computes the diff and language): -// { -// diff: string, // unified `git diff` text to review (required) -// language?: string, // e.g. "typescript" — selects a language reviewer -// changedFiles?: string[], // paths touched, used for the security trigger -// } -// Invalid input (missing/empty diff, bad JSON, non-array changedFiles) throws — -// the gate fails closed rather than silently approving an unreviewed payload. -// -// Returns: -// { verdict: 'APPROVE' | 'CHANGES_REQUESTED', // CHANGES_REQUESTED if any blocker OR a dimension failed -// incomplete: boolean, // true when one or more review dimensions failed to run -// failedDimensions: { dimension, error }[], -// blocking: Finding[], // confirmed CRITICAL/HIGH + unverifiable ones — must clear before Gate 2 -// advisory: Finding[], // MEDIUM/LOW + refuted findings, informational -// stats: { dimensions, failed, raw, unique, confirmed, unverified, refuted } } -// --------------------------------------------------------------------------- - -// Language → ECC reviewer agent. Mirrors the agents present in agents/. -const LANGUAGE_REVIEWER = { - typescript: 'ecc:typescript-reviewer', - javascript: 'ecc:typescript-reviewer', - python: 'ecc:python-reviewer', - go: 'ecc:go-reviewer', - rust: 'ecc:rust-reviewer', - java: 'ecc:java-reviewer', - kotlin: 'ecc:kotlin-reviewer', - swift: 'ecc:swift-reviewer', - php: 'ecc:php-reviewer', - csharp: 'ecc:csharp-reviewer', - fsharp: 'ecc:fsharp-reviewer', - react: 'ecc:react-reviewer', - vue: 'ecc:vue-reviewer', - flutter: 'ecc:flutter-reviewer', - dart: 'ecc:flutter-reviewer', - django: 'ecc:django-reviewer', - fastapi: 'ecc:fastapi-reviewer', - cpp: 'ecc:cpp-reviewer' -}; - -// orch-pipeline security trigger: auth/authz, user input, db queries, fs paths, -// external calls, crypto, secrets. Matched against the diff text + file paths. -const SECURITY_TRIGGER = - /\b(auth|login|password|passwd|token|secret|credential|api[_-]?key|session|jwt|oauth|cookie|sql|query|exec|eval|crypto|cipher|hash|hmac|sign|fs\.|readFile|writeFile|fetch|axios|request|subprocess|os\.system)\b/i; - -// A reviewer agent must emit findings in this shape — validated at the tool layer. -const FINDINGS_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['verdict', 'findings'], - properties: { - verdict: { type: 'string', enum: ['APPROVE', 'CHANGES_REQUESTED'] }, - findings: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['title', 'severity', 'file', 'evidence'], - properties: { - title: { type: 'string' }, - severity: { type: 'string', enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] }, - file: { type: 'string' }, - line: { type: ['integer', 'null'] }, - evidence: { type: 'string', minLength: 1, description: 'the offending snippet or exact location' }, - proof: { type: 'string', description: 'why it is a real problem (required for HIGH/CRITICAL)' }, - fix: { type: 'string', description: 'concrete suggested remediation' } - } - } - } - } -}; - -// Independent skeptic verdict for one finding. -const VERDICT_SCHEMA = { - type: 'object', - additionalProperties: false, - required: ['isReal', 'confidence', 'reasoning'], - properties: { - isReal: { type: 'boolean', description: 'true only if the finding genuinely holds against the diff' }, - confidence: { type: 'number', minimum: 0, maximum: 1 }, - reasoning: { type: 'string' } - } -}; - -const SEVERITY_RANK = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 }; -const isBlocking = f => f.severity === 'CRITICAL' || f.severity === 'HIGH'; -const normalize = s => (s || '').replace(/\s+/g, ' ').trim().toLowerCase(); - -function reviewPrompt(dimensionLabel, diff) { - return [ - `You are reviewing a unified diff along the "${dimensionLabel}" dimension.`, - 'Apply your standard checklist. Only report issues you are >80% sure are real problems.', - 'For any CRITICAL or HIGH finding you MUST supply concrete `evidence` and a `proof` of impact; if you cannot, demote it or drop it.', - 'Returning zero findings with verdict APPROVE is an acceptable and expected outcome for clean diffs.', - '', - 'DIFF:', - diff - ].join('\n'); -} - -function verifyPrompt(finding, diff) { - return [ - 'You are an independent skeptic. Try to REFUTE the finding below by checking it against the diff text provided here — and ONLY that text.', - 'The diff may be unapplied (a proposed PR), so the referenced file may not exist on disk yet. Do NOT refute a finding merely because the file is absent from the working tree; judge solely from the diff content.', - 'Default to isReal=false when you are uncertain or cannot locate supporting evidence in the diff text.', - '', - `Finding (${finding.severity}) in ${finding.file}: ${finding.title}`, - `Claimed evidence: ${finding.evidence}`, - finding.proof ? `Claimed proof: ${finding.proof}` : '', - '', - 'DIFF:', - diff - ].join('\n'); -} - -// --- main ----------------------------------------------------------------- - -// `args` arrives verbatim. Accept a JSON-encoded string too, so the workflow -// works whether the caller passes an object or a stringified payload. -// Fail CLOSED on invalid input: a review gate must never silently APPROVE a -// payload it could not actually review. -let input; -try { - input = typeof args === 'string' ? JSON.parse(args) : (args ?? {}); -} catch { - throw new Error('orch-review: args must be an object or valid JSON'); -} -if (typeof input !== 'object' || input === null) { - throw new Error('orch-review: args must be an object'); -} -if (typeof input.diff !== 'string' || input.diff.trim() === '') { - throw new Error('orch-review: args.diff must be a non-empty unified diff'); -} -if (input.changedFiles != null && !Array.isArray(input.changedFiles)) { - throw new Error('orch-review: args.changedFiles must be an array of paths'); -} - -const diff = input.diff; -const haystack = `${diff}\n${(input.changedFiles || []).join('\n')}`; - -// Build the review dimensions. Quality always runs; language + security are conditional. -const dimensions = [{ key: 'quality', label: 'correctness & quality', agentType: 'ecc:code-reviewer' }]; - -const langReviewer = input.language && LANGUAGE_REVIEWER[String(input.language).toLowerCase()]; -if (langReviewer) { - dimensions.push({ key: `lang:${input.language}`, label: `${input.language} idioms & pitfalls`, agentType: langReviewer }); -} - -if (SECURITY_TRIGGER.test(haystack)) { - dimensions.push({ key: 'security', label: 'security (OWASP, secrets, injection)', agentType: 'ecc:security-reviewer' }); - log('Security trigger matched — adding security-reviewer dimension.'); -} - -log(`Reviewing across ${dimensions.length} dimension(s): ${dimensions.map(d => d.key).join(', ')}`); - -// Stage 1 — every dimension reviews in parallel. This is a deliberate BARRIER: -// independent reviewers routinely flag the same line, so we need the full set -// before we can dedup. Verifying first and deduping later would waste verifier -// calls on duplicates (e.g. one SQL-injection bug reported by all 3 dimensions). -// A reviewer can fail two ways: agent() returns null on a terminal error/skip, -// or the thunk rejects. Capture both per-dimension so a lost dimension is never -// silently dropped — an unreviewed security dimension must not pass as APPROVE. -const reviews = await parallel( - dimensions.map( - d => () => - agent(reviewPrompt(d.label, diff), { agentType: d.agentType, phase: 'Review', label: `review:${d.key}`, schema: FINDINGS_SCHEMA }) - .then(r => (r === null ? { dim: d.key, ok: false, error: 'agent returned null (terminal failure or skip)', findings: [] } : { dim: d.key, ok: true, findings: r.findings || [] })) - // Log the raw error for operators; never return provider/runtime internals to the caller. - .catch(err => { - log(`Review dimension ${d.key} failed: ${String((err && err.message) || err)}`); - return { dim: d.key, ok: false, error: 'review agent failed', findings: [] }; - }) - ) -); - -const failedDimensions = reviews.filter(r => r && !r.ok).map(r => ({ dimension: r.dim, error: r.error })); -if (failedDimensions.length > 0) { - log(`WARNING: ${failedDimensions.length} review dimension(s) failed: ${failedDimensions.map(f => f.dimension).join(', ')}. Verdict will fail closed.`); -} - -// Dedup across dimensions. The evidence snippet (the offending code) is the most -// stable key — titles are phrased differently and line numbers drift per reviewer. -const tagged = reviews.filter(r => r && r.ok).flatMap(r => r.findings.map(f => ({ ...f, dimension: r.dim }))); -const byKey = new Map(); -for (const f of tagged) { - // Prefer the evidence snippet; fall back to title+line so empty-evidence - // findings in the same file don't all collapse onto one `${file}::` key. - const evidenceKey = normalize(f.evidence); - const key = evidenceKey ? `${f.file}::${evidenceKey}` : `${f.file}::${normalize(f.title)}::${f.line ?? 'na'}`; - const prev = byKey.get(key); - if (!prev) { - byKey.set(key, { ...f, dimensions: [f.dimension] }); - } else { - if (!prev.dimensions.includes(f.dimension)) prev.dimensions.push(f.dimension); - if (SEVERITY_RANK[f.severity] > SEVERITY_RANK[prev.severity]) prev.severity = f.severity; // keep the strictest - } -} -const unique = [...byKey.values()]; -log(`Reviews returned ${tagged.length} findings → ${unique.length} unique after dedup.`); - -// Stage 2 — adversarially verify each unique CRITICAL/HIGH. MEDIUM/LOW are advisory. -const advisory = unique.filter(f => !isBlocking(f)); -const verified = await parallel( - unique.filter(isBlocking).map( - f => () => - agent(verifyPrompt(f, diff), { phase: 'Verify', label: `verify:${f.file}:${normalize(f.evidence).slice(0, 40)}`, schema: VERDICT_SCHEMA }) - // A null return (terminal failure/skip) or a rejection means we could NOT - // verify the finding. Mark it `unverified` rather than refuted so it stays - // blocking (fail closed) — an unverifiable CRITICAL must never be demoted - // to advisory just because the verifier did not run. - .then(v => (v ? { ...f, verdict: v } : { ...f, unverified: true, verdict: { isReal: false, confidence: 0, reasoning: 'verifier returned null (terminal failure or skip)' } })) - .catch(err => { - log(`Verifier failed for ${f.file}: ${String((err && err.message) || err)}`); - return { ...f, unverified: true, verdict: { isReal: false, confidence: 0, reasoning: 'verifier error' } }; - }) - ) -); - -const verifiedClean = verified.filter(Boolean); -const confirmed = verifiedClean.filter(f => !f.unverified && f.verdict && f.verdict.isReal); -const unverified = verifiedClean.filter(f => f.unverified); -const refuted = verifiedClean.filter(f => !f.unverified && !(f.verdict && f.verdict.isReal)); - -// Unverifiable blockers stay in `blocking` (fail closed), tagged so the human -// at Gate 2 knows they were not independently confirmed. -const blocking = [...confirmed, ...unverified.map(f => ({ ...f, note: 'could not be verified — kept as blocking' }))]; - -log(`Done: ${confirmed.length} confirmed, ${unverified.length} unverified (kept blocking), ${refuted.length} refuted, ${advisory.length} advisory.`); - -// Fail closed: APPROVE only when every dimension ran AND nothing blocks. -const incomplete = failedDimensions.length > 0; -return { - verdict: blocking.length > 0 || incomplete ? 'CHANGES_REQUESTED' : 'APPROVE', - incomplete, - failedDimensions, - blocking, - advisory: [...advisory, ...refuted.map(f => ({ ...f, note: 'refuted by adversarial verifier' }))], - stats: { dimensions: dimensions.length, failed: failedDimensions.length, raw: tagged.length, unique: unique.length, confirmed: confirmed.length, unverified: unverified.length, refuted: refuted.length } -}; From 61c103d583dc88b838c82dea35d10de4071437ac Mon Sep 17 00:00:00 2001 From: KyawZinLatt Date: Tue, 30 Jun 2026 08:08:16 +0630 Subject: [PATCH 028/197] feat: add ecc-recipes skill (#2319) * feat: add ecc-recipes skill Maps a described workflow to the right ECC command-group with run-order and stop condition, and browses command-group recipe families. Fills the gap between ecc-guide (flat catalog) and prompt-optimizer (single-prompt match) by adding family grouping, run-order, and stop conditions. Advisory only; reads commands/ live. * fix(ecc-recipes): address review - flatten frontmatter origin/author/version to top-level (repo convention) - guard unset CMD_DIR before globbing; use find instead of ls - show burn-warning explicitly in output template * feat(ecc-recipes): add argument-hint for slash UI --- skills/ecc-recipes/SKILL.md | 149 ++++++++++++++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 skills/ecc-recipes/SKILL.md diff --git a/skills/ecc-recipes/SKILL.md b/skills/ecc-recipes/SKILL.md new file mode 100644 index 000000000..f4633b7cb --- /dev/null +++ b/skills/ecc-recipes/SKILL.md @@ -0,0 +1,149 @@ +--- +name: ecc-recipes +description: "Map a described workflow to the right ECC command-GROUP with run-order and stop condition, and browse all command-group recipe families. Adds a family-grouping + run-order + when-to-stop layer on top of the flat command catalog. Advisory only. TRIGGER when the user says which commands for X, what command group runs X, show ECC recipes, list ECC pipelines, or how do I run a workflow with ECC. DO NOT TRIGGER when the user wants the task executed directly, wants a single-command deep doc (use ecc-guide), or wants a draft prompt rewritten (use prompt-optimizer)." +argument-hint: +origin: community +author: KyawZinLatt +version: "1.0.0" +--- + +# ECC Recipes + +One entry point for "which group of ECC slash-commands runs my workflow, in what +order, and when do I stop." Also browses every command-group recipe family. + +Fills the gap between two existing skills: + +- `ecc-guide` — lists commands and where to read docs, but as a flat catalog. +- `prompt-optimizer` — matches a task to components, but outputs a single prompt, + not a multi-command group with run-order and stop condition. + +This skill adds: **family grouping + run-order + stop condition.** + +## When to Activate + +- "Which command group do I run for ?" +- "What's the command sequence to build an MVP / fix a defect / refactor?" +- "Show me all ECC command-group recipes" (catalog mode) +- "How many workflow pipelines does ECC have?" +- User invokes `/ecc-recipes` with or without a description. + +### Do Not Use When + +- User wants the task done now — route to the actual command, don't describe it. +- User wants deep docs for ONE command — use `ecc-guide`. +- User wants a draft prompt rewritten — use `prompt-optimizer`. + +## Core Principle + +**Answer from current files, not memory.** The command set changes; never +hardcode counts or member lists. Read the live `commands/` directory each run, +then classify into families. + +### Live reads + +Resolve the commands directory (first that exists), then list names: + +```bash +for D in \ + "$HOME"/.claude/plugins/marketplaces/ecc/commands \ + "$HOME"/.claude/plugins/cache/ecc/ecc/*/commands \ + ./commands \ + ./.claude/commands \ + "$HOME"/.claude/commands; do + [ -d "$D" ] && CMD_DIR="$D" && break +done +[ -z "${CMD_DIR:-}" ] && { echo "No ECC commands directory found."; return 1; } +find "$CMD_DIR" -maxdepth 1 -name '*.md' -exec basename {} .md \; | sort +``` + +Optionally read `manifests/install-*.json` if present for richer grouping. Use +the smallest set of reads needed. + +## Family Classification (by prefix) + +Group command names by leading prefix; map known singletons by hand. Families are +derived live — the table below is the *classification rule*, not a frozen list. + +| Family prefix | Recipe meaning | Typical run-order | +|---|---|---| +| `orch-*` | gated Research, Plan, TDD, Review, Commit per task type | pick one orch-* by task kind; it runs its own internal phases | +| `multi-*` | multi-model workflow | `multi-plan` then `multi-execute` then review (or `multi-workflow` end-to-end) | +| `prp-*` | PRD to plan to implement to PR pipeline | `prp-prd` then `prp-plan` then `prp-implement` then `prp-commit` then `prp-pr` | +| `epic-*` | large multi-unit epic, parallel | `epic-decompose` then `epic-claim` then `epic-validate` then `epic-review` then `epic-unblock` then `epic-sync` then `epic-publish` | +| `loop-*` | managed autonomous loop and monitor | `loop-start ` then watch with `loop-status` | +| `gan-*` | generator and evaluator loop | `gan-build` (code) or `gan-design` (UI); self-looping | +| `*-build` / `*-review` / `*-test` | per-language CI triad | `-test` (TDD) then `-build` (fix) then `-review` | +| `hookify-*` | behavior-hook management | `hookify` then `hookify-list` then `hookify-configure` | +| `learn` / `instinct-*` / `evolve` / `promote` / `prune` | continuous-learning | `learn` then `instinct-status` then `evolve` then `promote` | +| singletons | `santa-loop`, `plan`, `plan-prd`, `pr`, `code-review`, `checkpoint`, etc. | standalone or glue between groups | + +Any command not matching a prefix rule → list it under **singletons** with its +one-line description. + +## How It Works + +``` +1. Live-read command names from CMD_DIR. +2. Classify into families by prefix and a singleton map. +3. If a workflow description was given -> MATCH MODE. + If none -> CATALOG MODE. +4. Advisory only: print the plan. Never run the matched commands. +``` + +### Catalog mode (no description) + +Output the family table: each family, member count, members, one-line meaning, +typical run-order. End with the total command count and a prompt to describe a +workflow for a matched recipe. + +### Match mode (description given) + +1. Restate the workflow in one sentence. +2. Pick the best 1-2 families; say WHY in one line each. +3. **Run-order block** — exact command sequence for the matched family. +4. **Stop condition** — always explicit (max-runs, completion-signal, + review-passes, or single-shot). For autonomous loops, warn about subscription + burn and recommend a backstop bound. +5. **Where to read** — the `commands/.md` path plus `/ecc-guide `. + +## Output Template (match mode) + +``` +Workflow: + +Best fit: +(Alt: ) + +Run-order: + / # job + / # job + / # job + STOP when: + WARNING (autonomous loops only): an unbounded loop burns subscription/credits — + add a max-iteration or max-cost backstop alongside the completion signal. + +Read full docs: + commands/.md (or: /ecc-guide ) +``` + +## Examples + +**Catalog:** `/ecc-recipes` → prints the family table and total count. + +**Match:** `/ecc-recipes plan a whole app upfront then auto-build with adversarial +review until done` → Best fit: `loop-*` (autonomous) wrapping `gan-*` or +`santa-loop` (adversarial). Run-order: `plan-prd` then +`loop-start rfc-dag --mode safe` then monitor `loop-status`; STOP when all units +pass review N consecutive times (add a max-iteration backstop to bound burn). + +**Match:** `/ecc-recipes fix a bug in my Go service` → Best fit: `orch-fix-defect` +(reproduce, fix, review, commit). Alt: `go-test` then `go-build` then +`go-review`. STOP: regression test green and review pass. + +## Non-Goals + +- Not an executor — advisory only. +- Not per-command deep docs — that's `ecc-guide`. +- Not prompt rewriting — that's `prompt-optimizer`. +- Never hardcode command counts or member lists — always live-read. From 237b0b90088fc6191934f70b1e0f803ce6a86cb6 Mon Sep 17 00:00:00 2001 From: Awa Dieudonne Date: Tue, 30 Jun 2026 02:38:19 +0100 Subject: [PATCH 029/197] feat(skills): add mailtrap-email-integration skill (#2288) Adds a new Tool Integration skill (mailtrap-email-integration) covering transactional email sending patterns: sandbox vs. production separation, API authentication, and domain verification. Focused on patterns that generalize beyond one vendor, per the repo's Skill Adaptation Policy. --- skills/mailtrap-email-integration/SKILL.md | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 skills/mailtrap-email-integration/SKILL.md diff --git a/skills/mailtrap-email-integration/SKILL.md b/skills/mailtrap-email-integration/SKILL.md new file mode 100644 index 000000000..f70c7bc0f --- /dev/null +++ b/skills/mailtrap-email-integration/SKILL.md @@ -0,0 +1,77 @@ +--- +name: mailtrap-email-integration +description: Guides agents through integrating transactional email sending via Mailtrap's Email API, including sandbox testing, domain verification, and API authentication. Use when implementing email-sending features, debugging delivery issues, or setting up safe dev/staging email testing. +origin: ECC +--- + +# Mailtrap Email Integration + +Patterns for adding transactional email sending to an application using Mailtrap's Email API and Sandbox, covering authentication, environment separation, and common delivery pitfalls. + +## When to Activate + +- Implementing a "send email" feature (signup confirmation, password reset, notifications, receipts) +- Debugging why emails aren't arriving in dev/staging +- Setting up a project's first email-sending integration +- Reviewing code that calls an email API directly without sandbox separation + +## Core Concepts + +**Sandbox vs. Production separation.** Mailtrap provides a Sandbox API that captures emails without delivering them, used for dev/staging so test emails never reach real inboxes. Production sending uses a separate, verified-domain endpoint. Never point a dev environment at the production sending endpoint. + +**Authentication.** Requests use a Bearer token in the `Authorization` header. Tokens are scoped per project; sandbox and production typically use different tokens. + +**Domain verification.** Production sending requires verifying a sending domain via DNS records (SPF, DKIM, DMARC) before Mailtrap will deliver to real recipients. Skipping this causes silent delivery failures or spam-folder placement. + +## Code Examples + +```typescript +// Sending via Mailtrap's Email API (production) +async function sendEmail(to: string, subject: string, html: string) { + const response = await fetch("https://send.api.mailtrap.io/api/send", { + method: "POST", + headers: { + "Authorization": `Bearer ${process.env.MAILTRAP_API_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: { email: "no-reply@yourverifieddomain.com", name: "Your App" }, + to: [{ email: to }], + subject, + html, + }), + }); + + if (!response.ok) { + throw new Error(`Email send failed: ${response.status}`); + } + return response.json(); +} +``` + +```typescript +// Same call, routed to Sandbox in non-production environments +const MAILTRAP_ENDPOINT = process.env.NODE_ENV === "production" + ? "https://send.api.mailtrap.io/api/send" + : `https://sandbox.api.mailtrap.io/api/send/${process.env.MAILTRAP_INBOX_ID}`; +``` + +## Anti-Patterns + +| Anti-Pattern | Why It's a Problem | Instead | +| --- | --- | --- | +| Using the production sending endpoint in dev/test | Real test emails reach real inboxes, risking spam complaints and leaked test data | Route non-production environments to the Sandbox endpoint | +| Hardcoding API tokens in source | Credential leak risk if committed to version control | Load tokens from environment variables / secrets manager | +| Sending before domain verification completes | Emails silently fail or land in spam | Verify SPF/DKIM/DMARC records before enabling production sending | +| No retry/error handling on send failures | Silent notification failures (e.g., user never gets password reset email) | Check response status, log failures, surface actionable errors | + +## Best Practices + +- Keep sandbox and production tokens in separate environment variables, never share one token across environments +- Verify sending domain DNS records before any production launch involving email +- Log delivery failures with enough context to debug (recipient, template, timestamp, response code) +- Treat email sending as a fallible network call: wrap in try/catch, never assume success + +## Related Skills + +`api-and-interface-design`, `security-and-hardening`, `ci-cd-and-automation` From d178db82a2211f9a0f61e016e144a4fe695f7ffa Mon Sep 17 00:00:00 2001 From: Carlos Carvallo Date: Tue, 30 Jun 2026 02:38:22 +0100 Subject: [PATCH 030/197] docs(code-tour): document ref-field semantics to prevent PR-tour file-not-found (#2273) The code-tour skill mentioned the CodeTour 'ref' field only in an example, with no explanation of its behavior. CodeTour resolves each step's file content from the git revision named by 'ref' (not the working tree) whenever ref differs from HEAD, so any file that does not exist at that revision fails to open with 'The editor could not be opened because the file was not found' - even though the file is present on disk. This bit a generated PR tour where ref was set to the base branch (develop): every file ADDED by the PR is absent on the base, so all new-file steps 404'd while the tour tree and comments still rendered, making the cause non-obvious. Adds a 'The ref Field' section explaining the resolution behavior and the rule that PR tours must pin ref to the branch head (never the base), plus a validation step to confirm every referenced file exists at the chosen ref. --- skills/code-tour/SKILL.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/skills/code-tour/SKILL.md b/skills/code-tour/SKILL.md index 34a9629aa..fc82ee690 100644 --- a/skills/code-tour/SKILL.md +++ b/skills/code-tour/SKILL.md @@ -93,8 +93,25 @@ Before finishing: - every referenced path exists - every line or selection is valid - the first step is anchored to a real file or directory +- the `ref` points at a branch or commit that actually has every file the tour references (see below) - the tour tells a coherent story rather than listing files +## The `ref` Field + +`ref` ties the tour to a git branch or commit. It matters more than it looks: when `ref` is not the branch the reader has checked out, CodeTour opens each step's file from that revision in git, not from the files on disk. If a file is not in that revision, the step will not open — the reader sees *"The editor could not be opened because the file was not found"* even though the file is sitting right there. The tour and its comments still show, so the real cause is easy to miss. + +Pick `ref` by tour type: + +| Tour type | Set `ref` to | +| --- | --- | +| PR tour | the PR branch — never the base branch | +| Onboarding / architecture | the branch the reader will be on (often `main`), or leave it out | +| Not sure | leave `ref` out, so CodeTour reads files straight from disk | + +The PR case is the common trap: a PR usually adds new files, and new files do not exist on the base branch yet. Point `ref` at the base (e.g. `develop`) and every step on a new file fails to open. + +Before finishing, confirm each step's file actually exists at the `ref` you chose. + ## Step Types ### Content From ec4925135c19f1ffb80849c3b8780222dd23ce27 Mon Sep 17 00:00:00 2001 From: Jun <39075334+mc856@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:38:24 +0800 Subject: [PATCH 031/197] fix(gateguard): finish tool-agnostic checklist across edit gate and SKILL.md copies (#2274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit b3268fef (#2272) made the write-gate "confirm no existing file" item tool-agnostic in the JS hook, but the rest of the checklist surface still names Glob/Grep. On hosts without those tools the agent still hits a dead tool call on: - the edit-gate "list importers" item in the hook (scripts/hooks/gateguard-fact-force.js) - both checklist items in all three SKILL.md copies (en, ja-JP, zh-CN) Apply the same wording b3268fef introduced — "(search the tree — Glob/Grep, or find/grep via Bash)" — to those five remaining spots so the whole gate is consistent. Prose-only; no logic change. Follow-up to #2272 / b3268fef. --- docs/ja-JP/skills/gateguard/SKILL.md | 4 ++-- docs/zh-CN/skills/gateguard/SKILL.md | 4 ++-- scripts/hooks/gateguard-fact-force.js | 2 +- skills/gateguard/SKILL.md | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/ja-JP/skills/gateguard/SKILL.md b/docs/ja-JP/skills/gateguard/SKILL.md index 7048d868b..bdf78b276 100644 --- a/docs/ja-JP/skills/gateguard/SKILL.md +++ b/docs/ja-JP/skills/gateguard/SKILL.md @@ -52,7 +52,7 @@ MultiEdit is handled identically — each file in the batch is gated individuall ``` Before editing {file_path}, present these facts: -1. List ALL files that import/require this file (use Grep) +1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash) 2. List the public functions/classes affected by this change 3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data) @@ -65,7 +65,7 @@ Before editing {file_path}, present these facts: Before creating {file_path}, present these facts: 1. Name the file(s) and line(s) that will call this new file -2. Confirm no existing file serves the same purpose (use Glob) +2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash) 3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data) 4. Quote the user's current instruction verbatim diff --git a/docs/zh-CN/skills/gateguard/SKILL.md b/docs/zh-CN/skills/gateguard/SKILL.md index 7da52bf68..05b651d3d 100644 --- a/docs/zh-CN/skills/gateguard/SKILL.md +++ b/docs/zh-CN/skills/gateguard/SKILL.md @@ -52,7 +52,7 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案 ``` 在编辑 {file_path} 之前,请先呈现以下事实: -1. 列出所有导入/引用此文件的文件(使用 Grep) +1. 列出所有导入/引用此文件的文件(在代码树中搜索——Glob/Grep,或通过 Bash 用 find/grep) 2. 列出受此更改影响的公共函数/类 3. 如果此文件读取/写入数据文件,请显示字段名称、结构以及日期格式(使用脱敏或合成值,而非原始生产数据) 4. 逐字引用用户当前的指令 @@ -64,7 +64,7 @@ LLM 的自我评估不起作用。问"你是否违反了任何策略?"答案 在创建 {file_path} 之前,请先说明以下事实: 1. 命名将调用此新文件的文件及行号 -2. 确认没有现有文件具有相同功能(使用 Glob) +2. 确认没有现有文件具有相同功能(在代码树中搜索——Glob/Grep,或通过 Bash 用 find/grep) 3. 如果此文件读取/写入数据文件,请展示字段名称、结构及日期格式(使用脱敏或合成值,而非原始生产数据) 4. 逐字引用用户当前的指令 ``` diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index 99e72cc1b..4bc7b0361 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -1020,7 +1020,7 @@ function editGateMsg(filePath) { '', `Before editing ${safe}, present these facts:`, '', - '1. List ALL files that import/require this file (use Grep)', + '1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash)', '2. List the public functions/classes affected by this change', '3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)', "4. Quote the user's current instruction verbatim", diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 59cb39ec1..9a4bb0314 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -53,7 +53,7 @@ MultiEdit is handled identically — each file in the batch is gated individuall ``` Before editing {file_path}, present these facts: -1. List ALL files that import/require this file (use Grep) +1. List ALL files that import/require this file (search the tree — Glob/Grep, or find/grep via Bash) 2. List the public functions/classes affected by this change 3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data) @@ -66,7 +66,7 @@ Before editing {file_path}, present these facts: Before creating {file_path}, present these facts: 1. Name the file(s) and line(s) that will call this new file -2. Confirm no existing file serves the same purpose (use Glob) +2. Confirm no existing file serves the same purpose (search the tree — Glob/Grep, or find/grep via Bash) 3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data) 4. Quote the user's current instruction verbatim From 8c75abf02e6860c3c40d6d4a1161f62a01867cf6 Mon Sep 17 00:00:00 2001 From: jvirgovic Date: Tue, 30 Jun 2026 01:38:27 +0000 Subject: [PATCH 032/197] feat(skills): harden the file upload validation section in django-security (#2338) * feat(skills): harden the file upload validation section in django-security * Update skills/django-security/SKILL.md Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * add missing stuff to second code block * add import to the top of the code block --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- skills/django-security/SKILL.md | 68 ++++++++++++++++++++++++++++----- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/skills/django-security/SKILL.md b/skills/django-security/SKILL.md index c81a66437..b95b97958 100644 --- a/skills/django-security/SKILL.md +++ b/skills/django-security/SKILL.md @@ -392,27 +392,77 @@ def webhook_view(request): ```python import os +import magic # pip install python-magic from django.core.exceptions import ValidationError -def validate_file_extension(value): - """Validate file extension.""" - ext = os.path.splitext(value.name)[1] - valid_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.pdf'] - if not ext.lower() in valid_extensions: - raise ValidationError('Unsupported file extension.') +ALLOWED_MIMES = { + 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', +} + +MIME_TO_EXTENSIONS = { + 'image/jpeg': {'.jpg', '.jpeg'}, + 'image/png': {'.png'}, + 'image/gif': {'.gif'}, + 'application/pdf': {'.pdf'}, +} + +def validate_file_type(value): + """Validate file type using magic bytes and cross-check extension.""" + mime = magic.from_buffer(value.read(2048), mime=True) + value.seek(0) + + if mime not in ALLOWED_MIMES: + raise ValidationError('Unsupported file type.') + + ext = os.path.splitext(value.name)[1].lower() + if ext not in MIME_TO_EXTENSIONS.get(mime, set()): + raise ValidationError('File extension does not match file content.') def validate_file_size(value): """Validate file size (max 5MB).""" - filesize = value.size - if filesize > 5 * 1024 * 1024: + if value.size > 5 * 1024 * 1024: raise ValidationError('File too large. Max size is 5MB.') # models.py class Document(models.Model): file = models.FileField( upload_to='documents/', - validators=[validate_file_extension, validate_file_size] + validators=[validate_file_type, validate_file_size] ) + +``` + +For environments where installing libmagic is difficult (e.g., minimal containers), +use the pure-Python `filetype` package as an alternative: + +```python +import os +from django.core.exceptions import ValidationError + +import filetype # pip install filetype + +ALLOWED_MIMES = { + 'image/jpeg', 'image/png', 'image/gif', 'application/pdf', +} + +MIME_TO_EXTENSIONS = { + 'image/jpeg': {'.jpg', '.jpeg'}, + 'image/png': {'.png'}, + 'image/gif': {'.gif'}, + 'application/pdf': {'.pdf'}, +} + +def validate_file_type(value): + """Validate file type using magic bytes.""" + kind = filetype.guess(value.read(2048)) + value.seek(0) + + if kind is None or kind.mime not in ALLOWED_MIMES: + raise ValidationError('Unsupported file type.') + + ext = os.path.splitext(value.name)[1].lower() + if ext not in MIME_TO_EXTENSIONS.get(kind.mime, set()): + raise ValidationError('File extension does not match file content.') ``` ### Secure File Storage From 3a46c82b0c074d8c872be26b8141708c2771ab87 Mon Sep 17 00:00:00 2001 From: weizhiyuan <104509245+m18897829375@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:38:30 +0800 Subject: [PATCH 033/197] docs(skills): update Prisma and Zod API patterns for cross-version compatibility (#2336) * docs(skills): update Prisma and Zod API patterns for cross-version compatibility - skills/prisma-patterns: show both adapter-based and direct PrismaClient initialization side-by-side; update import paths with conditional notes; rewrite version header to be release-agnostic - skills/backend-patterns: fix ZodError.errors -> ZodError.issues - skills/coding-standards: fix ZodError.errors -> ZodError.issues - skills/security-review: fix ZodError.errors -> ZodError.issues These API differences were discovered during implementation of a full-stack health assessment project. The updated code samples show both the new and old API forms so the skill remains useful regardless of which Prisma or Zod version is installed. Closes #2335 * fix(skills): revert Prisma client imports to '@prisma/client' The 'prisma' npm package is the CLI tool, not the runtime client. Using it as an import source would cause compile-time failures on all versions. '@prisma/client' remains the correct import source for the generated PrismaClient and Prisma namespace types. Found by Greptile during PR review. --- skills/backend-patterns/SKILL.md | 2 +- skills/coding-standards/SKILL.md | 2 +- skills/prisma-patterns/SKILL.md | 65 +++++++++++++++++++++++--------- skills/security-review/SKILL.md | 2 +- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/skills/backend-patterns/SKILL.md b/skills/backend-patterns/SKILL.md index db124bb7a..24b318d84 100644 --- a/skills/backend-patterns/SKILL.md +++ b/skills/backend-patterns/SKILL.md @@ -290,7 +290,7 @@ export function errorHandler(error: unknown, req: Request): Response { return NextResponse.json({ success: false, error: 'Validation failed', - details: error.errors + details: error.issues }, { status: 400 }) } diff --git a/skills/coding-standards/SKILL.md b/skills/coding-standards/SKILL.md index bd6dc3223..2934c3dd6 100644 --- a/skills/coding-standards/SKILL.md +++ b/skills/coding-standards/SKILL.md @@ -324,7 +324,7 @@ export async function POST(request: Request) { return NextResponse.json({ success: false, error: 'Validation failed', - details: error.errors + details: error.issues }, { status: 400 }) } } diff --git a/skills/prisma-patterns/SKILL.md b/skills/prisma-patterns/SKILL.md index c9f75c172..894bab1e5 100644 --- a/skills/prisma-patterns/SKILL.md +++ b/skills/prisma-patterns/SKILL.md @@ -8,15 +8,18 @@ metadata: # Prisma Patterns Production patterns and non-obvious traps for Prisma ORM in TypeScript backends. -Tested against Prisma 5.x and 6.x. Some behaviors differ from Prisma 4. -Check the Prisma version before applying version-specific patterns: - -```bash -npx prisma --version -``` - -Prisma 5 introduced `relationJoins`, which can load relations via JOIN rather than separate queries depending on query strategy and configuration. The `omit` field modifier and `prisma.$extends` Client Extensions API were also added. Note: `relationJoins` can cause row explosion on large 1:N relations or deep nested `include` — benchmark both approaches when relations may return many rows per parent. +> **Check your version before applying patterns.** The Prisma API surface has evolved across major releases: +> +> ```bash +> npx prisma --version +> ``` +> +> Notable API differences across versions: +> - `relationJoins` can load relations via JOIN rather than separate queries, but may cause row explosion on large 1:N relations or deep `include` — benchmark both approaches +> - `omit` field modifier and `prisma.$extends` Client Extensions API were added +> - **Newer installs**: the package may be named `prisma` instead of `@prisma/client`; `PrismaClient` may require a driver adapter (e.g. `@prisma/adapter-pg`); `datasource.url` may live in `prisma.config.ts` instead of `schema.prisma` +> - CLI commands (`migrate dev`, `migrate deploy`, `generate`) are unchanged across versions ## When to Activate @@ -122,19 +125,35 @@ Each `PrismaClient` instance opens its own connection pool. Instantiate once. ```ts // lib/prisma.ts -import { PrismaClient } from '@prisma/client'; + +// Option A — adapter-based initialization (required by newer Prisma installs) +import { PrismaClient } from '@prisma/client'; // or the generated client path for your setup +import { PrismaPg } from '@prisma/adapter-pg'; + +function createPrismaClient() { + const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL!, + }); + return new PrismaClient({ + adapter, + log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'], + }); +} const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; -export const prisma = - globalForPrisma.prisma ?? - new PrismaClient({ - log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'], - }); +export const prisma = globalForPrisma.prisma ?? createPrismaClient(); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; + +// Option B — direct initialization (older installs, no adapter needed) +// import { PrismaClient } from '@prisma/client'; +// export const prisma = globalForPrisma.prisma ?? new PrismaClient({ ... }); ``` +Use Option A if your Prisma install requires an `adapter` argument in the `PrismaClient` constructor. +Use Option B if `new PrismaClient()` works without arguments. Let the compiler tell you which is correct. + The `globalThis` pattern prevents duplicate instances during hot reload (Next.js, nodemon, ts-node-dev). ### N+1 Problem @@ -192,7 +211,7 @@ await prisma.user.update({ where: { id }, data: { deletedAt: null } }); // resto ### Error Handling ```ts -import { Prisma } from '@prisma/client'; +import { Prisma } from '@prisma/client'; // or the generated client path for your setup try { await prisma.user.create({ data: { email } }); @@ -223,9 +242,19 @@ DATABASE_URL="postgresql://user:pass@host/db?pgbouncer=true&connection_limit=1" ``` ```ts -// Vercel, AWS Lambda, and similar serverless runtimes: cap pool to 1 per instance -// connection_limit and pool_timeout are controlled via DATABASE_URL -const prisma = new PrismaClient(); +// Vercel, AWS Lambda, and similar serverless runtimes: +// cap pool to 1 per instance; connection_limit and pool_timeout controlled via DATABASE_URL + +// Adapter-based setup (if your Prisma install requires an adapter): +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }), +}); + +// Direct setup (if your Prisma install does not require an adapter): +// const prisma = new PrismaClient(); ``` ## Anti-Patterns diff --git a/skills/security-review/SKILL.md b/skills/security-review/SKILL.md index 05dd17869..0846d70a1 100644 --- a/skills/security-review/SKILL.md +++ b/skills/security-review/SKILL.md @@ -67,7 +67,7 @@ export async function createUser(input: unknown) { return await db.users.create(validated) } catch (error) { if (error instanceof z.ZodError) { - return { success: false, errors: error.errors } + return { success: false, errors: error.issues } } throw error } From 7976e6faf24640fe660c8137ec9ff4fc8625b3d5 Mon Sep 17 00:00:00 2001 From: JongHyeok Park Date: Tue, 30 Jun 2026 10:38:33 +0900 Subject: [PATCH 034/197] feat(skills): make tdd-workflow test-runner aware (npm/pnpm/yarn/bun) (#2347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(skills): make tdd-workflow test-runner aware (npm/pnpm/yarn/bun) Add "Step 0: Detect the Test Runner" so the RED/GREEN cycle no longer hardcodes `npm test`. Distinguishes the package manager from the test runner (a project can install with Bun yet run Jest/Vitest), adds a runner command matrix, and warns about `bun test` (native bun:test runner) vs `bun run test` (runs the package.json script) — a common ESM failure mode. Adds a Bun native test pattern section and links the bun-runtime skill. Applied to both the canonical skills/ copy and the .agents/skills/ Codex subset (manual sync per CONTRIBUTING). * docs(skills): apply / placeholders in tdd-workflow steps Address review feedback on PR #2347: Step 0 instructs the agent to substitute the detected runner command, but Steps 3/5/7, Run Coverage Report, Watch Mode, Pre-Commit, and CI/CD still showed literal `npm test` / `npm run test:coverage` — so an agent reaching those blocks could run npm test on a pnpm/bun project. Replace them with the / / placeholders from Step 0. Left untouched: the plan-handoff allowlist example and the Step 8 evidence-table samples (illustrative, not run-this instructions). Applied to both the canonical and Codex-subset copies. * docs(skills): make pre-commit lint runner-agnostic via placeholder Follow-up to PR #2347 review (CodeRabbit): the pre-commit example still used `npm run lint`, coupling it to npm after test/coverage were made runner-aware. Add a `` column to the Step 0 runner matrix (npm run lint / pnpm lint / yarn lint / bun run lint) and change the Pre-Commit Hook example to ` && `. Applied to both the canonical and Codex-subset copies. * chore: re-trigger CI (flaky windows/node20 npm cell) --- .agents/skills/tdd-workflow/SKILL.md | 71 +++++++++++++++++++++++++--- skills/tdd-workflow/SKILL.md | 71 +++++++++++++++++++++++++--- 2 files changed, 128 insertions(+), 14 deletions(-) diff --git a/.agents/skills/tdd-workflow/SKILL.md b/.agents/skills/tdd-workflow/SKILL.md index 7e61dcef0..661a1e581 100644 --- a/.agents/skills/tdd-workflow/SKILL.md +++ b/.agents/skills/tdd-workflow/SKILL.md @@ -48,6 +48,34 @@ ALWAYS write tests first, then implement code to make tests pass. ## TDD Workflow Steps +### Step 0: Detect the Test Runner + +Do not assume `npm test`. The commands in the steps and examples below use ``, ``, and `` as placeholders for the project's actual runner. Resolve them once before starting: + +1. **Run the package-manager detector** (ships with ECC): + + ```bash + node scripts/setup-package-manager.js --detect + ``` + + It resolves the package manager (npm / pnpm / yarn / bun) from, in order: `CLAUDE_PACKAGE_MANAGER`, `.claude/package-manager.json`, the `package.json` `packageManager` field, the lockfile, then global config. + +2. **Distinguish the package manager from the test runner — they are not the same.** A project can use Bun to install dependencies yet still run Jest or Vitest. Inspect `package.json` `scripts.test` and the test files: + - `scripts.test` invokes `jest` / `vitest` -> run through the detected PM (`npm test`, `pnpm test`, `yarn test`, or `bun run test`). + - `scripts.test` is `bun test`, or test files `import { test, expect } from "bun:test"`, or there is no jest/vitest config but Bun is present -> use **Bun's native runner** (`bun test`). See [Bun Native Test Pattern](#bun-native-test-pattern-buntest) below. + +Runner command matrix: + +| Runner | `` | `` | `` | `` | +|--------|----------|----------------|--------------|----------| +| npm | `npm test` | `npm test -- --watch` | `npm run test:coverage` | `npm run lint` | +| pnpm | `pnpm test` | `pnpm test --watch` | `pnpm test:coverage` | `pnpm lint` | +| yarn | `yarn test` | `yarn test --watch` | `yarn test:coverage` | `yarn lint` | +| Bun (script runs jest/vitest) | `bun run test` | `bun run test --watch` | `bun run test:coverage` | `bun run lint` | +| Bun (native `bun:test`) | `bun test` | `bun test --watch` | `bun test --coverage` | `bun run lint` | + +> `bun test` (Bun's built-in runner) is **not** the same as `bun run test` (which runs the `package.json` `test` script). Picking the wrong one is a common failure — e.g. invoking Jest through `npx`/`bun run` in an ESM-only project breaks, while `bun test` runs the suite natively. Confirm which the project expects before the RED gate, then substitute `` / `` everywhere `npm test` appears below. + ### Step 1: Write User Journeys ``` As a [role], I want to [action], so that [benefit] @@ -82,7 +110,7 @@ describe('Semantic Search', () => { ### Step 3: Run Tests (They Should Fail) ```bash -npm test + # Tests should fail - we haven't implemented yet ``` @@ -98,7 +126,7 @@ export async function searchMarkets(query: string) { ### Step 5: Run Tests Again ```bash -npm test + # Tests should now pass ``` @@ -111,7 +139,7 @@ Improve code quality while keeping tests green: ### Step 7: Verify Coverage ```bash -npm run test:coverage + # Verify 80%+ coverage achieved ``` @@ -144,6 +172,35 @@ describe('Button Component', () => { }) ``` +### Bun Native Test Pattern (`bun:test`) + +When the project uses Bun's built-in runner (see [Step 0](#step-0-detect-the-test-runner)), import from `bun:test` and run with `bun test` — not `bun run test`. The API is Jest-like, so `describe` / `it` / `expect` and most matchers carry over. See the `bun-runtime` skill for runtime, install, and bundler details. + +```typescript +import { describe, it, expect, mock } from 'bun:test' +import { searchMarkets } from './search' + +describe('searchMarkets', () => { + it('returns an empty list for an empty query', async () => { + expect(await searchMarkets('')).toEqual([]) + }) + + it('sorts results by similarity score', async () => { + const results = await searchMarkets('election') + expect(results).toEqual([...results].sort((a, b) => b.score - a.score)) + }) +}) +``` + +```bash +bun test # run once (RED/GREEN gate) +bun test --watch # watch mode during development +bun test --coverage # coverage report +``` + +- Mock modules with `mock.module(...)` / `mock(...)` from `bun:test` instead of `jest.mock(...)`. +- Configure coverage thresholds in `bunfig.toml` under `[test]` (e.g. `coverageThreshold`) rather than the Jest `coverageThresholds` config block. + ### API Integration Test Pattern ```typescript import { NextRequest } from 'next/server' @@ -292,7 +349,7 @@ jest.mock('@/lib/openai', () => ({ ### Run Coverage Report ```bash -npm run test:coverage + ``` ### Coverage Thresholds @@ -363,21 +420,21 @@ test('updates user', () => { ### Watch Mode During Development ```bash -npm test -- --watch + # Tests run automatically on file changes ``` ### Pre-Commit Hook ```bash # Runs before every commit -npm test && npm run lint + && ``` ### CI/CD Integration ```yaml # GitHub Actions - name: Run Tests - run: npm test -- --coverage + run: - name: Upload Coverage uses: codecov/codecov-action@v3 ``` diff --git a/skills/tdd-workflow/SKILL.md b/skills/tdd-workflow/SKILL.md index 1e8765c48..03503df17 100644 --- a/skills/tdd-workflow/SKILL.md +++ b/skills/tdd-workflow/SKILL.md @@ -85,6 +85,34 @@ ALWAYS write tests first, then implement code to make tests pass. ## TDD Workflow Steps +### Step 0: Detect the Test Runner + +Do not assume `npm test`. The commands in the steps and examples below use ``, ``, and `` as placeholders for the project's actual runner. Resolve them once before starting: + +1. **Run the package-manager detector** (ships with ECC): + + ```bash + node scripts/setup-package-manager.js --detect + ``` + + It resolves the package manager (npm / pnpm / yarn / bun) from, in order: `CLAUDE_PACKAGE_MANAGER`, `.claude/package-manager.json`, the `package.json` `packageManager` field, the lockfile, then global config. + +2. **Distinguish the package manager from the test runner — they are not the same.** A project can use Bun to install dependencies yet still run Jest or Vitest. Inspect `package.json` `scripts.test` and the test files: + - `scripts.test` invokes `jest` / `vitest` -> run through the detected PM (`npm test`, `pnpm test`, `yarn test`, or `bun run test`). + - `scripts.test` is `bun test`, or test files `import { test, expect } from "bun:test"`, or there is no jest/vitest config but Bun is present -> use **Bun's native runner** (`bun test`). See [Bun Native Test Pattern](#bun-native-test-pattern-buntest) below. + +Runner command matrix: + +| Runner | `` | `` | `` | `` | +|--------|----------|----------------|--------------|----------| +| npm | `npm test` | `npm test -- --watch` | `npm run test:coverage` | `npm run lint` | +| pnpm | `pnpm test` | `pnpm test --watch` | `pnpm test:coverage` | `pnpm lint` | +| yarn | `yarn test` | `yarn test --watch` | `yarn test:coverage` | `yarn lint` | +| Bun (script runs jest/vitest) | `bun run test` | `bun run test --watch` | `bun run test:coverage` | `bun run lint` | +| Bun (native `bun:test`) | `bun test` | `bun test --watch` | `bun test --coverage` | `bun run lint` | + +> `bun test` (Bun's built-in runner) is **not** the same as `bun run test` (which runs the `package.json` `test` script). Picking the wrong one is a common failure — e.g. invoking Jest through `npx`/`bun run` in an ESM-only project breaks, while `bun test` runs the suite natively. Confirm which the project expects before the RED gate, then substitute `` / `` everywhere `npm test` appears below. + ### Step 1: Write User Journeys If a `*.plan.md` file was provided, extract the user journeys and acceptance criteria from that plan first. Only write new journeys for gaps the plan does not cover. @@ -122,7 +150,7 @@ describe('Semantic Search', () => { ### Step 3: Run Tests (They Should Fail) ```bash -npm test + # Tests should fail - we haven't implemented yet ``` @@ -163,7 +191,7 @@ If the repository is under Git, stage the minimal fix now but defer the checkpoi ### Step 5: Run Tests Again ```bash -npm test + # Tests should now pass ``` @@ -191,7 +219,7 @@ Recommended commit message format: ### Step 7: Verify Coverage ```bash -npm run test:coverage + # Verify 80%+ coverage achieved ``` @@ -261,6 +289,35 @@ describe('Button Component', () => { }) ``` +### Bun Native Test Pattern (`bun:test`) + +When the project uses Bun's built-in runner (see [Step 0](#step-0-detect-the-test-runner)), import from `bun:test` and run with `bun test` — not `bun run test`. The API is Jest-like, so `describe` / `it` / `expect` and most matchers carry over. See the `bun-runtime` skill for runtime, install, and bundler details. + +```typescript +import { describe, it, expect, mock } from 'bun:test' +import { searchMarkets } from './search' + +describe('searchMarkets', () => { + it('returns an empty list for an empty query', async () => { + expect(await searchMarkets('')).toEqual([]) + }) + + it('sorts results by similarity score', async () => { + const results = await searchMarkets('election') + expect(results).toEqual([...results].sort((a, b) => b.score - a.score)) + }) +}) +``` + +```bash +bun test # run once (RED/GREEN gate) +bun test --watch # watch mode during development +bun test --coverage # coverage report +``` + +- Mock modules with `mock.module(...)` / `mock(...)` from `bun:test` instead of `jest.mock(...)`. +- Configure coverage thresholds in `bunfig.toml` under `[test]` (e.g. `coverageThreshold`) rather than the Jest `coverageThresholds` config block. + ### API Integration Test Pattern ```typescript import { NextRequest } from 'next/server' @@ -409,7 +466,7 @@ jest.mock('@/lib/openai', () => ({ ### Run Coverage Report ```bash -npm run test:coverage + ``` ### Coverage Thresholds @@ -480,21 +537,21 @@ test('updates user', () => { ### Watch Mode During Development ```bash -npm test -- --watch + # Tests run automatically on file changes ``` ### Pre-Commit Hook ```bash # Runs before every commit -npm test && npm run lint + && ``` ### CI/CD Integration ```yaml # GitHub Actions - name: Run Tests - run: npm test -- --coverage + run: - name: Upload Coverage uses: codecov/codecov-action@v3 ``` From 1c3a989ea6055ebcad6cd460f5de85897315f148 Mon Sep 17 00:00:00 2001 From: JongHyeok Park Date: Tue, 30 Jun 2026 10:38:36 +0900 Subject: [PATCH 035/197] refactor(commands): remove duplicated content in skill-create and learn-eval (#2348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skill-create: drop the "Example Output" section (53 lines) — it re-rendered the same skeleton already defined by the Step 3 output template, just with filled-in `my-app` values. learn-eval: drop the "Next Action" column from the 5b verdict table — it duplicated Step 6's "Verdict-specific confirmation flow". The table now carries Verdict + Meaning, and a pointer to Step 6 as the single source for each verdict's action. No behavior, frontmatter, or design-rationale changes. --- commands/learn-eval.md | 14 +++++------ commands/skill-create.md | 53 ---------------------------------------- 2 files changed, 7 insertions(+), 60 deletions(-) diff --git a/commands/learn-eval.md b/commands/learn-eval.md index 23b3695c7..8a016f532 100644 --- a/commands/learn-eval.md +++ b/commands/learn-eval.md @@ -64,14 +64,14 @@ origin: auto-extracted ### 5b. Holistic verdict - Synthesize the checklist results and draft quality, then choose **one** of the following: + Synthesize the checklist results and draft quality, then choose **one** of the following (Step 6 defines the action each verdict triggers): - | Verdict | Meaning | Next Action | - |---------|---------|-------------| - | **Save** | Unique, specific, well-scoped | Proceed to Step 6 | - | **Improve then Save** | Valuable but needs refinement | List improvements → revise → re-evaluate (once) | - | **Absorb into [X]** | Should be appended to an existing skill | Show target skill and additions → Step 6 | - | **Drop** | Trivial, redundant, or too abstract | Explain reasoning and stop | + | Verdict | Meaning | + |---------|---------| + | **Save** | Unique, specific, well-scoped | + | **Improve then Save** | Valuable but needs refinement | + | **Absorb into [X]** | Should be appended to an existing skill | + | **Drop** | Trivial, redundant, or too abstract | **Guideline dimensions** (informing the verdict, not scored): diff --git a/commands/skill-create.md b/commands/skill-create.md index dcf1df746..1077ab742 100644 --- a/commands/skill-create.md +++ b/commands/skill-create.md @@ -102,59 +102,6 @@ Prefix commits with: feat:, fix:, chore:, docs:, test:, refactor: - {percentage}% follow conventional commit format ``` -## Example Output - -Running `/skill-create` on a TypeScript project might produce: - -```markdown ---- -name: my-app-patterns -description: Coding patterns from my-app repository -version: 1.0.0 -source: local-git-analysis -analyzed_commits: 150 ---- - -# My App Patterns - -## Commit Conventions - -This project uses **conventional commits**: -- `feat:` - New features -- `fix:` - Bug fixes -- `chore:` - Maintenance tasks -- `docs:` - Documentation updates - -## Code Architecture - -``` -src/ -├── components/ # React components (PascalCase.tsx) -├── hooks/ # Custom hooks (use*.ts) -├── utils/ # Utility functions -├── types/ # TypeScript type definitions -└── services/ # API and external services -``` - -## Workflows - -### Adding a New Component -1. Create `src/components/ComponentName.tsx` -2. Add tests in `src/components/__tests__/ComponentName.test.tsx` -3. Export from `src/components/index.ts` - -### Database Migration -1. Modify `src/db/schema.ts` -2. Run `pnpm db:generate` -3. Run `pnpm db:migrate` - -## Testing Patterns - -- Test files: `__tests__/` directories or `.test.ts` suffix -- Coverage target: 80%+ -- Framework: Vitest -``` - ## GitHub App Integration For advanced features (10k+ commits, team sharing, auto-PRs), use the [Skill Creator GitHub App](https://github.com/apps/skill-creator): From be91f21837cc5f504c12ecc60a66006adc96730f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Mon, 29 Jun 2026 18:39:22 -0700 Subject: [PATCH 036/197] chore(catalog): sync manifests after skill batch (#2319 #2288 #2273 #2274 #2338 #2336 #2347 #2348) (#2394) Regenerate catalog doc counts + command registry after merging the verified skill/agent batch. Local full suite was green (2924/2924) with these applied. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- AGENTS.md | 4 ++-- README.md | 6 +++--- README.zh-CN.md | 2 +- docs/zh-CN/AGENTS.md | 4 ++-- docs/zh-CN/README.md | 6 +++--- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 64cd37266..0d00179ad 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 67 agents, 271 skills, 92 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 67 agents, 273 skills, 92 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.0.0", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 2ebbbe643..134458dbb 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.0.0", - "description": "Harness-native ECC plugin for engineering teams - 67 agents, 271 skills, 92 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 67 agents, 273 skills, 92 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index f3e658d96..9cebb5b41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 67 specialized agents, 271 skills, 92 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 67 specialized agents, 273 skills, 92 commands, and automated hook workflows for software development. **Version:** 2.0.0 @@ -152,7 +152,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 67 specialized subagents -skills/ — 271 workflow skills and domain knowledge +skills/ — 273 workflow skills and domain knowledge commands/ — 92 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) diff --git a/README.md b/README.md index ed4c4300a..e129df40a 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,7 @@ If you stacked methods, clean up in this order: /plugin list ecc@ecc ``` -**That's it!** You now have access to 67 agents, 271 skills, and 92 legacy command shims. +**That's it!** You now have access to 67 agents, 273 skills, and 92 legacy command shims. ### Dashboard GUI @@ -1528,7 +1528,7 @@ The configuration is automatically detected from `.opencode/opencode.json`. |---------|---------------------|----------|--------| | Agents | PASS: 67 agents | PASS: 12 agents | **Claude Code leads** | | Commands | PASS: 92 commands | PASS: 35 commands | **Claude Code leads** | -| Skills | PASS: 271 skills | PASS: 37 skills | **Claude Code leads** | +| Skills | PASS: 273 skills | PASS: 37 skills | **Claude Code leads** | | Hooks | PASS: 8 event types | PASS: 11 events | **OpenCode has more!** | | Rules | PASS: 29 rules | PASS: 13 instructions | **Claude Code leads** | | MCP Servers | PASS: 14 servers | PASS: Full | **Full parity** | @@ -1689,7 +1689,7 @@ ECC is the **first plugin to maximize every major AI coding tool**. Here's how e |---------|-----------------------|------------|-----------|----------|----------------| | **Agents** | 67 | Shared (AGENTS.md) | Shared (AGENTS.md) | 12 | N/A | | **Commands** | 92 | Shared | Instruction-based | 35 | 5 prompts | -| **Skills** | 271 | Shared | 10 (native format) | 37 | Via instructions | +| **Skills** | 273 | Shared | 10 (native format) | 37 | Via instructions | | **Hook Events** | 8 types | 15 types | None yet | 11 types | None | | **Hook Scripts** | 20+ scripts | 16 scripts (DRY adapter) | N/A | Plugin hooks | N/A | | **Rules** | 34 (common + lang) | 34 (YAML frontmatter) | Instruction-based | 13 instructions | 1 always-on file | diff --git a/README.zh-CN.md b/README.zh-CN.md index c98b8a5b1..4e037b0ea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -164,7 +164,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 67 个代理、271 个技能和 92 个命令。 +**完成!** 你现在可以使用 67 个代理、273 个技能和 92 个命令。 ### multi-* 命令需要额外配置 diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index 7f7c979e8..3db48ee4b 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、271 项技能、92 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 67 个专业代理、273 项技能、92 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.0.0 @@ -147,7 +147,7 @@ ``` agents/ — 67 个专业子代理 -skills/ — 271 个工作流技能和领域知识 +skills/ — 273 个工作流技能和领域知识 commands/ — 92 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index 00379bf2e..16630ebca 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -228,7 +228,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 67 个智能体、271 项技能和 92 个命令了。 +**搞定!** 你现在可以使用 67 个智能体、273 项技能和 92 个命令了。 *** @@ -1142,7 +1142,7 @@ opencode |---------|---------------|----------|--------| | 智能体 | PASS: 67 个 | PASS: 12 个 | **Claude Code 领先** | | 命令 | PASS: 92 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 271 项 | PASS: 37 项 | **Claude Code 领先** | +| 技能 | PASS: 273 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1250,7 +1250,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 |---------|-----------------------|------------|-----------|----------| | **智能体** | 67 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **命令** | 92 | 共享 | 基于指令 | 35 | -| **技能** | 271 | 共享 | 10 (原生格式) | 37 | +| **技能** | 273 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | 暂无 | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | N/A | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | From f12b106c3ca692220a86da74ec898ba86cd90be2 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 30 Jun 2026 07:13:19 +0530 Subject: [PATCH 037/197] fix(clv2): align Python _update_registry schema with shell counterpart (#2369) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(clv2): align Python _update_registry schema with shell counterpart The Python `_update_registry` in instinct-cli.py wrote registry entries without the `id` and `created_at` fields, while the shell counterpart in detect-project.sh writes both. A projects.json entry could therefore have a different shape depending on which path (Python CLI or shell hook) last touched it. Emit the same field set and order as the shell version: id, name, root, remote, created_at (preserved from any existing entry), last_seen. Add regression tests asserting field parity and created_at preservation. Fixes #2299 * fix(clv2): guard _update_registry against a non-dict registry entry A malformed projects.json (a non-dict value for the current project id, e.g. null) would make existing.get("created_at", ...) raise and crash the update, losing the old code's ability to self-heal a corrupt per-entry value. Normalize existing to {} when it is not a dict so the entry is healed by the rewrite. Add a regression test for the malformed-entry path. * test(clv2): assert the first-write created_at == last_seen contract The new _update_registry tests only checked both timestamps were truthy. On the initial write both derive from the same `now`, so created_at must equal last_seen; assert that explicitly so a later refactor that breaks the contract is caught. Split the compound assertions into single-expression checks. * fix(clv2): heal a non-dict top-level registry in _update_registry A projects.json that is valid JSON but not a mapping (e.g. `[]` or a string) previously crashed _update_registry on registry.get(), before the per-entry guard could run, so the corrupt file could not be healed. Guard the top-level shape right after the load and fall back to {} so the rewrite repairs the file — matching the per-entry healing already in place. Resolves the remaining CodeRabbit finding on #2299. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../scripts/instinct-cli.py | 22 ++++++- .../scripts/test_parse_instinct.py | 64 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/skills/continuous-learning-v2/scripts/instinct-cli.py b/skills/continuous-learning-v2/scripts/instinct-cli.py index 13bc467bc..2274a852b 100755 --- a/skills/continuous-learning-v2/scripts/instinct-cli.py +++ b/skills/continuous-learning-v2/scripts/instinct-cli.py @@ -430,12 +430,32 @@ def _update_registry(pid: str, pname: str, proot: str, premote: str) -> None: registry = json.load(f) except (FileNotFoundError, json.JSONDecodeError): registry = {} + # A registry that is valid JSON but not a mapping (e.g. a list from a + # corrupt projects.json) must not crash the update before the per-entry + # guard below: fall back to an empty dict so the whole file is healed. + if not isinstance(registry, dict): + registry = {} + # Mirror the shell counterpart in detect-project.sh: the entry carries + # "id" and "created_at" alongside the other fields so a projects.json + # record has the same shape regardless of which path (Python CLI or + # shell hook) last wrote it. "created_at" is preserved from any + # existing entry; only "last_seen" advances on update. + now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + existing = registry.get(pid, {}) + # A malformed registry (e.g. a non-dict value for this id) must not + # crash the update: fall back to an empty dict so the corrupt entry is + # healed by the rewrite, matching the old unconditional-overwrite + # behavior. + if not isinstance(existing, dict): + existing = {} registry[pid] = { + "id": pid, "name": pname, "root": proot, "remote": premote, - "last_seen": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "created_at": existing.get("created_at", now), + "last_seen": now, } tmp_file = REGISTRY_FILE.parent / f".{REGISTRY_FILE.name}.tmp.{os.getpid()}" diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index 225dcb053..a79799863 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -1047,6 +1047,70 @@ def test_update_registry_atomic_replaces_file(patch_globals): assert leftovers == [] +def test_update_registry_matches_shell_schema(patch_globals): + # Issue #2299: the Python writer must emit the same field set as the shell + # counterpart in detect-project.sh (id, name, root, remote, created_at, + # last_seen) so a projects.json entry has a consistent shape regardless of + # which path wrote it. + tree = patch_globals + _update_registry("abc123", "demo", "/repo", "https://example.com/repo.git") + entry = json.loads(tree["registry_file"].read_text())["abc123"] + assert set(entry) == {"id", "name", "root", "remote", "created_at", "last_seen"} + assert entry["id"] == "abc123" + assert entry["name"] == "demo" + assert entry["root"] == "/repo" + assert entry["remote"] == "https://example.com/repo.git" + # On the initial write both timestamps come from the same `now`, so the + # first-write contract is created_at == last_seen. + assert entry["created_at"] + assert entry["created_at"] == entry["last_seen"] + + +def test_update_registry_preserves_created_at(patch_globals): + # created_at is stamped on first write and preserved on subsequent updates, + # while last_seen advances — matching entry.get("created_at", now) in the + # shell counterpart. + tree = patch_globals + _update_registry("abc123", "demo", "/repo", "https://example.com/repo.git") + first = json.loads(tree["registry_file"].read_text())["abc123"] + + _update_registry("abc123", "demo-renamed", "/repo", "https://example.com/repo.git") + second = json.loads(tree["registry_file"].read_text())["abc123"] + + assert second["created_at"] == first["created_at"] + assert second["name"] == "demo-renamed" + assert second["last_seen"] >= first["last_seen"] + + +def test_update_registry_heals_malformed_entry(patch_globals): + # Issue #2299 follow-up: a non-dict value for the project id (e.g. a + # corrupt registry) must not crash _update_registry. The entry is healed by + # the rewrite, preserving the old unconditional-overwrite behavior. + tree = patch_globals + tree["registry_file"].write_text(json.dumps({"abc123": None}), encoding="utf-8") + _update_registry("abc123", "demo", "/repo", "https://example.com/repo.git") + entry = json.loads(tree["registry_file"].read_text())["abc123"] + assert isinstance(entry, dict) + assert entry["id"] == "abc123" + assert entry["created_at"] + assert entry["created_at"] == entry["last_seen"] + + +def test_update_registry_heals_non_dict_registry(patch_globals): + # Issue #2299 follow-up: a top-level registry that is valid JSON but not a + # mapping (e.g. a list or string from a corrupt projects.json) must not + # crash _update_registry before the per-entry guard runs. The whole file is + # healed by the rewrite, preserving the old unconditional-overwrite behavior. + tree = patch_globals + tree["registry_file"].write_text(json.dumps(["oops"]), encoding="utf-8") + _update_registry("abc123", "demo", "/repo", "https://example.com/repo.git") + registry = json.loads(tree["registry_file"].read_text()) + assert isinstance(registry, dict) + entry = registry["abc123"] + assert entry["id"] == "abc123" + assert entry["created_at"] == entry["last_seen"] + + def test_write_registry_atomic_no_tmp_leftovers(patch_globals): # Issue #2294: _write_registry now holds the registry lock like # _update_registry. It must still write atomically with no stray tmp files. From a89b32c2b55a9511aec2c72539aeb7ab35f47c41 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 30 Jun 2026 07:13:23 +0530 Subject: [PATCH 038/197] fix(clv2): serialize observer signal-counter to stop dropped increments (#2372) observe.sh bumps the SIGUSR1 throttle counter in ${PROJECT_DIR}/.observer-signal-counter with an unlocked read-modify-write. The hook runs on every tool call, so concurrent invocations read the same value, both increment, and lose a write, signaling the observer at unpredictable intervals and defeating the #521 throttle. Serialize the read-modify-write under a lock, and only ever bump the counter while that lock is held: - Prefer flock with a bounded -w wait (the OS auto-releases it when the fd closes or the process dies, so there is no stale lock and no lost increment); on a timeout the tick is skipped rather than bumped unlocked. - Fall back to an atomic mkdir lock on platforms without flock, with a bounded spin. An EXIT trap cleans up on normal completion; INT/TERM traps release the lock and exit, so a signal cannot drop the lock and then continue the read-modify-write without ownership. If the lock cannot be acquired in the budget the tick is skipped rather than raced. No hand-rolled PID stale-reclaim (which is racy and can delete a live re-acquirer's lock). - Guard the counter read against a corrupt (non-integer) file that would abort the hook under set -e. Add tests/hooks/observe-signal-counter-race.test.js: 20 concurrent observe.sh invocations must not lose increments (exact under flock; at most one dropped on the best-effort mkdir fallback), the runner rejects on any hook execution failure or hang, plus content guards for the lock and the corrupt-counter handling. Fixes #2296 --- .../continuous-learning-v2/hooks/observe.sh | 77 ++++- .../hooks/observe-signal-counter-race.test.js | 271 ++++++++++++++++++ 2 files changed, 340 insertions(+), 8 deletions(-) create mode 100644 tests/hooks/observe-signal-counter-race.test.js diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 4fc51458b..65d2f1d21 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -477,21 +477,82 @@ fi # which caused runaway parallel Claude analysis processes. SIGNAL_EVERY_N="${ECC_OBSERVER_SIGNAL_EVERY_N:-20}" SIGNAL_COUNTER_FILE="${PROJECT_DIR}/.observer-signal-counter" +SIGNAL_COUNTER_LOCK="${SIGNAL_COUNTER_FILE}.lock" ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity" touch "$ACTIVITY_FILE" 2>/dev/null || true +# Serialize the throttle-counter read-modify-write. observe.sh runs on every +# tool call (which can fire every second), so concurrent invocations previously +# raced on this counter: both read the same value, both incremented, and one +# write was lost, signaling the observer at unpredictable intervals (#2296). +# Prefer flock (a kernel advisory lock the OS releases automatically if the hook +# is killed); fall back to the atomic mkdir lock this script already uses for +# the lazy-start path above. Both wrap the same read-modify-write below. should_signal=0 -if [ -f "$SIGNAL_COUNTER_FILE" ]; then - counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) - counter=$((counter + 1)) - if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then - should_signal=1 - counter=0 + +_ecc_bump_signal_counter() { + if [ -f "$SIGNAL_COUNTER_FILE" ]; then + counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) + # Guard against a corrupt counter file: a non-integer value would abort the + # hook under `set -e` at the arithmetic below. + case "$counter" in + ''|*[!0-9]*) counter=0 ;; + esac + counter=$((counter + 1)) + if [ "$counter" -ge "$SIGNAL_EVERY_N" ]; then + should_signal=1 + counter=0 + fi + echo "$counter" > "$SIGNAL_COUNTER_FILE" + else + echo "1" > "$SIGNAL_COUNTER_FILE" fi - echo "$counter" > "$SIGNAL_COUNTER_FILE" +} + +if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null; then + # flock is auto-released when fd 8 closes or the process dies, so there is no + # stale lock and no lost increment. Use a bounded -w wait so the hook never + # blocks indefinitely, and only bump the counter while the lock is held -- on + # a timeout we skip the tick rather than doing an unlocked read-modify-write. + if flock -w 2 8 2>/dev/null; then + _ecc_bump_signal_counter + flock -u 8 2>/dev/null || true + fi + exec 8>&- 2>/dev/null || true else - echo "1" > "$SIGNAL_COUNTER_FILE" + # No flock (e.g. macOS): atomic mkdir lock with a bounded spin so the hook + # never blocks indefinitely. A trap releases the lock on every exit path -- + # including the async-timeout SIGTERM -- so a killed hook does not strand the + # directory. We deliberately do NOT hand-roll PID-based stale reclaim: + # re-verifying then removing another process's lock is racy and can delete a + # live re-acquirer's directory, reintroducing the very race this fixes. + _signal_lock_held=0 + _signal_lock_spins=0 + while [ "$_signal_lock_spins" -lt 100 ]; do + if mkdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null; then + # EXIT cleans up on normal completion. INT/TERM must release AND exit: + # a signal trap that only released the lock would otherwise fall through + # and continue the read-modify-write without ownership. + trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true' EXIT + trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 130' INT + trap 'rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true; exit 143' TERM + _signal_lock_held=1 + break + fi + _signal_lock_spins=$((_signal_lock_spins + 1)) + sleep 0.02 + done + if [ "$_signal_lock_held" -eq 1 ]; then + # Bump only under the held lock -- never an unlocked read-modify-write. + _ecc_bump_signal_counter + rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true + trap - EXIT INT TERM + fi + # If the lock could not be acquired within the spin budget we skip this tick + # rather than racing on an unlocked counter. Dropping one throttle tick under + # extreme contention only delays the next observer signal slightly; it never + # corrupts the counter or signals spuriously. fi # Signal observer if running and throttle allows (check both project-scoped and global observer, deduplicate) diff --git a/tests/hooks/observe-signal-counter-race.test.js b/tests/hooks/observe-signal-counter-race.test.js new file mode 100644 index 000000000..2fb978aac --- /dev/null +++ b/tests/hooks/observe-signal-counter-race.test.js @@ -0,0 +1,271 @@ +/** + * Regression tests for the SIGUSR1 throttle-counter race in observe.sh (#2296) + * + * observe.sh runs on every tool call and bumps a throttle counter in + * ${PROJECT_DIR}/.observer-signal-counter so the observer is signaled only + * every N observations (#521). The bump used a plain read-modify-write with no + * locking, so concurrent hook invocations could read the same value, both + * increment, and lose a write — the observer then fired at unpredictable + * intervals. The fix serializes the read-modify-write with an atomic mkdir + * lock. + * + * These tests drive the real observe.sh (reusing the stub harness from + * observer-memory.test.js) and assert the lock's invariant: with the reset + * threshold set high enough that no reset fires, the final counter must equal + * the number of invocations — i.e. no increment is ever lost, even under heavy + * concurrency. + * + * Run with: node tests/hooks/observe-signal-counter-race.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { spawn, spawnSync } = require('child_process'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed++; + } +} + +async function asyncTest(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed++; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-signal-race-')); +} + +function cleanupDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +} + +const repoRoot = path.resolve(__dirname, '..', '..'); +const observeShPath = path.join(repoRoot, 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + +const isWindows = process.platform === 'win32'; +const hasPython = !isWindows && spawnSync('python3', ['--version']).status === 0; +// When the runner has flock the lock is exact (blocking, kernel auto-release); +// without it observe.sh uses a best-effort mkdir spin that may drop at most one +// increment under pathological contention. +const hasFlock = !isWindows && spawnSync('bash', ['-c', 'command -v flock']).status === 0; + +// Build a self-contained observe.sh sandbox (stub detect-project.sh + +// homunculus-dir.sh, SKILL_ROOT patched to the sandbox) and return its paths. +function buildSandbox() { + const testDir = createTempDir(); + const projectDir = path.join(testDir, 'project'); + fs.mkdirSync(projectDir, { recursive: true }); + + const skillRoot = path.join(testDir, 'skill'); + const scriptsDir = path.join(skillRoot, 'scripts'); + const scriptsLibDir = path.join(scriptsDir, 'lib'); + const hooksDir = path.join(skillRoot, 'hooks'); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.mkdirSync(scriptsLibDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + + fs.writeFileSync( + path.join(scriptsDir, 'detect-project.sh'), + [ + '#!/bin/bash', + 'PROJECT_ID="test-project"', + 'PROJECT_NAME="test-project"', + `PROJECT_ROOT="${projectDir}"`, + `PROJECT_DIR="${projectDir}"`, + 'CLV2_PYTHON_CMD="python3"', + '' + ].join('\n') + ); + fs.writeFileSync( + path.join(scriptsLibDir, 'homunculus-dir.sh'), + [ + '#!/bin/bash', + '_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }', + '' + ].join('\n') + ); + + let observeContent = fs.readFileSync(observeShPath, 'utf8'); + const skillRootMarker = 'SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"'; + // Fail fast if observe.sh's SKILL_ROOT definition drifts; otherwise the + // no-op replace would leave the sandbox pointing at the real skill tree and + // the test could pass spuriously. + assert.ok( + observeContent.includes(skillRootMarker), + 'observe.sh SKILL_ROOT definition changed; update the sandbox rewrite' + ); + observeContent = observeContent.replace( + skillRootMarker, + `SKILL_ROOT="${skillRoot}"` + ); + const testObserve = path.join(hooksDir, 'observe.sh'); + fs.writeFileSync(testObserve, observeContent, { mode: 0o755 }); + + return { testDir, projectDir, testObserve }; +} + +// Run observe.sh once against the sandbox. Resolves when the process exits. +function runObserve(testObserve, projectDir) { + const input = JSON.stringify({ + tool_name: 'Read', + tool_input: { file_path: '/tmp/test.txt' }, + session_id: 'test-session', + cwd: projectDir + }); + return new Promise((resolve, reject) => { + const child = spawn('bash', [testObserve, 'post'], { + env: { + ...process.env, + HOME: projectDir, + CLAUDE_CODE_ENTRYPOINT: 'cli', + ECC_HOOK_PROFILE: 'standard', + ECC_SKIP_OBSERVE: '0', + CLAUDE_PROJECT_DIR: projectDir, + // Reset threshold far above the invocation count, so no reset fires and + // the final counter equals the number of invocations. + ECC_OBSERVER_SIGNAL_EVERY_N: '100000' + }, + stdio: ['pipe', 'ignore', 'pipe'] + }); + let stderr = ''; + // Fail the test on a hung hook rather than waiting forever. + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('observe.sh timed out')); + }, 20000); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + // A broken observe.sh must fail the test, not be silently swallowed. + child.on('close', (code, signal) => { + clearTimeout(timer); + if (code === 0 && signal === null) { + resolve(); + } else { + reject(new Error(`observe.sh failed code=${code} signal=${signal}: ${stderr.trim()}`)); + } + }); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.stdin.end(input); + }); +} + +function readCounter(projectDir) { + const counterFile = path.join(projectDir, '.observer-signal-counter'); + if (!fs.existsSync(counterFile)) { + return null; + } + return parseInt(fs.readFileSync(counterFile, 'utf8').trim(), 10); +} + +console.log('\n=== observe.sh signal-counter race regression (#2296) ===\n'); + +test('observe.sh uses a lock around the throttle-counter update', () => { + const content = fs.readFileSync(observeShPath, 'utf8'); + assert.ok( + content.includes('SIGNAL_COUNTER_LOCK'), + 'observe.sh should define a lock for the signal counter' + ); + assert.ok( + /flock 8\b/.test(content) || /mkdir "\$SIGNAL_COUNTER_LOCK"/.test(content), + 'observe.sh should acquire the counter lock via flock or an atomic mkdir' + ); +}); + +test('observe.sh guards against a corrupt (non-integer) counter file', () => { + const content = fs.readFileSync(observeShPath, 'utf8'); + assert.ok( + /''\|\*\[!0-9\]\*\) counter=0/.test(content), + 'observe.sh should reset a non-integer counter to 0 before incrementing' + ); +}); + +async function runSequential() { + const { testDir, projectDir, testObserve } = buildSandbox(); + try { + const N = 5; + for (let i = 0; i < N; i++) { + await runObserve(testObserve, projectDir); + } + const counter = readCounter(projectDir); + assert.notStrictEqual(counter, null, 'counter file should exist after runs'); + assert.strictEqual(counter, N, `sequential counter should be ${N}, got ${counter}`); + } finally { + cleanupDir(testDir); + } +} + +async function runConcurrent() { + const { testDir, projectDir, testObserve } = buildSandbox(); + try { + const K = 20; + // Spawn all K before awaiting any, so they genuinely contend on the counter. + const runs = []; + for (let i = 0; i < K; i++) { + runs.push(runObserve(testObserve, projectDir)); + } + await Promise.all(runs); + const counter = readCounter(projectDir); + assert.notStrictEqual(counter, null, 'counter file should exist after concurrent runs'); + if (hasFlock) { + // flock serializes every invocation, so no increment is ever lost. The + // pre-fix unlocked code drops increments under this same contention. + assert.strictEqual( + counter, + K, + `with flock the counter must be exactly ${K}, got ${counter}` + ); + } else { + // mkdir fallback is best-effort: it may drop at most one increment if its + // bounded spin is exhausted, but never the multi-increment loss the + // unlocked code exhibited. + assert.ok( + counter >= K - 1, + `mkdir fallback should keep the counter >= ${K - 1}, got ${counter}` + ); + } + } finally { + cleanupDir(testDir); + } +} + +(async () => { + if (!isWindows && hasPython) { + await asyncTest('sequential invocations increment the counter exactly once each', runSequential); + await asyncTest('concurrent invocations never lose a counter increment', runConcurrent); + } else { + console.log(' - skipping shell-execution tests (requires non-Windows + python3)'); + } + + console.log('\n=== Test Results ==='); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log(`Total: ${passed + failed}`); + + process.exit(failed > 0 ? 1 : 0); +})(); From a6d12ec21e39b45d3b463febac97441279f8c6d7 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 30 Jun 2026 07:13:28 +0530 Subject: [PATCH 039/197] fix(clv2): surface SIGALRM timeout drops in observe.sh (#2373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(clv2): surface SIGALRM timeout drops in observe.sh The inline-Python observation writers in observe.sh arm a signal.SIGALRM alarm (8s) so they self-terminate before the async hook's 10s timeout can orphan them (#2278). The handler _ecc_bail called sys.exit(0) with no logging, so when the alarm fired the in-flight observation was silently dropped: nothing was logged, no partial write occurred, and the shell saw a clean exit. There was no way to detect or count how many observations were being lost. Add a single stderr visibility line to both _ecc_bail handlers (the parse-error fallback path and the main observation-writing path) before sys.exit(0), using the repo's "[observe]" log prefix. Exit code stays 0: in a Claude Code hook a non-zero exit signals a block, so changing it would turn an internal timeout into a user-facing tool block. The warning goes to stderr (not stdout) because both blocks redirect stdout into the observations file. Add tests/hooks/observe-signal-timeout.test.js: a static regression guard that every _ecc_bail handler logs to stderr before exiting and keeps exit 0, plus a behavioral check that runs the real handler text extracted from observe.sh and confirms a fired alarm exits 0 and emits the [observe] warning on stderr only. Fixes #2300 * test(clv2): exercise both _ecc_bail handlers end-to-end The behavioral SIGALRM-fire test ran only handlers[0] (the parse-error fallback path); the main observation-write path (handlers[1]) was covered only by the static regex guard. The write path is the higher-value one to verify end-to-end since it carries valid, parseable data that would succeed given more time, so a silent drop there is the worst case. Loop the behavioral check over every extracted handler so a regression that silenced the second handler's stderr write is caught at runtime, not just by the static guard. * test(clv2): select timeout handlers by marker, not array index The behavioral check looped over all extracted _ecc_bail handlers by index. If an unrelated _ecc_bail were ever added to observe.sh, the loop would either test the wrong block or be diluted. Filter the handlers to those carrying the "[observe] SIGALRM timeout" marker so the live SIGALRM check stays pinned to the two #2300 timeout handlers regardless of array order or future additions. * test(clv2): fail fast when python is missing in SIGALRM check The behavioral test returned early when no python interpreter was found, which the test harness records as a PASS — so the SIGALRM contract could go entirely unverified yet still look green. Throw instead, matching the existing insaits-security-monitor convention of failing when a required Python runtime is absent, and drop the in-test console.log. --- .../continuous-learning-v2/hooks/observe.sh | 2 + tests/hooks/observe-signal-timeout.test.js | 212 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 tests/hooks/observe-signal-timeout.test.js diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 65d2f1d21..45d962971 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -280,6 +280,7 @@ _SECRET_RE = re.compile( import signal def _ecc_bail(*_): + print("[observe] SIGALRM timeout: parse-error fallback observation dropped before write (#2300)", file=sys.stderr) sys.exit(0) try: signal.signal(signal.SIGALRM, _ecc_bail) @@ -317,6 +318,7 @@ import json, sys, os, re import signal def _ecc_bail(*_): + print("[observe] SIGALRM timeout: in-flight observation dropped before write (#2300)", file=sys.stderr) sys.exit(0) try: signal.signal(signal.SIGALRM, _ecc_bail) diff --git a/tests/hooks/observe-signal-timeout.test.js b/tests/hooks/observe-signal-timeout.test.js new file mode 100644 index 000000000..2f7e48047 --- /dev/null +++ b/tests/hooks/observe-signal-timeout.test.js @@ -0,0 +1,212 @@ +/** + * Tests for observe.sh SIGALRM timeout visibility (#2300). + * + * observe.sh arms a signal.SIGALRM alarm (8s) inside its inline-Python blocks so + * the observation writer self-terminates before the async hook's 10s timeout can + * orphan it (#2278). Before #2300 the handler `_ecc_bail` called sys.exit(0) with + * no logging, so a timeout silently dropped the in-flight observation: nothing was + * logged and the shell saw a clean exit. The fix adds a stderr visibility line to + * each handler while keeping exit 0 (changing to a non-zero exit would make the + * Claude hook report a block, per the repo's "always exit 0; log to stderr" rule). + * + * Two checks: + * 1. Static regression guard — every `_ecc_bail` handler in observe.sh writes to + * sys.stderr before sys.exit(0). + * 2. Behavioral check — the REAL handler text extracted from observe.sh, when its + * alarm fires, exits 0 and emits the `[observe]` visibility token on stderr + * (and never on stdout, which is the observations-file stream). + */ + +if (process.platform === 'win32') { + console.log('Skipping bash/SIGALRM-dependent observe tests on Windows'); + process.exit(0); +} + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(`PASS: ${name}`); + passed += 1; + } catch (error) { + console.log(`FAIL: ${name}`); + console.error(` ${error.message}`); + failed += 1; + } +} + +function findPython() { + const candidates = [ + { command: process.env.PYTHON, args: [] }, + { command: 'python3', args: [] }, + { command: 'python', args: [] }, + { command: 'py', args: ['-3'] }, + ].filter(candidate => candidate.command); + + for (const candidate of candidates) { + const result = spawnSync(candidate.command, [...candidate.args, '--version'], { + encoding: 'utf8', + timeout: 5000, + }); + if (result && result.status === 0) { + return candidate; + } + } + return null; +} + +const repoRoot = path.resolve(__dirname, '..', '..'); +const observeShPath = path.join( + repoRoot, + 'skills', + 'continuous-learning-v2', + 'hooks', + 'observe.sh' +); + +const observeSrc = fs.readFileSync(observeShPath, 'utf8'); + +// Extract each `_ecc_bail` handler body: the `def` line plus the indented lines +// that follow it, up to (and including) the first dedented `sys.exit(0)` line at +// the same indentation as the def's body. +function extractHandlers(src) { + const lines = src.split('\n'); + const handlers = []; + for (let i = 0; i < lines.length; i += 1) { + if (/^def _ecc_bail\(\*_\):\s*$/.test(lines[i])) { + const body = [lines[i]]; + for (let j = i + 1; j < lines.length; j += 1) { + // Stop when we hit a line that is not indented (next top-level stmt). + if (lines[j].length > 0 && !/^\s/.test(lines[j])) { + break; + } + body.push(lines[j]); + if (/^\s+sys\.exit\(0\)\s*$/.test(lines[j])) { + break; + } + } + handlers.push(body.join('\n')); + } + } + return handlers; +} + +const handlers = extractHandlers(observeSrc); + +// The #2300 timeout handlers are the ones that log the `[observe] SIGALRM +// timeout` marker. Selecting by marker (rather than by array index) keeps the +// behavioral check pinned to the timeout handlers even if an unrelated +// `_ecc_bail` is ever added elsewhere in observe.sh. +const timeoutHandlers = handlers.filter(body => + body.includes('[observe] SIGALRM timeout') +); + +test('observe.sh defines at least two _ecc_bail timeout handlers', () => { + assert.ok( + handlers.length >= 2, + `expected >= 2 _ecc_bail handlers, found ${handlers.length}` + ); + assert.ok( + timeoutHandlers.length >= 2, + `expected >= 2 handlers carrying the [observe] SIGALRM timeout marker, found ${timeoutHandlers.length}` + ); +}); + +test('every _ecc_bail handler logs to stderr before exiting (regression guard)', () => { + handlers.forEach((body, idx) => { + const stderrIdx = body.indexOf('file=sys.stderr'); + const exitIdx = body.indexOf('sys.exit(0)'); + assert.ok( + stderrIdx !== -1, + `handler #${idx + 1} does not write to sys.stderr (silent drop regression):\n${body}` + ); + assert.ok( + exitIdx !== -1, + `handler #${idx + 1} is missing sys.exit(0):\n${body}` + ); + assert.ok( + stderrIdx < exitIdx, + `handler #${idx + 1} must log to stderr BEFORE sys.exit(0):\n${body}` + ); + assert.ok( + body.includes('[observe]'), + `handler #${idx + 1} stderr log should use the [observe] prefix:\n${body}` + ); + }); +}); + +test('_ecc_bail handlers keep exit code 0 (no exit 2 / block regression)', () => { + handlers.forEach((body, idx) => { + assert.ok( + /sys\.exit\(0\)/.test(body), + `handler #${idx + 1} must exit 0 to preserve the async-hook timeout contract (#2278):\n${body}` + ); + assert.ok( + !/sys\.exit\([1-9]/.test(body), + `handler #${idx + 1} must not exit non-zero (would surface as a hook block):\n${body}` + ); + }); +}); + +function runHandlerTimeout(python, handler) { + // Run the ACTUAL handler text extracted from observe.sh, forcing the alarm. + const program = [ + 'import sys, signal, time', + handler, + 'signal.signal(signal.SIGALRM, _ecc_bail)', + 'signal.alarm(1)', + 'time.sleep(3)', + 'print("REACHED_END_SHOULD_NOT_HAPPEN")', + ].join('\n'); + + return spawnSync(python.command, [...python.args, '-c', program], { + encoding: 'utf8', + timeout: 15000, + }); +} + +// Exercise EVERY timeout handler end-to-end, not just the first. The main +// observation-write path is the higher-value one to verify: it carries valid, +// parseable data that would succeed given more time, so a silent drop there is +// the worst case. A behavioral check on only one handler would not catch a +// regression that silenced another. +timeoutHandlers.forEach((handler, idx) => { + test(`real _ecc_bail timeout handler #${idx + 1}: SIGALRM fire emits stderr token and exits 0`, () => { + const python = findPython(); + if (!python) { + // Fail fast rather than returning (which the harness would record as a + // PASS): a missing interpreter means the SIGALRM contract went + // unverified, which must not look like a green regression test. + throw new Error('python3 interpreter not available; the SIGALRM regression cannot be verified'); + } + + const result = runHandlerTimeout(python, handler); + + assert.strictEqual(result.signal, null, `python killed by signal ${result.signal}`); + assert.strictEqual(result.status, 0, `expected exit 0 on timeout, got ${result.status}`); + assert.ok( + /\[observe\] SIGALRM timeout/.test(result.stderr), + `expected the [observe] SIGALRM timeout warning on stderr, got: ${JSON.stringify(result.stderr)}` + ); + assert.ok( + !/REACHED_END_SHOULD_NOT_HAPPEN/.test(result.stdout), + 'handler should have terminated before the post-sleep stdout write' + ); + assert.ok( + !/\[observe\] SIGALRM timeout/.test(result.stdout), + 'the warning must go to stderr, never stdout (stdout is the observations stream)' + ); + }); +}); + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); + +process.exit(failed > 0 ? 1 : 0); From a36148fff97293c8cecc142ddda71e56fa072c76 Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 30 Jun 2026 07:13:32 +0530 Subject: [PATCH 040/197] test(clv2): add coverage for instinct-cli prune, projects ops, promote dry-run, normalize-url (#2374) * test(clv2): cover instinct-cli prune, projects ops, promote dry-run, normalize-url Add pytest coverage for previously-untested functions in skills/continuous-learning-v2/scripts/instinct-cli.py: - _normalize_remote_url: scp/https/file forms, credential + .git stripping, network lowercasing, case-preserving local paths, idempotence - _promote_specific dry-run: returns 0 and writes no global file - projects delete/gc/merge: invalid-id, not-found, dry-run, and force paths over registry + storage, asserting destructive ops are gated - cmd_prune: dry-run keeps files; non-dry-run deletes only expired; quiet Test-only change; no production code modified. Fixes #2302 * test(clv2): assert dry-run storage no-op and quiet-mode stderr silence Address CodeRabbit review on #2374: - projects gc/merge dry-run tests now also assert on-disk storage is untouched (empty1 project dir survives; nothing copied into dest personal), closing the gap where a storage-mutating dry-run regression would still pass. - cmd_prune quiet test now asserts stderr is empty too, not just stdout. * test(clv2): cover merge missing-destination and prune empty-pending branches --- .../scripts/test_parse_instinct.py | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) diff --git a/skills/continuous-learning-v2/scripts/test_parse_instinct.py b/skills/continuous-learning-v2/scripts/test_parse_instinct.py index a79799863..290ff9114 100644 --- a/skills/continuous-learning-v2/scripts/test_parse_instinct.py +++ b/skills/continuous-learning-v2/scripts/test_parse_instinct.py @@ -1147,3 +1147,275 @@ def test_remove_project_storage_blocks_traversal(patch_globals): def test_remove_project_storage_blocks_root_itself(patch_globals): with pytest.raises(ValueError): _remove_project_storage(".") + + +# ───────────────────────────────────────────── +# Issue #2302 coverage: +# _normalize_remote_url, _promote_specific dry-run, +# projects delete/gc/merge, cmd_prune +# ───────────────────────────────────────────── + +_normalize_remote_url = _mod._normalize_remote_url +_cmd_projects_delete = _mod._cmd_projects_delete +_cmd_projects_gc = _mod._cmd_projects_gc +_cmd_projects_merge = _mod._cmd_projects_merge +cmd_prune = _mod.cmd_prune + + +# ── _normalize_remote_url ──────────────────── + +def test_normalize_remote_url_empty_returns_empty(): + assert _normalize_remote_url("") == "" + assert _normalize_remote_url(None) == "" + + +def test_normalize_remote_url_scp_form(): + # scp-style host:path -> host/path, credentials/.git stripped, lowercased + assert _normalize_remote_url("git@github.com:Test/Repo.git") == "github.com/test/repo" + + +def test_normalize_remote_url_https_strips_credentials_and_scheme(): + assert ( + _normalize_remote_url("https://user:token@github.com/test/repo.git") + == "github.com/test/repo" + ) + + +def test_normalize_remote_url_network_is_lowercased(): + assert _normalize_remote_url("https://GitHub.com/Owner/Project") == "github.com/owner/project" + + +def test_normalize_remote_url_trailing_slash_and_dotgit_stripped(): + assert _normalize_remote_url("https://github.com/a/b.git/") == "github.com/a/b" + + +def test_normalize_remote_url_file_scheme_preserves_case(): + # Local file paths are not network URLs: scheme is stripped but case is preserved. + assert _normalize_remote_url("file:///srv/Repos/My-Repo/") == "/srv/Repos/My-Repo" + + +def test_normalize_remote_url_idempotent(): + once = _normalize_remote_url("https://user@github.com/Test/Repo.git") + assert _normalize_remote_url(once) == once + + +# ── _promote_specific dry-run ──────────────── + +def test_promote_specific_dry_run_writes_nothing(patch_globals, capsys): + """dry_run returns 0, prints [DRY RUN], and writes no global file.""" + tree = patch_globals + project = _make_project(tree) + (project["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML) + + ret = _promote_specific(project, "test-instinct", force=True, dry_run=True) + assert ret == 0 + out = capsys.readouterr().out + assert "[DRY RUN]" in out + assert not (tree["global_personal"] / "test-instinct.yaml").exists() + assert list(tree["global_personal"].iterdir()) == [] + + +# ── projects delete ────────────────────────── + +def test_projects_delete_rejects_invalid_id(patch_globals, capsys): + args = SimpleNamespace(project_id="../escape", dry_run=False, force=True) + assert _cmd_projects_delete(args) == 1 + assert "Invalid project ID" in capsys.readouterr().err + + +def test_projects_delete_not_found(patch_globals, capsys): + args = SimpleNamespace(project_id="ghost123", dry_run=False, force=True) + assert _cmd_projects_delete(args) == 1 + assert "not found" in capsys.readouterr().err + + +def test_projects_delete_dry_run_keeps_registry_and_storage(patch_globals, capsys): + tree = patch_globals + _make_project(tree, pid="proj1", pname="p1") + tree["registry_file"].write_text(json.dumps({"proj1": {"name": "p1"}})) + + args = SimpleNamespace(project_id="proj1", dry_run=True, force=False) + assert _cmd_projects_delete(args) == 0 + assert "[DRY RUN]" in capsys.readouterr().out + assert (tree["projects_dir"] / "proj1").exists() + assert "proj1" in json.loads(tree["registry_file"].read_text()) + + +def test_projects_delete_force_removes_registry_and_storage(patch_globals, capsys): + tree = patch_globals + _make_project(tree, pid="proj1", pname="p1") + tree["registry_file"].write_text(json.dumps({"proj1": {"name": "p1"}})) + + args = SimpleNamespace(project_id="proj1", dry_run=False, force=True) + assert _cmd_projects_delete(args) == 0 + assert "Deleted project" in capsys.readouterr().out + assert not (tree["projects_dir"] / "proj1").exists() + assert "proj1" not in json.loads(tree["registry_file"].read_text()) + + +# ── projects gc ────────────────────────────── + +def test_projects_gc_no_candidates(patch_globals, capsys): + tree = patch_globals + tree["registry_file"].write_text("{}") + args = SimpleNamespace(dry_run=False, force=True) + assert _cmd_projects_gc(args) == 0 + assert "No zero-value project entries" in capsys.readouterr().out + + +def test_projects_gc_dry_run_keeps_entry(patch_globals, capsys): + tree = patch_globals + _make_project(tree, pid="empty1", pname="e1") # zero instincts/observations + tree["registry_file"].write_text(json.dumps({"empty1": {"name": "e1"}})) + + args = SimpleNamespace(dry_run=True, force=False) + assert _cmd_projects_gc(args) == 0 + assert "[DRY RUN]" in capsys.readouterr().out + assert "empty1" in json.loads(tree["registry_file"].read_text()) + # dry-run must not touch storage on disk + assert (tree["projects_dir"] / "empty1").exists() + + +def test_projects_gc_force_removes_only_zero_value(patch_globals, capsys): + tree = patch_globals + _make_project(tree, pid="empty1", pname="e1") + full = _make_project(tree, pid="full1", pname="f1") + (full["instincts_personal"] / "inst.yaml").write_text(SAMPLE_INSTINCT_YAML) + tree["registry_file"].write_text( + json.dumps({"empty1": {"name": "e1"}, "full1": {"name": "f1"}}) + ) + + args = SimpleNamespace(dry_run=False, force=True) + assert _cmd_projects_gc(args) == 0 + reg = json.loads(tree["registry_file"].read_text()) + assert "empty1" not in reg + assert "full1" in reg + assert not (tree["projects_dir"] / "empty1").exists() + assert (tree["projects_dir"] / "full1").exists() + + +# ── projects merge ─────────────────────────── + +def test_projects_merge_rejects_same_id(patch_globals, capsys): + args = SimpleNamespace(from_id="dup", into_id="dup", dry_run=False, force=True) + assert _cmd_projects_merge(args) == 1 + assert "into itself" in capsys.readouterr().err + + +def test_projects_merge_missing_source(patch_globals, capsys): + tree = patch_globals + tree["registry_file"].write_text(json.dumps({"dest": {"name": "d"}})) + args = SimpleNamespace(from_id="src", into_id="dest", dry_run=False, force=True) + assert _cmd_projects_merge(args) == 1 + assert "Source project" in capsys.readouterr().err + + +def test_projects_merge_missing_destination(patch_globals, capsys): + tree = patch_globals + # Source present, destination absent — exercises the symmetric error branch. + tree["registry_file"].write_text(json.dumps({"src": {"name": "s"}})) + args = SimpleNamespace(from_id="src", into_id="dest", dry_run=False, force=True) + assert _cmd_projects_merge(args) == 1 + assert "Destination project" in capsys.readouterr().err + + +def test_projects_merge_dry_run_no_changes(patch_globals, capsys): + tree = patch_globals + src = _make_project(tree, pid="src", pname="s") + _make_project(tree, pid="dest", pname="d") + (src["instincts_personal"] / "i.yaml").write_text(SAMPLE_INSTINCT_YAML) + tree["registry_file"].write_text(json.dumps({"src": {"name": "s"}, "dest": {"name": "d"}})) + + args = SimpleNamespace(from_id="src", into_id="dest", dry_run=True, force=False) + assert _cmd_projects_merge(args) == 0 + assert "[DRY RUN]" in capsys.readouterr().out + reg = json.loads(tree["registry_file"].read_text()) + assert "src" in reg and "dest" in reg + assert (tree["projects_dir"] / "src").exists() + # dry-run must not copy any instinct into the destination storage + assert not list((tree["projects_dir"] / "dest" / "instincts" / "personal").glob("*.yaml")) + + +def test_projects_merge_force_moves_and_removes_source(patch_globals, capsys): + tree = patch_globals + src = _make_project(tree, pid="src", pname="s") + _make_project(tree, pid="dest", pname="d") + (src["instincts_personal"] / "i.yaml").write_text(SAMPLE_INSTINCT_YAML) + tree["registry_file"].write_text(json.dumps({"src": {"name": "s"}, "dest": {"name": "d"}})) + + args = SimpleNamespace(from_id="src", into_id="dest", dry_run=False, force=True) + assert _cmd_projects_merge(args) == 0 + reg = json.loads(tree["registry_file"].read_text()) + assert "src" not in reg + assert "dest" in reg + assert not (tree["projects_dir"] / "src").exists() + moved = list((tree["projects_dir"] / "dest" / "instincts" / "personal").glob("*.yaml")) + assert len(moved) >= 1 + + +# ── cmd_prune ──────────────────────────────── + +def _pending_item(path, age_days): + return { + "path": path, + "created": None, + "age_days": age_days, + "name": path.stem, + "parent_dir": str(path.parent), + } + + +def test_cmd_prune_dry_run_keeps_files(monkeypatch, tmp_path, capsys): + f_old = tmp_path / "old.yaml" + f_old.write_text("x", encoding="utf-8") + f_new = tmp_path / "new.yaml" + f_new.write_text("y", encoding="utf-8") + items = [_pending_item(f_old, 40), _pending_item(f_new, 5)] + monkeypatch.setattr(_mod, "_collect_pending_instincts", lambda: items) + + args = SimpleNamespace(max_age=30, dry_run=True, quiet=False) + assert cmd_prune(args) == 0 + assert "[DRY RUN]" in capsys.readouterr().out + assert f_old.exists() + assert f_new.exists() + + +def test_cmd_prune_deletes_only_expired(monkeypatch, tmp_path, capsys): + f_old = tmp_path / "old.yaml" + f_old.write_text("x", encoding="utf-8") + f_new = tmp_path / "new.yaml" + f_new.write_text("y", encoding="utf-8") + items = [_pending_item(f_old, 40), _pending_item(f_new, 5)] + monkeypatch.setattr(_mod, "_collect_pending_instincts", lambda: items) + + args = SimpleNamespace(max_age=30, dry_run=False, quiet=False) + assert cmd_prune(args) == 0 + assert not f_old.exists() + assert f_new.exists() + assert "Pruned 1" in capsys.readouterr().out + + +def test_cmd_prune_quiet_suppresses_output(monkeypatch, tmp_path, capsys): + f_old = tmp_path / "old.yaml" + f_old.write_text("x", encoding="utf-8") + items = [_pending_item(f_old, 99)] + monkeypatch.setattr(_mod, "_collect_pending_instincts", lambda: items) + + args = SimpleNamespace(max_age=30, dry_run=False, quiet=True) + assert cmd_prune(args) == 0 + assert not f_old.exists() + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == "" + + +def test_cmd_prune_empty_pending_nothing_to_do(monkeypatch, capsys): + # Nothing pending at all: the non-dry-run, non-quiet branch must report + # "nothing to do" (not "[DRY RUN]"), return 0, and not crash. + monkeypatch.setattr(_mod, "_collect_pending_instincts", lambda: []) + + args = SimpleNamespace(max_age=30, dry_run=False, quiet=False) + assert cmd_prune(args) == 0 + out = capsys.readouterr().out + assert "No pending instincts older than 30 days." in out + assert "[DRY RUN]" not in out From f720885cea7aba6afc033b72a2becde48d1d819e Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 30 Jun 2026 07:13:37 +0530 Subject: [PATCH 041/197] fix(clv2): archive observations only after successful analysis in observer-loop (#2386) analyze_observations moved observations.jsonl into observations.archive/ unconditionally, even when the Claude analysis failed (timeout, non-zero exit, rate limit). Because the analyzer only reads the live file, a failed batch was archived and never re-analyzed, silently dropping the instincts it would have produced. Return early on a non-zero analysis exit so the archive mv runs only on success, retaining observations for the next cycle to retry. Resolve the script's own directory from ${BASH_SOURCE[0]} (SCRIPT_DIR) so sibling scripts (session-guardian.sh) and relative helpers resolve correctly under both execution and sourcing, and add a source-guard so observer-loop.sh can be sourced without starting the loop. Add a regression test covering both the failure (retain) and success (archive) paths. Fixes #2370 --- .../agents/observer-loop.sh | 28 ++- tests/hooks/observer-loop-archive.test.js | 202 ++++++++++++++++++ 2 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 tests/hooks/observer-loop-archive.test.js diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index 9bab37c5b..1ac1c56c1 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -19,6 +19,12 @@ IDLE_TIMEOUT_SECONDS="${ECC_OBSERVER_IDLE_TIMEOUT_SECONDS:-1800}" SESSION_LEASE_DIR="${PROJECT_DIR}/.observer-sessions" ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity" +# Resolve this script's own directory so sibling scripts (session-guardian.sh) +# and relative helpers (../scripts/instinct-cli.py) resolve correctly whether +# this file is executed or sourced. $0 is the *caller* when sourced, so prefer +# ${BASH_SOURCE[0]}, which always points at this file (#2370). +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + cleanup() { [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null if [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE" 2>/dev/null)" = "$$" ]; then @@ -129,7 +135,7 @@ analyze_observations() { fi # session-guardian: gate observer cycle (active hours, cooldown, idle detection) - if ! bash "$(dirname "$0")/session-guardian.sh"; then + if ! bash "${SCRIPT_DIR}/session-guardian.sh"; then echo "[$(date)] Observer cycle skipped by session-guardian" >> "$LOG_FILE" return fi @@ -259,9 +265,14 @@ PROMPT rm -f "$analysis_file" if [ "$exit_code" -ne 0 ]; then - echo "[$(date)] Claude analysis failed (exit $exit_code)" >> "$LOG_FILE" + echo "[$(date)] Claude analysis failed (exit $exit_code); retaining observations for retry" >> "$LOG_FILE" + return fi + # Archive observations only after a successful analysis. A transient + # failure (timeout, non-zero exit, rate limit) must not discard the batch + # before it has been turned into instincts, since the analyzer only ever + # reads the live observations file (#2370). if [ -f "$OBSERVATIONS_FILE" ]; then archive_dir="${PROJECT_DIR}/observations.archive" mkdir -p "$archive_dir" @@ -298,11 +309,20 @@ on_usr1() { } trap on_usr1 USR1 +# When this file is sourced (e.g. by tests/hooks/observer-loop-archive.test.js) +# rather than executed, stop here so callers can invoke individual functions +# such as analyze_observations without starting the observer loop. The only +# production caller (start-observer.sh) executes the script, so $0 equals +# BASH_SOURCE[0] there and this guard is a no-op (#2370). +if [ "${BASH_SOURCE[0]}" != "${0}" ]; then + return 0 2>/dev/null || true +fi + echo "$$" > "$PID_FILE" echo "[$(date)] Observer started for ${PROJECT_NAME} (PID: $$)" >> "$LOG_FILE" -# Prune expired pending instincts before analysis -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# Prune expired pending instincts before analysis (SCRIPT_DIR resolved at top +# via ${BASH_SOURCE[0]} so it is correct under both execution and sourcing). "${CLV2_PYTHON_CMD:-python3}" "${SCRIPT_DIR}/../scripts/instinct-cli.py" prune --quiet >> "$LOG_FILE" 2>&1 || echo "[$(date)] Warning: instinct prune failed (non-fatal)" >> "$LOG_FILE" while true; do diff --git a/tests/hooks/observer-loop-archive.test.js b/tests/hooks/observer-loop-archive.test.js new file mode 100644 index 000000000..56676e76b --- /dev/null +++ b/tests/hooks/observer-loop-archive.test.js @@ -0,0 +1,202 @@ +/** + * Tests for observer-loop archive-on-failure fix (#2370) + * + * Bug: analyze_observations() in observer-loop.sh moved the live + * observations.jsonl into observations.archive/ unconditionally, even when + * the Claude analysis step failed (timeout, non-zero exit, rate limit). + * Because the analyzer only ever reads the live file, a failed batch could + * never be re-analyzed and its instincts were silently lost. + * + * Fix: archive only after a successful analysis; on failure log and return, + * retaining observations for the next cycle to retry. + * + * Strategy: source observer-loop.sh (a BASH_SOURCE guard stops the main + * loop from running when sourced) and drive analyze_observations directly + * with a stub `claude` (exit code controlled per case) and a stub sibling + * session-guardian.sh. Assert symmetric outcomes for failure vs success. + * + * Run with: node tests/hooks/observer-loop-archive.test.js + */ + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + failed++; + } +} + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-archive-')); +} + +function cleanupDir(dir) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } +} + +const repoRoot = path.resolve(__dirname, '..', '..'); +const observerLoopPath = path.join( + repoRoot, 'skills', 'continuous-learning-v2', 'agents', 'observer-loop.sh' +); + +/** + * Run analyze_observations once with the given stub claude exit code. + * Returns { liveExists, archivedCount, log } describing the resulting state. + */ +function runAnalyzeOnce(claudeExitCode) { + const sandbox = createTempDir(); + try { + const binDir = path.join(sandbox, 'bin'); + const projectDir = path.join(sandbox, 'project'); + fs.mkdirSync(binDir, { recursive: true }); + fs.mkdirSync(projectDir, { recursive: true }); + + // Stub claude: exit with the requested code, ignoring all args. + const claudeStub = path.join(binDir, 'claude'); + fs.writeFileSync(claudeStub, '#!/usr/bin/env bash\nexit ${CLAUDE_STUB_EXIT:-0}\n'); + fs.chmodSync(claudeStub, 0o755); + + // analyze_observations resolves the real session-guardian.sh via its own + // ${BASH_SOURCE[0]}-derived SCRIPT_DIR, so we drive the real guardian with + // all of its gates disabled/isolated (see env below) rather than stubbing it. + + // Driver sources observer-loop.sh (guard stops the main loop) then runs + // the single function under test. + const driver = path.join(sandbox, 'driver.sh'); + fs.writeFileSync( + driver, + `#!/usr/bin/env bash\nsource ${JSON.stringify(observerLoopPath)}\nanalyze_observations\n` + ); + fs.chmodSync(driver, 0o755); + + const observationsFile = path.join(projectDir, 'observations.jsonl'); + fs.writeFileSync(observationsFile, '{"a":1}\n{"a":2}\n{"a":3}\n'); + + // Defensive: never leak CLAUDE_PLUGIN_ROOT into the ECC test shell (it + // contaminates this project's hook-root resolution). + const childEnv = Object.assign({}, process.env); + delete childEnv.CLAUDE_PLUGIN_ROOT; + childEnv.PATH = binDir + path.delimiter + process.env.PATH; + childEnv.CLAUDE_STUB_EXIT = String(claudeExitCode); + childEnv.OBSERVATIONS_FILE = observationsFile; + childEnv.MIN_OBSERVATIONS = '1'; + childEnv.PROJECT_DIR = projectDir; + childEnv.LOG_FILE = path.join(projectDir, 'observer.log'); + childEnv.PROJECT_NAME = 'test-project'; + childEnv.PROJECT_ID = 'test-project'; + childEnv.INSTINCTS_DIR = path.join(projectDir, 'instincts'); + childEnv.CONFIG_DIR = projectDir; + childEnv.CLV2_IS_WINDOWS = 'false'; + childEnv.ECC_OBSERVER_TIMEOUT_SECONDS = '2'; + // Make the real session-guardian.sh deterministically proceed (exit 0): + // disable the active-hours and idle gates, isolate the cooldown log, and + // zero the cooldown interval so a fresh project always passes. + childEnv.OBSERVER_ACTIVE_HOURS_START = '0'; + childEnv.OBSERVER_ACTIVE_HOURS_END = '0'; + childEnv.OBSERVER_MAX_IDLE_SECONDS = '0'; + childEnv.OBSERVER_INTERVAL_SECONDS = '0'; + childEnv.OBSERVER_LAST_RUN_LOG = path.join(projectDir, 'observer-last-run.log'); + + const result = spawnSync('bash', [driver], { + encoding: 'utf8', + timeout: 15000, + env: childEnv + }); + + assert.strictEqual( + result.status, 0, + `driver should exit 0, got ${result.status}; stderr: ${result.stderr}` + ); + + const archiveDir = path.join(projectDir, 'observations.archive'); + let archivedCount = 0; + if (fs.existsSync(archiveDir)) { + archivedCount = fs.readdirSync(archiveDir) + .filter(f => /^processed-.*\.jsonl$/.test(f)).length; + } + let log = ''; + try { log = fs.readFileSync(childEnv.LOG_FILE, 'utf8'); } catch { /* none */ } + + return { liveExists: fs.existsSync(observationsFile), archivedCount, log }; + } finally { + cleanupDir(sandbox); + } +} + +console.log('\n=== Observer-loop Archive-on-Failure Tests (#2370) ===\n'); + +console.log('--- behavioral ---'); + +test('failed analysis retains observations and archives nothing', () => { + // Shell-driven behavioral check; skip on Windows where the bash driver's + // $0 path handling differs (matches observer-memory.test.js convention). + if (process.platform === 'win32') { + return; + } + const { liveExists, archivedCount, log } = runAnalyzeOnce(1); + assert.ok(liveExists, 'live observations.jsonl must be retained when analysis fails'); + assert.strictEqual(archivedCount, 0, 'nothing should be archived when analysis fails'); + assert.ok( + /retaining observations for retry/.test(log), + `failure log should note retention; got: ${log}` + ); +}); + +test('successful analysis archives the batch (happy path preserved)', () => { + // Shell-driven behavioral check; skip on Windows (see note above). + if (process.platform === 'win32') { + return; + } + const { liveExists, archivedCount } = runAnalyzeOnce(0); + assert.ok(!liveExists, 'live observations.jsonl should be moved after a successful analysis'); + assert.strictEqual(archivedCount, 1, 'exactly one processed-*.jsonl should be archived on success'); +}); + +console.log('--- static guards ---'); + +test('analyze_observations returns on failure before the archive mv', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + // Operate on full file content with explicit anchors rather than a lazy + // function-body extraction (which could truncate on a future inner "\n}" + // and pass vacuously). These tokens each occur once, inside the function. + const failIdx = content.search(/exit_code"?\s+-ne\s+0/); + const returnIdx = content.indexOf('return', failIdx); + const archiveIdx = content.indexOf('observations.archive'); + assert.ok(failIdx !== -1, 'should find the non-zero exit_code check'); + assert.ok(archiveIdx !== -1, 'should find the archive block'); + assert.ok(returnIdx !== -1, 'failure branch should contain a return'); + assert.ok(returnIdx < archiveIdx, + 'failure branch must return before reaching the archive block'); +}); + +test('observer-loop.sh has a source-guard so it can be sourced in tests', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + assert.ok( + content.includes('BASH_SOURCE[0]') && content.includes('return 0 2>/dev/null'), + 'observer-loop.sh should short-circuit when sourced rather than executed' + ); +}); + +console.log('\n=== Test Results ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}\n`); + +process.exit(failed > 0 ? 1 : 0); From c2bcc4ec2f783c59a62a590d571e88aa23904c72 Mon Sep 17 00:00:00 2001 From: jack-finance-able Date: Mon, 29 Jun 2026 20:43:42 -0500 Subject: [PATCH 042/197] feat(continuous-learning-v2): make observer model configurable via ECC_OBSERVER_MODEL (#2390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(continuous-learning-v2): make observer model configurable via ECC_OBSERVER_MODEL The observer hardcoded `--model haiku`. Parameterize as "${ECC_OBSERVER_MODEL:-haiku}": the haiku default is preserved (no behavior change for existing users), but users can opt into a stronger model — e.g. `ECC_OBSERVER_MODEL=opus` — for higher-quality instinct extraction. Useful on subscription plans where model cost isn't the limiting factor. * fix(continuous-learning-v2): address review — update wiring test + docs - Update source-inspection test to assert the ${ECC_OBSERVER_MODEL:-haiku} defaulting behavior (was matching the literal `claude --model haiku`, which this PR changed). All 31 tests pass. - Add guidance to raise ECC_OBSERVER_TIMEOUT_SECONDS for slower models (e.g. opus) so the 120s watchdog doesn't kill analysis mid-run. - Fix now-stale 'Haiku session' comment -> 'observer session' (model is configurable). --- skills/continuous-learning-v2/agents/observer-loop.sh | 8 ++++++-- tests/hooks/observer-memory.test.js | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index 1ac1c56c1..fa50c1f61 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -241,11 +241,15 @@ PROMPT # on all platforms, not just when the observer happens to be launched from the project root. cd "$PROJECT_DIR" || { echo "[$(date)] Failed to cd to PROJECT_DIR ($PROJECT_DIR), skipping analysis" >> "$LOG_FILE"; rm -f "$analysis_file"; return; } - # Prevent observe.sh from recording this automated Haiku session as observations. + # Prevent observe.sh from recording this automated observer session as observations. # Pass prompt via -p flag instead of stdin redirect for Windows compatibility (#842). # prompt_content is already loaded in-memory so this no longer depends on the # mktemp absolute path continuing to resolve after cwd changes (#1296). - ECC_SKIP_OBSERVE=1 ECC_HOOK_PROFILE=minimal claude --model haiku --max-turns "$max_turns" --print \ + # Model is configurable via ECC_OBSERVER_MODEL (defaults to haiku for cost efficiency); + # e.g. ECC_OBSERVER_MODEL=opus for higher-quality instinct extraction. Heavier models are + # slower — consider raising ECC_OBSERVER_TIMEOUT_SECONDS (default 120s) so the watchdog + # doesn't kill the analysis mid-run. + ECC_SKIP_OBSERVE=1 ECC_HOOK_PROFILE=minimal claude --model "${ECC_OBSERVER_MODEL:-haiku}" --max-turns "$max_turns" --print \ --allowedTools "Read,Write" \ -p "$prompt_content" >> "$LOG_FILE" 2>&1 & claude_pid=$! diff --git a/tests/hooks/observer-memory.test.js b/tests/hooks/observer-memory.test.js index 597c7df27..9bfbdde09 100644 --- a/tests/hooks/observer-memory.test.js +++ b/tests/hooks/observer-memory.test.js @@ -454,8 +454,13 @@ test('claude invocation still includes ECC_SKIP_OBSERVE and ECC_HOOK_PROFILE gua const content = fs.readFileSync(observerLoopPath, 'utf8'); // Find the claude execution line(s) const lines = content.split('\n'); - const claudeLine = lines.find(l => l.includes('claude --model haiku')); - assert.ok(claudeLine, 'Should find claude --model haiku invocation line'); + const claudeLine = lines.find(l => l.includes('claude --model')); + assert.ok(claudeLine, 'Should find claude --model invocation line'); + // Model is configurable via ECC_OBSERVER_MODEL but must still default to haiku. + assert.ok( + claudeLine.includes('${ECC_OBSERVER_MODEL:-haiku}'), + `claude --model should default to haiku and honor ECC_OBSERVER_MODEL, got: ${claudeLine}` + ); // The env vars are on the same line as the claude command const claudeLineIndex = lines.indexOf(claudeLine); const fullCommand = lines.slice(Math.max(0, claudeLineIndex - 1), claudeLineIndex + 3).join(' '); From a141db3ad2c52190271b7784c291f03be6bf6f76 Mon Sep 17 00:00:00 2001 From: Yeris Rifan Date: Tue, 30 Jun 2026 09:22:48 +0700 Subject: [PATCH 043/197] feat(rules,skills): add React Native / Expo rules pack and react-native-patterns skill (#2275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(rules,skills): add React Native / Expo rules pack and react-native-patterns skill * fix(rules,skills): address review feedback — safeParse nav example, drop deprecated sentry-expo, memoize list renderItem, clarify New Architecture SDK support * fix(rules,skills): drop deprecated Flipper, surface permission-denied state in location hook --- rules/README.md | 8 +- rules/react-native/accessibility.md | 55 ++++ rules/react-native/coding-style.md | 71 +++++ rules/react-native/hooks.md | 28 ++ rules/react-native/patterns.md | 88 ++++++ rules/react-native/performance.md | 45 +++ rules/react-native/production-readiness.md | 51 ++++ rules/react-native/security.md | 43 +++ rules/react-native/testing.md | 52 ++++ skills/react-native-patterns/SKILL.md | 326 +++++++++++++++++++++ 10 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 rules/react-native/accessibility.md create mode 100644 rules/react-native/coding-style.md create mode 100644 rules/react-native/hooks.md create mode 100644 rules/react-native/patterns.md create mode 100644 rules/react-native/performance.md create mode 100644 rules/react-native/production-readiness.md create mode 100644 rules/react-native/security.md create mode 100644 rules/react-native/testing.md create mode 100644 skills/react-native-patterns/SKILL.md diff --git a/rules/README.md b/rules/README.md index e4b69f737..0a9f48e45 100644 --- a/rules/README.md +++ b/rules/README.md @@ -1,4 +1,5 @@ # Rules + ## Structure Rules are organized into a **common** layer plus **language-specific** directories: @@ -21,6 +22,7 @@ rules/ ├── python/ # Python specific ├── golang/ # Go specific ├── web/ # Web and frontend specific +├── react-native/ # React Native / Expo specific ├── swift/ # Swift specific ├── php/ # PHP specific ├── ruby/ # Ruby / Rails specific @@ -43,6 +45,7 @@ rules/ ./install.sh python ./install.sh golang ./install.sh web +./install.sh react-native ./install.sh swift ./install.sh php ./install.sh ruby @@ -79,6 +82,7 @@ cp -r rules/nuxt ~/.claude/rules/ecc/ cp -r rules/python ~/.claude/rules/ecc/ cp -r rules/golang ~/.claude/rules/ecc/ cp -r rules/web ~/.claude/rules/ecc/ +cp -r rules/react-native ~/.claude/rules/ecc/ cp -r rules/swift ~/.claude/rules/ecc/ cp -r rules/php ~/.claude/rules/ecc/ cp -r rules/ruby ~/.claude/rules/ecc/ @@ -100,7 +104,7 @@ cp -r rules/typescript .claude/rules/ecc/ - **Rules** define standards, conventions, and checklists that apply broadly (e.g., "80% test coverage", "no hardcoded secrets"). - **Skills** (`skills/` directory) provide deep, actionable reference material for specific tasks (e.g., `python-patterns`, `golang-testing`). -Language-specific rule files reference relevant skills where appropriate. Rules tell you *what* to do; skills tell you *how* to do it. +Language-specific rule files reference relevant skills where appropriate. Rules tell you _what_ to do; skills tell you _how_ to do it. ## Adding a New Language @@ -126,7 +130,7 @@ For non-language domains like `web/`, follow the same layered pattern when there When language-specific rules and common rules conflict, **language-specific rules take precedence** (specific overrides general). This follows the standard layered configuration pattern (similar to CSS specificity or `.gitignore` precedence). - `rules/common/` defines universal defaults applicable to all projects. -- `rules/golang/`, `rules/python/`, `rules/swift/`, `rules/php/`, `rules/typescript/`, etc. override those defaults where language idioms differ. +- `rules/golang/`, `rules/python/`, `rules/swift/`, `rules/php/`, `rules/typescript/`, `rules/react-native/`, etc. override those defaults where language idioms differ. ### Example diff --git a/rules/react-native/accessibility.md b/rules/react-native/accessibility.md new file mode 100644 index 000000000..86c1f2b60 --- /dev/null +++ b/rules/react-native/accessibility.md @@ -0,0 +1,55 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Accessibility + +> Extends the ECC quality bar to accessibility (a11y). Treat a11y as a release requirement, not an afterthought. +> Target: usable with screen readers (VoiceOver on iOS, TalkBack on Android) and at large font sizes. + +## Labeling + +- Every interactive element has an `accessibilityRole` and an `accessibilityLabel` (or readable child text). +- Icon-only buttons MUST have an `accessibilityLabel` — there is no visible text for the reader to announce. +- Use `accessibilityHint` only when the action is non-obvious; keep it short. +- Group related elements with `accessible` on the container so they're announced as one unit when appropriate. + +```tsx + + + +``` + +## State & Live Regions + +- Communicate state with `accessibilityState` (e.g. `{ disabled, selected, checked, expanded }`). +- Announce async/transient changes (toasts, validation errors) via `accessibilityLiveRegion` (Android) and `AccessibilityInfo.announceForAccessibility` where needed. +- Reflect loading/error/empty states in text the reader can reach — not just spinners or color. + +## Touch Targets & Layout + +- Minimum touch target ~44x44pt (iOS) / 48x48dp (Android); use `hitSlop` to enlarge small controls. +- Respect Dynamic Type / font scaling — avoid fixed heights that clip scaled text; test at the largest accessibility font size. +- Honor `prefers-reduced-motion` (`AccessibilityInfo.isReduceMotionEnabled`) — gate non-essential animation. + +## Color & Contrast + +- Do not convey meaning by color alone; pair with text, icon, or shape. +- Meet WCAG AA contrast: 4.5:1 for body text, 3:1 for large text and meaningful UI/graphical elements. +- Verify both light and dark themes. + +## Focus & Navigation + +- Logical focus order; move focus to new content (modals, screens) on open and restore on close. +- Ensure custom components are reachable and operable by the screen reader, not just by touch. + +## Testing + +- Manually test with VoiceOver and TalkBack on real devices — automated checks do not catch everything. +- In component tests, query by role/label (see testing.md) so a11y and tests reinforce each other. +- Add a11y to the pre-release gate: key flows pass a screen-reader walkthrough. diff --git a/rules/react-native/coding-style.md b/rules/react-native/coding-style.md new file mode 100644 index 000000000..5de07cf77 --- /dev/null +++ b/rules/react-native/coding-style.md @@ -0,0 +1,71 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with React Native / Expo specific content. + +## Components + +- Define props with a named `interface` or `type`; do not use `React.FC`. +- Keep screens thin: a screen composes hooks + presentational components, it does not hold heavy logic. +- One component per file for anything reusable; co-locate small private subcomponents. +- Prefer function components and hooks. No class components. + +```tsx +interface AvatarProps { + uri: string + size?: number + onPress?: () => void +} + +export function Avatar({ uri, size = 40, onPress }: AvatarProps) { + return ( + + + + ) +} +``` + +## Styling + +Pick ONE styling system per project and stay consistent. `StyleSheet.create()` is the framework-native option; utility-class libraries (e.g. NativeWind) are a common alternative. This rule is library-agnostic — what matters is consistency and avoiding inline allocations. + +- StyleSheet: define styles with `StyleSheet.create()` at module scope — never build style objects inline inside `render`/JSX on hot paths (it allocates on every render). +- Utility-class approach: extract repeated class strings into shared constants or a variant helper. +- Never hardcode raw colors, spacing, or font sizes scattered across files. Centralize design tokens (theme file or config). + +```tsx +// WRONG: inline style object recreated every render + + +// CORRECT (StyleSheet) +const styles = StyleSheet.create({ card: { padding: 16, backgroundColor: '#fff' } }) + + +// CORRECT (NativeWind) + +``` + +## Platform Differences + +- Use platform-specific files (`Component.ios.tsx`, `Component.android.tsx`) for substantial divergence. +- Use `Platform.select()` / `Platform.OS` for small differences only. +- Account for safe areas with `react-native-safe-area-context`; do not hardcode status bar / notch offsets. + +## Imports & Project Layout + +- Use the Expo/TS path alias (e.g. `@/components/...`) instead of long relative chains. +- Organize by feature/domain, not by type. Keep files focused (200-400 lines typical, 800 max). + +## Logging + +- No `console.log` in shipped code. Use a logger and strip logs in production builds. +- Surface user-facing errors through UI state, not console. + +## TypeScript + +All TypeScript rules from `rules/typescript/` apply (explicit types on public APIs, avoid `any`, Zod for validation, immutable updates). This file only adds RN-specific guidance on top. diff --git a/rules/react-native/hooks.md b/rules/react-native/hooks.md new file mode 100644 index 000000000..27759ed4c --- /dev/null +++ b/rules/react-native/hooks.md @@ -0,0 +1,28 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with React Native / Expo-specific automation guidance. + +These are recommended PostToolUse automations to keep RN/Expo code healthy. Wire them in your hook runtime (or run manually); adapt commands to your package manager. + +## Suggested PostToolUse checks (on edit of *.ts/*.tsx) + +- **Type check:** `tsc --noEmit` — catch type errors early. +- **Lint:** `npx expo lint` (uses `eslint-config-expo`; flat config `eslint.config.js` is the default from SDK 53+). +- **Format:** `prettier --write` on changed files. + +## Pre-release / periodic + +- `npx expo-doctor` — validates Expo/native dependency health and config. +- `npx expo install --check` — keeps native deps aligned with the installed Expo SDK. +- `npm audit` — dependency vulnerability scan. + +## Notes + +- Do not run heavy native builds inside fast edit hooks; keep edit-time hooks to typecheck/lint/format. +- Reserve `eas build` / E2E for explicit commands or CI, not per-edit automation. +- Keep these consistent with ECC hook runtime controls (`ECC_HOOK_PROFILE`, `ECC_DISABLED_HOOKS`). diff --git a/rules/react-native/patterns.md b/rules/react-native/patterns.md new file mode 100644 index 000000000..5ffef06af --- /dev/null +++ b/rules/react-native/patterns.md @@ -0,0 +1,88 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with React Native / Expo specific patterns. +> Note: Do NOT install the `web/` ruleset in a React Native project — those patterns assume the DOM (e.g. URL-as-state) and do not apply here. + +## Navigation (Expo Router) + +Expo Router is Expo's built-in, file-based router (`app/` directory); React Navigation is the established alternative. The examples below use Expo Router; the principles apply either way. + +- Keep route files (`app/**`) thin — they wire params + hooks to a screen component that lives in `components/` or `features/`. +- Type route params; validate untrusted params (e.g. from deep links) with Zod before use. +- Use typed navigation helpers (`useLocalSearchParams`, `Link`, `router.push`). +- Centralize linking config; never trust deep-link params without validation. + +```tsx +// app/user/[id].tsx +import { useLocalSearchParams, router } from 'expo-router' +import { z } from 'zod' + +const Params = z.object({ id: z.string().uuid() }) + +export default function UserScreen() { + // Use safeParse, not parse: a malformed deep link would otherwise throw + // during render and crash the screen. Redirect instead of throwing. + const parsed = Params.safeParse(useLocalSearchParams()) + if (!parsed.success) { + router.replace('/not-found') + return null + } + return +} +``` + +## State Management + +The rule is to keep these concerns separate and not duplicate server data into client stores. The tools listed are common choices, not requirements — pick what fits your project. + +| Concern | Common choices | +|---------|---------| +| Server state | a server-cache library (TanStack Query, SWR) | +| Client/UI state | a lightweight store (Zustand, Jotai) or Context | +| Navigation/route state | Expo Router params (NOT a global store) | +| Form state | a form library (e.g. React Hook Form) with schema validation | +| Secure persistence | `expo-secure-store` | +| Non-secure persistence | `AsyncStorage` / MMKV | + +- Derive values instead of storing redundant computed state. +- Keep global client state minimal; prefer local `useState` until sharing is actually needed. + +## Data Fetching + +Use a server-cache library (TanStack Query, SWR) instead of ad-hoc fetch-in-`useEffect`. The examples use TanStack Query. + +- Route server reads through the cache (e.g. `useQuery`) and mutations through it (e.g. `useMutation`) with cache invalidation. +- Validate API responses with Zod at the boundary; infer types from the schema. (Zod is already the validation default in ECC's `typescript/` rules.) +- Handle the three states explicitly in UI: loading, error, empty. +- Use optimistic updates for fast interactions: snapshot, apply, roll back on failure with visible feedback. +- Fetch independent data in parallel; avoid request waterfalls between parent and child. + +```tsx +function useUser(id: string) { + return useQuery({ + queryKey: ['user', id], + queryFn: async () => userSchema.parse(await api.getUser(id)), + }) +} +``` + +## Lists + +- Use `FlatList`/`SectionList` (or `FlashList` for large/heavy lists) — never `.map()` a large array inside a `ScrollView`. +- Provide a stable `keyExtractor`; memoize `renderItem`. +- Paginate or virtualize long data sets. + +## Custom Hooks + +- Extract reusable logic (data, permissions, device APIs) into `use*` hooks. +- Keep side effects (Expo SDK calls, subscriptions) inside hooks, not in JSX. + +## Async & Effects + +- Clean up subscriptions, timers, and listeners in the effect's return function. +- Cancel or ignore stale async results on unmount to avoid setState-after-unmount. diff --git a/rules/react-native/performance.md b/rules/react-native/performance.md new file mode 100644 index 000000000..b96af5cdb --- /dev/null +++ b/rules/react-native/performance.md @@ -0,0 +1,45 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Performance + +> This file extends [common/performance.md](../common/performance.md) with React Native / Expo specific content. + +## Rendering + +- Memoize expensive components with `React.memo`; memoize callbacks/values passed to children with `useCallback`/`useMemo` only where they prevent real re-renders. +- Keep component state local and narrow — lifting state too high re-renders large subtrees. +- Avoid creating new objects/arrays/functions inline in props on hot paths; they break memoization. +- Split large screens so a state change re-renders the smallest possible subtree. + +## Lists + +- Use `FlatList`/`SectionList`, or `FlashList` (Shopify) for large or heterogeneous lists. +- Provide `keyExtractor`, a memoized `renderItem`, and stable item heights when possible (`getItemLayout`). +- Tune `initialNumToRender`, `windowSize`, `maxToRenderPerBatch` for heavy rows. +- Never render large data sets with `.map()` inside a `ScrollView`. + +## Images & Assets + +- Use `expo-image` for caching, priority, and placeholders; serve appropriately sized images. +- Avoid loading full-resolution images into small thumbnails. + +## Animations + +- Prefer `react-native-reanimated` (runs on the UI thread) over the JS-driven `Animated` API. +- For legacy `Animated`, set `useNativeDriver: true` where supported. +- Keep heavy computation off the JS thread; offload to Reanimated worklets or native modules. + +## Runtime & Build + +- Build on the **New Architecture** (Fabric + TurboModules). It is the default in recent Expo SDKs (opt-out still available on SDK 53–54) and is mandatory — cannot be disabled — from SDK 55+. Verify every native dependency is New-Arch compatible before shipping. +- Ensure **Hermes** is enabled (default in modern Expo) for faster startup and lower memory. +- Defer non-critical work after first paint; lazy-load heavy screens/modules. +- Use `InteractionManager.runAfterInteractions` for work that can wait until animations finish. + +## Measuring + +- Profile with the React DevTools profiler, the Hermes sampling profiler, and the in-app performance monitor. (Avoid Flipper — it is deprecated and not supported on the New Architecture.) +- Watch for: long lists without virtualization, oversized images, frequent full-tree re-renders, and synchronous work on the JS thread. diff --git a/rules/react-native/production-readiness.md b/rules/react-native/production-readiness.md new file mode 100644 index 000000000..6ce720a9c --- /dev/null +++ b/rules/react-native/production-readiness.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Production Readiness + +> Extends the ECC philosophy to ship-grade concerns that style/pattern rules cannot encode by themselves. +> A clean codebase is necessary but not sufficient for production — these items are mandatory before release. + +## Architecture + +- Ship on the **New Architecture** (Fabric + TurboModules). It is the default in recent Expo SDKs and is mandatory (cannot be disabled) from SDK 55+. Audit native deps for compatibility. +- Pin the Expo SDK version; upgrade deliberately with `npx expo install --check` and test on both platforms. + +## Build & Release (EAS) + +- Use **EAS Build** for production binaries and **EAS Submit** for store delivery. Do not rely on local ad-hoc builds for release. +- Keep separate build profiles (`development`, `preview`, `production`) in `eas.json`. +- Manage signing credentials via EAS; never commit keystores or provisioning profiles. + +## Over-the-Air Updates + +- Use **EAS Update** (`expo-updates`) for JS-only fixes, with a defined runtime version policy. +- Never push native changes via OTA — those require a new store build. +- Roll out gradually and keep the ability to roll back. + +## Observability + +- Integrate crash + error reporting (e.g. **Sentry** via `@sentry/react-native`) in production builds. +- Add structured logging and, where useful, analytics — but strip verbose logs from release. +- Capture and surface failed network/mutation states; do not fail silently. + +## Configuration & Versioning + +- Bump `version` and `ios.buildNumber` / `android.versionCode` per release. +- Public config via `EXPO_PUBLIC_*`; real secrets via EAS secrets only. +- Validate required config at startup and fail fast with a clear message. + +## Pre-Release Gate + +Before shipping, all must pass: + +- [ ] `tsc --noEmit` clean +- [ ] `npx expo lint` clean +- [ ] Tests green, coverage >= 80% (see testing.md) +- [ ] `npx expo-doctor` healthy +- [ ] Critical-flow E2E (Maestro/Detox) pass on a real build +- [ ] No secrets in bundle (see security.md) +- [ ] Crash reporting active and verified +- [ ] Tested on physical iOS and Android devices, not just simulators diff --git a/rules/react-native/security.md b/rules/react-native/security.md new file mode 100644 index 000000000..edd701ef4 --- /dev/null +++ b/rules/react-native/security.md @@ -0,0 +1,43 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Security + +> This file extends [common/security.md](../common/security.md) with React Native / Expo specific content. +> The mandatory pre-commit checklist and Security Response Protocol from common/security.md still apply. + +## The Bundle Is Public + +Treat everything shipped in the app as readable by an attacker. A mobile binary can be unpacked. + +- NEVER ship real secrets (private API keys, service-role keys, signing secrets) in the JS bundle or `app.config`. +- Public/anon keys (e.g. Supabase anon key, Firebase config) are acceptable ONLY when protected by server-side rules (RLS, security rules). Enforce authorization on the backend, never in the client. +- Keep privileged operations behind your own server / edge functions. + +## Secret & Token Storage + +- Store auth tokens and sensitive values in `expo-secure-store` (Keychain / Keystore) — never in `AsyncStorage` or plain MMKV. +- Do not persist secrets in Redux/Zustand state that may be serialized to disk. + +## Configuration + +- Read environment via `expo-constants` / `app.config.ts` `extra`, and `EXPO_PUBLIC_*` only for genuinely public values. +- Keep build secrets in EAS secrets, not in the repo. + +## Network & Data + +- HTTPS only; reject cleartext. Consider certificate pinning for high-risk apps. +- Validate ALL external data (API responses, deep-link params, push payloads) with Zod before use. +- Validate and sanitize deep links and universal links — never route or grant access based on unvalidated params. + +## Permissions & Privacy + +- Request the minimum device permissions, at the moment they are needed, with clear rationale. +- Declare data collection accurately for App Store / Play Store privacy disclosures. + +## Dependencies + +- Run `expo-doctor` and `npm audit` regularly; keep the Expo SDK and native deps current. +- Use `/security-scan` (AgentShield) on the agent configuration itself. diff --git a/rules/react-native/testing.md b/rules/react-native/testing.md new file mode 100644 index 000000000..628e31965 --- /dev/null +++ b/rules/react-native/testing.md @@ -0,0 +1,52 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" +--- +# React Native / Expo Testing + +> This file extends [common/testing.md](../common/testing.md) with React Native / Expo specific content. +> Coverage target and TDD workflow are inherited from common/testing.md (80% minimum, RED-GREEN-REFACTOR). + +## Tooling + +| Layer | Tool | +|-------|------| +| Unit / component | Jest + `@testing-library/react-native` (via `jest-expo` preset) | +| Hooks | `@testing-library/react-native` `renderHook` | +| E2E | Maestro (recommended, simple YAML flows) or Detox | +| Type safety | `tsc --noEmit` in CI | + +## Component Tests + +- Query by accessible role/label/text, not by `testID` unless necessary — this also enforces accessibility. +- Assert on user-visible behavior, not implementation details. +- Follow Arrange-Act-Assert. + +```tsx +import { render, screen, fireEvent } from '@testing-library/react-native' + +test('calls onSelect with the user id when pressed', () => { + const onSelect = jest.fn() + render() + + fireEvent.press(screen.getByText('a@b.com')) + + expect(onSelect).toHaveBeenCalledWith('1') +}) +``` + +## Mocking + +- Mock Expo SDK modules (camera, location, notifications, secure-store) at the test boundary. +- Wrap components that use TanStack Query in a `QueryClientProvider` with a fresh client per test. +- Mock navigation (`expo-router`) so screens render in isolation. + +## E2E + +- Cover critical flows only: auth, primary navigation, core transactions. +- Run E2E on CI against a built app (EAS Build) before release. + +## What to test first + +Use the `tdd-guide` agent proactively for new features: write a failing test that captures the behavior, then implement. diff --git a/skills/react-native-patterns/SKILL.md b/skills/react-native-patterns/SKILL.md new file mode 100644 index 000000000..d0e6c3272 --- /dev/null +++ b/skills/react-native-patterns/SKILL.md @@ -0,0 +1,326 @@ +--- +name: react-native-patterns +description: React Native and Expo app patterns — Expo Router navigation, state separation (server/client/route/form), TanStack Query data fetching with Zod, performant lists, NativeWind/StyleSheet styling, native APIs, and secure storage. Use when building or editing React Native / Expo screens, components, navigation, or data layers. +origin: ECC +--- + +# React Native / Expo Patterns + +Practical patterns for building production React Native apps with Expo. Covers navigation, state, data fetching, lists, styling, and native APIs. Pairs with the `rules/react-native/` ruleset: rules say *what* to enforce, this skill shows *how*. + +Libraries named below (NativeWind, Zustand/Jotai, TanStack Query) are common, well-established options shown for illustration — the patterns matter more than the specific package, and any equivalent works. Zod is used for validation to stay consistent with ECC's existing `typescript/` rules. + +These patterns assume the managed Expo workflow (Expo Router, EAS, `expo-*` modules) on the New Architecture (the default in recent Expo SDKs, mandatory from SDK 55+). They do NOT assume the browser DOM — React Native has no `
`, no URL bar, and no web data-fetching defaults. + +## When to Activate + +Use this skill when: + +- Building or editing React Native / Expo screens, components, or navigation +- Setting up routing with Expo Router (file-based `app/` directory) +- Deciding where state belongs (server cache vs client store vs route params vs form) +- Wiring data fetching with TanStack Query and validating responses with Zod +- Rendering long or heavy lists +- Choosing or applying a styling approach (NativeWind or StyleSheet) +- Accessing native device APIs (camera, location, notifications) or secure storage +- Reviewing RN code for mobile-specific issues + +Do NOT use the web/React-DOM patterns here — URL-as-state, `
`, and SWR-for-browser do not apply to React Native. + +## Core Concepts + +### Project structure (Expo Router) + +File-based routing under `app/`. Keep route files thin: they read and validate params, then delegate to a screen component that lives in `components/` or `features/`. + +``` +app/ + _layout.tsx # root stack + (tabs)/ + _layout.tsx # tab navigator + index.tsx # Home + user/[id].tsx # dynamic route +components/ +features/ + user/UserProfile.tsx +``` + +### Navigation: validate route params + +Deep links and dynamic routes deliver untrusted strings. Validate them with Zod before use. + +```tsx +// app/user/[id].tsx +import { useLocalSearchParams, router } from 'expo-router' +import { z } from 'zod' +import { UserProfile } from '@/features/user/UserProfile' + +const Params = z.object({ id: z.string().uuid() }) + +export default function UserRoute() { + const parsed = Params.safeParse(useLocalSearchParams()) + if (!parsed.success) { + router.replace('/not-found') + return null + } + return +} +``` + +### State: keep concerns separate + +Do not duplicate server data into a client store. Each concern has its own home. + +| Concern | Common choices | +|---------|------| +| Server state (remote data) | a server-cache library (TanStack Query, SWR) | +| Client/UI state | a lightweight store (Zustand, Jotai) or Context | +| Route/navigation state | Expo Router params | +| Form state | a form library (e.g. React Hook Form) + schema validation | +| Secrets / tokens | `expo-secure-store` | +| Non-secret persistence | `AsyncStorage` / MMKV | + +Prefer local `useState` until state genuinely needs sharing. + +### Data fetching: a cache library + Zod + +Use a server-cache library (TanStack Query, SWR) instead of fetch-in-`useEffect`. Validate at the boundary and infer types from the schema. Handle loading, error, and empty states explicitly. (Example uses TanStack Query.) + +```tsx +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { z } from 'zod' + +const User = z.object({ id: z.string(), email: z.string().email() }) +type User = z.infer + +export function useUser(id: string) { + return useQuery({ + queryKey: ['user', id], + queryFn: async (): Promise => User.parse(await api.getUser(id)), + }) +} + +export function useUpdateEmail(id: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (email: string) => api.updateEmail(id, email), + onSuccess: () => qc.invalidateQueries({ queryKey: ['user', id] }), + }) +} +``` + +### Lists: virtualize, never map a big array in a ScrollView + +```tsx +import { FlatList } from 'react-native' + + item.id} + renderItem={renderItem} // memoized + initialNumToRender={10} + windowSize={5} +/> +``` + +Use `FlashList` (Shopify) for large or heterogeneous lists. + +### Styling: pick one system + +`StyleSheet.create()` is the framework-native option; utility-class libraries (e.g. NativeWind) are a common alternative. Choose one and stay consistent. Never build style objects inline in JSX on hot paths. + +```tsx +// NativeWind + + Hello + + +// StyleSheet +const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 16, backgroundColor: '#fff' } }) +... +``` + +### Native APIs: wrap in hooks, clean up effects + +Keep Expo SDK calls and subscriptions inside `use*` hooks, not in JSX. Always clean up. + +```tsx +import { useEffect, useState } from 'react' +import * as Location from 'expo-location' + +type LocationState = + | { status: 'loading' } + | { status: 'denied' } + | { status: 'granted'; coords: Location.LocationObjectCoords } + +export function useCurrentLocation() { + // Track status, not just coords — so the UI can tell "still loading" apart + // from "permission denied" and show an actionable message. + const [state, setState] = useState({ status: 'loading' }) + + useEffect(() => { + let active = true + ;(async () => { + const { status } = await Location.requestForegroundPermissionsAsync() + if (status !== 'granted') { + if (active) setState({ status: 'denied' }) + return + } + const pos = await Location.getCurrentPositionAsync({}) + if (active) setState({ status: 'granted', coords: pos.coords }) + })() + return () => { active = false } // ignore stale result after unmount + }, []) + + return state +} +``` + +### Secure storage for tokens + +```tsx +import * as SecureStore from 'expo-secure-store' + +await SecureStore.setItemAsync('auth_token', token) // Keychain / Keystore +const token = await SecureStore.getItemAsync('auth_token') +``` + +## Code Examples + +### A full screen: route → query → list → states + +```tsx +// app/(tabs)/orders.tsx +import { memo, useCallback } from 'react' +import { FlatList, Text, View } from 'react-native' +import { useQuery } from '@tanstack/react-query' +import { z } from 'zod' + +const OrderSchema = z.object({ id: z.string(), total: z.number(), status: z.string() }) +const OrdersSchema = z.array(OrderSchema) +type Order = z.infer + +function useOrders() { + return useQuery({ + queryKey: ['orders'], + queryFn: async () => OrdersSchema.parse(await api.listOrders()), + }) +} + +// Memoized so its reference is stable across renders (see the lists guidance). +const OrderRow = memo(function OrderRow({ item }: { item: Order }) { + return ( + + #{item.id} + {item.status} · ${item.total} + + ) +}) + +export default function OrdersScreen() { + const { data, isLoading, isError, refetch, isRefetching } = useOrders() + const renderItem = useCallback(({ item }: { item: Order }) => , []) + + if (isLoading) return Loading… + if (isError) return Could not load orders. + if (!data?.length) return No orders yet. + + return ( + o.id} + onRefresh={refetch} + refreshing={isRefetching} + renderItem={renderItem} + /> + ) +} +``` + +### A form: React Hook Form + Zod resolver + +```tsx +import { useForm, Controller } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { TextInput, Button, Text } from 'react-native' + +const Schema = z.object({ email: z.string().email('Invalid email') }) +type FormValues = z.infer + +export function EmailForm({ onSubmit }: { onSubmit: (v: FormValues) => void }) { + const { control, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(Schema), + defaultValues: { email: '' }, + }) + + return ( + <> + ( + + )} + /> + {errors.email && {errors.email.message}} + +
+

+
+ +
+ + +
+
Enter to queue · Cmd/Ctrl+Enter to queue & send
+
\`; + const attach = () => document.body ? document.body.appendChild(host) : null; + if (document.body) attach(); + else document.addEventListener('DOMContentLoaded', attach); + + const hl = root.querySelector('.hl'); + const selhint = root.querySelector('.selhint'); + const cardEl = root.querySelector('.card'); + const cardTitle = cardEl.querySelector('h4'); + const cardSnippet = cardEl.querySelector('.snippet'); + const cardText = cardEl.querySelector('textarea'); + + // --- selectors & context --------------------------------------------- + const esc = v => (window.CSS && CSS.escape) ? CSS.escape(v) : v.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); + function selectorFor(el) { + const parts = []; + let node = el; + for (let depth = 0; node && node.nodeType === 1 && depth < 6; depth++) { + if (node.id) { parts.unshift('#' + esc(node.id)); return parts.join(' > '); } + const tag = node.tagName.toLowerCase(); + if (tag === 'body' || tag === 'html') { parts.unshift(tag); break; } + let nth = 1; + let sib = node; + while ((sib = sib.previousElementSibling)) if (sib.tagName === node.tagName) nth++; + parts.unshift(tag + ':nth-of-type(' + nth + ')'); + node = node.parentElement; + } + return parts.join(' > '); + } + function snippetFor(el) { + return (el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 200); + } + const INTERACTIVE = new Set(['button', 'input', 'select', 'textarea', 'option', 'label', 'summary', 'a']); + function isInteractive(el) { + let node = el; + while (node && node.nodeType === 1) { + if (INTERACTIVE.has(node.tagName.toLowerCase()) || node.isContentEditable) return true; + node = node.parentElement; + } + return false; + } + const isOurs = el => el === host || host.contains(el); + + // --- annotation card --------------------------------------------------- + function openCard(target) { + card = target; + cardTitle.textContent = target.kindLabel; + cardSnippet.textContent = target.anchor.snippet || target.anchor.selector; + cardText.value = ''; + cardEl.style.display = 'block'; + const x = Math.min(target.x, window.innerWidth - 320) + window.scrollX; + const y = target.y + 12 + window.scrollY; + cardEl.style.left = Math.max(8, x) + 'px'; + cardEl.style.top = y + 'px'; + cardText.focus(); + } + function closeCard() { + card = null; + cardEl.style.display = 'none'; + } + function queueCard(sendNow) { + if (!card) return; + const text = cardText.value.trim(); + if (!text) { cardText.focus(); return; } + post({ + type: sendNow ? 'pc:queue-and-send' : 'pc:queue', + item: { kind: 'annotation', text, anchor: card.anchor } + }); + closeCard(); + } + cardEl.querySelector('.cancel').addEventListener('click', closeCard); + cardEl.querySelector('.queue').addEventListener('click', () => queueCard(false)); + cardText.addEventListener('keydown', e => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); queueCard(true); } + else if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); queueCard(false); } + else if (e.key === 'Escape') closeCard(); + }); + + // --- element hover / click --------------------------------------------- + document.addEventListener('mousemove', e => { + if (!annotate || card) { hl.style.display = 'none'; return; } + const el = e.target; + if (!el || isOurs(el) || el === document.body || el === document.documentElement || isInteractive(el)) { + hl.style.display = 'none'; + return; + } + const rect = el.getBoundingClientRect(); + hl.style.display = 'block'; + hl.style.left = rect.left - 2 + 'px'; + hl.style.top = rect.top - 2 + 'px'; + hl.style.width = rect.width + 'px'; + hl.style.height = rect.height + 'px'; + }, true); + + document.addEventListener('click', e => { + if (!annotate) return; + const el = e.target; + if (isOurs(el)) return; + if (card) { if (!cardEl.contains(e.composedPath()[0])) closeCard(); return; } + if (isInteractive(el)) return; // let controls behave natively + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; // handled by selection flow + if (el === document.body || el === document.documentElement) return; + e.preventDefault(); + e.stopPropagation(); + hl.style.display = 'none'; + openCard({ + kindLabel: 'Annotate <' + el.tagName.toLowerCase() + '>', + anchor: { selector: selectorFor(el), tag: el.tagName.toLowerCase(), snippet: snippetFor(el) }, + x: e.clientX, + y: e.clientY + }); + }, true); + + // --- text selection ------------------------------------------------------- + document.addEventListener('mouseup', e => { + if (!annotate || card || isOurs(e.target)) return; + setTimeout(() => { + const selection = window.getSelection(); + const text = selection ? String(selection).replace(/\\s+/g, ' ').trim() : ''; + if (!text || !selection.rangeCount) { selhint.style.display = 'none'; return; } + const rect = selection.getRangeAt(0).getBoundingClientRect(); + selhint.style.display = 'block'; + selhint.style.left = rect.left + window.scrollX + 'px'; + selhint.style.top = rect.bottom + 6 + window.scrollY + 'px'; + selhint.onclick = () => { + selhint.style.display = 'none'; + const anchorNode = selection.anchorNode; + const el = anchorNode && anchorNode.nodeType === 1 ? anchorNode : anchorNode && anchorNode.parentElement; + openCard({ + kindLabel: 'Annotate selection', + anchor: { + selector: el ? selectorFor(el) : 'body', + tag: 'text', + snippet: text.slice(0, 200), + textRange: { text: text.slice(0, 1000) } + }, + x: rect.left, + y: rect.bottom + }); + }; + }, 0); + }, true); + document.addEventListener('selectionchange', () => { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) selhint.style.display = 'none'; + }); + + // --- chrome bridge --------------------------------------------------------- + window.addEventListener('message', e => { + const msg = e.data || {}; + if (msg.type === 'pc:set-mode') { + annotate = Boolean(msg.annotate); + if (!annotate) { hl.style.display = 'none'; selhint.style.display = 'none'; closeCard(); } + } else if (msg.type === 'pc:restore-scroll') { + window.scrollTo(msg.x || 0, msg.y || 0); + } + }); + document.addEventListener('keydown', e => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') { + e.preventDefault(); + post({ type: 'pc:toggle-mode' }); + } else if (e.key === 'Escape' && card) closeCard(); + }, true); + + let scrollTimer = null; + window.addEventListener('scroll', () => { + if (scrollTimer) return; + scrollTimer = setTimeout(() => { + scrollTimer = null; + post({ type: 'pc:scroll', x: window.scrollX, y: window.scrollY }); + }, 150); + }, { passive: true }); + + post({ type: 'pc:ready' }); +})();`; +} + +module.exports = { artifactSdkJs }; diff --git a/scripts/lib/plan-canvas/server.js b/scripts/lib/plan-canvas/server.js new file mode 100644 index 000000000..28e7c2b70 --- /dev/null +++ b/scripts/lib/plan-canvas/server.js @@ -0,0 +1,532 @@ +'use strict'; + +/** + * Plan Canvas loopback server. + * + * One detached process serves every open review session: the browser chrome, + * the rendered artifact, an SSE stream for live updates, and the long-poll + * endpoint agents block on. Sessions are keyed by canonical artifact path + * (see sessions.js). + */ + +const { EventEmitter } = require('events'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); + +const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard'); +const { renderMarkdown } = require('./markdown'); +const { artifactSdkJs } = require('./sdk'); +const { + canvasCss, + canvasClientJs, + renderCanvasHtml, + renderMarkdownArtifactHtml, + renderSessionListHtml +} = require('./ui'); + +const DEFAULT_PORT = 4517; +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000; +const MAX_BODY_BYTES = 1024 * 1024; + +const CONTENT_TYPES = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.md': 'text/plain; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.ttf': 'font/ttf', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2' +}; + +function resolvePort(env = process.env) { + const value = Number.parseInt(env.ECC_PLAN_CANVAS_PORT || '', 10); + return Number.isInteger(value) && value >= 0 && value <= 65535 ? value : DEFAULT_PORT; +} + +function resolveIdleTimeoutMs(env = process.env) { + const raw = String(env.ECC_PLAN_CANVAS_IDLE_MS || '').trim().toLowerCase(); + if (raw === '0' || raw === 'off') return 0; + const value = Number.parseInt(raw, 10); + return Number.isInteger(value) && value > 0 ? value : DEFAULT_IDLE_TIMEOUT_MS; +} + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + req.on('data', chunk => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new Error('body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (chunks.length === 0) return resolve({}); + try { + resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + reject(new Error('invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res, statusCode, payload) { + const body = JSON.stringify(payload); + res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + res.end(body); +} + +function sendHtml(res, statusCode, html, { csp = true } = {}) { + const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }; + if (csp) { + headers['content-security-policy'] = + "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'"; + } + res.writeHead(statusCode, headers); + res.end(html); +} + +function createPlanCanvasServer({ + store, + host = DEFAULT_HOST, + version = '0.0.0', + idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, + heartbeatMs = 15000, + onIdleShutdown = null, + log = () => {} +} = {}) { + if (!store) throw new Error('createPlanCanvasServer requires a session store'); + + const allowedHostnames = buildAllowedHostnames(host); + const wake = new EventEmitter(); + wake.setMaxListeners(0); + const sseClients = new Map(); // key -> Set + const awaitCounts = new Map(); // key -> active long-poll count + const workingKeys = new Set(); // keys whose agent took feedback and is off working + const watchers = new Map(); // key -> fs.FSWatcher + let idleTimer = null; + let closed = false; + + // --- presence + SSE --------------------------------------------------- + + function presenceFor(key) { + const session = store.get(key); + if (!session || session.status === 'ended') return 'ended'; + if ((awaitCounts.get(key) || 0) > 0) return 'listening'; + return workingKeys.has(key) ? 'working' : 'waiting'; + } + + function broadcast(key, event, payload) { + const clients = sseClients.get(key); + if (!clients) return; + const frameText = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + for (const client of clients) client.write(frameText); + } + + function broadcastPresence(key) { + broadcast(key, 'presence', { state: presenceFor(key) }); + } + + function connectionCount() { + let total = 0; + for (const clients of sseClients.values()) total += clients.size; + for (const count of awaitCounts.values()) total += count; + return total; + } + + function armIdleTimer() { + if (!idleTimeoutMs || closed) return; + if (connectionCount() > 0) return; + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + if (connectionCount() === 0 && !closed) { + log('[plan-canvas] idle timeout reached, shutting down'); + if (onIdleShutdown) onIdleShutdown(); + } + }, idleTimeoutMs); + if (idleTimer.unref) idleTimer.unref(); + } + + function noteConnectionOpened() { + clearTimeout(idleTimer); + } + + function noteConnectionClosed() { + armIdleTimer(); + } + + // --- artifact watching -------------------------------------------------- + + function watchSession(session) { + if (watchers.has(session.key)) return; + const dir = path.dirname(session.file); + const base = path.basename(session.file); + let debounce = null; + try { + const watcher = fs.watch(dir, (eventType, filename) => { + if (filename && filename !== base) return; + clearTimeout(debounce); + debounce = setTimeout(() => broadcast(session.key, 'reload', {}), 150); + }); + watcher.on('error', () => watchers.delete(session.key)); + watchers.set(session.key, watcher); + } catch { + // Watching is best-effort; manual reload still works. + } + } + + function unwatchSession(key) { + const watcher = watchers.get(key); + if (watcher) { + watcher.close(); + watchers.delete(key); + } + } + + // --- session actions ------------------------------------------------------ + + function endSession(key, endedBy) { + const session = store.end(key, endedBy); + if (!session) return null; + wake.emit(`wake:${key}`); + broadcast(key, 'ended', { endedBy: session.endedBy }); + broadcastPresence(key); + unwatchSession(key); + return session; + } + + // --- request handlers ------------------------------------------------------- + + async function handleApi(req, res, url) { + const { pathname } = url; + + if (req.method === 'POST' && pathname === '/api/sessions') { + const body = await readJsonBody(req); + if (!body.file || typeof body.file !== 'string') { + return sendJson(res, 400, { error: 'file is required' }); + } + if (!fs.existsSync(path.resolve(body.file))) { + return sendJson(res, 404, { error: `artifact not found: ${body.file}` }); + } + const { session, refused } = store.open(body.file, { reopen: Boolean(body.reopen) }); + if (refused) { + return sendJson(res, 409, { + status: 'user-ended', + key: session.key, + next_step: 'The user ended this review from the browser. Do not reopen it unless they ask; pass reopen:true when they do.' + }); + } + watchSession(session); + broadcastPresence(session.key); + return sendJson(res, 200, { + status: 'open', + key: session.key, + file: session.file, + url: `/canvas/${session.key}` + }); + } + + if (req.method === 'GET' && pathname === '/api/sessions') { + return sendJson(res, 200, { sessions: store.list() }); + } + + if (req.method === 'GET' && pathname === '/api/await') { + const file = url.searchParams.get('file'); + if (!file) return sendJson(res, 400, { error: 'file query parameter is required' }); + const session = store.findByFile(file); + if (!session) return sendJson(res, 200, { status: 'missing' }); + const key = session.key; + const timeoutRaw = url.searchParams.get('timeoutMs'); + const timeoutMs = timeoutRaw === null ? null : Math.max(0, Number.parseInt(timeoutRaw, 10) || 0); + + const first = store.takeFeedback(key); + if (first.status !== 'waiting') { + if (first.status === 'feedback') workingKeys.add(key); + broadcastPresence(key); + return sendJson(res, 200, first); + } + + // Long poll: hold the request open until feedback or session end. + noteConnectionOpened(); + awaitCounts.set(key, (awaitCounts.get(key) || 0) + 1); + workingKeys.delete(key); + broadcastPresence(key); + + let settled = false; + let heartbeat = null; + let waitTimer = null; + const finish = payload => { + if (settled) return; + settled = true; + cleanup(); + if (payload) { + if (payload.status === 'feedback') workingKeys.add(key); + res.end(JSON.stringify(payload)); + } + broadcastPresence(key); + noteConnectionClosed(); + }; + const onWake = () => { + const result = store.takeFeedback(key); + if (result.status !== 'waiting') finish(result); + }; + // Settle held polls on shutdown so server.close() can complete; the + // CLI tells agents to simply re-run await. + const onServerClose = () => + finish({ status: 'waiting', note: 'canvas server is shutting down; re-run await' }); + const cleanup = () => { + wake.removeListener(`wake:${key}`, onWake); + wake.removeListener('server-close', onServerClose); + clearInterval(heartbeat); + clearTimeout(waitTimer); + awaitCounts.set(key, Math.max(0, (awaitCounts.get(key) || 1) - 1)); + }; + + res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); + // Leading whitespace keeps the connection visibly alive without + // corrupting the JSON payload written at the end. + res.write(' '); + heartbeat = setInterval(() => { + if (!settled) res.write(' '); + }, heartbeatMs); + if (timeoutMs !== null) { + waitTimer = setTimeout(() => finish({ status: 'waiting' }), timeoutMs); + } + wake.on(`wake:${key}`, onWake); + wake.once('server-close', onServerClose); + req.on('close', () => finish(null)); + return undefined; + } + + if (req.method === 'POST' && pathname === '/api/end') { + const body = await readJsonBody(req); + if (!body.file || typeof body.file !== 'string') { + return sendJson(res, 400, { error: 'file is required' }); + } + const session = store.findByFile(body.file); + if (!session) return sendJson(res, 404, { error: 'no session for that file' }); + endSession(session.key, 'agent'); + return sendJson(res, 200, { status: 'ended', endedBy: 'agent' }); + } + + const sessionMatch = pathname.match(/^\/api\/session\/([a-f0-9]{12})\/(feedback|end|reply)$/); + if (sessionMatch && req.method === 'POST') { + const [, key, action] = sessionMatch; + const session = store.get(key); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + + if (action === 'feedback') { + const body = await readJsonBody(req); + const result = store.queueFeedback(key, body.items, { endSession: Boolean(body.endSession) }); + if (!result) return sendJson(res, 409, { error: 'session already ended' }); + wake.emit(`wake:${key}`); + broadcast(key, 'chat-sync', { chat: store.get(key).chat }); + if (body.endSession) broadcast(key, 'ended', { endedBy: 'user' }); + return sendJson(res, 200, { status: 'queued', accepted: result.accepted.length, pending: result.pending }); + } + + if (action === 'end') { + endSession(key, 'user'); + return sendJson(res, 200, { status: 'ended', endedBy: 'user' }); + } + + if (action === 'reply') { + const body = await readJsonBody(req); + if (!body.text || typeof body.text !== 'string') { + return sendJson(res, 400, { error: 'text is required' }); + } + const entry = store.addAgentReply(key, body.text); + broadcast(key, 'chat-sync', { chat: store.get(key).chat }); + return sendJson(res, 200, { status: 'sent', at: entry.at }); + } + } + + return sendJson(res, 404, { error: 'not found' }); + } + + function handleEvents(req, res, key) { + const session = store.get(key); + if (!session) return sendJson(res, 404, { error: 'unknown session' }); + noteConnectionOpened(); + res.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + connection: 'keep-alive' + }); + res.write(`event: chat-sync\ndata: ${JSON.stringify({ chat: session.chat })}\n\n`); + res.write(`event: presence\ndata: ${JSON.stringify({ state: presenceFor(key) })}\n\n`); + if (!sseClients.has(key)) sseClients.set(key, new Set()); + sseClients.get(key).add(res); + const ping = setInterval(() => res.write(': ping\n\n'), 25000); + if (ping.unref) ping.unref(); + req.on('close', () => { + clearInterval(ping); + const clients = sseClients.get(key); + if (clients) { + clients.delete(res); + if (clients.size === 0) sseClients.delete(key); + } + noteConnectionClosed(); + }); + } + + function serveArtifact(res, key, assetPath) { + const session = store.get(key); + if (!session) return sendHtml(res, 404, '

Unknown session

'); + + if (!assetPath) { + let content; + try { + content = fs.readFileSync(session.file, 'utf8'); + } catch { + return sendHtml(res, 404, `

Artifact missing

${session.file} no longer exists.

`, { csp: false }); + } + const ext = path.extname(session.file).toLowerCase(); + if (ext === '.md' || ext === '.markdown') { + const html = renderMarkdownArtifactHtml(renderMarkdown(content), { + title: path.basename(session.file), + sdkSrc: '/sdk.js' + }); + return sendHtml(res, 200, html, { csp: false }); + } + const sdkTag = ''; + const injected = content.includes('') + ? content.replace('', `${sdkTag}\n`) + : `${content}\n${sdkTag}`; + return sendHtml(res, 200, injected, { csp: false }); + } + + // Sibling assets resolve relative to the artifact's directory and must + // stay confined to it. + const baseDir = path.dirname(session.file); + const resolved = path.resolve(baseDir, assetPath); + if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) { + return sendJson(res, 403, { error: 'asset path escapes artifact directory' }); + } + let data; + try { + data = fs.readFileSync(resolved); + } catch { + return sendJson(res, 404, { error: 'asset not found' }); + } + const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream'; + res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' }); + return res.end(data); + } + + const server = http.createServer((req, res) => { + if (!isAllowedHostHeader(req.headers.host, allowedHostnames)) { + return sendJson(res, 403, { error: 'forbidden host header' }); + } + if (!isAllowedOrigin(req.headers.origin, allowedHostnames)) { + return sendJson(res, 403, { error: 'forbidden origin' }); + } + const url = new URL(req.url, `http://${req.headers.host}`); + const { pathname } = url; + + Promise.resolve() + .then(() => { + if (req.method === 'GET' && pathname === '/health') { + return sendJson(res, 200, { ok: true, app: 'ecc-plan-canvas', version }); + } + if (req.method === 'POST' && pathname === '/shutdown') { + sendJson(res, 200, { status: 'stopping' }); + setImmediate(() => { + if (onIdleShutdown) onIdleShutdown(); + }); + return undefined; + } + if (req.method === 'GET' && pathname === '/') { + return sendHtml(res, 200, renderSessionListHtml(store.list())); + } + if (req.method === 'GET' && pathname === '/canvas.css') { + res.writeHead(200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(canvasCss()); + } + if (req.method === 'GET' && pathname === '/client.js') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(canvasClientJs()); + } + if (req.method === 'GET' && pathname === '/sdk.js') { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'no-store' }); + return res.end(artifactSdkJs()); + } + const canvasMatch = pathname.match(/^\/canvas\/([a-f0-9]{12})$/); + if (req.method === 'GET' && canvasMatch) { + const session = store.get(canvasMatch[1]); + if (!session) return sendHtml(res, 404, '

Unknown session

'); + return sendHtml(res, 200, renderCanvasHtml(session)); + } + const eventsMatch = pathname.match(/^\/events\/([a-f0-9]{12})$/); + if (req.method === 'GET' && eventsMatch) { + return handleEvents(req, res, eventsMatch[1]); + } + const artifactMatch = pathname.match(/^\/artifact\/([a-f0-9]{12})\/(.*)$/); + if (req.method === 'GET' && artifactMatch) { + const assetPath = decodeURIComponent(artifactMatch[2]); + return serveArtifact(res, artifactMatch[1], assetPath || null); + } + if (pathname.startsWith('/api/')) { + return handleApi(req, res, url); + } + return sendJson(res, 404, { error: 'not found' }); + }) + .catch(error => { + if (!res.headersSent) sendJson(res, 400, { error: error.message }); + else res.end(); + }); + }); + + function close() { + closed = true; + clearTimeout(idleTimer); + for (const key of watchers.keys()) unwatchSession(key); + for (const clients of sseClients.values()) { + for (const client of clients) client.end(); + } + sseClients.clear(); + wake.emit('server-close'); + return new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + // Browser keep-alive sockets would otherwise hold close() open. + if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); + }); + } + + function listen(port = resolvePort()) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + armIdleTimer(); + resolve({ port: server.address().port, host }); + }); + }); + } + + return { server, listen, close, presenceFor, watchSession }; +} + +module.exports = { + DEFAULT_HOST, + DEFAULT_PORT, + createPlanCanvasServer, + resolveIdleTimeoutMs, + resolvePort +}; diff --git a/scripts/lib/plan-canvas/sessions.js b/scripts/lib/plan-canvas/sessions.js new file mode 100644 index 000000000..799cf4b36 --- /dev/null +++ b/scripts/lib/plan-canvas/sessions.js @@ -0,0 +1,269 @@ +'use strict'; + +/** + * Plan Canvas session store. + * + * Sessions are keyed by the canonical artifact file path so agents never + * juggle opaque ids. State is persisted as JSON in the Plan Canvas state + * dir so queued human feedback survives a server restart. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const FEEDBACK_KINDS = new Set(['chat', 'annotation', 'verdict']); +const VERDICTS = new Set(['approve', 'request-changes']); + +function resolveStateDir(env = process.env) { + const override = env.ECC_PLAN_CANVAS_STATE_DIR; + if (override && String(override).trim()) return path.resolve(String(override).trim()); + return path.join(os.homedir(), '.claude', 'plan-canvas'); +} + +// Canonicalize so `./plan.md`, symlinks, and absolute paths all land on the +// same session. +function canonicalizeArtifactPath(filePath) { + const absolute = path.resolve(filePath); + try { + return fs.realpathSync(absolute); + } catch { + return absolute; + } +} + +function sessionKeyFor(canonicalPath) { + return crypto.createHash('sha256').update(canonicalPath).digest('hex').slice(0, 12); +} + +function nowIso() { + return new Date().toISOString(); +} + +function sanitizeText(value, maxLength = 4000) { + if (typeof value !== 'string') return ''; + return value.slice(0, maxLength); +} + +// Normalize one browser-submitted feedback item into the shape delivered to +// the agent. Returns null for unusable input rather than throwing so a +// malformed item can never wedge the queue. +function normalizeFeedbackItem(raw, counter) { + if (!raw || typeof raw !== 'object') return null; + const kind = FEEDBACK_KINDS.has(raw.kind) ? raw.kind : null; + if (!kind) return null; + const item = { + id: `fb-${counter}`, + kind, + text: sanitizeText(raw.text), + at: nowIso() + }; + if (kind === 'verdict') { + if (!VERDICTS.has(raw.verdict)) return null; + item.verdict = raw.verdict; + } + if (kind === 'annotation') { + const anchor = raw.anchor && typeof raw.anchor === 'object' ? raw.anchor : null; + if (!anchor || typeof anchor.selector !== 'string') return null; + item.anchor = { + selector: sanitizeText(anchor.selector, 500), + tag: sanitizeText(anchor.tag, 60), + snippet: sanitizeText(anchor.snippet, 400) + }; + if (anchor.textRange && typeof anchor.textRange === 'object') { + item.anchor.textRange = { + text: sanitizeText(anchor.textRange.text, 1000) + }; + } + if (!item.text) return null; + } + if (kind === 'chat' && !item.text) return null; + return item; +} + +function createSessionStore({ stateDir = resolveStateDir() } = {}) { + const stateFile = path.join(stateDir, 'sessions.json'); + let state = { sessions: {}, feedbackCounter: 0 }; + + function load() { + try { + const parsed = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.sessions) { + state = { + sessions: parsed.sessions, + feedbackCounter: Number(parsed.feedbackCounter) || 0 + }; + } + } catch { + // Missing or corrupt state starts fresh; queued feedback loss on a + // corrupt file beats refusing to start at all. + } + } + + function persist() { + fs.mkdirSync(stateDir, { recursive: true }); + const tmpFile = `${stateFile}.tmp`; + fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2)); + fs.renameSync(tmpFile, stateFile); + } + + load(); + + function get(key) { + return state.sessions[key] || null; + } + + function findByFile(filePath) { + const canonical = canonicalizeArtifactPath(filePath); + return get(sessionKeyFor(canonical)); + } + + // Open (or resume) a session. A session the *user* ended from the browser + // is sticky: it refuses a plain reopen so agents do not pop the browser + // back up uninvited. Pass reopen:true only when the human asked. + function open(filePath, { reopen = false } = {}) { + const canonical = canonicalizeArtifactPath(filePath); + const key = sessionKeyFor(canonical); + const existing = state.sessions[key]; + if (existing && existing.status === 'ended' && existing.endedBy === 'user' && !reopen) { + return { session: existing, refused: true }; + } + const session = existing || { + key, + file: canonical, + chat: [], + pendingFeedback: [], + createdAt: nowIso() + }; + session.status = 'open'; + delete session.endedBy; + session.updatedAt = nowIso(); + state.sessions[key] = session; + persist(); + return { session, refused: false }; + } + + // Queue feedback from the browser. Chat-shaped items are mirrored into the + // session transcript immediately so the conversation panel stays coherent + // across reloads. + function queueFeedback(key, rawItems, { endSession = false } = {}) { + const session = get(key); + if (!session || session.status === 'ended') return null; + const accepted = []; + for (const raw of Array.isArray(rawItems) ? rawItems : []) { + state.feedbackCounter += 1; + const item = normalizeFeedbackItem(raw, state.feedbackCounter); + if (item) accepted.push(item); + } + session.pendingFeedback.push(...accepted); + for (const item of accepted) { + session.chat.push({ role: 'user', kind: item.kind, text: chatLineFor(item), at: item.at }); + } + if (endSession) { + session.status = 'ended'; + session.endedBy = 'user'; + } else if (accepted.length > 0) { + session.status = 'feedback'; + } + session.updatedAt = nowIso(); + persist(); + return { accepted, pending: session.pendingFeedback.length, session }; + } + + // Deliver-and-drain: feedback is handed to exactly one await call, after + // which the session flips back to open. An ended session keeps reporting + // ended (with attribution) so agents know to stop polling. + function takeFeedback(key) { + const session = get(key); + if (!session) return { status: 'missing' }; + if (session.pendingFeedback.length > 0) { + const items = session.pendingFeedback; + session.pendingFeedback = []; + const result = { status: 'feedback', items }; + if (session.status === 'ended') { + result.sessionEnded = true; + result.endedBy = session.endedBy; + } else { + session.status = 'open'; + } + session.updatedAt = nowIso(); + persist(); + return result; + } + if (session.status === 'ended') { + return { status: 'ended', endedBy: session.endedBy }; + } + return { status: 'waiting' }; + } + + function addAgentReply(key, text) { + const session = get(key); + if (!session) return null; + const entry = { role: 'agent', kind: 'chat', text: sanitizeText(text), at: nowIso() }; + session.chat.push(entry); + session.updatedAt = nowIso(); + persist(); + return entry; + } + + function end(key, endedBy) { + const session = get(key); + if (!session) return null; + session.status = 'ended'; + session.endedBy = endedBy === 'user' ? 'user' : 'agent'; + session.updatedAt = nowIso(); + persist(); + return session; + } + + function list() { + return Object.values(state.sessions).map(session => ({ + key: session.key, + file: session.file, + status: session.status, + endedBy: session.endedBy, + pending: session.pendingFeedback.length, + updatedAt: session.updatedAt + })); + } + + function hasOpenSessions() { + return Object.values(state.sessions).some(session => session.status !== 'ended'); + } + + return { + stateDir, + stateFile, + open, + get, + findByFile, + queueFeedback, + takeFeedback, + addAgentReply, + end, + list, + hasOpenSessions + }; +} + +// One-line rendering of a feedback item for the conversation transcript. +function chatLineFor(item) { + if (item.kind === 'verdict') { + const label = item.verdict === 'approve' ? 'Approved the plan' : 'Requested changes'; + return item.text ? `${label}: ${item.text}` : label; + } + if (item.kind === 'annotation') { + const where = item.anchor.snippet || item.anchor.selector; + return `[${where}] ${item.text}`; + } + return item.text; +} + +module.exports = { + canonicalizeArtifactPath, + createSessionStore, + normalizeFeedbackItem, + resolveStateDir, + sessionKeyFor +}; diff --git a/scripts/lib/plan-canvas/ui.js b/scripts/lib/plan-canvas/ui.js new file mode 100644 index 000000000..0432282f6 --- /dev/null +++ b/scripts/lib/plan-canvas/ui.js @@ -0,0 +1,542 @@ +'use strict'; + +/** + * Plan Canvas browser chrome: the editor shell that frames an artifact, + * plus the rendered-markdown artifact template. + * + * Visual language mirrors the ECC web dashboard (scripts/dashboard-web.js): + * same design tokens, dark-first with a light theme, accent→pink brand + * gradient. Everything is served inline — no CDNs, no external assets. + */ + +const path = require('path'); + +const { escapeHtml } = require('./markdown'); + +// Pinned Mermaid ESM build, loaded in the browser only when an artifact +// actually contains a diagram. Override with a local/vendored URL (e.g. an +// air-gapped mirror) via ECC_PLAN_CANVAS_MERMAID_URL. If the fetch fails, the +// diagram source stays visible as a styled code block — nothing breaks. +const DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11.4.1/dist/mermaid.esm.min.mjs'; + +function mermaidUrl(env = process.env) { + const override = env.ECC_PLAN_CANVAS_MERMAID_URL; + return override && String(override).trim() ? String(override).trim() : DEFAULT_MERMAID_URL; +} + +// Browser module that renders `
` blocks, themed to match
+// the ECC canvas. Kept import-only so a CDN failure degrades gracefully.
+function mermaidLoaderScript(url) {
+  return ``;
+}
+
+// Design tokens shared by the chrome and the markdown artifact template.
+const TOKENS_CSS = `
+  :root{
+    --bg:#080a0e; --bg2:#0d0f14; --bg3:#13161e; --bg4:#191d2a;
+    --surface:#101218; --surface-hover:#171a24; --border:#1d2130; --border-light:#272c3e;
+    --text:#dfe2e9; --text2:#80859a; --text3:#4c5168;
+    --accent:#6885e8; --accent-glow:rgba(104,133,232,0.15); --accent-dim:#3d5ab8;
+    --green:#4acb8a; --green-glow:rgba(74,203,138,0.15);
+    --orange:#eca85a; --orange-glow:rgba(236,168,90,0.15);
+    --pink:#e26a9e; --pink-glow:rgba(226,106,158,0.15);
+    --red:#e86060; --red-glow:rgba(232,96,96,0.15);
+    --teal:#4acbbe; --teal-glow:rgba(74,203,190,0.15);
+    --radius:8px; --radius-sm:5px;
+    --font:-apple-system,BlinkMacSystemFont,'SF Pro Display','Inter','Segoe UI',Roboto,sans-serif;
+    --mono:'SF Mono','Fira Code','JetBrains Mono','Cascadia Code',monospace;
+    --shadow:0 1px 2px rgba(0,0,0,0.4);
+    --shadow-lg:0 8px 32px rgba(0,0,0,0.6);
+  }
+  [data-theme="light"]{
+    --bg:#f4f5f7; --bg2:#ffffff; --bg3:#eaecef; --bg4:#dfe2e6;
+    --surface:#ffffff; --surface-hover:#f4f5f7; --border:#cdd1d9; --border-light:#dde1e8;
+    --text:#181b23; --text2:#585e6e; --text3:#9197a8;
+    --accent:#4560d0; --accent-glow:rgba(69,96,208,0.08); --accent-dim:#2f44a0;
+    --green:#16a34a; --green-glow:rgba(22,163,74,0.08);
+    --orange:#d97706; --orange-glow:rgba(217,119,6,0.08);
+    --pink:#c73877; --pink-glow:rgba(199,56,119,0.08);
+    --red:#dc2626; --red-glow:rgba(220,38,38,0.08);
+    --teal:#0d9488; --teal-glow:rgba(13,148,136,0.08);
+    --shadow:0 1px 2px rgba(0,0,0,0.04);
+    --shadow-lg:0 8px 32px rgba(0,0,0,0.08);
+  }
+`;
+
+function canvasCss() {
+  return `${TOKENS_CSS}
+  *{margin:0;padding:0;box-sizing:border-box}
+  html,body{height:100%}
+  body{font-family:var(--font);background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;line-height:1.4;overflow:hidden}
+  ::selection{background:var(--accent);color:#fff}
+  ::-webkit-scrollbar{width:8px;height:8px}
+  ::-webkit-scrollbar-track{background:transparent}
+  ::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px}
+  button{font-family:var(--font)}
+
+  .bar{display:flex;align-items:center;gap:12px;height:52px;padding:0 16px;background:color-mix(in srgb,var(--bg2) 88%,transparent);border-bottom:1px solid var(--border);backdrop-filter:blur(16px)}
+  .brand{display:flex;align-items:center;gap:9px;min-width:0}
+  .brand .logo{width:26px;height:26px;flex:none;background:linear-gradient(135deg,var(--accent),var(--pink));border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff}
+  .brand .name{font-size:13.5px;font-weight:600;white-space:nowrap}
+  .brand .file{font-size:11.5px;color:var(--text2);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:34vw}
+  .bar .spacer{flex:1}
+
+  .presence{display:flex;align-items:center;gap:6px;font-size:11px;font-weight:500;color:var(--text2);background:var(--bg3);border:1px solid var(--border);border-radius:99px;padding:3px 10px 3px 8px;white-space:nowrap}
+  .presence .dot{width:7px;height:7px;border-radius:99px;background:var(--text3)}
+  .presence[data-state="listening"] .dot{background:var(--green);box-shadow:0 0 0 3px var(--green-glow);animation:pulse 2s infinite}
+  .presence[data-state="working"] .dot{background:var(--orange);box-shadow:0 0 0 3px var(--orange-glow)}
+  @keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}
+
+  .toggle{display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--text2);cursor:pointer;user-select:none}
+  .toggle .track{width:30px;height:17px;border-radius:99px;background:var(--bg4);border:1px solid var(--border);position:relative;transition:background .15s}
+  .toggle .knob{position:absolute;top:1px;left:1px;width:13px;height:13px;border-radius:99px;background:var(--text2);transition:transform .15s,background .15s}
+  .toggle[aria-pressed="true"] .track{background:var(--accent);border-color:var(--accent-dim)}
+  .toggle[aria-pressed="true"] .knob{transform:translateX(13px);background:#fff}
+
+  .icon-btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg3);color:var(--text2);cursor:pointer;font-size:11.5px;display:flex;align-items:center;gap:5px;transition:all .12s}
+  .icon-btn:hover{border-color:var(--border-light);color:var(--text);background:var(--bg4)}
+  .icon-btn.danger:hover{border-color:var(--red);color:var(--red);background:var(--red-glow)}
+
+  .layout{display:flex;height:calc(100% - 52px)}
+  .frame{flex:1;min-width:0;position:relative;background:var(--bg2)}
+  .frame iframe{width:100%;height:100%;border:0;background:#fff}
+  [data-theme] .frame iframe{background:var(--bg2)}
+
+  .panel{width:340px;flex:none;display:flex;flex-direction:column;border-left:1px solid var(--border);background:var(--bg2)}
+  .panel h2{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:12px 14px 8px}
+
+  .verdict{display:flex;gap:8px;padding:0 14px 12px;border-bottom:1px solid var(--border)}
+  .verdict button{flex:1;height:30px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;transition:all .12s}
+  .verdict .approve{border:1px solid var(--green);background:var(--green-glow);color:var(--green)}
+  .verdict .approve:hover{background:var(--green);color:#fff}
+  .verdict .changes{border:1px solid var(--orange);background:var(--orange-glow);color:var(--orange)}
+  .verdict .changes:hover{background:var(--orange);color:#fff}
+
+  .chat{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px}
+  .msg{max-width:92%;padding:7px 10px;border-radius:10px;font-size:12.5px;white-space:pre-wrap;word-break:break-word}
+  .msg.user{align-self:flex-end;background:var(--accent-glow);border:1px solid color-mix(in srgb,var(--accent) 35%,transparent);color:var(--text);border-bottom-right-radius:3px}
+  .msg.agent{align-self:flex-start;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-bottom-left-radius:3px}
+  .msg .meta{display:block;font-size:9.5px;color:var(--text3);margin-top:3px}
+  .msg.kind-annotation{border-left:2px solid var(--teal)}
+  .msg.kind-verdict{border-left:2px solid var(--green)}
+  .chat .empty{color:var(--text3);font-size:12px;text-align:center;margin-top:24px;line-height:1.6}
+
+  .queue{padding:8px 14px 0;display:flex;flex-direction:column;gap:6px;max-height:180px;overflow-y:auto}
+  .pill{display:flex;align-items:flex-start;gap:8px;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:6px 8px;font-size:11.5px}
+  .pill.kind-chat{border-left-color:var(--accent)}
+  .pill.kind-verdict{border-left-color:var(--green)}
+  .pill .where{color:var(--teal);font-family:var(--mono);font-size:10px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .pill .body{flex:1;min-width:0;color:var(--text2)}
+  .pill .txt{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
+  .pill button{border:none;background:none;color:var(--text3);cursor:pointer;font-size:13px;line-height:1;padding:1px}
+  .pill button:hover{color:var(--red)}
+
+  .composer{padding:10px 14px 14px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px}
+  .composer .hint{font-size:10px;color:var(--text3)}
+  .composer textarea{width:100%;min-height:60px;max-height:160px;resize:vertical;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12.5px;font-family:var(--font);outline:none;transition:all .15s}
+  .composer textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
+  .composer .row{display:flex;gap:8px;align-items:center}
+  .composer .send{flex:1;height:32px;border:none;border-radius:6px;background:var(--accent);color:#fff;font-size:12.5px;font-weight:600;cursor:pointer;transition:all .12s}
+  .composer .send:hover{background:var(--accent-dim)}
+  .composer .send:disabled{opacity:.5;cursor:default}
+  .composer .status{font-size:10.5px;color:var(--text3)}
+
+  .overlay{position:absolute;inset:0;display:none;align-items:center;justify-content:center;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:blur(6px);z-index:50}
+  .overlay.show{display:flex}
+  .overlay .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-lg);padding:26px 32px;text-align:center;max-width:340px}
+  .overlay .card h3{font-size:14px;margin-bottom:6px}
+  .overlay .card p{font-size:12px;color:var(--text2);line-height:1.5}
+  `;
+}
+
+// Client logic for the chrome page (runs in the top window).
+function canvasClientJs() {
+  return `'use strict';
+(() => {
+  const boot = JSON.parse(document.getElementById('pc-session').textContent);
+  const key = boot.key;
+  const $ = id => document.getElementById(id);
+  const frame = $('artifact');
+  const chatLog = $('chatLog');
+  const queueEl = $('queue');
+  const input = $('chatInput');
+  const sendBtn = $('send');
+  const statusEl = $('sendStatus');
+  const presence = $('presence');
+  const QKEY = 'ecc-plan-canvas:queue:' + key;
+  let queue = [];
+  let lastScroll = { x: 0, y: 0 };
+  let ended = boot.status === 'ended';
+  let sending = false;
+
+  try { queue = JSON.parse(sessionStorage.getItem(QKEY) || '[]'); } catch { queue = []; }
+
+  // --- theme ---------------------------------------------------------
+  const themeKey = 'ecc-plan-canvas:theme';
+  function applyTheme(t) {
+    if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
+    else document.documentElement.removeAttribute('data-theme');
+    $('themeBtn').textContent = t === 'light' ? '\\u263E dark' : '\\u2600 light';
+  }
+  let theme = localStorage.getItem(themeKey) || 'dark';
+  applyTheme(theme);
+  $('themeBtn').addEventListener('click', () => {
+    theme = theme === 'light' ? 'dark' : 'light';
+    localStorage.setItem(themeKey, theme);
+    applyTheme(theme);
+  });
+
+  // --- annotate mode -------------------------------------------------
+  let annotate = true;
+  function setAnnotate(on) {
+    annotate = on;
+    $('annotate').setAttribute('aria-pressed', String(on));
+    postToFrame({ type: 'pc:set-mode', annotate: on });
+  }
+  $('annotate').addEventListener('click', () => setAnnotate(!annotate));
+  document.addEventListener('keydown', e => {
+    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'i') {
+      e.preventDefault();
+      setAnnotate(!annotate);
+    }
+  }, true);
+
+  // --- iframe bridge --------------------------------------------------
+  function postToFrame(msg) {
+    if (frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
+  }
+  window.addEventListener('message', e => {
+    if (e.source !== frame.contentWindow) return;
+    const msg = e.data || {};
+    if (msg.type === 'pc:queue' && msg.item) addToQueue(msg.item);
+    else if (msg.type === 'pc:queue-and-send' && msg.item) { addToQueue(msg.item); send(); }
+    else if (msg.type === 'pc:scroll') lastScroll = { x: msg.x || 0, y: msg.y || 0 };
+    else if (msg.type === 'pc:toggle-mode') setAnnotate(!annotate);
+    else if (msg.type === 'pc:ready') {
+      postToFrame({ type: 'pc:set-mode', annotate });
+      postToFrame({ type: 'pc:restore-scroll', x: lastScroll.x, y: lastScroll.y });
+    }
+  });
+
+  // --- queue ----------------------------------------------------------
+  function persistQueue() { try { sessionStorage.setItem(QKEY, JSON.stringify(queue)); } catch { /* full */ } }
+  function addToQueue(item) { queue.push(item); persistQueue(); renderQueue(); }
+  function renderQueue() {
+    queueEl.innerHTML = '';
+    queue.forEach((item, i) => {
+      const pill = document.createElement('div');
+      pill.className = 'pill kind-' + item.kind;
+      const body = document.createElement('span');
+      body.className = 'body';
+      if (item.anchor) {
+        const where = document.createElement('span');
+        where.className = 'where';
+        where.textContent = item.anchor.snippet || item.anchor.selector;
+        body.appendChild(where);
+      }
+      const txt = document.createElement('span');
+      txt.className = 'txt';
+      txt.textContent = item.kind === 'verdict' ? (item.verdict === 'approve' ? 'Approve plan' : 'Request changes') + (item.text ? ': ' + item.text : '') : item.text;
+      body.appendChild(txt);
+      const rm = document.createElement('button');
+      rm.textContent = '\\u00D7';
+      rm.title = 'Remove';
+      rm.addEventListener('click', () => { queue.splice(i, 1); persistQueue(); renderQueue(); });
+      pill.append(body, rm);
+      queueEl.appendChild(pill);
+    });
+  }
+  renderQueue();
+
+  // --- chat -----------------------------------------------------------
+  function renderChat(entries) {
+    chatLog.innerHTML = '';
+    if (!entries.length) {
+      const empty = document.createElement('div');
+      empty.className = 'empty';
+      empty.textContent = 'Click anything in the plan to annotate it, or type below. Feedback goes straight to your agent.';
+      chatLog.appendChild(empty);
+      return;
+    }
+    for (const entry of entries) {
+      const div = document.createElement('div');
+      div.className = 'msg ' + (entry.role === 'agent' ? 'agent' : 'user') + ' kind-' + (entry.kind || 'chat');
+      div.textContent = entry.text;
+      const meta = document.createElement('span');
+      meta.className = 'meta';
+      meta.textContent = (entry.role === 'agent' ? 'agent' : 'you') + ' \\u00B7 ' + new Date(entry.at).toLocaleTimeString();
+      div.appendChild(meta);
+      chatLog.appendChild(div);
+    }
+    chatLog.scrollTop = chatLog.scrollHeight;
+  }
+  renderChat(boot.chat || []);
+
+  // --- send -----------------------------------------------------------
+  async function send(extraItems) {
+    if (ended || sending) return;
+    const items = queue.slice();
+    if (extraItems) items.push(...extraItems);
+    const text = input.value.trim();
+    if (text) items.push({ kind: 'chat', text });
+    if (!items.length) {
+      statusEl.textContent = 'Nothing to send yet - annotate the plan or type a message.';
+      return;
+    }
+    sending = true;
+    sendBtn.disabled = true;
+    statusEl.textContent = 'Sending\\u2026';
+    try {
+      const res = await fetch('/api/session/' + key + '/feedback', {
+        method: 'POST',
+        headers: { 'content-type': 'application/json' },
+        body: JSON.stringify({ items })
+      });
+      if (!res.ok) throw new Error('HTTP ' + res.status);
+      queue = [];
+      persistQueue();
+      renderQueue();
+      input.value = '';
+      statusEl.textContent = 'Sent. Your agent picks this up on its next check-in.';
+    } catch (err) {
+      statusEl.textContent = 'Send failed (' + err.message + ') - is the canvas server still running?';
+    } finally {
+      sending = false;
+      sendBtn.disabled = ended;
+    }
+  }
+  sendBtn.addEventListener('click', () => send());
+  input.addEventListener('keydown', e => {
+    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
+  });
+  $('approve').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'approve' }]));
+  $('changes').addEventListener('click', () => send([{ kind: 'verdict', verdict: 'request-changes' }]));
+
+  // --- session controls ------------------------------------------------
+  $('reloadBtn').addEventListener('click', reloadArtifact);
+  $('endBtn').addEventListener('click', async () => {
+    if (!window.confirm('End this review session?')) return;
+    try { await fetch('/api/session/' + key + '/end', { method: 'POST' }); } catch { /* server gone */ }
+  });
+  function reloadArtifact() {
+    const base = frame.getAttribute('data-artifact-src');
+    frame.src = base + '?t=' + Date.now();
+  }
+  function markEnded(endedBy) {
+    ended = true;
+    sendBtn.disabled = true;
+    input.disabled = true;
+    presence.setAttribute('data-state', 'ended');
+    presence.querySelector('.label').textContent = 'session ended';
+    $('endedOverlay').classList.add('show');
+    $('endedWho').textContent = endedBy === 'agent'
+      ? 'Your agent closed this review.'
+      : 'You ended this review. Head back to your agent session.';
+  }
+  if (ended) markEnded(boot.endedBy);
+
+  // --- server events ----------------------------------------------------
+  const PRESENCE_LABELS = { waiting: 'agent not connected', listening: 'agent listening', working: 'agent working\\u2026' };
+  function connectEvents() {
+    const es = new EventSource('/events/' + key);
+    es.addEventListener('chat-sync', e => renderChat(JSON.parse(e.data).chat || []));
+    es.addEventListener('presence', e => {
+      const state = JSON.parse(e.data).state;
+      if (ended) return;
+      presence.setAttribute('data-state', state);
+      presence.querySelector('.label').textContent = PRESENCE_LABELS[state] || state;
+    });
+    es.addEventListener('reload', reloadArtifact);
+    es.addEventListener('ended', e => { markEnded(JSON.parse(e.data).endedBy); es.close(); });
+    es.onerror = () => {
+      if (ended) return;
+      presence.setAttribute('data-state', 'waiting');
+      presence.querySelector('.label').textContent = 'canvas server offline';
+    };
+  }
+  connectEvents();
+})();`;
+}
+
+// The chrome page: header bar, artifact iframe, conversation rail.
+function renderCanvasHtml(session, { clientPath = '/client.js', cssPath = '/canvas.css' } = {}) {
+  const name = path.basename(session.file);
+  const bootstrap = JSON.stringify({
+    key: session.key,
+    file: session.file,
+    status: session.status,
+    endedBy: session.endedBy || null,
+    chat: session.chat
+  }).replace(/
+
+
+
+
+${escapeHtml(name)} · Plan Canvas
+
+
+
+
+
+
+
+ + Plan Canvas + ${escapeHtml(name)} +
+
+
agent not connected
+
+ Annotate +
+ + + +
+
+
+ +

Session ended

+
+ +
+ + +`; +} + +// ECC-styled document template for rendered markdown plan artifacts. +function renderMarkdownArtifactHtml(bodyHtml, { title, sdkSrc }) { + const hasMermaid = bodyHtml.includes('class="mermaid"'); + return ` + + + + +${escapeHtml(title)} + + + +
+${bodyHtml} +
+${hasMermaid ? mermaidLoaderScript(mermaidUrl()) : ''} + + +`; +} + +// Landing page listing sessions (GET /). +function renderSessionListHtml(sessions) { + const rows = sessions.map(s => { + const status = s.status === 'ended' ? `ended by ${escapeHtml(s.endedBy || 'agent')}` : s.status; + const link = s.status === 'ended' + ? escapeHtml(path.basename(s.file)) + : `${escapeHtml(path.basename(s.file))}`; + return `${link}${escapeHtml(s.file)}${status}`; + }).join('\n'); + return ` + + + +Plan Canvas · sessions + + + +

Plan Canvas sessions

+${sessions.length ? `${rows}
ArtifactPathStatus
` : '

No sessions yet. Ask your agent to open a plan with the plan-canvas skill.

'} + +`; +} + +module.exports = { + canvasCss, + canvasClientJs, + renderCanvasHtml, + renderMarkdownArtifactHtml, + renderSessionListHtml +}; diff --git a/scripts/plan-canvas.js b/scripts/plan-canvas.js new file mode 100755 index 000000000..3f1206817 --- /dev/null +++ b/scripts/plan-canvas.js @@ -0,0 +1,339 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Plan Canvas CLI — open plan artifacts in a browser review canvas and block + * on human feedback. + * + * node scripts/plan-canvas.js open .claude/plans/feature.plan.md + * node scripts/plan-canvas.js await .claude/plans/feature.plan.md + * node scripts/plan-canvas.js await --reply "Updated section 3." + * node scripts/plan-canvas.js end + * node scripts/plan-canvas.js stop + * + * Agents: `open` returns immediately (the server is a detached process); + * `await` long-polls until the human sends feedback, a verdict, or ends the + * session, then prints a JSON payload to stdout. Progress notes go to stderr + * so stdout stays parseable. + */ + +const fs = require('fs'); +const http = require('http'); +const path = require('path'); +const { spawn } = require('child_process'); + +const { + canonicalizeArtifactPath, + createSessionStore, + resolveStateDir, + sessionKeyFor +} = require('./lib/plan-canvas/sessions'); +const { + DEFAULT_HOST, + createPlanCanvasServer, + resolveIdleTimeoutMs, + resolvePort +} = require('./lib/plan-canvas/server'); + +const VERSION = require('../package.json').version; + +function usage() { + return [ + 'Plan Canvas - review plans and HTML artifacts in the browser', + '', + 'Usage:', + ' node scripts/plan-canvas.js Show server status and sessions', + ' node scripts/plan-canvas.js open Open (or resume) a review session', + ' node scripts/plan-canvas.js await Block until the human sends feedback', + ' node scripts/plan-canvas.js end End a session as the agent', + ' node scripts/plan-canvas.js stop Shut down the canvas server', + ' node scripts/plan-canvas.js server Run the server in the foreground', + '', + 'Options:', + ' open: --no-open Do not launch a browser window', + ' --reopen Reopen a session the user ended from the browser', + ' await: --reply Show an agent reply in the canvas chat before waiting', + ' --timeout-ms Return {status:"waiting"} after n ms (tests/debug only)', + ' server: --port --host ', + '', + 'Environment: ECC_PLAN_CANVAS_PORT, ECC_PLAN_CANVAS_STATE_DIR, ECC_PLAN_CANVAS_IDLE_MS' + ].join('\n'); +} + +function valueAfter(args, name) { + const index = args.indexOf(name); + return index >= 0 && index + 1 < args.length ? args[index + 1] : null; +} + +function serverInfoPath(stateDir) { + return path.join(stateDir, 'server.json'); +} + +function readServerInfo(stateDir) { + try { + return JSON.parse(fs.readFileSync(serverInfoPath(stateDir), 'utf8')); + } catch { + return null; + } +} + +function request(port, method, requestPath, body = null) { + return new Promise((resolve, reject) => { + const payload = body === null ? null : JSON.stringify(body); + const req = http.request( + { + host: DEFAULT_HOST, + port, + method, + path: requestPath, + headers: payload + ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } + : {} + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + resolve({ statusCode: res.statusCode, body: JSON.parse(data.trim() || '{}') }); + } catch { + resolve({ statusCode: res.statusCode, body: {} }); + } + }); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function healthCheck(port) { + try { + const res = await request(port, 'GET', '/health'); + return res.body && res.body.app === 'ecc-plan-canvas' ? res.body : null; + } catch { + return null; + } +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Start (or reuse) the detached canvas server and return its port. A version +// mismatch after an ECC update restarts the server so browser and CLI never +// disagree about the protocol. +async function ensureServer({ stateDir, port }) { + const health = await healthCheck(port); + if (health && health.version === VERSION) return port; + if (health) { + await request(port, 'POST', '/shutdown').catch(() => {}); + for (let i = 0; i < 20 && (await healthCheck(port)); i++) await sleep(100); + } + fs.mkdirSync(stateDir, { recursive: true }); + const logFd = fs.openSync(path.join(stateDir, 'server.log'), 'a'); + const child = spawn(process.execPath, [__filename, 'server', '--port', String(port)], { + detached: true, + stdio: ['ignore', logFd, logFd], + env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir } + }); + child.unref(); + fs.closeSync(logFd); + for (let i = 0; i < 50; i++) { + await sleep(100); + if (await healthCheck(port)) return port; + } + throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`); +} + +function openBrowser(url) { + const platform = process.platform; + const [cmd, args] = + platform === 'darwin' ? ['open', [url]] + : platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + try { + spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref(); + return true; + } catch { + return false; + } +} + +function output(payload) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); +} + +async function cmdStatus({ stateDir, port }) { + const health = await healthCheck(port); + if (!health) { + return { server: 'not running', hint: 'open an artifact to start one', stateDir }; + } + const sessions = await request(port, 'GET', '/api/sessions'); + return { server: `http://${DEFAULT_HOST}:${port}`, version: health.version, sessions: sessions.body.sessions }; +} + +async function cmdOpen(file, args, { stateDir, port }) { + if (!file) throw new Error('open requires a file path'); + if (!fs.existsSync(path.resolve(file))) throw new Error(`artifact not found: ${file}`); + await ensureServer({ stateDir, port }); + const res = await request(port, 'POST', '/api/sessions', { + file: path.resolve(file), + reopen: args.includes('--reopen') + }); + if (res.statusCode === 409) return res.body; + if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`); + const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`; + const launched = args.includes('--no-open') ? false : openBrowser(url); + return { + status: 'open', + url, + browser: launched ? 'opened' : 'not opened', + next_step: + 'Run `ecc-plan-canvas await ` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.' + }; +} + +function awaitRequest(port, file, timeoutMs) { + const params = new URLSearchParams({ file }); + if (timeoutMs !== null) params.set('timeoutMs', String(timeoutMs)); + return new Promise((resolve, reject) => { + const req = http.request( + { host: DEFAULT_HOST, port, method: 'GET', path: `/api/await?${params}` }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => { + try { + resolve(JSON.parse(data.trim())); + } catch { + reject(new Error('await response was not JSON (server restarted?) - re-run await; feedback is never lost')); + } + }); + } + ); + req.setTimeout(0); + req.on('error', reject); + req.end(); + }); +} + +async function cmdAwait(file, args, { stateDir, port }) { + if (!file) throw new Error('await requires a file path'); + if (!(await healthCheck(port))) { + return { status: 'no-server', hint: 'no canvas server is running; use `open` first', stateDir }; + } + const reply = valueAfter(args, '--reply'); + if (reply) { + const key = sessionKeyFor(canonicalizeArtifactPath(file)); + await request(port, 'POST', `/api/session/${key}/reply`, { text: reply }); + } + const timeoutRaw = valueAfter(args, '--timeout-ms'); + const timeoutMs = timeoutRaw === null ? null : Number.parseInt(timeoutRaw, 10) || 0; + process.stderr.write('[plan-canvas] waiting for human feedback... leave this running (re-run if interrupted; queued feedback is never lost)\n'); + const result = await awaitRequest(port, path.resolve(file), timeoutMs); + if (result.status === 'feedback') { + result.next_step = result.sessionEnded + ? 'The user sent this feedback and ended the session. Address it and report in chat; do not reopen the canvas uninvited.' + : 'Address the feedback, then run `ecc-plan-canvas await --reply ""` to answer in the canvas and keep listening.'; + } else if (result.status === 'ended') { + result.next_step = + result.endedBy === 'user' + ? 'The user ended this review. Stop polling and deliver any remaining updates in chat; do not reopen uninvited.' + : 'Session ended. Stop polling.'; + } + return result; +} + +async function cmdEnd(file, { port }) { + if (!file) throw new Error('end requires a file path'); + if (!(await healthCheck(port))) return { status: 'no-server' }; + const res = await request(port, 'POST', '/api/end', { file: path.resolve(file) }); + return res.body; +} + +async function cmdStop({ stateDir, port }) { + if (!(await healthCheck(port))) return { status: 'not running' }; + await request(port, 'POST', '/shutdown').catch(() => {}); + fs.rmSync(serverInfoPath(stateDir), { force: true }); + return { status: 'stopping' }; +} + +async function cmdServer(args, { stateDir, port }) { + const portArg = valueAfter(args, '--port'); + const hostArg = valueAfter(args, '--host'); + const listenPort = portArg !== null ? Number.parseInt(portArg, 10) : port; + const store = createSessionStore({ stateDir }); + let shuttingDown = false; + const shutdown = async code => { + if (shuttingDown) return; + shuttingDown = true; + fs.rmSync(serverInfoPath(stateDir), { force: true }); + await canvas.close().catch(() => {}); + process.exit(code); + }; + const canvas = createPlanCanvasServer({ + store, + host: hostArg || DEFAULT_HOST, + version: VERSION, + idleTimeoutMs: resolveIdleTimeoutMs(), + onIdleShutdown: () => shutdown(0), + log: line => process.stderr.write(`${line}\n`) + }); + const bound = await canvas.listen(listenPort); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync( + serverInfoPath(stateDir), + JSON.stringify({ pid: process.pid, port: bound.port, version: VERSION, startedAt: new Date().toISOString() }, null, 2) + ); + // Sessions restored from disk resume their file watchers. + for (const session of store.list()) { + if (session.status !== 'ended') canvas.watchSession(store.get(session.key)); + } + process.on('SIGINT', () => shutdown(0)); + process.on('SIGTERM', () => shutdown(0)); + process.stderr.write(`[plan-canvas] serving on http://${bound.host}:${bound.port}\n`); + return new Promise(() => {}); // run until a signal or idle shutdown +} + +async function main(argv = process.argv.slice(2)) { + const args = argv.slice(); + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(`${usage()}\n`); + return 0; + } + const command = args[0] && !args[0].startsWith('--') ? args.shift() : null; + const stateDir = resolveStateDir(); + // A running server may sit on a non-default port; trust its recorded info. + const recorded = readServerInfo(stateDir); + const context = { stateDir, port: (recorded && recorded.port) || resolvePort() }; + try { + if (command === null) output(await cmdStatus(context)); + else if (command === 'open') output(await cmdOpen(args[0], args, context)); + else if (command === 'await') output(await cmdAwait(args[0], args, context)); + else if (command === 'end') output(await cmdEnd(args[0], context)); + else if (command === 'stop') output(await cmdStop(context)); + else if (command === 'server') await cmdServer(args, context); + else { + process.stderr.write(`Unknown command: ${command}\n\n${usage()}\n`); + return 1; + } + return 0; + } catch (error) { + output({ error: error.message }); + return 1; + } +} + +if (require.main === module) { + main().then(code => { + process.exitCode = code; + }); +} + +module.exports = { main, ensureServer, healthCheck }; diff --git a/skills/plan-canvas/SKILL.md b/skills/plan-canvas/SKILL.md new file mode 100644 index 000000000..342033fde --- /dev/null +++ b/skills/plan-canvas/SKILL.md @@ -0,0 +1,153 @@ +--- +name: plan-canvas +description: Open plans and HTML artifacts in a local browser canvas where the human annotates elements, chats, and approves or requests changes without leaving the page. Use when presenting a plan for review, or when feedback like "move this, change that" is easier pointed at than typed. +metadata: + origin: ECC +version: "1.0.0" +--- + +# Plan Canvas + +Review loop for plans and visual artifacts: you write the artifact, the human +reviews it in the browser — annotating the exact element they mean, chatting, +and delivering an **Approve plan / Request changes** verdict — while you block +on a single CLI call that returns their feedback as JSON. + +Inspired by [lavish-axi](https://github.com/kunchenguid/lavish-axi); rebuilt +ECC-native around the `/plan` confirmation gate, with zero dependencies. + +## When to Use + +- You just wrote a plan artifact (`.claude/plans/*.plan.md` from `/plan`) and + need the CONFIRM/approve decision — the canvas verdict replaces a typed + "yes/proceed". +- The user should *point at* what to change: reviewing designs, comparisons, + reports, or any local `.md` / `.html` artifact. +- The user asks for `/plan-canvas`, a visual review, or "open it in the browser". + +Do NOT use for: code review of diffs (`/code-review`), running web apps, or +remote URLs. The canvas serves local artifact files only. + +## How It Works + +Invoke the CLI as `ecc-plan-canvas` — the bin shipped by the `ecc-universal` +package (on PATH after a global/plugin install; `node "$CLAUDE_PLUGIN_ROOT/scripts/plan-canvas.js"` +also works for plugin installs). Run it from the project you are reviewing in; +it works from any working directory. It manages a detached loopback server +(`127.0.0.1:4517`) shared by all sessions, keyed by artifact path — no session +ids to track. + +The workflow is a plain CLI-plus-JSON loop, so it is model- and harness-agnostic: +any agent that can run a shell command and read stdout drives it the same way +(Claude Code, Codex, Cursor, Gemini, OpenCode, Copilot). Trigger it however your +harness surfaces skills — e.g. `/plan-canvas` in Claude Code, `$plan-canvas` in +Codex — or just run the `ecc-plan-canvas` commands directly. + +```bash +# 1. Open the artifact in the user's browser (returns immediately) +ecc-plan-canvas open .claude/plans/feature.plan.md + +# 2. Block until the human responds. Leave running; re-run if interrupted — +# queued feedback is never lost. Run in the background if your harness +# time-limits foreground commands. +ecc-plan-canvas await .claude/plans/feature.plan.md +``` + +`await` prints JSON when the human acts: + +```json +{ + "status": "feedback", + "items": [ + { "kind": "annotation", "text": "Split this into two phases", + "anchor": { "selector": "h2:nth-of-type(3)", "tag": "h2", "snippet": "Phase 2: Migration" } }, + { "kind": "verdict", "verdict": "request-changes" } + ] +} +``` + +- `kind: "chat"` — freeform message; answer in the canvas, not the terminal. +- `kind: "annotation"` — feedback anchored to an element (`anchor.selector`, + `anchor.snippet` show what they pointed at; `anchor.textRange.text` when + they highlighted a passage). +- `kind: "verdict"` — `approve` means the plan is CONFIRMED: stop polling, + end the session, and start implementing. `request-changes` means revise the + artifact (the canvas live-reloads it) and keep the loop going. + +**3. Respond in the canvas**, then keep listening — one command does both: + +```bash +ecc-plan-canvas await --reply "Split Phase 2 as requested — take a look." +``` + +**4. End** when review concludes: `ecc-plan-canvas end `. + +## Diagrams (Mermaid) + +When part of the plan is a flow, architecture, sequence, state machine, ER +model, or dependency graph, author it as a fenced ` ```mermaid ` block instead +of ASCII art or a wall of prose — the canvas renders it as a themed diagram the +human can point at. Reach for it when a picture reads faster than a paragraph; +skip it for simple lists or tables. + +````markdown +```mermaid +flowchart LR + A[Market resolves] --> B{Watchers?} + B -->|yes| C[Enqueue jobs] --> D[Fan-out worker] +``` +```` + +Diagrams render in the ECC dark theme with the accent palette. Mermaid loads in +the browser from a pinned CDN; if that is unavailable (offline), the block +degrades to showing its source, so the review is never blocked. Point a local +mirror at `ECC_PLAN_CANVAS_MERMAID_URL` for air-gapped use. + +## Rules + +- Markdown artifacts render in ECC's plan template (including Mermaid blocks); + `.html` artifacts render as-is with the annotation layer injected. For HTML + authoring guidance use the `frontend-design-direction` and `artifact-design` + skills. +- Edit the artifact file to revise — the canvas live-reloads on save. Never + re-run `open` to refresh. +- `{"status": "ended", "endedBy": "user"}` (or `sessionEnded: true` on a + feedback batch) means the user closed the review: stop polling, deliver + remaining updates in chat, and do not reopen. A plain `open` on that + session is refused; pass `--reopen` only when the user asks to resume. +- Sibling assets (images, CSS) must sit next to the artifact and be + referenced by relative path. +- The server is loopback-only and exits after 30 idle minutes + (`ECC_PLAN_CANVAS_IDLE_MS`); `stop` shuts it down explicitly. State lives + in `~/.claude/plan-canvas/` (`ECC_PLAN_CANVAS_STATE_DIR`). + +## Examples + +**Plan approval flow** — `/plan` writes +`.claude/plans/notifications.plan.md` and must WAIT for confirmation: + +```bash +ecc-plan-canvas open .claude/plans/notifications.plan.md +ecc-plan-canvas await .claude/plans/notifications.plan.md +# → {"status":"feedback","items":[{"kind":"verdict","verdict":"approve"}]} +ecc-plan-canvas end .claude/plans/notifications.plan.md +# plan is confirmed — begin implementation +``` + +**Revision loop** — feedback arrives, you edit the file, reply, keep listening: + +```bash +# await returned annotations → edit the .plan.md (canvas live-reloads) +ecc-plan-canvas await --reply "Reworked the risk table." +# → blocks again until the next response +``` + +## Anti-Patterns + +- Polling with `--timeout-ms` in a loop — it exists for tests. Leave the + plain `await` running instead. +- Reopening after a user-initiated end "just to show" something. +- Pasting the whole plan into chat *and* opening a canvas — pick the canvas + and keep the terminal summary to one line. +- Parsing the canvas chat from state files — everything you need arrives via + `await`. diff --git a/tests/hooks/plan-canvas-sessions-hook.test.js b/tests/hooks/plan-canvas-sessions-hook.test.js new file mode 100644 index 000000000..5e8c3ee2d --- /dev/null +++ b/tests/hooks/plan-canvas-sessions-hook.test.js @@ -0,0 +1,100 @@ +/** + * Integration tests for scripts/hooks/plan-canvas-sessions.js (SessionStart) + * + * Run with: node tests/hooks/plan-canvas-sessions-hook.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runHook(stateDir) { + return spawnSync('node', [HOOK], { + encoding: 'utf8', + input: '{}', + env: { ...process.env, ECC_PLAN_CANVAS_STATE_DIR: stateDir } + }); +} + +function writeState(stateDir, sessions) { + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'sessions.json'), JSON.stringify({ sessions })); +} + +function runTests() { + console.log('\n=== Testing plan-canvas-sessions hook ===\n'); + + let passed = 0; + let failed = 0; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-hook-')); + + if (test('exits 0 and prints nothing when no state exists', () => { + const result = runHook(path.join(tmp, 'missing')); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + if (test('exits 0 and prints nothing when all sessions are ended', () => { + const dir = path.join(tmp, 'ended'); + writeState(dir, { + abc123abc123: { key: 'abc123abc123', file: '/x/plan.md', status: 'ended', endedBy: 'user', pendingFeedback: [] } + }); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + if (test('surfaces open sessions with resume guidance', () => { + const dir = path.join(tmp, 'open'); + writeState(dir, { + abc123abc123: { + key: 'abc123abc123', + file: '/projects/x/.claude/plans/feature.plan.md', + status: 'feedback', + pendingFeedback: [{ id: 'fb-1' }, { id: 'fb-2' }] + } + }); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.ok(result.stdout.includes('[PlanCanvas]')); + assert.ok(result.stdout.includes('/projects/x/.claude/plans/feature.plan.md')); + assert.ok(result.stdout.includes('2 undelivered feedback items')); + assert.ok(result.stdout.includes('plan-canvas.js await')); + })) passed++; else failed++; + + if (test('exits 0 on corrupt state (never blocks session start)', () => { + const dir = path.join(tmp, 'corrupt'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'sessions.json'), '{nope'); + const result = runHook(dir); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, ''); + })) passed++; else failed++; + + fs.rmSync(tmp, { recursive: true, force: true }); + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/integration/plan-canvas-e2e.test.js b/tests/integration/plan-canvas-e2e.test.js new file mode 100644 index 000000000..e4c971a95 --- /dev/null +++ b/tests/integration/plan-canvas-e2e.test.js @@ -0,0 +1,265 @@ +/** + * End-to-end test for Plan Canvas: the complete review workflow through the + * real CLI (scripts/plan-canvas.js) and a real detached server process, with + * the browser side simulated over the same HTTP surface the chrome uses. + * + * Flow under test: + * agent: open --no-open → detached server starts, session opens + * browser: loads canvas + artifact + * agent: await (blocking child) → long poll + * browser: POST annotation + request-changes verdict + * agent: await resolves with feedback JSON + * agent: edits plan, await --reply → reply lands in canvas chat + * browser: POST end → user end is sticky + * agent: open refused / --reopen works / end / stop + * + * Run with: node tests/integration/plan-canvas-e2e.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const os = require('os'); +const path = require('path'); +const { spawn, spawnSync } = require('child_process'); + +const CLI = path.join(__dirname, '..', '..', 'scripts', 'plan-canvas.js'); +const HOOK = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'plan-canvas-sessions.js'); + +const results = []; +async function test(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + results.push(true); + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.stack || err.message}`); + results.push(false); + } +} + +function cli(env, args, { timeoutMs = 15000 } = {}) { + const result = spawnSync('node', [CLI, ...args], { + encoding: 'utf8', + timeout: timeoutMs, + env: { ...process.env, ...env } + }); + let parsed = null; + try { + parsed = JSON.parse(result.stdout.trim()); + } catch { + // leave null; callers assert + } + return { ...result, parsed }; +} + +function request(port, method, requestPath, body = null) { + return new Promise((resolve, reject) => { + const payload = body === null ? null : JSON.stringify(body); + const req = http.request( + { + host: '127.0.0.1', + port, + method, + path: requestPath, + agent: false, + headers: payload ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {} + }, + res => { + let data = ''; + res.on('data', chunk => { + data += chunk; + }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: data })); + } + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +async function main() { + console.log('\n=== Plan Canvas end-to-end workflow ===\n'); + + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-canvas-e2e-')); + const stateDir = path.join(tmp, 'state'); + const plansDir = path.join(tmp, '.claude', 'plans'); + fs.mkdirSync(plansDir, { recursive: true }); + const plan = path.join(plansDir, 'notifications.plan.md'); + fs.writeFileSync( + plan, + [ + '# Plan: Real-Time Notifications', + '', + '**Complexity**: Medium', + '', + '## Summary', + 'Notify users when watched markets resolve.', + '', + '## Files to Change', + '| File | Action | Why |', + '|---|---|---|', + '| `lib/notify.ts` | CREATE | delivery service |', + '', + '## Tasks', + '### Task 1: Schema', + '- **Action**: add notifications table', + '- **Validate**: `npm test`', + '' + ].join('\n') + ); + + // Unique port so the test never collides with a user's real canvas server. + const port = 20000 + Math.floor(Math.random() * 20000); + const env = { ECC_PLAN_CANVAS_STATE_DIR: stateDir, ECC_PLAN_CANVAS_PORT: String(port) }; + let key = null; + + try { + await test('agent opens the plan: detached server starts, session created', async () => { + const result = cli(env, ['open', plan, '--no-open']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.parsed.status, 'open'); + assert.ok(result.parsed.url.includes(`127.0.0.1:${port}/canvas/`)); + key = result.parsed.url.split('/canvas/')[1]; + const info = JSON.parse(fs.readFileSync(path.join(stateDir, 'server.json'), 'utf8')); + assert.strictEqual(info.port, port); + }); + + await test('browser loads the canvas chrome and the rendered plan', async () => { + const chrome = await request(port, 'GET', `/canvas/${key}`); + assert.strictEqual(chrome.statusCode, 200); + assert.ok(chrome.body.includes('Plan Canvas')); + assert.ok(chrome.body.includes('notifications.plan.md')); + const doc = await request(port, 'GET', `/artifact/${key}/`); + assert.ok(doc.body.includes('

')); + assert.ok(doc.body.includes('lib/notify.ts')); + assert.ok(doc.body.includes('/sdk.js')); + }); + + await test('SessionStart hook surfaces the open review', async () => { + const hook = spawnSync('node', [HOOK], { encoding: 'utf8', input: '{}', env: { ...process.env, ...env } }); + assert.strictEqual(hook.status, 0); + assert.ok(hook.stdout.includes('notifications.plan.md')); + }); + + let awaitChild = null; + let awaitStdout = ''; + const awaitExit = () => + new Promise(resolve => { + awaitChild.on('close', resolve); + }); + + await test('agent blocks on await; user annotation + verdict resolve it', async () => { + awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } }); + awaitChild.stdout.on('data', chunk => { + awaitStdout += chunk; + }); + const exited = awaitExit(); + // Queued-then-drained semantics make this race-free: feedback posted + // before the poll attaches is delivered the moment it does. + const post = await request(port, 'POST', `/api/session/${key}/feedback`, { + items: [ + { + kind: 'annotation', + text: 'Also notify via webhook, not just email', + anchor: { selector: 'h3:nth-of-type(1)', tag: 'h3', snippet: 'Task 1: Schema' } + }, + { kind: 'verdict', verdict: 'request-changes' } + ] + }); + assert.strictEqual(post.statusCode, 200); + await exited; + const feedback = JSON.parse(awaitStdout.trim()); + assert.strictEqual(feedback.status, 'feedback'); + assert.strictEqual(feedback.items.length, 2); + assert.strictEqual(feedback.items[0].kind, 'annotation'); + assert.ok(feedback.items[0].anchor.snippet.includes('Task 1')); + assert.strictEqual(feedback.items[1].verdict, 'request-changes'); + assert.ok(feedback.next_step.includes('--reply')); + }); + + await test('agent edits the plan and replies; reply reaches the canvas chat', async () => { + fs.appendFileSync(plan, '\n### Task 2: Webhook channel\n- **Action**: add webhook delivery\n'); + const result = cli(env, ['await', plan, '--reply', 'Added webhook delivery as Task 2.', '--timeout-ms', '400']); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.parsed.status, 'waiting'); + // The chrome bootstraps its chat from the canvas page. + const chrome = await request(port, 'GET', `/canvas/${key}`); + assert.ok(chrome.body.includes('Added webhook delivery as Task 2.')); + const doc = await request(port, 'GET', `/artifact/${key}/`); + assert.ok(doc.body.includes('Webhook channel')); + }); + + await test('user approves; the verdict arrives as plan confirmation', async () => { + awaitChild = spawn('node', [CLI, 'await', plan], { env: { ...process.env, ...env } }); + awaitStdout = ''; + awaitChild.stdout.on('data', chunk => { + awaitStdout += chunk; + }); + const exited = awaitExit(); + await request(port, 'POST', `/api/session/${key}/feedback`, { + items: [{ kind: 'verdict', verdict: 'approve' }] + }); + await exited; + const feedback = JSON.parse(awaitStdout.trim()); + assert.strictEqual(feedback.items[0].verdict, 'approve'); + }); + + await test('user ends the session; plain reopen is refused, --reopen works', async () => { + await request(port, 'POST', `/api/session/${key}/end`); + const refused = cli(env, ['open', plan, '--no-open']); + assert.strictEqual(refused.parsed.status, 'user-ended'); + assert.ok(refused.parsed.next_step.includes('Do not reopen')); + const forced = cli(env, ['open', plan, '--no-open', '--reopen']); + assert.strictEqual(forced.parsed.status, 'open'); + }); + + await test('await on a user-ended session reports ended with guidance', async () => { + await request(port, 'POST', `/api/session/${key}/end`); + const result = cli(env, ['await', plan, '--timeout-ms', '400']); + assert.strictEqual(result.parsed.status, 'ended'); + assert.strictEqual(result.parsed.endedBy, 'user'); + assert.ok(result.parsed.next_step.includes('Stop polling')); + }); + + await test('agent end + status + stop shut everything down', async () => { + cli(env, ['open', plan, '--no-open', '--reopen']); + const ended = cli(env, ['end', plan]); + assert.strictEqual(ended.parsed.endedBy, 'agent'); + const status = cli(env, []); + assert.ok(String(status.parsed.server).includes(`127.0.0.1:${port}`)); + const stop = cli(env, ['stop']); + assert.strictEqual(stop.parsed.status, 'stopping'); + // Server actually exits: health checks fail shortly after. + let gone = false; + for (let i = 0; i < 30 && !gone; i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + gone = await request(port, 'GET', '/health').then(() => false).catch(() => true); + } + assert.ok(gone, 'server should stop listening after stop'); + const after = cli(env, []); + assert.strictEqual(after.parsed.server, 'not running'); + }); + } finally { + // Belt and braces: never leave a server running even if a test failed. + cli(env, ['stop']); + fs.rmSync(tmp, { recursive: true, force: true }); + } + + const passed = results.filter(Boolean).length; + const failed = results.length - passed; + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error(err); + console.log('Passed: 0'); + console.log('Failed: 1'); + process.exit(1); +}); diff --git a/tests/lib/loopback-guard.test.js b/tests/lib/loopback-guard.test.js new file mode 100644 index 000000000..61b01dd56 --- /dev/null +++ b/tests/lib/loopback-guard.test.js @@ -0,0 +1,122 @@ +/** + * Tests for scripts/lib/loopback-guard.js + * + * Run with: node tests/lib/loopback-guard.test.js + */ + +const assert = require('assert'); + +const { + LOOPBACK_HOSTNAMES, + buildAllowedHostnames, + isAllowedHostHeader, + isAllowedOrigin, + parseHostHeader +} = require('../../scripts/lib/loopback-guard'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing loopback-guard.js ===\n'); + + let passed = 0; + let failed = 0; + + console.log('parseHostHeader:'); + + if (test('strips port from hostname', () => { + assert.strictEqual(parseHostHeader('127.0.0.1:4517'), '127.0.0.1'); + assert.strictEqual(parseHostHeader('localhost:80'), 'localhost'); + })) passed++; else failed++; + + if (test('handles bare hostnames', () => { + assert.strictEqual(parseHostHeader('localhost'), 'localhost'); + })) passed++; else failed++; + + if (test('lowercases hostnames', () => { + assert.strictEqual(parseHostHeader('LocalHost:3000'), 'localhost'); + })) passed++; else failed++; + + if (test('keeps bracketed IPv6 hosts intact', () => { + assert.strictEqual(parseHostHeader('[::1]:4517'), '[::1]'); + })) passed++; else failed++; + + if (test('returns null for missing or malformed values', () => { + assert.strictEqual(parseHostHeader(null), null); + assert.strictEqual(parseHostHeader(undefined), null); + assert.strictEqual(parseHostHeader(''), null); + assert.strictEqual(parseHostHeader(' '), null); + assert.strictEqual(parseHostHeader(42), null); + assert.strictEqual(parseHostHeader('bad:host:extra'), null); + })) passed++; else failed++; + + console.log('\nbuildAllowedHostnames:'); + + if (test('always includes loopback names', () => { + const set = buildAllowedHostnames(null); + for (const name of LOOPBACK_HOSTNAMES) assert.ok(set.has(name)); + })) passed++; else failed++; + + if (test('adds the configured host lowercased', () => { + const set = buildAllowedHostnames('MyBox.Local'); + assert.ok(set.has('mybox.local')); + })) passed++; else failed++; + + console.log('\nisAllowedHostHeader:'); + + const allowed = buildAllowedHostnames('127.0.0.1'); + + if (test('accepts loopback host headers', () => { + assert.strictEqual(isAllowedHostHeader('127.0.0.1:4517', allowed), true); + assert.strictEqual(isAllowedHostHeader('localhost:4517', allowed), true); + assert.strictEqual(isAllowedHostHeader('[::1]:4517', allowed), true); + })) passed++; else failed++; + + if (test('rejects DNS-rebinding style hostnames', () => { + assert.strictEqual(isAllowedHostHeader('evil.example.com', allowed), false); + assert.strictEqual(isAllowedHostHeader('127.0.0.1.evil.example.com', allowed), false); + })) passed++; else failed++; + + if (test('rejects missing host header', () => { + assert.strictEqual(isAllowedHostHeader(undefined, allowed), false); + })) passed++; else failed++; + + console.log('\nisAllowedOrigin:'); + + if (test('absent origin is allowed (same-origin nav, CLI)', () => { + assert.strictEqual(isAllowedOrigin(undefined, allowed), true); + assert.strictEqual(isAllowedOrigin(null, allowed), true); + })) passed++; else failed++; + + if (test('loopback origins are allowed', () => { + assert.strictEqual(isAllowedOrigin('http://127.0.0.1:4517', allowed), true); + assert.strictEqual(isAllowedOrigin('http://localhost:4517', allowed), true); + })) passed++; else failed++; + + if (test('cross-site origins are rejected', () => { + assert.strictEqual(isAllowedOrigin('https://evil.example.com', allowed), false); + })) passed++; else failed++; + + if (test('malformed origins are rejected', () => { + assert.strictEqual(isAllowedOrigin('not a url', allowed), false); + })) passed++; else failed++; + + console.log('\n' + '='.repeat(40)); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log('='.repeat(40)); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/plan-canvas-markdown.test.js b/tests/lib/plan-canvas-markdown.test.js new file mode 100644 index 000000000..5b401ff42 --- /dev/null +++ b/tests/lib/plan-canvas-markdown.test.js @@ -0,0 +1,409 @@ +/** + * Tests for scripts/lib/plan-canvas/markdown.js + * + * Run with: node tests/lib/plan-canvas-markdown.test.js + */ + +const assert = require('assert'); + +// Import the module +const { renderMarkdown, escapeHtml, slugify } = require('../../scripts/lib/plan-canvas/markdown'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (err) { + console.log(` ✗ ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +// Test suite +function runTests() { + console.log('\n=== Testing plan-canvas/markdown.js ===\n'); + + let passed = 0; + let failed = 0; + + // escapeHtml tests + console.log('escapeHtml:'); + + if (test('escapes & < > " \'', () => { + assert.strictEqual( + escapeHtml(''), + '<a href="x" & 'y'>' + ); + })) passed++; else failed++; + + if (test('leaves safe text unchanged', () => { + assert.strictEqual(escapeHtml('plain text 123'), 'plain text 123'); + })) passed++; else failed++; + + if (test('handles null/undefined as empty string', () => { + assert.strictEqual(escapeHtml(null), ''); + assert.strictEqual(escapeHtml(undefined), ''); + })) passed++; else failed++; + + // slugify tests + console.log('\nslugify:'); + + if (test('lowercases and hyphenates spaces', () => { + assert.strictEqual(slugify('Plan Overview'), 'plan-overview'); + })) passed++; else failed++; + + if (test('strips punctuation', () => { + assert.strictEqual(slugify('Files to Change: Phase 1!'), 'files-to-change-phase-1'); + })) passed++; else failed++; + + if (test('collapses repeated separators and trims', () => { + assert.strictEqual(slugify(' A B--C '), 'a-b-c'); + })) passed++; else failed++; + + if (test('returns empty string for symbol-only input', () => { + assert.strictEqual(slugify('***'), ''); + })) passed++; else failed++; + + // Heading tests + console.log('\nHeadings:'); + + for (let level = 1; level <= 6; level++) { + if (test(`renders h${level} with slug id`, () => { + const md = `${'#'.repeat(level)} Title ${level}`; + assert.strictEqual( + renderMarkdown(md), + `Title ${level}` + ); + })) passed++; else failed++; + } + + if (test('heading supports inline formatting, slug ignores markers', () => { + assert.strictEqual( + renderMarkdown('## Rollout **Plan**'), + '

Rollout Plan

' + ); + })) passed++; else failed++; + + // Paragraph and inline tests + console.log('\nParagraphs and Inline:'); + + if (test('splits paragraphs on blank lines', () => { + assert.strictEqual( + renderMarkdown('first para\n\nsecond para'), + '

first para

\n

second para

' + ); + })) passed++; else failed++; + + if (test('joins consecutive lines into one paragraph', () => { + assert.strictEqual(renderMarkdown('line a\nline b'), '

line a\nline b

'); + })) passed++; else failed++; + + if (test('renders bold, italic, strikethrough, inline code', () => { + const out = renderMarkdown('has **bold**, *ital*, _emph_, ~~gone~~, and `a < b`.'); + assert.strictEqual( + out, + '

has bold, ital, emph, gone, and a < b.

' + ); + })) passed++; else failed++; + + if (test('does not italicize snake_case identifiers', () => { + const out = renderMarkdown('use snake_case_name here'); + assert.ok(!out.includes(''), `No expected, got ${out}`); + })) passed++; else failed++; + + if (test('inline code contents are not parsed further', () => { + assert.strictEqual(renderMarkdown('`**x**`'), '

**x**

'); + })) passed++; else failed++; + + // List tests + console.log('\nLists:'); + + if (test('renders nested unordered list (2 levels)', () => { + const out = renderMarkdown('- top one\n - child one\n - child two\n- top two'); + assert.ok(out.startsWith('