Compare commits

...

5 Commits

Author SHA1 Message Date
Affaan Mustafa
f7035b5644 Harden CI installs against supply-chain lifecycle hooks 2026-05-15 17:29:03 -04:00
Affaan Mustafa
6951b8d5d2 Add scheduled supply-chain watch workflow 2026-05-15 16:56:49 -04:00
Affaan Mustafa
6887f2952d Add discussion audit gate 2026-05-15 16:26:57 -04:00
Affaan Mustafa
0b6763463f Add operator readiness dashboard gate 2026-05-15 16:04:11 -04:00
Affaan Mustafa
c0f8c3bc81 Refresh rc1 evidence for AgentShield provenance 2026-05-15 15:07:15 -04:00
20 changed files with 1177 additions and 236 deletions

View File

@@ -68,73 +68,6 @@ jobs:
if: matrix.pm == 'bun'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
# Cache configuration
- name: Get npm cache directory
if: matrix.pm == 'npm'
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Restore npm cache
if: matrix.pm == 'npm'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node }}-npm-
- name: Get pnpm store directory
if: matrix.pm == 'pnpm'
id: pnpm-cache-dir
shell: bash
env:
COREPACK_ENABLE_STRICT: '0'
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
if: matrix.pm == 'pnpm'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node }}-pnpm-
- name: Get yarn cache directory
if: matrix.pm == 'yarn'
id: yarn-cache-dir
shell: bash
run: |
# Try Yarn Berry first, fall back to Yarn v1
if yarn config get cacheFolder >/dev/null 2>&1; then
echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
else
echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
fi
- name: Restore yarn cache
if: matrix.pm == 'yarn'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ matrix.node }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-${{ matrix.node }}-yarn-
- name: Restore bun cache
if: matrix.pm == 'bun'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
# Install dependencies
# COREPACK_ENABLE_STRICT=0 allows pnpm to install even though
# package.json declares "packageManager": "yarn@..."
@@ -142,16 +75,18 @@ jobs:
shell: bash
env:
COREPACK_ENABLE_STRICT: '0'
npm_config_ignore_scripts: 'true'
YARN_ENABLE_SCRIPTS: 'false'
run: |
case "${{ matrix.pm }}" in
npm) npm ci ;;
npm) npm ci --ignore-scripts ;;
# pnpm v10 can fail CI on ignored native build scripts
# (for example msgpackr-extract) even though this repo is Yarn-native
# and pnpm is only exercised here as a compatibility lane.
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
pnpm) pnpm install --ignore-scripts --config.strict-dep-builds=false --no-frozen-lockfile ;;
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
yarn) yarn install ;;
bun) bun install ;;
yarn) yarn install --mode=skip-build ;;
bun) bun install --ignore-scripts ;;
*) echo "Unsupported package manager: ${{ matrix.pm }}" && exit 1 ;;
esac

View File

@@ -59,88 +59,24 @@ jobs:
if: inputs.package-manager == 'bun'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Get npm cache directory
if: inputs.package-manager == 'npm'
id: npm-cache-dir
shell: bash
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
- name: Restore npm cache
if: inputs.package-manager == 'npm'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.npm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-${{ inputs.node-version }}-npm-
- name: Get pnpm store directory
if: inputs.package-manager == 'pnpm'
id: pnpm-cache-dir
shell: bash
env:
COREPACK_ENABLE_STRICT: '0'
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Restore pnpm cache
if: inputs.package-manager == 'pnpm'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ inputs.node-version }}-pnpm-
- name: Get yarn cache directory
if: inputs.package-manager == 'yarn'
id: yarn-cache-dir
shell: bash
run: |
# Try Yarn Berry first, fall back to Yarn v1
if yarn config get cacheFolder >/dev/null 2>&1; then
echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
else
echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
fi
- name: Restore yarn cache
if: inputs.package-manager == 'yarn'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ${{ steps.yarn-cache-dir.outputs.dir }}
key: ${{ runner.os }}-node-${{ inputs.node-version }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node-${{ inputs.node-version }}-yarn-
- name: Restore bun cache
if: inputs.package-manager == 'bun'
continue-on-error: true
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }}
restore-keys: |
${{ runner.os }}-bun-
# COREPACK_ENABLE_STRICT=0 allows pnpm to install even though
# package.json declares "packageManager": "yarn@..."
- name: Install dependencies
shell: bash
env:
COREPACK_ENABLE_STRICT: '0'
npm_config_ignore_scripts: 'true'
YARN_ENABLE_SCRIPTS: 'false'
run: |
case "${{ inputs.package-manager }}" in
npm) npm ci ;;
npm) npm ci --ignore-scripts ;;
# pnpm v10 can fail CI on ignored native build scripts
# (for example msgpackr-extract) even though this repo is Yarn-native
# and pnpm is only exercised here as a compatibility lane.
pnpm) pnpm install --config.strict-dep-builds=false --no-frozen-lockfile ;;
pnpm) pnpm install --ignore-scripts --config.strict-dep-builds=false --no-frozen-lockfile ;;
# Yarn Berry (v4+) removed --ignore-engines; engine checking is no longer a core feature
yarn) yarn install ;;
bun) bun install ;;
yarn) yarn install --mode=skip-build ;;
bun) bun install --ignore-scripts ;;
*) echo "Unsupported package manager: ${{ inputs.package-manager }}" && exit 1 ;;
esac

View File

@@ -0,0 +1,57 @@
name: Supply-Chain Watch
on:
schedule:
- cron: '17 */6 * * *'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
ioc-watch:
name: IOC watch
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20.x'
- name: Install dependencies without lifecycle scripts
run: npm ci --ignore-scripts
- name: Verify registry signatures and advisories
run: |
npm audit signatures
npm audit --audit-level=high
- name: Validate IOC scanner fixtures
run: node tests/ci/scan-supply-chain-iocs.test.js
- name: Generate IOC report
run: |
mkdir -p artifacts
node scripts/ci/scan-supply-chain-iocs.js --json > artifacts/supply-chain-ioc-report.json
- name: Validate workflow hardening rules
run: node scripts/ci/validate-workflow-security.js
- name: Upload IOC report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: supply-chain-ioc-report
path: artifacts/supply-chain-ioc-report.json
retention-days: 14

View File

@@ -34,8 +34,8 @@ As of 2026-05-15:
- `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` records the
queue, discussion, Linear roadmap, ECC Tools access, Mini Shai-Hulud/TanStack
full-campaign follow-up, restore-only CI cache hardening, AgentShield #85
registry-signature verification, ECC-Tools #75 billing-gate tightening, and
PR #1935 `ecc2` current-dir test stabilization evidence refresh.
registry-signature verification, AgentShield #86 evidence-pack CI provenance,
ECC-Tools #75 billing-gate tightening, and PR #1936 release-evidence refresh.
- `npm run harness:audit -- --format json` reports 70/70 on current `main`.
- `npm run observability:ready` reports 21/21 readiness on current `main`,
including the GitHub/Linear/handoff/roadmap progress-sync contract.
@@ -494,11 +494,11 @@ is not complete unless the evidence column exists and has been freshly verified.
| Naming and rename readiness | Naming matrix across package/plugin/docs/social surfaces | `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` records current package, repo, Claude plugin, Codex plugin, OpenCode, and npm availability evidence | Complete for rc.1; post-rc rename remains future work |
| Claude and Codex plugin publication | Contact/submission path with required artifacts and status | Publication readiness, naming matrix, and May 12 dry-run evidence document plugin validation, clean-checkout Claude tag/install smoke, and Codex marketplace CLI shape | Needs explicit approval for real tag/push and marketplace submission |
| Articles, tweets, and announcements | X thread, LinkedIn copy, GitHub release copy, push checklist | Draft launch collateral exists under rc.1 release docs | Needs URL-backed refresh |
| AgentShield enterprise iteration | Policy gates, SARIF, packs, provenance, corpus, HTML reports, exception lifecycle audit, baseline drift Action/CLI surfaces, evidence-pack redaction, harness adapter registry, enterprise research roadmap, supply-chain hardened release path, CI-safe baseline fingerprints, corpus accuracy recommendations, remediation workflow phases, env proxy hijack corpus coverage, Mini Shai-Hulud full-campaign package IOCs | PRs #53, #55-#64, #67-#69, and #78-#84 landed with test evidence; native PDF export deferred in favor of self-contained HTML plus print-to-PDF until explicit enterprise demand appears; `docs/architecture/agentshield-enterprise-research-roadmap.md` now has baseline drift, evidence-pack bundle, redaction, adapter-registry, supply-chain hardening, hashed baseline fingerprints, corpus accuracy recommendation, remediation workflow, env proxy hijack corpus, and Mini Shai-Hulud full-campaign package-table slices landed | Next hosted evidence-pack workflow depth |
| AgentShield enterprise iteration | Policy gates, SARIF, packs, provenance, corpus, HTML reports, exception lifecycle audit, baseline drift Action/CLI surfaces, evidence-pack redaction, harness adapter registry, enterprise research roadmap, supply-chain hardened release path, CI-safe baseline fingerprints, corpus accuracy recommendations, remediation workflow phases, env proxy hijack corpus coverage, Mini Shai-Hulud full-campaign package IOCs, and CI-provenance evidence packs | PRs #53, #55-#64, #67-#69, and #78-#86 landed with test evidence; native PDF export deferred in favor of self-contained HTML plus print-to-PDF until explicit enterprise demand appears; `docs/architecture/agentshield-enterprise-research-roadmap.md` now has baseline drift, evidence-pack bundle, redaction, adapter-registry, supply-chain hardening, hashed baseline fingerprints, corpus accuracy recommendation, remediation workflow, env proxy hijack corpus, Mini Shai-Hulud full-campaign package-table, and `ci-context.json` provenance slices landed | Next evidence-pack consumer/readback workflow depth |
| ECC Tools next-level app | Billing audit, PR checks, deep analyzer, sync backlog, evaluator/RAG corpus, analysis-depth readiness, hosted execution planning, hosted CI diagnostics, hosted security evidence review, hosted harness compatibility audit, hosted reference-set evaluation, hosted AI routing/cost review, hosted team backlog routing, hosted depth-plan check-run, PR-comment hosted job dispatch, hosted job result history/check-runs, hosted result status command, status-aware depth-plan recommendations, hosted promotion readiness, hosted promotion output scoring, hosted promotion retrieval planning, hosted promotion judge contract, gated hosted promotion judge execution, payment-announcement readiness | PRs #26-#43 plus #53-#74 landed with test evidence, including AgentShield evidence-pack gap routing, canonical bundle recognition, supply-chain signature gates, PR draft follow-up Linear tracking, evidence-backed/deep-ready repository classification, the `/api/analysis/depth-plan` hosted job plan, `/api/analysis/jobs/ci-diagnostics`, `/api/analysis/jobs/security-evidence-review`, `/api/analysis/jobs/harness-compatibility-audit`, `/api/analysis/jobs/reference-set-evaluation`, `/api/analysis/jobs/ai-routing-cost-review`, `/api/analysis/jobs/team-backlog-routing`, the `ECC Tools / Hosted Depth Plan` check-run, `/ecc-tools analyze --job ...` PR-comment dispatch, non-blocking per-hosted-job result check-runs backed by 30-day result cache records, `/ecc-tools analyze --job status` cache lookup, cache-aware next-job recommendations in the depth-plan check-run, the `ECC Tools / Hosted Promotion Readiness` corpus-backed PR check-run, deterministic hosted-output scoring against cached completed job artifacts/findings, ranked retrieval/model-prompt planning, the fail-closed `hosted-promotion-judge.v1` request contract, opt-in live model-judge execution behind hosted evidence, entitlement, budget, provider, executor, strict JSON, and citation gates, a fail-closed `/api/billing/readiness` `announcementGate` for native GitHub payments claims, and `npm run billing:announcement-gate` as the non-secret operator verifier | Next work is hosted promotion telemetry, operator review UX, and live Marketplace test-account readback |
| GitGuardian/Dependabot/CodeRabbit-style checks | Non-blocking taxonomy, deterministic follow-up checks, and local supply-chain gates | ECC-Tools risk taxonomy check plus follow-up signals landed, including Skill Quality, Deep Analyzer Evidence, Analyzer Corpus Evidence, RAG/Evaluator Evidence, PR Review/Salvage Evidence, and AgentShield evidence-pack evidence; #1846 added npm registry signature gates; #1848 added the supply-chain incident-response playbook and `pull_request_target` cache-poisoning validator guard; #1851 added the privileged checkout credential-persistence guard; AgentShield #78, JARVIS #13, and ECC-Tools #53 applied the same hardening outside trunk | Current supply-chain gate complete; deeper hosted review features remain future |
| Harness-agnostic learning system | Audit, adapter matrix, observability, traces, promotion loop | Audit/adapters/observability gates plus `docs/architecture/evaluator-rag-prototype.md`, `examples/evaluator-rag-prototype/`, and ECC-Tools PR #40 define read-only stale-salvage, billing-readiness, CI-failure-diagnosis, harness-config-quality, AgentShield policy-exception, skill-quality evidence, deep-analyzer evidence, and RAG/evaluator comparison scenarios with trace, report, playbook, verifier, and predictive-check artifacts; ECC-Tools PRs #68-#72 now turn that corpus into a deterministic PR check-run gate with cached hosted-output scoring, ranked retrieval candidates, a model prompt seed, a fail-closed hosted model-judge request contract, and opt-in live model execution behind strict hosted-evidence gates | Deterministic hosted PR check, cached output scoring, retrieval planning, judge contract, and gated model execution integrated |
| Linear roadmap is detailed | Linear project status plus repo mirror | Repo mirror exists; issue creation was retried on 2026-05-12 and remains blocked by the workspace free issue limit; this May 15 sync adds ECC #1860, AgentShield #78-#84, JARVIS #13, ECC-Tools #53-#74, resolved queue/discussion counts, and notes that Linear connector status updates after ECC-Tools #68 remain blocked by a connector secret-owner error | Needs recurring status updates after connector recovery |
| Linear roadmap is detailed | Linear project status plus repo mirror | Repo mirror exists; issue creation was retried on 2026-05-12 and remains blocked by the workspace free issue limit; this May 15 sync adds ECC #1860, AgentShield #78-#86, JARVIS #13, ECC-Tools #53-#74, resolved queue/discussion counts, and notes that Linear connector status updates after ECC-Tools #68 remain blocked by a connector secret-owner error | Needs recurring status updates after connector recovery |
| Flow separation and progress tracking | Flow lanes with owner artifacts and update cadence | This roadmap defines lanes below and `docs/architecture/progress-sync-contract.md` makes GitHub/Linear/handoff/roadmap sync part of the readiness gate | Active |
| Realtime Linear sync | Project updates while issue limit is blocked; issues later | ECC-Tools #39 implements opt-in Linear API sync for deferred follow-up backlog items, and ECC-Tools #54 adds copy-ready PR drafts to that backlog when draft PR shells are not opened; `docs/architecture/progress-sync-contract.md` defines the local file-backed realtime boundary while issue capacity is blocked | Needs workspace capacity/config rollout |
| Observability for self-use | Local readiness gate, traces, status snapshots, HUD/status contract, risk ledger, progress-sync contract | `npm run observability:ready` reports 21/21 | Complete for local gate |
@@ -734,8 +734,11 @@ Acceptance:
baselines; PR #80 added prioritized corpus accuracy recommendations for
failed regression gates; PR #81 added ordered remediation workflow phases;
PR #82 expanded corpus coverage for env proxy hijacks and out-of-band
exfiltration; and ECC-Tools PRs #42/#43 now route and recognize evidence
packs. The next slice is hosted evidence-pack workflow depth.
exfiltration; PRs #83-#85 hardened Mini Shai-Hulud IOC coverage and
release-path supply-chain verification; PR #86 added whitelisted
`ci-context.json` workflow, commit, run, and runtime provenance to evidence
packs; and ECC-Tools PRs #42/#43 now route and recognize evidence packs.
The next slice is evidence-pack consumer/readback workflow depth.
2. Run ECC-Tools `/api/billing/readiness` against a Marketplace-managed test
account and require `announcementGate.ready === true` before any native
GitHub payments announcement.

View File

@@ -0,0 +1,114 @@
# ECC Operator Readiness Dashboard - 2026-05-15
This dashboard is an operator snapshot, not a release approval. Use it to decide
the next ECC 2.0 work batch and to keep Linear, GitHub, and repo evidence in
sync. Before publishing, repeat the checks from the final release commit in a
clean checkout.
## Current Status
| Area | Status | Evidence |
| --- | --- | --- |
| PR queue | Current | 0 open PRs across checked repos |
| Issue queue | Current | 0 open issues across checked repos |
| Discussions | Current | 58 main-repo discussions; 0 need maintainer touch; 0 answerable discussions missing accepted answers |
| Local worktree | Current with caveat | `main...origin/main`; unrelated `?? docs/drafts/` ignored |
| Security sweep | Current with follow-up | IOC scan, audits, package-manager hardening, and scheduled watch workflow completed |
| Linear roadmap | Current with follow-up | `ECC Platform Roadmap`, ITO-44 through ITO-59 |
| ECC 2.0 publication | Not complete | Release, npm, plugin, and announcement gates pending |
| AgentShield enterprise depth | In progress | AgentShield #86 merged; live IOC loop still pending |
| ECC Tools next-level app | In progress | Billing announcement gate merged; live readback pending |
| Legacy audit and salvage | Not complete | ITO-55 remains open |
## Live Command Evidence
Run these from `everything-claude-code` unless a row says otherwise.
| Evidence | Command | 2026-05-15 result |
| --- | --- | --- |
| Platform audit | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` | `ready: true`; open PRs 0/20; open issues 0/20; discussions needing maintainer touch 0; answerable discussions missing accepted answers 0; blocking dirty files 0 |
| Discussion audit | `node scripts/discussion-audit.js --json --repo affaan-m/everything-claude-code` | `ready: true`; 58 discussions sampled; 0 need maintainer touch; 0 answerable discussions missing accepted answers |
| Main repo status | `git status --short --branch` | `## main...origin/main`; `?? docs/drafts/` remains unrelated |
| Main commit | `git rev-parse HEAD` | `6887f2952d193cff10b3eb79af7765555d8ca9f5` |
| Main repo PRs/issues | GitHub connector and `gh` readback | 0 open PRs; 0 open issues |
| AgentShield PRs/issues | GitHub connector and `gh` readback | 0 open PRs; 0 open issues |
| ECC Tools PRs/issues | Local `gh pr list` and `gh issue list` | 0 open PRs; 0 open issues |
| Discussion baseline | GraphQL discussion sweep | Main repo #1923 marked answered; no answerable Q&A missing an answer |
| Supply-chain IOC scan | `node scripts/ci/scan-supply-chain-iocs.js --root <ECC-workspace> --home` | Passed; repo/home targeted scan inspected 200 files after clean no-script reinstall |
| IOC unit tests | `node tests/ci/scan-supply-chain-iocs.test.js` | 15/15 passed |
| Dead-man switch persistence sweep | Process, LaunchAgent, and known payload filename sweep for Mini Shai-Hulud markers | No matches |
| Workflow security gate | `node scripts/ci/validate-workflow-security.js` | Passed; 8 workflow files inspected; package-manager test installs disable lifecycle scripts and no Actions cache use remains |
| Supply-chain watch workflow | `.github/workflows/supply-chain-watch.yml` | Scheduled every 6 hours; emits `supply-chain-ioc-report.json` |
| npm signatures and audit | `npm audit signatures && npm audit --audit-level=high` in main | 213 verified signatures, 17 verified attestations, 0 high vulnerabilities |
## Prompt-To-Artifact Checklist
| Objective requirement | Artifact or evidence | Status | Gap |
| --- | --- | --- | --- |
| Keep PRs under 20 | `scripts/platform-audit.js`; live GitHub readback | Current | Repeat before release |
| Keep issues under 20 | `scripts/platform-audit.js`; live GitHub readback | Current | Repeat before release |
| Respond and manage discussions | `scripts/discussion-audit.js`; #1923 answer marked | Current | Repeat before release |
| ECC 2.0 preview pack ready | `preview-pack-manifest.md`; `publication-readiness.md` | In progress | Final publish evidence still pending |
| Include Hermes specialized skills | `docs/HERMES-SETUP.md`; `skills/hermes-imports/SKILL.md` | In progress | Final preview-pack smoke still pending |
| Name-change and availability path | `naming-and-publication-matrix.md`; ITO-46 | In progress | Final name/package/channel choice not approved |
| Claude plugin publication path | `.claude-plugin/`; `publication-readiness.md`; ITO-46 | In progress | Actual publication still pending |
| Codex plugin publication path | `.codex-plugin/`; repo marketplace evidence; ITO-46 | In progress | Official directory path still pending |
| Release notes and push notifications | `release-notes.md`; `x-thread.md`; `linkedin-post.md`; ITO-47/56 | In progress | Live URLs and publish approval missing |
| AgentShield enterprise iteration | AgentShield PRs #83-#86; ITO-48/49 | In progress | Live IOC update loop and cross-harness depth pending |
| ECC Tools native payments announcement | ECC-Tools #75; ITO-50 | In progress | Live Marketplace test-account readback pending |
| ECC Tools AI-native harness-agnostic roadmap | ITO-51/52/53/54 | In progress | Implementation and hosted deep-analysis proof pending |
| Linear roadmap extremely detailed | Linear `ECC Platform Roadmap`; ITO-44 through ITO-59 | Current | Keep status comments synchronized |
| Legacy work audited, pruned, or attached | `docs/legacy-artifact-inventory.md`; ITO-55 | In progress | Final salvage/prune pass pending |
| Realtime progress tracking with Linear | ITO-54; Linear progress comments | In progress | Productized sync/observability plane pending |
| ECC 2.0 observability | `docs/architecture/observability-readiness.md`; ITO-54 | In progress | Runtime/dashboard implementation pending |
## Linear Operating State
Project:
<https://linear.app/itomarkets/project/ecc-platform-roadmap-52b328ee03e1>
Active issue state after this pass:
| Issue | Lane | State |
| --- | --- | --- |
| ITO-44 | Completion audit and readiness dashboard | In Progress |
| ITO-57 | Supply-chain intelligence and local protection loop | In Progress |
| ITO-59 | Discussions and public response queue | Current; Linear status sync pending |
Still-open lanes:
- ITO-45: ECC 2.0 preview pack, Hermes skills, packaging, and cross-harness
readiness.
- ITO-46: name availability, Claude plugin, Codex plugin, and package channels.
- ITO-47: release notes, articles, and social copy since last release.
- ITO-48 and ITO-49: AgentShield enterprise iteration and live supply-chain
intelligence.
- ITO-50 through ITO-54: ECC Tools payments, deep analysis, setup
recommendations, queue automation, Linear sync, and observability.
- ITO-55: legacy audit, prune, attach, or salvage.
- ITO-56: final publication gate, release notes, and push notifications.
- ITO-58: ECC Tools GitHub access blocker.
## Decisions From This Pass
- The checked GitHub queues are below the explicit target, so the next work
should not spend more time closing nonexistent PRs/issues.
- The discussion queue is current and repeatable through `discussion:audit`.
ITO-59 remains open only for recurring Linear/status synchronization.
- The Mini Shai-Hulud/TanStack protection pass now has a durable scheduled
watch workflow. ITO-57 remains open for advisory-source refresh automation
and Linear status synchronization.
- The release is still blocked by publication, package, plugin, billing, and
announcement gates. Passing `platform:audit` alone is not proof that ECC 2.0
is publishable.
## Next Work Order
1. Build the ITO-44 completion dashboard into a repeatable command or generated
markdown artifact.
2. Run `platform:audit` and `discussion:audit` from the final release commit
before recording publication evidence.
3. Continue ITO-57 by adding advisory-source refresh automation and Linear
status synchronization for the scheduled supply-chain watch.
4. Resume release/publication lanes ITO-45, ITO-46, and ITO-56 only after the
readiness dashboard can be refreshed from commands.

View File

@@ -20,7 +20,7 @@ surfaces, or posting announcements.
| `docs/releases/2.0.0-rc.1/quickstart.md` | Clone-to-first-workflow path | Covers clone, install, verify, first skill, and harness switch |
| `docs/releases/2.0.0-rc.1/launch-checklist.md` | Operator launch checklist | Must remain approval-gated for release, package, plugin, and announcement actions |
| `docs/releases/2.0.0-rc.1/publication-readiness.md` | Release gate | Requires fresh evidence from the exact release commit |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` | Current May 15 queue, roadmap, security, AgentShield, ECC Tools billing-gate, CI cache, and `ecc2` test evidence through PR #1935 | Must be superseded by a final clean-checkout evidence file before real publication |
| `docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md` | Current May 15 queue, roadmap, security, AgentShield #86 evidence-pack provenance, ECC Tools billing-gate, CI cache, and `ecc2` test evidence through PR #1936 | Must be superseded by a final clean-checkout evidence file before real publication |
| `docs/releases/2.0.0-rc.1/naming-and-publication-matrix.md` | Naming, slug, and publication-path decision record | Keeps `Everything Claude Code / ECC`, npm `ecc-universal`, and plugin slug `ecc` for rc.1 |
| `docs/releases/2.0.0-rc.1/x-thread.md` | X launch draft | Must replace placeholders with live URLs after release/package/plugin publication |
| `docs/releases/2.0.0-rc.1/linkedin-post.md` | LinkedIn launch draft | Must replace placeholders with live URLs after release/package/plugin publication |

View File

@@ -7,9 +7,9 @@ npm publication, plugin tag, marketplace submission, or announcement post.
| Field | Evidence |
| --- | --- |
| Upstream main base | `6b8a49a6eed11cc7df19d8b1f2add085b37cf466` |
| Evidence branch | `codex/rc1-current-publication-evidence` |
| Evidence scope | Current `main` after PR #1932, #1933, #1934, and #1935; AgentShield #85; and ECC-Tools #75 |
| Upstream main base | `1949d75e18e59a37de269d88b188fc701f5cf122` |
| Evidence branch | `codex/rc1-agentshield-86-evidence` |
| Evidence scope | Current `main` after PR #1932, #1933, #1934, #1935, and #1936; AgentShield #86; and ECC-Tools #75 |
| Git remote | `https://github.com/affaan-m/everything-claude-code.git` |
| Local status caveat | Working tree had the unrelated untracked `docs/drafts/` directory before this docs refresh |
@@ -74,9 +74,10 @@ Project documents added in Linear:
| AgentShield PR #83 | Merged Mini Shai-Hulud IOC coverage for TanStack, Mistral, OpenSearch, Guardrails, UiPath, Squawk, Claude Code / VS Code persistence, and dead-man switch artifacts |
| AgentShield PR #84 | Merged the broader Mini Shai-Hulud full-campaign affected-package table, including additional `@cap-js`, `@draftlab`, `@tallyui`, `intercom-client`, `lightning`, and related package/version IOCs |
| AgentShield PR #85 | Added GitHub Action supply-chain verification, gating, and evidence packs so AgentShield's enterprise scanner release path has a verified registry-signature surface |
| AgentShield PR #86 | Added `ci-context.json` to AgentShield evidence packs with whitelisted GitHub Actions workflow, commit, run, and runtime provenance while keeping arbitrary environment variables and tokens out of the bundle |
| ECC-Tools PR #75 | Tightened the native GitHub payments announcement gate so public billing claims remain blocked until live Marketplace-managed test-account readback is ready |
| Trunk merge commits | `f04702bdac132662c8496e817bcd850c86e2b854`, `ee85e1482e3d6322ddb2706392ea0fc97469bd26`, `13585f1092c92fa3f20ffe0d756e40c5720b0de5`, `553d507ea63bc252e815a924c0d2baea961351a1`, `c0bac4d6ced7f78a5464c6e3fd8cfbb43515a9d5`, `c2c54e7c0b84a213848b9ab3dfeb3ae16fb9844d`, `6b8a49a6eed11cc7df19d8b1f2add085b37cf466` |
| AgentShield merge commits | `f899b27ba3fa60ec7e0dca41cc2dadcb1a1fb75d`, `d1aa5313afd915d0b7296e57aabaeb979b1ea93b`, `908d8f3a52a6a65b21e737339b56906603eb1345` |
| Trunk merge commits | `f04702bdac132662c8496e817bcd850c86e2b854`, `ee85e1482e3d6322ddb2706392ea0fc97469bd26`, `13585f1092c92fa3f20ffe0d756e40c5720b0de5`, `553d507ea63bc252e815a924c0d2baea961351a1`, `c0bac4d6ced7f78a5464c6e3fd8cfbb43515a9d5`, `c2c54e7c0b84a213848b9ab3dfeb3ae16fb9844d`, `6b8a49a6eed11cc7df19d8b1f2add085b37cf466`, `1949d75e18e59a37de269d88b188fc701f5cf122` |
| AgentShield merge commits | `f899b27ba3fa60ec7e0dca41cc2dadcb1a1fb75d`, `d1aa5313afd915d0b7296e57aabaeb979b1ea93b`, `908d8f3a52a6a65b21e737339b56906603eb1345`, `69a5e25b675b77666d0c96abc22639a5ba883403` |
| ECC-Tools merge commits | `6d00d67043e92cadc80f160bfe947115bfef33b1` |
| Local IOC tests | `node tests/ci/scan-supply-chain-iocs.test.js` passed 15/15 |
| Unicode safety | `node scripts/ci/check-unicode-safety.js` passed |
@@ -108,6 +109,13 @@ AgentShield PR #85 and trunk PR #1934 extend the response from IOC detection
into release-path hardening: AgentShield now records registry-signature evidence
for its action surface, while trunk CI restore-only dependency caches avoid
writing ordinary test dependency state back into shared caches.
AgentShield PR #86 completes the next evidence-pack provenance slice:
`agentshield scan --evidence-pack <dir>` now writes `ci-context.json`, includes
that artifact in the signed bundle digest, documents it in the bundle README,
and verifies that token-bearing environment variables such as `GITHUB_TOKEN`
are not copied into long-lived security-review artifacts. The PR passed local
build, typecheck, lint, 1764/1764 tests, and the full GitHub Actions matrix
across Node 18, 20, and 22 before merge.
PR #1933 closes the practical workstation persistence gap for the documented
Claude Code and VS Code automation paths, including user-level config files that
survive package uninstall.

View File

@@ -16,8 +16,12 @@ For the May 13 post-hardening evidence refresh after PR #1850 and PR #1851, see
[`publication-evidence-2026-05-13-post-hardening.md`](publication-evidence-2026-05-13-post-hardening.md).
For the May 15 queue, discussion, Linear roadmap, Mini Shai-Hulud/TanStack
follow-up, restore-only cache, AgentShield release-verification, billing-gate,
and `ecc2` current-dir guard evidence refresh through PR #1935, see
AgentShield #86 evidence-pack provenance, and `ecc2` current-dir guard evidence
refresh through PR #1936, see
[`publication-evidence-2026-05-15.md`](publication-evidence-2026-05-15.md).
For the operator-facing prompt-to-artifact readiness dashboard from the same
May 15 pass, see
[`operator-readiness-dashboard-2026-05-15.md`](operator-readiness-dashboard-2026-05-15.md).
## Release Identity Matrix
@@ -68,8 +72,9 @@ Record the exact commit SHA and command output before any publication action:
| Release surface | `node tests/docs/ecc2-release-surface.test.js` | 0 failures | `publication-evidence-2026-05-13.md`: 18/18 passed |
| Optional Rust surface | `cd ecc2 && cargo test` | 0 failures or explicit deferral | `publication-evidence-2026-05-15.md`: 462/462 passed, existing warnings only after PR #1935 current-dir guard |
| Queue baseline | `gh pr list` / `gh issue list` across trunk, AgentShield, JARVIS, ECC Tools, and ECC website | Under 20 open PRs and under 20 open issues | `publication-evidence-2026-05-15.md`: platform audit ready, 0 open PRs and 0 open issues across checked repos |
| Discussion baseline | GraphQL discussion count and maintainer-touch sweep | No unmanaged active discussion queue | `publication-evidence-2026-05-15.md`: 58 trunk discussions, 0 without maintainer touch; other tracked repos disabled or 0 |
| Discussion baseline | `node scripts/discussion-audit.js --json` | No unmanaged active discussion queue and no answerable Q&A missing an accepted answer | `publication-evidence-2026-05-15.md`: 58 trunk discussions, 0 without maintainer touch; other tracked repos disabled or 0 |
| Linear roadmap | Linear project and issue readback | Detailed roadmap exists with release, security, AgentShield, ECC Tools, legacy, and observability lanes | `publication-evidence-2026-05-15.md`: project and 16 issue lanes recorded |
| Operator readiness dashboard | `node scripts/platform-audit.js --json --allow-untracked docs/drafts/` plus `node scripts/discussion-audit.js --json` | Current queue state mapped to macro-goal deliverables and incomplete gaps | `operator-readiness-dashboard-2026-05-15.md`: live status, command evidence, Linear state, and next work order |
## Do Not Publish If

View File

@@ -81,6 +81,21 @@ node tests/run-all.js
If a search hit appears only in documentation examples, note it in the release
evidence but do not rotate credentials for a docs-only reference.
## Durable Watch Workflow
ECC also runs `.github/workflows/supply-chain-watch.yml` every six hours and on
manual dispatch. The workflow is read-only, disables checkout credential
persistence, installs with `npm ci --ignore-scripts`, verifies npm registry
signatures, runs the IOC scanner fixtures, emits
`supply-chain-ioc-report.json`, and re-validates GitHub Actions hardening rules.
Treat a failed scheduled watch as a release blocker until an operator confirms
whether the failure is a newly reported advisory, a stale scanner fixture, a
registry-signature issue, or a workflow hardening regression. If the scanner
needs new indicators, update `scripts/ci/scan-supply-chain-iocs.js`, add fixture
coverage in `tests/ci/scan-supply-chain-iocs.test.js`, refresh this runbook, and
attach the latest JSON artifact to the release evidence.
## Immediate Response
If ECC or a maintainer machine installed a known-bad package version:
@@ -111,8 +126,10 @@ If ECC or a maintainer machine installed a known-bad package version:
keys, and local `.npmrc` tokens;
- any MCP, plugin, or harness credentials available in environment variables
or user-scope config.
6. Purge GitHub Actions caches for affected repositories.
7. Reinstall from a clean environment with `npm ci --ignore-scripts` first.
6. Purge GitHub Actions dependency caches for affected repositories.
7. Reinstall from a clean environment with lifecycle scripts disabled first:
`npm ci --ignore-scripts`, `pnpm install --ignore-scripts`,
`yarn install --mode=skip-build`, or `bun install --ignore-scripts`.
8. Re-enable lifecycle scripts only after the dependency tree and package
versions are pinned to known-clean releases.
@@ -121,7 +138,9 @@ If ECC or a maintainer machine installed a known-bad package version:
ECC enforces these rules through `scripts/ci/validate-workflow-security.js`:
- privileged workflows must not checkout untrusted PR refs;
- workflows with write permissions must use `npm ci --ignore-scripts`;
- all workflow dependency installs must disable lifecycle scripts;
- workflows must not restore or save shared GitHub Actions dependency caches
during active supply-chain hardening;
- workflows with `id-token: write` must not restore or save shared dependency
caches;
- workflows that run `npm audit` must also run `npm audit signatures`;

View File

@@ -69,6 +69,7 @@
"scripts/claw.js",
"scripts/codex/merge-codex-config.js",
"scripts/codex/merge-mcp-config.js",
"scripts/discussion-audit.js",
"scripts/doctor.js",
"scripts/ecc.js",
"scripts/gemini-adapt-agents.js",
@@ -296,6 +297,7 @@
"harness:audit": "node scripts/harness-audit.js",
"observability:ready": "node scripts/observability-readiness.js",
"platform:audit": "node scripts/platform-audit.js",
"discussion:audit": "node scripts/discussion-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",

View File

@@ -25,11 +25,28 @@ const RULES = [
];
const WRITE_PERMISSION_PATTERN = /^\s*(?:contents|issues|pull-requests|actions|checks|deployments|discussions|id-token|packages|pages|repository-projects|security-events|statuses):\s*write\b/m;
const NPM_CI_PATTERN = /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g;
const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/;
const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/;
const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m;
const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m;
const UNSAFE_INSTALL_PATTERNS = [
{
pattern: /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g,
description: 'npm ci must include --ignore-scripts',
},
{
pattern: /\bpnpm\s+install\b(?![^\n]*--ignore-scripts)/g,
description: 'pnpm install must include --ignore-scripts',
},
{
pattern: /\byarn\s+install\b(?![^\n]*--mode=skip-build)/g,
description: 'yarn install must use --mode=skip-build',
},
{
pattern: /\bbun\s+install\b(?![^\n]*--ignore-scripts)/g,
description: 'bun install must include --ignore-scripts',
},
];
function getWorkflowFiles(workflowsDir) {
if (!fs.existsSync(workflowsDir)) {
@@ -120,11 +137,14 @@ function findViolations(filePath, source) {
}
}
for (const match of source.matchAll(NPM_CI_PATTERN)) {
}
for (const installRule of UNSAFE_INSTALL_PATTERNS) {
for (const match of source.matchAll(installRule.pattern)) {
violations.push({
filePath,
event: 'write-permission install',
description: 'workflows with write permissions must install npm dependencies with --ignore-scripts',
event: 'dependency install scripts',
description: `workflow dependency installs must not run lifecycle scripts: ${installRule.description}`,
expression: match[0],
line: getLineNumber(source, match.index),
});
@@ -141,6 +161,16 @@ function findViolations(filePath, source) {
});
}
if (ACTIONS_CACHE_PATTERN.test(source)) {
violations.push({
filePath,
event: 'dependency cache',
description: 'GitHub Actions dependency caches are disabled during active supply-chain hardening',
expression: 'actions/cache',
line: getLineNumber(source, source.search(ACTIONS_CACHE_PATTERN)),
});
}
if (/\bpull_request_target\s*:/m.test(source) && ACTIONS_CACHE_PATTERN.test(source)) {
violations.push({
filePath,

350
scripts/discussion-audit.js Normal file
View File

@@ -0,0 +1,350 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const {
DEFAULT_DISCUSSION_FIRST,
emptyDiscussionSummary,
fetchDiscussionSummary,
} = require('./lib/github-discussions');
const SCHEMA_VERSION = 'ecc.discussion-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',
]);
function usage() {
console.log([
'Usage: node scripts/discussion-audit.js [options]',
'',
'Audit GitHub discussions for maintainer touch and accepted-answer gaps.',
'',
'Options:',
' --format <text|json|markdown>',
' Output format (default: text)',
' --json Alias for --format json',
' --markdown Alias for --format markdown',
' --write <path> Write json or markdown output to a file',
' --repo <owner/repo> GitHub repo to inspect; repeatable',
' --first <n> Discussions to sample per repo (default: 100)',
' --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 = {
exitCode: false,
first: DEFAULT_DISCUSSION_FIRST,
format: 'text',
help: false,
repos: [],
useEnvGithubToken: false,
writePath: null,
};
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 === '--json') {
parsed.format = 'json';
continue;
}
if (arg === '--markdown') {
parsed.format = 'markdown';
continue;
}
if (arg === '--write') {
parsed.writePath = path.resolve(readValue(args, index, arg));
index += 1;
continue;
}
if (arg.startsWith('--write=')) {
parsed.writePath = path.resolve(arg.slice('--write='.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 === '--first') {
parsed.first = parseIntegerFlag(readValue(args, index, arg), arg);
index += 1;
continue;
}
if (arg.startsWith('--first=')) {
parsed.first = parseIntegerFlag(arg.slice('--first='.length), '--first');
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', 'markdown'].includes(parsed.format)) {
throw new Error(`Invalid format: ${parsed.format}. Use text, json, or markdown.`);
}
if (parsed.writePath && parsed.format === 'text') {
throw new Error('--write requires --json, --markdown, or --format json|markdown');
}
return parsed;
}
function buildReport(options) {
const repos = options.repos.length > 0 ? options.repos : DEFAULT_REPOS;
const repoReports = repos.map(repo => {
try {
return {
repo,
discussions: fetchDiscussionSummary(repo, options),
};
} catch (error) {
return {
repo,
error: error.message,
discussions: emptyDiscussionSummary(),
};
}
});
const totals = {
repos: repoReports.length,
totalDiscussions: repoReports.reduce((sum, repo) => sum + repo.discussions.totalCount, 0),
sampledDiscussions: repoReports.reduce((sum, repo) => sum + repo.discussions.sampledCount, 0),
needingMaintainerTouch: repoReports.reduce((sum, repo) => sum + repo.discussions.needingMaintainerTouch.length, 0),
missingAcceptedAnswer: repoReports.reduce((sum, repo) => sum + repo.discussions.answerableWithoutAcceptedAnswer.length, 0),
errors: repoReports.filter(repo => repo.error).length,
};
const checks = [
{
id: 'discussion-fetch',
status: totals.errors === 0 ? 'pass' : 'fail',
summary: `GitHub discussion fetch errors: ${totals.errors}`,
fix: 'Re-run with working gh authentication or ECC_GH_SHIM for deterministic tests.',
},
{
id: 'discussion-maintainer-touch',
status: totals.needingMaintainerTouch === 0 ? 'pass' : 'fail',
summary: `discussions needing maintainer touch: ${totals.needingMaintainerTouch}`,
fix: 'Respond to or route discussions without maintainer touch.',
},
{
id: 'discussion-accepted-answers',
status: totals.missingAcceptedAnswer === 0 ? 'pass' : 'fail',
summary: `answerable discussions missing accepted answer: ${totals.missingAcceptedAnswer}`,
fix: 'Mark an accepted answer or route Q&A discussions that still need resolution.',
},
];
const topActions = checks
.filter(check => check.status === 'fail')
.map(check => ({
id: check.id,
summary: check.summary,
fix: check.fix,
}));
return {
schema_version: SCHEMA_VERSION,
generatedAt: new Date().toISOString(),
ready: topActions.length === 0,
sampleFirst: options.first,
repos: repoReports,
totals,
checks,
top_actions: topActions,
};
}
function markdownEscape(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/\|/g, '\\|')
.replace(/\r?\n/g, '<br>');
}
function renderText(report) {
const lines = [
`ECC Discussion Audit: ${report.ready ? 'ready' : 'attention required'}`,
`Generated: ${report.generatedAt}`,
`Repos: ${report.totals.repos}`,
`Discussions sampled: ${report.totals.sampledDiscussions}/${report.totals.totalDiscussions}`,
`Needs maintainer touch: ${report.totals.needingMaintainerTouch}`,
`Missing accepted answers: ${report.totals.missingAcceptedAnswer}`,
`Fetch errors: ${report.totals.errors}`,
'',
'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 renderMarkdown(report) {
const lines = [
'# ECC Discussion Audit',
'',
`Generated: ${report.generatedAt}`,
`Status: ${report.ready ? 'ready' : 'attention required'}`,
'',
'## Summary',
'',
'| Surface | Count | Target | Status |',
'| --- | ---: | ---: | --- |',
`| Fetch errors | ${report.totals.errors} | 0 | ${report.totals.errors === 0 ? 'PASS' : 'FAIL'} |`,
`| Discussions needing maintainer touch | ${report.totals.needingMaintainerTouch} | 0 | ${report.totals.needingMaintainerTouch === 0 ? 'PASS' : 'FAIL'} |`,
`| Answerable discussions missing accepted answer | ${report.totals.missingAcceptedAnswer} | 0 | ${report.totals.missingAcceptedAnswer === 0 ? 'PASS' : 'FAIL'} |`,
'',
'## Repositories',
'',
'| Repository | Total | Sampled | Needs maintainer | Missing answers |',
'| --- | ---: | ---: | ---: | ---: |',
];
for (const repo of report.repos) {
lines.push(
`| \`${markdownEscape(repo.repo)}\` | ${repo.discussions.totalCount} | ${repo.discussions.sampledCount} | ${repo.discussions.needingMaintainerTouch.length} | ${repo.discussions.answerableWithoutAcceptedAnswer.length} |`
);
}
lines.push('', '## Top Actions', '');
if (report.top_actions.length === 0) {
lines.push('- none');
} else {
for (const action of report.top_actions) {
lines.push(`- \`${markdownEscape(action.id)}\`: ${markdownEscape(action.fix)}`);
}
}
return `${lines.join('\n')}\n`;
}
function writeOutput(writePath, output) {
fs.mkdirSync(path.dirname(writePath), { recursive: true });
fs.writeFileSync(writePath, output, 'utf8');
}
function renderReport(report, format) {
if (format === 'json') {
return `${JSON.stringify(report, null, 2)}\n`;
}
if (format === 'markdown') {
return renderMarkdown(report);
}
return renderText(report);
}
function main() {
let options;
try {
options = parseArgs(process.argv);
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (options.help) {
usage();
return;
}
const report = buildReport(options);
const output = renderReport(report, options.format);
if (options.writePath) {
writeOutput(options.writePath, output);
}
process.stdout.write(output);
if (options.exitCode && !report.ready) {
process.exit(2);
}
}
if (require.main === module) {
main();
}
module.exports = {
buildReport,
parseArgs,
renderMarkdown,
renderReport,
renderText,
};

View File

@@ -0,0 +1,141 @@
'use strict';
const { spawnSync } = require('child_process');
const DEFAULT_DISCUSSION_FIRST = 100;
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 category { name isAnswerable } answer { url authorAssociation } comments(first: 20) { nodes { authorAssociation } } } } } }';
function splitRepo(repo) {
const [owner, name] = String(repo || '').split('/');
if (!owner || !name) {
throw new Error(`Invalid repo: ${repo}`);
}
return { owner, name };
}
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 discussionNeedsMaintainerTouch(discussion) {
if (MAINTAINER_ASSOCIATIONS.has(discussion.authorAssociation)) {
return false;
}
if (
discussion.answer
&& MAINTAINER_ASSOCIATIONS.has(discussion.answer.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 discussionNeedsAcceptedAnswer(discussion) {
return Boolean(
discussion
&& discussion.category
&& discussion.category.isAnswerable
&& !discussion.answer
);
}
function summarizeDiscussion(discussion) {
return {
number: discussion.number,
title: discussion.title,
url: discussion.url,
updatedAt: discussion.updatedAt,
category: discussion.category ? discussion.category.name : null,
};
}
function fetchDiscussionSummary(repo, options = {}) {
const { owner, name } = splitRepo(repo);
const first = Number.isFinite(options.first) ? options.first : DEFAULT_DISCUSSION_FIRST;
const payload = runGhJson([
'api',
'graphql',
'-f',
`owner=${owner}`,
'-f',
`name=${name}`,
'-F',
`first=${first}`,
'-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);
const missingAcceptedAnswer = nodes.filter(discussionNeedsAcceptedAnswer);
return {
enabled: Boolean(repository && repository.hasDiscussionsEnabled),
totalCount: discussions && Number.isFinite(discussions.totalCount) ? discussions.totalCount : 0,
sampledCount: nodes.length,
needingMaintainerTouch: needingTouch.map(summarizeDiscussion),
answerableWithoutAcceptedAnswer: missingAcceptedAnswer.map(summarizeDiscussion),
};
}
function emptyDiscussionSummary() {
return {
enabled: false,
totalCount: 0,
sampledCount: 0,
needingMaintainerTouch: [],
answerableWithoutAcceptedAnswer: [],
};
}
module.exports = {
DEFAULT_DISCUSSION_FIRST,
DISCUSSION_QUERY,
MAINTAINER_ASSOCIATIONS,
discussionNeedsAcceptedAnswer,
discussionNeedsMaintainerTouch,
emptyDiscussionSummary,
fetchDiscussionSummary,
splitRepo,
summarizeDiscussion,
};

View File

@@ -5,6 +5,10 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const {
emptyDiscussionSummary,
fetchDiscussionSummary,
} = require('./lib/github-discussions');
const SCHEMA_VERSION = 'ecc.platform-audit.v1';
const DEFAULT_REPOS = Object.freeze([
@@ -19,9 +23,6 @@ const DEFAULT_THRESHOLDS = Object.freeze({
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]',
@@ -333,57 +334,6 @@ function inspectGit(rootDir, options) {
}
}
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',
@@ -431,6 +381,7 @@ function buildGithubReport(options) {
openPrs: 0,
openIssues: 0,
discussionsNeedingMaintainerTouch: 0,
discussionsMissingAcceptedAnswer: 0,
dirtyPrs: 0,
errors: 0,
},
@@ -446,12 +397,7 @@ function buildGithubReport(options) {
error: error.message,
openPrs: 0,
openIssues: 0,
discussions: {
enabled: false,
totalCount: 0,
sampledCount: 0,
needingMaintainerTouch: [],
},
discussions: emptyDiscussionSummary(),
dirtyPrs: [],
};
}
@@ -464,6 +410,7 @@ function buildGithubReport(options) {
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),
discussionsMissingAcceptedAnswer: repoReports.reduce((sum, repo) => sum + repo.discussions.answerableWithoutAcceptedAnswer.length, 0),
dirtyPrs: repoReports.reduce((sum, repo) => sum + repo.dirtyPrs.length, 0),
errors: repoReports.filter(repo => repo.error).length,
},
@@ -477,13 +424,17 @@ function buildLocalEvidenceChecks(rootDir) {
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');
const operatorDashboard = readText(rootDir, 'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-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.' }
packageScripts['platform:audit'] === 'node scripts/platform-audit.js'
&& packageScripts['discussion:audit'] === 'node scripts/discussion-audit.js'
? 'pass'
: 'fail',
'package.json exposes the platform and discussion audit commands',
{ fix: 'Add platform:audit and discussion:audit commands to package.json.' }
),
buildCheck(
'roadmap-linear-mirror',
@@ -509,6 +460,12 @@ function buildLocalEvidenceChecks(rootDir) {
'rc.1 evidence includes current supply-chain verification artifacts',
{ path: 'docs/releases/2.0.0-rc.1/publication-evidence-2026-05-15.md' }
),
buildCheck(
'operator-readiness-dashboard',
includesAll(operatorDashboard, ['Prompt-To-Artifact Checklist', 'ITO-44', 'ITO-59', 'PR queue', 'Not complete']) ? 'pass' : 'fail',
'operator dashboard maps macro-goal requirements to current evidence and open gaps',
{ path: 'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md' }
),
];
}
@@ -560,6 +517,13 @@ function buildReport(options) {
{ fix: 'Respond to or route discussions without maintainer touch before marking the queue current.' }
));
checks.push(buildCheck(
'github-discussion-answers',
github.totals.discussionsMissingAcceptedAnswer === 0 ? 'pass' : 'fail',
`answerable discussions missing accepted answer: ${github.totals.discussionsMissingAcceptedAnswer}`,
{ fix: 'Mark an accepted answer or route Q&A discussions that still need resolution.' }
));
checks.push(buildCheck(
'github-conflict-queue',
github.totals.dirtyPrs === 0 ? 'pass' : 'fail',
@@ -604,6 +568,7 @@ function renderText(report) {
`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}`,
`Answerable discussions missing accepted answer: ${report.github.totals.discussionsMissingAcceptedAnswer}`,
`Conflicting open PRs: ${report.github.totals.dirtyPrs}`,
'',
'Checks:',
@@ -659,18 +624,19 @@ function renderMarkdown(report) {
`| Open PRs | ${report.github.totals.openPrs} | ${report.thresholds.maxOpenPrs} | ${report.github.totals.openPrs <= report.thresholds.maxOpenPrs ? 'PASS' : 'FAIL'} |`,
`| Open issues | ${report.github.totals.openIssues} | ${report.thresholds.maxOpenIssues} | ${report.github.totals.openIssues <= report.thresholds.maxOpenIssues ? 'PASS' : 'FAIL'} |`,
`| Discussions needing maintainer touch | ${report.github.totals.discussionsNeedingMaintainerTouch} | 0 | ${report.github.totals.discussionsNeedingMaintainerTouch === 0 ? 'PASS' : 'FAIL'} |`,
`| Answerable discussions missing accepted answer | ${report.github.totals.discussionsMissingAcceptedAnswer} | 0 | ${report.github.totals.discussionsMissingAcceptedAnswer === 0 ? 'PASS' : 'FAIL'} |`,
`| Conflicting open PRs | ${report.github.totals.dirtyPrs} | 0 | ${report.github.totals.dirtyPrs === 0 ? 'PASS' : 'FAIL'} |`,
`| Blocking dirty files | ${report.git.blockingDirtyCount} | ${report.thresholds.maxDirtyFiles} | ${report.git.blockingDirtyCount <= report.thresholds.maxDirtyFiles ? 'PASS' : 'FAIL'} |`,
'',
'## Repositories',
'',
'| Repository | PRs | Issues | Discussions sampled | Needs maintainer | Dirty PRs |',
'| --- | ---: | ---: | ---: | ---: | ---: |',
'| Repository | PRs | Issues | Discussions sampled | Needs maintainer | Missing answers | Dirty PRs |',
'| --- | ---: | ---: | ---: | ---: | ---: | ---: |',
];
for (const repo of report.github.repos) {
lines.push(
`| \`${markdownEscape(repo.repo)}\` | ${repo.openPrs || 0} | ${repo.openIssues || 0} | ${repo.discussions ? repo.discussions.sampledCount : 0} | ${repo.discussions ? repo.discussions.needingMaintainerTouch.length : 0} | ${repo.dirtyPrs ? repo.dirtyPrs.length : 0} |`
`| \`${markdownEscape(repo.repo)}\` | ${repo.openPrs || 0} | ${repo.openIssues || 0} | ${repo.discussions ? repo.discussions.sampledCount : 0} | ${repo.discussions ? repo.discussions.needingMaintainerTouch.length : 0} | ${repo.discussions ? repo.discussions.answerableWithoutAcceptedAnswer.length : 0} | ${repo.dirtyPrs ? repo.dirtyPrs.length : 0} |`
);
}

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env node
/**
* Validate the scheduled supply-chain watch workflow contract.
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const WORKFLOW_PATH = path.join(
__dirname,
'..',
'..',
'.github',
'workflows',
'supply-chain-watch.yml',
);
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function run() {
console.log('\n=== Testing supply-chain watch workflow ===\n');
const source = fs.readFileSync(WORKFLOW_PATH, 'utf8');
let passed = 0;
let failed = 0;
if (test('runs on schedule and manual dispatch', () => {
assert.match(source, /schedule:\r?\n\s+- cron: '17 \*\/6 \* \* \*'/);
assert.match(source, /workflow_dispatch:/);
})) passed++; else failed++;
if (test('uses read-only permissions and non-persisting checkout credentials', () => {
assert.match(source, /permissions:\r?\n\s+contents: read/);
assert.doesNotMatch(source, /^\s+[A-Za-z-]+:\s*write\b/m);
assert.match(source, /uses: actions\/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd/);
assert.match(source, /persist-credentials: false/);
assert.doesNotMatch(source, /id-token:\s*write/);
assert.doesNotMatch(source, /actions\/cache@/);
})) passed++; else failed++;
if (test('installs without lifecycle scripts and verifies registry signatures', () => {
assert.match(source, /npm ci --ignore-scripts/);
assert.match(source, /npm audit signatures/);
assert.match(source, /npm audit --audit-level=high/);
})) passed++; else failed++;
if (test('runs IOC fixtures, emits JSON report, and uploads the artifact', () => {
assert.match(source, /node tests\/ci\/scan-supply-chain-iocs\.test\.js/);
assert.match(source, /node scripts\/ci\/scan-supply-chain-iocs\.js --json > artifacts\/supply-chain-ioc-report\.json/);
assert.match(source, /node scripts\/ci\/validate-workflow-security\.js/);
assert.match(source, /uses: actions\/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a/);
assert.match(source, /name: supply-chain-ioc-report/);
assert.match(source, /retention-days: 14/);
})) passed++; else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
run();

View File

@@ -107,21 +107,39 @@ function run() {
assert.match(result.stderr, /pull_request_target workflows must not restore or save shared dependency caches/);
})) passed++; else failed++;
if (test('rejects npm ci without ignore-scripts in workflows with write permissions', () => {
if (test('rejects dependency cache use in ordinary workflows', () => {
const result = runValidator({
'unsafe-write-install.yml': `name: Unsafe\non:\n workflow_dispatch:\npermissions:\n contents: read\n issues: write\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci\n`,
'unsafe-cache.yml': `name: Unsafe\non:\n pull_request:\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/cache@v5\n with:\n path: ~/.npm\n key: cache\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on npm ci without --ignore-scripts');
assert.match(result.stderr, /write permissions must install npm dependencies with --ignore-scripts/);
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on actions/cache use');
assert.match(result.stderr, /dependency caches are disabled during active supply-chain hardening/);
})) passed++; else failed++;
if (test('allows npm ci with ignore-scripts in workflows with write permissions', () => {
if (test('rejects npm ci without ignore-scripts in any workflow', () => {
const result = runValidator({
'safe-write-install.yml': `name: Safe\non:\n workflow_dispatch:\npermissions:\n contents: read\n issues: write\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci --ignore-scripts\n`,
'unsafe-install.yml': `name: Unsafe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: npm ci\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on npm ci without --ignore-scripts');
assert.match(result.stderr, /npm ci must include --ignore-scripts/);
})) passed++; else failed++;
if (test('allows package-manager installs with lifecycle scripts disabled', () => {
const result = runValidator({
'safe-install.yml': `name: Safe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: |\n npm ci --ignore-scripts\n pnpm install --ignore-scripts --no-frozen-lockfile\n yarn install --mode=skip-build\n bun install --ignore-scripts\n`,
});
assert.strictEqual(result.status, 0, result.stderr || result.stdout);
})) passed++; else failed++;
if (test('rejects pnpm, yarn, and bun installs that run lifecycle scripts', () => {
const result = runValidator({
'unsafe-matrix-install.yml': `name: Unsafe\non:\n pull_request:\njobs:\n audit:\n runs-on: ubuntu-latest\n steps:\n - run: |\n pnpm install --no-frozen-lockfile\n yarn install\n bun install\n`,
});
assert.notStrictEqual(result.status, 0, 'Expected validator to fail on script-running installs');
assert.match(result.stderr, /pnpm install must include --ignore-scripts/);
assert.match(result.stderr, /yarn install must use --mode=skip-build/);
assert.match(result.stderr, /bun install must include --ignore-scripts/);
})) passed++; else failed++;
if (test('rejects checkout credential persistence in workflows with write permissions', () => {
const result = runValidator({
'unsafe-write-checkout.yml': `name: Unsafe\non:\n workflow_dispatch:\npermissions:\n contents: write\njobs:\n release:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: npm ci --ignore-scripts\n`,

View File

@@ -269,6 +269,8 @@ test('publication readiness checklist gates public release actions on evidence',
assert.ok(may15Evidence.includes('PR #1935'));
assert.ok(may15Evidence.includes('AgentShield PR #83'));
assert.ok(may15Evidence.includes('AgentShield PR #85'));
assert.ok(may15Evidence.includes('AgentShield PR #86'));
assert.ok(may15Evidence.includes('ci-context.json'));
assert.ok(may15Evidence.includes('ECC Tools PR #73'));
assert.ok(may15Evidence.includes('ECC-Tools PR #75'));
assert.ok(may15Evidence.includes('| Platform audit |'));

View File

@@ -0,0 +1,258 @@
/**
* Tests for scripts/discussion-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', 'discussion-audit.js');
const { DISCUSSION_QUERY } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions'));
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
function cleanup(dirPath) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function discussionGhKey(owner, name, first = 100) {
return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`;
}
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 discussion-audit.js ===\n');
let passed = 0;
let failed = 0;
if (test('passes when discussions have maintainer touch and accepted answers', () => {
const rootDir = createTempDir('discussion-audit-pass-');
try {
const shimPath = writeGhShim(rootDir, {
[discussionGhKey('affaan-m', 'everything-claude-code')]: {
data: {
repository: {
hasDiscussionsEnabled: true,
discussions: {
totalCount: 2,
nodes: [
{
number: 1923,
title: 'Does Continuous Learning v2 work with VS Code Claude Code?',
url: 'https://github.com/example/discussions/1923',
updatedAt: '2026-05-15T19:08:52Z',
authorAssociation: 'NONE',
category: { name: 'Q&A', isAnswerable: true },
answer: { url: 'https://github.com/example/discussions/1923#discussioncomment-1', authorAssociation: 'OWNER' },
comments: { nodes: [] }
},
{
number: 73,
title: 'Compacting during workflow',
url: 'https://github.com/example/discussions/73',
updatedAt: '2026-05-15T00:00:00Z',
authorAssociation: 'NONE',
category: { name: 'General', isAnswerable: false },
answer: null,
comments: { nodes: [{ authorAssociation: 'MEMBER' }] }
}
]
}
}
}
}
});
const parsed = JSON.parse(run([
'--json',
'--repo',
'affaan-m/everything-claude-code'
], {
cwd: rootDir,
env: {
ECC_GH_SHIM: shimPath,
GITHUB_TOKEN: 'must-be-removed'
}
}));
assert.strictEqual(parsed.ready, true);
assert.strictEqual(parsed.totals.needingMaintainerTouch, 0);
assert.strictEqual(parsed.totals.missingAcceptedAnswer, 0);
assert.ok(parsed.checks.some(check => check.id === 'discussion-accepted-answers' && check.status === 'pass'));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('fails when Q&A lacks accepted answer and maintainer touch', () => {
const rootDir = createTempDir('discussion-audit-fail-');
try {
const shimPath = writeGhShim(rootDir, {
[discussionGhKey('affaan-m', 'everything-claude-code')]: {
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',
category: { name: 'Q&A', isAnswerable: true },
answer: null,
comments: { nodes: [] }
}
]
}
}
}
}
});
const result = runProcess([
'--json',
'--repo',
'affaan-m/everything-claude-code',
'--exit-code'
], {
cwd: rootDir,
env: { ECC_GH_SHIM: shimPath }
});
const parsed = JSON.parse(result.stdout);
assert.strictEqual(result.status, 2);
assert.strictEqual(parsed.ready, false);
assert.strictEqual(parsed.totals.needingMaintainerTouch, 1);
assert.strictEqual(parsed.totals.missingAcceptedAnswer, 1);
assert.ok(parsed.top_actions.some(action => action.id === 'discussion-maintainer-touch'));
assert.ok(parsed.top_actions.some(action => action.id === 'discussion-accepted-answers'));
} finally {
cleanup(rootDir);
}
})) passed++; else failed++;
if (test('writes markdown output as a durable operator artifact', () => {
const rootDir = createTempDir('discussion-audit-markdown-');
const outputPath = path.join(rootDir, 'artifacts', 'discussion-audit.md');
try {
const shimPath = writeGhShim(rootDir, {
[discussionGhKey('affaan-m', 'everything-claude-code')]: {
data: {
repository: {
hasDiscussionsEnabled: true,
discussions: { totalCount: 0, nodes: [] }
}
}
}
});
const stdout = run([
'--markdown',
'--write',
outputPath,
'--repo',
'affaan-m/everything-claude-code'
], {
cwd: rootDir,
env: { ECC_GH_SHIM: shimPath }
});
const written = fs.readFileSync(outputPath, 'utf8');
assert.strictEqual(stdout, written);
assert.ok(written.includes('# ECC Discussion Audit'));
assert.ok(written.includes('Answerable discussions missing accepted answer'));
assert.ok(written.includes('- none'));
} finally {
cleanup(rootDir);
}
})) 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/discussion-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);
}
}
runTests();

View File

@@ -46,6 +46,7 @@ function buildExpectedPublishPaths(repoRoot) {
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/consult.js",
"scripts/claw.js",
"scripts/discussion-audit.js",
"scripts/doctor.js",
"scripts/status.js",
"scripts/sessions-cli.js",
@@ -123,6 +124,7 @@ function main() {
"scripts/catalog.js",
"scripts/ci/scan-supply-chain-iocs.js",
"scripts/consult.js",
"scripts/discussion-audit.js",
"scripts/work-items.js",
"scripts/platform-audit.js",
".gemini/GEMINI.md",

View File

@@ -9,6 +9,7 @@ const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'platform-audit.js');
const { DISCUSSION_QUERY } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'github-discussions'));
function createTempDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -30,6 +31,7 @@ function seedRepo(rootDir, overrides = {}) {
name: 'everything-claude-code',
scripts: {
'platform:audit': 'node scripts/platform-audit.js',
'discussion:audit': 'node scripts/discussion-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'
@@ -60,6 +62,13 @@ function seedRepo(rootDir, overrides = {}) {
'Node IPC follow-up',
'node-ipc',
'IOC scan'
].join('\n'),
'docs/releases/2.0.0-rc.1/operator-readiness-dashboard-2026-05-15.md': [
'Prompt-To-Artifact Checklist',
'ITO-44',
'ITO-59',
'PR queue',
'Not complete'
].join('\n')
};
@@ -71,6 +80,10 @@ function seedRepo(rootDir, overrides = {}) {
}
}
function discussionGhKey(owner, name, first = 100) {
return `api graphql -f owner=${owner} -f name=${name} -F first=${first} -f query=${DISCUSSION_QUERY}`;
}
function writeGhShim(rootDir, responses) {
const shimPath = path.join(rootDir, 'gh-shim.js');
fs.writeFileSync(shimPath, `
@@ -188,6 +201,7 @@ function runTests() {
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.ok(parsed.checks.some(check => check.id === 'operator-readiness-dashboard' && check.status === 'pass'));
assert.deepStrictEqual(parsed.top_actions, []);
} finally {
cleanup(projectRoot);
@@ -229,7 +243,7 @@ function runTests() {
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 } } } } } }': {
[discussionGhKey('affaan-m', 'everything-claude-code')]: {
data: {
repository: {
hasDiscussionsEnabled: true,
@@ -242,6 +256,8 @@ function runTests() {
url: 'https://github.com/example/discussions/73',
updatedAt: '2026-05-15T00:00:00Z',
authorAssociation: 'NONE',
category: { name: 'General', isAnswerable: false },
answer: null,
comments: { nodes: [{ authorAssociation: 'OWNER' }] }
}
]
@@ -268,7 +284,9 @@ function runTests() {
assert.strictEqual(parsed.github.totals.openPrs, 0);
assert.strictEqual(parsed.github.totals.openIssues, 0);
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 0);
assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 0);
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-touch' && check.status === 'pass'));
assert.ok(parsed.checks.some(check => check.id === 'github-discussion-answers' && check.status === 'pass'));
} finally {
cleanup(projectRoot);
}
@@ -291,7 +309,7 @@ function runTests() {
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 } } } } } }': {
[discussionGhKey('affaan-m', 'everything-claude-code')]: {
data: {
repository: {
hasDiscussionsEnabled: true,
@@ -304,6 +322,8 @@ function runTests() {
url: 'https://github.com/example/discussions/1239',
updatedAt: '2026-05-15T00:00:00Z',
authorAssociation: 'NONE',
category: { name: 'Q&A', isAnswerable: true },
answer: null,
comments: { nodes: [] }
}
]
@@ -328,7 +348,9 @@ function runTests() {
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.ok(parsed.top_actions.some(action => action.id === 'github-discussion-answers'));
assert.strictEqual(parsed.github.totals.discussionsNeedingMaintainerTouch, 1);
assert.strictEqual(parsed.github.totals.discussionsMissingAcceptedAnswer, 1);
} finally {
cleanup(projectRoot);
}