diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 50b0cc02..7ebda383 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -29,6 +29,9 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts
+ - name: Run supply-chain IOC scan
+ run: npm run security:ioc-scan
+
- name: Verify OpenCode package payload
run: node tests/scripts/build-opencode.test.js
diff --git a/.github/workflows/reusable-release.yml b/.github/workflows/reusable-release.yml
index 901ac526..62839e74 100644
--- a/.github/workflows/reusable-release.yml
+++ b/.github/workflows/reusable-release.yml
@@ -53,6 +53,9 @@ jobs:
- name: Install dependencies
run: npm ci --ignore-scripts
+ - name: Run supply-chain IOC scan
+ run: npm run security:ioc-scan
+
- name: Verify OpenCode package payload
run: node tests/scripts/build-opencode.test.js
diff --git a/package.json b/package.json
index 8c30dca4..207c81bc 100644
--- a/package.json
+++ b/package.json
@@ -63,6 +63,7 @@
"rules/",
"schemas/",
"scripts/catalog.js",
+ "scripts/ci/scan-supply-chain-iocs.js",
"scripts/consult.js",
"scripts/auto-update.js",
"scripts/claw.js",
@@ -74,6 +75,7 @@
"scripts/harness-adapter-compliance.js",
"scripts/harness-audit.js",
"scripts/observability-readiness.js",
+ "scripts/platform-audit.js",
"scripts/hooks/",
"scripts/install-apply.js",
"scripts/install-plan.js",
@@ -293,6 +295,7 @@
"harness:adapters": "node scripts/harness-adapter-compliance.js",
"harness:audit": "node scripts/harness-audit.js",
"observability:ready": "node scripts/observability-readiness.js",
+ "platform:audit": "node scripts/platform-audit.js",
"security:ioc-scan": "node scripts/ci/scan-supply-chain-iocs.js",
"claw": "node scripts/claw.js",
"orchestrate:status": "node scripts/orchestration-status.js",
diff --git a/scripts/ci/scan-supply-chain-iocs.js b/scripts/ci/scan-supply-chain-iocs.js
index 7394288b..15e2e6e6 100755
--- a/scripts/ci/scan-supply-chain-iocs.js
+++ b/scripts/ci/scan-supply-chain-iocs.js
@@ -253,7 +253,6 @@ const CRITICAL_TEXT_INDICATORS = [
'seed2.getsession.org',
'seed3.getsession.org',
'signalservice',
- 'snode',
'git-tanstack.com',
'litter.catbox.moe/h8nc9u.js',
'litter.catbox.moe/7rrc6l.mjs',
@@ -620,7 +619,9 @@ function parseArgs(argv) {
const options = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
- if (arg === '--root') {
+ if (arg === '--help' || arg === '-h') {
+ options.help = true;
+ } else if (arg === '--root') {
options.rootDir = argv[++i];
} else if (arg === '--home') {
options.home = true;
@@ -636,6 +637,26 @@ function parseArgs(argv) {
return options;
}
+function printHelp() {
+ console.log(`Usage: node scripts/ci/scan-supply-chain-iocs.js [options]
+
+Scan dependency manifests, lockfiles, installed package payloads, and AI-tool
+persistence paths for active supply-chain IOC markers.
+
+Options:
+ --root
Directory to scan (default: repo root)
+ --home Also scan user-level Claude, VS Code, LaunchAgent, systemd,
+ and /tmp persistence targets
+ --home-dir Home directory to use with --home
+ --json Emit JSON instead of text
+ --help, -h Show this help
+
+Examples:
+ node scripts/ci/scan-supply-chain-iocs.js --home
+ node scripts/ci/scan-supply-chain-iocs.js --root /path/to/project --json
+`);
+}
+
function printReport(result, json = false) {
if (json) {
console.log(JSON.stringify(result, null, 2));
@@ -658,6 +679,10 @@ function printReport(result, json = false) {
if (require.main === module) {
try {
const options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printHelp();
+ process.exit(0);
+ }
const result = scanSupplyChainIocs(options);
printReport(result, options.json);
process.exit(result.findings.length > 0 ? 1 : 0);
diff --git a/scripts/ecc.js b/scripts/ecc.js
index 5fbeac97..7f5c41d0 100755
--- a/scripts/ecc.js
+++ b/scripts/ecc.js
@@ -45,6 +45,14 @@ const COMMANDS = {
script: 'status.js',
description: 'Query the ECC SQLite state store status summary',
},
+ 'platform-audit': {
+ script: 'platform-audit.js',
+ description: 'Audit GitHub queues, discussions, roadmap, release, and security evidence',
+ },
+ 'security-ioc-scan': {
+ script: 'ci/scan-supply-chain-iocs.js',
+ description: 'Scan dependency and AI-tool persistence surfaces for active supply-chain IOCs',
+ },
sessions: {
script: 'sessions-cli.js',
description: 'List or inspect ECC sessions from the SQLite state store',
@@ -77,6 +85,8 @@ const PRIMARY_COMMANDS = [
'repair',
'auto-update',
'status',
+ 'platform-audit',
+ 'security-ioc-scan',
'sessions',
'work-items',
'session-inspect',
@@ -115,6 +125,8 @@ Examples:
ecc status --json
ecc status --exit-code
ecc status --markdown --write status.md
+ ecc platform-audit --json --allow-untracked docs/drafts/
+ ecc security-ioc-scan --home
ecc sessions
ecc sessions session-active --json
ecc work-items upsert linear-ecc-20 --source linear --source-id ECC-20 --title "Review control-plane contract" --status blocked
diff --git a/scripts/platform-audit.js b/scripts/platform-audit.js
new file mode 100644
index 00000000..1b91e251
--- /dev/null
+++ b/scripts/platform-audit.js
@@ -0,0 +1,630 @@
+#!/usr/bin/env node
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { spawnSync } = require('child_process');
+
+const SCHEMA_VERSION = 'ecc.platform-audit.v1';
+const DEFAULT_REPOS = Object.freeze([
+ 'affaan-m/everything-claude-code',
+ 'affaan-m/agentshield',
+ 'affaan-m/JARVIS',
+ 'ECC-Tools/ECC-Tools',
+ 'ECC-Tools/ECC-website',
+]);
+const DEFAULT_THRESHOLDS = Object.freeze({
+ maxOpenPrs: 20,
+ maxOpenIssues: 20,
+ maxDirtyFiles: 0,
+});
+const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
+const DISCUSSION_QUERY = 'query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }';
+
+function usage() {
+ console.log([
+ 'Usage: node scripts/platform-audit.js [options]',
+ '',
+ 'Operator readiness audit for ECC queue, discussion, roadmap, release, and security evidence.',
+ '',
+ 'Options:',
+ ' --format Output format (default: text)',
+ ' --root Repository root to inspect (default: cwd)',
+ ' --repo GitHub repo to inspect; repeatable',
+ ' --skip-github Skip live GitHub queue/discussion checks',
+ ' --max-open-prs Fail when open PR count is above n (default: 20)',
+ ' --max-open-issues Fail when open issue count is above n (default: 20)',
+ ' --max-dirty-files Fail when blocking dirty file count is above n (default: 0)',
+ ' --allow-untracked Ignore untracked files under path; repeatable',
+ ' --use-env-github-token Keep GITHUB_TOKEN when invoking gh',
+ ' --exit-code Return 2 when the audit is not ready',
+ ' --help, -h Show this help',
+ ].join('\n'));
+}
+
+function readValue(args, index, flagName) {
+ const value = args[index + 1];
+ if (!value || value.startsWith('--')) {
+ throw new Error(`${flagName} requires a value`);
+ }
+ return value;
+}
+
+function parseIntegerFlag(value, flagName) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ throw new Error(`Invalid ${flagName}: ${value}`);
+ }
+ return parsed;
+}
+
+function parseArgs(argv) {
+ const args = argv.slice(2);
+ const parsed = {
+ allowUntracked: [],
+ exitCode: false,
+ format: 'text',
+ help: false,
+ repos: [],
+ root: path.resolve(process.cwd()),
+ skipGithub: false,
+ thresholds: { ...DEFAULT_THRESHOLDS },
+ useEnvGithubToken: false,
+ };
+
+ for (let index = 0; index < args.length; index += 1) {
+ const arg = args[index];
+
+ if (arg === '--help' || arg === '-h') {
+ parsed.help = true;
+ continue;
+ }
+
+ if (arg === '--format') {
+ parsed.format = readValue(args, index, arg).toLowerCase();
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--format=')) {
+ parsed.format = arg.slice('--format='.length).toLowerCase();
+ continue;
+ }
+
+ if (arg === '--root') {
+ parsed.root = path.resolve(readValue(args, index, arg));
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--root=')) {
+ parsed.root = path.resolve(arg.slice('--root='.length));
+ continue;
+ }
+
+ if (arg === '--repo') {
+ parsed.repos.push(readValue(args, index, arg));
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--repo=')) {
+ parsed.repos.push(arg.slice('--repo='.length));
+ continue;
+ }
+
+ if (arg === '--skip-github') {
+ parsed.skipGithub = true;
+ continue;
+ }
+
+ if (arg === '--allow-untracked') {
+ parsed.allowUntracked.push(readValue(args, index, arg));
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--allow-untracked=')) {
+ parsed.allowUntracked.push(arg.slice('--allow-untracked='.length));
+ continue;
+ }
+
+ if (arg === '--max-open-prs') {
+ parsed.thresholds.maxOpenPrs = parseIntegerFlag(readValue(args, index, arg), arg);
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--max-open-prs=')) {
+ parsed.thresholds.maxOpenPrs = parseIntegerFlag(arg.slice('--max-open-prs='.length), '--max-open-prs');
+ continue;
+ }
+
+ if (arg === '--max-open-issues') {
+ parsed.thresholds.maxOpenIssues = parseIntegerFlag(readValue(args, index, arg), arg);
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--max-open-issues=')) {
+ parsed.thresholds.maxOpenIssues = parseIntegerFlag(arg.slice('--max-open-issues='.length), '--max-open-issues');
+ continue;
+ }
+
+ if (arg === '--max-dirty-files') {
+ parsed.thresholds.maxDirtyFiles = parseIntegerFlag(readValue(args, index, arg), arg);
+ index += 1;
+ continue;
+ }
+
+ if (arg.startsWith('--max-dirty-files=')) {
+ parsed.thresholds.maxDirtyFiles = parseIntegerFlag(arg.slice('--max-dirty-files='.length), '--max-dirty-files');
+ continue;
+ }
+
+ if (arg === '--use-env-github-token') {
+ parsed.useEnvGithubToken = true;
+ continue;
+ }
+
+ if (arg === '--exit-code') {
+ parsed.exitCode = true;
+ continue;
+ }
+
+ throw new Error(`Unknown argument: ${arg}`);
+ }
+
+ if (!['text', 'json'].includes(parsed.format)) {
+ throw new Error(`Invalid format: ${parsed.format}. Use text or json.`);
+ }
+
+ parsed.allowUntracked = parsed.allowUntracked.map(normalizeRelativePrefix);
+
+ return parsed;
+}
+
+function normalizeRelativePrefix(value) {
+ return String(value || '')
+ .replace(/\\/g, '/')
+ .replace(/^\.\/+/, '')
+ .replace(/\/+$/, '') + (String(value || '').endsWith('/') ? '/' : '');
+}
+
+function runCommand(command, args, options = {}) {
+ const result = spawnSync(command, args, {
+ cwd: options.cwd,
+ env: options.env || process.env,
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ });
+
+ if (result.error) {
+ throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
+ }
+
+ if (result.status !== 0) {
+ throw new Error(`${command} ${args.join(' ')} failed: ${(result.stderr || result.stdout || '').trim()}`);
+ }
+
+ return result.stdout || '';
+}
+
+function runGhJson(args, options = {}) {
+ const shimPath = process.env.ECC_GH_SHIM;
+ const command = shimPath ? process.execPath : 'gh';
+ const commandArgs = shimPath ? [shimPath, ...args] : args;
+ const env = { ...process.env };
+
+ if (!options.useEnvGithubToken) {
+ delete env.GITHUB_TOKEN;
+ }
+
+ const stdout = runCommand(command, commandArgs, { env });
+ try {
+ return JSON.parse(stdout || 'null');
+ } catch (error) {
+ throw new Error(`gh ${args.join(' ')} returned invalid JSON: ${error.message}`);
+ }
+}
+
+function readText(rootDir, relativePath) {
+ try {
+ return fs.readFileSync(path.join(rootDir, relativePath), 'utf8');
+ } catch (_error) {
+ return '';
+ }
+}
+
+function safeParseJson(text) {
+ if (!text || !text.trim()) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(text);
+ } catch (_error) {
+ return null;
+ }
+}
+
+function includesAll(text, needles) {
+ return needles.every(needle => text.includes(needle));
+}
+
+function buildCheck(id, status, summary, details = {}) {
+ return { id, status, summary, ...details };
+}
+
+function parseGitStatus(output) {
+ const lines = output.split(/\r?\n/).filter(Boolean);
+ const branchLine = lines[0] || '';
+ const dirtyLines = lines.slice(1);
+ return {
+ branch: branchLine.replace(/^##\s*/, '') || null,
+ dirtyLines,
+ };
+}
+
+function isAllowedUntracked(statusLine, allowUntracked) {
+ if (!statusLine.startsWith('?? ')) {
+ return false;
+ }
+
+ const relativePath = statusLine.slice(3).replace(/\\/g, '/');
+ return allowUntracked.some(prefix => relativePath === prefix || relativePath.startsWith(prefix));
+}
+
+function inspectGit(rootDir, options) {
+ try {
+ const parsed = parseGitStatus(runCommand('git', ['status', '--short', '--branch'], { cwd: rootDir }));
+ const ignoredDirty = parsed.dirtyLines.filter(line => isAllowedUntracked(line, options.allowUntracked));
+ const blockingDirty = parsed.dirtyLines.filter(line => !isAllowedUntracked(line, options.allowUntracked));
+
+ return {
+ available: true,
+ branch: parsed.branch,
+ dirtyLines: parsed.dirtyLines,
+ ignoredDirty,
+ blockingDirty,
+ blockingDirtyCount: blockingDirty.length,
+ };
+ } catch (error) {
+ return {
+ available: false,
+ error: error.message,
+ branch: null,
+ dirtyLines: [],
+ ignoredDirty: [],
+ blockingDirty: [],
+ blockingDirtyCount: 0,
+ };
+ }
+}
+
+function discussionNeedsMaintainerTouch(discussion) {
+ if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) {
+ return false;
+ }
+
+ const comments = discussion.comments && Array.isArray(discussion.comments.nodes)
+ ? discussion.comments.nodes
+ : [];
+ return !comments.some(comment => MAINTAINER_ASSOCIATIONS.has(comment.authorAssociation));
+}
+
+function splitRepo(repo) {
+ const [owner, name] = String(repo || '').split('/');
+ if (!owner || !name) {
+ throw new Error(`Invalid repo: ${repo}`);
+ }
+ return { owner, name };
+}
+
+function fetchDiscussionSummary(repo, options) {
+ const { owner, name } = splitRepo(repo);
+ const payload = runGhJson([
+ 'api',
+ 'graphql',
+ '-f',
+ `owner=${owner}`,
+ '-f',
+ `name=${name}`,
+ '-F',
+ 'first=100',
+ '-f',
+ `query=${DISCUSSION_QUERY}`,
+ ], options);
+ const repository = payload && payload.data && payload.data.repository;
+ const discussions = repository && repository.discussions;
+ const nodes = discussions && Array.isArray(discussions.nodes) ? discussions.nodes : [];
+ const needingTouch = nodes.filter(discussionNeedsMaintainerTouch);
+
+ return {
+ enabled: Boolean(repository && repository.hasDiscussionsEnabled),
+ totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0,
+ sampledCount: nodes.length,
+ needingMaintainerTouch: needingTouch.map(discussion => ({
+ number: discussion.number,
+ title: discussion.title,
+ url: discussion.url,
+ updatedAt: discussion.updatedAt,
+ })),
+ };
+}
+
+function fetchGithubRepo(repo, options) {
+ const prs = runGhJson([
+ 'pr',
+ 'list',
+ '--repo',
+ repo,
+ '--state',
+ 'open',
+ '--json',
+ 'number,title,isDraft,mergeStateStatus,updatedAt,url,author',
+ ], options);
+ const issues = runGhJson([
+ 'issue',
+ 'list',
+ '--repo',
+ repo,
+ '--state',
+ 'open',
+ '--json',
+ 'number,title,updatedAt,url,author,labels',
+ ], options);
+ const discussionSummary = fetchDiscussionSummary(repo, options);
+
+ return {
+ repo,
+ openPrs: Array.isArray(prs) ? prs.length : 0,
+ openIssues: Array.isArray(issues) ? issues.length : 0,
+ discussions: discussionSummary,
+ dirtyPrs: (Array.isArray(prs) ? prs : []).filter(pr => pr.mergeStateStatus === 'DIRTY').map(pr => ({
+ number: pr.number,
+ title: pr.title,
+ url: pr.url,
+ })),
+ };
+}
+
+function buildGithubReport(options) {
+ const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS;
+
+ if (options.skipGithub) {
+ return {
+ skipped: true,
+ repos: repos.map(repo => ({ repo, skipped: true })),
+ totals: {
+ openPrs: 0,
+ openIssues: 0,
+ discussionsNeedingMaintainerTouch: 0,
+ dirtyPrs: 0,
+ errors: 0,
+ },
+ };
+ }
+
+ const repoReports = repos.map(repo => {
+ try {
+ return fetchGithubRepo(repo, options);
+ } catch (error) {
+ return {
+ repo,
+ error: error.message,
+ openPrs: 0,
+ openIssues: 0,
+ discussions: {
+ enabled: false,
+ totalCount: 0,
+ sampledCount: 0,
+ needingMaintainerTouch: [],
+ },
+ dirtyPrs: [],
+ };
+ }
+ });
+
+ return {
+ skipped: false,
+ repos: repoReports,
+ totals: {
+ openPrs: repoReports.reduce((sum, repo) => sum + repo.openPrs, 0),
+ openIssues: repoReports.reduce((sum, repo) => sum + repo.openIssues, 0),
+ discussionsNeedingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0),
+ dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0),
+ errors: repoReports.filter(repo => repo.error).length,
+ },
+ };
+}
+
+function buildLocalEvidenceChecks(rootDir) {
+ const packageJson = safeParseJson(readText(rootDir, 'package.json')) || {};
+ const packageScripts = packageJson.scripts || {};
+ const roadmap = readText(rootDir, 'docs/ECC-2.0-GA-ROADMAP.md');
+ const progressSync = readText(rootDir, 'docs/architecture/progress-sync-contract.md');
+ const supplyChain = readText(rootDir, 'docs/security/supply-chain-incident-response.md');
+ const evidence = readText(rootDir, 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md');
+
+ return [
+ buildCheck(
+ 'platform-audit-cli-surface',
+ packageScripts['platform:audit'] === 'node scripts/platform-audit.js' ? 'pass' : 'fail',
+ 'package.json exposes the platform audit command',
+ { fix: 'Add "platform:audit": "node scripts/platform-audit.js" to package.json.' }
+ ),
+ buildCheck(
+ 'roadmap-linear-mirror',
+ includesAll(roadmap, ['linear.app/itomarkets/project/ecc-platform-roadmap', 'ITO-44', 'ITO-59']) ? 'pass' : 'fail',
+ 'repo roadmap mirrors the Linear roadmap and security/operator lanes',
+ { path: 'docs/ECC-2.0-GA-ROADMAP.md' }
+ ),
+ buildCheck(
+ 'progress-sync-contract',
+ includesAll(progressSync, ['GitHub PRs/issues/discussions', 'Linear project', 'local handoff', 'repo roadmap', 'scripts/work-items.js']) ? 'pass' : 'fail',
+ 'progress sync contract names GitHub, Linear, handoff, roadmap, and work-items surfaces',
+ { path: 'docs/architecture/progress-sync-contract.md' }
+ ),
+ buildCheck(
+ 'supply-chain-runbook',
+ includesAll(supplyChain, ['TanStack', 'Mini Shai-Hulud', 'node-ipc', 'scan-supply-chain-iocs.js']) ? 'pass' : 'fail',
+ 'supply-chain runbook covers the current TanStack/Mini Shai-Hulud/node-ipc scanner lane',
+ { path: 'docs/security/supply-chain-incident-response.md' }
+ ),
+ buildCheck(
+ 'release-evidence-current',
+ includesAll(evidence, ['TanStack', 'Mini Shai-Hulud', 'Node IPC follow-up', 'node-ipc', 'IOC scan']) ? 'pass' : 'fail',
+ 'rc.1 evidence includes current supply-chain verification artifacts',
+ { path: 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md' }
+ ),
+ ];
+}
+
+function buildReport(options) {
+ const rootDir = path.resolve(options.root);
+ const git = inspectGit(rootDir, options);
+ const github = buildGithubReport(options);
+ const checks = [];
+
+ checks.push(buildCheck(
+ 'git-worktree-blockers',
+ !git.available ? 'warn' : (git.blockingDirtyCount <= options.thresholds.maxDirtyFiles ? 'pass' : 'fail'),
+ !git.available
+ ? 'git status is unavailable for this root'
+ : `blocking dirty files: ${git.blockingDirtyCount}`,
+ {
+ branch: git.branch,
+ ignoredDirtyCount: git.ignoredDirty.length,
+ blockingDirty: git.blockingDirty,
+ fix: 'Commit, stash, or explicitly allow unrelated untracked files before claiming release readiness.',
+ }
+ ));
+
+ checks.push(buildCheck(
+ 'github-fetch',
+ github.skipped ? 'warn' : (github.totals.errors === 0 ? 'pass' : 'fail'),
+ github.skipped ? 'live GitHub checks skipped' : `GitHub fetch errors: ${github.totals.errors}`,
+ { fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.' }
+ ));
+
+ checks.push(buildCheck(
+ 'github-open-pr-budget',
+ github.totals.openPrs <= options.thresholds.maxOpenPrs ? 'pass' : 'fail',
+ `open PRs: ${github.totals.openPrs}/${options.thresholds.maxOpenPrs}`,
+ { fix: 'Triage, merge, close, or attach open PRs to roadmap issues until under budget.' }
+ ));
+
+ checks.push(buildCheck(
+ 'github-open-issue-budget',
+ github.totals.openIssues <= options.thresholds.maxOpenIssues ? 'pass' : 'fail',
+ `open issues: ${github.totals.openIssues}/${options.thresholds.maxOpenIssues}`,
+ { fix: 'Triage, close, or attach open issues to Linear/project lanes until under budget.' }
+ ));
+
+ checks.push(buildCheck(
+ 'github-discussion-touch',
+ github.totals.discussionsNeedingMaintainerTouch === 0 ? 'pass' : 'fail',
+ `discussions needing maintainer touch: ${github.totals.discussionsNeedingMaintainerTouch}`,
+ { fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' }
+ ));
+
+ checks.push(buildCheck(
+ 'github-conflict-queue',
+ github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
+ `conflicting open PRs: ${github.totals.dirtyPrs}`,
+ { fix: 'Update, rebase, salvage, or close conflicting open PRs.' }
+ ));
+
+ checks.push(...buildLocalEvidenceChecks(rootDir));
+
+ const topActions = checks
+ .filter(check => check.status === 'fail')
+ .map(check => ({
+ id: check.id,
+ summary: check.summary,
+ fix: check.fix || 'Review and remediate this failed check.',
+ }));
+
+ return {
+ schema_version: SCHEMA_VERSION,
+ generatedAt: new Date().toISOString(),
+ root: rootDir,
+ ready: topActions.length === 0,
+ thresholds: options.thresholds,
+ git,
+ github,
+ checks,
+ top_actions: topActions,
+ };
+}
+
+function renderText(report) {
+ const lines = [
+ `ECC Platform Audit: ${report.ready ? 'ready' : 'attention required'}`,
+ `Generated: ${report.generatedAt}`,
+ `Root: ${report.root}`,
+ '',
+ `Git: ${report.git.available ? report.git.branch : 'unavailable'}`,
+ `Blocking dirty files: ${report.git.blockingDirtyCount}`,
+ `Ignored dirty files: ${report.git.ignoredDirty.length}`,
+ '',
+ `GitHub skipped: ${report.github.skipped ? 'yes' : 'no'}`,
+ `Open PRs: ${report.github.totals.openPrs}/${report.thresholds.maxOpenPrs}`,
+ `Open issues: ${report.github.totals.openIssues}/${report.thresholds.maxOpenIssues}`,
+ `Discussions needing maintainer touch: ${report.github.totals.discussionsNeedingMaintainerTouch}`,
+ `Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
+ '',
+ 'Checks:',
+ ];
+
+ for (const check of report.checks) {
+ lines.push(` ${check.status.toUpperCase()} ${check.id}: ${check.summary}`);
+ }
+
+ lines.push('', 'Top actions:');
+ if (report.top_actions.length === 0) {
+ lines.push(' none');
+ } else {
+ for (const action of report.top_actions) {
+ lines.push(` - ${action.id}: ${action.fix}`);
+ }
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+function main() {
+ try {
+ const options = parseArgs(process.argv);
+ if (options.help) {
+ usage();
+ return;
+ }
+
+ const report = buildReport(options);
+ const output = options.format === 'json'
+ ? `${JSON.stringify(report, null, 2)}\n`
+ : renderText(report);
+ process.stdout.write(output);
+
+ if (options.exitCode && !report.ready) {
+ process.exitCode = 2;
+ }
+ } catch (error) {
+ console.error(`Error: ${error.message}`);
+ process.exit(1);
+ }
+}
+
+if (require.main === module) {
+ main();
+}
+
+module.exports = {
+ buildReport,
+ parseArgs,
+ renderText,
+ runGhJson,
+};
diff --git a/tests/ci/scan-supply-chain-iocs.test.js b/tests/ci/scan-supply-chain-iocs.test.js
index 2bbd48d7..e1768313 100755
--- a/tests/ci/scan-supply-chain-iocs.test.js
+++ b/tests/ci/scan-supply-chain-iocs.test.js
@@ -154,6 +154,21 @@ function run() {
});
})) passed++; else failed++;
+ if (test('does not flag benign substrings in clean package scripts', () => {
+ withFixture({
+ 'node_modules/uuid/package.json': JSON.stringify({
+ name: 'uuid',
+ version: '9.0.1',
+ scripts: {
+ test: 'BABEL_ENV=commonjsNode node --throw-deprecation node_modules/.bin/jest test/unit/',
+ },
+ }, null, 2),
+ }, rootDir => {
+ const result = scanSupplyChainIocs({ rootDir });
+ assert.deepStrictEqual(result.findings, []);
+ });
+ })) passed++; else failed++;
+
if (test('rejects malicious optional dependency markers', () => {
withFixture({
'package-lock.json': JSON.stringify({
@@ -241,7 +256,6 @@ function run() {
assert.ok(indicators.includes('claude@users.noreply.github.com'));
assert.ok(indicators.includes('dependabout/'));
assert.ok(indicators.includes('signalservice'));
- assert.ok(indicators.includes('snode'));
});
})) passed++; else failed++;
diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js
index c380623a..7d3d5ad8 100644
--- a/tests/scripts/ecc.test.js
+++ b/tests/scripts/ecc.test.js
@@ -72,6 +72,8 @@ function main() {
assert.match(result.stdout, /consult/);
assert.match(result.stdout, /loop-status/);
assert.match(result.stdout, /work-items/);
+ assert.match(result.stdout, /platform-audit/);
+ assert.match(result.stdout, /security-ioc-scan/);
}],
['delegates explicit install command', () => {
const result = runCli(['install', '--dry-run', '--json', 'typescript']);
@@ -207,6 +209,28 @@ function main() {
assert.strictEqual(result.status, 0, result.stderr);
assert.match(result.stdout, /node scripts\/work-items\.js upsert/);
}],
+ ['supports help for the platform-audit subcommand', () => {
+ const result = runCli(['help', 'platform-audit']);
+ assert.strictEqual(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Usage: node scripts\/platform-audit\.js/);
+ }],
+ ['supports help for the security-ioc-scan subcommand', () => {
+ const result = runCli(['help', 'security-ioc-scan']);
+ assert.strictEqual(result.status, 0, result.stderr);
+ assert.match(result.stdout, /Usage: node scripts\/ci\/scan-supply-chain-iocs\.js/);
+ }],
+ ['delegates security-ioc-scan command', () => {
+ const projectRoot = createTempDir('ecc-cli-ioc-scan-');
+ fs.writeFileSync(
+ path.join(projectRoot, 'package.json'),
+ JSON.stringify({ dependencies: { leftpad: '1.0.0' } }, null, 2)
+ );
+
+ const result = runCli(['security-ioc-scan', '--root', projectRoot, '--json']);
+ assert.strictEqual(result.status, 0, result.stderr);
+ const payload = parseJson(result.stdout);
+ assert.deepStrictEqual(payload.findings, []);
+ }],
['fails on unknown commands instead of treating them as installs', () => {
const result = runCli(['bogus']);
assert.strictEqual(result.status, 1);
diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js
index 07c28c78..6f9f53d1 100644
--- a/tests/scripts/npm-publish-surface.test.js
+++ b/tests/scripts/npm-publish-surface.test.js
@@ -43,6 +43,7 @@ function buildExpectedPublishPaths(repoRoot) {
"manifests",
"scripts/ecc.js",
"scripts/catalog.js",
+ "scripts/ci/scan-supply-chain-iocs.js",
"scripts/consult.js",
"scripts/claw.js",
"scripts/doctor.js",
@@ -54,6 +55,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/list-installed.js",
"scripts/loop-status.js",
"scripts/observability-readiness.js",
+ "scripts/platform-audit.js",
"scripts/skill-create-output.js",
"scripts/repair.js",
"scripts/harness-adapter-compliance.js",
@@ -119,8 +121,10 @@ function main() {
for (const requiredPath of [
"scripts/catalog.js",
+ "scripts/ci/scan-supply-chain-iocs.js",
"scripts/consult.js",
"scripts/work-items.js",
+ "scripts/platform-audit.js",
".gemini/GEMINI.md",
".qwen/QWEN.md",
".claude-plugin/plugin.json",
diff --git a/tests/scripts/platform-audit.test.js b/tests/scripts/platform-audit.test.js
new file mode 100644
index 00000000..573c2e3d
--- /dev/null
+++ b/tests/scripts/platform-audit.test.js
@@ -0,0 +1,328 @@
+/**
+ * Tests for scripts/platform-audit.js
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { execFileSync, spawnSync } = require('child_process');
+
+const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
+
+function createTempDir(prefix) {
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
+}
+
+function cleanup(dirPath) {
+ fs.rmSync(dirPath, { recursive: true, force: true });
+}
+
+function writeFile(rootDir, relativePath, content) {
+ const targetPath = path.join(rootDir, relativePath);
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
+ fs.writeFileSync(targetPath, content);
+}
+
+function seedRepo(rootDir, overrides = {}) {
+ const files = {
+ 'package.json': JSON.stringify({
+ name: 'everything-claude-code',
+ scripts: {
+ 'platform:audit': 'node scripts/platform-audit.js',
+ 'observability:ready': 'node scripts/observability-readiness.js',
+ 'security:ioc-scan': 'node scripts/ci/scan-supply-chain-iocs.js',
+ 'harness:audit': 'node scripts/harness-audit.js'
+ }
+ }, null, 2),
+ 'docs/ECC-2.0-GA-ROADMAP.md': [
+ 'ECC Platform Roadmap',
+ 'https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1',
+ 'ITO-44',
+ 'ITO-59'
+ ].join('\n'),
+ 'docs/architecture/progress-sync-contract.md': [
+ 'GitHub PRs/issues/discussions',
+ 'Linear project',
+ 'local handoff',
+ 'repo roadmap',
+ 'scripts/work-items.js'
+ ].join('\n'),
+ 'docs/security/supply-chain-incident-response.md': [
+ 'TanStack',
+ 'Mini Shai-Hulud',
+ 'node-ipc',
+ 'scan-supply-chain-iocs.js'
+ ].join('\n'),
+ 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md': [
+ 'TanStack',
+ 'Mini Shai-Hulud',
+ 'Node IPC follow-up',
+ 'node-ipc',
+ 'IOC scan'
+ ].join('\n')
+ };
+
+ for (const [relativePath, content] of Object.entries({ ...files, ...overrides })) {
+ if (content === null) {
+ continue;
+ }
+ writeFile(rootDir, relativePath, content);
+ }
+}
+
+function writeGhShim(rootDir, responses) {
+ const shimPath = path.join(rootDir, 'gh-shim.js');
+ fs.writeFileSync(shimPath, `
+const responses = ${JSON.stringify(responses)};
+const args = process.argv.slice(2);
+const key = args.join(' ');
+if (process.env.GITHUB_TOKEN) {
+ console.error('GITHUB_TOKEN should be unset by default');
+ process.exit(42);
+}
+if (!Object.prototype.hasOwnProperty.call(responses, key)) {
+ console.error('Unexpected gh args: ' + key);
+ process.exit(3);
+}
+process.stdout.write(JSON.stringify(responses[key]));
+`);
+ return shimPath;
+}
+
+function run(args = [], options = {}) {
+ const env = {
+ ...process.env,
+ ...(options.env || {})
+ };
+
+ return execFileSync('node', [SCRIPT, ...args], {
+ cwd: options.cwd || path.join(__dirname, '..', '..'),
+ env,
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: 10000
+ });
+}
+
+function runProcess(args = [], options = {}) {
+ const env = {
+ ...process.env,
+ ...(options.env || {})
+ };
+
+ return spawnSync('node', [SCRIPT, ...args], {
+ cwd: options.cwd || path.join(__dirname, '..', '..'),
+ env,
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: 10000
+ });
+}
+
+function test(name, fn) {
+ try {
+ fn();
+ console.log(` PASS ${name}`);
+ return true;
+ } catch (error) {
+ console.log(` FAIL ${name}`);
+ console.log(` Error: ${error.message}`);
+ return false;
+ }
+}
+
+function runTests() {
+ console.log('\n=== Testing platform-audit.js ===\n');
+
+ let passed = 0;
+ let failed = 0;
+
+ if (test('parseArgs accepts supported flags and rejects invalid values', () => {
+ const { parseArgs } = require(SCRIPT);
+ const rootDir = createTempDir('platform-audit-args-');
+
+ try {
+ const parsed = parseArgs([
+ 'node',
+ 'script',
+ '--format=json',
+ `--root=${rootDir}`,
+ '--repo',
+ 'affaan-m/everything-claude-code',
+ '--max-open-prs',
+ '5',
+ '--max-open-issues',
+ '6',
+ '--allow-untracked',
+ 'docs/drafts/'
+ ]);
+
+ assert.strictEqual(parsed.format, 'json');
+ assert.strictEqual(parsed.root, path.resolve(rootDir));
+ assert.deepStrictEqual(parsed.repos, ['affaan-m/everything-claude-code']);
+ assert.strictEqual(parsed.thresholds.maxOpenPrs, 5);
+ assert.strictEqual(parsed.thresholds.maxOpenIssues, 6);
+ assert.deepStrictEqual(parsed.allowUntracked, ['docs/drafts/']);
+
+ assert.throws(() => parseArgs(['node', 'script', '--format', 'xml']), /Invalid format/);
+ assert.throws(() => parseArgs(['node', 'script', '--repo']), /--repo requires a value/);
+ assert.throws(() => parseArgs(['node', 'script', '--max-open-prs', 'x']), /Invalid --max-open-prs/);
+ assert.throws(() => parseArgs(['node', 'script', '--unknown']), /Unknown argument/);
+ } finally {
+ cleanup(rootDir);
+ }
+ })) passed++; else failed++;
+
+ if (test('skip-github report checks local release and security evidence', () => {
+ const projectRoot = createTempDir('platform-audit-local-');
+
+ try {
+ seedRepo(projectRoot);
+ const parsed = JSON.parse(run(['--format=json', `--root=${projectRoot}`, '--skip-github'], { cwd: projectRoot }));
+
+ assert.strictEqual(parsed.schema_version, 'ecc.platform-audit.v1');
+ assert.strictEqual(parsed.ready, true);
+ assert.strictEqual(parsed.github.skipped, true);
+ assert.ok(parsed.checks.some(check => check.id === 'roadmap-linear-mirror' && check.status === 'pass'));
+ assert.ok(parsed.checks.some(check => check.id === 'supply-chain-runbook' && check.status === 'pass'));
+ assert.deepStrictEqual(parsed.top_actions, []);
+ } finally {
+ cleanup(projectRoot);
+ }
+ })) passed++; else failed++;
+
+ if (test('github queue and discussion budgets pass with maintainer touch', () => {
+ const projectRoot = createTempDir('platform-audit-github-pass-');
+
+ try {
+ seedRepo(projectRoot);
+ const shimPath = writeGhShim(projectRoot, {
+ 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': [],
+ 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
+ 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
+ data: {
+ repository: {
+ hasDiscussionsEnabled: true,
+ discussions: {
+ totalCount: 1,
+ nodes: [
+ {
+ number: 73,
+ title: 'Compacting during workflow',
+ url: 'https://github.com/example/discussions/73',
+ updatedAt: '2026-05-15T00:00:00Z',
+ authorAssociation: 'NONE',
+ comments: { nodes: [{ authorAssociation: 'OWNER' }] }
+ }
+ ]
+ }
+ }
+ }
+ }
+ });
+
+ const parsed = JSON.parse(run([
+ '--format=json',
+ `--root=${projectRoot}`,
+ '--repo',
+ 'affaan-m/everything-claude-code'
+ ], {
+ cwd: projectRoot,
+ env: {
+ ECC_GH_SHIM: shimPath,
+ GITHUB_TOKEN: 'must-be-removed'
+ }
+ }));
+
+ assert.strictEqual(parsed.ready, true);
+ assert.strictEqual(parsed.github.totals.openPrs, 0);
+ assert.strictEqual(parsed.github.totals.openIssues, 0);
+ assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0);
+ assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass'));
+ } finally {
+ cleanup(projectRoot);
+ }
+ })) passed++; else failed++;
+
+ if (test('threshold failures and untouched discussions become top actions', () => {
+ const projectRoot = createTempDir('platform-audit-github-fail-');
+
+ try {
+ seedRepo(projectRoot);
+ const prs = Array.from({ length: 3 }, (_, index) => ({
+ number: index + 1,
+ title: `PR ${index + 1}`,
+ isDraft: false,
+ mergeStateStatus: 'CLEAN',
+ updatedAt: '2026-05-15T00:00:00Z',
+ url: `https://github.com/example/pull/${index + 1}`,
+ author: { login: 'contributor' }
+ }));
+ const shimPath = writeGhShim(projectRoot, {
+ 'pr list --repo affaan-m/everything-claude-code --state open --json number,title,isDraft,mergeStateStatus,updatedAt,url,author': prs,
+ 'issue list --repo affaan-m/everything-claude-code --state open --json number,title,updatedAt,url,author,labels': [],
+ 'api graphql -f owner=affaan-m -f name=everything-claude-code -F first=100 -f query=query($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { hasDiscussionsEnabled discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { totalCount nodes { number title url updatedAt authorAssociation comments(first: 20) { nodes { authorAssociation } } } } } }': {
+ data: {
+ repository: {
+ hasDiscussionsEnabled: true,
+ discussions: {
+ totalCount: 1,
+ nodes: [
+ {
+ number: 1239,
+ title: 'Losing context',
+ url: 'https://github.com/example/discussions/1239',
+ updatedAt: '2026-05-15T00:00:00Z',
+ authorAssociation: 'NONE',
+ comments: { nodes: [] }
+ }
+ ]
+ }
+ }
+ }
+ }
+ });
+
+ const parsed = JSON.parse(run([
+ '--format=json',
+ `--root=${projectRoot}`,
+ '--repo',
+ 'affaan-m/everything-claude-code',
+ '--max-open-prs',
+ '2'
+ ], {
+ cwd: projectRoot,
+ env: { ECC_GH_SHIM: shimPath }
+ }));
+
+ assert.strictEqual(parsed.ready, false);
+ assert.ok(parsed.top_actions.some(action => action.id === 'github-open-pr-budget'));
+ assert.ok(parsed.top_actions.some(action => action.id === 'github-discussion-touch'));
+ assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1);
+ } finally {
+ cleanup(projectRoot);
+ }
+ })) passed++; else failed++;
+
+ if (test('cli help and invalid args exit cleanly', () => {
+ const help = runProcess(['--help']);
+ assert.strictEqual(help.status, 0);
+ assert.ok(help.stdout.includes('Usage: node scripts/platform-audit.js'));
+
+ const invalid = runProcess(['--format', 'xml']);
+ assert.strictEqual(invalid.status, 1);
+ assert.ok(invalid.stderr.includes('Invalid format'));
+ })) passed++; else failed++;
+
+ console.log(`\nPassed: ${passed}`);
+ console.log(`Failed: ${failed}`);
+
+ if (failed > 0) {
+ process.exit(1);
+ }
+}
+
+if (require.main === module) {
+ runTests();
+}