fix: resolve open-issue cluster (#2295, #2298, #2303–#2306, #2340) + createdTime fallback bug (#2408)

* 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>

* 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>

* 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>

* fix(observer): portable mktemp template on BSD/macOS (#2417); correct false attribution-disabled claim in git-workflow docs (#2426) (#2430)

Co-authored-by: affaan <affaan@itomarkets.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: remove duplicate resolveCreatedTime introduced by merge (no-redeclare)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: restore heading-based Scope Decision Guide ref (line numbers drift) + keep behavioral #2340 install test

---------

Co-authored-by: affaan <affaan@itomarkets.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Affaan Mustafa <me@affaanmustafa.com>
This commit is contained in:
devin-ai-integration[bot] 2026-07-03 21:10:45 -07:00 committed by GitHub
parent 3167852753
commit 2d40baacbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 143 additions and 41 deletions

View file

@ -13,7 +13,7 @@ alwaysApply: true
Types: feat, fix, refactor, docs, test, chore, perf, ci Types: feat, fix, refactor, docs, test, chore, perf, ci
Note: Attribution disabled globally via ~/.claude/settings.json. Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
## Pull Request Workflow ## Pull Request Workflow

View file

@ -15,7 +15,7 @@ description: Git workflow guidelines for conventional commits and pull request p
Types: feat, fix, refactor, docs, test, chore, perf, ci Types: feat, fix, refactor, docs, test, chore, perf, ci
Note: Attribution disabled globally via ~/.claude/settings.json. Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
## Pull Request Workflow ## Pull Request Workflow

View file

@ -9,7 +9,7 @@
Tipos: feat, fix, refactor, docs, test, chore, perf, ci Tipos: feat, fix, refactor, docs, test, chore, perf, ci
Nota: Atribución deshabilitada globalmente mediante ~/.claude/settings.json. Nota: Para desactivar la atribución de coautoría, configure `"includeCoAuthoredBy": false` en `~/.claude/settings.json`; Claude Code agrega `Co-Authored-By` de forma predeterminada y ECC no incluye esta configuración.
## Flujo de Trabajo de Pull Request ## Flujo de Trabajo de Pull Request

View file

@ -10,7 +10,7 @@
タイプ: feat, fix, refactor, docs, test, chore, perf, ci タイプ: feat, fix, refactor, docs, test, chore, perf, ci
注記: Attribution は ~/.claude/settings.json でグローバルに無効化されています 注記: コミットの共同作成者の属性を無効にするには、`~/.claude/settings.json``"includeCoAuthoredBy": false` を設定します。Claude Code は既定で `Co-Authored-By` を付与し、ECC はこの設定を同梱しません
## Pull Request ワークフロー ## Pull Request ワークフロー

View file

@ -9,7 +9,7 @@
타입: feat, fix, refactor, docs, test, chore, perf, ci 타입: feat, fix, refactor, docs, test, chore, perf, ci
참고: 어트리뷰션 비활성화 여부는 각자의 `~/.claude/settings.json` 로컬 설정에 따라 달라질 수 있습니다. 참고: 공동 작성자 표기를 비활성화하려면 `~/.claude/settings.json``"includeCoAuthoredBy": false`를 설정하세요. Claude Code는 기본적으로 `Co-Authored-By`를 추가하며 ECC는 이 설정을 포함하지 않습니다.
## Pull Request 워크플로우 ## Pull Request 워크플로우

View file

@ -9,7 +9,7 @@
Tipos: feat, fix, refactor, docs, test, chore, perf, ci Tipos: feat, fix, refactor, docs, test, chore, perf, ci
Nota: Atribuição desabilitada globalmente via ~/.claude/settings.json. Nota: Para desativar a atribuição de coautoria, defina `"includeCoAuthoredBy": false` em `~/.claude/settings.json`; o Claude Code adiciona `Co-Authored-By` por padrão e o ECC não inclui essa configuração.
## Fluxo de Trabalho de Pull Request ## Fluxo de Trabalho de Pull Request

View file

@ -9,7 +9,7 @@
Types: feat, fix, refactor, docs, test, chore, perf, ci Types: feat, fix, refactor, docs, test, chore, perf, ci
Not: Attribution ~/.claude/settings.json aracılığıyla global olarak devre dışı bırakıldı. Not: Ortak yazar atfını devre dışı bırakmak için `~/.claude/settings.json` içinde `"includeCoAuthoredBy": false` ayarlayın; Claude Code varsayılan olarak `Co-Authored-By` ekler ve ECC bu ayarı içermez.
## Pull Request İş Akışı ## Pull Request İş Akışı

View file

@ -10,7 +10,7 @@
类型feat, fix, refactor, docs, test, chore, perf, ci 类型feat, fix, refactor, docs, test, chore, perf, ci
注意:通过 ~/.claude/settings.json 全局禁用了归因 注意:若要禁用共同作者归因,请在 `~/.claude/settings.json` 中设置 `"includeCoAuthoredBy": false`Claude Code 默认会附加 `Co-Authored-By`,而 ECC 不会附带此设置
## 拉取请求工作流程 ## 拉取请求工作流程

View file

@ -10,7 +10,7 @@
類型feat、fix、refactor、docs、test、chore、perf、ci 類型feat、fix、refactor、docs、test、chore、perf、ci
注意:歸屬透過 ~/.claude/settings.json 全域停用 注意:若要停用共同作者歸屬,請在 `~/.claude/settings.json` 中設定 `"includeCoAuthoredBy": false`Claude Code 預設會附加 `Co-Authored-By`,而 ECC 不會隨附這個設定
## Pull Request 工作流程 ## Pull Request 工作流程

View file

@ -9,7 +9,7 @@
Types: feat, fix, refactor, docs, test, chore, perf, ci Types: feat, fix, refactor, docs, test, chore, perf, ci
Note: Attribution disabled globally via ~/.claude/settings.json. Note: To disable co-author attribution on commits, set `"includeCoAuthoredBy": false` in `~/.claude/settings.json` (Claude Code appends `Co-Authored-By` by default; ECC does not ship this setting).
## Pull Request Workflow ## Pull Request Workflow

View file

@ -18,10 +18,6 @@ const {
log log
} = require('./utils'); } = require('./utils');
function resolveCreatedTime(stats) {
return stats.birthtimeMs > 0 ? stats.birthtime : stats.ctime;
}
// Session filename pattern: YYYY-MM-DD-[session-id]-session.tmp // Session filename pattern: YYYY-MM-DD-[session-id]-session.tmp
// The session-id is optional (old format) and can include letters, digits, // The session-id is optional (old format) and can include letters, digits,
// underscores, and hyphens, but must not start with a hyphen. // underscores, and hyphens, but must not start with a hyphen.
@ -30,6 +26,18 @@ function resolveCreatedTime(stats) {
// "2026-02-01-ChezMoi_2-session.tmp" // "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$/; 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 * 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") * @param {string} filename - Session filename (e.g., "2026-01-17-abc123-session.tmp" or "2026-01-17-session.tmp")

View file

@ -145,7 +145,10 @@ analyze_observations() {
MAX_ANALYSIS_LINES="${ECC_OBSERVER_MAX_ANALYSIS_LINES:-500}" MAX_ANALYSIS_LINES="${ECC_OBSERVER_MAX_ANALYSIS_LINES:-500}"
observer_tmp_dir="${PROJECT_DIR}/.observer-tmp" observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"
mkdir -p "$observer_tmp_dir" mkdir -p "$observer_tmp_dir"
analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.XXXXXX.jsonl")" # Keep the XXXXXX run at the very end of the template: BSD/macOS mktemp only
# substitutes a trailing X run, so a suffix after it (e.g. `.jsonl`) produces a
# literal, non-random name that wedges every later cycle with "File exists" (#2417).
analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.jsonl.XXXXXX")"
tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file" tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file"
analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0) analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0)
echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE" echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE"

View file

@ -121,7 +121,7 @@ Validate and sanitize all user input before processing.
When creating instincts, determine scope based on these heuristics: 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. **When in doubt, default to `scope: project`** — it's safer to be project-specific and promote later than to contaminate the global space.

View file

@ -135,7 +135,7 @@ fi
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$(dirname "$0")/../scripts/lib/homunculus-dir.sh" . "$(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) # Skip if disabled (check both default and CLV2_CONFIG-derived locations)
if [ -f "$CONFIG_DIR/disabled" ]; then if [ -f "$CONFIG_DIR/disabled" ]; then
@ -279,11 +279,11 @@ _SECRET_RE = re.compile(
) )
import signal import signal
def _ecc_bail(*_): def _clv2_bail(*_):
print("[observe] SIGALRM timeout: parse-error fallback observation dropped before write (#2300)", file=sys.stderr) print("[observe] SIGALRM timeout: parse-error fallback observation dropped before write (#2300)", file=sys.stderr)
sys.exit(0) sys.exit(0)
try: 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) signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception: except Exception:
pass pass
@ -317,11 +317,11 @@ echo "$PARSED" | "$PYTHON_CMD" -c '
import json, sys, os, re import json, sys, os, re
import signal import signal
def _ecc_bail(*_): def _clv2_bail(*_):
print("[observe] SIGALRM timeout: in-flight observation dropped before write (#2300)", file=sys.stderr) print("[observe] SIGALRM timeout: in-flight observation dropped before write (#2300)", file=sys.stderr)
sys.exit(0) sys.exit(0)
try: 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) signal.alarm(8) # self-terminate before the async hook 10s timeout can orphan us (#2278)
except Exception: except Exception:
pass 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. # the lazy-start path above. Both wrap the same read-modify-write below.
should_signal=0 should_signal=0
_ecc_bump_signal_counter() { _clv2_bump_signal_counter() {
if [ -f "$SIGNAL_COUNTER_FILE" ]; then if [ -f "$SIGNAL_COUNTER_FILE" ]; then
counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0) counter=$(cat "$SIGNAL_COUNTER_FILE" 2>/dev/null || echo 0)
# Guard against a corrupt counter file: a non-integer value would abort the # 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 # 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. # a timeout we skip the tick rather than doing an unlocked read-modify-write.
if flock -w 2 8 2>/dev/null; then if flock -w 2 8 2>/dev/null; then
_ecc_bump_signal_counter _clv2_bump_signal_counter
flock -u 8 2>/dev/null || true flock -u 8 2>/dev/null || true
fi fi
exec 8>&- 2>/dev/null || true exec 8>&- 2>/dev/null || true
@ -547,7 +547,7 @@ else
done done
if [ "$_signal_lock_held" -eq 1 ]; then if [ "$_signal_lock_held" -eq 1 ]; then
# Bump only under the held lock -- never an unlocked read-modify-write. # 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 rmdir "$SIGNAL_COUNTER_LOCK" 2>/dev/null || true
trap - EXIT INT TERM trap - EXIT INT TERM
fi fi

View file

@ -21,7 +21,7 @@
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$(dirname "${BASH_SOURCE[0]}")/lib/homunculus-dir.sh" . "$(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_PROJECTS_DIR="${_CLV2_HOMUNCULUS_DIR}/projects"
_CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json" _CLV2_REGISTRY_FILE="${_CLV2_HOMUNCULUS_DIR}/projects.json"

View file

@ -6,7 +6,7 @@
# 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute # 2. XDG_DATA_HOME/ecc-homunculus, when XDG_DATA_HOME is absolute
# 3. HOME/.local/share/ecc-homunculus # 3. HOME/.local/share/ecc-homunculus
_ecc_resolve_homunculus_dir() { _clv2_resolve_homunculus_dir() {
if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then if [ -n "${CLV2_HOMUNCULUS_DIR:-}" ]; then
case "$CLV2_HOMUNCULUS_DIR" in case "$CLV2_HOMUNCULUS_DIR" in
/*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;; /*) printf '%s\n' "$CLV2_HOMUNCULUS_DIR"; return 0 ;;

View file

@ -7,7 +7,7 @@ OLD="${HOME}/.claude/homunculus"
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. "$(dirname "$0")/lib/homunculus-dir.sh" . "$(dirname "$0")/lib/homunculus-dir.sh"
NEW="$(_ecc_resolve_homunculus_dir)" NEW="$(_clv2_resolve_homunculus_dir)"
if [ "$NEW" = "$OLD" ]; then if [ "$NEW" = "$OLD" ]; then
echo "Resolved destination equals source ($OLD); nothing to migrate." echo "Resolved destination equals source ($OLD); nothing to migrate."

View file

@ -19,7 +19,6 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest import mock
import pytest import pytest

View file

@ -104,7 +104,7 @@ function buildSandbox() {
path.join(scriptsLibDir, 'homunculus-dir.sh'), path.join(scriptsLibDir, 'homunculus-dir.sh'),
[ [
'#!/bin/bash', '#!/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') ].join('\n')
); );

View file

@ -3,14 +3,14 @@
* *
* observe.sh arms a signal.SIGALRM alarm (8s) inside its inline-Python blocks so * 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 * 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 * 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 * 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 * 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). * Claude hook report a block, per the repo's "always exit 0; log to stderr" rule).
* *
* Two checks: * 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). * sys.stderr before sys.exit(0).
* 2. Behavioral check the REAL handler text extracted from observe.sh, when its * 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 * 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'); 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 // that follow it, up to (and including) the first dedented `sys.exit(0)` line at
// the same indentation as the def's body. // the same indentation as the def's body.
function extractHandlers(src) { function extractHandlers(src) {
const lines = src.split('\n'); const lines = src.split('\n');
const handlers = []; const handlers = [];
for (let i = 0; i < lines.length; i += 1) { 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]]; const body = [lines[i]];
for (let j = i + 1; j < lines.length; j += 1) { for (let j = i + 1; j < lines.length; j += 1) {
// Stop when we hit a line that is not indented (next top-level stmt). // 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 // The #2300 timeout handlers are the ones that log the `[observe] SIGALRM
// timeout` marker. Selecting by marker (rather than by array index) keeps the // timeout` marker. Selecting by marker (rather than by array index) keeps the
// behavioral check pinned to the timeout handlers even if an unrelated // 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 => const timeoutHandlers = handlers.filter(body =>
body.includes('[observe] SIGALRM timeout') 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( assert.ok(
handlers.length >= 2, handlers.length >= 2,
`expected >= 2 _ecc_bail handlers, found ${handlers.length}` `expected >= 2 _clv2_bail handlers, found ${handlers.length}`
); );
assert.ok( assert.ok(
timeoutHandlers.length >= 2, 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) => { handlers.forEach((body, idx) => {
const stderrIdx = body.indexOf('file=sys.stderr'); const stderrIdx = body.indexOf('file=sys.stderr');
const exitIdx = body.indexOf('sys.exit(0)'); 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) => { handlers.forEach((body, idx) => {
assert.ok( assert.ok(
/sys\.exit\(0\)/.test(body), /sys\.exit\(0\)/.test(body),
@ -160,7 +160,7 @@ function runHandlerTimeout(python, handler) {
const program = [ const program = [
'import sys, signal, time', 'import sys, signal, time',
handler, handler,
'signal.signal(signal.SIGALRM, _ecc_bail)', 'signal.signal(signal.SIGALRM, _clv2_bail)',
'signal.alarm(1)', 'signal.alarm(1)',
'time.sleep(3)', 'time.sleep(3)',
'print("REACHED_END_SHOULD_NOT_HAPPEN")', '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 // the worst case. A behavioral check on only one handler would not catch a
// regression that silenced another. // regression that silenced another.
timeoutHandlers.forEach((handler, idx) => { 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(); const python = findPython();
if (!python) { if (!python) {
// Fail fast rather than returning (which the harness would record as a // Fail fast rather than returning (which the harness would record as a

View file

@ -0,0 +1,58 @@
/**
* Regression test for #2417 mktemp template portability in observer-loop.sh
*
* BSD/macOS mktemp only substitutes a trailing run of X characters. The
* observer-loop analysis template must therefore keep the randomized X run at
* the end of the quoted template string.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
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 repoRoot = path.resolve(__dirname, '..', '..');
const observerLoopPath = path.join(
repoRoot,
'skills',
'continuous-learning-v2',
'agents',
'observer-loop.sh'
);
console.log('\n=== Observer-loop mktemp portability regression (#2417) ===\n');
test('every mktemp template ends with the randomized X run', () => {
const content = fs.readFileSync(observerLoopPath, 'utf8');
const mktempTemplates = [...content.matchAll(/mktemp\s+"([^"]+)"/g)].map(match => match[1]);
assert.ok(mktempTemplates.length > 0, 'expected at least one mktemp template');
for (const template of mktempTemplates) {
assert.ok(
/X+$/.test(template),
`mktemp template must end with Xs for BSD/macOS portability: ${template}`
);
}
});
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);

View file

@ -375,7 +375,7 @@ test('observe.sh creates counter file and increments on each call', () => {
path.join(scriptsLibDir, 'homunculus-dir.sh'), path.join(scriptsLibDir, 'homunculus-dir.sh'),
[ [
'#!/bin/bash', '#!/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') ].join('\n')
); );

View file

@ -123,6 +123,40 @@ function runTests() {
} }
})) passed++; else failed++; })) 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', () => { if (test('installs Cursor configs and writes install-state', () => {
const homeDir = createTempDir('install-apply-home-'); const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-'); const projectDir = createTempDir('install-apply-project-');