Compare commits

..

3 Commits

Author SHA1 Message Date
affaan a0b7b1e788 fix(ci): require clean probe exit for Windows shell/bash detection; add pyyaml dev dep
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-30 22:13:04 +00:00
affaan 752ee82de1 fix(ci): resync lockfiles with package.json (eslint 10) + migrate yarn.lock to Yarn 4 format
package.json requires eslint@^10.6.0 but the committed locks pinned 9.39.2, so
npm ci aborted and Yarn 4 hardened mode rejected the stale v1-classic yarn.lock
(YN0028). Regenerate package-lock.json and rewrite yarn.lock in Yarn 4 (berry)
format so npm ci and immutable yarn installs both pass.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-30 21:56:54 +00:00
affaan 9a70d61a0e fix: resolve issue cluster (#2295,#2298,#2303,#2304,#2305,#2306,#2340) + createdTime fallback bug
- session-manager: fix createdTime birthtime||ctime fallback that never fired
  (a Date is always truthy); use birthtimeMs>0 check via resolveCreatedTime()
- installer: rewrite source-relative rules/skills links for the injected
  ecc/ namespace so installed skills resolve correctly (#2340)
- continuous-learning-v2: drop unused mock import (#2305); standardize bash
  shebangs (#2303); poll for PID file instead of fixed sleep (#2295);
  rename _ecc_* -> _clv2_* (#2304); align promotion confidence docs (#2298);
  de-brittle Scope Decision Guide cross-reference (#2306)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-30 21:45:33 +00:00
19 changed files with 272 additions and 55 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
---
description: Run AgentShield against agent, hook, MCP, permission, and secret surfaces.
agent: ecc:security-reviewer
agent: everything-claude-code:security-reviewer
subtask: true
---
+33
View File
@@ -5,6 +5,29 @@ const path = require('path');
const { writeInstallState } = require('../install-state');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { rewriteNamespaceLinks } = require('./rewrite-namespace-links');
const CLAUDE_ECC_NAMESPACE = 'ecc';
// Claude home/project installs inject `skills/ecc/` and `rules/ecc/`. Markdown
// copied under those namespaced roots may carry source-relative links to a
// sibling top-level dir that break post-install; rewrite them on copy.
function getNamespaceLinkRewrite(plan, destinationPath) {
if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) {
return null;
}
if (!plan.targetRoot || !destinationPath.toLowerCase().endsWith('.md')) {
return null;
}
const namespacedRoots = [
path.join(plan.targetRoot, 'skills', CLAUDE_ECC_NAMESPACE) + path.sep,
path.join(plan.targetRoot, 'rules', CLAUDE_ECC_NAMESPACE) + path.sep,
];
if (!namespacedRoots.some(root => destinationPath.startsWith(root))) {
return null;
}
return CLAUDE_ECC_NAMESPACE;
}
function readJsonObject(filePath, label) {
let parsed;
@@ -149,6 +172,16 @@ function applyInstallPlan(plan) {
continue;
}
const namespace = operation.kind === 'copy-file'
? getNamespaceLinkRewrite(plan, operation.destinationPath)
: null;
if (namespace) {
const original = fs.readFileSync(operation.sourcePath, 'utf8');
const rewritten = rewriteNamespaceLinks(original, namespace);
fs.writeFileSync(operation.destinationPath, rewritten, 'utf8');
continue;
}
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
}
@@ -0,0 +1,48 @@
'use strict';
// The Claude home/project installers inject a namespace segment into the
// destination layout: `skills/` -> `skills/<ns>/` and `rules/` -> `rules/<ns>/`.
// Skills that link to a sibling top-level dir using a source-relative path
// (e.g. `../../rules/react/hooks.md`) break after install, because the extra
// `<ns>/` level changes what `../../` resolves to and the target dir is itself
// namespaced.
//
// This rewrites such links so they remain valid post-install: prepend one `../`
// (to climb out of the injected namespace level) and insert the namespace
// segment after the managed top-level dir name. Only links that climb with at
// least two `../` and reference a namespaced managed dir (`rules`/`skills`) are
// touched; intra-skill links (a single `../`) and absolute/bare paths are left
// alone.
const NAMESPACED_DIRS = ['rules', 'skills'];
function buildLinkPattern() {
const dirs = NAMESPACED_DIRS.join('|');
// group 1: the `../` climb (>= 2), group 2: managed dir, group 3: trailing `/`
return new RegExp(`((?:\\.\\./){2,})(${dirs})(/)`, 'g');
}
/**
* Rewrite source-relative links in a namespaced skill/rule markdown file so they
* resolve correctly after the installer injects `<namespace>/`.
* @param {string} content - file contents
* @param {string} namespace - injected namespace segment (e.g. "ecc")
* @returns {string} rewritten contents (unchanged if no matching links)
*/
function rewriteNamespaceLinks(content, namespace) {
if (typeof content !== 'string' || !namespace) {
return content;
}
const pattern = buildLinkPattern();
return content.replace(pattern, (match, climb, dir, slash, offset, fullText) => {
// Already namespaced (`../../rules/ecc/...`) — leave untouched (idempotent).
const rest = fullText.slice(offset + match.length);
if (rest.startsWith(`${namespace}/`)) {
return match;
}
return `../${climb}${dir}${slash}${namespace}/`;
});
}
module.exports = { rewriteNamespaceLinks, NAMESPACED_DIRS };
+14 -2
View File
@@ -26,6 +26,18 @@ const {
// "2026-02-01-ChezMoi_2-session.tmp"
const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_-]*))?-session\.tmp$/;
/**
* Resolve a file's creation time, preferring birthtime but falling back to
* ctime when birthtime is unavailable. Some filesystems (e.g. overlayfs in
* containers) report birthtime as epoch 0; a Date object is always truthy, so
* `birthtime || ctime` would never fall back. Compare on milliseconds instead.
* @param {import('fs').Stats} stats
* @returns {Date}
*/
function resolveCreatedTime(stats) {
return stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime;
}
/**
* Parse session filename to extract metadata
* @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")
@@ -116,7 +128,7 @@ function getSessionCandidates(options = {}) {
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
createdTime: resolveCreatedTime(stats)
});
}
}
@@ -151,7 +163,7 @@ function buildSessionRecord(sessionPath, metadata) {
hasContent: stats.size > 0,
size: stats.size,
modifiedTime: stats.mtime,
createdTime: stats.birthtime || stats.ctime
createdTime: resolveCreatedTime(stats)
};
}
@@ -121,7 +121,7 @@ Validate and sanitize all user input before processing.
When creating instincts, determine scope based on these heuristics:
> **Scope Decision Guide** See the canonical table in `skills/continuous-learning-v2/SKILL.md` (lines 271282).
> **Scope Decision Guide** See the canonical table under the "Scope Decision Guide" heading in `skills/continuous-learning-v2/SKILL.md`.
**When in doubt, default to `scope: project`** — it's safer to be project-specific and promote later than to contaminate the global space.
@@ -142,7 +142,7 @@ Confidence adjusts over time:
An instinct should be promoted from project-scoped to global when:
1. The **same pattern** (by id or similar trigger) exists in **2+ different projects**
2. Each instance has confidence **>= 0.8**
2. The **average** confidence across those instances is **>= 0.8** (matching `PROMOTE_CONFIDENCE_THRESHOLD` in `instinct-cli.py`)
3. The domain is in the global-friendly list (security, general-best-practices, workflow)
Promotion is handled by the `instinct-cli.py promote` command or the `/evolve` analysis.
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Continuous Learning v2 - Observer Agent Launcher
#
# Starts the background observer agent that analyzes observations
@@ -37,7 +37,7 @@ PYTHON_CMD="${CLV2_PYTHON_CMD:-}"
# shellcheck disable=SC1091
. "${SKILL_ROOT}/scripts/lib/homunculus-dir.sh"
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
CONFIG_DIR="$(_clv2_resolve_homunculus_dir)"
if [ -n "${CLV2_CONFIG:-}" ]; then
CONFIG_FILE="$CLV2_CONFIG"
elif [ -f "${CONFIG_DIR}/config.json" ]; then
@@ -215,8 +215,13 @@ case "$ACTION" in
CLV2_OBSERVER_PROMPT_PATTERN="$CLV2_OBSERVER_PROMPT_PATTERN" \
"$OBSERVER_LOOP_SCRIPT" >> "$LOG_FILE" 2>&1 &
# Wait for PID file
sleep 2
# Wait for the PID file to appear (poll up to ~10s; exits early once ready).
# A fixed sleep is either too short on loaded/slow filesystems or wastes
# time on healthy ones.
for _i in $(seq 1 50); do
[ -f "$PID_FILE" ] && break
sleep 0.2
done
# Check for confirmation-seeking output in the observer log
if tail -n +"$((start_line + 1))" "$LOG_FILE" 2>/dev/null | grep -E -i -q "$CLV2_OBSERVER_PROMPT_PATTERN"; then
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Continuous Learning v2 - Observation Hook
#
# Captures tool use events for pattern analysis.
@@ -135,7 +135,7 @@ fi
# shellcheck disable=SC1091
. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh"
CONFIG_DIR="$(_ecc_resolve_homunculus_dir)"
CONFIG_DIR="$(_clv2_resolve_homunculus_dir)"
# Skip if disabled (check both default and CLV2_CONFIG-derived locations)
if [ -f "$CONFIG_DIR/disabled" ]; then
@@ -279,11 +279,11 @@ _SECRET_RE = re.compile(
)
import signal
def _ecc_bail(*_):
def _clv2_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)
signal.signal(signal.SIGALRM, _clv2_bail)
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception:
pass
@@ -317,11 +317,11 @@ echo "$PARSED" | "$PYTHON_CMD" -c '
import json, sys, os, re
import signal
def _ecc_bail(*_):
def _clv2_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)
signal.signal(signal.SIGALRM, _clv2_bail)
signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception:
pass
@@ -493,7 +493,7 @@ touch "$ACTIVITY_FILE" 2>/dev/null || true
# the lazy-start path above. Both wrap the same read-modify-write below.
should_signal=0
_ecc_bump_signal_counter() {
_clv2_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
@@ -518,7 +518,7 @@ if command -v flock >/dev/null 2>&1 && exec 8>"$SIGNAL_COUNTER_LOCK" 2>/dev/null
# 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
_clv2_bump_signal_counter
flock -u 8 2>/dev/null || true
fi
exec 8>&- 2>/dev/null || true
@@ -547,7 +547,7 @@ else
done
if [ "$_signal_lock_held" -eq 1 ]; then
# Bump only under the held lock -- never an unlocked read-modify-write.
_ecc_bump_signal_counter
_clv2_bump_signal_counter
rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true
trap - EXIT INT TERM
fi
@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
# Continuous Learning v2 - Project Detection Helper
#
# Shared logic for detecting current project context.
@@ -21,7 +21,7 @@
# shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh"
_CLV2_HOMUNCULUS_DIR="$(_ecc_resolve_homunculus_dir)"
_CLV2_HOMUNCULUS_DIR="$(_clv2_resolve_homunculus_dir)"
_CLV2_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"
@@ -6,7 +6,7 @@
# 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute
# 3. HOME/.local/share/ecc-homunculus
_ecc_resolve_homunculus_dir() {
_clv2_resolve_homunculus_dir() {
if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then
case "$CLV2_HOMUNCULUS_DIR" in
/*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;;
@@ -7,7 +7,7 @@ OLD="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091
. "$(dirname "$0")/lib/homunculus-dir.sh"
NEW="$(_ecc_resolve_homunculus_dir)"
NEW="$(_clv2_resolve_homunculus_dir)"
if [ "$NEW" = "$OLD" ]; then
echo "Resolved destination equals source ($OLD); nothing to migrate."
@@ -19,7 +19,6 @@ import os
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
import pytest
+9 -9
View File
@@ -51,10 +51,10 @@ Let `<claude-home>` denote the Claude Code home directory: `~/.claude` on macOS/
| Form | Detection | `{ORCH_CMD}` | Agent name format |
|---|---|---|---|
| Plugin install (2.0.0+) | `<claude-home>/plugins/marketplaces/ecc/` exists | `/ecc:orchestrate` | `ecc:<name>` |
| Plugin install (1.9.0+) | `<claude-home>/plugins/marketplaces/everything-claude-code/` exists | `/everything-claude-code:orchestrate` | `everything-claude-code:<name>` |
| Legacy bare install | Above absent; agent files under `<claude-home>/agents/` | `/orchestrate` | `<name>` |
Why this matters: under the plugin install, agents register as `ecc:tdd-guide`. Bare names force fuzzy matching, which fails intermittently under parallel calls. Under legacy, the prefixed forms are not registered and fail outright.
Why this matters: under the plugin install, agents register as `everything-claude-code:tdd-guide`. Bare names force fuzzy matching, which fails intermittently under parallel calls. Under legacy, the prefixed forms are not registered and fail outright.
## Available agent catalogue (must pick from these)
@@ -87,7 +87,7 @@ A misspelled agent name fails `/orchestrate`. Cross-check against this list befo
1. Read `<plan-doc-path>`. If missing or empty, report and stop.
2. Detect ECC install form once and freeze it into `ECC_MODE`. Algorithm (run in order, stop at the first match):
1. If `<claude-home>/plugins/marketplaces/ecc/` exists → `ECC_MODE=plugin`.
1. If `<claude-home>/plugins/marketplaces/everything-claude-code/` exists → `ECC_MODE=plugin`.
2. Else if `<claude-home>/agents/` exists and contains at least one ECC agent file (e.g. `tdd-guide.md`, `code-reviewer.md`) → `ECC_MODE=legacy`.
3. Else → default to `ECC_MODE=legacy` and emit a one-line warning at the top of the output: `> Warning: could not detect ECC install; defaulting to legacy form. If you use the plugin install, edit the prefixes manually.`
4. If both markers exist (mixed install), `plugin` wins — the plugin namespace is the only one that resolves agent names without fuzzy matching.
@@ -99,7 +99,7 @@ A misspelled agent name fails `/orchestrate`. Cross-check against this list befo
- No marker matched → set `lang=unknown`.
- `lang=unknown` is a sentinel — it is **not** an agent name. Phase 2 rules 4 and 5 turn it into `code-reviewer` / `build-error-resolver` at chain composition time.
4. Detect a **PyTorch sub-profile**: when `lang=python` and any of `pyproject.toml` / `requirements.txt` / `uv.lock` declares a dependency on `torch`, set `pytorch=true`. This only affects `build` chain selection (Phase 2 rule below); the reviewer remains `python-reviewer`.
5. **Normalize any agent names declared in the plan**: if the plan text references agents by their plugin-prefixed form (e.g. `ecc:tdd-guide`), strip the prefix to get the bare catalogue name before validating or composing chains. Re-prefixing happens only at output time per `ECC_MODE` (Phase 4). Never let a pre-prefixed name flow into chain composition — it would double-prefix in plugin mode.
5. **Normalize any agent names declared in the plan**: if the plan text references agents by their plugin-prefixed form (e.g. `everything-claude-code:tdd-guide`), strip the prefix to get the bare catalogue name before validating or composing chains. Re-prefixing happens only at output time per `ECC_MODE` (Phase 4). Never let a pre-prefixed name flow into chain composition — it would double-prefix in plugin mode.
### Phase 1 — Decompose steps
@@ -161,8 +161,8 @@ Emit Markdown using **the form determined by `ECC_MODE`**. The output uses one f
Concrete rendering rules:
- `{ORCH_CMD}` = `/ecc:orchestrate` under `plugin`, `/orchestrate` under `legacy`.
- `{AGENT(name)}` = `ecc:<name>` under `plugin`, `<name>` under `legacy`.
- `{ORCH_CMD}` = `/everything-claude-code:orchestrate` under `plugin`, `/orchestrate` under `legacy`.
- `{AGENT(name)}` = `everything-claude-code:<name>` under `plugin`, `<name>` under `legacy`.
- The overview-table "Chain" column uses the same `{AGENT(name)}` rendering.
- Per-step bash blocks contain only the runnable command. **No `# plugin form` or `# legacy form` comments** — the form is implicit and uniform across the whole output.
@@ -203,7 +203,7 @@ Append a final "Batch execution" block aggregating every step's command in order
### Phase 5 — Self-check (run before emitting)
- [ ] Every agent in every chain comes from the catalogue (after stripping any `ecc:` prefix that appeared in the plan; see Phase 0 step 5).
- [ ] Every agent in every chain comes from the catalogue (after stripping any `everything-claude-code:` prefix that appeared in the plan; see Phase 0 step 5).
- [ ] Resolved `{ORCH_CMD}` and every resolved `{AGENT(...)}` use the **same** form (`plugin` or `legacy`) — never mixed in one output.
- [ ] No `# plugin form` / `# legacy form` annotations and no "strip the prefix" instructions remain in the rendered output.
- [ ] No invented `--mode` / `--gate` / `--agents=...` fields.
@@ -221,7 +221,7 @@ Append a final "Batch execution" block aggregating every step's command in order
- **No clear steps**: prefer H2/H3 splitting; if still ambiguous, report "no structured steps detected" with the document outline and ask the user to confirm running by outline.
- **Large plan (>1500 lines)**: enter **overview-only mode** — emit only the overview table and ask the user to narrow with `--scope` before re-running for details. In this mode, skip per-step detail blocks and skip the Batch execution block.
- **Step too broad** (e.g. "complete all backend work"): do not force a single chain. Suggest splitting into N.a and N.b and propose a split.
- **Plan declares agents** (rare): first **strip any `ecc:` prefix** to get the bare catalogue name (Phase 0 step 5), then validate against the catalogue. Replace invalid agents and explain under "Chain rationale". The bare name is re-prefixed at output time per `ECC_MODE`.
- **Plan declares agents** (rare): first **strip any `everything-claude-code:` prefix** to get the bare catalogue name (Phase 0 step 5), then validate against the catalogue. Replace invalid agents and explain under "Chain rationale". The bare name is re-prefixed at output time per `ECC_MODE`.
- **Polyglot project where `--lang=auto` cannot pick a winner**: set `lang=unknown`; reviewer resolves to `code-reviewer` and build resolver to `build-error-resolver`. Mention the fallback under "Chain rationale".
## Examples
@@ -242,7 +242,7 @@ Excerpt of expected output:
**Chain rationale**: Security-sensitive write path, so `security-reviewer` closes the chain; `database-reviewer` validates the alembic migration; `python-reviewer` covers typing and PEP 8.
```bash
/ecc:orchestrate custom "ecc:tdd-guide,ecc:database-reviewer,ecc:python-reviewer,ecc:security-reviewer" "[Plan: docs/plan/example-feature.md#step-2] Implement EncryptedString SQLAlchemy type and migrate UserProfile.birth_datetime/location columns; key from ENV APP_DB_KEY; Acceptance: encrypt/decrypt roundtrip tests pass; alembic upgrade/downgrade clean on empty DB; no plaintext in DB after migrate; Out of scope: cross-tenant profile sharing logic"
/everything-claude-code:orchestrate custom "everything-claude-code:tdd-guide,everything-claude-code:database-reviewer,everything-claude-code:python-reviewer,everything-claude-code:security-reviewer" "[Plan: docs/plan/example-feature.md#step-2] Implement EncryptedString SQLAlchemy type and migrate UserProfile.birth_datetime/location columns; key from ENV APP_DB_KEY; Acceptance: encrypt/decrypt roundtrip tests pass; alembic upgrade/downgrade clean on empty DB; no plaintext in DB after migrate; Out of scope: cross-tenant profile sharing logic"
```
````
+2 -2
View File
@@ -50,7 +50,7 @@ agents/
Agents are discovered via two methods, merged and deduplicated by agent name:
1. **`claude agents` command** (primary) — run `claude agents` to get all agents known to the CLI, including user agents, plugin agents (e.g. `ecc:architect`), and built-in agents. This automatically covers ECC marketplace installs without any path configuration.
1. **`claude agents` command** (primary) — run `claude agents` to get all agents known to the CLI, including user agents, plugin agents (e.g. `everything-claude-code:architect`), and built-in agents. This automatically covers ECC marketplace installs without any path configuration.
2. **File glob** (fallback, for reading agent content) — agent markdown files are read from:
- `./agents/**/*.md` + `./agents/*.md` — project-local agents
- `~/.claude/agents/**/*.md` + `~/.claude/agents/*.md` — global user agents
@@ -62,7 +62,7 @@ Earlier sources take precedence when names collide: user agents > plugin agents
### Step 1: Discover Available Agents
Run `claude agents` to get the full agent list. Parse each line:
- **Plugin agents** are prefixed with `plugin-name:` (e.g., `ecc:security-reviewer`). Use the part after `:` as the agent name and the plugin name as the domain.
- **Plugin agents** are prefixed with `plugin-name:` (e.g., `everything-claude-code:security-reviewer`). Use the part after `:` as the agent name and the plugin name as the domain.
- **User agents** have no prefix. Read the corresponding markdown file from `~/.claude/agents/` or `./agents/` to extract the name and description.
- **Built-in agents** (e.g., `Explore`, `Plan`) are skipped unless the user explicitly asks to include them.
@@ -104,7 +104,7 @@ function buildSandbox() {
path.join(scriptsLibDir, 'homunculus-dir.sh'),
[
'#!/bin/bash',
'_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
'_clv2_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
''
].join('\n')
);
+11 -11
View File
@@ -3,14 +3,14 @@
*
* 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
* orphan it (#2278). Before #2300 the handler `_clv2_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
* 1. Static regression guard — every `_clv2_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
@@ -73,14 +73,14 @@ const observeShPath = path.join(
const observeSrc = fs.readFileSync(observeShPath, 'utf8');
// Extract each `_ecc_bail` handler body: the `def` line plus the indented lines
// Extract each `_clv2_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])) {
if (/^def _clv2_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).
@@ -103,15 +103,15 @@ 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.
// `_clv2_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', () => {
test('observe.sh defines at least two _clv2_bail timeout handlers', () => {
assert.ok(
handlers.length >= 2,
`expected >= 2 _ecc_bail handlers, found ${handlers.length}`
`expected >= 2 _clv2_bail handlers, found ${handlers.length}`
);
assert.ok(
timeoutHandlers.length >= 2,
@@ -119,7 +119,7 @@ test('observe.sh defines at least two _ecc_bail timeout handlers', () => {
);
});
test('every _ecc_bail handler logs to stderr before exiting (regression guard)', () => {
test('every _clv2_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)');
@@ -142,7 +142,7 @@ test('every _ecc_bail handler logs to stderr before exiting (regression guard)',
});
});
test('_ecc_bail handlers keep exit code 0 (no exit 2 / block regression)', () => {
test('_clv2_bail handlers keep exit code 0 (no exit 2 / block regression)', () => {
handlers.forEach((body, idx) => {
assert.ok(
/sys\.exit\(0\)/.test(body),
@@ -160,7 +160,7 @@ function runHandlerTimeout(python, handler) {
const program = [
'import sys, signal, time',
handler,
'signal.signal(signal.SIGALRM, _ecc_bail)',
'signal.signal(signal.SIGALRM, _clv2_bail)',
'signal.alarm(1)',
'time.sleep(3)',
'print("REACHED_END_SHOULD_NOT_HAPPEN")',
@@ -178,7 +178,7 @@ function runHandlerTimeout(python, handler) {
// 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`, () => {
test(`real _clv2_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
+1 -1
View File
@@ -375,7 +375,7 @@ test('observe.sh creates counter file and increments on each call', () => {
path.join(scriptsLibDir, 'homunculus-dir.sh'),
[
'#!/bin/bash',
'_ecc_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
'_clv2_resolve_homunculus_dir() { printf "%s\\n" "$HOME/.local/share/ecc-homunculus"; }',
''
].join('\n')
);
+81
View File
@@ -0,0 +1,81 @@
/**
* Tests for scripts/lib/install/rewrite-namespace-links.js (#2340)
*/
const assert = require('assert');
const { rewriteNamespaceLinks } = require('../../scripts/lib/install/rewrite-namespace-links');
function test(name, fn) {
try {
fn();
console.log(` \u2713 ${name}`);
return true;
} catch (error) {
console.log(` \u2717 ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function runTests() {
console.log('\n=== Testing rewrite-namespace-links.js ===\n');
let passed = 0;
let failed = 0;
if (test('rewrites a markdown link to a sibling rules dir', () => {
assert.strictEqual(
rewriteNamespaceLinks('See [hooks](../../rules/react/hooks.md) here', 'ecc'),
'See [hooks](../../../rules/ecc/react/hooks.md) here'
);
})) passed++; else failed++;
if (test('rewrites a bare rules dir link', () => {
assert.strictEqual(
rewriteNamespaceLinks('Rules: [r](../../rules/react/)', 'ecc'),
'Rules: [r](../../../rules/ecc/react/)'
);
})) passed++; else failed++;
if (test('handles deeper skill nesting (3+ climbs)', () => {
assert.strictEqual(
rewriteNamespaceLinks('link ../../../rules/x/y.md', 'ecc'),
'link ../../../../rules/ecc/x/y.md'
);
})) passed++; else failed++;
if (test('leaves intra-skill single-climb links untouched', () => {
const input = 'See ../sibling/file.md and ./local.md';
assert.strictEqual(rewriteNamespaceLinks(input, 'ecc'), input);
})) passed++; else failed++;
if (test('is idempotent on already-namespaced links', () => {
const input = 'See [x](../../../rules/ecc/react/hooks.md)';
assert.strictEqual(rewriteNamespaceLinks(input, 'ecc'), input);
})) passed++; else failed++;
if (test('rewrites multiple links in one document', () => {
assert.strictEqual(
rewriteNamespaceLinks('(../../rules/a) and (../../rules/b)', 'ecc'),
'(../../../rules/ecc/a) and (../../../rules/ecc/b)'
);
})) passed++; else failed++;
if (test('rewrites sibling skills-dir links symmetrically', () => {
assert.strictEqual(
rewriteNamespaceLinks('[s](../../skills/other/SKILL.md)', 'ecc'),
'[s](../../../skills/ecc/other/SKILL.md)'
);
})) passed++; else failed++;
if (test('returns input unchanged when namespace is missing', () => {
const input = 'See [x](../../rules/react/)';
assert.strictEqual(rewriteNamespaceLinks(input, ''), input);
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();
+13 -8
View File
@@ -1165,16 +1165,21 @@ src/main.ts
})) passed++; else failed++;
if (test('createdTime falls back to ctime when birthtime is epoch-zero', () => {
// This tests the || fallback logic: stats.birthtime || stats.ctime
// On some FS, birthtime may be epoch 0 (falsy as a Date number comparison
// but truthy as a Date object). The fallback is defensive.
// Some filesystems (e.g. overlayfs in containers) report birthtime as
// epoch 0. A Date object is always truthy, so `birthtime || ctime` would
// never fall back; the source compares birthtimeMs > 0 instead. Verify the
// resolved createdTime is always a non-zero Date regardless of birthtime.
const stats = fs.statSync(r33FilePath);
// Both birthtime and ctime should be valid Dates on any modern OS
assert.ok(stats.ctime instanceof Date, 'ctime should exist');
// The fallback expression `birthtime || ctime` should always produce a valid Date
const fallbackResult = stats.birthtime || stats.ctime;
assert.ok(fallbackResult instanceof Date, 'Fallback should produce a Date');
assert.ok(fallbackResult.getTime() > 0, 'Fallback date should be non-zero');
const expected = stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime;
assert.ok(expected.getTime() > 0, 'Resolved created time should be non-zero');
const session = sessionManager.getSessionById('r33birth');
assert.ok(session, 'Should find the session');
assert.strictEqual(
session.createdTime.getTime(),
expected.getTime(),
'createdTime should fall back to ctime when birthtime is epoch-zero'
);
})) passed++; else failed++;
// Cleanup Round 33 HOME override
+34
View File
@@ -123,6 +123,40 @@ function runTests() {
}
})) passed++; else failed++;
if (test('rewrites namespaced skill links to the ecc/ rules path (#2340)', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');
try {
const result = run(['typescript'], { cwd: projectDir, homeDir });
assert.strictEqual(result.code, 0, result.stderr);
const claudeRoot = path.join(homeDir, '.claude');
const skillPath = path.join(claudeRoot, 'skills', 'ecc', 'react-patterns', 'SKILL.md');
assert.ok(fs.existsSync(skillPath), 'react-patterns SKILL.md should be installed');
const content = fs.readFileSync(skillPath, 'utf8');
assert.ok(
content.includes('../../../rules/ecc/react/'),
'source-relative rules link should be rewritten for the ecc/ namespace'
);
assert.ok(
!content.includes('](../../rules/'),
'no un-namespaced ](../../rules/ links should remain'
);
// The rewritten link must resolve to a file that actually exists on disk.
const linkTarget = path.join(
path.dirname(skillPath),
'../../../rules/ecc/react/hooks.md'
);
assert.ok(fs.existsSync(linkTarget), 'rewritten link target should exist');
} finally {
cleanup(homeDir);
cleanup(projectDir);
}
})) passed++; else failed++;
if (test('installs Cursor configs and writes install-state', () => {
const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-');