From caca4d255ca2fff1258a4908c6bbb6416e55a379 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 10 Jun 2026 10:00:30 -0400 Subject: [PATCH] =?UTF-8?q?feat(#985):=20intel=20command=20cutover=20?= =?UTF-8?q?=E2=80=94=20commands-only=20capability,=20last=20first-party=20?= =?UTF-8?q?family=20(ADR-857=20phase=204d-impl-4)=20(#988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#985): intel command cutover — commands-only capability, last first-party family (ADR-857 phase 4d-impl-4) Migrate the intel CLI command family from a hardcoded gsd-tools.cjs case arm to a registry-dispatched Capability (commandFamilies mechanism, #961), mirroring the graphify (#972) and audit (#984) cutovers. New src/intel-command-router.cts exports routeIntelCommand reproducing all 9 subcommands verbatim (incl. the status timeAgo non-raw post-processing), lazily requiring intel.cjs inside the route fn. capabilities/intel/capability.json declares the intel command family; commands-only (skills:[]), declares the existing intel.enabled gate (default false — behavior unchanged). Behavior-CHANGING (dispatch path) but equivalence-proven: CLI output identical; existing intel.test.cjs passes unchanged. Completes the first-party command- family cutover sequence (graphify/audit/intel). Closes #985 Co-Authored-By: Claude Opus 4.8 * test(#985): compute expected planningDir via path.join in intel cutover unit tests (Windows CI) The intel-command-cutover unit-mock assertions hardcoded a POSIX `/.planning` expectation while the router builds it with path.join(cwd, '.planning') → backslashes on Windows, so the planningDir-arg assertions (query/status/diff/ snapshot/validate/update/api-surface) failed only on windows-latest CI. Compute the expectation with path.join (cross-platform); production router unchanged. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + CONTEXT.md | 2 +- capabilities/intel/capability.json | 28 + docs/ARCHITECTURE.md | 1 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 50 -- gsd-core/bin/lib/capability-registry.cjs | 41 ++ scripts/lint-test-file-count.allowlist.json | 1 + src/intel-command-router.cts | 147 ++++ tests/intel-command-cutover.test.cjs | 755 ++++++++++++++++++++ 12 files changed, 979 insertions(+), 52 deletions(-) create mode 100644 capabilities/intel/capability.json create mode 100644 src/intel-command-router.cts create mode 100644 tests/intel-command-cutover.test.cjs diff --git a/.gitignore b/.gitignore index dcafd1f3f..6701498e2 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,7 @@ build/ /gsd-core/bin/lib/graphify.cjs /gsd-core/bin/lib/graphify-command-router.cjs /gsd-core/bin/lib/audit-command-router.cjs +/gsd-core/bin/lib/intel-command-router.cjs /gsd-core/bin/lib/install-profiles.cjs /gsd-core/bin/lib/intel.cjs /gsd-core/bin/lib/installer-migrations.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 35671cbb0..bff1a6bcb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -161,7 +161,7 @@ A named, stable site on a host loop step (per-step `pre`/`post` plus per-wave in ADR-857 phase 4b unified resolver that composes the three toggle systems (install profile, runtime surface, config activation) into one per-capability view. ADDITIVE — install/surface/workflows untouched; currently consumed by nothing (phase-6 wiring out of scope). Source of truth: `gsd-core/bin/lib/capability-state.cjs` (generated from `src/capability-state.cts`). Interface: `resolveCapabilityState({ registry, installedSkills, surfacedSkills, config, cwd? }) → { capabilities: CapabilityStateEntry[] }` (pure, no I/O); `cmdCapabilityState(cwd, runtimeConfigDir, raw, opts)` (I/O entry point). CLI surface: `gsd-tools capability state [--config-dir ]` — emits `{ runtimeConfigDir, capabilities[] }`. Per-capability output: `{ id, tier, skills[], installed, surfaced, hooks[] }` where `installed` = every owned skill ∈ installedSkills (or `installedSkills==='*'`; vacuously true for empty-skills caps), `surfaced` = every owned skill ∈ surfacedSkills (vacuously true for empty-skills caps), `hooks` = `[{ point, kind: 'step'|'gate'|'contribution', when, active }]` derived from the cap's `steps`, `gates`, `contributions` arrays (no `when` → active=true; `when` resolved via `_resolveActivationValue` from loop-resolver). Capabilities sorted by `id` for determinism. Defensive: malformed registry → `{ capabilities: [] }`, never throws; inline literal `__proto__`/`constructor`/`prototype` prototype-pollution guard on capability id keys. `runtimeConfigDir` auto-detection falls back to `getGlobalConfigDir` based on env-var presence (CODEX_HOME → codex, CURSOR_CONFIG_DIR → cursor, GEMINI_CONFIG_DIR → gemini, CLAUDE_CONFIG_DIR → claude, default → claude/`~/.claude`). ### Capability Command Family [Planned — mechanism built, unconsumed] -ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). +ADR-959 (phase 4d) — a CLI command family (a top-level `gsd-tools` command and its subcommands) owned by a Capability via a new optional `commands: [{ family, module, router }]` field on the `feature` role. The Capability declares the `family` name, a first-party in-tree `module` (under `gsd-core/bin/lib/`), and the exported `router` — a standard `route*Command({ args, cwd, raw, error })` function identical in shape to the 12 existing host routers (so it routes through the stateless CommandRoutingHub via `routeCjsCommandFamily`, owning its own subcommand list and arg parsing). The registry materializes a `commandFamilies` index (`family → { capId, module, router }`); the formerly-dead `_dispatchNonFamily` shim is replaced by a real `dispatchCapabilityCommand` (exported from `gsd-core/bin/gsd-tools.cjs`) consulted in `runCommand`'s **`default` case** — an unmigrated command hits its hardcoded `case`; a migrated command's `case` is removed so it reaches `default` → registry → router, making collision structurally impossible. The registry *discovers* a router (it does not rebuild a handler table). First-party only; third-party command loading deferred. **Mechanism built (4d-impl-1):** `commands` schema + validator + single-family-ownership cross-check in `gen-capability-registry.cjs`; `commandFamilies` index emitted in the generated `capability-registry.cjs` (currently `{}` — no capability declares commands yet); `dispatchCapabilityCommand` wired into `runCommand`'s `default` case (behavior-preserving today). **Pilot complete (4d-impl-2):** `graphify` cut over as the first real capability command family — `capabilities/graphify/capability.json` bundles the command (`family: graphify`, `module: graphify-command-router.cjs`, `router: routeGraphifyCommand`), skill (`graphify`), config gate (`graphify.enabled`), and `tier: full`; the `case 'graphify':` arm removed from `gsd-tools.cjs`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.graphify → graphify-command-router.cjs → routeGraphifyCommand`; behavior proven equivalent (all subcommands: build, query, status, diff, build snapshot, unknown subcommand error, usage error, disabled gate). Template for phase-6 per-feature cutovers. **Audit cutover (4d-impl-3):** `audit-uat` and `audit-open` cut over as the second capability command family pair — `capabilities/audit/capability.json` declares two commands (`family: audit-uat`, `module: audit-command-router.cjs`, `router: routeAuditUat`) and (`family: audit-open`, `module: audit-command-router.cjs`, `router: routeAuditOpen`); the `case 'audit-uat':` and `case 'audit-open':` arms removed from `gsd-tools.cjs`; `commandFamilies` now holds `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies["audit-uat"|"audit-open"] → audit-command-router.cjs → routeAuditUat|routeAuditOpen`; behavior equivalence proven by existing regression tests (bug-2659, bug-2911, uat.test.cjs) plus new cutover tests. Confirms hyphenated family names pass registry validator (no format restriction beyond non-empty + non-reserved). **Intel cutover (4d-impl-4, last first-party cutover):** `intel` cut over — `capabilities/intel/capability.json` declares the command (`family: intel`, `module: intel-command-router.cjs`, `router: routeIntelCommand`) and the existing config gate (`intel.enabled`, default false); the `case 'intel':` arm removed from `gsd-tools.cjs`; `commandFamilies` now holds `intel`, `audit-uat`, `audit-open`, and `graphify`; dispatch flows `default → dispatchCapabilityCommand → commandFamilies.intel → intel-command-router.cjs → routeIntelCommand`; all 9 subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and both usage-error paths preserved; non-raw `timeAgo` transform on `status.files[*].updated_at` preserved exactly. `intel.enabled` declared in capability config (`pending-migration` warning expected during staged 3a-impl cutover). Completes the initial 4d capability command cutover batch. ### Runtime Capability [Planned] A `role: runtime` variant of a Capability (a Capability carries `role: feature | runtime`) that projects GSD's produced artifacts (skills/agents/hooks/commands) onto one host CLI's conventions — config-surface format, artifact-layout kinds, command template, hooks manifest, sandbox tier. It is a declarative descriptor over a fixed first-party primitive vocabulary (not a code adapter); install composes active Feature Capabilities × the chosen Runtime Capability at the InstallPlan seam (ADR-0058). First-party runtimes are authored through the same descriptor a third party would write (dogfooding the interface); tier-1 (Claude Code, Codex, Antigravity) is fully tested, the other existing runtimes ship lower-tier, none dropped. Third-party runtime loading is deferred to a purely additive external loader + trust gate. diff --git a/capabilities/intel/capability.json b/capabilities/intel/capability.json new file mode 100644 index 000000000..7ad17ee5f --- /dev/null +++ b/capabilities/intel/capability.json @@ -0,0 +1,28 @@ +{ + "id": "intel", + "role": "feature", + "title": "Codebase intelligence", + "description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.", + "tier": "full", + "requires": [], + "skills": [], + "agents": [], + "config": { + "intel.enabled": { + "type": "boolean", + "default": false, + "description": "Enable the intel code-intelligence command." + } + }, + "commands": [ + { + "family": "intel", + "module": "intel-command-router.cjs", + "router": "routeIntelCommand" + } + ], + "hooks": [], + "steps": [], + "contributions": [], + "gates": [] +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 40a03e518..d4bda30af 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -377,6 +377,7 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core | `capability-state.cjs` | Unified capability-state resolver — ADR-857 phase 4b; composes install profile, runtime surface, and config activation into one per-capability view; pure `resolveCapabilityState` + I/O `cmdCapabilityState`; `gsd-tools capability state [--config-dir ]` | | `graphify-command-router.cjs` | ADR-959 capability command router — first real capability command cutover (phase 4d-impl-2); extracted from the `case 'graphify':` arm in `gsd-tools.cjs`; dispatches build/query/status/diff subcommands; discovered via `commandFamilies` in the capability registry | | `audit-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-3); extracted from the `case 'audit-uat':` and `case 'audit-open':` arms in `gsd-tools.cjs`; `routeAuditUat` → `uat.cjs:cmdAuditUat`, `routeAuditOpen` → `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; discovered via `commandFamilies` in the capability registry | +| `intel-command-router.cjs` | ADR-959 capability command router (phase 4d-impl-4, last first-party cutover); extracted from the `case 'intel':` arm in `gsd-tools.cjs`; `routeIntelCommand` → all 9 intel subcommands via lazy `require('./intel.cjs')`; preserves non-raw `timeAgo` transform on `status.files[*].updated_at`; discovered via `commandFamilies` in the capability registry | --- diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 6e16f9438..9018a6279 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -307,6 +307,7 @@ "installer-migration-authoring.cjs", "installer-migration-report.cjs", "installer-migrations.cjs", + "intel-command-router.cjs", "intel.cjs", "io.cjs", "learnings.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 9d5f92c49..e0ffeee1f 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (104 shipped) +## CLI Modules (105 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -419,6 +419,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `installer-migration-report.cjs` | Installer migration report projection and blocked-action guard for install/update integration | | `installer-migrations.cjs` | Installer migration planning, artifact classification, install-state persistence, journaled apply, and rollback helpers | | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | +| `intel-command-router.cjs` | ADR-959 capability command router for `gsd-tools intel` — extracted from the `case 'intel':` arm in `gsd-tools.cjs`; dispatches query/status/diff/snapshot/patch-meta/validate/extract-exports/update/api-surface subcommands; preserves `timeAgo` transform on `status.files[*].updated_at` in non-raw mode; phase 4d-impl-4 (last first-party cutover) | | `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 092f6be76..6bd3e929e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,6 +86,7 @@ export default tseslint.config( 'gsd-core/bin/lib/graphify.cjs', 'gsd-core/bin/lib/graphify-command-router.cjs', 'gsd-core/bin/lib/audit-command-router.cjs', + 'gsd-core/bin/lib/intel-command-router.cjs', 'gsd-core/bin/lib/install-profiles.cjs', 'gsd-core/bin/lib/intel.cjs', 'gsd-core/bin/lib/installer-migrations.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7f9337e26..50a2e04f1 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1436,56 +1436,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } - // ─── Intel ──────────────────────────────────────────────────────────── - - case 'intel': { - const intel = require('./lib/intel.cjs'); - const subcommand = args[1]; - if (subcommand === 'query') { - const term = args[2]; - if (!term) error('Usage: gsd-tools intel query ', ERROR_REASON.USAGE); - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelQuery(term, planningDir), raw); - } else if (subcommand === 'status') { - const planningDir = path.join(cwd, '.planning'); - const status = intel.intelStatus(planningDir); - if (!raw && status.files) { - for (const file of Object.values(status.files)) { - if (file.updated_at) { - file.updated_at = core.timeAgo(new Date(file.updated_at)); - } - } - } - core.output(status, raw); - } else if (subcommand === 'diff') { - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelDiff(planningDir), raw); - } else if (subcommand === 'snapshot') { - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelSnapshot(planningDir), raw); - } else if (subcommand === 'patch-meta') { - const filePath = args[2]; - if (!filePath) error('Usage: gsd-tools intel patch-meta ', ERROR_REASON.USAGE); - core.output(intel.intelPatchMeta(path.resolve(cwd, filePath)), raw); - } else if (subcommand === 'validate') { - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelValidate(planningDir), raw); - } else if (subcommand === 'extract-exports') { - const filePath = args[2]; - if (!filePath) error('Usage: gsd-tools intel extract-exports ', ERROR_REASON.USAGE); - core.output(intel.intelExtractExports(path.resolve(cwd, filePath)), raw); - } else if (subcommand === 'update') { - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelUpdate(planningDir), raw); - } else if (subcommand === 'api-surface') { - const planningDir = path.join(cwd, '.planning'); - core.output(intel.intelApiSurface(planningDir), raw); - } else { - error('Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface', ERROR_REASON.SDK_UNKNOWN_COMMAND); - } - break; - } - // ─── Documentation ──────────────────────────────────────────────────── case 'docs-init': { diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index f64034844..d1d0222b5 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -64,6 +64,34 @@ const capabilities = { "contributions": [], "gates": [] }, + "intel": { + "id": "intel", + "role": "feature", + "title": "Codebase intelligence", + "description": "Code-intelligence store for codebase querying, diff, snapshot, and API-surface extraction; exposes `gsd-tools intel` subcommands (query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface) and backs `/gsd-map-codebase` and `gsd-intel-updater`.", + "tier": "full", + "requires": [], + "skills": [], + "agents": [], + "config": { + "intel.enabled": { + "type": "boolean", + "default": false, + "description": "Enable the intel code-intelligence command." + } + }, + "commands": [ + { + "family": "intel", + "module": "intel-command-router.cjs", + "router": "routeIntelCommand" + } + ], + "hooks": [], + "steps": [], + "contributions": [], + "gates": [] + }, "ui": { "id": "ui", "role": "feature", @@ -261,6 +289,7 @@ const byLoopPoint = { const configKeys = { "graphify.enabled": "graphify", + "intel.enabled": "intel", "workflow.ui_phase": "ui", "workflow.ui_review": "ui", "workflow.ui_safety_gate": "ui" @@ -273,6 +302,12 @@ const configSchema = { "default": false, "description": "Enable the graphify knowledge-graph command + skill." }, + "intel.enabled": { + "owner": "intel", + "type": "boolean", + "default": false, + "description": "Enable the intel code-intelligence command." + }, "workflow.ui_phase": { "owner": "ui", "type": "boolean", @@ -310,6 +345,11 @@ const commandFamilies = { "capId": "graphify", "module": "graphify-command-router.cjs", "router": "routeGraphifyCommand" + }, + "intel": { + "capId": "intel", + "module": "intel-command-router.cjs", + "router": "routeIntelCommand" } }; @@ -341,6 +381,7 @@ const profileMembership = { const _requiresGraph = { "audit": [], "graphify": [], + "intel": [], "ui": [] }; diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 85c353086..8003cfa49 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -39,6 +39,7 @@ "files": [ "bug-2351-intel-kilo-layout.test.cjs", "bug-3290-intel-updater-layout-block.test.cjs", + "intel-command-cutover.test.cjs", "intel.test.cjs" ], "issue": "TBD" diff --git a/src/intel-command-router.cts b/src/intel-command-router.cts new file mode 100644 index 000000000..ffbf0aebf --- /dev/null +++ b/src/intel-command-router.cts @@ -0,0 +1,147 @@ +'use strict'; +/** + * Intel command router — CLI subcommand dispatcher for `gsd-tools intel`. + * + * ADR-959 (phase 4d-impl-4): intel command family cutover — last first-party + * command cutover in the initial capability rollout. + * Extracted from the hardcoded `case 'intel':` arm in gsd-tools.cjs. + * Behaviour is preserved byte-for-behaviour from the prior inline case; + * the dispatch path now flows: default → dispatchCapabilityCommand → + * require(intel-command-router.cjs) → routeIntelCommand. + * + * Router signature: { args, cwd, raw, error } — identical to the existing + * host routers. No new handler/arg convention; the capability registry + * discovers this router by name. + * + * Arg indexing (preserved exactly from the original case): + * args[0] = 'intel' (family — matched by dispatchCapabilityCommand) + * args[1] = subcommand (query | status | diff | snapshot | patch-meta | + * validate | extract-exports | update | api-surface) + * args[2] = term (query) | filePath (patch-meta | extract-exports) + * + * Notable: the `status` subcommand applies a `timeAgo` transform on + * `status.files[*].updated_at` in non-raw mode — preserved exactly. + * + * Test seams: pass `_intel` to inject a mock intel module; pass `_core` to + * inject a mock core module (captures `output` calls and provides a + * deterministic `timeAgo` without writing to real stdout). The `_`-prefix + * follows the repo's established seam convention (see audit-command-router.cts + * for the `_core` seam pattern). Production callers omit both. + * + * Note on `error(); return` pairs: in production `error()` calls + * `process.exit(1)` so the `return` is an equivalent no-op halt. The pairs + * are kept for lint/control-flow clarity; they do NOT change behaviour. + * + * Lazy require: intel.cjs is required INSIDE the route function so it is + * only loaded when an intel command is actually dispatched (preserves + * equivalence with the old inline case arm which required it at the top of + * the case block). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import core = require('./core.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import io = require('./io.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +import path = require('path'); + +const { ERROR_REASON } = io; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface IntelModule { + intelQuery(term: string, planningDir: string): unknown; + intelStatus(planningDir: string): { files?: Record }; + intelDiff(planningDir: string): unknown; + intelSnapshot(planningDir: string): unknown; + intelValidate(planningDir: string): unknown; + intelUpdate(planningDir: string): unknown; + intelApiSurface(planningDir: string): unknown; + intelPatchMeta(filePath: string): unknown; + intelExtractExports(filePath: string): unknown; +} + +interface CoreModule { + output(value: unknown, raw: boolean): void; + timeAgo(date: Date): string; +} + +interface RouteIntelCommandOptions { + args: string[]; + cwd: string; + raw: boolean; + error: (message: string, reason?: string) => void; + /** Test seam: inject a mock intel module. Defaults to the real module. */ + _intel?: IntelModule; + /** Test seam: inject a mock core module to capture output calls and provide + * a deterministic timeAgo. Defaults to the real module. */ + _core?: CoreModule; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +function routeIntelCommand({ args, cwd, raw, error, _intel, _core }: RouteIntelCommandOptions): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment + const intel: IntelModule = _intel ?? require('./intel.cjs'); + const c: CoreModule = _core ?? core; + const subcommand = args[1]; + + if (subcommand === 'query') { + const term = args[2]; + if (!term) { + error('Usage: gsd-tools intel query ', ERROR_REASON.USAGE); + return; + } + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelQuery(term, planningDir), raw); + } else if (subcommand === 'status') { + const planningDir = path.join(cwd, '.planning'); + const status = intel.intelStatus(planningDir); + if (!raw && status.files) { + for (const file of Object.values(status.files)) { + if (file.updated_at) { + file.updated_at = c.timeAgo(new Date(file.updated_at)); + } + } + } + c.output(status, raw); + } else if (subcommand === 'diff') { + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelDiff(planningDir), raw); + } else if (subcommand === 'snapshot') { + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelSnapshot(planningDir), raw); + } else if (subcommand === 'patch-meta') { + const filePath = args[2]; + if (!filePath) { + error('Usage: gsd-tools intel patch-meta ', ERROR_REASON.USAGE); + return; + } + c.output(intel.intelPatchMeta(path.resolve(cwd, filePath)), raw); + } else if (subcommand === 'validate') { + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelValidate(planningDir), raw); + } else if (subcommand === 'extract-exports') { + const filePath = args[2]; + if (!filePath) { + error('Usage: gsd-tools intel extract-exports ', ERROR_REASON.USAGE); + return; + } + c.output(intel.intelExtractExports(path.resolve(cwd, filePath)), raw); + } else if (subcommand === 'update') { + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelUpdate(planningDir), raw); + } else if (subcommand === 'api-surface') { + const planningDir = path.join(cwd, '.planning'); + c.output(intel.intelApiSurface(planningDir), raw); + } else { + error( + 'Unknown intel subcommand. Available: query, status, update, diff, snapshot, patch-meta, validate, extract-exports, api-surface', + ERROR_REASON.SDK_UNKNOWN_COMMAND, + ); + } +} + +export = { + routeIntelCommand, +}; diff --git a/tests/intel-command-cutover.test.cjs b/tests/intel-command-cutover.test.cjs new file mode 100644 index 000000000..a4318abd9 --- /dev/null +++ b/tests/intel-command-cutover.test.cjs @@ -0,0 +1,755 @@ +'use strict'; +/** + * intel-command-cutover.test.cjs — ADR-959 phase 4d-impl-4 equivalence tests. + * + * Verifies that the `intel` command family, after cutover from the hardcoded + * `case 'intel':` arm in gsd-tools.cjs to the capability registry dispatch + * path (default → dispatchCapabilityCommand → intel-command-router.cjs → + * routeIntelCommand), behaves identically to the old inline case. + * + * Test categories: + * 1. UNIT (recording mock) — precise arg/call equivalence for each subcommand + * 2. DISPATCH — command reaches the router via default-case registry dispatch + * 3. BEHAVIOR — subprocess output shape assertions (query, status, disabled gate) + * 4. ERROR PATHS — unknown subcommand, usage (missing term/filePath) + * 5. JSON-ERRORS — structured {ok:false,reason,message} for error paths + * 6. REGISTRY — commandFamilies.intel, configSchema["intel.enabled"], capabilities.intel + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +const registry = require('../gsd-core/bin/lib/capability-registry.cjs'); +const { routeIntelCommand } = require('../gsd-core/bin/lib/intel-command-router.cjs'); + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function makeErrorRecorder() { + const calls = []; + const fn = (msg, reason) => calls.push({ msg, reason }); + fn.calls = calls; + return fn; +} + +/** + * Build a recording mock for the intel module. + * Each function records its call and returns a sentinel so tests can assert + * on WHICH function was called and with WHICH arguments without real I/O. + */ +function makeIntelMock(overrides = {}) { + const calls = []; + function recorder(name, ...fnArgs) { + const sentinel = { _mock: name, args: fnArgs }; + calls.push(sentinel); + return sentinel; + } + return { + calls, + mock: { + intelQuery: (term, planningDir) => recorder('intelQuery', term, planningDir), + intelStatus: (planningDir) => { + const sentinel = recorder('intelStatus', planningDir); + // Return a status with files so the timeAgo loop can be exercised + sentinel.files = overrides.statusFiles ?? {}; + return sentinel; + }, + intelDiff: (planningDir) => recorder('intelDiff', planningDir), + intelSnapshot: (planningDir) => recorder('intelSnapshot', planningDir), + intelValidate: (planningDir) => recorder('intelValidate', planningDir), + intelUpdate: (planningDir) => recorder('intelUpdate', planningDir), + intelApiSurface: (planningDir) => recorder('intelApiSurface', planningDir), + intelPatchMeta: (filePath) => recorder('intelPatchMeta', filePath), + intelExtractExports: (filePath) => recorder('intelExtractExports', filePath), + ...overrides.methods, + }, + }; +} + +function runJsonErrors(args, tmpDir, env = {}) { + const result = runGsdTools(args, tmpDir, { ...env, GSD_JSON_ERRORS: '1' }); + assert.strictEqual(result.success, false, + `Expected failure with GSD_JSON_ERRORS=1 for args: ${args.join(' ')}\n` + + `stdout: ${result.output}\nstderr: ${result.error}`); + let parsed; + try { + parsed = JSON.parse(result.error); + } catch (e) { + throw new Error( + `GSD_JSON_ERRORS=1 must emit valid JSON on stderr.\n` + + `Args: ${args.join(' ')}\nstderr: ${result.error}\nparse error: ${e.message}`, + ); + } + return parsed; +} + +function assertTypedError(parsed, expectedReason, label) { + assert.strictEqual(parsed.ok, false, `${label}: error object must have ok: false`); + assert.strictEqual(parsed.reason, expectedReason, + `${label}: reason must be "${expectedReason}", got: ${parsed.reason}`); + assert.ok(typeof parsed.message === 'string' && parsed.message.length > 0, + `${label}: message must be a non-empty string`); +} + +function enableIntel(tmpDir) { + const planningDir = path.join(tmpDir, '.planning'); + const configPath = path.join(planningDir, 'config.json'); + const config = fs.existsSync(configPath) + ? JSON.parse(fs.readFileSync(configPath, 'utf8')) + : {}; + // isIntelEnabled() requires the NESTED form { intel: { enabled: true } }. + // A flat dotted key like config['intel.enabled'] = true is NOT recognised. + config.intel = { ...(config.intel ?? {}), enabled: true }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8'); +} + +/** + * Build a recording mock for the core module. + * Captures all core.output() calls so no bytes reach real stdout. + * Provides a deterministic timeAgo that returns a fixed relative string. + */ +function makeCoreMock() { + const outputCalls = []; + return { + outputCalls, + mock: { + output: (value, raw) => { outputCalls.push({ value, raw }); }, + timeAgo: (_date) => '2 hours ago', + }, + }; +} + +// ─── 1. UNIT — recording mocks (precise routing equivalence) ───────────────── + +describe('intel router: unit tests via recording mocks', () => { + const CWD = '/fake/cwd'; + const PLANNING_DIR = path.join(CWD, '.planning'); + + // ── query ────────────────────────────────────────────────────────────────── + + test('routeIntelCommand query: calls intelQuery(term, planningDir)', () => { + const m = makeIntelMock(); + const c = makeCoreMock(); + const intelCalls = []; + const TERM = 'myterm'; + + routeIntelCommand({ + args: ['intel', 'query', TERM], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...m.mock, + intelQuery: (term, planningDir) => { + intelCalls.push({ fn: 'intelQuery', term, planningDir }); + return { matches: [], total: 0, term }; + }, + }, + }); + + assert.strictEqual(intelCalls.length, 1, 'intelQuery must be called once'); + assert.strictEqual(intelCalls[0].term, TERM, 'term must be forwarded'); + assert.strictEqual(intelCalls[0].planningDir, PLANNING_DIR, + 'planningDir must be path.join(cwd, ".planning")'); + // core.output must be called with the query result (no real stdout write) + assert.strictEqual(c.outputCalls.length, 1, 'core.output must be called once for query result'); + }); + + test('routeIntelCommand query: missing term calls error(USAGE)', () => { + const errFn = makeErrorRecorder(); + const c = makeCoreMock(); + routeIntelCommand({ + args: ['intel', 'query'], + cwd: CWD, raw: false, error: errFn, + _core: c.mock, + _intel: makeIntelMock().mock, + }); + assert.strictEqual(errFn.calls.length, 1, 'error must be called for missing term'); + assert.ok(errFn.calls[0].msg.includes('gsd-tools intel query '), + 'usage error must mention correct usage'); + assert.strictEqual(errFn.calls[0].reason, 'usage', + 'reason must be "usage" (ERROR_REASON.USAGE)'); + // core.output must NOT be called on usage error (early return) + assert.strictEqual(c.outputCalls.length, 0, 'core.output must not be called on usage error'); + }); + + // ── status ───────────────────────────────────────────────────────────────── + + test('routeIntelCommand status (raw=false): calls intelStatus; applies timeAgo on updated_at', () => { + const ISO_DATE = '2020-01-01T00:00:00.000Z'; + const statusCalls = []; + let capturedStatus = null; + const c = makeCoreMock(); + + routeIntelCommand({ + args: ['intel', 'status'], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...makeIntelMock().mock, + intelStatus: (planningDir) => { + statusCalls.push(planningDir); + // Return a status object with a file that has updated_at + capturedStatus = { + files: { + 'file-roles.json': { updated_at: ISO_DATE, stale: false }, + }, + overall_stale: false, + }; + return capturedStatus; + }, + }, + }); + + assert.strictEqual(statusCalls.length, 1, 'intelStatus must be called once'); + assert.strictEqual(statusCalls[0], PLANNING_DIR, 'planningDir must be path.join(cwd, ".planning")'); + // core.output must be called exactly once with the (mutated) status + assert.strictEqual(c.outputCalls.length, 1, 'core.output must be called once for status result'); + assert.strictEqual(c.outputCalls[0].raw, false, 'core.output must be called with raw=false'); + // The timeAgo transform must have mutated updated_at on the status object + // (c.mock.timeAgo returns '2 hours ago' deterministically) + const updatedAt = capturedStatus.files['file-roles.json'].updated_at; + assert.strictEqual(updatedAt, '2 hours ago', + 'non-raw mode: updated_at must be replaced with the timeAgo string from core.timeAgo()'); + assert.notStrictEqual(updatedAt, ISO_DATE, + 'non-raw mode: updated_at must no longer be an ISO string'); + }); + + test('routeIntelCommand status (raw=true): calls intelStatus; does NOT apply timeAgo', () => { + const ISO_DATE = '2020-01-01T00:00:00.000Z'; + let capturedStatus = null; + const c = makeCoreMock(); + + routeIntelCommand({ + args: ['intel', 'status'], + cwd: CWD, + raw: true, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...makeIntelMock().mock, + intelStatus: (_planningDir) => { + capturedStatus = { + files: { + 'file-roles.json': { updated_at: ISO_DATE, stale: false }, + }, + }; + return capturedStatus; + }, + }, + }); + + // core.output must be called exactly once + assert.strictEqual(c.outputCalls.length, 1, 'core.output must be called once for status result'); + assert.strictEqual(c.outputCalls[0].raw, true, 'core.output must be called with raw=true'); + // raw=true must skip the timeAgo loop — updated_at must remain unchanged + const updatedAt = capturedStatus.files['file-roles.json'].updated_at; + assert.strictEqual(updatedAt, ISO_DATE, + 'raw=true mode: updated_at must NOT be transformed — it must remain an ISO string'); + }); + + test('routeIntelCommand status: files without updated_at are left untouched', () => { + let capturedStatus = null; + const c = makeCoreMock(); + + routeIntelCommand({ + args: ['intel', 'status'], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...makeIntelMock().mock, + intelStatus: () => { + capturedStatus = { + files: { + 'some-file.json': { stale: true }, // no updated_at + }, + }; + return capturedStatus; + }, + }, + }); + + // Must not error and file object must be unchanged (no updated_at added) + assert.ok(!('updated_at' in capturedStatus.files['some-file.json']), + 'files without updated_at must not have it added by the transform'); + assert.strictEqual(c.outputCalls.length, 1, 'core.output must still be called once'); + }); + + // ── planningDir-only subcommands ─────────────────────────────────────────── + + for (const subcommand of ['diff', 'snapshot', 'validate', 'update', 'api-surface']) { + // Build the expected function name: 'diff' → 'intelDiff', 'api-surface' → 'intelApiSurface' + const fnName = 'intel' + subcommand.replace(/-([a-z])/g, (_, c) => c.toUpperCase()).replace(/^./, c => c.toUpperCase()); + + test(`routeIntelCommand ${subcommand}: calls ${fnName}(planningDir)`, () => { + const calls = []; + const coreMock = makeCoreMock(); + const mockMethod = (planningDir) => { + calls.push(planningDir); + return { result: subcommand }; + }; + routeIntelCommand({ + args: ['intel', subcommand], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: coreMock.mock, + _intel: { ...makeIntelMock().mock, [fnName]: mockMethod }, + }); + assert.strictEqual(calls.length, 1, `${fnName} must be called once`); + assert.strictEqual(calls[0], PLANNING_DIR, + `${fnName} must be called with path.join(cwd, ".planning")`); + assert.strictEqual(coreMock.outputCalls.length, 1, `core.output must be called once for ${subcommand}`); + }); + } + + // ── patch-meta ───────────────────────────────────────────────────────────── + + test('routeIntelCommand patch-meta: calls intelPatchMeta(path.resolve(cwd, filePath))', () => { + const calls = []; + const c = makeCoreMock(); + const FILE_ARG = 'src/auth.ts'; + const EXPECTED = path.resolve(CWD, FILE_ARG); + + routeIntelCommand({ + args: ['intel', 'patch-meta', FILE_ARG], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...makeIntelMock().mock, + intelPatchMeta: (fp) => { calls.push(fp); return { ok: true }; }, + }, + }); + + assert.strictEqual(calls.length, 1, 'intelPatchMeta must be called once'); + assert.strictEqual(calls[0], EXPECTED, + 'intelPatchMeta must receive path.resolve(cwd, filePath)'); + assert.strictEqual(c.outputCalls.length, 1, 'core.output must be called once for patch-meta result'); + }); + + test('routeIntelCommand patch-meta: missing filePath calls error(USAGE)', () => { + const errFn = makeErrorRecorder(); + const c = makeCoreMock(); + routeIntelCommand({ + args: ['intel', 'patch-meta'], + cwd: CWD, raw: false, error: errFn, + _core: c.mock, + _intel: makeIntelMock().mock, + }); + assert.strictEqual(errFn.calls.length, 1, 'error must be called for missing filePath'); + assert.ok(errFn.calls[0].msg.includes('gsd-tools intel patch-meta '), + 'usage error must mention correct usage'); + assert.strictEqual(errFn.calls[0].reason, 'usage', + 'reason must be "usage" (ERROR_REASON.USAGE)'); + assert.strictEqual(c.outputCalls.length, 0, 'core.output must not be called on usage error'); + }); + + // ── extract-exports ──────────────────────────────────────────────────────── + + test('routeIntelCommand extract-exports: calls intelExtractExports(path.resolve(cwd, filePath))', () => { + const calls = []; + const c = makeCoreMock(); + const FILE_ARG = 'lib/core.cjs'; + const EXPECTED = path.resolve(CWD, FILE_ARG); + + routeIntelCommand({ + args: ['intel', 'extract-exports', FILE_ARG], + cwd: CWD, + raw: false, + error: makeErrorRecorder(), + _core: c.mock, + _intel: { + ...makeIntelMock().mock, + intelExtractExports: (fp) => { calls.push(fp); return { exports: [] }; }, + }, + }); + + assert.strictEqual(calls.length, 1, 'intelExtractExports must be called once'); + assert.strictEqual(calls[0], EXPECTED, + 'intelExtractExports must receive path.resolve(cwd, filePath)'); + assert.strictEqual(c.outputCalls.length, 1, 'core.output must be called once for extract-exports result'); + }); + + test('routeIntelCommand extract-exports: missing filePath calls error(USAGE)', () => { + const errFn = makeErrorRecorder(); + const c = makeCoreMock(); + routeIntelCommand({ + args: ['intel', 'extract-exports'], + cwd: CWD, raw: false, error: errFn, + _core: c.mock, + _intel: makeIntelMock().mock, + }); + assert.strictEqual(errFn.calls.length, 1, 'error must be called for missing filePath'); + assert.ok(errFn.calls[0].msg.includes('gsd-tools intel extract-exports '), + 'usage error must mention correct usage'); + assert.strictEqual(errFn.calls[0].reason, 'usage', + 'reason must be "usage" (ERROR_REASON.USAGE)'); + assert.strictEqual(c.outputCalls.length, 0, 'core.output must not be called on usage error'); + }); + + // ── unknown subcommand ───────────────────────────────────────────────────── + + test('routeIntelCommand unknown subcommand: calls error(SDK_UNKNOWN_COMMAND)', () => { + const errFn = makeErrorRecorder(); + const c = makeCoreMock(); + routeIntelCommand({ + args: ['intel', 'nonexistent'], + cwd: CWD, raw: false, error: errFn, + _core: c.mock, + _intel: makeIntelMock().mock, + }); + assert.strictEqual(errFn.calls.length, 1, 'error must be called for unknown subcommand'); + assert.ok(errFn.calls[0].msg.includes('Unknown intel subcommand'), + `error message must say "Unknown intel subcommand"`); + assert.strictEqual(errFn.calls[0].reason, 'sdk_unknown_command', + 'reason must be "sdk_unknown_command" (ERROR_REASON.SDK_UNKNOWN_COMMAND)'); + assert.strictEqual(c.outputCalls.length, 0, 'core.output must not be called for unknown subcommand'); + }); +}); + +// ─── 2. DISPATCH — intel reaches the router via default-case registry ───────── + +describe('intel cutover: dispatch path (default-case → capability registry)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-intel-cutover-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('intel subcommand does not emit "Unknown command: intel" (reaches capability router)', () => { + // With intel.enabled absent/false the router will return a disabled payload — + // but it must NOT fall through to the "Unknown command" error. + const result = runGsdTools(['intel', 'status'], tmpDir); + const stderr = result.error || ''; + assert.strictEqual( + stderr.includes('Unknown command: intel'), + false, + `Must not emit "Unknown command: intel". stderr: ${stderr}`, + ); + // The disabled gate returns a JSON payload (exit 0) — or the command succeeds + assert.ok(result.success, + `intel status must exit 0. stderr: ${stderr}`); + }); + + test('intel status --raw dispatches correctly (no "Unknown command")', () => { + const result = runGsdTools(['intel', 'status', '--raw'], tmpDir); + const stderr = result.error || ''; + assert.strictEqual( + stderr.includes('Unknown command: intel'), + false, + `intel status --raw must not emit "Unknown command: intel". stderr: ${stderr}`, + ); + }); +}); + +// ─── 3. BEHAVIOR — subprocess output shape (equivalence to old inline cases) ── + +describe('intel cutover: output shape equivalence', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-intel-behavior-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('intel status (disabled gate): exits 0, produces JSON payload with disabled:true', () => { + // intel.enabled absent — disabled gate response from intel.cjs + const result = runGsdTools(['intel', 'status'], tmpDir); + assert.ok(result.success, `intel status must exit 0. stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'intel status must emit valid JSON when disabled', + ); + assert.strictEqual(parsed.disabled, true, 'disabled gate must set disabled: true'); + }); + + test('intel query (disabled gate): exits 0, produces JSON with disabled:true', () => { + const result = runGsdTools(['intel', 'query', 'someterm'], tmpDir); + assert.ok(result.success, `intel query must exit 0 (disabled gate). stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'intel query disabled gate must emit valid JSON', + ); + assert.strictEqual(parsed.disabled, true, 'disabled gate must set disabled: true on query'); + }); + + test('intel status --raw (disabled gate): exits 0, produces JSON', () => { + const result = runGsdTools(['intel', 'status', '--raw'], tmpDir); + assert.ok(result.success, `intel status --raw must exit 0. stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'intel status --raw must emit valid JSON', + ); + assert.strictEqual(parsed.disabled, true, 'raw disabled gate must still set disabled: true'); + }); + + test('intel status (non-raw, enabled, with intel files): updated_at is a timeAgo string', () => { + enableIntel(tmpDir); + const planningDir = path.join(tmpDir, '.planning'); + const intelDir = path.join(planningDir, 'intel'); + fs.mkdirSync(intelDir, { recursive: true }); + + // Write a file-roles.json intel file with an old updated_at + const oldDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); // 2 hours ago + const intelData = { + _meta: { updated_at: oldDate }, + entries: {}, + }; + fs.writeFileSync(path.join(intelDir, 'file-roles.json'), JSON.stringify(intelData, null, 2), 'utf8'); + + const result = runGsdTools(['intel', 'status'], tmpDir); + assert.ok(result.success, `intel status must exit 0. stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'intel status must emit valid JSON', + ); + // Confirm intel is actually ENABLED (not hitting the disabled gate) + assert.strictEqual(parsed.disabled, undefined, + `intel must be enabled; got disabled gate response instead. config may not be nested correctly.\noutput: ${result.output}`); + // Non-raw: file-roles.json must be present with updated_at set + const fileEntry = parsed.files?.['file-roles.json']; + assert.ok(fileEntry, `intel status must include file-roles.json in files. Got: ${JSON.stringify(parsed)}`); + assert.ok(fileEntry.updated_at, + `file-roles.json must have updated_at. Got: ${JSON.stringify(fileEntry)}`); + // The timeAgo transform converts ISO → "X hours ago" (never an ISO format) + const isIso = /^\d{4}-\d{2}-\d{2}T/.test(fileEntry.updated_at); + assert.strictEqual(isIso, false, + `Non-raw updated_at must be a timeAgo string, not an ISO date. Got: ${fileEntry.updated_at}`); + }); + + test('intel status raw=true (enabled, with intel files): updated_at remains an ISO string', () => { + enableIntel(tmpDir); + const planningDir = path.join(tmpDir, '.planning'); + const intelDir = path.join(planningDir, 'intel'); + fs.mkdirSync(intelDir, { recursive: true }); + + const isoDate = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + const intelData = { + _meta: { updated_at: isoDate }, + entries: {}, + }; + fs.writeFileSync(path.join(intelDir, 'file-roles.json'), JSON.stringify(intelData, null, 2), 'utf8'); + + const result = runGsdTools(['intel', 'status', '--raw'], tmpDir); + assert.ok(result.success, `intel status --raw must exit 0. stderr: ${result.error}`); + let parsed; + assert.doesNotThrow( + () => { parsed = JSON.parse(result.output); }, + 'intel status --raw must emit valid JSON', + ); + // Confirm intel is actually ENABLED (not hitting the disabled gate) + assert.strictEqual(parsed.disabled, undefined, + `intel must be enabled; got disabled gate response instead. config may not be nested correctly.\noutput: ${result.output}`); + // raw=true: file-roles.json must be present with updated_at set + const fileEntry = parsed.files?.['file-roles.json']; + assert.ok(fileEntry, `intel status --raw must include file-roles.json in files. Got: ${JSON.stringify(parsed)}`); + assert.ok(fileEntry.updated_at, + `file-roles.json must have updated_at in raw mode. Got: ${JSON.stringify(fileEntry)}`); + // raw=true: updated_at must remain an ISO string (no timeAgo transform) + const isIso = /^\d{4}-\d{2}-\d{2}T/.test(fileEntry.updated_at); + assert.strictEqual(isIso, true, + `--raw updated_at must remain an ISO string. Got: ${fileEntry.updated_at}`); + }); +}); + +// ─── 4. ERROR PATHS — unknown subcommand, usage errors ─────────────────────── + +describe('intel cutover: error paths (exit non-zero + correct messages)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-intel-err-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('intel query (missing term): exits non-zero, emits usage message', () => { + const result = runGsdTools(['intel', 'query'], tmpDir); + assert.strictEqual(result.success, false, 'intel query without term must exit non-zero'); + const output = result.error + result.output; + assert.ok( + output.includes('gsd-tools intel query '), + `Must emit usage hint for missing term. Got: ${output}`, + ); + }); + + test('intel patch-meta (missing filePath): exits non-zero, emits usage message', () => { + const result = runGsdTools(['intel', 'patch-meta'], tmpDir); + assert.strictEqual(result.success, false, 'intel patch-meta without filePath must exit non-zero'); + const output = result.error + result.output; + assert.ok( + output.includes('gsd-tools intel patch-meta '), + `Must emit usage hint for missing filePath. Got: ${output}`, + ); + }); + + test('intel extract-exports (missing filePath): exits non-zero, emits usage message', () => { + const result = runGsdTools(['intel', 'extract-exports'], tmpDir); + assert.strictEqual(result.success, false, 'intel extract-exports without filePath must exit non-zero'); + const output = result.error + result.output; + assert.ok( + output.includes('gsd-tools intel extract-exports '), + `Must emit usage hint for missing filePath. Got: ${output}`, + ); + }); + + test('intel unknown subcommand: exits non-zero, emits "Unknown intel subcommand"', () => { + const result = runGsdTools(['intel', 'bogussubcmd'], tmpDir); + assert.strictEqual(result.success, false, 'intel unknown subcommand must exit non-zero'); + const output = result.error + result.output; + assert.ok( + output.includes('Unknown intel subcommand'), + `Must emit "Unknown intel subcommand". Got: ${output}`, + ); + // Must list all 9 valid subcommands + const EXPECTED_SUBCMDS = ['query', 'status', 'update', 'diff', 'snapshot', 'patch-meta', 'validate', 'extract-exports', 'api-surface']; + for (const sc of EXPECTED_SUBCMDS) { + assert.ok(output.includes(sc), + `Unknown subcommand error must list "${sc}". Got: ${output}`); + } + }); +}); + +// ─── 5. JSON-ERRORS — GSD_JSON_ERRORS mode ─────────────────────────────────── + +describe('intel cutover: GSD_JSON_ERRORS structured error payloads', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject('gsd-intel-jsonerr-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('intel query (missing term) with GSD_JSON_ERRORS=1 emits {ok:false,reason:"usage"}', () => { + const parsed = runJsonErrors(['intel', 'query'], tmpDir); + assertTypedError(parsed, 'usage', 'intel query missing term'); + assert.ok(parsed.message.includes('gsd-tools intel query '), + 'usage message must include the usage hint'); + }); + + test('intel patch-meta (missing filePath) with GSD_JSON_ERRORS=1 emits {ok:false,reason:"usage"}', () => { + const parsed = runJsonErrors(['intel', 'patch-meta'], tmpDir); + assertTypedError(parsed, 'usage', 'intel patch-meta missing filePath'); + assert.ok(parsed.message.includes('gsd-tools intel patch-meta '), + 'usage message must include the usage hint'); + }); + + test('intel extract-exports (missing filePath) with GSD_JSON_ERRORS=1 emits {ok:false,reason:"usage"}', () => { + const parsed = runJsonErrors(['intel', 'extract-exports'], tmpDir); + assertTypedError(parsed, 'usage', 'intel extract-exports missing filePath'); + assert.ok(parsed.message.includes('gsd-tools intel extract-exports '), + 'usage message must include the usage hint'); + }); + + test('intel unknown subcommand with GSD_JSON_ERRORS=1 emits {ok:false,reason:"sdk_unknown_command"}', () => { + const parsed = runJsonErrors(['intel', 'notasubcmd'], tmpDir); + assertTypedError(parsed, 'sdk_unknown_command', 'intel unknown subcommand'); + assert.ok(parsed.message.includes('Unknown intel subcommand'), + 'sdk_unknown_command message must say "Unknown intel subcommand"'); + }); + + test('intel status (disabled gate) with GSD_JSON_ERRORS=1 does NOT emit error payload (succeeds)', () => { + // The disabled gate is not a CLI error — it exits 0 with a JSON payload + const result = runGsdTools(['intel', 'status'], tmpDir, { GSD_JSON_ERRORS: '1' }); + assert.ok(result.success, + `intel status disabled gate must exit 0 with GSD_JSON_ERRORS=1. stderr: ${result.error}`); + }); +}); + +// ─── 6. REGISTRY — commandFamilies.intel + configSchema + capabilities.intel ── + +describe('intel cutover: registry entries correct', () => { + test('commandFamilies["intel"] present and well-shaped', () => { + const entry = registry.commandFamilies['intel']; + assert.ok(entry, 'commandFamilies["intel"] must be present'); + assert.strictEqual(entry.capId, 'intel', + 'commandFamilies["intel"].capId must be "intel"'); + assert.strictEqual(entry.module, 'intel-command-router.cjs', + 'commandFamilies["intel"].module must be "intel-command-router.cjs"'); + assert.strictEqual(entry.router, 'routeIntelCommand', + 'commandFamilies["intel"].router must be "routeIntelCommand"'); + }); + + test('capabilities.intel present with role:feature and tier:full', () => { + const cap = registry.capabilities.intel; + assert.ok(cap, 'capabilities.intel must be present'); + assert.strictEqual(cap.role, 'feature', 'intel capability must have role: feature'); + assert.strictEqual(cap.tier, 'full', 'intel capability must have tier: full'); + }); + + test('capabilities.intel.commands has exactly one entry with family "intel"', () => { + const cap = registry.capabilities.intel; + assert.ok(Array.isArray(cap.commands) && cap.commands.length === 1, + 'intel capability must have exactly 1 command'); + const cmd = cap.commands[0]; + assert.strictEqual(cmd.family, 'intel', 'command family must be "intel"'); + assert.strictEqual(cmd.module, 'intel-command-router.cjs', + 'command module must be "intel-command-router.cjs"'); + assert.strictEqual(cmd.router, 'routeIntelCommand', + 'command router must be "routeIntelCommand"'); + }); + + test('configSchema["intel.enabled"] present with expected shape', () => { + const schemaEntry = registry.configSchema['intel.enabled']; + assert.ok(schemaEntry, 'configSchema["intel.enabled"] must be present'); + assert.strictEqual(schemaEntry.owner, 'intel', + 'configSchema["intel.enabled"].owner must be "intel"'); + assert.strictEqual(schemaEntry.type, 'boolean', + 'intel.enabled must have type: boolean'); + assert.strictEqual(schemaEntry.default, false, + 'intel.enabled must default to false'); + }); + + test('routeIntelCommand is an exported function', () => { + assert.strictEqual(typeof routeIntelCommand, 'function', + 'routeIntelCommand must be an exported function'); + }); + + test('intel has no skills — vacuous profileMembership (no entry)', () => { + // intel capability declares skills:[] → no skill-cluster-based profileMembership entry + const cap = registry.capabilities.intel; + assert.deepStrictEqual(cap.skills, [], + 'intel capability must have empty skills array'); + const pm = registry.profileMembership.intel; + assert.strictEqual(pm, undefined, + 'profileMembership.intel must be undefined (no skills declared)'); + }); + + test('intel has no skills — vacuous capabilityClusters (no entry)', () => { + const clusters = registry.capabilityClusters.intel; + assert.strictEqual(clusters, undefined, + 'capabilityClusters.intel must be undefined (no skills declared)'); + }); + + test('graphify/audit-uat/audit-open commandFamilies entries still present (no regression)', () => { + assert.ok(registry.commandFamilies['graphify'], 'commandFamilies["graphify"] must still be present'); + assert.ok(registry.commandFamilies['audit-uat'], 'commandFamilies["audit-uat"] must still be present'); + assert.ok(registry.commandFamilies['audit-open'], 'commandFamilies["audit-open"] must still be present'); + }); +});