diff --git a/.agents/skills/module-regression/SKILL.md b/.agents/skills/module-regression/SKILL.md
index 9210c6321..d8186cbfc 100644
--- a/.agents/skills/module-regression/SKILL.md
+++ b/.agents/skills/module-regression/SKILL.md
@@ -69,7 +69,7 @@ Related test points: TEST-ORDER-001, TEST-REFUND-003
Invoke `module-regression` through the host's skill entry point, such as `$module-regression` in Codex. The current session plans and reports the audit. Repository commands run through the trusted host runner within existing user authorization; ask only when a command needs new authority. Successful exit codes are regression evidence, never canonical ECC completion or integration authority.
-1. **List changes** with `git status -s` and `git diff --name-only`, then map them to ledger modules.
+1. **List changes** after selecting and recording the audit base, using the PR merge-base or last verified green commit. Combine `git diff --name-only HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist.
2. **Resolve propagation** from each module's downstream list and rule.
3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`.
4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code.
diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md
index 0d9331553..df33ae715 100644
--- a/.opencode/commands/instinct-status.md
+++ b/.opencode/commands/instinct-status.md
@@ -16,7 +16,7 @@ fallback), then run the instinct CLI. This avoids reading a stale legacy
active under `~/.claude/plugins/cache/...` (#2037).
```bash
-ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i {
+const server = net.createServer(client => {
const upstream = net.connect(targetPort, '127.0.0.1');
client.pipe(upstream).pipe(client);
const drop = () => {
@@ -47,6 +47,11 @@ net.createServer(client => {
upstream.on('close', drop);
client.setTimeout(30000, drop);
upstream.setTimeout(30000, drop);
-}).listen(listenPort, '0.0.0.0', () => {
+});
+server.on('error', error => {
+ console.error(`codex-auth-relay: ${error.code}: ${error.message}`);
+ process.exit(1);
+});
+server.listen(listenPort, '0.0.0.0', () => {
console.log(`codex-auth-relay: 0.0.0.0:${listenPort} -> 127.0.0.1:${targetPort}`);
});
diff --git a/docs/ja-JP/commands/auto-update.md b/docs/ja-JP/commands/auto-update.md
index 167be9d0f..d7df5de8b 100644
--- a/docs/ja-JP/commands/auto-update.md
+++ b/docs/ja-JP/commands/auto-update.md
@@ -11,7 +11,7 @@ ECCをアップストリームリポジトリから更新し、元のインス
```bash
# 何も変更せずに更新をプレビュー
-ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot({probe:p.join('scripts','auto-update.js')})}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) {
```bash
node -e "
-const aa = require((function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist.
2. **Resolve propagation** from each module's downstream list and rule.
3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`.
4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code.
diff --git a/scripts/lib/gateguard-search-evidence.js b/scripts/lib/gateguard-search-evidence.js
index e8d4cf086..567d41f0d 100644
--- a/scripts/lib/gateguard-search-evidence.js
+++ b/scripts/lib/gateguard-search-evidence.js
@@ -374,11 +374,28 @@ function createSearchEvidence({ quoteAwareSegments, commandBasename, SHELL_SEGME
return flags;
}
+ function powershellPathInput(rawCommand) {
+ let quote = null;
+ let command = '';
+ for (let i = 0; i < rawCommand.length; i += 1) {
+ const ch = rawCommand[i];
+ // The shared shell tokenizer concatenates adjacent quoted spans. In
+ // PowerShell doubled apostrophes represent a literal apostrophe instead.
+ // Refuse that unsupported form rather than crediting a different scope.
+ if (ch === "'" && quote === "'" && rawCommand[i + 1] === "'") return null;
+ if (ch === "'" && quote !== '"') quote = quote === "'" ? null : "'";
+ else if (ch === '"' && quote !== "'") quote = quote === '"' ? null : '"';
+ command += ch === '\\' && quote !== "'" ? '\\\\' : ch;
+ }
+ return command;
+ }
+
function shellSearchEvidence(rawCommand, toolName, dirsTrusted, misses) {
if (typeof rawCommand !== 'string' || !rawCommand || rawCommand.length > MAX_SEARCH_COMMAND_CHARS) return [];
if (AMBIGUOUS_SHELL_PATTERN.test(rawCommand)) return [];
// PowerShell escapes with a backtick, so a backslash there is a path separator, not an escape.
- const command = toolName === 'PowerShell' ? rawCommand.replace(/\\/g, '\\\\') : rawCommand;
+ const command = toolName === 'PowerShell' ? powershellPathInput(rawCommand) : rawCommand;
+ if (command === null) return [];
const pipeFlags = segmentPipeFlags(command);
const evidence = [];
let trusted = dirsTrusted;
diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js
index a32deb5ab..5adc3deb1 100644
--- a/scripts/lib/resolve-ecc-root.js
+++ b/scripts/lib/resolve-ecc-root.js
@@ -102,35 +102,22 @@ function resolveEccRoot(options = {}) {
}
}
- // Plugin cache — Claude Code stores marketplace plugins under
- // ~/.claude/plugins/cache////
- try {
- for (const slug of PLUGIN_CACHE_SLUGS) {
- const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug);
- const orgDirs = fs.readdirSync(cacheBase, { withFileTypes: true });
-
- for (const orgEntry of orgDirs) {
- if (!orgEntry.isDirectory()) continue;
- const orgPath = path.join(cacheBase, orgEntry.name);
-
- let versionDirs;
- try {
- versionDirs = fs.readdirSync(orgPath, { withFileTypes: true });
- } catch {
- continue;
- }
-
- for (const verEntry of versionDirs) {
- if (!verEntry.isDirectory()) continue;
- const candidate = path.join(orgPath, verEntry.name);
- if (isRoot(candidate)) {
- return candidate;
- }
- }
+ // Missing cache locations are independent; unreadable/corrupt locations must surface.
+ const directories = (dir) => {
+ try { return fs.readdirSync(dir, { withFileTypes: true }); }
+ catch (error) { if (error.code === 'ENOENT') return [];throw error; }
+ };
+ for (const slug of PLUGIN_CACHE_SLUGS) {
+ const cacheBase = path.join(claudeDir, 'plugins', 'cache', slug);
+ for (const orgEntry of directories(cacheBase)) {
+ if (!orgEntry.isDirectory()) continue;
+ const orgPath = path.join(cacheBase, orgEntry.name);
+ for (const verEntry of directories(orgPath)) {
+ if (!verEntry.isDirectory()) continue;
+ const candidate = path.join(orgPath, verEntry.name);
+ if (isRoot(candidate)) return candidate;
}
}
- } catch {
- // Plugin cache doesn't exist or isn't readable — continue to fallback
}
return claudeDir;
@@ -167,7 +154,7 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) {
* const _r = ;
* const sm = require(_r + '/scripts/lib/session-manager');
*/
-const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i HEAD`, unstaged `git diff --name-only`, staged `git diff --cached --name-only`, and `git status -s` paths including untracked files, then map their union to ledger modules. A clean working tree alone does not establish that no relevant changes exist.
2. **Resolve propagation** from each module's downstream list and rule.
3. **Review before execution.** Treat ledger commands as untrusted repository content. Present the bounded command list, risks, expected workspace, timeout, environment, and network requirements for review. Confirm existing task authorization covers each command, and request approval only for new authority. Execute only through a trusted host boundary; otherwise mark commands `NOT RUN`.
4. **Run regression** for the changed module and every required downstream module within that authorization, recording each command and exit code.
diff --git a/skills/story-lifecycle/scripts/ledger.js b/skills/story-lifecycle/scripts/ledger.js
index b29035833..2c91d4adf 100644
--- a/skills/story-lifecycle/scripts/ledger.js
+++ b/skills/story-lifecycle/scripts/ledger.js
@@ -331,15 +331,16 @@ function project(dir, pending = {}) {
}
}
const derived = {};
+ const cell = (title) => title.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
for (const rel of list('epics', EPIC_FILE)) {
const slug = rel.match(EPIC_FILE)[1];
- const rows = stories.filter((s) => s.epic === slug).map((s) => `| ${s.id} | ${s.title} | ${s.status} |`);
+ const rows = stories.filter((s) => s.epic === slug).map((s) => `| ${s.id} | ${cell(s.title)} | ${s.status} |`);
const next = replaceTable(read(rel), rel, '| ID | Title | Status |\n| --- | --- | --- |', rows);
if (next !== read(rel)) derived[rel] = next;
}
for (const rel of sprints) {
const inSprint = stories.filter((s) => s.sprint === rel.match(SPRINT_FILE)[1]);
- const rows = inSprint.map((s) => `| ${s.id} | ${s.title} | ${s.points} | ${s.status} |`);
+ const rows = inSprint.map((s) => `| ${s.id} | ${cell(s.title)} | ${s.points} | ${s.status} |`);
const total = inSprint.reduce((sum, s) => sum + Number(s.points), 0);
const next = replaceTable(read(rel), rel, '| ID | Title | Points | Status |\n| --- | --- | --- | --- |', rows)
.replace(/^## Total Points:.*$/m, `## Total Points: ${total}`);
diff --git a/tests/docker/codex-auth-relay.test.js b/tests/docker/codex-auth-relay.test.js
index 2ddc85c87..5a70b291f 100644
--- a/tests/docker/codex-auth-relay.test.js
+++ b/tests/docker/codex-auth-relay.test.js
@@ -70,3 +70,21 @@ for (const port of ['1455', '24567']) {
assert.equal(child.stdout, '');
});
}
+
+test('occupied listener reports a bounded relay diagnostic without a startup banner', { timeout: 5000 }, async t => {
+ const occupied = net.createServer();
+ occupied.listen(0, '0.0.0.0');
+ await once(occupied, 'listening');
+ t.after(() => new Promise(resolve => occupied.close(resolve)));
+ const port = occupied.address().port;
+ const child = spawn(process.execPath, [script], { env: { ...process.env,
+ ECC_RELAY_LISTEN: String(port), ECC_RELAY_TARGET: String(port === 1455 ? 1456 : 1455) } });
+ let stderr = '';let stdout = '';
+ child.stderr.on('data', chunk => { stderr += chunk; });
+ child.stdout.on('data', chunk => { stdout += chunk; });
+ const [code] = await once(child, 'close');
+ assert.equal(code, 1);
+ assert.match(stderr, /^codex-auth-relay: .*EADDRINUSE/m);
+ assert.doesNotMatch(stderr, /Unhandled 'error' event/);
+ assert.equal(stdout, '');
+});
diff --git a/tests/docs/story-lifecycle-errors.test.js b/tests/docs/story-lifecycle-errors.test.js
index 42c9dc46b..e1dc7e508 100644
--- a/tests/docs/story-lifecycle-errors.test.js
+++ b/tests/docs/story-lifecycle-errors.test.js
@@ -36,7 +36,7 @@ function fixture(update = false) {
const changes = { [update ? 'update' : 'create']: Object.fromEntries(ids.map(id => [`${id}.md`, story(id, update ? 'in-progress' : 'todo')])) };
return { root, ids, before, registryBefore: snapshot(path.join(root, '.git/story-lifecycle')), file: input(root, changes) };
}
-function fault(root, { _update = false, recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) {
+function fault(root, { recoveryFailure = false, cleanupFailure = false, journalRemovalFailure = false, releaseFailure = false } = {}) {
const file = path.join(root, 'bounded-fault.cjs');
fs.writeFileSync(file, `const fs=require('node:fs');const path=require('node:path');
const dir=${JSON.stringify(path.join(root, '.stories'))};const trace=${JSON.stringify(path.join(root, 'trace.json'))};
@@ -75,7 +75,7 @@ const tests = [
ok(run(f.root, ['recover']));restored(f);
}],
['failure after the committed journal marker does not undo a completed update', () => {
- const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, cleanupFailure: true }));
+ const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { cleanupFailure: true }));
assert.equal(result.status, 1);assert.match(result.stderr, /CLEANUP committed directory refused/);
for (const id of f.ids) assert.equal(fs.readFileSync(path.join(f.root, '.stories', `${id}.md`), 'utf8'), story(id, 'in-progress'));
assert.ok(!fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));
@@ -100,7 +100,7 @@ const tests = [
restored(f);
}]),
['rollback failure reports both errors, retains journal, and explicit retry restores all bytes', () => {
- const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { update: true, recoveryFailure: true }));
+ const f = fixture(true);const result = run(f.root, ['apply', '--input', f.file], fault(f.root, { recoveryFailure: true }));
assert.equal(result.status, 1);assert.match(result.stderr, /PRIMARY ordinary second publication/);assert.match(result.stderr, /RECOVERY backup restore refused/);assert.match(result.stderr, /recovery pending/i);
assert.ok(fs.existsSync(path.join(f.root, '.stories/.txn/journal.json')));assert.ok(!fs.existsSync(path.join(f.root, '.stories/.lock')));
const trace = JSON.parse(fs.readFileSync(path.join(f.root, 'trace.json')));assert.ok(trace.recoveryLocks.length > 0);assert.ok(trace.recoveryLocks.every(Boolean));
diff --git a/tests/docs/story-lifecycle-table-projection.test.js b/tests/docs/story-lifecycle-table-projection.test.js
index ee7adef81..893949a1d 100644
--- a/tests/docs/story-lifecycle-table-projection.test.js
+++ b/tests/docs/story-lifecycle-table-projection.test.js
@@ -57,6 +57,21 @@ function assertStable(root, rel, expected) {
}
const tests = [
+ ...['Sign in | recover', String.raw`Sign\|in | recover`].flatMap(title => ['apply', 'reconcile'].map(command => [
+ `${command} preserves title table boundaries for ${JSON.stringify(title)}`, () => {
+ const root = newRepo();const [id] = run(root, ['allocate', '--epic', 'auth-flow']).ids;
+ write(root, 'sprints/sprint-1.md', `# Sprint 1\n\n${MARKER}\n${SPRINT_HEADER}\n\n## Total Points: 0\n`);
+ const raw = story(id, 'todo', '1').replace('Sign in', title);
+ if (command === 'apply') apply(root, { create: { [`${id}.md`]: raw } });
+ else { apply(root, { create: { [`${id}.md`]: story(id, 'todo', '1') } });write(root, `${id}.md`, raw);run(root, ['reconcile']); }
+ const cell = title === 'Sign in | recover' ? String.raw`Sign in \| recover` : String.raw`Sign\\\|in \| recover`;
+ assert.ok(read(root, 'epics/auth-flow.md').includes(`| ${id} | ${cell} | todo |\n`));
+ assert.ok(read(root, 'sprints/sprint-1.md').includes(`| ${id} | ${cell} | 3 | todo |\n`));
+ assert.equal(read(root, `${id}.md`), raw, 'source title bytes stay unchanged');
+ assertStable(root, 'epics/auth-flow.md', read(root, 'epics/auth-flow.md'));
+ assertStable(root, 'sprints/sprint-1.md', read(root, 'sprints/sprint-1.md'));
+ },
+ ])),
...['epic', 'sprint'].flatMap(kind => ['apply', 'reconcile'].flatMap(command => ['', '\n'].map(gap => [
`${command} preserves a handwritten ${kind} table below an empty marker with gap ${JSON.stringify(gap)}`, () => {
const root = newRepo();const rel = kind === 'epic' ? 'epics/auth-flow.md' : 'sprints/sprint-1.md';
diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js
index 0e7295dbf..86d090803 100644
--- a/tests/lib/command-plugin-root.test.js
+++ b/tests/lib/command-plugin-root.test.js
@@ -129,6 +129,37 @@ for (const name of ['auto-update', 'instinct-status', 'skill-health', 'skill-hos
});
}
}
+for (const broken of [false, true]) {
+ test(`auto-update legacy-only cache ${broken ? 'rejects corrupt current cache before follow-up' : 'runs the actual selected updater'}`, () => {
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-legacy-update-'));
+ try {
+ const root = path.join(temp, 'plugins/cache/everything-claude-code/vendor/1.0.0');
+ fs.mkdirSync(path.join(root, 'scripts/lib'), { recursive: true });
+ fs.mkdirSync(path.join(root, ECC_SKILL_SENTINEL), { recursive: true });
+ fs.copyFileSync(path.join(__dirname, '../../scripts/lib/resolve-ecc-root.js'), path.join(root, 'scripts/lib/resolve-ecc-root.js'));
+ fs.writeFileSync(path.join(root, 'scripts/lib/utils.js'), '// fixture');
+ fs.writeFileSync(path.join(root, 'scripts/auto-update.js'), "console.log('UPDATER:'+__dirname+':'+process.argv.slice(2).join(','))");
+ if (broken) fs.writeFileSync(path.join(temp, 'plugins/cache/ecc'), 'occupied non-directory');
+ const env = { ...process.env, HOME: temp, USERPROFILE: temp, CLAUDE_CONFIG_DIR: temp };
+ delete env.CLAUDE_PLUGIN_ROOT;
+ const [bash] = resolveWindowsBashCandidates(['bash.exe', 'bash'], { env });
+ const shell = process.platform === 'win32' ? bash : 'bash';
+ const doc = fs.readFileSync(path.join(__dirname, '../../commands/auto-update.md'), 'utf8');
+ const assignment = doc.split('\n').find(line => line.startsWith('ECC_ROOT='));
+ const followup = broken ? 'node -e "console.log(\'FOLLOWUP_INVOKED\')"' : 'node "$ECC_ROOT/scripts/auto-update.js" --dry-run';
+ const result = spawnSync(shell, ['-s'], { input: assignment + '\n' + followup + '\n', env, cwd: temp, encoding: 'utf8' });
+ const { resolveEccRoot } = require('../../scripts/lib/resolve-ecc-root');
+ const options = { envRoot: '', configDir: temp, probe: path.join('scripts', 'auto-update.js') };
+ if (broken) {
+ assert.equal(result.status, 1);assert.match(result.stderr, /ENOTDIR/);assert.equal(result.stdout, '');
+ assert.throws(() => resolveEccRoot(options), error => error.code === 'ENOTDIR');
+ } else {
+ assert.equal(result.status, 0, result.stderr);assert.equal(result.stdout.trim(), `UPDATER:${fs.realpathSync(path.join(root, 'scripts'))}:--dry-run`);
+ assert.equal(resolveEccRoot(options), root);
+ }
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
+ });
+}
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
diff --git a/tests/lib/gateguard-search-evidence.test.js b/tests/lib/gateguard-search-evidence.test.js
index a39e56e6f..7045359c4 100644
--- a/tests/lib/gateguard-search-evidence.test.js
+++ b/tests/lib/gateguard-search-evidence.test.js
@@ -233,6 +233,22 @@ test('a search confined to the target file name is a lookup, not a search', () =
assert.strictEqual((findClosestMiss(lookupOfModule, testTarget, false, data) || {}).reason, 'excluded');
});
+test('PowerShell single-quoted backslash filters preserve inclusion and exclusion scope', () => {
+ const { findCreditingSearch, findClosestMiss } = loadHook();
+ const target = String.raw`C:\repo\src\lexer\handler.py`;
+ const data = { cwd: String.raw`C:\repo` };
+ for (const command of [String.raw`rg -g 'src\lexer\*.py' handler .`, String.raw`rg handler src\lexer`]) {
+ assert.ok(findCreditingSearch(filterScan([filterSearch('PowerShell', { command })]), target, false, data), command);
+ }
+ const command = String.raw`rg -g '!src\lexer\*.py' handler .`;
+ const scan = filterScan([filterSearch('PowerShell', { command })]);
+ assert.equal(findCreditingSearch(scan, target, false, data), null);
+ assert.equal(findClosestMiss(scan, target, false, data).reason, 'excluded');
+ const ambiguous = String.raw`rg -g 'src''\lexer\*.py' handler .`;
+ assert.equal(findCreditingSearch(filterScan([filterSearch('PowerShell', { command: ambiguous })]), target, false, data), null,
+ 'unsupported PowerShell literal-apostrophe syntax must never credit a different path');
+});
+
fs.rmSync(transcriptDir, { recursive: true, force: true });
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);