From 47bba87d908f34a5952897a5d451d39bc8823d00 Mon Sep 17 00:00:00 2001 From: Colin Johnson Date: Tue, 26 May 2026 18:56:00 -0400 Subject: [PATCH] fix: restore sdk query families in gsd-tools (#353) * fix: restore sdk query families in gsd-tools * chore: add changeset * docs: sync query router inventory --- .changeset/graceful-mice-wave.md | 5 + docs/INVENTORY-MANIFEST.json | 3 + docs/INVENTORY.md | 5 +- get-shit-done/bin/gsd-tools.cjs | 22 +- .../bin/lib/agent-command-router.cjs | 65 ++++ .../bin/lib/check-command-router.cjs | 333 ++++++++++++++++++ get-shit-done/bin/lib/task-command-router.cjs | 81 +++++ ...sdk-removal-query-family-dispatch.test.cjs | 101 ++++++ 8 files changed, 612 insertions(+), 3 deletions(-) create mode 100644 .changeset/graceful-mice-wave.md create mode 100644 get-shit-done/bin/lib/agent-command-router.cjs create mode 100644 get-shit-done/bin/lib/check-command-router.cjs create mode 100644 get-shit-done/bin/lib/task-command-router.cjs create mode 100644 tests/sdk-removal-query-family-dispatch.test.cjs diff --git a/.changeset/graceful-mice-wave.md b/.changeset/graceful-mice-wave.md new file mode 100644 index 000000000..cf85ed997 --- /dev/null +++ b/.changeset/graceful-mice-wave.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 353 +--- +Restore gsd-tools query handlers for check, task, and agent commands after SDK retirement. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d8e6e4c39..27b98b432 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -262,8 +262,10 @@ "cli_modules": [ "active-workstream-store.cjs", "adr-parser.cjs", + "agent-command-router.cjs", "artifacts.cjs", "audit.cjs", + "check-command-router.cjs", "cjs-command-router-adapter.cjs", "clusters.cjs", "code-review-flags.cjs", @@ -321,6 +323,7 @@ "state-document.cjs", "state.cjs", "surface.cjs", + "task-command-router.cjs", "template.cjs", "uat.cjs", "validate-command-router.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index a785b3b56..19561fc4d 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -362,7 +362,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (72 shipped) +## CLI Modules (75 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -370,8 +370,10 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. |--------|----------------| | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | +| `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | +| `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | | `code-review-flags.cjs` | Typed flag parser for `/gsd:code-review`; exports `parseCodeReviewFlags(argv)` (→ `{ fix, all, auto, depth, files }`) and `resolveCodeReviewWorkflow(flags)` (→ `'code-review.md' \| 'code-review-fix.md'`); canonical dispatch seam for `--fix`/`--all`/`--auto` routing | @@ -429,6 +431,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms | | `surface.cjs` | Runtime surface module — manages the runtime enable/disable surface state independently of the install-time profile marker (ADR-0011 Phase 2) | +| `task-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools task` | | `template.cjs` | Template selection and filling with variable substitution | | `uat.cjs` | UAT file parsing, verification debt tracking, audit-uat support | | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index 3790e8d95..90372985f 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -194,6 +194,9 @@ const { routePhaseCommand } = require('./lib/phase-command-router.cjs'); const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); const { routeValidateCommand } = require('./lib/validate-command-router.cjs'); const { routeRoadmapCommand } = require('./lib/roadmap-command-router.cjs'); +const { routeAgentCommand } = require('./lib/agent-command-router.cjs'); +const { routeCheckCommand } = require('./lib/check-command-router.cjs'); +const { routeTaskCommand } = require('./lib/task-command-router.cjs'); const { parseNamedArgs, parseMultiwordArg } = require('./lib/command-arg-projection.cjs'); // ─── Bridge collapsed (Phase 4) ──────────────────────────────────────────────── @@ -362,14 +365,14 @@ async function main() { // discovery; previously it was a partial subset that didn't include // phase / roadmap / milestone / progress / etc. const TOP_LEVEL_USAGE = 'Usage: gsd-tools [args] [--raw] [--pick ] [--cwd ] [--ws ] [--json-errors]\n' + - 'Commands: agent-skills, audit-open, audit-uat, check-commit, commit, commit-to-subrepo, ' + + 'Commands: agent, agent-skills, audit-open, audit-uat, check, check-commit, commit, commit-to-subrepo, ' + 'config-ensure-section, config-get, config-new-project, config-path, config-set, migrate-config, ' + 'current-timestamp, detect-custom-files, docs-init, extract-messages, find-phase, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + 'learnings, list-todos, milestone, phase, phase-plan-index, phases, profile-questionnaire, ' + 'profile-sample, progress, prompt-budget, requirements, resolve-model, roadmap, scaffold, state, ' + - 'template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + + 'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + ' --raw Emit raw output without post-processing\n' + ' --pick Extract a single field from JSON output (dot/bracket notation)\n' + @@ -509,6 +512,16 @@ function extractField(obj, fieldPath) { async function runCommand(command, args, cwd, raw, defaultValue, originalCommand, workstreamContext = null) { switch (command) { + case 'agent': { + routeAgentCommand({ args, raw }); + break; + } + + case 'check': { + routeCheckCommand({ args, cwd, raw }); + break; + } + case 'state': { routeStateCommand({ state, @@ -603,6 +616,11 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + case 'task': { + routeTaskCommand({ args, cwd, raw }); + break; + } + case 'frontmatter': { // Phase 6 (#3575): dispatch via SDK executeForCjs when available. // SDK handler: sdk/src/query/frontmatter.ts + frontmatter-mutation.ts. diff --git a/get-shit-done/bin/lib/agent-command-router.cjs b/get-shit-done/bin/lib/agent-command-router.cjs new file mode 100644 index 000000000..20ae56304 --- /dev/null +++ b/get-shit-done/bin/lib/agent-command-router.cjs @@ -0,0 +1,65 @@ +'use strict'; + +const { output, error, ERROR_REASON } = require('./core.cjs'); + +const QUOTA_SENTINELS = [ + '429', + 'usage_limit_reached', + 'usage limit', + 'rate limit', + 'rate-limited', + 'rate_limit', + 'resource_exhausted', + 'quota', + 'too many requests', + 'exceeded your', +]; + +const CLASSIFY_HANDOFF_SENTINEL = 'classifyhandoffifneeded is not defined'; + +function parseRetryAfter(body) { + const match = String(body || '').match(/\bretry[-_ ]after[:\s]+(\d+)\b/i); + if (!match) return undefined; + const seconds = Number.parseInt(match[1], 10); + return Number.isFinite(seconds) ? seconds : undefined; +} + +function classifyAgentFailure(body) { + const normalized = String(body || '').toLowerCase(); + if (normalized.trim() === '') { + return { class: 'unknown-failure' }; + } + + for (const sentinel of QUOTA_SENTINELS) { + if (normalized.includes(sentinel)) { + const retryAfterSeconds = parseRetryAfter(body); + return retryAfterSeconds === undefined + ? { class: 'quota-exceeded', sentinel } + : { class: 'quota-exceeded', sentinel, retryAfterSeconds }; + } + } + + if (normalized.includes(CLASSIFY_HANDOFF_SENTINEL)) { + return { + class: 'classify-handoff-bug', + sentinel: CLASSIFY_HANDOFF_SENTINEL, + }; + } + + return { class: 'unknown-failure' }; +} + +function routeAgentCommand({ args, raw }) { + const subcommand = args[1]; + if (subcommand !== 'classify-failure') { + error('Unknown agent subcommand. Available: classify-failure', ERROR_REASON.SDK_UNKNOWN_COMMAND); + } + + const bodyArgs = args.slice(2).filter((arg) => arg !== '--'); + output(classifyAgentFailure(bodyArgs.join(' ')), raw); +} + +module.exports = { + classifyAgentFailure, + routeAgentCommand, +}; diff --git a/get-shit-done/bin/lib/check-command-router.cjs b/get-shit-done/bin/lib/check-command-router.cjs new file mode 100644 index 000000000..52d8cafa0 --- /dev/null +++ b/get-shit-done/bin/lib/check-command-router.cjs @@ -0,0 +1,333 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { output, error, ERROR_REASON } = require('./core.cjs'); +const { parseDecisions } = require('./decisions.cjs'); + +function normalizePhrase(text) { + return String(text || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +const SOFT_PHRASE_MIN_WORDS = 6; + +function softPhrase(text) { + const words = normalizePhrase(text).split(' ').filter(Boolean); + if (words.length < SOFT_PHRASE_MIN_WORDS) return ''; + return words.slice(0, SOFT_PHRASE_MIN_WORDS).join(' '); +} + +function decisionMentioned(haystack, decision) { + if (!haystack) return false; + if (new RegExp(`\\b${decision.id}\\b`).test(haystack)) return true; + const phrase = softPhrase(decision.text); + return phrase ? normalizePhrase(haystack).includes(phrase) : false; +} + +function readIfExists(filePath) { + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return ''; + } +} + +function resolvePath(inputPath, projectDir) { + return path.isAbsolute(inputPath) ? inputPath : path.join(projectDir, inputPath); +} + +function readWorkflowConfig(projectDir) { + const configPath = path.join(projectDir, '.planning', 'config.json'); + try { + const parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + return { + ...(parsed.workflow || {}), + auto_advance: parsed.workflow?.auto_advance ?? parsed.auto_advance, + _auto_chain_active: parsed.workflow?._auto_chain_active ?? parsed._auto_chain_active, + context_coverage_gate: parsed.workflow?.context_coverage_gate ?? parsed.context_coverage_gate, + }; + } catch { + return {}; + } +} + +function cmdAutoMode(projectDir, raw) { + const workflow = readWorkflowConfig(projectDir); + const autoAdvance = Boolean(workflow.auto_advance ?? false); + const autoChainActive = Boolean(workflow._auto_chain_active ?? false); + let source = 'none'; + if (autoChainActive && autoAdvance) source = 'both'; + else if (autoChainActive) source = 'auto_chain'; + else if (autoAdvance) source = 'auto_advance'; + + output({ + active: autoChainActive || autoAdvance, + source, + auto_chain_active: autoChainActive, + auto_advance: autoAdvance, + }, raw); +} + +function gateEnabled(projectDir) { + const value = readWorkflowConfig(projectDir).context_coverage_gate; + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const lower = value.toLowerCase(); + if (lower === 'false' || lower === 'true') return lower !== 'false'; + } + return true; +} + +function loadPlanContents(phaseDir) { + if (!fs.existsSync(phaseDir)) return []; + try { + return fs.readdirSync(phaseDir) + .filter((entry) => /-PLAN\.md$/.test(entry)) + .map((entry) => readIfExists(path.join(phaseDir, entry))); + } catch { + return []; + } +} + +const DESIGNATED_HEADINGS_RE = /^#{1,6}\s+(?:must[_ ]haves?|truths?|tasks?|objective)\b/i; +const XML_DECISION_TAGS_RE = /<(?:objective|tasks?|action)(?:\s[^>]*)?>([\s\S]*?)<\/(?:objective|tasks?|action)>/gi; + +function stripCommentsAndFences(text) { + return text + .replace(//g, ' ') + .replace(/```[\s\S]*?```/g, ' ') + .replace(/~~~[\s\S]*?~~~/g, ' '); +} + +function extractYamlBlock(frontmatter, key) { + const match = frontmatter.match(new RegExp(`^${key}\\s*:(.*)$`, 'm')); + if (!match) return ''; + const startIdx = (match.index || 0) + match[0].length; + const rest = frontmatter.slice(startIdx + 1).split(/\r?\n/); + const block = [match[1] || '']; + for (const line of rest) { + if (line === '' || /^\s/.test(line)) block.push(line); + else break; + } + return block.join('\n'); +} + +function extractXmlTagBodies(text) { + const parts = []; + for (const match of text.matchAll(XML_DECISION_TAGS_RE)) { + if (match[1]) parts.push(match[1]); + } + return parts.join('\n'); +} + +function extractPlanDesignatedSections(planContent) { + if (!planContent) return ''; + const cleaned = stripCommentsAndFences(planContent); + const fmMatch = cleaned.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); + const frontmatter = fmMatch ? fmMatch[1] : ''; + const body = fmMatch ? fmMatch[2] : cleaned; + + const parts = []; + for (const key of ['must_haves', 'truths', 'objective']) { + const block = extractYamlBlock(frontmatter, key); + if (block) parts.push(block); + } + + const bodyParts = []; + let inDesignated = false; + for (const line of body.split(/\r?\n/)) { + const heading = /^#{1,6}\s+/.test(line); + if (heading) { + inDesignated = DESIGNATED_HEADINGS_RE.test(line); + if (inDesignated) bodyParts.push(line); + continue; + } + if (inDesignated) bodyParts.push(line); + } + parts.push(bodyParts.join('\n')); + parts.push(extractXmlTagBodies(cleaned)); + return parts.join('\n\n'); +} + +function buildPlanMessage(uncovered) { + if (uncovered.length === 0) return 'All trackable CONTEXT.md decisions are covered by plans.'; + return [ + '## Decision Coverage Gap', + '', + `${uncovered.length} CONTEXT.md decision(s) are not covered by any plan:`, + '', + ...uncovered.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`), + '', + 'Resolve by citing `D-NN:` in a relevant plan\'s `must_haves`/`truths` (or body),', + 'OR move the decision to `### Claude\'s Discretion` / tag it `[informational]` if it should not be tracked.', + ].join('\n'); +} + +function buildVerifyMessage(notHonored) { + if (notHonored.length === 0) return 'All trackable CONTEXT.md decisions are honored by shipped artifacts.'; + return [ + '### Decision Coverage (warning)', + '', + `${notHonored.length} decision(s) not found in shipped artifacts:`, + '', + ...notHonored.map((item) => `- **${item.id}** (${item.category || 'uncategorized'}): ${item.text}`), + '', + 'This is a soft warning - verification status is unchanged.', + ].join('\n'); +} + +function loadTrackableDecisions(contextPath) { + return parseDecisions(readIfExists(contextPath)).filter((decision) => decision.trackable); +} + +function cmdDecisionCoveragePlan(projectDir, args, raw) { + const phaseDir = args[2] ? resolvePath(args[2], projectDir) : ''; + const contextPath = args[3] ? resolvePath(args[3], projectDir) : ''; + + if (!gateEnabled(projectDir)) { + output({ passed: true, skipped: true, reason: 'workflow.context_coverage_gate is false', total: 0, covered: 0, uncovered: [], message: 'Decision coverage gate disabled by config.' }, raw); + return; + } + if (!contextPath || !fs.existsSync(contextPath)) { + output({ passed: true, skipped: true, reason: 'CONTEXT.md missing', total: 0, covered: 0, uncovered: [], message: 'No CONTEXT.md - nothing to check.' }, raw); + return; + } + + const decisions = loadTrackableDecisions(contextPath); + if (decisions.length === 0) { + output({ passed: true, skipped: true, reason: 'no trackable decisions', total: 0, covered: 0, uncovered: [], message: 'No trackable decisions in CONTEXT.md.' }, raw); + return; + } + + const sections = loadPlanContents(phaseDir).map(extractPlanDesignatedSections); + const uncovered = []; + let covered = 0; + for (const decision of decisions) { + if (sections.some((section) => decisionMentioned(section, decision))) covered++; + else uncovered.push({ id: decision.id, text: decision.text, category: decision.category }); + } + + output({ + passed: uncovered.length === 0, + skipped: false, + total: decisions.length, + covered, + uncovered, + message: buildPlanMessage(uncovered), + }, raw); +} + +function recentCommitMessages(projectDir) { + try { + return execFileSync('git', ['log', '-n', '200', '--pretty=%s%n%b'], { + cwd: projectDir, + encoding: 'utf-8', + maxBuffer: 4 * 1024 * 1024, + }); + } catch { + return ''; + } +} + +function isInsideRoot(candidatePath, rootDir) { + const root = path.resolve(rootDir); + const target = path.resolve(root, candidatePath); + return target === root || target.startsWith(`${root}${path.sep}`); +} + +function readModifiedFilesContent(projectDir, summaries) { + const out = []; + let total = 0; + for (const summary of summaries) { + if (!summary) continue; + for (const blockMatch of summary.matchAll(/files_modified:\s*\n((?:[ \t]*-\s+.+\n?)+)/g)) { + const files = [...(blockMatch[1] || '').matchAll(/-\s+(.+)/g)] + .map((match) => match[1].trim().replace(/^["']|["']$/g, '')); + for (const file of files) { + if (total >= 50) break; + if (!file || !isInsideRoot(file, projectDir)) continue; + const raw = readIfExists(resolvePath(file, projectDir)); + out.push(raw.length > 256 * 1024 ? raw.slice(0, 256 * 1024) : raw); + total++; + } + if (total >= 50) break; + } + if (total >= 50) break; + } + return out.join('\n\n'); +} + +function cmdDecisionCoverageVerify(projectDir, args, raw) { + const phaseDir = args[2] ? resolvePath(args[2], projectDir) : ''; + const contextPath = args[3] ? resolvePath(args[3], projectDir) : ''; + + if (!gateEnabled(projectDir)) { + output({ skipped: true, blocking: false, reason: 'workflow.context_coverage_gate is false', total: 0, honored: 0, not_honored: [], message: 'Decision coverage gate disabled by config.' }, raw); + return; + } + if (!contextPath || !fs.existsSync(contextPath)) { + output({ skipped: true, blocking: false, reason: 'CONTEXT.md missing', total: 0, honored: 0, not_honored: [], message: 'No CONTEXT.md - nothing to check.' }, raw); + return; + } + + const decisions = loadTrackableDecisions(contextPath); + if (decisions.length === 0) { + output({ skipped: true, blocking: false, reason: 'no trackable decisions', total: 0, honored: 0, not_honored: [], message: 'No trackable decisions in CONTEXT.md.' }, raw); + return; + } + + const planContents = loadPlanContents(phaseDir); + const summaryParts = fs.existsSync(phaseDir) + ? fs.readdirSync(phaseDir).filter((entry) => /-SUMMARY\.md$/.test(entry)).map((entry) => readIfExists(path.join(phaseDir, entry))) + : []; + const haystack = [ + planContents.join('\n\n'), + summaryParts.join('\n\n'), + readModifiedFilesContent(projectDir, summaryParts), + recentCommitMessages(projectDir), + ].join('\n\n'); + + const notHonored = []; + let honored = 0; + for (const decision of decisions) { + if (decisionMentioned(haystack, decision)) honored++; + else notHonored.push({ id: decision.id, text: decision.text, category: decision.category }); + } + + output({ + skipped: false, + blocking: false, + total: decisions.length, + honored, + not_honored: notHonored, + message: buildVerifyMessage(notHonored), + }, raw); +} + +function routeCheckCommand({ args, cwd, raw }) { + const subcommand = args[1]; + if (subcommand === 'auto-mode') { + cmdAutoMode(cwd, raw); + return; + } + if (subcommand === 'decision-coverage-plan') { + cmdDecisionCoveragePlan(cwd, args, raw); + return; + } + if (subcommand === 'decision-coverage-verify') { + cmdDecisionCoverageVerify(cwd, args, raw); + return; + } + error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify', ERROR_REASON.SDK_UNKNOWN_COMMAND); +} + +module.exports = { + routeCheckCommand, + decisionMentioned, + extractPlanDesignatedSections, +}; diff --git a/get-shit-done/bin/lib/task-command-router.cjs b/get-shit-done/bin/lib/task-command-router.cjs new file mode 100644 index 000000000..0cdd848bb --- /dev/null +++ b/get-shit-done/bin/lib/task-command-router.cjs @@ -0,0 +1,81 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { output, error, ERROR_REASON } = require('./core.cjs'); + +function isBehaviorAddingTaskContent(content) { + const tddTrue = /\btdd\s*=\s*["']true["']/i.test(content); + + const behaviorMatch = content.match(/([\s\S]*?)<\/behavior>/i); + const hasBehaviorBlock = Boolean(behaviorMatch && behaviorMatch[1].trim().length > 0); + + const filesMatch = content.match(/([\s\S]*?)<\/files>/i); + let hasSourceFiles = false; + if (filesMatch) { + const fileLines = filesMatch[1] + .split(/[\n,]/) + .map((line) => line.trim().replace(/^[-*]\s*/, '')) + .filter(Boolean); + hasSourceFiles = fileLines.some((file) => + !/\.md$/i.test(file) && + !/\.json$/i.test(file) && + !/\.test\.[^.]+$/i.test(file) && + !/\.spec\.[^.]+$/i.test(file) && + !/(^|[\\/])tests?[\\/]/i.test(file) && + !/\.(yml|yaml|toml|ini|cfg|conf|properties)$/i.test(file) && + !/(^|[\\/])\.env(\..+)?$/i.test(file) + ); + } + + const isBehaviorAdding = tddTrue && hasBehaviorBlock && hasSourceFiles; + const missing = []; + if (!tddTrue) missing.push('tdd="true" frontmatter absent'); + if (!hasBehaviorBlock) missing.push(' block missing or empty'); + if (!hasSourceFiles) missing.push(' has no non-test source file'); + + return { + is_behavior_adding: isBehaviorAdding, + checks: { + tdd_true: tddTrue, + has_behavior_block: hasBehaviorBlock, + has_source_files: hasSourceFiles, + }, + reason: isBehaviorAdding ? null : `Not behavior-adding: ${missing.join('; ')}`, + }; +} + +function routeTaskCommand({ args, cwd, raw }) { + const subcommand = args[1]; + if (subcommand !== 'is-behavior-adding') { + error('Unknown task subcommand. Available: is-behavior-adding', ERROR_REASON.SDK_UNKNOWN_COMMAND); + } + + let content = null; + if (args[2] === '--task-content') { + content = args[3] || null; + } else if (args[2]) { + const projectRoot = path.resolve(cwd || process.cwd()); + const requestedPath = args[2]; + const resolvedTaskPath = path.resolve(projectRoot, requestedPath); + const rel = path.relative(projectRoot, resolvedTaskPath); + if (rel === '..' || rel.startsWith(`..${path.sep}`)) { + error(`Task file is outside project scope: ${requestedPath}`, ERROR_REASON.USAGE); + } + if (!fs.existsSync(resolvedTaskPath)) { + error(`Task file not found: ${requestedPath}`, ERROR_REASON.USAGE); + } + content = fs.readFileSync(resolvedTaskPath, 'utf-8'); + } + + if (!content) { + error('Usage: task.is-behavior-adding | --task-content ""', ERROR_REASON.USAGE); + } + + output(isBehaviorAddingTaskContent(content), raw); +} + +module.exports = { + isBehaviorAddingTaskContent, + routeTaskCommand, +}; diff --git a/tests/sdk-removal-query-family-dispatch.test.cjs b/tests/sdk-removal-query-family-dispatch.test.cjs new file mode 100644 index 000000000..202e3c9c5 --- /dev/null +++ b/tests/sdk-removal-query-family-dispatch.test.cjs @@ -0,0 +1,101 @@ +'use strict'; + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); + +describe('SDK-removal CJS query family dispatch', () => { + let tmpDir; + let phaseDir; + let contextPath; + + beforeEach(() => { + tmpDir = createTempProject('gsd-query-families-'); + phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); + fs.mkdirSync(phaseDir, { recursive: true }); + contextPath = path.join(phaseDir, '01-CONTEXT.md'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('query agent.classify-failure classifies quota text', () => { + const result = runGsdTools(['query', 'agent.classify-failure', '--', '429 retry-after: 45'], tmpDir); + assert.strictEqual(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.class, 'quota-exceeded'); + assert.equal(parsed.sentinel, '429'); + assert.equal(parsed.retryAfterSeconds, 45); + }); + + test('query task.is-behavior-adding supports --pick for workflow gate usage', () => { + const task = [ + '', + 'User can save a profile', + '', + '- src/profile.js', + '- tests/profile.test.js', + '', + '', + ].join('\n'); + + const result = runGsdTools(['query', 'task.is-behavior-adding', '--task-content', task, '--pick', 'is_behavior_adding'], tmpDir); + assert.strictEqual(result.success, true, result.error); + assert.equal(result.output, 'true'); + }); + + test('query check auto-mode reads workflow flags from config', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ + workflow: { + auto_advance: false, + _auto_chain_active: true, + }, + }, null, 2)); + + const result = runGsdTools(['query', 'check', 'auto-mode', '--pick', 'source'], tmpDir); + assert.strictEqual(result.success, true, result.error); + assert.equal(result.output, 'auto_chain'); + }); + + test('query check.decision-coverage-plan reaches the CJS gate and passes covered decisions', () => { + fs.writeFileSync(contextPath, [ + '', + '### Product', + '- **D-01:** Keep the runtime command path installed and portable for users.', + '', + ].join('\n')); + fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), [ + '---', + 'objective: "Implement D-01 in the runtime launcher"', + 'must_haves:', + ' - "D-01 remains covered"', + '---', + '', + '', + 'Preserve D-01.', + '', + ].join('\n')); + + const result = runGsdTools(['query', 'check.decision-coverage-plan', phaseDir, contextPath, '--pick', 'passed'], tmpDir); + assert.strictEqual(result.success, true, result.error); + assert.equal(result.output, 'true'); + }); + + test('query check.decision-coverage-verify returns non-blocking misses', () => { + fs.writeFileSync(contextPath, [ + '', + '### Product', + '- **D-02:** Keep verification warnings visible when decisions are missing.', + '', + ].join('\n')); + + const result = runGsdTools(['query', 'check.decision-coverage-verify', phaseDir, contextPath], tmpDir); + assert.strictEqual(result.success, true, result.error); + const parsed = JSON.parse(result.output); + assert.equal(parsed.blocking, false); + assert.equal(parsed.not_honored[0].id, 'D-02'); + }); +});