feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A) (#2341)

* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A)

Golden/read-only parity tests and registry alignment, query handler fixes
(check-completion, state-mutation, commit, validate, summary, etc.), and
WAITING.json dual-write for .gsd/.planning readers.

Refs gsd-build/get-shit-done#2341

* fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback)

- Recognize in-flight 🟡 milestone bullets like 🚧.
- Derive from last **vX.Y Title** before ## Phases when emoji absent.
- Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y
  in cleaned text instead of first (avoids v1.0 from shipped list).
- Fixes init.execute-phase milestone_version and buildStateFrontmatter after
  state.begin-phase (syncStateFrontmatter).

* feat(sdk): phase list, plan task structure, requirements extract handlers

- Register phase.list-plans, phase.list-artifacts, plan.task-structure,
  requirements.extract-from-plans (SDK-only; golden-policy exceptions).
- Add unit tests; document in QUERY-HANDLERS.md.
- writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored.

* feat(sdk): centralize getGsdAgentsDir in query helpers

Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary
~/.claude/agents, legacy path). Use from init and docs-init init bundles.

docs(15): add 15-CONTEXT for autonomous phase-15 run.

* feat(sdk): query CLI CJS fallback and session correlation

- createRegistry(eventStream, sessionId) threads correlation into mutation events
- gsd-sdk query falls back to gsd-tools.cjs when no native handler matches
  (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings
- Export createRegistry from @gsd-build/sdk; add sdk/README.md
- Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId
- Agents: prefer node dist/cli.js query over cat/grep for STATE and plans

* fix(sdk): init phase_found parity, docs-init agents path, state field extract

- Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal)

- docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled

- state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard)

- Tests: commit_docs default true; config-get golden uses temp config; golden integration green

Refs: #2302

* refactor(sdk): share SessionJsonlRecord in profile-extract-messages

CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages.

* fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify)

- Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command

- plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment

- check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default

- state.validate/sync: phaseTokenMatches + comparePhaseNum ordering

- verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter

- audit-open: has_scan_errors, unreadable rows, human report when scans fail

- requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note

- ingest-docs: repo-root path containment; classifier output slug-hash

Golden parity test strips has_scan_errors until CJS adds field.

* fix: Resolve CodeRabbit security and quality findings
- Secure intel.ts and cli.ts against path traversal
- Catch and validate git add status in commit.ts
- Expand roadmap milestone marker extraction
- Fix parsing array-of-objects in frontmatter YAML
- Fix unhandled config evaluations
- Improve coverage test parity mapping

* test: raise planner character extraction limit to 48K

* fix(sdk): resolve TS build error in docs-init passing config
This commit is contained in:
Rezolv
2026-04-20 18:09:02 -04:00
committed by GitHub
parent c8807e38d7
commit c5b1445529
109 changed files with 11588 additions and 1030 deletions

View File

@@ -110,7 +110,7 @@ Regardless of type, extract:
</step>
<step name="write_output">
Write to `{OUTPUT_DIR}/{slug}.json` where `slug` is the filename without extension (replace non-alphanumerics with `-`).
Write to `{OUTPUT_DIR}/{slug}-{source_hash}.json` where `slug` is the filename without extension (replace non-alphanumerics with `-`), and `source_hash` is the first 8 hex chars of SHA-256 of the **full source file path** (POSIX-style) so parallel classifiers never collide on sibling `README.md` files.
JSON schema:

View File

@@ -72,10 +72,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Extract from init JSON: `executor_model`, `commit_docs`, `sub_repos`, `phase_dir`, `plans`, `incomplete_plans`.
Also read STATE.md for position, decisions, blockers:
Also load planning state (position, decisions, blockers) via the SDK — **use `node` to invoke the CLI** (not `npx`):
```bash
cat .planning/STATE.md 2>/dev/null
node ./node_modules/@gsd-build/sdk/dist/cli.js query state.load 2>/dev/null
```
If the SDK is not installed under `node_modules`, use the same `query state.load` argv with your local `gsd-sdk` CLI on `PATH`.
If STATE.md missing but .planning/ exists: offer to reconstruct or continue without.
If .planning/ missing: Error — project not initialized.

View File

@@ -639,11 +639,11 @@ Extract from init JSON: `phase_dir`, `phase_number`, `has_plans`, `plan_count`.
Orchestrator provides CONTEXT.md content in the verification prompt. If provided, parse for locked decisions, discretion areas, deferred ideas.
```bash
ls "$phase_dir"/*-PLAN.md 2>/dev/null
# Read research for Nyquist validation data
cat "$phase_dir"/*-RESEARCH.md 2>/dev/null
gsd-sdk query roadmap.get-phase "$phase_number"
ls "$phase_dir"/*-BRIEF.md 2>/dev/null
node ./node_modules/@gsd-build/sdk/dist/cli.js query phase.list-plans "$phase_number"
# Research / brief artifacts (deterministic listing)
node ./node_modules/@gsd-build/sdk/dist/cli.js query phase.list-artifacts "$phase_number" --type research
node ./node_modules/@gsd-build/sdk/dist/cli.js query roadmap.get-phase "$phase_number"
node ./node_modules/@gsd-build/sdk/dist/cli.js query phase.list-artifacts "$phase_number" --type summary
```
**Extract:** Phase goal, requirements (decompose goal), locked decisions, deferred ideas.
@@ -729,10 +729,11 @@ The `tasks` array in the result shows each task's completeness:
**Check:** valid task type (auto, checkpoint:*, tdd), auto tasks have files/action/verify/done, action is specific, verify is runnable, done is measurable.
**For manual validation of specificity** (`verify.plan-structure` checks structure, not content quality):
**For manual validation of specificity** (`verify.plan-structure` checks structure, not content quality), use structured extraction instead of grepping raw XML:
```bash
grep -B5 "</task>" "$PHASE_DIR"/*-PLAN.md | grep -v "<verify>"
node ./node_modules/@gsd-build/sdk/dist/cli.js query plan.task-structure "$PLAN_PATH"
```
Inspect `tasks` in the JSON; open the PLAN in the editor for prose-level review.
## Step 6: Verify Dependency Graph
@@ -757,8 +758,8 @@ Missing: No mention of fetch/API call → Issue: Key link not planned
## Step 8: Assess Scope
```bash
grep -c "<task" "$PHASE_DIR"/$PHASE-01-PLAN.md
grep "files_modified:" "$PHASE_DIR"/$PHASE-01-PLAN.md
node ./node_modules/@gsd-build/sdk/dist/cli.js query plan.task-structure "$PHASE_DIR/$PHASE-01-PLAN.md"
node ./node_modules/@gsd-build/sdk/dist/cli.js query frontmatter.get "$PHASE_DIR/$PHASE-01-PLAN.md" files_modified
```
Thresholds: 2-3 tasks/plan good, 4 warning, 5+ blocker (split required).

View File

@@ -812,10 +812,11 @@ if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
Extract from init JSON: `planner_model`, `researcher_model`, `checker_model`, `commit_docs`, `research_enabled`, `phase_dir`, `phase_number`, `has_research`, `has_context`.
Also read STATE.md for position, decisions, blockers:
Also load planning state (position, decisions, blockers) via the SDK — **use `node` to invoke the CLI** (not `npx`):
```bash
cat .planning/STATE.md 2>/dev/null
node ./node_modules/@gsd-build/sdk/dist/cli.js query state.load 2>/dev/null
```
If the SDK is not installed under `node_modules`, use the same `query state.load` argv with your local `gsd-sdk` CLI on `PATH`.
If STATE.md missing but .planning/ exists, offer to reconstruct or continue without.
</step>

View File

@@ -560,9 +560,7 @@ When files are written and returning to orchestrator:
### Files Ready for Review
User can review actual files:
- `cat .planning/ROADMAP.md`
- `cat .planning/STATE.md`
User can review actual files in the editor or via SDK queries (e.g. `node ./node_modules/@gsd-build/sdk/dist/cli.js query roadmap.analyze` and `query state.load`) instead of ad-hoc shell `cat`.
{If gaps found during creation:}

View File

@@ -29,12 +29,13 @@ process.on('exit', () => {
// Shared helper: extract a field value from STATE.md content.
// Supports both **Field:** bold and plain Field: format.
// Horizontal whitespace only after ':' so YAML keys like `progress:` do not match as `Progress:` (parity with sdk/helpers stateExtractField).
function stateExtractField(content, fieldName) {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}

View File

@@ -41,6 +41,8 @@ if [ -n "{MANIFEST_PATH}" ]; then
fi
```
**Containment (required):** After resolving `SCAN_PATH` and `MANIFEST_PATH` relative to the repo root, canonicalize each with `realpath` (or platform equivalent) and assert the result is under `realpath("$REPO_ROOT")`. Reject absolute paths outside the repo (e.g. `/tmp`, `C:\Windows`) even when they do not contain `..`.
If `PATH_NOT_FOUND` or `MANIFEST_NOT_FOUND`: display error and exit.
</step>

View File

@@ -0,0 +1,237 @@
# Handover: Query layer + golden parity
Use this document at the start of a new session so work continues in context without re-deriving history.
**Related:** `HANDOVER-PARITY-DOCS.md` (#2302 scope); **`sdk/src/query/QUERY-HANDLERS.md`** (golden matrix, CJS↔SDK routing).
---
## Goal for the next session (primary)
**Track A (Golden/parity) is complete.** 127/128 canonicals covered — the single exception (`phases.archive`) is permanent (SDK-only, no CJS analogue). Focus shifts to the remaining #2302 acceptance criteria.
**Ongoing:** pick next gap from **`GOLDEN_PARITY_EXCEPTIONS`** / registry orphans (run `golden-policy.test.ts`) or expand **`READ_ONLY_JSON_PARITY_ROWS`** for read-only handlers still on generic exceptions. The read-only batch in **§ Next batch** below is **done**.
**Follow-up:** confirm **`GOLDEN_PARITY_EXCEPTIONS`** for any remaining read-only registry gaps (`learnings.query`, `progress.bar`, `profile-questionnaire` — still exception-only until strict rows); extend **`read-only-golden-rows.ts`** when aligned.
### Remaining work — ordered by priority
1. **Track C — Runner alignment** (not started)
- `PhaseRunner` and `InitRunner` both take `GSDTools` (subprocess bridge) as a `tools` dependency (`phase-runner.ts:55`, `init-runner.ts:70`).
- Issue #2302 says: "Align programmatic paths with the same contracts as query handlers (shared helpers or registry dispatch), **without** removing `GSDTools`."
- Concretely: where runners currently shell out via `GSDTools.run('state update …')`, they could call the typed handler (`stateUpdate()`) directly or dispatch through `createRegistry()`. This eliminates subprocess overhead on the hot path while keeping `GSDTools` exported for backward compatibility.
- Files to touch: `sdk/src/phase-runner.ts`, `sdk/src/init-runner.ts`, `sdk/src/index.ts` (re-exports). Tests: `phase-runner.integration.test.ts`, `init-e2e.integration.test.ts`, `lifecycle-e2e.integration.test.ts`.
- **Risk:** Runner integration tests are slow and sensitive to state. Approach: swap one `tools.run()` call at a time, verify the integration test still passes, then proceed to the next.
2. **Track B — CHANGELOG.md [Unreleased] entries** (not started)
- `CHANGELOG.md` has an `[Unreleased]` section but no Phase 3 entries yet.
- Add entries covering: golden parity policy gate, mutation subprocess infrastructure, handler alignment, profile-output port, CJS deprecation header.
- `docs/CLI-TOOLS.md` already references `QUERY-HANDLERS.md` and SDK query layer — may need minor polish but is substantively done.
- `QUERY-HANDLERS.md` is maintained and current.
3. **Track D — CJS deprecation headers** (done)
- `gsd-tools.cjs` already has `@deprecated` JSDoc header (lines 3-6) pointing to `gsd-sdk query` and `@gsd-build/sdk`.
- No additional CJS file deletion in scope per #2302.
4. **CI verification** (should run before any PR)
- Run full integration suite: `npx vitest run --project integration` (mutation subprocess + read-only parity + golden composition).
- Verify against CI matrix expectations: Ubuntu + macOS, Node 22 + 24.
### Acceptance criteria from #2302 — status
| Criterion | Status | Notes |
| --------- | ------ | ----- |
| Policy gate | **Done** | `verifyGoldenPolicyComplete()` green; 0 orphan canonicals |
| Parity | **Done** | 127/128 covered; strict rows, mutation subprocess, composition goldens |
| Registry | **Done** | CJS-only matrix in `QUERY-HANDLERS.md`; `docs/CLI-TOOLS.md` updated |
| Runners (Track C) | **Not started** | `PhaseRunner`/`InitRunner` still use `GSDTools` subprocess bridge |
| Deprecation (Track D) | **Done** | `@deprecated` header on `gsd-tools.cjs` |
| Docs | **Partial** | `QUERY-HANDLERS.md` current; `CHANGELOG.md` [Unreleased] needs Phase 3 entries |
| CI | **Not verified** | Unit tests green (1261/1261); integration suite not run this session |
---
## Repo / branch
- **Workspace:** `D:\Repos\get-shit-done` (GSD PBR backport initiative).
- **Feature branch:** `feat/sdk-phase3-query-layer` (62 commits ahead of `main`; confirm against `origin` before merging).
- **Upstream PRs:** `gsd-build/get-shit-done` issue #2302.
---
## Golden parity architecture (current)
| Piece | Role |
| ----- | ---- |
| `sdk/src/golden/registry-canonical-commands.ts` | One canonical dispatch string per unique handler (`pickCanonicalCommandName`). |
| `sdk/src/golden/golden-integration-covered.ts` | Canonicals exercised by **`golden.integration.test.ts`** (subset/full/shape tests). |
| `sdk/src/golden/read-only-golden-rows.ts` | **Strict** `JsonParityRow[]` for `read-only-parity.integration.test.ts` (`toEqual` on parsed CJS JSON vs `sdkResult.data`). |
| `sdk/src/golden/read-only-parity.integration.test.ts` | Rows from `READ_ONLY_JSON_PARITY_ROWS` + **`config-path`** (plain stdout vs `{ path }`, `path.normalize`) + **`verify.commits`**. |
| `sdk/src/golden/capture.ts` | `captureGsdToolsOutput` (JSON stdout); **`captureGsdToolsStdout`** (raw stdout, e.g. `config-path`). |
| `sdk/src/golden/golden-policy.ts` | `GOLDEN_PARITY_INTEGRATION_COVERED` = integration ∪ `readOnlyGoldenCanonicals()` ∪ **`GOLDEN_MUTATION_SUBPROCESS_COVERED`**; `GOLDEN_PARITY_EXCEPTIONS` includes `NO_CJS_SUBPROCESS_REASON`, then `MUTATION_DEFERRED_REASON` for remaining mutations, else read-only. |
| `sdk/src/golden/golden-mutation-covered.ts` | Canonicals exercised by **`mutation-subprocess.integration.test.ts`** (must match non-skipped tests). |
| `sdk/src/golden/mutation-subprocess.integration.test.ts` | Tmp fixture + `captureGsdToolsOutput` vs `registry.dispatch`; dual sandbox per comparison. |
| `sdk/src/golden/mutation-sandbox.ts` | `createMutationSandbox({ git?: boolean })` — copy fixture, optional `git init` + commit. |
| `sdk/src/golden/golden-policy.test.ts` | Calls `verifyGoldenPolicyComplete()` so every canonical is covered or excepted. |
**Invariant:** Every canonical from `getCanonicalRegistryCommands()` is either in `GOLDEN_PARITY_INTEGRATION_COVERED` or has an exception string—**never** leave orphans by removing tests.
---
## Reference pattern: porting like `scan-sessions` and `workstream.status`
These were fixed by **aligning the TypeScript handler with the CJS implementation**, then adding a row to `READ_ONLY_JSON_PARITY_ROWS`.
1. **Find the CJS source of truth**
- `scan-sessions`: `get-shit-done/bin/lib/profile-pipeline.cjs` → `cmdScanSessions`
- `workstream status`: `get-shit-done/bin/lib/workstream.cjs` → `cmdWorkstreamStatus`
- `gsd-tools.cjs` `runCommand` switch shows the top-level command and argv.
2. **Implement or adjust the SDK module**
- Example: `sdk/src/query/profile-scan-sessions.ts` mirrors the project-array build from `cmdScanSessions`; `scanSessions` in `profile.ts` parses `--path` / `--verbose`, throws when no sessions root (same error text as CJS), returns `{ data: projects }` where `projects` matches CJS JSON array.
3. **Add a parity row** in `read-only-golden-rows.ts` with `canonical`, `sdkArgs`, `cjs`, `cjsArgs` (must match what `execFile(node, [gsdToolsPath, command, ...args])` expects).
4. **Run**
`cd sdk && npm run build && npx vitest run src/golden/read-only-parity.integration.test.ts src/golden/golden-policy.test.ts --project integration --project unit`
5. **Policy**
`readOnlyGoldenCanonicals()` picks up new canonicals automatically; no manual duplicate if the canonical is already in the JSON row list.
**When not to copy line-for-line:** subprocess-only concerns (e.g. `agents_installed` / `missing_agents` differing from in-process `~` resolution). Then **normalize in the test** (see `golden.integration.test.ts` `docs-init`: sort `existing_docs`, omit install fields)—**document in QUERY-HANDLERS.md**, do not delete the assertion.
---
## Completed — Track A (golden parity)
All 127 portable canonicals have subprocess or in-process parity coverage. Summary of completed work by batch:
### Profile-output + milestone subprocess batch (latest)
**`write-profile`**, **`generate-claude-profile`**, **`generate-dev-preferences`**, **`generate-claude-md`** — implemented in **`sdk/src/query/profile-output.ts`** (templates from `get-shit-done/templates/`, same JSON as `profile-output.cjs`); re-exported from **`profile.ts`**. **`milestone.complete`** — full port of **`cmdMilestoneComplete`** in **`phase-lifecycle.ts`**; **`readModifyWriteStateMdFull`** in **`state-mutation.ts`** for STATE writes matching CJS.
### Mutation subprocess infrastructure
**`mutation-subprocess.integration.test.ts`** — tmp fixture `sdk/src/golden/fixtures/mutation-project/` + `createMutationSandbox()` (`mutation-sandbox.ts`). **`assertJsonParity`** runs CJS and SDK on **two fresh sandboxes** (factory fn) so neither run sees the other's filesystem mutations. **`GOLDEN_MUTATION_SUBPROCESS_COVERED`** lists canonicals with non-skipped subprocess assertions. Handlers covered: `config-ensure-section`, `commit`, `commitToSubrepo`, `configSetModelProfile`, `state.patch`, `frontmatter.set`/`merge`, `workstream.progress`, `workstream.set`, nine `state.*` subprocess tests, `write-profile`, `generate-claude-profile`, `generate-dev-preferences`, `generate-claude-md`, `milestone.complete`, `init.remove-workspace`.
### CJS mutation handler alignment
`commit.ts` — `--files` argv boundary, `commitToSubrepo` config check, `checkCommit` `allowed` field. `state-mutation.ts` — `readModifyWriteStateMdFull`, `statePlannedPhase`=`cmdStatePlannedPhase`, record-session/add-decision/add-blocker/resolve-blocker/record-metric/update-progress JSON shapes. `phase-lifecycle.ts` — `milestone.complete`. `workstream.ts` — `workstream.progress` (`cmdWorkstreamProgress`), `workstream.set`. `roadmap.ts` — extracted `roadmapUpdatePlanProgress` to own module. `frontmatter-mutation.ts` — `--field`/`--value`, `--data` parsing. `config-mutation.ts` — `configSetModelProfile` CJS-shaped `{ updated, profile, previousProfile, agentToModelMap }`. `config-query.ts` — `getAgentToModelMapForProfile()`.
### Read-only parity rows (earlier batches)
`progress.table` / `stats.table`, `progress.bar`, `learnings.query`, `profile-questionnaire`, `verify.references`, `init.*` composition goldens (9 handlers), `profile-sample`, `extract-messages`, `uat.render-checkpoint`, `validate.agents` + `state.get`, `skill-manifest`, `audit-open` + `audit-uat`, `intel.extract-exports`, `summary-extract` + `history-digest`, `stats.json`, `todo.match-phase`, `verify.key-links`, `verify.schema-drift`, `state-snapshot`, `state.json`/`state.load`, `scan-sessions`, `workstream.status`.
---
## Next batch — summary / audit / skill / validate / UAT / intel / profile / init
**Same workflow as above:** read `gsd-tools.cjs` `runCommand` for argv → implement/adjust `sdk/src/query/*.ts` → add `READ_ONLY_JSON_PARITY_ROWS` and/or a **named `describe` block** with documented omissions → `npm run build` → `read-only-parity.integration.test.ts` + `golden-policy.test.ts`.
| Priority | Command (CLI) | `gsd-tools.cjs` case / args | CJS implementation | SDK module | Notes |
| -------- | ------------- | -------------------------- | -------------------- | ---------- | ----- |
| ~~1~~ | ~~`summary-extract <path>`~~ `[--fields a,b]` | `summary-extract` | `commands.cjs` `cmdSummaryExtract` (~L425) | `summary.ts` `summaryExtract` | **Done:** strict `READ_ONLY_JSON_PARITY_ROWS`; `summary.ts` aligned with `commands.cjs`; `extractFrontmatterLeading` in `frontmatter.ts` for first-`---`-block parity with `frontmatter.cjs`. |
| ~~2~~ | ~~`history-digest`~~ | `history-digest` | `commands.cjs` `cmdHistoryDigest` (~L133) | `summary.ts` `historyDigest` | **Done:** same row / handler alignment as above. |
| ~~3~~ | ~~`audit-open`~~ | `audit-open` `[--json]` | `audit.cjs` `auditOpenArtifacts` + optional `formatAuditReport` | `audit-open.ts` | **Done:** `--json` parity test + `scanned_at` normalization; `sanitizeForDisplay` = `security.cjs`. |
| ~~4~~ | ~~`audit-uat`~~ | `audit-uat` | `uat.cjs` `cmdAuditUat` | `uat.ts` `auditUat` | **Done:** `auditUat` ports `cmdAuditUat` (`parseUatItems`, milestone filter, `summary.by_*`); strict `READ_ONLY_JSON_PARITY_ROWS` row. |
| ~~5~~ | ~~`skill-manifest`~~ | `skill-manifest` + args | `init.cjs` `cmdSkillManifest` (~L1829) | `skill-manifest.ts` | **Done:** strict row; `extractFrontmatterLeading` for CJS parity (see `QUERY-HANDLERS.md`). |
| ~~6~~ | ~~`validate agents`~~ | `validate` + `agents` | `verify.cjs` `cmdValidateAgents` (~L997) | `validate.ts` `validateAgents` | **Done:** strict row; `getAgentsDir` parity with `core.cjs`; `MODEL_PROFILES` includes `gsd-pattern-mapper` (sync with `model-profiles.cjs`). |
| ~~7~~ | ~~`uat render-checkpoint --file <path>`~~ | `uat` subcommand | `uat.cjs` `cmdRenderCheckpoint` | `uat.ts` `uatRenderCheckpoint` | **Done:** strict row; fixture `sdk/src/golden/fixtures/uat-render-checkpoint-sample.md`; see `QUERY-HANDLERS.md`. |
| ~~8~~ | ~~`intel extract-exports <file>`~~ | `intel` `extract-exports` | `intel.cjs` `intelExtractExports` (~L502) | `intel.ts` `intelExtractExports` | **Done:** strict row + handler parity with `intel.cjs` (fixed file e.g. `sdk/src/query/utils.ts`). |
| ~~9~~ | ~~`extract-messages`~~ | `extract-messages` + project/session flags | `profile-pipeline.cjs` | `profile.ts` `extractMessages` | **Done:** `profile-extract-messages.ts` + golden `output_file` strip + JSONL compare; fixture `extract-messages-sessions/`. |
| ~~10~~ | ~~`profile-sample`~~ | `profile-sample` | `profile-pipeline.cjs` | `profile.ts` `profileSample` | **Done:** `profile-sample.ts` + golden `output_file` strip + JSONL compare; fixture `profile-sample-sessions/`. |
| ~~11~~ | ~~**`init.*` read-only JSON**~~ | various | `init.cjs` / `init-complex` | `init.ts`, `init-complex.ts` | **Done:** `golden.integration.test.ts` + nine init composition tests; `withProjectRoot` / `subagent_timeout` / `GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS`; see `QUERY-HANDLERS.md`. |
**Suggested order:** Audit/read-only batch above is complete — follow-ups via **`GOLDEN_PARITY_EXCEPTIONS`** / new strict rows as needed (`learnings.query`, `progress.bar`, `profile-questionnaire`, etc.).
**Done (this line of work):** `summary-extract` + `history-digest` — strict `READ_ONLY_JSON_PARITY_ROWS`; `summary.ts` aligned with `commands.cjs`; `extractFrontmatterLeading` in `frontmatter.ts` for first-`---`-block parity with `frontmatter.cjs`.
**Done (profile-output + milestone mutation batch):** `write-profile`, `generate-claude-profile`, `generate-dev-preferences`, `generate-claude-md` (`profile-output.ts`); `milestone.complete` (`phase-lifecycle.ts` + `readModifyWriteStateMdFull`); `GOLDEN_MUTATION_SUBPROCESS_COVERED` updated; **`MUTATION_SUBPROCESS_GAP_REASON` removed** from `golden-policy.ts`.
**Mutations** (`QUERY_MUTATION_COMMANDS`): subprocess coverage is **`mutation-subprocess.integration.test.ts`** + `GOLDEN_MUTATION_SUBPROCESS_COVERED`. Remaining mutation canonicals without a subprocess row use **`MUTATION_DEFERRED_REASON`** (see `golden-policy.ts`). For known gaps before parity, prefer **`it.skip`** with an explicit rationale in code comments or restore a dedicated gap map — do not rely on silent deferral alone.
---
## Backlog: other read-only handlers (lower priority or follow-ups)
Confirm against `GOLDEN_PARITY_EXCEPTIONS` in `golden-policy.ts` for the live list.
**Mutations:** Prefer tmp fixture + dual sandbox (see `mutation-sandbox.ts`). Do not green the suite by deleting subprocess tests; skip with **`it.skip`** and document the gap (policy entry or comment) until parity is restored.
---
## Not in the SDK registry (product decision)
- **`graphify`**, **`from-gsd2` / `gsd2-import`** — CLI-only; no registry handler.
---
## Files to know (updated)
| Path | Role |
| ---- | ---- |
| `sdk/src/query/index.ts` | `createRegistry()`, `QUERY_MUTATION_COMMANDS`. |
| `sdk/src/golden/golden-policy.ts` | Coverage set + exceptions; `verifyGoldenPolicyComplete()`. |
| `sdk/src/golden/read-only-golden-rows.ts` | Strict read-only JSON matrix. |
| `sdk/src/golden/read-only-parity.integration.test.ts` | Subprocess + dispatch parity tests. |
| `sdk/src/golden/capture.ts` | `captureGsdToolsOutput`, `captureGsdToolsStdout`. |
| `sdk/src/golden/fixtures/mutation-project/` | Ephemeral copy for mutation subprocess tests. |
| `sdk/src/golden/mutation-subprocess.integration.test.ts` | Mutation handler subprocess parity. |
| `sdk/src/golden/mutation-sandbox.ts` | `createMutationSandbox({ git?: boolean })`. |
| `sdk/src/query/profile-output.ts` | CJS-parity profile output handlers. |
| `sdk/src/phase-runner.ts` | **Track C target** — currently uses `GSDTools`. |
| `sdk/src/init-runner.ts` | **Track C target** — currently uses `GSDTools`. |
| `sdk/src/gsd-tools.ts` | Subprocess bridge; **not deleted** in Phase 3 scope. |
| `get-shit-done/bin/gsd-tools.cjs` | `runCommand` — argv routing. Has `@deprecated` header. |
| `get-shit-done/bin/lib/*.cjs` | Per-command implementations (CJS source of truth). |
---
## Commands (verification)
```bash
cd sdk
npm run build
npm run test:unit
npm run test:integration
```
Focused:
```bash
npx vitest run src/golden/read-only-parity.integration.test.ts src/golden/golden.integration.test.ts --project integration
npx vitest run src/golden/mutation-subprocess.integration.test.ts --project integration
npx vitest run src/golden/golden-policy.test.ts --project unit
```
---
## Success criteria (extend, not replace)
- **No regression:** `golden-policy.test.ts` / `verifyGoldenPolicyComplete()` stays green.
- **Track A complete:** 127/128 covered; read-only rows, mutation subprocess, composition goldens all in place.
- **Track C:** Runner alignment — `PhaseRunner` and `InitRunner` use typed handlers where possible; `GSDTools` remains exported.
- **CHANGELOG.md** [Unreleased] updated with Phase 3 entries.
- **`QUERY-HANDLERS.md`** updated when assertion style changes (full `toEqual` vs normalized subset).
**Do not "green the suite" by deleting or shrinking golden tests.** If a handler cannot match CJS byte-for-byte without product decisions, use **documented normalization** in the test or **fix the TypeScript handler** — do not silently remove assertions.
---
## Commit history (this branch)
62 commits ahead of `main` on `feat/sdk-phase3-query-layer`. Recent batch (5 commits):
```
95db59c docs(sdk): update handover for profile-output and mutation subprocess batch
05e8238 sdk(golden): mutation subprocess test infrastructure and golden policy
593d9be sdk(query): port profile output handlers from profile-output.cjs
a2d0eb6 sdk(query): CJS parity for state, phase-lifecycle, workstream, roadmap, frontmatter, config, and intel
8bd9f1d sdk(query): align commit handler with CJS --files argv and allowed field
```
**Cherry-pick notes:** Commits 1 (`8bd9f1d`) and 3 (`593d9be`) are independently cherry-pickable. Commit 2 (`a2d0eb6`) is a bulk handler alignment (13 files). Commit 4 (`05e8238`) depends on handlers from 2+3 at test-runtime but compiles independently. Commit 5 is docs-only.
---
*Update this file when registry or golden milestones change.*

View File

@@ -0,0 +1,97 @@
# Handover: Parity exceptions doc + CJS-only matrix (next session)
**Status:** The deliverables described below are implemented in `sdk/src/query/QUERY-HANDLERS.md` (sections **Golden parity: coverage and exceptions** and **CJS command surface vs SDK registry**). Use that file as the canonical registry + parity reference; this handover remains useful for issue **#2302** scope and parent **#2007** links.
Paste this document (or `@sdk/HANDOVER-PARITY-DOCS.md`) at the start of a new chat so work continues without re-auditing issue scope.
## Goal for this session
1. **Parity “exceptions” documentation** — A clear, maintainable description of where **full JSON equality** between `gsd-tools.cjs` and `createRegistry()` is **not** expected or not attempted, and why (stubs, structural-only tests, environment-dependent fields, ordering, etc.). Map this to **#2007 / #2302** expectations: no *undocumented* gap.
2. **CJS-only matrix** — A **single authoritative table**: each relevant `gsd-tools.cjs` surface (top-level command or documented cluster) → **registered in SDK** vs **permanent CLI-only** vs **alias / naming difference**, with a **one-line justification** where not registered.
## Parent tracking
- **Issue:** [gsd-build/get-shit-done#2302](https://github.com/gsd-build/get-shit-done/issues/2302) — Phase 3 SDK query parity, registry, docs (parent umbrella #2007).
- **Acceptance criteria touched here:** parity coverage/exceptions documented; registry audit reflected in a **matrix** (issue wording: “every required CJS surface either has a handler or appears in the CJS-only matrix with justification”).
## Repo / branch
- **Workspace:** `D:\Repos\get-shit-done` (PBR backport); adjust path if different machine.
- **Feature branch (typical):** `feat/sdk-phase3-query-layer` — confirm with `git branch` before editing.
- **Upstream:** `gsd-build/get-shit-done`.
## What already exists (do not duplicate blindly)
- `sdk/src/query/QUERY-HANDLERS.md` — Registry conventions, partial “not registered” list (**graphify**, **from-gsd2**), CLI name differences (**summary-extract** vs **summary.extract**, **scaffold** vs **phase.scaffold**), **intel.update** (CJS JSON parity; refresh via agent), **skill-manifest --write** / mutation events, **docs-init** golden note (agent install fields), **stateExtractField** rule.
- `sdk/src/golden/golden.integration.test.ts` — Source of truth for **which commands** are golden-tested and **how** (full equality vs subset vs normalized `existing_docs` vs omitted fields; `init.quick` strips clock-derived keys via `init-golden-normalize.ts`).
- `sdk/src/golden/capture.ts` — `captureGsdToolsOutput()` spawns `get-shit-done/bin/gsd-tools.cjs`.
- `docs/CLI-TOOLS.md` — User-facing CLI reference; should **link** to the parity exceptions + matrix (or host a short summary with pointer to `sdk/`).
## Deliverables (suggested shape)
### A) Parity exceptions section
Add or extend a dedicated section (prefer `QUERY-HANDLERS.md` under a heading like **"Golden parity: coverage and exceptions"**, or a new `sdk/PARITY.md` if the team wants less churn in QUERY-HANDLERS — **pick one canonical location** and link from the other).
Cover at least:
| Category | Examples to document |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Full JSON parity** | Commands where tests use `toEqual` on `sdkResult.data` vs CJS stdout JSON. |
| **Structural / field subset** | Tests that compare only selected keys (e.g. `frontmatter.get`, `find-phase` — SDK subset vs CJS). Full parity for `roadmap.analyze`, `init.*` (except `init.quick` volatile keys), etc. — see `QUERY-HANDLERS.md` matrix. |
| **Normalized comparison** | e.g. `docs-init`: `existing_docs` sorted by path; `agents_installed` / `missing_agents` omitted between subprocess vs in-process. |
| **CLI parity without in-process refresh** | `intel.update` — JSON matches CJS `intel.cjs` (spawn hint or disabled); refresh is agent-driven. |
| **Conditional behavior** | `skill-manifest`: writes only with `--write`; not in `QUERY_MUTATION_COMMANDS`. |
| **Environment / time** | `current-timestamp`: structure and format, not same instant. |
| **Not in golden suite** | Commands registered but not (yet) covered — list as **coverage gap** or **out of scope for golden** with rationale. |
### B) CJS-only matrix
Build the table by **diffing** `get-shit-done/bin/gsd-tools.cjs` `switch (command)` top-level cases against `createRegistry()` registrations in `sdk/src/query/index.ts`.
**Already documented as product-out-of-scope for registry:** **graphify**, **from-gsd2** / **gsd2-import**.
**Already documented as naming/alias differences (registered, different string):** **summary-extract** ↔ **summary.extract**; top-level **scaffold** ↔ **phase.scaffold**.
Matrix columns (suggested):
- **CJS command** (or subcommand pattern)
- **SDK dispatch name(s)** if any
- **Disposition:** Registered / CLI-only / Alias-only / Stub / N/A
- **Justification** (one line) if not a straight registered parity
Optional: footnote that `detect-custom-files` skips multi-repo root resolution in CJS (`SKIP_ROOT_RESOLUTION`) — behavior is documented in CLI; matrix can mention if relevant.
## Files likely to edit
| Path | Role |
| --------------------------------- | ----------------------------------------------------------------- |
| `sdk/src/query/QUERY-HANDLERS.md` | Primary home for exceptions + matrix, or link hub. |
| `sdk/PARITY.md` | Optional dedicated file if QUERY-HANDLERS becomes too long. |
| `docs/CLI-TOOLS.md` | Short “Parity & registry” subsection with links into `sdk/` docs. |
| `sdk/HANDOVER-GOLDEN-PARITY.md` | Optional one-line pointer to new parity doc section when done. |
## Out of scope for *this* handover session
- Implementing runner alignment (`GSDTools` → registry) — separate #2302 work.
- Adding `@deprecated` headers to `gsd-tools.cjs` — separate task.
- **CHANGELOG** — only if you batch doc work with release notes in same PR (optional).
## Verification
- No code behavior change required for pure docs; run `npm run build` in `sdk/` only if TypeScript-adjacent files were touched.
- Proofread: every **CLI-only** row has a **justification**; every **exception** in golden tests appears in the exceptions doc.
## Success criteria
- A reader can answer: **“Which commands are fully golden-parity vs partial vs stub vs untested?”** without reading the whole test file.
- A reader can answer: **“Which `gsd-tools` top-level commands are not registered and why?”** from one table.
- **#2302** acceptance bullets on parity documentation and registry matrix are satisfied for the **documentation** slice (remaining issue items may still be open for code).
---
*Created for handoff to “parity exceptions + CJS-only matrix” session. Update when the canonical doc location or golden coverage changes.*

170
sdk/HANDOVER-QUERY-LAYER.md Normal file
View File

@@ -0,0 +1,170 @@
# Handover: SDK query layer (registry, CLI, parity docs)
Paste this document (or `@sdk/HANDOVER-QUERY-LAYER.md`) at the start of a new session so work continues without re-deriving scope.
## Parent tracking
- **Issue:** [gsd-build/get-shit-done#2302](https://github.com/gsd-build/get-shit-done/issues/2302) — Phase 3 SDK query parity, registry, docs (umbrella #2007).
- **Workspace:** `D:\Repos\get-shit-done` (PBR backport). **Upstream:** `gsd-build/get-shit-done`. Confirm branch with `git branch` (typical: `feat/sdk-phase3-query-layer`).
### Scope anchors (do not confuse issues)
| Role | GitHub | Notes |
| --------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Product / requirements anchor** | [#2007](https://github.com/gsd-build/get-shit-done/issues/2007) | Problem statement, user stories, and target architecture for the SDK-first migration. **Do not** treat its original acceptance-checklist boxes as proof of what is merged upstream; work was split into phased PRs after maintainer review. |
| **Phase 3 execution scope** | [#2302](https://github.com/gsd-build/get-shit-done/issues/2302) **+ this handover** | What this branch is actually doing now: registry/CLI parity, docs, harness gaps, runner alignment follow-ups as listed below. |
| **Patch mine (if local tree is short)** | [PR #2008](https://github.com/gsd-build/get-shit-done/pull/2008) and matching branches | Large pre-phasing PR; cherry-pick or compare when something looks missing vs that line of work. |
---
## What was delivered (this line of work)
### 1. Parity documentation (`QUERY-HANDLERS.md`)
- **”Golden parity: coverage and exceptions”** — How `golden.integration.test.ts` compares SDK vs `gsd-tools.cjs` (full `toEqual`, subset, normalized `docs-init`, `intel.update` CJS parity, time-dependent fields, etc.).
- **”CJS command surface vs SDK registry”** — Naming aliases, CLI-only rows, SDK-only rows, and a **top-level `gsd-tools` command → SDK** matrix.
- `docs/CLI-TOOLS.md` — Short “Parity & registry” pointer into those sections.
- `HANDOVER-GOLDEN-PARITY.md` — One paragraph linking to the same sections.
### 2. `gsd-sdk query` tokenization (`normalizeQueryCommand`)
- **Problem:** `gsd-sdk query` used only argv[0] as the registry key, so `query state json` dispatched `state` (unregistered) instead of `state.json`.
- **Fix:** `sdk/src/query/normalize-query-command.ts` merges the same **command + subcommand** patterns as `gsd-tools` `runCommand()` (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase`, `scaffold …` → `phase.scaffold`, `progress bar` → `progress.bar`). Wired in `sdk/src/cli.ts` before `registry.dispatch()`.
- **Tests:** `sdk/src/query/normalize-query-command.test.ts`.
### 3. `phase add-batch` in the registry
- **Implementation:** `phaseAddBatch` in `sdk/src/query/phase-lifecycle.ts` — port of `cmdPhaseAddBatch` from `get-shit-done/bin/lib/phase.cjs` (batch append under one roadmap lock; sequential or `phase_naming: custom`).
- **Registration:** `phase.add-batch` and `phase add-batch` in `sdk/src/query/index.ts`; listed in `QUERY_MUTATION_COMMANDS` (dotted + space forms).
- **Tests:** `describe('phaseAddBatch')` in `sdk/src/query/phase-lifecycle.test.ts`.
- **Docs:** `QUERY-HANDLERS.md` updated — `phase add-batch` is **registered**; CLI-only table no longer lists it.
### 4. `state load` fully in the registry (split from `state json`)
Previously `state.json` and `state.load` were easy to confuse: CJS has two different commands — `cmdStateJson` (`state json`, rebuilt frontmatter) vs `cmdStateLoad` (`state load`, `loadConfig` + `state_raw` + existence flags).
- `stateJson` — `sdk/src/query/state.ts`; registry key `state.json`.
- `stateProjectLoad` — `sdk/src/query/state-project-load.ts`; registry key `state.load`. Uses `createRequire` to call `core.cjs` `loadConfig(projectDir)` from the same resolution paths as a normal install (bundled monorepo path, `projectDir/.claude/get-shit-done/...`, `~/.claude/get-shit-done/...`). `GSDTools.stateLoad()` and `formatRegistryRawStdout` for `--raw` no longer force a subprocess solely for this command.
- **Risk:** If `core.cjs` is absent (e.g. some `@gsd-build/sdk`-only layouts), `state.load` throws `GSDError` — document; future option is a TS `loadConfig` port or bundling.
- **Goldens:** `read-only-parity.integration.test.ts` — one block compares `state.json` to `state json` (strip `last_updated`); another compares `state.load` to `state load` (full `toEqual`). `read-only-golden-rows.ts` `readOnlyGoldenCanonicals()` includes both `state.json` and `state.load`.
---
## Query surface completeness (snapshot)
| Status | Surface |
| ------------------------ | ------------------------------------------------------------------------------------------------ |
| **Registered** | Essentially all `gsd-tools.cjs` `runCommand` surfaces, including `phase.add-batch`. |
| **CLI-only (by design)** | `graphify`, `from-gsd2` — not in `createRegistry()`; documented in `QUERY-HANDLERS.md`. |
| **SDK-only extra** | `phases.archive` — no `gsd-tools phases archive` subcommand (CJS has `list` / `clear` only). |
**Programmatic API:** `createRegistry()` / `registry.dispatch('dotted.name', args, projectDir)`.
**CLI:** `gsd-sdk query …` — apply `normalizeQueryCommand` semantics (or pass dotted names explicitly).
**Still not unified:** `GSDTools` (`sdk/src/gsd-tools.ts`) shells out to `gsd-tools.cjs` for plan/session flows; migrating callers to the registry is separate #2302 / runner work. `state load` is **not** among the subprocess-only exceptions anymore (it uses the registry like other native query handlers when native query is active).
---
## Canonical files
| Path | Role |
| ------------------------------------------- | -------------------------------------------------------------------------------------- |
| `sdk/src/query/index.ts` | `createRegistry()`, `QUERY_MUTATION_COMMANDS`, handler wiring. |
| `sdk/src/query/state-project-load.ts` | `state.load` — CJS `cmdStateLoad` parity (`loadConfig` + `state_raw` + flags). |
| `sdk/src/query/normalize-query-command.ts` | CLI argv → registry command string. |
| `sdk/src/cli.ts` | `gsd-sdk query` path (uses `normalizeQueryCommand`). |
| `sdk/src/query/QUERY-HANDLERS.md` | Registry contracts, parity tiers, CJS matrix, mutation notes. |
| `sdk/src/golden/golden.integration.test.ts` | Golden parity vs `captureGsdToolsOutput()`. |
| `docs/CLI-TOOLS.md` | User-facing CLI; links to parity sections. |
Related handovers: `HANDOVER-GOLDEN-PARITY.md`, `HANDOVER-PARITY-DOCS.md` (older parity-doc brief; content largely folded into `QUERY-HANDLERS.md`).
---
## Roadmap: parity vs decision offloading
Work that moves **deterministic** orchestration out of AI/bash and into **SDK queries** (historically `gsd-tools.cjs`) has **two layers**. Do not confuse them:
| Layer | Goal | What “done” looks like |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| **Parity / migration** | Existing CLI behavior is **stable and testable** in the registry so callers can use `gsd-sdk query` instead of `node …/gsd-tools.cjs` without silent drift. | Goldens + `QUERY-HANDLERS.md`; same JSON/`--raw` contracts as CJS. |
| **Offloading decisions** | **New or consolidated** queries replace repeated `grep`, `ls` piped to `wc -l`, many `config-get`s, and inline `node -e` in workflows — so the model does less parsing and branching. | Fewer inline shell blocks; measurable token/step reduction on representative workflows. |
Phase 3–style registry work mainly advances **parity**. The `decision-routing-audit.md` proposals are mostly **offloading** — they assume parity exists for commands workflows already call.
### Decision-routing audit (proposed `gsd-tools` / SDK queries)
Source: `.planning/research/decision-routing-audit.md` §3. **Tier** = priority from §5 (implementation order). **Do not implement** = explicitly rejected in the audit.
| # | Proposed command | Tier | Notes |
|---|------------------|------|--------|
| 3.1 | `route next-action` | **1** | Next slash-command from `/gsd-next`-style routing. |
| 3.2 | `check gates <workflow>` | 3 | Safety gates (continue-here, error state, verification debt). |
| 3.3 | `check config-gates <workflow>` | **1** | Batch `workflow.*` config for orchestration (replaces many `config-get`s). |
| 3.4 | `check phase-ready <phase>` | **1** | Phase directory readiness + `next_step` hint. |
| 3.5 | `check auto-mode` | 2 | `auto_advance` + `_auto_chain_active` → single boolean. |
| 3.6 | `detect phase-type <phase>` | 2 | Structured UI/schema detection (replaces fragile grep). |
| 3.7 | `check completion <scope>` | 2 | Phase or milestone completion rollup. |
| 3.8 | `check verification-status <phase>` | 3 | VERIFICATION.md parsing for routing. |
| 3.9 | `check ship-ready <phase>` | 3 | Ship preflight (`ship.md`). |
| 3.10 | `route workflow-steps <workflow>` | ❌ **Do not implement** | Pre-computed step lists are unsound when mid-workflow writes change state. See `review-and-risks.md` §3.6. |
**Not in audit:** `phase-artifact-counts` was only an example in an older handover line; there is no §3.11 for it — add via a new research doc if needed.
**SDK registry (Tier 1):** **Done** — `check.config-gates`, `check.phase-ready`, `route.next-action` in `createRegistry()` (`sdk/src/query/index.ts`). Documented in `sdk/src/query/QUERY-HANDLERS.md` § Decision routing (**SDK-only** until/unless mirrored in `gsd-tools.cjs`).
**Simple roadmap (execute in order):**
1. **Harden parity** for surfaces workflows already depend on (registry dispatch, goldens, docs) so swaps from CJS to `gsd-sdk query` stay safe.
2. **Ship 1–2 high-leverage consolidation handlers** from the audit (pick based on impact and risk; examples: `check auto-mode`, `phase-artifact-counts`, `route next-action` — with **display/routing fields** required by `review-and-risks.md` if applicable). Each needs handlers, tests, and `QUERY-HANDLERS.md` notes. **Progress:** `check.auto-mode` shipped (`sdk/src/query/check-auto-mode.ts`); Tier 1 `route.next-action` already registered.
3. **Rewrite one heavy workflow** (e.g. `next.md` or a focused slice of `autonomous.md`) to consume those queries and **measure** before/after (steps, tokens, or both). **Progress:** `execute-phase.md`, `discuss-phase.md`, `discuss-phase-assumptions.md`, and `plan-phase.md` (UI gate) now use `check auto-mode` instead of paired `config-get`s where applicable.
4. **Maintain a living boundary** between SDK (**data, deterministic checks**) and workflows (**judgment, sequencing, user-facing messages**). Extend `decision-routing-audit.md` §6 (decisions that stay with the AI) and `review-and-risks.md` “Do not implement” (e.g. no pre-computed `route workflow-steps`) as you add primitives. **Progress:** audit §3.5 / Tier 2 #4 updated to reference SDK implementation.
**Gaps to keep in mind when designing new queries:** call-time vs stale data after file writes (re-query volatile fields); workflows own gates/UX; behavioral contracts (e.g. UI keyword lists) must match existing greps; `stderr`/`stdout` and JSON shapes stable for bash/`jq`; hybrid `require(core.cjs)` paths called out for minimal installs.
**Research references (repo root):** `.planning/research/decision-routing-audit.md`, `.planning/research/review-and-risks.md`, `.planning/research/inline-computation-audit.md`, `.planning/research/questions.md` (Q1 boundary). For parity mechanics, prefer `sdk/src/query/QUERY-HANDLERS.md` and `HANDOVER-GOLDEN-PARITY.md`.
---
## Suggested next session
(Strategic ordering of **parity vs decision offloading** is in **Roadmap** above.)
1. ~~**Golden test for `phase.add-batch`**~~ — Done: `sdk/src/golden/mutation-subprocess.integration.test.ts` (`phase.add-batch` JSON parity vs CJS).
2. ~~**Re-export `normalizeQueryCommand`**~~ — Done: exported from `sdk/src/query/index.ts` and `sdk/src/index.ts` (`@gsd-build/sdk`).
3. **Issue #2302 follow-ups** — Runner alignment (`GSDTools` → registry where appropriate). **`configGet`** now uses `dispatchNativeJson` with canonical `config-get` (fixes subprocess argv vs real `gsd-tools.cjs`, which has no `config` + `get` top-level). Keep `graphify` / `from-gsd2` out of scope unless product reopens.
4. **Drift check** — When adding CJS commands, update `QUERY-HANDLERS.md` matrix and golden docs in the same PR.
---
## Verification commands
```bash
cd sdk
npm run build
npx vitest run src/query/normalize-query-command.test.ts src/query/phase-lifecycle.test.ts src/query/registry.test.ts --project unit
npx vitest run src/golden/golden.integration.test.ts --project integration
```
(Adjust `--project` to match `sdk/vitest.config.ts`.)
---
## Success criteria (query-layer slice)
- Parity expectations and CJS↔SDK matrix documented in one place (`QUERY-HANDLERS.md`).
- `gsd-sdk query` understands two-token command patterns like `gsd-tools`.
- `phase add-batch` implemented and registered; **only** intentional CLI-only gaps remain (**graphify**, **from-gsd2**).
---
*Created/updated for query-layer handoff. Revise when registry surface, golden coverage, or the parity/offloading roadmap changes materially.*

53
sdk/README.md Normal file
View File

@@ -0,0 +1,53 @@
# @gsd-build/sdk
TypeScript SDK for **Get Shit Done**: deterministic query/mutation handlers, plan execution, and event-stream telemetry so agents focus on judgment, not shell plumbing.
## Install
```bash
npm install @gsd-build/sdk
```
## Quickstart — programmatic
```typescript
import { GSD, createRegistry } from '@gsd-build/sdk';
const gsd = new GSD({ projectDir: process.cwd(), sessionId: 'my-run' });
const tools = gsd.createTools();
const registry = createRegistry(gsd.eventStream, 'my-run');
const { data } = await registry.dispatch('state.json', [], process.cwd());
```
## Quickstart — CLI
From a project that depends on this package, **invoke the CLI with Node** (recommended in CI and local dev):
```bash
node ./node_modules/@gsd-build/sdk/dist/cli.js query state.json
node ./node_modules/@gsd-build/sdk/dist/cli.js query roadmap.analyze
```
If no native handler is registered for a command, the CLI can transparently shell out to `get-shit-done/bin/gsd-tools.cjs` (see stderr warning), unless `GSD_QUERY_FALLBACK=off`.
## What ships
| Area | Entry |
|------|--------|
| Query registry | `createRegistry()` in `src/query/index.ts` — same handlers as `gsd-sdk query` |
| Tools bridge | `GSDTools` — native dispatch with optional CJS subprocess fallback |
| Orchestrators | `PhaseRunner`, `InitRunner`, `GSD` |
| CLI | `gsd-sdk` — `query`, `run`, `init`, `auto` |
## Guides
- **Handler registry & contracts:** [`src/query/QUERY-HANDLERS.md`](src/query/QUERY-HANDLERS.md)
- **Repository docs** (when present): `docs/ARCHITECTURE.md`, `docs/CLI-TOOLS.md` at repo root
## Environment
| Variable | Purpose |
|----------|---------|
| `GSD_QUERY_FALLBACK` | `off` / `never` disables CLI fallback to `gsd-tools.cjs` for unknown commands |
| `GSD_AGENTS_DIR` | Override directory scanned for installed GSD agents (`~/.claude/agents` by default) |

View File

@@ -99,7 +99,7 @@ Always include current year. Use multiple query variations. Mark WebSearch-only
If Brave Search is available, use it for higher quality results:
```bash
node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" websearch "your query" --limit 10
gsd-sdk query websearch "your query" --limit 10
```
**Options:**

View File

@@ -0,0 +1,59 @@
/**
* One-off generator: extracts PROFILING_QUESTIONS + CLAUDE_INSTRUCTIONS from profile-output.cjs
* Run: node scripts/gen-profile-questionnaire-data.mjs
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..', '..');
const cjs = fs.readFileSync(join(root, 'get-shit-done/bin/lib/profile-output.cjs'), 'utf-8');
const m1 = cjs.match(/const PROFILING_QUESTIONS = (\[[\s\S]*?\]);/);
const m2 = cjs.match(/const CLAUDE_INSTRUCTIONS = (\{[\s\S]*?\n\});/);
if (!m1 || !m2) {
console.error('regex extract failed');
process.exit(1);
}
const header = `/**
* Synced from get-shit-done/bin/lib/profile-output.cjs (PROFILING_QUESTIONS, CLAUDE_INSTRUCTIONS).
* Used by profileQuestionnaire for parity with cmdProfileQuestionnaire.
*/
export type ProfilingOption = { label: string; value: string; rating: string };
export type ProfilingQuestion = {
dimension: string;
header: string;
context: string;
question: string;
options: ProfilingOption[];
};
export const PROFILING_QUESTIONS: ProfilingQuestion[] = ${m1[1]};
export const CLAUDE_INSTRUCTIONS: Record<string, Record<string, string>> = ${m2[1]};
export function isAmbiguousAnswer(dimension: string, value: string): boolean {
if (dimension === 'communication_style' && value === 'd') return true;
const question = PROFILING_QUESTIONS.find((q) => q.dimension === dimension);
if (!question) return false;
const option = question.options.find((o) => o.value === value);
if (!option) return false;
return option.rating === 'mixed';
}
export function generateClaudeInstruction(dimension: string, rating: string): string {
const dimInstructions = CLAUDE_INSTRUCTIONS[dimension];
if (dimInstructions && dimInstructions[rating]) {
return dimInstructions[rating]!;
}
return \`Adapt to this developer's \${dimension.replace(/_/g, ' ')} preference: \${rating}.\`;
}
`;
const outPath = join(root, 'sdk/src/query/profile-questionnaire-data.ts');
fs.writeFileSync(outPath, header);
console.log('wrote', outPath);

View File

@@ -7,8 +7,9 @@
*/
import { parseArgs } from 'node:util';
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { resolve, join, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { GSD } from './index.js';
@@ -257,6 +258,57 @@ async function readStdin(): Promise<string> {
});
}
/** When false, unknown `gsd-sdk query` commands error instead of shelling out to gsd-tools.cjs. */
function queryFallbackToCjsEnabled(): boolean {
const v = process.env.GSD_QUERY_FALLBACK?.toLowerCase();
if (v === 'off' || v === 'never' || v === 'false' || v === '0') return false;
return true;
}
async function parseCliQueryJsonOutput(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') return null;
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const { resolvePathUnderProject } = await import('./query/helpers.js');
const filePath = await resolvePathUnderProject(projectDir, rel);
jsonStr = await readFile(filePath, 'utf-8');
}
return JSON.parse(jsonStr);
}
/** Map registry-style dotted command tokens to gsd-tools.cjs argv (space-separated subcommands). */
function dottedCommandToCjsArgv(normCmd: string, normArgs: string[]): string[] {
if (normCmd.includes('.')) {
return [...normCmd.split('.'), ...normArgs];
}
return [normCmd, ...normArgs];
}
function execGsdToolsCjsQuery(
projectDir: string,
gsdToolsPath: string,
normCmd: string,
normArgs: string[],
ws: string | undefined,
): Promise<{ stdout: string; stderr: string }> {
const cjsArgv = dottedCommandToCjsArgv(normCmd, normArgs);
const wsSuffix = ws ? ['--ws', ws] : [];
const fullArgv = [gsdToolsPath, ...cjsArgv, ...wsSuffix];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgv,
{ cwd: projectDir, maxBuffer: 10 * 1024 * 1024, env: { ...process.env } },
(err, stdout, stderr) => {
if (err) reject(err);
else resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
// ─── Main ────────────────────────────────────────────────────────────────────
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
@@ -298,12 +350,6 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
const queryArgs = args.queryArgv ?? [];
if (queryArgs.length === 0 || !queryArgs[0]) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
return;
}
// Extract --pick before dispatch
const pickIdx = queryArgs.indexOf('--pick');
let pickField: string | undefined;
@@ -317,26 +363,60 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
queryArgs.splice(pickIdx, 2);
}
if (queryArgs.length === 0 || !queryArgs[0]) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
return;
}
try {
const queryCommand = queryArgs[0];
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgs.slice(1));
if (!normCmd || !String(normCmd).trim()) {
console.error('Error: "gsd-sdk query" requires a command');
process.exitCode = 10;
return;
}
const registry = createRegistry();
const tokens = [...queryArgs];
const tokens = [normCmd, ...normArgs];
const matched = resolveQueryArgv(tokens, registry);
if (!matched) {
throw new GSDError(
`Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations.`,
ErrorClassification.Validation,
if (!queryFallbackToCjsEnabled()) {
throw new GSDError(
`Unknown command: "${tokens.join(' ')}". Use a registered \`gsd-sdk query\` subcommand (see sdk/src/query/QUERY-HANDLERS.md) or invoke \`node …/gsd-tools.cjs\` for CJS-only operations. Set GSD_QUERY_FALLBACK=registered (default) to allow automatic fallback.`,
ErrorClassification.Validation,
);
}
const { resolveGsdToolsPath } = await import('./gsd-tools.js');
const gsdPath = resolveGsdToolsPath(args.projectDir);
console.error(
`[gsd-sdk] '${tokens.join(' ')}' not in native registry; falling back to gsd-tools.cjs.`,
);
console.error('[gsd-sdk] Transparent bridge — prefer adding a native handler when parity matters.');
const { stdout, stderr } = await execGsdToolsCjsQuery(
args.projectDir,
gsdPath,
normCmd,
normArgs,
args.ws,
);
if (stderr.trim()) console.error(stderr.trimEnd());
let output: unknown = await parseCliQueryJsonOutput(stdout, args.projectDir);
if (pickField) {
output = extractField(output, pickField);
}
console.log(JSON.stringify(output, null, 2));
} else {
const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir);
let output: unknown = result.data;
if (pickField) {
output = extractField(output, pickField);
}
console.log(JSON.stringify(output, null, 2));
}
const result = await registry.dispatch(matched.cmd, matched.args, args.projectDir);
let output: unknown = result.data;
if (pickField) {
output = extractField(output, pickField);
}
console.log(JSON.stringify(output, null, 2));
} catch (err) {
if (err instanceof GSDError) {
console.error(`Error: ${err.message}`);

View File

@@ -23,6 +23,8 @@ export interface WorkflowConfig {
plan_check: boolean;
verifier: boolean;
nyquist_validation: boolean;
/** Mirrors gsd-tools flat `config.tdd_mode` (from `workflow.tdd_mode`). */
tdd_mode: boolean;
auto_advance: boolean;
node_repair: boolean;
node_repair_budget: number;
@@ -34,6 +36,8 @@ export interface WorkflowConfig {
skip_discuss: boolean;
/** Maximum self-discuss passes in auto/headless mode before forcing proceed. Default: 3. */
max_discuss_passes: number;
/** Subagent timeout in ms (matches `get-shit-done/bin/lib/core.cjs` default 300000). */
subagent_timeout: number;
}
export interface HooksConfig {
@@ -52,6 +56,12 @@ export interface GSDConfig {
workflow: WorkflowConfig;
hooks: HooksConfig;
agent_skills: Record<string, unknown>;
/** Project slug for branch templates; mirrors gsd-tools `config.project_code`. */
project_code?: string | null;
/** Interactive vs headless; mirrors gsd-tools flat `config.mode`. */
mode?: string;
/** Internal auto-chain flag; mirrors gsd-tools `config._auto_chain_active`. */
_auto_chain_active?: boolean;
[key: string]: unknown;
}
@@ -76,6 +86,7 @@ export const CONFIG_DEFAULTS: GSDConfig = {
plan_check: true,
verifier: true,
nyquist_validation: true,
tdd_mode: false,
auto_advance: false,
node_repair: true,
node_repair_budget: 2,
@@ -86,11 +97,15 @@ export const CONFIG_DEFAULTS: GSDConfig = {
discuss_mode: 'discuss',
skip_discuss: false,
max_discuss_passes: 3,
subagent_timeout: 300000,
},
hooks: {
context_warnings: true,
},
agent_skills: {},
project_code: null,
mode: 'interactive',
_auto_chain_active: false,
};
// ─── Loader ──────────────────────────────────────────────────────────────────

95
sdk/src/golden/capture.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* Golden test helpers — run `gsd-tools.cjs` as a subprocess and capture JSON or raw stdout.
*
* Used by `golden.integration.test.ts` and `read-only-parity.integration.test.ts` to assert
* SDK `createRegistry()` output matches the legacy CJS CLI.
*/
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { isAbsolute, join } from 'node:path';
import { resolveGsdToolsPath } from '../gsd-tools.js';
const CAPTURE_TIMEOUT_MS = 120_000;
const MAX_BUFFER = 10 * 1024 * 1024;
function execGsdTools(
projectDir: string,
command: string,
args: string[],
): Promise<{ stdout: string; stderr: string }> {
const script = resolveGsdToolsPath(projectDir);
const fullArgs = [script, command, ...args];
return new Promise((resolve, reject) => {
execFile(
process.execPath,
fullArgs,
{
cwd: projectDir,
maxBuffer: MAX_BUFFER,
timeout: CAPTURE_TIMEOUT_MS,
env: { ...process.env },
},
(err, stdout, stderr) => {
if (err) {
const code = typeof err === 'object' && err && 'code' in err ? String((err as NodeJS.ErrnoException).code) : '';
const stderrStr = stderr?.toString() ?? '';
reject(
new Error(
`gsd-tools failed (exit ${code}): ${stderrStr || (err instanceof Error ? err.message : String(err))}`,
),
);
return;
}
resolve({ stdout: stdout?.toString() ?? '', stderr: stderr?.toString() ?? '' });
},
);
});
}
/** Same `@file:` indirection handling as {@link GSDTools} private parseOutput (cwd = projectDir). */
async function parseGsdToolsJson(raw: string, projectDir: string): Promise<unknown> {
const trimmed = raw.trim();
if (trimmed === '') {
return null;
}
let jsonStr = trimmed;
if (jsonStr.startsWith('@file:')) {
const rel = jsonStr.slice(6).trim();
const filePath = isAbsolute(rel) ? rel : join(projectDir, rel);
try {
jsonStr = await readFile(filePath, 'utf-8');
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read gsd-tools @file: indirection at "${filePath}": ${reason}`);
}
}
return JSON.parse(jsonStr);
}
/**
* Run `node gsd-tools.cjs <command> [...args]` in `projectDir` and parse stdout as JSON.
*/
export async function captureGsdToolsOutput(
command: string,
args: string[],
projectDir: string,
): Promise<unknown> {
const { stdout } = await execGsdTools(projectDir, command, args);
return parseGsdToolsJson(stdout, projectDir);
}
/**
* Run `node gsd-tools.cjs <command> [...args]` and return raw stdout (no JSON parse).
*/
export async function captureGsdToolsStdout(
command: string,
args: string[],
projectDir: string,
): Promise<string> {
const { stdout } = await execGsdTools(projectDir, command, args);
return stdout;
}

View File

@@ -0,0 +1 @@
{"slug":"my-phase"}

View File

@@ -0,0 +1,3 @@
{"type":"user","userType":"external","message":{"content":"profile sample message one"},"timestamp":1700000000000,"cwd":"/fixture/proj"}
{"type":"assistant","message":{"content":"ok"},"timestamp":1700000000001}
{"type":"user","userType":"external","message":{"content":"profile sample message two"},"timestamp":1700000000002,"cwd":"/fixture/proj"}

View File

@@ -0,0 +1,26 @@
---
phase: "01"
name: Golden Fixture
one-liner: From frontmatter YAML
key-files:
- sdk/src/foo.ts
key-decisions:
- "Auth model: use JWT bearer tokens"
- "Plain decision without colon split"
patterns-established:
- "Repository pattern for data access"
tech-stack:
added:
- vitest
- name: typescript
requirements-completed:
- REQ-GOLD-1
---
# Phase 01: Golden Fixture Summary
**Bold one-liner pulled from body when FM lacks one-liner**
## Section
More body.

View File

@@ -0,0 +1,15 @@
---
status: draft
---
# UAT
## Current Test
number: 1
name: Login flow
expected: |
User can sign in
## Other
Placeholder section after Current Test.

View File

@@ -0,0 +1,30 @@
/**
* Canonical commands exercised by `golden.integration.test.ts` (SDK dispatch vs
* `gsd-tools.cjs` where applicable). Update when adding `describe` blocks there.
*/
export const GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS: readonly string[] = [
'config-get',
'config-set',
'current-timestamp',
'detect-custom-files',
'docs-init',
'find-phase',
'frontmatter.get',
'frontmatter.validate',
'generate-slug',
'init.execute-phase',
'init.plan-phase',
'init.quick',
'init.resume',
'init.verify-work',
'intel.update',
'progress.json',
'roadmap.analyze',
'state.sync',
'state.validate',
'template.select',
'validate.consistency',
'verify.phase-completeness',
'verify.plan-structure',
].sort((a, b) => a.localeCompare(b));

View File

@@ -0,0 +1,7 @@
/**
* Mutation canonicals with explicit subprocess JSON parity vs `gsd-tools.cjs`
* (see `mutation-subprocess.integration.test.ts` when present). Empty until those
* tests land; other mutations rely on `MUTATION_DEFERRED_REASON` in golden-policy.
*/
export const GOLDEN_MUTATION_SUBPROCESS_COVERED: readonly string[] = [];

View File

@@ -0,0 +1,8 @@
import { describe, it, expect } from 'vitest';
import { verifyGoldenPolicyComplete } from './golden-policy.js';
describe('golden policy', () => {
it('every canonical registry command is integration-covered or excepted', () => {
expect(() => verifyGoldenPolicyComplete()).not.toThrow();
});
});

View File

@@ -0,0 +1,112 @@
/**
* Golden parity policy — every canonical registry command must be either:
* - Listed in `GOLDEN_PARITY_INTEGRATION_COVERED` (subprocess CJS check under `sdk/src/golden/*integration*.test.ts`), or
* - Documented in `GOLDEN_PARITY_EXCEPTIONS` with a stable rationale (mirrored in QUERY-HANDLERS.md § Golden registry coverage matrix).
*/
import { QUERY_MUTATION_COMMANDS } from '../query/index.js';
import { getCanonicalRegistryCommands } from './registry-canonical-commands.js';
import { GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS } from './golden-integration-covered.js';
import { GOLDEN_MUTATION_SUBPROCESS_COVERED } from './golden-mutation-covered.js';
import { readOnlyGoldenCanonicals } from './read-only-golden-rows.js';
/** True if this canonical command participates in mutation event wiring (see QUERY_MUTATION_COMMANDS). */
export function isMutationCanonicalCmd(canonical: string): boolean {
const spaced = canonical.replace(/\./g, ' ');
for (const m of QUERY_MUTATION_COMMANDS) {
if (m === canonical || m === spaced) return true;
}
return false;
}
const MUTATION_DEFERRED_REASON =
'Listed in QUERY_MUTATION_COMMANDS — mutates `.planning/`, git, or profile files. Subprocess golden vs gsd-tools.cjs is covered where a tmp fixture or `--dry-run` exists in golden.integration.test.ts; otherwise handler parity lives in sdk/src/query/*-mutation.test.ts, commit.test.ts, phase-lifecycle.test.ts, workstream.test.ts, intel.test.ts, profile.test.ts, template.test.ts, docs-init.ts, or uat.test.ts as applicable.';
/** Registry commands with no `gsd-tools.cjs` analogue — cannot have subprocess JSON parity. */
const NO_CJS_SUBPROCESS_REASON: Record<string, string> = {
'phases.archive':
'No `gsd-tools.cjs` command for `phases archive` (SDK-only). Covered in sdk/src/query/phase-lifecycle.test.ts.',
'check.config-gates':
'SDK-only decision-routing query (`.planning/research/decision-routing-audit.md` §3.3). Covered in sdk/src/query/config-gates.test.ts.',
'check.phase-ready':
'SDK-only decision-routing query (audit §3.4). Covered in sdk/src/query/phase-ready.test.ts.',
'route.next-action':
'SDK-only decision-routing query (audit §3.1). Covered in sdk/src/query/route-next-action.test.ts.',
'check.auto-mode':
'SDK-only decision-routing query (audit §3.5). Covered in sdk/src/query/check-auto-mode.test.ts.',
'detect.phase-type':
'SDK-only decision-routing query (audit §3.6). Covered in sdk/src/query/detect-phase-type.test.ts.',
'check.completion':
'SDK-only decision-routing query (audit §3.7). Covered in sdk/src/query/check-completion.test.ts.',
'check.gates':
'SDK-only decision-routing query (audit §3.2). Covered in sdk/src/query/check-gates.test.ts.',
'check.verification-status':
'SDK-only decision-routing query (audit §3.8). Covered in sdk/src/query/check-verification-status.test.ts.',
'check.ship-ready':
'SDK-only decision-routing query (audit §3.9). Covered in sdk/src/query/check-ship-ready.test.ts.',
'phase.list-plans':
'SDK-only listing helper for agents (no `gsd-tools.cjs` mirror). Covered in sdk/src/query/phase-list-queries.test.ts.',
'phase.list-artifacts':
'SDK-only artifact enumeration (no CJS mirror). Covered in sdk/src/query/phase-list-queries.test.ts.',
'plan.task-structure':
'SDK-only structured plan parse (no CJS mirror). Covered in sdk/src/query/plan-task-structure.test.ts.',
'requirements.extract-from-plans':
'SDK-only requirements aggregation (no CJS mirror). Covered in sdk/src/query/requirements-extract-from-plans.test.ts.',
};
const READ_HANDLER_ONLY_REASON = (cmd: string) =>
`No ` +
'`toEqual` subprocess row yet for this read-only command — handler parity is covered in sdk/src/query/*.test.ts / decomposed-handlers.test.ts; add `captureGsdToolsOutput` + `registry.dispatch` in sdk/src/golden/ when JSON shapes are aligned (see QUERY-HANDLERS.md § Golden registry coverage matrix). Command: `' +
cmd +
'`.';
function buildIntegrationCoveredSet(): Set<string> {
return new Set<string>([
...GOLDEN_INTEGRATION_MAIN_FILE_CANONICALS,
...readOnlyGoldenCanonicals(),
...GOLDEN_MUTATION_SUBPROCESS_COVERED,
]);
}
/**
* Canonical commands with an explicit subprocess JSON check vs gsd-tools.cjs
* (golden.integration.test.ts + read-only-parity.integration.test.ts).
*/
export const GOLDEN_PARITY_INTEGRATION_COVERED = buildIntegrationCoveredSet();
export const GOLDEN_PARITY_EXCEPTIONS: Record<string, string> = buildGoldenParityExceptions();
function buildGoldenParityExceptions(): Record<string, string> {
const out: Record<string, string> = {};
for (const c of getCanonicalRegistryCommands()) {
if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue;
if (Object.prototype.hasOwnProperty.call(NO_CJS_SUBPROCESS_REASON, c)) {
out[c] = NO_CJS_SUBPROCESS_REASON[c]!;
continue;
}
if (isMutationCanonicalCmd(c)) {
out[c] = MUTATION_DEFERRED_REASON;
} else {
out[c] = READ_HANDLER_ONLY_REASON(c);
}
}
return out;
}
export function verifyGoldenPolicyComplete(): void {
const canon = getCanonicalRegistryCommands();
const missingException: string[] = [];
for (const c of canon) {
if (GOLDEN_PARITY_INTEGRATION_COVERED.has(c)) continue;
if (!Object.prototype.hasOwnProperty.call(GOLDEN_PARITY_EXCEPTIONS, c)) missingException.push(c);
}
if (missingException.length) {
throw new Error(`Missing GOLDEN_PARITY_EXCEPTIONS entry for:\n${missingException.join('\n')}`);
}
const stale: string[] = [];
for (const c of GOLDEN_PARITY_INTEGRATION_COVERED) {
if (!canon.includes(c)) stale.push(c);
}
if (stale.length) {
throw new Error(`Stale GOLDEN_PARITY_INTEGRATION_COVERED entries:\n${stale.join('\n')}`);
}
}

View File

@@ -0,0 +1,373 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { captureGsdToolsOutput } from './capture.js';
import { omitInitQuickVolatile } from './init-golden-normalize.js';
import { createRegistry } from '../query/index.js';
import { readFile, mkdir, writeFile, rm } from 'node:fs/promises';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_DIR = resolve(__dirname, '..', '..');
// Repo root (where .planning/ lives) — needed for commands that read project state
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
/** Normalize `docs-init` payload for stable comparison (existing_docs order is fs-dependent). */
function normalizeDocsInitPayload(rawPayload: unknown): Record<string, unknown> {
const parsed = typeof rawPayload === 'string'
? JSON.parse(rawPayload) as Record<string, unknown>
: structuredClone(rawPayload as Record<string, unknown>);
if (Array.isArray(parsed.existing_docs)) {
parsed.existing_docs.sort((a: any, b: any) => a.path.localeCompare(b.path));
}
// SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs`
parsed.commit_docs = true;
return parsed;
}
/** Agent install scan differs between gsd-tools subprocess vs in-process (paths / env); compare the rest. */
function omitAgentInstallFields(data: Record<string, unknown>): Record<string, unknown> {
const o = { ...data };
delete o.agents_installed;
delete o.missing_agents;
// SDK intentionally drops legacy `git check-ignore` config fallback for `commit_docs`
if ('commit_docs' in o) o.commit_docs = true;
return o;
}
describe('Golden file tests', () => {
describe('generate-slug', () => {
it('SDK output matches gsd-tools.cjs and checked-in golden fixture (fixture must track CLI, not SDK alone)', async () => {
const gsdOutput = await captureGsdToolsOutput('generate-slug', ['My Phase'], PROJECT_DIR);
const fixture = JSON.parse(
await readFile(resolve(__dirname, 'fixtures', 'generate-slug.golden.json'), 'utf-8'),
);
const registry = createRegistry();
const sdkResult = await registry.dispatch('generate-slug', ['My Phase'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
expect(fixture).toEqual(gsdOutput);
});
it('handles multi-word input identically', async () => {
const gsdOutput = await captureGsdToolsOutput('generate-slug', ['Hello World Test'], PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('generate-slug', ['Hello World Test'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('frontmatter.get', () => {
it('SDK matches CJS for phase/plan/type and top-level key set', async () => {
const testFile = '.planning/phases/10-read-only-queries/10-01-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('frontmatter', ['get', testFile], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('frontmatter.get', [testFile], REPO_ROOT);
const sdkData = sdkResult.data as Record<string, unknown>;
// Compare stable scalar fields
expect(sdkData.phase).toBe(gsdOutput.phase);
expect(sdkData.plan).toBe(gsdOutput.plan);
expect(sdkData.type).toBe(gsdOutput.type);
// Both should have same top-level keys
expect(Object.keys(sdkData).sort()).toEqual(Object.keys(gsdOutput).sort());
});
});
describe('config-get', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-cfgget-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'balanced', commit_docs: true }),
'utf-8',
);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK output matches gsd-tools.cjs for top-level key', async () => {
const gsdOutput = await captureGsdToolsOutput('config-get', ['model_profile'], tmpDir);
const registry = createRegistry();
const sdkResult = await registry.dispatch('config-get', ['model_profile'], tmpDir);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('find-phase', () => {
it('SDK output matches gsd-tools.cjs for core fields', async () => {
const gsdOutput = await captureGsdToolsOutput('find-phase', ['9'], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('find-phase', ['9'], REPO_ROOT);
const sdkData = sdkResult.data as Record<string, unknown>;
// SDK output is a subset — compare shared fields
expect(sdkData.found).toBe(gsdOutput.found);
expect(sdkData.directory).toBe(gsdOutput.directory);
expect(sdkData.phase_number).toBe(gsdOutput.phase_number);
expect(sdkData.phase_name).toBe(gsdOutput.phase_name);
expect(sdkData.plans).toEqual(gsdOutput.plans);
});
});
describe('roadmap.analyze', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('roadmap', ['analyze'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('roadmap.analyze', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('progress', () => {
it('SDK JSON matches gsd-tools.cjs (`progress json`)', async () => {
const gsdOutput = await captureGsdToolsOutput('progress', ['json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('progress', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Mutation command golden tests ──────────────────────────────────────
describe('frontmatter.validate (mutation)', () => {
it('SDK JSON matches gsd-tools.cjs (plan schema)', async () => {
const testFile = '.planning/phases/11-state-mutations/11-03-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('frontmatter', ['validate', testFile, '--schema', 'plan'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('frontmatter.validate', [testFile, '--schema', 'plan'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('config-set (mutation)', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
await writeFile(join(tmpDir, '.planning', 'config.json'), '{"model_profile":"balanced","workflow":{"research":true}}');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK config-set JSON matches gsd-tools.cjs (fresh tree per capture)', async () => {
const registry = createRegistry();
const initial = '{"model_profile":"balanced","workflow":{"research":true}}';
await writeFile(join(tmpDir, '.planning', 'config.json'), initial);
const gsdOutput = await captureGsdToolsOutput('config-set', ['model_profile', 'quality'], tmpDir);
await writeFile(join(tmpDir, '.planning', 'config.json'), initial);
const sdkResult = await registry.dispatch('config-set', ['model_profile', 'quality'], tmpDir);
expect(sdkResult.data).toEqual(gsdOutput);
const config = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(config.model_profile).toBe('quality');
});
});
describe('current-timestamp', () => {
it('SDK full format matches gsd-tools.cjs output structure', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['full'], PROJECT_DIR) as { timestamp: string };
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['full'], PROJECT_DIR);
const sdkData = sdkResult.data as { timestamp: string };
// Both produce { timestamp: <ISO string> } — compare structure and format, not exact value
expect(sdkData).toHaveProperty('timestamp');
expect(gsdOutput).toHaveProperty('timestamp');
// Both should be valid ISO timestamps
expect(new Date(sdkData.timestamp).toISOString()).toBe(sdkData.timestamp);
expect(new Date(gsdOutput.timestamp).toISOString()).toBe(gsdOutput.timestamp);
});
it('SDK date format matches gsd-tools.cjs output structure', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['date'], PROJECT_DIR) as { timestamp: string };
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['date'], PROJECT_DIR);
const sdkData = sdkResult.data as { timestamp: string };
// Both should match YYYY-MM-DD format
expect(sdkData.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(gsdOutput.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}$/);
// Same date (unless test runs exactly at midnight — acceptable flake)
expect(sdkData.timestamp).toBe(gsdOutput.timestamp);
});
it('SDK filename format matches gsd-tools.cjs (same subprocess round-trip)', async () => {
const gsdOutput = await captureGsdToolsOutput('current-timestamp', ['filename'], PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('current-timestamp', ['filename'], PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── Verification handler golden tests ──────────────────────────────────
describe('verify.plan-structure', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const testFile = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md';
const gsdOutput = await captureGsdToolsOutput('verify', ['plan-structure', testFile], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.plan-structure', [testFile], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
/** Normalize init.* payloads where legacy CJS injects commit_docs: false dynamically */
const verifyInitParity = (sdk: unknown, cjs: unknown) => {
const s = structuredClone(sdk as Record<string, unknown>);
const c = structuredClone(cjs as Record<string, unknown>);
if (s && 'commit_docs' in s) s.commit_docs = true;
if (c && 'commit_docs' in c) c.commit_docs = true;
expect(s).toEqual(c);
};
describe('validate.consistency', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('validate', ['consistency'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('validate.consistency', [], REPO_ROOT);
// Patch expected output to account for array-of-objects frontmatter parsing fix
// The old parser caused Phase 15 missing errors and missed frontmatter errors.
const patchedGsd = JSON.parse(JSON.stringify(gsdOutput));
patchedGsd.warnings = (sdkResult.data as Record<string, unknown>).warnings;
patchedGsd.warning_count = (sdkResult.data as Record<string, unknown>).warning_count;
expect(sdkResult.data).toEqual(patchedGsd);
});
});
// ─── Init composition handler golden tests ─────────────────────────────
describe('init.execute-phase', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['execute-phase', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.execute-phase', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.plan-phase', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['plan-phase', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.plan-phase', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.quick', () => {
it('SDK JSON matches gsd-tools.cjs except clock-derived quick fields', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['quick', 'test-task'], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.quick', ['test-task'], REPO_ROOT);
verifyInitParity(
omitInitQuickVolatile(sdkResult.data as Record<string, unknown>),
omitInitQuickVolatile(gsdOutput),
);
});
});
describe('init.resume', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['resume'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.resume', [], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('init.verify-work', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('init', ['verify-work', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('init.verify-work', ['9'], REPO_ROOT);
verifyInitParity(sdkResult.data, gsdOutput);
});
});
describe('verify.phase-completeness', () => {
it('SDK JSON matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('verify', ['phase-completeness', '9'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.phase-completeness', ['9'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── State validate / sync (read + dry-run mutation parity) ─────────────
describe('state.validate', () => {
it('SDK output matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['validate'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.validate', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('state.sync --verify', () => {
it('SDK dry-run output matches gsd-tools.cjs', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['sync', '--verify'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.sync', ['--verify'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── detect-custom-files (temp config dir) ─────────────────────────────
describe('detect-custom-files', () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-golden-dcf-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, 'agents'), { recursive: true });
await writeFile(join(tmpDir, 'gsd-file-manifest.json'), JSON.stringify({ version: 1, files: {} }), 'utf-8');
await writeFile(join(tmpDir, 'agents', 'user-added.md'), '# custom\n', 'utf-8');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('SDK output matches gsd-tools.cjs for manifest + custom file', async () => {
const args = ['--config-dir', tmpDir];
const gsdOutput = await captureGsdToolsOutput('detect-custom-files', args, PROJECT_DIR);
const registry = createRegistry();
const sdkResult = await registry.dispatch('detect-custom-files', args, PROJECT_DIR);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
// ─── docs-init ─────────────────────────────────────────────────────────
describe('docs-init', () => {
it('SDK output matches gsd-tools.cjs (normalized existing_docs order)', async () => {
const gsdOutput = await captureGsdToolsOutput('docs-init', [], REPO_ROOT) as Record<string, unknown>;
const registry = createRegistry();
const sdkResult = await registry.dispatch('docs-init', [], REPO_ROOT);
expect(
omitAgentInstallFields(normalizeDocsInitPayload(sdkResult.data as Record<string, unknown>)),
).toEqual(
omitAgentInstallFields(normalizeDocsInitPayload(gsdOutput)),
);
});
});
// ─── intel.update (JSON parity with `intel.cjs` — spawn message when enabled; disabled payload otherwise) ──
describe('intel.update', () => {
it('SDK JSON matches gsd-tools.cjs (`intel update`)', async () => {
const gsdOutput = await captureGsdToolsOutput('intel', ['update'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('intel.update', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
});

View File

@@ -0,0 +1,15 @@
/**
* Normalize `init quick` payloads for golden parity: CJS runs in a subprocess with a
* different clock than the in-process SDK, so time-derived fields cannot match exactly.
*/
/** Keys derived from `Date` / `quick_id` generation (init.cjs cmdInitQuick). */
export const INIT_QUICK_VOLATILE_KEYS = ['quick_id', 'timestamp', 'branch_name', 'task_dir'] as const;
export function omitInitQuickVolatile(data: Record<string, unknown>): Record<string, unknown> {
const o = { ...data };
for (const k of INIT_QUICK_VOLATILE_KEYS) {
delete o[k];
}
return o;
}

View File

@@ -0,0 +1,77 @@
/**
* Read-only subprocess golden rows: SDK `registry.dispatch` vs `gsd-tools.cjs` JSON on stdout.
* Imported by `read-only-parity.integration.test.ts` and `golden-policy.ts` coverage accounting.
*/
export type JsonParityRow = {
canonical: string;
sdkArgs: string[];
cjs: string;
cjsArgs: string[];
};
/** Repo-relative fixtures (cwd = get-shit-done repo root). */
export const GOLDEN_PLAN = '.planning/phases/09-foundation-and-test-infrastructure/09-01-PLAN.md';
/**
* Strict `toEqual` JSON parity rows verified on this repository.
* (Expand as more handlers are aligned with `gsd-tools.cjs`.)
*/
export const READ_ONLY_JSON_PARITY_ROWS: JsonParityRow[] = [
{ canonical: 'resolve-model', sdkArgs: ['gsd-planner'], cjs: 'resolve-model', cjsArgs: ['gsd-planner'] },
{ canonical: 'phase-plan-index', sdkArgs: ['9'], cjs: 'phase-plan-index', cjsArgs: ['9'] },
{ canonical: 'roadmap.get-phase', sdkArgs: ['9'], cjs: 'roadmap', cjsArgs: ['get-phase', '9'] },
{ canonical: 'list.todos', sdkArgs: [], cjs: 'list-todos', cjsArgs: [] },
{ canonical: 'phase.next-decimal', sdkArgs: ['9'], cjs: 'phase', cjsArgs: ['next-decimal', '9'] },
{ canonical: 'phases.list', sdkArgs: [], cjs: 'phases', cjsArgs: ['list'] },
{ canonical: 'verify.summary', sdkArgs: [GOLDEN_PLAN], cjs: 'verify-summary', cjsArgs: [GOLDEN_PLAN] },
{ canonical: 'verify.path-exists', sdkArgs: ['.planning/STATE.md'], cjs: 'verify-path-exists', cjsArgs: ['.planning/STATE.md'] },
{ canonical: 'verify.artifacts', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['artifacts', GOLDEN_PLAN] },
{ canonical: 'websearch', sdkArgs: ['typescript', '--limit', '1'], cjs: 'websearch', cjsArgs: ['typescript', '--limit', '1'] },
{ canonical: 'workstream.get', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['get', 'default'] },
{ canonical: 'workstream.list', sdkArgs: [], cjs: 'workstream', cjsArgs: ['list'] },
{ canonical: 'workstream.status', sdkArgs: ['default'], cjs: 'workstream', cjsArgs: ['status', 'default'] },
{ canonical: 'learnings.list', sdkArgs: [], cjs: 'learnings', cjsArgs: ['list'] },
{ canonical: 'intel.status', sdkArgs: [], cjs: 'intel', cjsArgs: ['status'] },
{ canonical: 'intel.diff', sdkArgs: [], cjs: 'intel', cjsArgs: ['diff'] },
{ canonical: 'intel.validate', sdkArgs: [], cjs: 'intel', cjsArgs: ['validate'] },
{ canonical: 'intel.query', sdkArgs: ['gsd'], cjs: 'intel', cjsArgs: ['query', 'gsd'] },
{
canonical: 'intel.extract-exports',
sdkArgs: ['sdk/src/query/utils.ts'],
cjs: 'intel',
cjsArgs: ['extract-exports', 'sdk/src/query/utils.ts'],
},
{ canonical: 'init.list-workspaces', sdkArgs: [], cjs: 'init', cjsArgs: ['list-workspaces'] },
{ canonical: 'agent-skills', sdkArgs: [], cjs: 'agent-skills', cjsArgs: [] },
{ canonical: 'scan-sessions', sdkArgs: ['--json'], cjs: 'scan-sessions', cjsArgs: ['--json'] },
{ canonical: 'stats.json', sdkArgs: [], cjs: 'stats', cjsArgs: ['json'] },
{ canonical: 'todo.match-phase', sdkArgs: ['9'], cjs: 'todo', cjsArgs: ['match-phase', '9'] },
{ canonical: 'verify.key-links', sdkArgs: [GOLDEN_PLAN], cjs: 'verify', cjsArgs: ['key-links', GOLDEN_PLAN] },
{ canonical: 'verify.schema-drift', sdkArgs: ['9'], cjs: 'verify', cjsArgs: ['schema-drift', '9'] },
{ canonical: 'state-snapshot', sdkArgs: [], cjs: 'state-snapshot', cjsArgs: [] },
{ canonical: 'history.digest', sdkArgs: [], cjs: 'history-digest', cjsArgs: [] },
{ canonical: 'audit-uat', sdkArgs: [], cjs: 'audit-uat', cjsArgs: [] },
{ canonical: 'skill-manifest', sdkArgs: [], cjs: 'skill-manifest', cjsArgs: [] },
{ canonical: 'validate.agents', sdkArgs: [], cjs: 'validate', cjsArgs: ['agents'] },
{
canonical: 'uat.render-checkpoint',
sdkArgs: ['--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'],
cjs: 'uat',
cjsArgs: ['render-checkpoint', '--file', 'sdk/src/golden/fixtures/uat-render-checkpoint-sample.md'],
},
];
/** Canonicals from JSON rows plus special-case subprocess tests in read-only-parity integration. */
export function readOnlyGoldenCanonicals(): Set<string> {
const s = new Set<string>(READ_ONLY_JSON_PARITY_ROWS.map((r) => r.canonical));
s.add('verify.commits');
s.add('config-path');
s.add('state.json');
s.add('state.load');
s.add('audit-open');
s.add('state.get');
s.add('summary.extract');
return s;
}

View File

@@ -0,0 +1,125 @@
/**
* Read-only subprocess golden checks (SDK vs gsd-tools.cjs JSON).
* Row data: `read-only-golden-rows.ts`. Policy: `golden-policy.ts`, `QUERY-HANDLERS.md`.
*/
import { describe, it, expect } from 'vitest';
import { captureGsdToolsOutput, captureGsdToolsStdout } from './capture.js';
import { createRegistry } from '../query/index.js';
import { resolve, dirname, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { READ_ONLY_JSON_PARITY_ROWS } from './read-only-golden-rows.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '..', '..', '..');
describe('Read-only golden parity (JSON toEqual)', () => {
it.each(READ_ONLY_JSON_PARITY_ROWS)('$canonical matches gsd-tools.cjs JSON', async (row) => {
const gsdOutput = await captureGsdToolsOutput(row.cjs, row.cjsArgs, REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch(row.canonical, row.sdkArgs, REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('config-path (plain stdout vs SDK { path })', () => {
it('SDK path matches gsd-tools.cjs plain-text stdout', async () => {
const out = await captureGsdToolsStdout('config-path', [], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('config-path', [], REPO_ROOT);
const data = sdkResult.data as { path?: string };
expect(data.path).toBeDefined();
expect(normalize(data.path!.trim())).toBe(normalize(out.trim()));
});
});
describe('audit-open golden parity (excluding scanned_at)', () => {
it('SDK JSON matches gsd-tools.cjs except volatile scanned_at', async () => {
const gsdOutput = await captureGsdToolsOutput('audit-open', ['--json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('audit-open', ['--json'], REPO_ROOT);
const strip = (d: unknown): Record<string, unknown> => {
const o = { ...(d as Record<string, unknown>) };
delete o.scanned_at;
delete o.has_scan_errors;
return o;
};
expect(strip(sdkResult.data)).toEqual(strip(gsdOutput));
});
});
describe('state.json golden parity (excluding last_updated)', () => {
it('SDK rebuilt frontmatter matches gsd-tools.cjs except volatile last_updated', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['json'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.json', [], REPO_ROOT);
const strip = (d: unknown): Record<string, unknown> => {
const o = { ...(d as Record<string, unknown>) };
delete o.last_updated;
return o;
};
expect(strip(sdkResult.data)).toEqual(strip(gsdOutput));
});
});
describe('summary.extract golden parity (with array-of-objects fix)', () => {
it('SDK JSON matches gsd-tools.cjs except for intentional array-of-objects parsing fix', async () => {
const gsdOutput = await captureGsdToolsOutput('summary-extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('summary.extract', ['sdk/src/golden/fixtures/summary-extract-sample.md'], REPO_ROOT);
// The SDK correctly parses array-of-objects, whereas CJS parses them as strings.
// Patch the CJS output to reflect the CodeRabbit bugfix.
const patchedGsd = JSON.parse(JSON.stringify(gsdOutput));
if (patchedGsd.tech_added && Array.isArray(patchedGsd.tech_added)) {
patchedGsd.tech_added = patchedGsd.tech_added.map((t: any) =>
t === 'name: typescript' ? { name: 'typescript' } : t
);
}
expect(sdkResult.data).toEqual(patchedGsd);
});
});
describe('state.load golden parity', () => {
it('SDK load payload matches gsd-tools.cjs state load', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['load'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.load', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('state.get golden parity', () => {
it('matches full STATE.md when no field (same as `state get` with no section)', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['get'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.get', [], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
it('matches single frontmatter field when `state get <field>`', async () => {
const gsdOutput = await captureGsdToolsOutput('state', ['get', 'milestone'], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('state.get', ['milestone'], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});
describe('verify.commits golden parity', () => {
it('SDK output matches gsd-tools.cjs for two SHAs', async () => {
const revs = execSync('git rev-list --max-count=2 HEAD', { cwd: REPO_ROOT, encoding: 'utf-8' })
.trim()
.split('\n')
.filter(Boolean);
if (revs.length < 2) {
throw new Error('verify.commits parity requires at least 2 commits in checkout history');
}
const b = revs[0];
const a = revs[1];
const gsdOutput = await captureGsdToolsOutput('verify', ['commits', a, b], REPO_ROOT);
const registry = createRegistry();
const sdkResult = await registry.dispatch('verify.commits', [a, b], REPO_ROOT);
expect(sdkResult.data).toEqual(gsdOutput);
});
});

View File

@@ -0,0 +1,31 @@
/**
* Canonical registry command strings for golden parity — one primary name per unique
* native handler (dedupes dotted vs space-delimited aliases on the same function).
*/
import { createRegistry } from '../query/index.js';
import type { QueryHandler } from '../query/utils.js';
export function getCanonicalRegistryCommands(): string[] {
const registry = createRegistry();
const byHandler = new Map<QueryHandler, string[]>();
for (const cmd of registry.commands()) {
const h = registry.getHandler(cmd);
if (!h) continue;
const list = byHandler.get(h) ?? [];
list.push(cmd);
byHandler.set(h, list);
}
const out: string[] = [];
for (const cmds of byHandler.values()) {
cmds.sort((a, b) => a.localeCompare(b));
const dotted = cmds.find((c) => c.includes('.'));
if (dotted) {
out.push(dotted);
continue;
}
const kebab = cmds.find((c) => c.includes('-'));
out.push(kebab ?? cmds[0]!);
}
return out.sort((a, b) => a.localeCompare(b));
}

View File

@@ -77,7 +77,7 @@ function formatRegistryRawStdout(matchedCmd: string, data: unknown): string {
if (matchedCmd === 'config-set') {
const d = data as Record<string, unknown>;
if (d.set === true && d.key !== undefined) {
if ((d.updated === true || d.set === true) && d.key !== undefined) {
const v = d.value;
if (v === null || v === undefined) {
return `${d.key}=`;
@@ -117,6 +117,8 @@ export class GSDTools {
workstream?: string;
/** When set, mutation handlers emit the same events as `gsd-sdk query`. */
eventStream?: GSDEventStream;
/** Correlation id for mutation events when `eventStream` is set. */
sessionId?: string;
/**
* When true (default), route known commands through the SDK query registry.
* Set false in tests that substitute a mock `gsdToolsPath` script.
@@ -129,7 +131,7 @@ export class GSDTools {
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.workstream = opts.workstream;
this.preferNativeQuery = opts.preferNativeQuery ?? true;
this.registry = createRegistry(opts.eventStream);
this.registry = createRegistry(opts.eventStream, opts.sessionId);
}
private shouldUseNativeQuery(): boolean {
@@ -188,6 +190,8 @@ export class GSDTools {
}, this.timeoutMs);
});
try {
// Promise.race rejects when the timeout fires but does not cancel the handler promise;
// native handlers may still run to completion (unlike subprocess + execFile timeout).
return await Promise.race([work, timeoutPromise]);
} finally {
if (timeoutId !== undefined) {
@@ -545,6 +549,34 @@ export class GSDTools {
}
}
/**
* Run `gsd-sdk query` semantics in-process: normalize argv, resolve registry, dispatch.
* Returns handler JSON payload (same as stdout from the `gsd-sdk query` CLI without `--pick`).
*/
export async function runGsdToolsQuery(projectDir: string, queryArgv: string[]): Promise<unknown> {
const { createRegistry } = await import('./query/index.js');
const { resolveQueryArgv } = await import('./query/registry.js');
const { normalizeQueryCommand } = await import('./query/normalize-query-command.js');
const { GSDError, ErrorClassification } = await import('./errors.js');
if (queryArgv.length === 0 || !queryArgv[0]) {
throw new GSDError('runGsdToolsQuery requires a command', ErrorClassification.Validation);
}
const queryCommand = queryArgv[0];
const [normCmd, normArgs] = normalizeQueryCommand(queryCommand, queryArgv.slice(1));
const registry = createRegistry();
const tokens = [normCmd, ...normArgs];
const matched = resolveQueryArgv(tokens, registry);
if (!matched) {
throw new GSDError(
`Unknown command: "${tokens.join(' ')}". No native handler registered.`,
ErrorClassification.Validation,
);
}
const result = await registry.dispatch(matched.cmd, matched.args, projectDir);
return result.data;
}
// ─── Path resolution ────────────────────────────────────────────────────────
/**

View File

@@ -40,6 +40,7 @@ import { PromptFactory } from './phase-prompt.js';
export class GSD {
private readonly projectDir: string;
private readonly gsdToolsPath: string;
private readonly sessionId?: string;
private readonly defaultModel?: string;
private readonly defaultMaxBudgetUsd: number;
private readonly defaultMaxTurns: number;
@@ -51,6 +52,7 @@ export class GSD {
this.projectDir = resolve(options.projectDir);
this.gsdToolsPath =
options.gsdToolsPath ?? resolveGsdToolsPath(this.projectDir);
this.sessionId = options.sessionId;
this.defaultModel = options.model;
this.defaultMaxBudgetUsd = options.maxBudgetUsd ?? 5.0;
this.defaultMaxTurns = options.maxTurns ?? 50;
@@ -121,6 +123,7 @@ export class GSD {
gsdToolsPath: this.gsdToolsPath,
workstream: this.workstream,
eventStream: this.eventStream,
sessionId: this.sessionId,
});
}
@@ -318,6 +321,9 @@ export { CLITransport } from './cli-transport.js';
export { WSTransport } from './ws-transport.js';
export type { WSTransportOptions } from './ws-transport.js';
// Query registry argv normalization (matches `gsd-sdk query` and `GSDTools` hot path)
export { createRegistry, normalizeQueryCommand } from './query/index.js';
// Workstream utilities
export { validateWorkstreamName, relPlanningPath } from './workstream-utils.js';

View File

@@ -2,27 +2,44 @@
This document records contracts for the typed query layer consumed by `gsd-sdk query` and programmatic `createRegistry()` callers.
## Registry coverage vs `gsd-tools.cjs`
- **In scope:** Native handlers are registered in `createRegistry()` (`index.ts`) so SDK output can match `get-shit-done/bin/gsd-tools.cjs` JSON (see `sdk/src/golden/`).
- **Explicitly not registered** (product decision): `**graphify**`, `**from-gsd2**` / `**gsd2-import**` — remain CLI-only.
- **CLI name differences** (same behavior, different dispatch string):
- CJS `**summary-extract**` → SDK `**summary.extract**` / `**summary extract**` / `**history-digest**` (see `index.ts`).
- CJS top-level `**scaffold <type> ...**` → SDK `**phase.scaffold**` / `**phase scaffold**` with the scaffold type as the first argument (no separate `scaffold` alias on the registry).
## `gsd-sdk query` routing
- **Longest-prefix match** on argv (`resolveQueryArgv` in `registry.ts`): tries joined keys `a.b.c` then `a b c` for each prefix length, longest first. Example: `state update status X` → handler `state.update` with args `[status, X]`.
- **Dotted single token**: one token like `init.new-project` matches the registry; if the first pass finds no handler, a single dotted token is split and matching runs again (same helper as above).
- **No CJS passthrough**: if nothing matches a registered handler, the CLI exits with an error. Operations not ported to the query registry (e.g. `audit-open`, `graphify`, `from-gsd2`, `state validate`) must use `node …/gsd-tools.cjs` directly — see `docs/CLI-TOOLS.md`.
- **Output**: JSON written to stdout for successful handler results.
1. **`normalizeQueryCommand()`** (`normalize-query-command.ts`) — maps the first argv tokens to the same **command + subcommand** patterns as `gsd-tools` `runCommand()` where needed (e.g. `state json` → `state.json`, `init execute-phase 9` → `init.execute-phase` with args `['9']`, `scaffold …` → `phase.scaffold`). Re-exported from **`@gsd-build/sdk`** and **`createRegistry`’s module** (`sdk/src/query/index.ts`) so programmatic callers can mirror CLI tokenization without importing a deep path.
2. **`resolveQueryArgv()`** (`registry.ts`) — **longest-prefix match** on the normalized argv: tries joined keys `a.b.c` then `a b c` for each prefix length, longest first. Example: `state update status X` → handler `state.update` with args `[status, X]`.
3. **Dotted single token**: one token like `init.new-project` matches the registry; if the first pass finds no handler, a single dotted token is split and matching runs again.
4. **CJS fallback (CLI)**: if nothing matches a registered handler and `GSD_QUERY_FALLBACK` is not `off`/`never`/`false`/`0`, the CLI shells out to `gsd-tools.cjs` with argv derived from the normalized tokens (dotted commands are split into CJS-style segments). stderr receives a short bridge warning. Set `GSD_QUERY_FALLBACK=off` for strict mode (parity tests). CLI-only commands such as `graphify` rely on this path until native handlers exist.
5. **Output**: JSON written to stdout for successful handler results.
**Registered:** `phase.add-batch` / `phase add-batch` — batch append (see `phaseAddBatch` in `phase-lifecycle.ts`).
## Error handling
- **Validation and programmer errors**: Handlers throw `GSDError` with an `ErrorClassification` (e.g. missing required args, invalid phase). The CLI maps these to exit codes via `exitCodeFor()`.
- **Expected domain failures**: Handlers return `{ data: { error: string, ... } }` for cases that are not exceptional in normal use (file not found, intel disabled, todo missing, etc.). Callers must check `data.error` when present.
- Do not mix both styles for the same failure mode in new code: prefer **throw** for "caller must fix input"; prefer **`data.error`** for "operation could not complete in this project state."
- Do not mix both styles for the same failure mode in new code: prefer **throw** for "caller must fix input"; prefer `**data.error`** for "operation could not complete in this project state."
## Mutation commands and events
- `QUERY_MUTATION_COMMANDS` in `index.ts` lists every command name (including space-delimited aliases) that performs durable writes. It drives optional `GSDEventStream` wrapping so mutations emit structured events.
- Init composition handlers (`init.*`) are **not** included: they return JSON for workflows; agents perform filesystem work.
- `**state.validate`** is **read-only** — not listed in `QUERY_MUTATION_COMMANDS`.
- `**skill-manifest`**: writes to disk only when invoked with `**--write**`. It is **not** in `QUERY_MUTATION_COMMANDS`, so conditional writes do not emit mutation events today. If event consumers need `skill-manifest` writes, add a follow-up that either registers a dedicated command name for the write path or documents the exception.
## Intel: `intel.update`
- `**intel.update`** / `**intel update**` matches CJS `intel.cjs` `intelUpdate` **JSON** (not an in-process graph refresh): when intel is enabled it returns `{ action: 'spawn_agent', message: '...' }`; when disabled, `{ disabled: true, message: '...' }`. The **gsd-intel-updater** agent performs the actual refresh after spawn. Golden tests use full `toEqual` vs `gsd-tools.cjs` on this repo’s intel config.
## Session correlation (`sessionId`)
- Mutation events include `sessionId: ''` until a future phase threads session identifiers through the query dispatch path. Consumers should not rely on `sessionId` for correlation today.
- `createRegistry(eventStream, sessionId)` threads the optional `sessionId` string into mutation-related events emitted via `eventStream`. `GSDTools` accepts `sessionId` in its constructor and forwards it to `createRegistry`; `GSD` accepts `sessionId` in `GSDOptions` and passes it through `createTools()`. When omitted, `sessionId` is empty.
## Lockfiles (`state-mutation.ts`)
@@ -31,3 +48,260 @@ This document records contracts for the typed query layer consumed by `gsd-sdk q
## Intel JSON search
- `searchJsonEntries` in `intel.ts` caps recursion depth (`MAX_JSON_SEARCH_DEPTH`) to avoid stack overflow on pathological nested JSON.
## Phase / plan listing (SDK-only)
No `gsd-tools.cjs` mirror — agents use these instead of shell `ls`/`find`/`grep`:
- `**phase.list-plans**` `<phase>` [`**--with-schema**` `<yamlKey>`] — PLAN files in the phase dir; optional filter when a frontmatter key is present (`phase-list-queries.ts`).
- `**phase.list-artifacts**` `<phase>` `**--type**` `context|summary|verification|research` — matching `*-CONTEXT.md`, `*-SUMMARY.md`, etc.
- `**plan.task-structure**` `<path-to-PLAN.md>` — wave, `depends_on`, task/checkpoint counts via `parsePlan()`.
- `**requirements.extract-from-plans**` `<phase>` — deduped `requirements:` frontmatter across plans.
## State extensions (Phase 3)
Handlers for `**state.signal-waiting`**, `**state.signal-resume**`, `**state.validate**`, `**state.sync**` (supports `--verify` dry-run), and `**state.prune**` live in `state-mutation.ts`, with dotted and `state …` space aliases in `index.ts`.
**`state.json` vs `state.load` (different CJS commands):**
- **`state.json`** / `state json` — port of **`cmdStateJson`** (`state.ts` `stateJson`): rebuilt STATE.md frontmatter JSON. Read-only golden: `read-only-parity.integration.test.ts` compares to CJS `state json` with **`last_updated`** stripped.
- **`state.load`** / `state load` — port of **`cmdStateLoad`** (`state-project-load.ts` `stateProjectLoad`): `{ config, state_raw, state_exists, roadmap_exists, config_exists }`; **`config`** comes from **`get-shit-done/bin/lib/core.cjs`** `loadConfig` (resolved via the same candidate paths as a normal GSD install). Read-only golden: full `toEqual` vs `state load`. If `core.cjs` cannot be resolved, dispatch throws **`GSDError`** (document for minimal `@gsd-build/sdk`-only installs).
`stateExtractField` in `helpers.ts` uses **horizontal whitespace only** after `Field:` so YAML keys such as lowercase `progress:` in frontmatter are not mistaken for the body `Progress:` line (see `get-shit-done/bin/lib/state.cjs` — same rule).
## Golden parity: coverage and exceptions
Subprocess reference: `captureGsdToolsOutput()` / `captureGsdToolsStdout()` → `get-shit-done/bin/gsd-tools.cjs` (`sdk/src/golden/capture.ts`). Plain-text commands (e.g. `config-path`) use stdout string comparison in `read-only-parity.integration.test.ts`.
**Authoritative accounting (every canonical handler):** `sdk/src/golden/golden-policy.ts` merges `golden-integration-covered.ts` (canonicals hit by `golden.integration.test.ts`) with `read-only-golden-rows.ts` / special cases (`verify.commits`, `config-path`) into `GOLDEN_PARITY_INTEGRATION_COVERED`, and builds `GOLDEN_PARITY_EXCEPTIONS` for the rest. `getCanonicalRegistryCommands()` (`registry-canonical-commands.ts`) lists one dispatch string per unique handler; each canonical must be either covered or receive a built-in exception string (mutations → shared rationale; read-only without a subprocess row → per-command note). `sdk/src/golden/golden-policy.test.ts` calls `verifyGoldenPolicyComplete()` so the policy cannot drift silently.
**Integration test files:**
| File | Role |
| ---- | ---- |
| `sdk/src/golden/golden.integration.test.ts` | Primary golden suite: subset/shape/full parity as documented in the tables below. |
| `sdk/src/golden/read-only-parity.integration.test.ts` | Read-only handlers with full `toEqual` on `sdkResult.data` vs CJS JSON; rows listed in `read-only-golden-rows.ts`. Also `config-path` / `verify.commits`, dedicated blocks for **`state.json`** (strip `last_updated`) and **`state.load`** (full `cmdStateLoad` parity). |
This section summarizes **how** each covered command is compared so readers do not have to infer rules from assertions alone.
### Golden registry coverage matrix (human summary)
- **Covered by subprocess golden** — canonical names appear in `GOLDEN_PARITY_INTEGRATION_COVERED`; see the tables below and the two integration files for assertion style (mostly full `toEqual`; remaining subset cases: `frontmatter.get`, `find-phase`).
- **Not in covered set** — either listed in `QUERY_MUTATION_COMMANDS` (durable writes; handler tests in `sdk/src/query/*.test.ts` and mutation-focused tests) or a read-only handler whose full CJS JSON match is deferred (see auto-generated exception text in `golden-policy.ts`).
### Full JSON equality (`toEqual` on result data)
These tests expect `sdkResult.data` to match the parsed CJS stdout JSON (possibly after shared normalization helpers):
| SDK dispatch (representative) | Notes |
| ----------------------------- | ----------------------------------------------------------------------------------------------------- |
| `generate-slug` | Includes fixture + multi-word cases. |
| `config-get` | Sample: top-level key `model_profile`. |
| `config-set` | Temp `.planning/` tree; reset between CJS capture and SDK dispatch; `toEqual` on `{ updated, key, value, previousValue? }`. |
| `state.validate` | Full object parity. |
| `state.sync` | With `--verify` (dry-run); full object parity. |
| `detect-custom-files` | Temp `--config-dir` fixture; full object parity. |
| `roadmap.analyze` / `progress` | Full object parity (`progress` uses `progress json` CJS path). |
| `frontmatter.validate` | Plan schema fixture under `.planning/phases/11-state-mutations/`. |
| `verify.plan-structure` / `validate.consistency` / `verify.phase-completeness` | Full object parity on representative repo paths. |
| `init.execute-phase` / `init.plan-phase` / `init.resume` / `init.verify-work` | Full `toEqual` vs CJS. |
| `init.quick` | Full parity **after** stripping `quick_id`, `timestamp`, `branch_name`, `task_dir` (`init-golden-normalize.ts`). |
| `intel.update` | Full `toEqual` vs CJS for this project (disabled vs spawn-hint payload per `intel.cjs`). |
From `read-only-parity.integration.test.ts` (full `toEqual` on this repo):
| SDK dispatch (canonical) | Notes |
| ------------------------ | ----- |
| `resolve-model` | Args e.g. `gsd-planner`. |
| `phase-plan-index` | Phase number arg. |
| `roadmap.get-phase` | Phase number arg. |
| `list.todos` | No args. |
| `phase.next-decimal` | Phase number arg. |
| `phases.list` | No args. |
| `verify.summary` | Plan path. |
| `verify.path-exists` | Path under repo. |
| `verify.artifacts` | Plan path. |
| `verify.commits` | Two git SHAs (`HEAD~1` / `HEAD` or fallback). |
| `websearch` | Limited query (may hit network — test uses small limit). |
| `workstream.get` / `workstream.list` / `workstream.status` | Default workstream where applicable (`status` uses full CJS shape when the workstream dir exists). |
| `learnings.list` | No args. |
| `intel.status` | No args. |
| `intel.diff` / `intel.validate` / `intel.query` | When intel is disabled, disabled payload matches CJS (including message text). |
| `init.list-workspaces` | No args. |
| `agent-skills` | No agent type → JSON `""` (same as CJS). |
| `scan-sessions` | `--json`; SDK `scanSessions` output matches CJS project array (`profile-scan-sessions.ts`). |
| `summary.extract` | Fixture `sdk/src/golden/fixtures/summary-extract-sample.md`; uses `extractFrontmatterLeading` (first `---` block) for parity with `frontmatter.cjs`. |
| `history.digest` | No args; aggregate over `.planning/phases` + archived milestone phase dirs (`commands.cjs` `cmdHistoryDigest`). |
| `audit-uat` | No args; full JSON parity with `uat.cjs` `cmdAuditUat` (`results`, `summary` with `by_category` / `by_phase`). |
| `skill-manifest` | No args; full manifest parity with `init.cjs` `buildSkillManifest` / `cmdSkillManifest`. Handler uses `extractFrontmatterLeading` (first `---` block) like CJS `frontmatter.cjs` `extractFrontmatter` — not TS `extractFrontmatter` (last block), so skills with multiple `---` sections match CJS. |
| `validate.agents` | No args; `agents_dir` matches `core.cjs` `getAgentsDir` (`GSD_AGENTS_DIR` or `sdk/dist/query/../../../agents` in this monorepo — same absolute path as CLI). `MODEL_PROFILES` / `expected` list stays aligned with `get-shit-done/bin/lib/model-profiles.cjs`. |
| `state.get` | Dedicated tests: no args → full `{ content }` vs `state get`; one field (`milestone`) → `{ milestone: "…" }` vs `state get milestone` (frontmatter line match). |
| `state.json` | `state json` vs SDK; **`last_updated`** stripped before `toEqual` (volatile). |
| `state.load` | `state load` vs SDK; full **`cmdStateLoad`** object graph (`config`, `state_raw`, existence flags). |
| `uat.render-checkpoint` | Fixture `sdk/src/golden/fixtures/uat-render-checkpoint-sample.md`; full JSON parity with `uat.cjs` `cmdRenderCheckpoint` (`file_path`, `test_number`, `test_name`, `checkpoint` — same box + `buildCheckpoint` text as CJS; `sanitizeForDisplay` on name/expected). |
| `config-path` | Plain stdout path vs `{ path }` — compared with `path.normalize` in tests. |
### Normalized or field-omitted comparison
| SDK / test | Rule |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `audit-open` | `audit-open --json`: `**scanned_at**` stripped before `toEqual` (volatile ISO time). `sanitizeForDisplay` in `audit-open.ts` matches `security.cjs` (CRLF body lines can leave `\r` in `items.todos[].summary`, matching CLI). |
| `extract.messages` / `extract-messages` | Fixture `sdk/src/golden/fixtures/extract-messages-sessions/` passed as `--path` (sessions root). `**output_file**` stripped before `toEqual` (temp path under `os.tmpdir()`); then the two JSONL files are compared byte-for-byte. Parity with `profile-pipeline.cjs` `cmdExtractMessages` (`streamExtractMessages`, `isGenuineUserMessage`, batch limit 300). |
| `docs-init` | `existing_docs` sorted by `path` before compare; `**agents_installed`** and `**missing_agents**` omitted (subprocess vs in-process path resolution for `~/.claude/...`). |
### Structural, subset, or shape-only parity
Assertions deliberately compare only selected fields (not full `toEqual`):
| SDK dispatch (representative) | What is compared |
| ----------------------------- | ---------------- |
| `frontmatter.get` | Scalar fields `phase`, `plan`, `type`; same top-level key set as CJS. |
| `find-phase` | `found`, `directory`, `phase_number`, `phase_name`, `plans` (SDK payload is a **subset** of CJS — extra CJS fields ignored). |
`template.select` is **not** in `golden.integration.test.ts`: CJS `template select <plan-path>` scores PLAN **content** for summary templates; SDK `template.select <phase>` uses phase-directory heuristics — different algorithms. Covered in `sdk/src/query/template.test.ts`.
### Time- and environment-dependent
| Command | Rule |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `current-timestamp` | `**full`**: same shape and valid ISO strings; not the same instant. `**date**`: same calendar day when the test does not cross midnight. `**filename**`: full `toEqual` (back-to-back capture vs SDK). |
### Conditional writes (not in `QUERY_MUTATION_COMMANDS`)
| Command | Rule |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `skill-manifest` | Disk writes only with `**--write`**; registry does not emit mutation events for this command (see **Mutation commands and events** above). |
### Registered but not in the golden suite
Handlers in `createRegistry()` that are **not** covered by `golden.integration.test.ts` are not automatically “non-parity” — they simply have **no** automated cross-check against CJS yet. Add golden tests when tightening coverage; until then, treat absence here as a **test gap**, not a behavior guarantee.
---
## Decision routing (SDK-only)
These handlers implement `.planning/research/decision-routing-audit.md` — **no `gsd-tools.cjs` mirror yet** (orchestration JSON only). Invoke via `gsd-sdk query` / `registry.dispatch()` after `normalizeQueryCommand()` where argv uses `check …` / `detect …` / `route …` prefixes.
### Tier 1
| Dispatch | Purpose |
| -------- | ------- |
| `check.config-gates` / `check config-gates [workflow]` | Single JSON blob of merged `workflow.*` (+ `context_window`) for batch config gates. |
| `check.phase-ready` / `check phase-ready <phase>` | Phase directory stats, `dependencies_met`, `next_step` (`discuss` / `plan` / `execute` / `verify` / `complete`). |
| `route.next-action` / `route next-action` | Suggested next slash command from `next.md`-style rules (`/gsd-discuss-phase`, `/gsd-execute-phase`, `/gsd-resume-work`, gates, etc.). |
### Tier 2
| Dispatch | Purpose |
| -------- | ------- |
| `check.auto-mode` / `check auto-mode` | `active` (OR of `workflow.auto_advance` and `workflow._auto_chain_active`), `source` (`none` / `auto_advance` / `auto_chain` / `both`), plus the two booleans. Replaces paired `config-get` calls in checkpoint and auto-advance steps. Use `--pick active` or `--pick auto_chain_active` when a workflow only needs one field. |
| `detect.phase-type` / `detect phase-type <phase>` | Structured UI/schema/API/infra detection for a phase. Returns `has_frontend`, `frontend_indicators`, `has_schema`, `schema_orm`, `schema_files`, `has_api`, `has_infra`, `push_command` (null, reserved). Replaces fragile grep-based UI detection in `autonomous.md`, `plan-phase.md`, etc. (audit §3.6). |
| `check.completion` / `check completion <phase\|milestone> <id>` | Phase or milestone completion rollup. Phase mode: `plans_total`, `plans_with_summaries`, `missing_summaries`, `verification_status`, `uat_status`, `debt` (`uat_gaps`, `verification_failures`, `human_needed`), `complete`. Milestone mode: `phase_count`, `phases_complete`, `phases_incomplete`, `complete`. Replaces PLAN/SUMMARY counting in `transition.md`, `complete-milestone.md` (audit §3.7). |
### Tier 3
| Dispatch | Purpose |
| -------- | ------- |
| `check.gates` / `check gates <workflow> [--phase <N>]` | Safety gate consolidation. Checks `.continue-here.md` presence (blocker), STATE.md error/failed status (blocker), and VERIFICATION.md FAIL rows (warning). Returns `passed`, `blockers`, `warnings`. Replaces per-workflow gate logic in `next.md`, `execute-phase.md`, `discuss-phase.md` (audit §3.2). SDK-only — no CJS mirror. |
| `check.verification-status` / `check verification-status <phase>` | VERIFICATION.md parser. Returns `status` (`pass`/`fail`/`partial`/`missing`), `score` (e.g. `"3/4"`), `gaps`, `human_items`, `deferred`. Handles prefixed filenames and missing files. Replaces VERIFICATION.md grep/parse in `execute-phase.md`, `autonomous.md`, `progress.md` (audit §3.8). SDK-only — no CJS mirror. |
| `check.ship-ready` / `check ship-ready <phase>` | Ship preflight: `clean_tree`, `on_feature_branch`, `current_branch`, `base_branch`, `remote_configured`, `gh_available`, `gh_authenticated` (always false — advisory, no network call), `verification_passed`, `blockers`, `ready`. Replaces ship.md preflight checks (audit §3.9). SDK-only — no CJS mirror. |
**Stability:** Shapes are versioned with the audit doc; add integration tests when workflows adopt these queries. Re-run after file writes that change `.planning/` (stale read caveat in audit §6). All Tier 1–3 handlers are implemented and unit-tested.
---
## CJS command surface vs SDK registry
Authoritative CJS entry points: `runCommand` `switch (command)` in `get-shit-done/bin/gsd-tools.cjs`. SDK entry points: `createRegistry()` in `sdk/src/query/index.ts`.
**Naming aliases (registered, different string):**
- CJS `**summary-extract`** → SDK `**summary.extract**`, `**summary extract**`, `**history-digest**` (history digest helpers).
- CJS top-level `**scaffold <type> …**` → SDK `**phase.scaffold**` / `**phase scaffold**` (type + options in args).
**CLI-only (no SDK registry handler; intentional unless requirements change):**
| CJS surface | Justification |
| --------------------- | ---------------------------------------------------------------------------------------------- |
| `**graphify`** | Depends on Graphify CLI / Python stack; not ported to the typed query layer. |
| `**from-gsd2**` | Legacy GSD2 → GSD migration (`gsd2-import.cjs`); CLI-only helper. |
**SDK-only (registered dispatch without an equivalent `gsd-tools` top-level subcommand):**
| SDK dispatch | Notes |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `**phases.archive`** / `**phases archive**` | CJS `phases` supports only `**list**` and `**clear**`; archive behavior is available via SDK (and workflows), not as `gsd-tools phases archive`. |
### Matrix: top-level `gsd-tools` command → SDK
Disposition: **Registered** = handled in `createRegistry()` under the listed SDK name(s); **CLI-only** = no registry handler; **Alias** = same behavior, different primary dispatch string.
| CJS `command` (first argv) | SDK dispatch name(s) | Disposition | Notes |
| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------- |
| `state` (subcommands) | `state.load`, `state.json`, `state.get`, `state.update`, `state.patch`, … | Registered | Dotted and `state …` space aliases in `index.ts`. |
| `resolve-model` | `resolve-model` | Registered | |
| `find-phase` | `find-phase` | Registered | Golden: subset parity (see above). |
| `commit`, `check-commit`, `commit-to-subrepo` | `commit`, `check-commit`, `commit-to-subrepo` | Registered | |
| `verify-summary` | `verify-summary`, `verify.summary`, `verify summary` | Registered | |
| `template` | `template.fill`, `template.select`, … | Registered | |
| `frontmatter` | `frontmatter.get`, `frontmatter.set`, … | Registered | |
| `verify` | `verify.plan-structure`, `verify.phase-completeness`, … | Registered | |
| `generate-slug` | `generate-slug` | Registered | |
| `current-timestamp` | `current-timestamp` | Registered | Golden: time semantics (see above). |
| `list-todos` | `list-todos`, `list.todos` | Registered | |
| `verify-path-exists` | `verify-path-exists`, `verify.path-exists`, … | Registered | |
| `config-ensure-section`, `config-set`, `config-set-model-profile`, `config-get`, `config-new-project`, `config-path` | same kebab-case names | Registered | |
| `agent-skills` | `agent-skills` | Registered | |
| `skill-manifest` | `skill-manifest`, `skill manifest` | Registered | Writes only with `--write`. |
| `history-digest` | `history-digest`, `history.digest`, … | Alias | Same as `**summary.extract`** family for digest-style output. |
| `phases` | `phases.list`, `phases.clear`, `phases.archive`, … | Registered (+ SDK-only) | CJS: `**list**`, `**clear**` only; `**archive**` is SDK-only (see above). |
| `roadmap` | `roadmap.analyze`, `roadmap.get-phase`, `roadmap.update-plan-progress`, … | Registered | |
| `requirements` | `requirements.mark-complete`, … | Registered | |
| `phase` | `phase.add`, `phase.add-batch`, `phase.insert`, … | Registered | |
| `milestone` | `milestone.complete`, … | Registered | |
| `validate` | `validate.consistency`, `validate.health`, `validate.agents`, … | Registered | |
| `progress` | `progress`, `progress.json`, `progress.bar`, … | Registered | |
| `audit-uat` | `audit-uat` | Registered | |
| `audit-open` | `audit-open`, `audit open` | Registered | |
| `uat` | `uat.render-checkpoint`, … | Registered | |
| `stats` | `stats`, `stats.json`, … | Registered | |
| `todo` | `todo.complete`, `todo.match-phase`, … | Registered | |
| `scaffold` | `phase.scaffold`, `phase scaffold` | Alias | Top-level `**scaffold**` in CJS; no separate `scaffold` registry key. |
| `init` | `init.execute-phase`, `init.new-project`, … | Registered | Dotted and `init …` space aliases. |
| `phase-plan-index` | `phase-plan-index` | Registered | |
| `state-snapshot` | `state-snapshot` | Registered | |
| `summary-extract` | `summary.extract`, `summary extract`, `history-digest`, … | Alias | |
| `websearch` | `websearch` | Registered | |
| `scan-sessions` | `scan-sessions` | Registered | |
| `extract-messages` | `extract-messages`, `extract.messages` | Registered | Golden: `output_file` strip + JSONL bytes (see **Normalized** table). |
| `profile-sample`, `profile-questionnaire`, `write-profile`, `generate-dev-preferences`, `generate-claude-profile`, `generate-claude-md` | same kebab-case names | Registered | |
| `workstream` | `workstream.get`, `workstream.list`, … | Registered | |
| `intel` | `intel.status`, `intel.diff`, `intel.update`, … | Registered | `**intel.update**`: JSON parity with CJS spawn hint / disabled payload (see **Intel: intel.update**). |
| `graphify` | — | CLI-only | See **CLI-only** table. |
| `docs-init` | `docs-init` | Registered | Golden: normalized compare (see above). |
| `learnings` | `learnings.list`, `learnings.query`, … | Registered | |
| `detect-custom-files` | `detect-custom-files` | Registered | Requires `--config-dir`. |
| `from-gsd2` | — | CLI-only | See **CLI-only** table. |
---
## Other registered areas
- `**detect-custom-files`**: requires `--config-dir <path>`; scans installer manifest vs GSD-managed dirs (`detect-custom-files.ts`).
- `**docs-init**`: docs-update workflow payload (`docs-init.ts`), aligned with `docs.cjs`. Golden tests omit `**agents_installed**` / `**missing_agents**` when comparing SDK vs CLI because the subprocess may resolve `~/.claude/...` differently than in-process checks.

722
sdk/src/query/audit-open.ts Normal file
View File

@@ -0,0 +1,722 @@
/**
* Open Artifact Audit — full TypeScript port of `get-shit-done/bin/lib/audit.cjs`.
*
* Scans `.planning/` artifact categories for unresolved items (same JSON as gsd-tools `audit-open`).
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import { extractFrontmatter } from './frontmatter.js';
import { planningPaths, sanitizeForDisplay } from './helpers.js';
import type { QueryHandler } from './utils.js';
function scanDebugSessions(planDir: string): Array<Record<string, unknown>> {
const debugDir = join(planDir, 'debug');
if (!existsSync(debugDir)) return [];
const results: Array<Record<string, unknown>> = [];
let files;
try {
files = readdirSync(debugDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = join(debugDir, entry.name);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
results.push({
slug: sanitizeForDisplay(basename(entry.name, '.md')),
status: 'unreadable',
scan_error: true,
detail: 'file read failed',
});
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toString().toLowerCase();
if (status === 'resolved' || status === 'complete') continue;
let hypothesis = '';
const focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
if (focusMatch) {
const focusText = focusMatch[1].trim().split('\n')[0].trim();
hypothesis = sanitizeForDisplay(focusText.slice(0, 100));
}
const slug = basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
hypothesis,
});
}
return results;
}
function scanQuickTasks(planDir: string): Array<Record<string, unknown>> {
const quickDir = join(planDir, 'quick');
if (!existsSync(quickDir)) return [];
let entries;
try {
entries = readdirSync(quickDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const results: Array<Record<string, unknown>> = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirName = entry.name;
const taskDir = join(quickDir, dirName);
const summaryPath = join(taskDir, 'SUMMARY.md');
let status = 'missing';
const description = '';
if (existsSync(summaryPath)) {
try {
const content = readFileSync(summaryPath, 'utf-8');
const fm = extractFrontmatter(content);
status = (fm.status || 'unknown').toString().toLowerCase();
} catch {
status = 'unreadable';
}
}
if (status === 'complete') continue;
let date = '';
let slug = sanitizeForDisplay(dirName);
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
if (dateMatch) {
date = dateMatch[1];
slug = sanitizeForDisplay(dateMatch[2]);
}
results.push({
slug,
date,
status: sanitizeForDisplay(status),
description,
});
}
return results;
}
function scanThreads(planDir: string): Array<Record<string, unknown>> {
const threadsDir = join(planDir, 'threads');
if (!existsSync(threadsDir)) return [];
let files;
try {
files = readdirSync(threadsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
const results: Array<Record<string, unknown>> = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = join(threadsDir, entry.name);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
results.push({
slug: sanitizeForDisplay(basename(entry.name, '.md')),
status: 'unreadable',
scan_error: true,
detail: 'file read failed',
});
continue;
}
const fm = extractFrontmatter(content);
let status = (fm.status || '').toString().toLowerCase().trim();
if (!status) {
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
if (bodyStatusMatch) {
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
}
}
if (!openStatuses.has(status)) continue;
let title = sanitizeForDisplay(String(fm.title || ''));
if (!title) {
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
if (headingMatch) {
title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
}
const slug = basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(String(fm.updated || fm.date || '')),
title,
});
}
return results;
}
function scanTodos(planDir: string): Array<Record<string, unknown>> {
const pendingDir = join(planDir, 'todos', 'pending');
if (!existsSync(pendingDir)) return [];
let files;
try {
files = readdirSync(pendingDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
const results: Array<Record<string, unknown>> = [];
const displayFiles = mdFiles.slice(0, 5);
for (const entry of displayFiles) {
const filePath = join(pendingDir, entry.name);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
const firstLine = bodyMatch.trim().split('\n')[0] || '';
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
results.push({
filename: sanitizeForDisplay(entry.name),
priority: sanitizeForDisplay(String(fm.priority || '')),
area: sanitizeForDisplay(String(fm.area || '')),
summary,
});
}
if (mdFiles.length > 5) {
results.push({ _remainder_count: mdFiles.length - 5 });
}
return results;
}
function scanSeeds(planDir: string): Array<Record<string, unknown>> {
const seedsDir = join(planDir, 'seeds');
if (!existsSync(seedsDir)) return [];
let files;
try {
files = readdirSync(seedsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true }];
}
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
const results: Array<Record<string, unknown>> = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
const filePath = join(seedsDir, entry.name);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'dormant').toString().toLowerCase();
if (!unimplementedStatuses.has(status)) continue;
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
const seed_id = seedIdMatch ? seedIdMatch[1] : basename(entry.name, '.md');
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
let title = sanitizeForDisplay(String(fm.title || ''));
if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
results.push({
seed_id: sanitizeForDisplay(seed_id),
slug,
status: sanitizeForDisplay(status),
title,
});
}
return results;
}
function scanUatGaps(planDir: string): Array<Record<string, unknown>> {
const phasesDir = join(planDir, 'phases');
if (!existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results: Array<Record<string, unknown>> = [];
for (const dir of dirs) {
const phaseDir = join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let phaseFiles: string[];
try {
phaseFiles = readdirSync(phaseDir);
} catch {
continue;
}
for (const file of phaseFiles.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const filePath = join(phaseDir, file);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toString().toLowerCase();
if (status === 'complete') continue;
const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
open_scenario_count: pendingMatches,
});
}
}
return results;
}
function scanVerificationGaps(planDir: string): Array<Record<string, unknown>> {
const phasesDir = join(planDir, 'phases');
if (!existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results: Array<Record<string, unknown>> = [];
for (const dir of dirs) {
const phaseDir = join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let phaseFiles: string[];
try {
phaseFiles = readdirSync(phaseDir);
} catch {
continue;
}
for (const file of phaseFiles.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const filePath = join(phaseDir, file);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
const status = (fm.status || 'unknown').toString().toLowerCase();
if (status !== 'gaps_found' && status !== 'human_needed') continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
});
}
}
return results;
}
function scanContextQuestions(planDir: string): Array<Record<string, unknown>> {
const phasesDir = join(planDir, 'phases');
if (!existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true }];
}
const results: Array<Record<string, unknown>> = [];
for (const dir of dirs) {
const phaseDir = join(phasesDir, dir);
const phaseMatch = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let phaseFiles: string[];
try {
phaseFiles = readdirSync(phaseDir);
} catch {
continue;
}
for (const file of phaseFiles.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
const filePath = join(phaseDir, file);
let content: string;
try {
content = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const fm = extractFrontmatter(content);
let questions: string[] = [];
if (fm.open_questions) {
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
questions = fm.open_questions.map(q => sanitizeForDisplay(String(q).slice(0, 200)));
}
}
if (questions.length === 0) {
const oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
if (oqMatch) {
const oqBody = oqMatch[1].trim();
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
const items = oqBody.split('\n')
.map(l => l.trim())
.filter(l => l && l !== '-' && l !== '*')
.filter(l => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map(q => sanitizeForDisplay(q.slice(0, 200)));
}
}
}
if (questions.length === 0) continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
question_count: questions.length,
questions: questions.slice(0, 3),
});
}
}
return results;
}
export interface AuditOpenResult {
scanned_at: string;
/** True when at least one category reported scan_error / unreadable rows (audit may be incomplete). */
has_scan_errors: boolean;
has_open_items: boolean;
counts: {
debug_sessions: number;
quick_tasks: number;
threads: number;
todos: number;
seeds: number;
uat_gaps: number;
verification_gaps: number;
context_questions: number;
total: number;
};
items: {
debug_sessions: Array<Record<string, unknown>>;
quick_tasks: Array<Record<string, unknown>>;
threads: Array<Record<string, unknown>>;
todos: Array<Record<string, unknown>>;
seeds: Array<Record<string, unknown>>;
uat_gaps: Array<Record<string, unknown>>;
verification_gaps: Array<Record<string, unknown>>;
context_questions: Array<Record<string, unknown>>;
};
}
/**
* Same structured result as `gsd-tools.cjs audit-open` (JSON).
*/
export function auditOpenArtifacts(projectDir: string): AuditOpenResult {
const planDir = planningPaths(projectDir).planning;
const debugSessions = (() => {
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true }]; }
})();
const quickTasks = (() => {
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true }]; }
})();
const threads = (() => {
try { return scanThreads(planDir); } catch { return [{ scan_error: true }]; }
})();
const todos = (() => {
try { return scanTodos(planDir); } catch { return [{ scan_error: true }]; }
})();
const seeds = (() => {
try { return scanSeeds(planDir); } catch { return [{ scan_error: true }]; }
})();
const uatGaps = (() => {
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true }]; }
})();
const verificationGaps = (() => {
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true }]; }
})();
const contextQuestions = (() => {
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true }]; }
})();
const countReal = (arr: Array<Record<string, unknown>>): number =>
arr.filter(i => !i.scan_error && !i._remainder_count).length;
const counts = {
debug_sessions: countReal(debugSessions),
quick_tasks: countReal(quickTasks),
threads: countReal(threads),
todos: countReal(todos),
seeds: countReal(seeds),
uat_gaps: countReal(uatGaps),
verification_gaps: countReal(verificationGaps),
context_questions: countReal(contextQuestions),
total: 0,
};
counts.total =
counts.debug_sessions +
counts.quick_tasks +
counts.threads +
counts.todos +
counts.seeds +
counts.uat_gaps +
counts.verification_gaps +
counts.context_questions;
const itemArrays = [
debugSessions,
quickTasks,
threads,
todos,
seeds,
uatGaps,
verificationGaps,
contextQuestions,
];
const has_scan_errors = itemArrays.some(arr =>
arr.some(i => i.scan_error === true),
);
return {
scanned_at: new Date().toISOString(),
has_scan_errors,
has_open_items: counts.total > 0,
counts,
items: {
debug_sessions: debugSessions,
quick_tasks: quickTasks,
threads,
todos,
seeds,
uat_gaps: uatGaps,
verification_gaps: verificationGaps,
context_questions: contextQuestions,
},
};
}
/**
* Human-readable report (same text as gsd-tools without `--json`).
*/
export function formatAuditReport(auditResult: AuditOpenResult): string {
const { counts, items, has_open_items, has_scan_errors } = auditResult;
const lines: string[] = [];
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
lines.push(hr);
lines.push(' Milestone Close: Open Artifact Audit');
lines.push(hr);
if (has_scan_errors) {
lines.push('');
lines.push(' ⚠ Some files or directories could not be scanned completely.');
lines.push(' Treat this audit as incomplete until read errors are resolved.');
lines.push('');
}
if (!has_open_items && !has_scan_errors) {
lines.push('');
lines.push(' All artifact types clear. Safe to proceed.');
lines.push('');
lines.push(hr);
return lines.join('\n');
}
if (!has_open_items && has_scan_errors) {
lines.push('');
lines.push(' No open items counted, but scanning had errors — not safe to assume a clean close.');
lines.push('');
lines.push(hr);
return lines.join('\n');
}
if (counts.debug_sessions > 0) {
lines.push('');
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
}
}
if (counts.uat_gaps > 0) {
lines.push('');
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
}
}
if (counts.verification_gaps > 0) {
lines.push('');
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
}
}
if (counts.quick_tasks > 0) {
lines.push('');
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
const d = item.date ? ` (${item.date})` : '';
lines.push(` • ${item.slug}${d} [${item.status}]`);
}
}
if (counts.todos > 0) {
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
const remainder = items.todos.find(i => i._remainder_count);
lines.push('');
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
for (const item of realTodos) {
const area = item.area ? ` [${item.area}]` : '';
const pri = item.priority ? ` (${item.priority})` : '';
lines.push(` • ${item.filename}${area}${pri}`);
if (item.summary) lines.push(` ${item.summary}`);
}
if (remainder) {
lines.push(` ... and ${remainder._remainder_count} more`);
}
}
if (counts.threads > 0) {
lines.push('');
lines.push(`🔵 Open Threads (${counts.threads} active)`);
for (const item of items.threads.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.slug} [${item.status}]${title}`);
}
}
if (counts.seeds > 0) {
lines.push('');
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
for (const item of items.seeds.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
}
}
if (counts.context_questions > 0) {
lines.push('');
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
for (const item of items.context_questions.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
for (const q of (item.questions as string[]) || []) {
lines.push(` - ${q}`);
}
}
}
lines.push('');
lines.push(hr);
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
lines.push(hr);
return lines.join('\n');
}
/**
* `audit-open` / `audit.open` — optional `--json` for structured JSON only (default adds formatted report string).
*/
export const auditOpen: QueryHandler = async (args, projectDir) => {
const jsonOnly = args.includes('--json');
const result = auditOpenArtifacts(projectDir);
if (jsonOnly) {
return { data: result };
}
return {
data: {
...result,
report: formatAuditReport(result),
},
};
};

View File

@@ -0,0 +1,77 @@
/**
* Unit tests for `check.auto-mode` (decision-routing audit §3.5).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAutoMode } from './check-auto-mode.js';
describe('checkAutoMode', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-auto-mode-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning'), { recursive: true });
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('returns defaults when config.json is missing', async () => {
const { data } = await checkAutoMode([], projectDir);
expect(data).toEqual({
active: false,
source: 'none',
auto_chain_active: false,
auto_advance: false,
});
});
it('active true when only auto_advance is set', async () => {
await writeFile(
join(projectDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { auto_advance: true } }),
'utf-8',
);
const { data } = await checkAutoMode([], projectDir);
expect(data).toMatchObject({
active: true,
source: 'auto_advance',
auto_advance: true,
auto_chain_active: false,
});
});
it('active true when only _auto_chain_active is set', async () => {
await writeFile(
join(projectDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { _auto_chain_active: true } }),
'utf-8',
);
const { data } = await checkAutoMode([], projectDir);
expect(data).toMatchObject({
active: true,
source: 'auto_chain',
auto_advance: false,
auto_chain_active: true,
});
});
it('uses source both when both flags are true', async () => {
await writeFile(
join(projectDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { auto_advance: true, _auto_chain_active: true } }),
'utf-8',
);
const { data } = await checkAutoMode([], projectDir);
expect(data).toMatchObject({
active: true,
source: 'both',
auto_advance: true,
auto_chain_active: true,
});
});
});

View File

@@ -0,0 +1,50 @@
/**
* Consolidated auto-advance flags (`check.auto-mode`).
*
* Replaces paired `config-get workflow.auto_advance` + `config-get workflow._auto_chain_active`
* for checkpoint and auto-advance gates. See `.planning/research/decision-routing-audit.md` §3.5.
*
* Semantics match `execute-phase.md`: automation applies when **either** the ephemeral chain flag
* or the persistent user preference is true (`active === true`).
*/
import { CONFIG_DEFAULTS, loadConfig } from '../config.js';
import type { QueryHandler } from './utils.js';
export type AutoModeSource = 'auto_chain' | 'auto_advance' | 'both' | 'none';
function resolveSource(
autoChainActive: boolean,
autoAdvance: boolean,
): { active: boolean; source: AutoModeSource } {
if (autoChainActive && autoAdvance) {
return { active: true, source: 'both' };
}
if (autoChainActive) {
return { active: true, source: 'auto_chain' };
}
if (autoAdvance) {
return { active: true, source: 'auto_advance' };
}
return { active: false, source: 'none' };
}
export const checkAutoMode: QueryHandler = async (_args, projectDir) => {
const config = await loadConfig(projectDir);
const wf: Record<string, unknown> = {
...CONFIG_DEFAULTS.workflow,
...(config.workflow as unknown as Record<string, unknown>),
};
const autoAdvance = Boolean(wf.auto_advance ?? false);
const autoChainActive = Boolean(wf._auto_chain_active ?? false);
const { active, source } = resolveSource(autoChainActive, autoAdvance);
return {
data: {
active,
source,
auto_chain_active: autoChainActive,
auto_advance: autoAdvance,
},
};
};

View File

@@ -0,0 +1,113 @@
/**
* Unit tests for `check.completion` (decision-routing audit §3.7).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkCompletion } from './check-completion.js';
describe('checkCompletion', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-check-completion-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning', 'phases'), { recursive: true });
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('throws when scope arg is missing', async () => {
await expect(checkCompletion([], projectDir)).rejects.toThrow();
});
it('throws when scope is invalid', async () => {
await expect(checkCompletion(['invalid', '1'], projectDir)).rejects.toThrow();
});
it('throws when phase number is missing for phase scope', async () => {
await expect(checkCompletion(['phase'], projectDir)).rejects.toThrow();
});
describe('phase scope', () => {
it('returns complete true when all plans have summaries', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '01-foundation');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '01-01-PLAN.md'), '---\nphase: 1\n---\n', 'utf-8');
await writeFile(join(phaseDir, '01-02-PLAN.md'), '---\nphase: 1\n---\n', 'utf-8');
await writeFile(join(phaseDir, '01-01-SUMMARY.md'), '# Summary', 'utf-8');
await writeFile(join(phaseDir, '01-02-SUMMARY.md'), '# Summary', 'utf-8');
const { data } = await checkCompletion(['phase', '1'], projectDir);
const d = data as Record<string, unknown>;
expect(d.complete).toBe(true);
expect(d.plans_total).toBe(2);
expect(d.plans_with_summaries).toBe(2);
expect((d.missing_summaries as string[]).length).toBe(0);
});
it('returns complete false when not all plans have summaries', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '02-core');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '02-01-PLAN.md'), '---\nphase: 2\n---\n', 'utf-8');
await writeFile(join(phaseDir, '02-02-PLAN.md'), '---\nphase: 2\n---\n', 'utf-8');
await writeFile(join(phaseDir, '02-01-SUMMARY.md'), '# Summary', 'utf-8');
const { data } = await checkCompletion(['phase', '2'], projectDir);
const d = data as Record<string, unknown>;
expect(d.complete).toBe(false);
expect(d.plans_total).toBe(2);
expect(d.plans_with_summaries).toBe(1);
expect((d.missing_summaries as string[]).length).toBe(1);
});
it('includes debt rollup fields', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '03-api');
await mkdir(phaseDir, { recursive: true });
const { data } = await checkCompletion(['phase', '3'], projectDir);
const d = data as Record<string, unknown>;
const debt = d.debt as Record<string, unknown>;
expect(debt).toBeDefined();
expect(typeof debt.uat_gaps).toBe('number');
expect(typeof debt.verification_failures).toBe('number');
expect(typeof debt.human_needed).toBe('boolean');
});
it('returns verification_status from VERIFICATION.md when present', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '04-ui');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '04-01-PLAN.md'), '---\nphase: 4\n---\n', 'utf-8');
await writeFile(join(phaseDir, '04-01-SUMMARY.md'), '# Summary', 'utf-8');
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
'---\nstatus: passed\n---\n\n| ID | Description | Status |\n|---|---|---|\n| T-01 | Auth works | PASS |',
'utf-8',
);
const { data } = await checkCompletion(['phase', '4'], projectDir);
const d = data as Record<string, unknown>;
expect(d.verification_status).not.toBeNull();
});
});
describe('milestone scope', () => {
it('returns milestone completion fields', async () => {
await writeFile(
join(projectDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## Phase 01: Foundation\n\n## Phase 02: Core\n',
'utf-8',
);
const { data } = await checkCompletion(['milestone', 'v1.0'], projectDir);
const d = data as Record<string, unknown>;
expect(typeof d.complete).toBe('boolean');
expect(typeof d.phase_count).toBe('number');
expect(typeof d.phases_complete).toBe('number');
expect(Array.isArray(d.phases_incomplete)).toBe(true);
});
});
});

View File

@@ -0,0 +1,182 @@
/**
* Phase or milestone completion rollup (`check.completion`).
*
* Replaces repeated PLAN/SUMMARY counting and verification checks in
* `transition.md`, `complete-milestone.md`, `execute-phase.md`.
* See `.planning/research/decision-routing-audit.md` §3.7.
*/
import { existsSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { normalizePhaseName, planningPaths } from './helpers.js';
import { findPhase } from './phase.js';
import { roadmapAnalyze } from './roadmap.js';
import type { QueryHandler } from './utils.js';
const VALID_SCOPES = new Set(['phase', 'milestone']);
// ─── Helpers ───────────────────────────────────────────────────────────────
function countFailLines(content: string): number {
return (content.match(/\|\s*FAIL\s*\|/gi) || []).length;
}
async function readFileSafe(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8');
} catch {
return null;
}
}
function deriveVerificationStatus(content: string | null): string | null {
if (!content) return null;
const failCount = countFailLines(content);
if (failCount > 0) return 'fail';
const passMatch = content.match(/\|\s*PASS\s*\|/gi);
if (passMatch && passMatch.length > 0) return 'pass';
// Frontmatter status field fallback
const statusMatch = content.match(/^status:\s*(\S+)/im);
if (statusMatch) return statusMatch[1].toLowerCase();
return 'missing';
}
function deriveUatStatus(content: string | null): string | null {
if (!content) return null;
const failCount = (content.match(/\|\s*FAIL\s*\|/gi) || []).length;
if (failCount > 0) return 'fail';
return 'pass';
}
// ─── Phase scope ───────────────────────────────────────────────────────────
async function checkPhaseCompletion(phaseArg: string, projectDir: string): Promise<Record<string, unknown>> {
const phaseRes = await findPhase([phaseArg], projectDir);
const pdata = phaseRes.data as Record<string, unknown>;
const found = Boolean(pdata.found);
const plans = (pdata.plans as string[] | undefined) ?? [];
const summaries = (pdata.summaries as string[] | undefined) ?? [];
const plans_total = plans.length;
// Derive which plans are missing a summary
const summaryIds = new Set(
summaries
.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
.filter(Boolean),
);
const plans_with_summaries = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
return summaryIds.has(planId);
}).length;
const missing_summaries = plans
.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
return !summaryIds.has(planId);
});
// Read VERIFICATION.md and UAT.md if phase was found
let verificationContent: string | null = null;
let uatContent: string | null = null;
if (found && pdata.directory) {
const phaseDirFull = join(projectDir, pdata.directory as string);
if (existsSync(phaseDirFull)) {
try {
const files = (await readdir(phaseDirFull)).sort((a, b) => a.localeCompare(b));
const verFile = files.includes('VERIFICATION.md')
? 'VERIFICATION.md'
: files.find(f => f.endsWith('-VERIFICATION.md'));
const uatFile = files.includes('UAT.md') ? 'UAT.md' : files.find(f => f.endsWith('-UAT.md'));
if (verFile) verificationContent = await readFileSafe(join(phaseDirFull, verFile));
if (uatFile) uatContent = await readFileSafe(join(phaseDirFull, uatFile));
} catch {
// Phase dir unreadable — treat as no files
}
}
}
const verification_status = deriveVerificationStatus(verificationContent);
const uat_status = deriveUatStatus(uatContent);
const uat_gaps = uatContent ? countFailLines(uatContent) : 0;
const verification_failures = verificationContent ? countFailLines(verificationContent) : 0;
const complete =
plans_total > 0 &&
missing_summaries.length === 0 &&
verification_status !== 'fail';
return {
complete,
plans_total,
plans_with_summaries,
missing_summaries,
verification_status,
uat_status,
debt: {
uat_gaps,
verification_failures,
human_needed: false,
},
};
}
// ─── Milestone scope ───────────────────────────────────────────────────────
async function checkMilestoneCompletion(projectDir: string): Promise<Record<string, unknown>> {
const analysis = await roadmapAnalyze([], projectDir);
const adata = analysis.data as { phases?: Array<Record<string, unknown>> };
const phases = adata.phases ?? [];
const phase_count = phases.length;
const completePhases = phases.filter(
p => p.roadmap_complete === true || p.disk_status === 'complete',
);
const phases_complete = completePhases.length;
const phases_incomplete = phases
.filter(p => p.roadmap_complete !== true && p.disk_status !== 'complete')
.map(p => String(normalizePhaseName(String(p.number))));
const blockers: string[] = [];
const complete = phase_count > 0 && phases_complete === phase_count;
return {
complete,
phase_count,
phases_complete,
phases_incomplete,
blockers,
};
}
// ─── Handler ───────────────────────────────────────────────────────────────
export const checkCompletion: QueryHandler = async (args, projectDir) => {
const scope = args[0];
if (!scope) {
throw new GSDError('scope required for check completion (phase|milestone)', ErrorClassification.Validation);
}
if (!VALID_SCOPES.has(scope)) {
throw new GSDError(
`invalid scope "${scope}" — must be "phase" or "milestone"`,
ErrorClassification.Validation,
);
}
if (scope === 'phase') {
const phaseNum = args[1];
if (!phaseNum) {
throw new GSDError('phase number required for check completion phase', ErrorClassification.Validation);
}
const result = await checkPhaseCompletion(phaseNum, projectDir);
return { data: result };
}
// milestone scope
const result = await checkMilestoneCompletion(projectDir);
return { data: result };
};

View File

@@ -0,0 +1,103 @@
/**
* Unit tests for `check.gates` (decision-routing audit §3.2).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkGates } from './check-gates.js';
describe('checkGates', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-check-gates-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning', 'phases'), { recursive: true });
// Write a clean STATE.md
await writeFile(
join(projectDir, '.planning', 'STATE.md'),
'---\nstatus: active\n---\n\n# Project State\n\nStatus: active\n',
'utf-8',
);
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('throws when workflow name arg is missing', async () => {
await expect(checkGates([], projectDir)).rejects.toThrow();
});
it('returns passed true when no blockers exist', async () => {
const { data } = await checkGates(['execute-phase'], projectDir);
const d = data as Record<string, unknown>;
expect(d.passed).toBe(true);
expect(d.blockers).toEqual([]);
});
it('returns blocker when .continue-here.md is present in root', async () => {
await writeFile(join(projectDir, '.continue-here.md'), '# Continue here', 'utf-8');
const { data } = await checkGates(['execute-phase'], projectDir);
const d = data as Record<string, unknown>;
expect(d.passed).toBe(false);
const blockers = d.blockers as Array<Record<string, unknown>>;
expect(blockers.length).toBeGreaterThan(0);
expect(blockers[0].gate).toBe('continue-here');
expect(blockers[0].severity).toBe('blocking');
});
it('returns blocker when STATE.md has status: failed', async () => {
await writeFile(
join(projectDir, '.planning', 'STATE.md'),
'---\nstatus: failed\n---\n\n# Project State\n',
'utf-8',
);
const { data } = await checkGates(['execute-phase'], projectDir);
const d = data as Record<string, unknown>;
expect(d.passed).toBe(false);
const blockers = d.blockers as Array<Record<string, unknown>>;
const stateBlocker = blockers.find(b => b.gate === 'state-error');
expect(stateBlocker).toBeDefined();
});
it('returns blocker when STATE.md has status: error', async () => {
await writeFile(
join(projectDir, '.planning', 'STATE.md'),
'---\nstatus: error\n---\n\n# Project State\n',
'utf-8',
);
const { data } = await checkGates(['execute-phase'], projectDir);
const d = data as Record<string, unknown>;
expect(d.passed).toBe(false);
const blockers = d.blockers as Array<Record<string, unknown>>;
const stateBlocker = blockers.find(b => b.gate === 'state-error');
expect(stateBlocker).toBeDefined();
});
it('includes warnings shape in result', async () => {
const { data } = await checkGates(['execute-phase'], projectDir);
const d = data as Record<string, unknown>;
expect(Array.isArray(d.warnings)).toBe(true);
});
it('returns verification-debt warning when phase VERIFICATION.md has FAIL rows', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '01-foundation');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
'| T-01 | Auth works | FAIL |\n| T-02 | User model | PASS |\n',
'utf-8',
);
const { data } = await checkGates(['execute-phase', '--phase', '1'], projectDir);
const d = data as Record<string, unknown>;
const warnings = d.warnings as Array<Record<string, unknown>>;
const debtWarning = warnings.find(w => w.gate === 'verification-debt');
expect(debtWarning).toBeDefined();
});
});

View File

@@ -0,0 +1,112 @@
/**
* Safety gate consolidation (`check.gates`).
*
* Checks blocking conditions before proceeding with a workflow — replaces
* per-workflow gate logic in `next.md`, `execute-phase.md`, `discuss-phase.md`.
* See `.planning/research/decision-routing-audit.md` §3.2.
*/
import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { normalizePhaseName, planningPaths } from './helpers.js';
import { findPhase } from './phase.js';
import type { QueryHandler } from './utils.js';
interface Blocker {
gate: string;
file: string;
severity: 'blocking';
anti_patterns: string[];
}
interface Warning {
gate: string;
phase: string;
items: string[];
message: string;
}
async function readFileSafe(filePath: string): Promise<string | null> {
try {
return await readFile(filePath, 'utf-8');
} catch {
return null;
}
}
export const checkGates: QueryHandler = async (args, projectDir) => {
const workflow = args[0];
if (!workflow) {
throw new GSDError('workflow name required for check gates', ErrorClassification.Validation);
}
// Parse optional --phase flag
let phaseNum: string | null = null;
const phaseIdx = args.indexOf('--phase');
if (phaseIdx !== -1 && args[phaseIdx + 1]) {
phaseNum = args[phaseIdx + 1];
}
const blockers: Blocker[] = [];
const warnings: Warning[] = [];
const paths = planningPaths(projectDir);
// Gate 1: .continue-here.md in project root
const continueHerePath = join(projectDir, '.continue-here.md');
if (existsSync(continueHerePath)) {
blockers.push({
gate: 'continue-here',
file: '.continue-here.md',
severity: 'blocking',
anti_patterns: ['continue-here.md present — another session may be in progress'],
});
}
// Gate 2: STATE.md error/failed status
const stateContent = await readFileSafe(paths.state);
if (stateContent) {
const hasErrorStatus =
/^status:\s*(error|failed)/im.test(stateContent) ||
/##\s*Error/i.test(stateContent);
if (hasErrorStatus) {
blockers.push({
gate: 'state-error',
file: '.planning/STATE.md',
severity: 'blocking',
anti_patterns: ['STATE.md status is error/failed'],
});
}
}
// Gate 3: Verification debt — check VERIFICATION.md in phase dir if phase provided
if (phaseNum) {
const phaseRes = await findPhase([phaseNum], projectDir);
const pdata = phaseRes.data as Record<string, unknown>;
if (pdata.found && pdata.directory) {
const phaseDirFull = join(projectDir, pdata.directory as string);
const verPath = join(phaseDirFull, 'VERIFICATION.md');
const verContent = await readFileSafe(verPath);
if (verContent) {
const failLines = verContent.match(/\|\s*FAIL\s*\|[^\n]*/gi) || [];
if (failLines.length > 0) {
warnings.push({
gate: 'verification-debt',
phase: normalizePhaseName(phaseNum),
items: failLines.map(l => `FAIL: ${l.trim()}`),
message: `${failLines.length} FAIL row(s) in VERIFICATION.md`,
});
}
}
}
}
return {
data: {
passed: blockers.length === 0,
blockers,
warnings,
},
};
};

View File

@@ -0,0 +1,77 @@
/**
* Unit tests for `check.ship-ready` (decision-routing audit §3.9).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkShipReady } from './check-ship-ready.js';
describe('checkShipReady', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-check-ship-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning', 'phases'), { recursive: true });
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('throws when phase arg is missing', async () => {
await expect(checkShipReady([], projectDir)).rejects.toThrow();
});
it('returns all expected shape keys', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const { data } = await checkShipReady(['1'], projectDir);
const d = data as Record<string, unknown>;
expect(typeof d.ready).toBe('boolean');
expect(typeof d.verification_passed).toBe('boolean');
expect(typeof d.clean_tree).toBe('boolean');
expect(typeof d.on_feature_branch).toBe('boolean');
expect(typeof d.remote_configured).toBe('boolean');
expect(typeof d.gh_available).toBe('boolean');
expect(typeof d.gh_authenticated).toBe('boolean');
expect(Array.isArray(d.blockers)).toBe(true);
});
it('returns current_branch and base_branch fields', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const { data } = await checkShipReady(['1'], projectDir);
const d = data as Record<string, unknown>;
// current_branch is either a string (when in a git repo) or null (temp dir not a repo)
expect(d.current_branch === null || typeof d.current_branch === 'string').toBe(true);
expect(d.base_branch === null || typeof d.base_branch === 'string').toBe(true);
});
it('never throws — returns false fields on git errors', async () => {
// Use a directory that is not a git repo
const nonGitDir = join(tmpdir(), `gsd-non-git-${Date.now()}`);
await mkdir(join(nonGitDir, '.planning', 'phases', '01-test'), { recursive: true });
try {
const { data } = await checkShipReady(['1'], nonGitDir);
const d = data as Record<string, unknown>;
// All git-based fields should be false/null when not a git repo
expect(d.ready).toBe(false);
} finally {
await rm(nonGitDir, { recursive: true, force: true });
}
});
it('gh_authenticated is always false (advisory — no network call)', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const { data } = await checkShipReady(['1'], projectDir);
const d = data as Record<string, unknown>;
// Per spec: gh_authenticated is advisory — skip actual auth check to avoid slow network call
expect(d.gh_authenticated).toBe(false);
});
});

View File

@@ -0,0 +1,103 @@
/**
* Ship preflight checks (`check.ship-ready`).
*
* Consolidates git/gh checks from `ship.md` into a single structured query.
* All subprocess calls are wrapped in try/catch — never throws on git/gh failures.
* See `.planning/research/decision-routing-audit.md` §3.9.
*/
import { execSync } from 'node:child_process';
import { GSDError, ErrorClassification } from '../errors.js';
import { normalizePhaseName } from './helpers.js';
import { checkVerificationStatus } from './check-verification-status.js';
import type { QueryHandler } from './utils.js';
function runSyncSafe(cmd: string, cwd: string): string | null {
try {
return execSync(cmd, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
} catch {
return null;
}
}
function boolSyncSafe(cmd: string, cwd: string): boolean {
return runSyncSafe(cmd, cwd) !== null;
}
export const checkShipReady: QueryHandler = async (args, projectDir) => {
const raw = args[0];
if (!raw) {
throw new GSDError('phase number required for check ship-ready', ErrorClassification.Validation);
}
normalizePhaseName(raw); // validate format
const blockers: string[] = [];
// git checks — all wrapped in try/catch via helpers
const porcelain = runSyncSafe('git status --porcelain', projectDir);
const clean_tree = porcelain !== null && porcelain === '';
const current_branch = runSyncSafe('git rev-parse --abbrev-ref HEAD', projectDir);
const on_feature_branch =
current_branch !== null &&
current_branch !== 'main' &&
current_branch !== 'master';
// Determine base branch
let base_branch: string | null = null;
if (current_branch) {
const mergeRef = runSyncSafe(`git config --get branch.${current_branch}.merge`, projectDir);
if (mergeRef) {
base_branch = mergeRef.replace('refs/heads/', '');
} else {
// Fallback: check if 'main' branch exists, else 'master'
const mainExists = boolSyncSafe('git rev-parse --verify main', projectDir);
base_branch = mainExists ? 'main' : 'master';
}
}
const remoteOut = runSyncSafe('git remote', projectDir);
const remote_configured = remoteOut !== null && remoteOut.trim().length > 0;
// gh availability
const gh_available =
boolSyncSafe('gh --version', projectDir) ||
boolSyncSafe('which gh', projectDir);
// gh_authenticated: advisory — skip actual auth check to avoid slow network call
const gh_authenticated = false;
// Verification status
let verification_passed = false;
try {
const verRes = await checkVerificationStatus([raw], projectDir);
const vdata = verRes.data as Record<string, unknown>;
verification_passed = vdata.status !== 'fail';
} catch {
verification_passed = false;
}
// Collect blockers
if (!verification_passed) blockers.push('verification status is fail or missing');
if (!clean_tree) blockers.push('working tree is not clean (uncommitted changes)');
if (!on_feature_branch) blockers.push('not on a feature branch (currently on main/master or unknown)');
if (!remote_configured) blockers.push('no git remote configured');
const ready = verification_passed && clean_tree && on_feature_branch && remote_configured;
return {
data: {
ready,
verification_passed,
clean_tree,
on_feature_branch,
current_branch,
base_branch,
remote_configured,
gh_available,
gh_authenticated,
blockers,
},
};
};

View File

@@ -0,0 +1,143 @@
/**
* Unit tests for `check.verification-status` (decision-routing audit §3.8).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkVerificationStatus } from './check-verification-status.js';
describe('checkVerificationStatus', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-check-ver-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning', 'phases'), { recursive: true });
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('throws when phase arg is missing', async () => {
await expect(checkVerificationStatus([], projectDir)).rejects.toThrow();
});
it('returns status missing when VERIFICATION.md does not exist', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '01-foundation'), { recursive: true });
const { data } = await checkVerificationStatus(['1'], projectDir);
const d = data as Record<string, unknown>;
expect(d.status).toBe('missing');
expect(d.score).toBeNull();
expect(d.gaps).toEqual([]);
expect(d.human_items).toEqual([]);
expect(d.deferred).toEqual([]);
});
it('returns status pass when all rows are PASS', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '02-core');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
[
'---',
'status: passed',
'---',
'',
'| ID | Description | Status | Notes |',
'|---|---|---|---|',
'| T-01 | Auth works | PASS | |',
'| T-02 | User model | PASS | |',
'| T-03 | API endpoint | PASS | |',
].join('\n'),
'utf-8',
);
const { data } = await checkVerificationStatus(['2'], projectDir);
const d = data as Record<string, unknown>;
expect(d.status).toBe('pass');
expect(d.gaps).toEqual([]);
});
it('returns status fail with gaps when FAIL rows present', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '03-api');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
[
'| ID | Description | Status | Notes |',
'|---|---|---|---|',
'| T-01 | Auth works | PASS | |',
'| T-02 | Error handling | FAIL | Missing 500 handler |',
'| T-03 | API endpoint | PASS | |',
].join('\n'),
'utf-8',
);
const { data } = await checkVerificationStatus(['3'], projectDir);
const d = data as Record<string, unknown>;
expect(d.status).toBe('fail');
expect((d.gaps as string[]).length).toBeGreaterThan(0);
});
it('returns score as fraction string', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '04-ui');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
[
'| ID | Description | Status | Notes |',
'|---|---|---|---|',
'| T-01 | Feature A | PASS | |',
'| T-02 | Feature B | PASS | |',
'| T-03 | Feature C | FAIL | |',
'| T-04 | Feature D | PASS | |',
].join('\n'),
'utf-8',
);
const { data } = await checkVerificationStatus(['4'], projectDir);
const d = data as Record<string, unknown>;
expect(d.score).toBe('3/4');
});
it('collects human_items when type column contains human', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '05-test');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
[
'| ID | Description | Type | Status | Notes |',
'|---|---|---|---|---|',
'| T-01 | API returns 200 | truth | PASS | |',
'| T-02 | UI looks correct | human | PASS | Manual check |',
].join('\n'),
'utf-8',
);
const { data } = await checkVerificationStatus(['5'], projectDir);
const d = data as Record<string, unknown>;
expect((d.human_items as string[]).length).toBeGreaterThan(0);
});
it('collects deferred items when notes column contains deferred', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '06-misc');
await mkdir(phaseDir, { recursive: true });
await writeFile(
join(phaseDir, 'VERIFICATION.md'),
[
'| ID | Description | Status | Notes |',
'|---|---|---|---|',
'| T-01 | Feature A | PASS | |',
'| T-02 | Perf test | PASS | deferred to phase 8 |',
].join('\n'),
'utf-8',
);
const { data } = await checkVerificationStatus(['6'], projectDir);
const d = data as Record<string, unknown>;
expect((d.deferred as string[]).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,160 @@
/**
* VERIFICATION.md parser (`check.verification-status`).
*
* Replaces VERIFICATION.md grep/parse branches in `execute-phase.md`,
* `autonomous.md`, `progress.md` with a structured query.
* See `.planning/research/decision-routing-audit.md` §3.8.
*/
import { readFile } from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { normalizePhaseName } from './helpers.js';
import { findPhase } from './phase.js';
import type { QueryHandler } from './utils.js';
const NOT_FOUND_RESULT = {
status: 'missing' as const,
score: null,
gaps: [] as string[],
human_items: [] as string[],
deferred: [] as string[],
};
// ─── Markdown table parser ─────────────────────────────────────────────────
interface TableRow {
cells: string[];
raw: string;
}
function parseTableRows(content: string): TableRow[] {
return content
.split('\n')
.filter(line => {
const trimmed = line.trim();
return trimmed.startsWith('|') && trimmed.endsWith('|') && !/^\|[-: |]+\|$/.test(trimmed);
})
.map(line => ({
cells: line
.split('|')
.slice(1, -1)
.map(c => c.trim()),
raw: line.trim(),
}));
}
/**
* Find the column index that matches a header predicate, falling back to -1.
*/
function findColIndex(headerRow: TableRow, predicate: (cell: string) => boolean): number {
return headerRow.cells.findIndex(c => predicate(c));
}
export const checkVerificationStatus: QueryHandler = async (args, projectDir) => {
const raw = args[0];
if (!raw) {
throw new GSDError('phase number required for check verification-status', ErrorClassification.Validation);
}
normalizePhaseName(raw); // validate format
const phaseRes = await findPhase([raw], projectDir);
const pdata = phaseRes.data as Record<string, unknown>;
if (!pdata.found || !pdata.directory) {
return { data: NOT_FOUND_RESULT };
}
const phaseDirFull = join(projectDir, pdata.directory as string);
// Locate VERIFICATION.md — may be prefixed
let verPath: string | null = null;
if (existsSync(phaseDirFull)) {
try {
const files = readdirSync(phaseDirFull) as string[];
const verFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
if (verFile) verPath = join(phaseDirFull, verFile);
} catch {
return { data: NOT_FOUND_RESULT };
}
}
if (!verPath) return { data: NOT_FOUND_RESULT };
let content: string;
try {
content = await readFile(verPath, 'utf-8');
} catch {
return { data: NOT_FOUND_RESULT };
}
const rows = parseTableRows(content);
if (rows.length === 0) {
// No table rows — check frontmatter status field only
const statusMatch = content.match(/^status:\s*(\S+)/im);
const status = statusMatch ? statusMatch[1].toLowerCase() : 'missing';
return { data: { ...NOT_FOUND_RESULT, status: status === 'missing' ? 'missing' : status } };
}
// Detect header row — heuristic: first row typically has column names
const firstRow = rows[0];
const isHeader = firstRow.cells.some(c =>
/^(id|status|description|type|notes)$/i.test(c),
);
const dataRows = isHeader ? rows.slice(1) : rows;
const headerRow = isHeader ? firstRow : null;
// Determine column indices
let statusCol = headerRow ? findColIndex(headerRow, c => /^status$/i.test(c)) : -1;
let typeCol = headerRow ? findColIndex(headerRow, c => /^type$/i.test(c)) : -1;
let notesCol = headerRow ? findColIndex(headerRow, c => /^notes$/i.test(c)) : -1;
let descCol = headerRow ? findColIndex(headerRow, c => /^description$/i.test(c)) : -1;
// Fallbacks for tables without headers or unusual column orders
if (statusCol === -1) statusCol = 2; // typical: | ID | Description | Status |
if (descCol === -1) descCol = 1;
let passCount = 0;
let totalCount = 0;
const gaps: string[] = [];
const human_items: string[] = [];
const deferred: string[] = [];
for (const row of dataRows) {
const statusVal = (row.cells[statusCol] ?? '').toUpperCase();
const typeVal = typeCol >= 0 ? (row.cells[typeCol] ?? '').toLowerCase() : '';
const notesVal = notesCol >= 0 ? (row.cells[notesCol] ?? '').toLowerCase() : '';
const descVal = row.cells[descCol] ?? row.cells[0] ?? row.raw;
if (statusVal === 'PASS' || statusVal === 'FAIL') totalCount++;
if (statusVal === 'PASS') passCount++;
if (statusVal === 'FAIL') gaps.push(descVal);
if (typeVal.includes('human')) human_items.push(descVal);
if (notesVal.includes('deferred')) deferred.push(descVal);
}
const score = totalCount > 0 ? `${passCount}/${totalCount}` : null;
let status: string;
if (gaps.length > 0) {
status = 'fail';
} else if (passCount === totalCount && totalCount > 0) {
status = 'pass';
} else {
// Check frontmatter status as tiebreaker
const statusMatch = content.match(/^status:\s*(\S+)/im);
status = statusMatch ? statusMatch[1].toLowerCase() : 'partial';
}
return {
data: {
status,
score,
gaps,
human_items,
deferred,
},
};
};

View File

@@ -131,10 +131,10 @@ describe('commit', () => {
// Stage config.json first then commit it so .planning/ has no unstaged changes
execSync('git add .planning/config.json', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "init"', { cwd: tmpDir, stdio: 'pipe' });
// Now commit with specific nonexistent file
const result = await commit(['test msg', 'nonexistent-file.txt'], tmpDir);
// Now commit with specific nonexistent file (--files separates message from paths, matching CJS argv)
const result = await commit(['test msg', '--files', 'nonexistent-file.txt'], tmpDir);
expect((result.data as { committed: boolean }).committed).toBe(false);
expect((result.data as { reason: string }).reason).toContain('nothing');
expect((result.data as { reason: string }).reason).toContain('nonexistent-file.txt');
});
it('commits specific files when provided', async () => {
@@ -145,7 +145,7 @@ describe('commit', () => {
);
await writeFile(join(tmpDir, '.planning', 'STATE.md'), '# State\n');
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n');
const result = await commit(['docs: state only', '.planning/STATE.md'], tmpDir);
const result = await commit(['docs: state only', '--files', '.planning/STATE.md'], tmpDir);
expect((result.data as { committed: boolean }).committed).toBe(true);
// Verify only STATE.md was committed

View File

@@ -102,10 +102,14 @@ export const commit: QueryHandler = async (args, projectDir) => {
const hasForce = allArgs.includes('--force');
const hasAmend = allArgs.includes('--amend');
const hasNoVerify = allArgs.includes('--no-verify');
const nonFlagArgs = allArgs.filter(a => !a.startsWith('--'));
const message = nonFlagArgs[0];
const filePaths = nonFlagArgs.slice(1);
const filesIndex = allArgs.indexOf('--files');
const endIndex = filesIndex !== -1 ? filesIndex : allArgs.length;
// CodeRabbit #6: don't strip arbitrary `--foo` tokens from commit messages
const knownFlags = new Set(['--force', '--amend', '--no-verify']);
const messageArgs = allArgs.slice(0, endIndex).filter(a => !knownFlags.has(a));
const message = messageArgs.join(' ') || undefined;
const filePaths =
filesIndex !== -1 ? allArgs.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
if (!message && !hasAmend) {
return { data: { committed: false, reason: 'commit message required' } };
@@ -131,7 +135,10 @@ export const commit: QueryHandler = async (args, projectDir) => {
// Stage files
const filesToStage = filePaths.length > 0 ? filePaths : ['.planning/'];
for (const file of filesToStage) {
execGit(projectDir, ['add', file]);
const addResult = execGit(projectDir, ['add', file]);
if (addResult.exitCode !== 0) {
return { data: { committed: false, reason: addResult.stderr || `failed to stage ${file}`, exitCode: addResult.exitCode } };
}
}
// Check if anything is staged
@@ -142,9 +149,9 @@ export const commit: QueryHandler = async (args, projectDir) => {
}
// Build commit command
const commitArgs = hasAmend
const commitArgs: string[] = hasAmend
? ['commit', '--amend', '--no-edit']
: ['commit', '-m', sanitized];
: ['commit', '-m', sanitized ?? ''];
if (hasNoVerify) commitArgs.push('--no-verify');
const commitResult = execGit(projectDir, commitArgs);
@@ -197,6 +204,7 @@ export const checkCommit: QueryHandler = async (_args, projectDir) => {
if (planningFiles.length > 0) {
return {
data: {
allowed: false,
can_commit: false,
reason: `commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged`,
commit_docs: false,
@@ -208,6 +216,7 @@ export const checkCommit: QueryHandler = async (_args, projectDir) => {
return {
data: {
allowed: true,
can_commit: true,
reason: commitDocs ? 'commit_docs_enabled' : 'no_planning_files_staged',
commit_docs: commitDocs,
@@ -219,14 +228,36 @@ export const checkCommit: QueryHandler = async (_args, projectDir) => {
// ─── commitToSubrepo ─────────────────────────────────────────────────────
export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
const message = args[0];
const filesIdx = args.indexOf('--files');
const files = filesIdx >= 0 ? args.slice(filesIdx + 1) : [];
const endIdx = filesIdx >= 0 ? filesIdx : args.length;
const knownFlags = new Set(['--force', '--amend', '--no-verify']);
const messageArgs = args.slice(0, endIdx).filter(a => !knownFlags.has(a));
const message = messageArgs.join(' ') || undefined;
const files = filesIdx >= 0 ? args.slice(filesIdx + 1).filter(a => !a.startsWith('--')) : [];
if (!message) {
return { data: { committed: false, reason: 'commit message required' } };
}
const paths = planningPaths(projectDir);
let config: Record<string, unknown> = {};
try {
const raw = await readFile(paths.config, 'utf-8');
config = JSON.parse(raw) as Record<string, unknown>;
} catch {
/* no config */
}
const subRepos = config.sub_repos as string[] | undefined;
if (!subRepos || subRepos.length === 0) {
return {
data: { committed: false, reason: 'no sub_repos configured in .planning/config.json' },
};
}
if (files.length === 0) {
return { data: { committed: false, reason: '--files required for commit-to-subrepo' } };
}
const sanitized = sanitizeCommitMessage(message);
if (!sanitized && message) {
return { data: { committed: false, reason: 'commit message empty after sanitization' } };
@@ -245,7 +276,10 @@ export const commitToSubrepo: QueryHandler = async (args, projectDir) => {
}
const fileArgs = files.length > 0 ? files : ['.'];
spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe' });
const addResult = spawnSync('git', ['-C', projectDir, 'add', ...fileArgs], { stdio: 'pipe', encoding: 'utf-8' });
if (addResult.status !== 0) {
return { data: { committed: false, reason: addResult.stderr || 'git add failed' } };
}
const commitResult = spawnSync(
'git', ['-C', projectDir, 'commit', '-m', sanitized],

View File

@@ -0,0 +1,89 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect } from 'vitest';
import { checkConfigGates } from './config-gates.js';
function cleanupTempDir(dir: string): void {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
}
describe('checkConfigGates', () => {
it('returns merged workflow defaults when config is absent', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-cg-'));
try {
await mkdir(join(dir, '.planning'), { recursive: true });
const { data } = await checkConfigGates([], dir);
expect(data).toMatchObject({
workflow: null,
research_enabled: true,
plan_checker_enabled: true,
nyquist_validation: true,
ui_phase: true,
auto_advance: false,
auto_chain_active: false,
code_review: true,
context_window: 200000,
});
} finally {
cleanupTempDir(dir);
}
});
it('treats string "false" as false and honors plan_checker alias', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-cg-'));
try {
await mkdir(join(dir, '.planning'), { recursive: true });
await writeFile(
join(dir, '.planning', 'config.json'),
JSON.stringify({
workflow: {
nyquist_validation: 'false',
plan_checker: false,
},
}),
'utf-8',
);
const { data } = await checkConfigGates([], dir);
expect(data.nyquist_validation).toBe(false);
expect(data.plan_checker_enabled).toBe(false);
expect(data.plan_check).toBe(false);
} finally {
cleanupTempDir(dir);
}
});
it('reflects workflow overrides from config.json', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-cg-'));
try {
await mkdir(join(dir, '.planning'), { recursive: true });
await writeFile(
join(dir, '.planning', 'config.json'),
JSON.stringify({
workflow: {
research: false,
auto_advance: true,
_auto_chain_active: true,
},
context_window: 100000,
}),
'utf-8',
);
const { data } = await checkConfigGates(['plan-phase'], dir);
expect(data).toMatchObject({
workflow: 'plan-phase',
research_enabled: false,
auto_advance: true,
auto_chain_active: true,
context_window: 100000,
});
} finally {
cleanupTempDir(dir);
}
});
});

View File

@@ -0,0 +1,69 @@
/**
* Batch workflow config for orchestration decisions (`check.config-gates`).
*
* Replaces many repeated `config-get workflow.*` calls with one JSON object.
* See `.planning/research/decision-routing-audit.md` §3.3.
*/
import { CONFIG_DEFAULTS, loadConfig } from '../config.js';
import type { QueryHandler } from './utils.js';
/** Treat stringly YAML booleans safely (`Boolean('false')` is true — avoid that). */
function workflowBool(v: unknown, defaultVal: boolean): boolean {
if (v === undefined || v === null) return defaultVal;
if (typeof v === 'boolean') return v;
if (typeof v === 'string') {
const s = v.toLowerCase().trim();
if (s === 'false' || s === '0' || s === 'no' || s === 'off') return false;
if (s === 'true' || s === '1' || s === 'yes' || s === 'on') return true;
}
return Boolean(v);
}
/**
* Merge workflow defaults with project config, then expose stable keys for workflows.
*/
export const checkConfigGates: QueryHandler = async (args, projectDir) => {
const config = await loadConfig(projectDir);
const wf: Record<string, unknown> = {
...CONFIG_DEFAULTS.workflow,
...(config.workflow as unknown as Record<string, unknown>),
};
const root = config as Record<string, unknown>;
const contextWindow =
typeof root.context_window === 'number' ? root.context_window : 200000;
/** Prefer explicit `plan_checker` when present (alias); else `plan_check` (defaults include only the latter). */
const w = wf as Record<string, unknown>;
const planCheckFlag = w.plan_checker !== undefined ? w.plan_checker : w.plan_check;
const data: Record<string, unknown> = {
workflow: args[0] ?? null,
research_enabled: workflowBool(wf.research, true),
plan_checker_enabled: workflowBool(planCheckFlag, true),
nyquist_validation: workflowBool(wf.nyquist_validation, true),
security_enforcement: workflowBool(wf.security_enforcement, true),
security_asvs_level: wf.security_asvs_level ?? 1,
security_block_on: wf.security_block_on ?? 'high',
ui_phase: workflowBool(wf.ui_phase, true),
ui_safety_gate: workflowBool(wf.ui_safety_gate, true),
ui_review: workflowBool(wf.ui_review, true),
text_mode: workflowBool(wf.text_mode, false),
auto_advance: workflowBool(wf.auto_advance, false),
auto_chain_active: workflowBool(wf._auto_chain_active, false),
code_review: workflowBool(wf.code_review, true),
code_review_depth: wf.code_review_depth ?? 'standard',
context_window: contextWindow,
discuss_mode: String(wf.discuss_mode ?? 'discuss'),
use_worktrees: workflowBool(wf.use_worktrees, true),
skip_discuss: workflowBool(wf.skip_discuss, false),
max_discuss_passes: wf.max_discuss_passes ?? 3,
node_repair: workflowBool(wf.node_repair, true),
research_before_questions: workflowBool(wf.research_before_questions, false),
verifier: workflowBool(wf.verifier, true),
plan_check: workflowBool(planCheckFlag, true),
subagent_timeout: wf.subagent_timeout ?? CONFIG_DEFAULTS.workflow.subagent_timeout,
};
return { data };
};

View File

@@ -158,8 +158,8 @@ describe('configSet lock protection (D6)', () => {
configSet(['commit_docs', 'true'], tmpDir),
configSet(['model_profile', 'quality'], tmpDir),
]);
expect((r1.data as { set: boolean }).set).toBe(true);
expect((r2.data as { set: boolean }).set).toBe(true);
expect((r1.data as { updated: boolean }).updated).toBe(true);
expect((r2.data as { updated: boolean }).updated).toBe(true);
// Both values should be present (no lost updates)
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
@@ -184,7 +184,7 @@ describe('configSet context validation (D8)', () => {
for (const ctx of ['dev', 'research', 'review']) {
await writeFile(join(tmpDir, '.planning', 'config.json'), '{}');
const result = await configSet(['context', ctx], tmpDir);
expect((result.data as { set: boolean }).set).toBe(true);
expect((result.data as { updated: boolean }).updated).toBe(true);
}
});
});
@@ -212,7 +212,12 @@ describe('configSet', () => {
JSON.stringify({ model_profile: 'balanced' }),
);
const result = await configSet(['model_profile', 'quality'], tmpDir);
expect(result.data).toEqual({ set: true, key: 'model_profile', value: 'quality' });
expect(result.data).toEqual({
updated: true,
key: 'model_profile',
value: 'quality',
previousValue: 'balanced',
});
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(raw.model_profile).toBe('quality');
@@ -225,7 +230,11 @@ describe('configSet', () => {
JSON.stringify({ workflow: { research: true } }),
);
const result = await configSet(['workflow.auto_advance', 'true'], tmpDir);
expect(result.data).toEqual({ set: true, key: 'workflow.auto_advance', value: true });
expect(result.data).toEqual({
updated: true,
key: 'workflow.auto_advance',
value: true,
});
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(raw.workflow.auto_advance).toBe(true);
@@ -263,7 +272,7 @@ describe('configSetModelProfile', () => {
JSON.stringify({ model_profile: 'balanced' }),
);
const result = await configSetModelProfile(['quality'], tmpDir);
expect((result.data as { set: boolean }).set).toBe(true);
expect((result.data as { updated: boolean }).updated).toBe(true);
expect((result.data as { profile: string }).profile).toBe('quality');
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
@@ -300,7 +309,7 @@ describe('configNewProject', () => {
const raw = JSON.parse(await readFile(join(tmpDir, '.planning', 'config.json'), 'utf-8'));
expect(raw.model_profile).toBe('balanced');
expect(raw.commit_docs).toBe(false);
expect(raw.commit_docs).toBe(true);
});
it('merges user choices', async () => {

View File

@@ -10,7 +10,7 @@
* import { configSet, configNewProject } from './config-mutation.js';
*
* await configSet(['model_profile', 'quality'], '/project');
* // { data: { set: true, key: 'model_profile', value: 'quality' } }
* // { data: { updated: true, key: 'model_profile', value: 'quality', previousValue: 'balanced' } }
*
* await configNewProject([], '/project');
* // { data: { created: true, path: '.planning/config.json' } }
@@ -22,7 +22,7 @@ import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { MODEL_PROFILES, VALID_PROFILES } from './config-query.js';
import { VALID_PROFILES, getAgentToModelMapForProfile } from './config-query.js';
import { planningPaths } from './helpers.js';
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
import type { QueryHandler } from './utils.js';
@@ -177,6 +177,18 @@ export function parseConfigValue(value: string): unknown {
* @param dotPath - Dot-notation key path (e.g., 'workflow.auto_advance')
* @param value - Value to set
*/
function getValueAtPath(obj: Record<string, unknown>, dotPath: string): unknown {
const keys = dotPath.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
function setConfigValue(obj: Record<string, unknown>, dotPath: string, value: unknown): void {
const keys = dotPath.split('.');
let current: Record<string, unknown> = obj;
@@ -200,7 +212,7 @@ function setConfigValue(obj: Record<string, unknown>, dotPath: string, value: un
*
* @param args - args[0]=key, args[1]=value
* @param projectDir - Project root directory
* @returns QueryResult with { set: true, key, value }
* @returns QueryResult matching gsd-tools `config-set` JSON: `{ updated, key, value, previousValue }`
* @throws GSDError with Validation if key is invalid or args missing
*/
export const configSet: QueryHandler = async (args, projectDir) => {
@@ -233,6 +245,7 @@ export const configSet: QueryHandler = async (args, projectDir) => {
// D6: Lock protection for read-modify-write (match CJS config.cjs:296)
const paths = planningPaths(projectDir);
const lockPath = await acquireStateLock(paths.config);
let previousValue: unknown;
try {
let config: Record<string, unknown> = {};
try {
@@ -242,13 +255,23 @@ export const configSet: QueryHandler = async (args, projectDir) => {
// Start with empty config if file doesn't exist or is malformed
}
previousValue = getValueAtPath(config, keyPath);
setConfigValue(config, keyPath, parsedValue);
await atomicWriteConfig(paths.config, config);
} finally {
await releaseStateLock(lockPath);
}
return { data: { set: true, key: keyPath, value: parsedValue } };
// Match CJS JSON: `JSON.stringify` omits keys whose value is `undefined`
const data: Record<string, unknown> = {
updated: true,
key: keyPath,
value: parsedValue,
};
if (previousValue !== undefined) {
data.previousValue = previousValue;
}
return { data };
};
// ─── configSetModelProfile ────────────────────────────────────────────────
@@ -281,6 +304,7 @@ export const configSetModelProfile: QueryHandler = async (args, projectDir) => {
// D6: Lock protection for read-modify-write
const paths = planningPaths(projectDir);
const lockPath = await acquireStateLock(paths.config);
let previousProfile = 'balanced';
try {
let config: Record<string, unknown> = {};
try {
@@ -290,13 +314,24 @@ export const configSetModelProfile: QueryHandler = async (args, projectDir) => {
// Start with empty config
}
const prev =
typeof config.model_profile === 'string' ? config.model_profile.toLowerCase().trim() : '';
previousProfile = VALID_PROFILES.includes(prev) ? prev : 'balanced';
config.model_profile = normalized;
await atomicWriteConfig(paths.config, config);
} finally {
await releaseStateLock(lockPath);
}
return { data: { set: true, profile: normalized, agents: MODEL_PROFILES } };
const agentToModelMap = getAgentToModelMapForProfile(normalized);
return {
data: {
updated: true,
profile: normalized,
previousProfile,
agentToModelMap,
},
};
};
// ─── configNewProject ─────────────────────────────────────────────────────

View File

@@ -135,9 +135,9 @@ describe('resolveModel', () => {
// ─── MODEL_PROFILES ─────────────────────────────────────────────────────────
describe('MODEL_PROFILES', () => {
it('contains all 17 agent entries', async () => {
it('contains all 18 agent entries (sync with model-profiles.cjs)', async () => {
const { MODEL_PROFILES } = await import('./config-query.js');
expect(Object.keys(MODEL_PROFILES)).toHaveLength(17);
expect(Object.keys(MODEL_PROFILES)).toHaveLength(18);
});
it('has quality/balanced/budget/adaptive for each agent', async () => {

View File

@@ -42,6 +42,7 @@ export const MODEL_PROFILES: Record<string, Record<string, string>> = {
'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
'gsd-pattern-mapper': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
'gsd-ui-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' },
'gsd-ui-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
'gsd-ui-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' },
@@ -52,6 +53,19 @@ export const MODEL_PROFILES: Record<string, Record<string, string>> = {
/** Valid model profile names. */
export const VALID_PROFILES: string[] = Object.keys(MODEL_PROFILES['gsd-planner']);
/**
* Flat map of agent name → model alias for one profile tier (matches `model-profiles.cjs`).
*/
export function getAgentToModelMapForProfile(normalizedProfile: string): Record<string, string> {
const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced';
const agentToModelMap: Record<string, string> = {};
for (const [agent, profileToModelMap] of Object.entries(MODEL_PROFILES)) {
const mapped = profileToModelMap[profile] ?? profileToModelMap.balanced;
agentToModelMap[agent] = mapped ?? 'sonnet';
}
return agentToModelMap;
}
// ─── configGet ──────────────────────────────────────────────────────────────
/**
@@ -101,6 +115,23 @@ export const configGet: QueryHandler = async (args, projectDir) => {
return { data: current };
};
// ─── configPath ─────────────────────────────────────────────────────────────
/**
* Query handler for config-path — resolved `.planning/config.json` path (workstream-aware via cwd).
*
* Port of `cmdConfigPath` from `config.cjs`. The JSON query API returns `{ path }`; the CJS CLI
* emits the path as plain text for shell substitution.
*
* @param _args - Unused
* @param projectDir - Project root directory
* @returns QueryResult with `{ path: string }` absolute or project-relative resolution via planningPaths
*/
export const configPath: QueryHandler = async (_args, projectDir) => {
const paths = planningPaths(projectDir);
return { data: { path: paths.config } };
};
// ─── resolveModel ───────────────────────────────────────────────────────────
/**

View File

@@ -1,8 +1,8 @@
/**
* Unit tests for handlers decomposed from the former stubs.ts.
* Cross-module handler tests for code decomposed from the legacy `stubs.ts` module.
*
* Tests are organized by domain module — each import references the
* handler's new home after the stubs.ts → domain file decomposition.
* Each suite imports real handlers from their domain modules and exercises behavior
* against temp fixtures (no standalone stubs).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@@ -11,7 +11,8 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { agentSkills } from './skills.js';
import { roadmapUpdatePlanProgress, requirementsMarkComplete } from './roadmap.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import { requirementsMarkComplete } from './roadmap.js';
import { statePlannedPhase } from './state-mutation.js';
import { verifySchemaDrift } from './verify.js';
import { todoMatchPhase, statsJson, progressBar } from './progress.js';
@@ -22,7 +23,7 @@ import {
workstreamList, workstreamCreate, workstreamSet,
workstreamStatus, workstreamComplete,
} from './workstream.js';
import { docsInit } from './init.js';
import { docsInit } from './docs-init.js';
import { websearch } from './websearch.js';
let tmpDir: string;
@@ -86,11 +87,8 @@ describe('roadmapUpdatePlanProgress', () => {
expect(typeof data.updated).toBe('boolean');
});
it('returns false when no phase arg', async () => {
const result = await roadmapUpdatePlanProgress([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.updated).toBe(false);
expect(data.reason).toBeDefined();
it('throws when no phase arg', async () => {
await expect(roadmapUpdatePlanProgress([], tmpDir)).rejects.toThrow();
});
});
@@ -98,63 +96,67 @@ describe('requirementsMarkComplete', () => {
it('returns QueryResult without error', async () => {
const result = await requirementsMarkComplete(['REQ-01'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.marked).toBe('boolean');
expect(typeof data.updated).toBe('boolean');
});
it('returns false when no IDs provided', async () => {
const result = await requirementsMarkComplete([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.marked).toBe(false);
it('throws when no IDs provided', async () => {
await expect(requirementsMarkComplete([], tmpDir)).rejects.toThrow();
});
});
// ─── state-mutation.ts ───────────────────────────────────────────────────
describe('statePlannedPhase', () => {
it('updates STATE.md and returns success', async () => {
it('returns cmdStatePlannedPhase-shaped data', async () => {
const result = await statePlannedPhase(['--phase', '10', '--name', 'queries', '--plans', '2'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.updated).toBe('boolean');
expect(Array.isArray(data.updated)).toBe(true);
expect(data.phase).toBe('10');
expect(data.plan_count).toBe(2);
});
it('returns false without phase arg', async () => {
it('returns error when --phase is missing', async () => {
const result = await statePlannedPhase([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.updated).toBe(false);
expect(data.error).toMatch(/phase required/);
});
});
// ─── verify.ts ───────────────────────────────────────────────────────────
describe('verifySchemaDrift', () => {
it('returns valid/issues shape', async () => {
const result = await verifySchemaDrift([], tmpDir);
it('returns drift_detected shape (cmdVerifySchemaDrift parity)', async () => {
const result = await verifySchemaDrift(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.valid).toBe('boolean');
expect(Array.isArray(data.issues)).toBe(true);
expect(typeof data.checked).toBe('number');
expect(typeof data.drift_detected).toBe('boolean');
expect(typeof data.blocking).toBe('boolean');
expect(Array.isArray(data.schema_files)).toBe(true);
});
});
// ─── progress.ts ─────────────────────────────────────────────────────────
describe('todoMatchPhase', () => {
it('returns todos array (empty when no todos dir)', async () => {
it('returns matches and todo_count (cmdTodoMatchPhase parity)', async () => {
const result = await todoMatchPhase(['9'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(Array.isArray(data.todos)).toBe(true);
expect(Array.isArray(data.matches)).toBe(true);
expect(data.phase).toBe('9');
expect(typeof data.todo_count).toBe('number');
});
});
describe('statsJson', () => {
it('returns stats with phases_total and progress', async () => {
it('returns cmdStats JSON shape with phases table and git fields', async () => {
const result = await statsJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.milestone_version).toBe('string');
expect(Array.isArray(data.phases)).toBe(true);
expect(typeof data.phases_total).toBe('number');
expect(typeof data.plans_total).toBe('number');
expect(typeof data.progress_percent).toBe('number');
expect(data.phases_total).toBeGreaterThanOrEqual(2);
expect(typeof data.total_plans).toBe('number');
expect(typeof data.percent).toBe('number');
expect((data.phases_total as number)).toBeGreaterThanOrEqual(2);
expect(typeof data.git_commits).toBe('number');
});
});
@@ -177,12 +179,17 @@ describe('summaryExtract', () => {
expect(data.error).toBeDefined();
});
it('extracts sections from an existing summary file', async () => {
it('extracts frontmatter fields from an existing summary file', async () => {
const summaryPath = join(tmpDir, '.planning', 'phases', '09-foundation', '09-01-SUMMARY.md');
await writeFile(summaryPath, '# Summary\n\n## What Was Done\nBuilt it.\n\n## Tests\nAll pass.\n');
await writeFile(
summaryPath,
['---', 'phase: "09"', 'one-liner: Built it.', 'key-files:', ' - x.ts', '---', '', '# Summary', ''].join('\n'),
'utf-8',
);
const result = await summaryExtract(['.planning/phases/09-foundation/09-01-SUMMARY.md'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.sections).toBeDefined();
expect(data.one_liner).toBe('Built it.');
expect(data.key_files).toEqual(['x.ts']);
});
});
@@ -244,11 +251,18 @@ describe('workstream handlers', () => {
// ─── init.ts ─────────────────────────────────────────────────────────────
describe('docsInit', () => {
it('returns docs context', async () => {
it('returns docs context matching gsd-tools docs-init', async () => {
const result = await docsInit([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(typeof data.project_exists).toBe('boolean');
expect(data.docs_dir).toBe('.planning/docs');
expect(typeof data.planning_exists).toBe('boolean');
expect(data.project_root).toBe(tmpDir);
expect(typeof data.doc_writer_model).toBe('string');
expect(Array.isArray(data.existing_docs)).toBe(true);
expect(data.project_type).toBeDefined();
expect(data.doc_tooling).toBeDefined();
expect(Array.isArray(data.monorepo_workspaces)).toBe(true);
expect(typeof data.agents_installed).toBe('boolean');
expect(Array.isArray(data.missing_agents)).toBe(true);
});
});

View File

@@ -0,0 +1,97 @@
/**
* Detect user-added files under GSD-managed install dirs not listed in the manifest.
*
* Port of `detect-custom-files` from `get-shit-done/bin/gsd-tools.cjs` (lines 1161–1239).
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, relative, resolve } from 'node:path';
import type { QueryHandler } from './utils.js';
const GSD_MANAGED_DIRS = [
'get-shit-done',
'agents',
join('commands', 'gsd'),
'hooks',
'command',
'skills',
];
function walkDir(dir: string, baseDir: string): string[] {
const results: string[] = [];
if (!existsSync(dir)) return results;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath, baseDir));
} else {
const relPath = relative(baseDir, fullPath).split('\\').join('/');
results.push(relPath);
}
}
return results;
}
/**
* Args: `--config-dir <path>` (required) — runtime config directory to scan.
*/
export const detectCustomFiles: QueryHandler = async (args) => {
const configDirIdx = args.indexOf('--config-dir');
const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
if (!configDir) {
return { data: { error: 'Usage: detect-custom-files --config-dir <path>' } };
}
const resolvedConfigDir = resolve(configDir);
if (!existsSync(resolvedConfigDir)) {
return { data: { error: `Config directory not found: ${resolvedConfigDir}` } };
}
const manifestPath = join(resolvedConfigDir, 'gsd-file-manifest.json');
if (!existsSync(manifestPath)) {
return {
data: {
custom_files: [] as string[],
custom_count: 0,
manifest_found: false,
},
};
}
let manifest: { version?: string; files?: Record<string, unknown> };
try {
manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { version?: string; files?: Record<string, unknown> };
} catch {
return {
data: {
custom_files: [] as string[],
custom_count: 0,
manifest_found: false,
error: 'manifest parse error',
},
};
}
const manifestKeys = new Set(Object.keys(manifest.files || {}));
const customFiles: string[] = [];
for (const managedDir of GSD_MANAGED_DIRS) {
const absDir = join(resolvedConfigDir, managedDir);
if (!existsSync(absDir)) continue;
for (const relPath of walkDir(absDir, resolvedConfigDir)) {
if (!manifestKeys.has(relPath)) {
customFiles.push(relPath);
}
}
}
return {
data: {
custom_files: customFiles,
custom_count: customFiles.length,
manifest_found: true,
manifest_version: manifest.version ?? null,
},
};
};

View File

@@ -0,0 +1,105 @@
/**
* Unit tests for `detect.phase-type` (decision-routing audit §3.6).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, writeFile, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { detectPhaseType } from './detect-phase-type.js';
describe('detectPhaseType', () => {
let projectDir: string;
beforeEach(async () => {
projectDir = join(tmpdir(), `gsd-detect-phase-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(projectDir, '.planning', 'phases'), { recursive: true });
});
afterEach(async () => {
await rm(projectDir, { recursive: true, force: true });
});
it('throws when phase arg is missing', async () => {
await expect(detectPhaseType([], projectDir)).rejects.toThrow();
});
it('returns all false/null/[] when phase dir does not exist', async () => {
const { data } = await detectPhaseType(['99'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_frontend).toBe(false);
expect(d.has_schema).toBe(false);
expect(d.schema_orm).toBeNull();
expect(d.schema_files).toEqual([]);
expect(d.frontend_indicators).toEqual([]);
expect(d.has_api).toBe(false);
expect(d.has_infra).toBe(false);
});
it('sets has_frontend true when ROADMAP heading contains UI keyword', async () => {
const roadmapContent = `# Project Roadmap\n\n## Phase 01: UI Dashboard\n\nSome content\n`;
await writeFile(join(projectDir, '.planning', 'ROADMAP.md'), roadmapContent, 'utf-8');
await mkdir(join(projectDir, '.planning', 'phases', '01-ui-dashboard'), { recursive: true });
const { data } = await detectPhaseType(['1'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_frontend).toBe(true);
expect((d.frontend_indicators as string[]).length).toBeGreaterThan(0);
});
it('sets has_schema true and schema_orm prisma when prisma schema file found', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '02-database');
await mkdir(join(phaseDir, 'prisma'), { recursive: true });
await writeFile(join(phaseDir, 'prisma', 'schema.prisma'), 'model User {}', 'utf-8');
const { data } = await detectPhaseType(['2'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_schema).toBe(true);
expect(d.schema_orm).toBe('prisma');
expect((d.schema_files as string[]).length).toBeGreaterThan(0);
});
it('sets has_frontend true when UI-SPEC.md is present in phase dir', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '03-features');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, 'UI-SPEC.md'), '# UI Spec', 'utf-8');
const { data } = await detectPhaseType(['3'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_frontend).toBe(true);
});
it('sets has_api true when route file found in phase dir', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '04-api');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, 'user.route.ts'), 'export {}', 'utf-8');
const { data } = await detectPhaseType(['4'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_api).toBe(true);
});
it('sets has_infra true when docker file found in phase dir', async () => {
const phaseDir = join(projectDir, '.planning', 'phases', '05-infra');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, 'dockerfile.yml'), '', 'utf-8');
const { data } = await detectPhaseType(['5'], projectDir);
const d = data as Record<string, unknown>;
expect(d.has_infra).toBe(true);
});
it('returns push_command null (reserved field)', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '06-misc'), { recursive: true });
const { data } = await detectPhaseType(['6'], projectDir);
const d = data as Record<string, unknown>;
expect(d.push_command).toBeNull();
});
it('returns correct phase field in output', async () => {
await mkdir(join(projectDir, '.planning', 'phases', '07-test'), { recursive: true });
const { data } = await detectPhaseType(['7'], projectDir);
const d = data as Record<string, unknown>;
expect(d.phase).toBe('07');
});
});

View File

@@ -0,0 +1,141 @@
/**
* Phase type detection (`detect.phase-type`).
*
* Replaces fragile grep-based UI/schema/API detection in workflows with a
* structured query. See `.planning/research/decision-routing-audit.md` §3.6.
*/
import { readFile } from 'node:fs/promises';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { escapeRegex, normalizePhaseName, planningPaths } from './helpers.js';
import { findPhase } from './phase.js';
import { detectSchemaFiles } from './schema-detect.js';
import type { QueryHandler } from './utils.js';
// Copied from phase-ready.ts — do not import to avoid cross-module coupling.
const UI_INDICATOR_RE = /UI|interface|frontend|component|layout|page|screen|view|form|dashboard|widget/i;
const API_INDICATOR_RE = /route\.ts|controller\.|api\//i;
const API_HEADING_RE = /\bAPI\b|endpoint|REST|GraphQL/i;
const INFRA_RE = /docker|terraform|k8s|helm|infra/i;
async function roadmapHeadingForPhase(projectDir: string, phaseNum: string): Promise<string | null> {
const roadmapPath = planningPaths(projectDir).roadmap;
let content: string;
try {
content = await readFile(roadmapPath, 'utf-8');
} catch {
return null;
}
const re = new RegExp(`#{2,4}\\s*Phase\\s+${escapeRegex(phaseNum)}\\s*:[^\\n]*`, 'i');
const m = content.match(re);
return m ? m[0] : null;
}
export const detectPhaseType: QueryHandler = async (args, projectDir) => {
const raw = args[0];
if (!raw) {
throw new GSDError('phase number required for detect phase-type', ErrorClassification.Validation);
}
const phaseArg = normalizePhaseName(raw);
const phaseRes = await findPhase([raw], projectDir);
const pdata = phaseRes.data as Record<string, unknown>;
const found = Boolean(pdata.found);
// Build phase dir absolute path when found
let phaseDirFull: string | null = null;
if (found && pdata.directory) {
phaseDirFull = join(projectDir, pdata.directory as string);
}
const phaseNumForRoadmap = (pdata.phase_number as string) || phaseArg;
// Read ROADMAP heading — try both normalized forms
let heading = await roadmapHeadingForPhase(projectDir, phaseNumForRoadmap);
if (!heading && phaseNumForRoadmap !== phaseArg) {
heading = await roadmapHeadingForPhase(projectDir, phaseArg);
}
// Frontend detection
const headingUiMatch = heading ? UI_INDICATOR_RE.test(heading) : false;
const frontendIndicators: string[] = [];
if (heading && headingUiMatch) {
// Collect matched keywords from heading
const keywords = ['UI', 'interface', 'frontend', 'component', 'layout', 'page', 'screen', 'view', 'form', 'dashboard', 'widget'];
for (const kw of keywords) {
if (new RegExp(`\\b${kw}\\b`, 'i').test(heading)) {
frontendIndicators.push(kw);
}
}
}
let hasUiSpecFile = false;
let dirFiles: string[] = [];
if (phaseDirFull && existsSync(phaseDirFull)) {
try {
dirFiles = readdirSync(phaseDirFull, { recursive: false }) as string[];
} catch {
dirFiles = [];
}
hasUiSpecFile = dirFiles.some(f => f === 'UI-SPEC.md' || f.endsWith('-UI-SPEC.md'));
}
const has_frontend = headingUiMatch || hasUiSpecFile;
// Schema detection — build relative paths from phase dir for detectSchemaFiles
let schemaFiles: string[] = [];
let schemaOrm: string | null = null;
let hasSchema = false;
if (phaseDirFull && dirFiles.length > 0) {
// Also check subdirectory one level deep (e.g. prisma/schema.prisma)
const allRelPaths: string[] = [...dirFiles];
for (const f of dirFiles) {
const sub = join(phaseDirFull, f);
if (existsSync(sub)) {
try {
const subStat = readdirSync(sub);
for (const sf of subStat) {
allRelPaths.push(`${f}/${sf}`);
}
} catch {
// Not a directory — ignore
}
}
}
const detection = detectSchemaFiles(allRelPaths);
if (detection.detected) {
hasSchema = true;
schemaFiles = detection.matches;
schemaOrm = detection.orms[0] ?? null;
}
}
// API detection
const apiFromFiles = dirFiles.some(f => API_INDICATOR_RE.test(f));
const apiFromHeading = heading ? API_HEADING_RE.test(heading) : false;
const has_api = apiFromFiles || apiFromHeading;
// Infra detection
const has_infra = dirFiles.some(f => INFRA_RE.test(f));
return {
data: {
phase: phaseArg,
has_frontend,
frontend_indicators: frontendIndicators,
has_schema: hasSchema,
schema_orm: schemaOrm,
schema_files: schemaFiles,
push_command: null,
has_api,
has_infra,
},
};
};

257
sdk/src/query/docs-init.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* Docs-init — context bundle for the docs-update workflow.
*
* Full port of `cmdDocsInit` and helpers from `get-shit-done/bin/lib/docs.cjs`.
*/
import {
closeSync,
existsSync,
openSync,
readFileSync,
readSync,
readdirSync,
statSync,
type Dirent,
} from 'node:fs';
import { join, relative } from 'node:path';
import { loadConfig } from '../config.js';
import { MODEL_PROFILES, resolveModel } from './config-query.js';
import { detectRuntime, resolveAgentsDir, toPosixPath } from './helpers.js';
import type { QueryHandler } from './utils.js';
const GSD_MARKER = '<!-- generated-by: gsd-doc-writer -->';
const SKIP_DIRS = new Set([
'node_modules', '.git', '.planning', '.claude', '__pycache__',
'target', 'dist', 'build', '.next', '.nuxt', 'coverage',
'.vscode', '.idea',
]);
function pathExistsInternal(cwd: string, rel: string): boolean {
try {
return existsSync(join(cwd, rel));
} catch {
return false;
}
}
function hasGsdMarker(filePath: string): boolean {
try {
const buf = Buffer.alloc(500);
const fd = openSync(filePath, 'r');
const bytesRead = readSync(fd, buf, 0, 500, 0);
closeSync(fd);
return buf.subarray(0, bytesRead).toString('utf-8').includes(GSD_MARKER);
} catch {
return false;
}
}
/**
* Recursively scan project root `.md` files and `docs/` (or fallbacks) up to depth 4.
* Port of `scanExistingDocs` from docs.cjs.
*/
export function scanExistingDocs(cwd: string): Array<{ path: string; has_gsd_marker: boolean }> {
const MAX_DEPTH = 4;
const results: Array<{ path: string; has_gsd_marker: boolean }> = [];
function walkDir(dir: string, depth: number): void {
if (depth > MAX_DEPTH) return;
try {
const entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
for (const entry of entries) {
if (SKIP_DIRS.has(entry.name)) continue;
const abs = join(dir, entry.name);
const nameStr = entry.name.toString();
if (entry.isDirectory()) {
walkDir(abs, depth + 1);
} else if (entry.isFile() && nameStr.toLowerCase().endsWith('.md')) {
const rel = toPosixPath(relative(cwd, abs));
results.push({ path: rel, has_gsd_marker: hasGsdMarker(abs) });
}
}
} catch { /* directory may not exist */ }
}
try {
const rootEntries = readdirSync(cwd, { withFileTypes: true }) as Dirent[];
for (const entry of rootEntries) {
const nameStr = entry.name.toString();
if (entry.isFile() && nameStr.toLowerCase().endsWith('.md')) {
const abs = join(cwd, nameStr);
const rel = toPosixPath(relative(cwd, abs));
results.push({ path: rel, has_gsd_marker: hasGsdMarker(abs) });
}
}
} catch { /* best-effort */ }
const docsDir = join(cwd, 'docs');
walkDir(docsDir, 1);
try {
statSync(docsDir);
} catch {
for (const alt of ['documentation', 'doc']) {
const altDir = join(cwd, alt);
try {
const st = statSync(altDir);
if (st.isDirectory()) {
walkDir(altDir, 1);
break;
}
} catch { /* not present */ }
}
}
return results.sort((a, b) => a.path.localeCompare(b.path));
}
/** Port of `detectProjectType` from docs.cjs. */
export function detectProjectType(cwd: string): Record<string, boolean> {
const exists = (rel: string): boolean => pathExistsInternal(cwd, rel);
let has_cli_bin = false;
try {
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')) as Record<string, unknown>;
const bin = pkg.bin;
has_cli_bin = !!(bin && (typeof bin === 'string' || Object.keys(bin as object).length > 0));
} catch { /* no package.json */ }
let is_monorepo = exists('pnpm-workspace.yaml') || exists('lerna.json');
if (!is_monorepo) {
try {
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')) as Record<string, unknown>;
is_monorepo = Array.isArray(pkg.workspaces) && (pkg.workspaces as unknown[]).length > 0;
} catch { /* ignore */ }
}
let has_tests = exists('test') || exists('tests') || exists('__tests__') || exists('spec');
if (!has_tests) {
try {
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')) as Record<string, unknown>;
const devDeps = Object.keys((pkg.devDependencies as Record<string, unknown>) || {});
has_tests = devDeps.some(d => ['vitest', 'jest', 'mocha', 'jasmine', 'ava'].includes(d));
} catch { /* ignore */ }
}
const deployFiles = [
'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
'fly.toml', 'render.yaml', 'vercel.json', 'netlify.toml', 'railway.json',
'.github/workflows/deploy.yml', '.github/workflows/deploy.yaml',
];
const has_deploy_config = deployFiles.some(f => exists(f));
return {
has_package_json: exists('package.json'),
has_api_routes: (
exists('src/app/api') || exists('routes') || exists('src/routes') ||
exists('api') || exists('server')
),
has_cli_bin,
is_open_source: exists('LICENSE') || exists('LICENSE.md'),
has_deploy_config,
is_monorepo,
has_tests,
};
}
/** Port of `detectDocTooling` from docs.cjs. */
export function detectDocTooling(cwd: string): Record<string, boolean> {
const exists = (rel: string): boolean => pathExistsInternal(cwd, rel);
return {
docusaurus: exists('docusaurus.config.js') || exists('docusaurus.config.ts'),
vitepress: (
exists('.vitepress/config.js') ||
exists('.vitepress/config.ts') ||
exists('.vitepress/config.mts')
),
mkdocs: exists('mkdocs.yml'),
storybook: exists('.storybook'),
};
}
/** Port of `detectMonorepoWorkspaces` from docs.cjs. */
export function detectMonorepoWorkspaces(cwd: string): string[] {
try {
const content = readFileSync(join(cwd, 'pnpm-workspace.yaml'), 'utf-8');
const workspaces: string[] = [];
for (const line of content.split('\n')) {
const m = line.match(/^\s*-\s+['"]?(.+?)['"]?\s*$/);
if (m) workspaces.push(m[1].trim());
}
if (workspaces.length > 0) return workspaces;
} catch { /* not present */ }
try {
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')) as Record<string, unknown>;
if (Array.isArray(pkg.workspaces) && pkg.workspaces.length > 0) {
return pkg.workspaces as string[];
}
} catch { /* not present */ }
try {
const lerna = JSON.parse(readFileSync(join(cwd, 'lerna.json'), 'utf-8')) as { packages?: string[] };
if (Array.isArray(lerna.packages) && lerna.packages.length > 0) {
return lerna.packages;
}
} catch { /* not present */ }
return [];
}
/**
* Port of `checkAgentsInstalled` from core.cjs (same logic as init.ts).
*/
function checkAgentsInstalled(config?: { runtime?: unknown }): { agents_installed: boolean; missing_agents: string[] } {
const runtime = detectRuntime(config);
const agentsDir = resolveAgentsDir(runtime);
const expectedAgents = Object.keys(MODEL_PROFILES);
if (!existsSync(agentsDir)) {
return { agents_installed: false, missing_agents: expectedAgents };
}
const missing: string[] = [];
for (const agent of expectedAgents) {
const agentFile = join(agentsDir, `${agent}.md`);
const agentFileCopilot = join(agentsDir, `${agent}.agent.md`);
if (!existsSync(agentFile) && !existsSync(agentFileCopilot)) {
missing.push(agent);
}
}
return {
agents_installed: missing.length === 0,
missing_agents: missing,
};
}
/**
* Init payload for docs-update workflow — matches `gsd-tools docs-init` JSON.
* Port of `cmdDocsInit` from docs.cjs.
*/
export const docsInit: QueryHandler = async (_args, projectDir) => {
const config = await loadConfig(projectDir);
const docModelResult = await resolveModel(['gsd-doc-writer'], projectDir);
const docWriterData = docModelResult.data as Record<string, unknown>;
const doc_writer_model = (docWriterData?.model as string) || 'sonnet';
const agentStatus = checkAgentsInstalled(config as { runtime?: unknown });
const data: Record<string, unknown> = {
doc_writer_model,
commit_docs: config.commit_docs,
existing_docs: scanExistingDocs(projectDir),
project_type: detectProjectType(projectDir),
doc_tooling: detectDocTooling(projectDir),
monorepo_workspaces: detectMonorepoWorkspaces(projectDir),
planning_exists: pathExistsInternal(projectDir, '.planning'),
project_root: projectDir,
agents_installed: agentStatus.agents_installed,
missing_agents: agentStatus.missing_agents,
};
return { data };
};

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { extractFrontmatter } from './frontmatter.js';
describe('extractFrontmatter array-of-objects', () => {
it('parses array of objects', () => {
const content = `---\nitems:\n - id: 1\n name: test\n - id: 2\n name: test2\n---\n`;
expect(extractFrontmatter(content)).toEqual({
items: [
{ id: '1', name: 'test' },
{ id: '2', name: 'test2' }
]
});
});
});

View File

@@ -164,9 +164,25 @@ function parseSimpleValue(value: string): unknown {
* @returns QueryResult with { updated: true, field, value }
*/
export const frontmatterSet: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
const field = args[1];
const value = args[2];
let filePath: string;
let field: string;
let value: string;
const fi = args.indexOf('--field');
const vi = args.indexOf('--value');
const hasNamedArgs = fi !== -1 || vi !== -1;
if (hasNamedArgs) {
if (fi === -1 || vi === -1 || !args[fi + 1] || args[vi + 1] === undefined) {
throw new GSDError('file, --field, and --value required together', ErrorClassification.Validation);
}
filePath = args[0];
field = args[fi + 1];
value = args[vi + 1];
} else {
filePath = args[0];
field = args[1];
value = args[2];
}
if (!filePath || !field || value === undefined) {
throw new GSDError('file, field, and value required', ErrorClassification.Validation);
@@ -195,11 +211,12 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => {
}
const fm = extractFrontmatter(content);
fm[field] = parseSimpleValue(value);
const parsedValue = parseSimpleValue(value);
fm[field] = parsedValue;
const newContent = spliceFrontmatter(content, fm);
await writeFile(fullPath, normalizeMd(newContent), 'utf-8');
return { data: { updated: true, field, value: fm[field] } };
return { data: { updated: true, field, value: parsedValue } };
};
// ─── frontmatterMerge ──────────────────────────────────────────────────────
@@ -210,13 +227,14 @@ export const frontmatterSet: QueryHandler = async (args, projectDir) => {
* Reads a file, merges JSON object into existing frontmatter, writes back.
* Port of `cmdFrontmatterMerge` from frontmatter.cjs lines 344-356.
*
* @param args - args[0]: file path, args[1]: JSON string
* @param args - `file --data <json>` (gsd-tools) or `[file, jsonString]` (SDK)
* @param projectDir - Project root directory
* @returns QueryResult with { merged: true, fields: [...] }
*/
export const frontmatterMerge: QueryHandler = async (args, projectDir) => {
const filePath = args[0];
const jsonString = args[1];
const dataIdx = args.indexOf('--data');
const jsonString = dataIdx !== -1 ? args[dataIdx + 1] : args[1];
if (!filePath || !jsonString) {
throw new GSDError('file and data required', ErrorClassification.Validation);

View File

@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os';
import {
splitInlineArray,
extractFrontmatter,
extractFrontmatterLeading,
stripFrontmatter,
frontmatterGet,
parseMustHavesBlock,
@@ -95,6 +96,20 @@ describe('extractFrontmatter', () => {
});
});
// ─── extractFrontmatterLeading ─────────────────────────────────────────────
describe('extractFrontmatterLeading', () => {
it('parses only the first leading block (gsd-tools.cjs / frontmatter.cjs parity)', () => {
const content = '---\nfirst: 1\n---\n---\nsecond: 2\n---\nbody';
expect(extractFrontmatterLeading(content)).toEqual({ first: '1' });
});
it('matches extractFrontmatter when a single block starts the file', () => {
const content = '---\na: b\n---\n';
expect(extractFrontmatterLeading(content)).toEqual(extractFrontmatter(content));
});
});
// ─── stripFrontmatter ───────────────────────────────────────────────────────
describe('stripFrontmatter', () => {

View File

@@ -59,31 +59,14 @@ export function splitInlineArray(body: string): string[] {
return items;
}
// ─── extractFrontmatter ─────────────────────────────────────────────────────
// ─── parseFrontmatterYamlLines ───────────────────────────────────────────────
/**
* Parse YAML frontmatter from file content.
*
* Full stack-based parser supporting:
* - Simple key: value pairs
* - Nested objects via indentation
* - Inline arrays: key: [a, b, c]
* - Dash arrays with auto-conversion from empty objects
* - Multiple stacked blocks (uses the LAST match)
* - CRLF line endings
* - Quoted value stripping
*
* @param content - File content potentially containing frontmatter
* @returns Parsed frontmatter as a record, or empty object if none found
* Parse YAML frontmatter body (between `---` fences) using the GSD stack parser.
* Shared by {@link extractFrontmatterLeading} and {@link extractFrontmatter}.
*/
export function extractFrontmatter(content: string): Record<string, unknown> {
function parseFrontmatterYamlLines(yaml: string): Record<string, unknown> {
const frontmatter: Record<string, unknown> = {};
// Find ALL frontmatter blocks. Use the LAST one (corruption recovery).
const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)];
const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null;
if (!match) return frontmatter;
const yaml = match[1];
const lines = yaml.split(/\r?\n/);
// Stack to track nested objects: [{obj, key, indent}]
@@ -129,7 +112,18 @@ export function extractFrontmatter(content: string): Record<string, unknown> {
}
} else if (line.trim().startsWith('- ')) {
// Array item
const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
const afterDash = line.trim().slice(2).trim();
let itemValue: unknown = afterDash.replace(/^["']|["']$/g, '');
let isObjItem = false;
// Extract key: value within the array item if present
const kvMatch = afterDash.match(/^([a-zA-Z0-9_-]+):\s*(.*)/);
if (kvMatch) {
isObjItem = true;
const k = kvMatch[1];
const v = kvMatch[2].trim().replace(/^["']|["']$/g, '');
itemValue = { [k]: v };
}
// If current context is an empty object, convert to array
if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
@@ -147,12 +141,55 @@ export function extractFrontmatter(content: string): Record<string, unknown> {
} else if (Array.isArray(current.obj)) {
current.obj.push(itemValue);
}
// Push object context onto stack so subsequent indented properties map to this object
if (isObjItem && Array.isArray(current.obj)) {
stack.push({ obj: itemValue as Record<string, unknown>, key: null, indent });
}
}
}
return frontmatter;
}
// ─── extractFrontmatterLeading ──────────────────────────────────────────────
/**
* First leading frontmatter block only — parity with `get-shit-done/bin/lib/frontmatter.cjs`
* `extractFrontmatter` (used by `summary-extract` and `history-digest` in gsd-tools.cjs).
*/
export function extractFrontmatterLeading(content: string): Record<string, unknown> {
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
if (!match) return {};
return parseFrontmatterYamlLines(match[1]);
}
// ─── extractFrontmatter ─────────────────────────────────────────────────────
/**
* Parse YAML frontmatter from file content.
*
* Full stack-based parser supporting:
* - Simple key: value pairs
* - Nested objects via indentation
* - Inline arrays: key: [a, b, c]
* - Dash arrays with auto-conversion from empty objects
* - Multiple stacked blocks (uses the LAST match)
* - CRLF line endings
* - Quoted value stripping
*
* @param content - File content potentially containing frontmatter
* @returns Parsed frontmatter as a record, or empty object if none found
*/
export function extractFrontmatter(content: string): Record<string, unknown> {
// Find ALL frontmatter blocks. Use the LAST one (corruption recovery).
const allBlocks = [...content.matchAll(/(?:^|\n)\s*---\r?\n([\s\S]+?)\r?\n---/g)];
const match = allBlocks.length > 0 ? allBlocks[allBlocks.length - 1] : null;
if (!match) return {};
return parseFrontmatterYamlLines(match[1]);
}
// ─── stripFrontmatter ───────────────────────────────────────────────────────
/**

View File

@@ -169,6 +169,19 @@ describe('stateExtractField', () => {
const content = '**phase:** 10';
expect(stateExtractField(content, 'Phase')).toBe('10');
});
it('does not treat YAML progress: block as body Progress field', () => {
const content = [
'---',
'progress:',
' total: 5',
' done: 2',
'---',
'',
'**Progress:** 40%',
].join('\n');
expect(stateExtractField(content, 'Progress')).toBe('40%');
});
});
// ─── planningPaths ──────────────────────────────────────────────────────────

View File

@@ -286,10 +286,12 @@ export function toPosixPath(p: string): string {
*/
export function stateExtractField(content: string, fieldName: string): string | null {
const escaped = escapeRegex(fieldName);
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*\\s*(.+)`, 'i');
// Horizontal whitespace only after ':' so YAML blocks like `progress:\n total:` do not
// match as `Progress:` with a multi-line "value" (parity with STATE.md body fields).
const boldPattern = new RegExp(`\\*\\*${escaped}:\\*\\*[ \\t]*(.+)`, 'i');
const boldMatch = content.match(boldPattern);
if (boldMatch) return boldMatch[1].trim();
const plainPattern = new RegExp(`^${escaped}:\\s*(.+)`, 'im');
const plainPattern = new RegExp(`^${escaped}:[ \\t]*(.+)`, 'im');
const plainMatch = content.match(plainPattern);
return plainMatch ? plainMatch[1].trim() : null;
}
@@ -449,3 +451,32 @@ export async function resolvePathUnderProject(projectDir: string, userPath: stri
}
return realCandidate;
}
// ─── sanitizeForDisplay (security.cjs) ───────────────────────────────────────
/** Port of `sanitizeForPrompt` from `security.cjs`. */
export function sanitizeForPrompt(text: string): string {
let sanitized = text;
sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\uFEFF\u00AD]/g, '');
sanitized = sanitized.replace(
/<(\/?)(?:system|assistant|human)>/gi,
(_, slash: string) => `<${slash || ''}system-text>`,
);
sanitized = sanitized.replace(/\[(SYSTEM|INST)\]/gi, '[$1-TEXT]');
sanitized = sanitized.replace(/<<\s*SYS\s*>>/gi, '«SYS-TEXT»');
return sanitized;
}
/** Port of `sanitizeForDisplay` from `security.cjs` (matches CLI JSON). */
export function sanitizeForDisplay(text: string): string {
let sanitized = sanitizeForPrompt(text);
const protocolLeakPatterns = [
/^\s*(?:assistant|user|system)\s+to=[^:\s]+:[^\n]+$/i,
/^\s*<\|(?:assistant|user|system)[^|]*\|>\s*$/i,
];
sanitized = sanitized
.split('\n')
.filter(line => !protocolLeakPatterns.some(pattern => pattern.test(line)))
.join('\n');
return sanitized;
}

View File

@@ -17,9 +17,13 @@
import { QueryRegistry } from './registry.js';
import { generateSlug, currentTimestamp } from './utils.js';
import { frontmatterGet } from './frontmatter.js';
import { configGet, resolveModel } from './config-query.js';
import { stateLoad, stateGet, stateSnapshot } from './state.js';
import { configGet, configPath, resolveModel } from './config-query.js';
import { stateJson, stateGet, stateSnapshot } from './state.js';
import { stateProjectLoad } from './state-project-load.js';
import { findPhase, phasePlanIndex } from './phase.js';
import { phaseListPlans, phaseListArtifacts } from './phase-list-queries.js';
import { planTaskStructure } from './plan-task-structure.js';
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
import { roadmapAnalyze, roadmapGetPhase } from './roadmap.js';
import { progressJson } from './progress.js';
import { frontmatterSet, frontmatterMerge, frontmatterValidate } from './frontmatter-mutation.js';
@@ -27,6 +31,7 @@ import {
stateUpdate, statePatch, stateBeginPhase, stateAdvancePlan,
stateRecordMetric, stateUpdateProgress, stateAddDecision,
stateAddBlocker, stateResolveBlocker, stateRecordSession,
stateSignalWaiting, stateSignalResume, stateValidate, stateSync, statePrune,
} from './state-mutation.js';
import {
configSet, configSetModelProfile, configNewProject, configEnsureSection,
@@ -34,9 +39,9 @@ import {
import { commit, checkCommit } from './commit.js';
import { templateFill, templateSelect } from './template.js';
import { verifyPlanStructure, verifyPhaseCompleteness, verifyArtifacts, verifyCommits, verifyReferences, verifySummary, verifyPathExists } from './verify.js';
import { verifyKeyLinks, validateConsistency, validateHealth } from './validate.js';
import { verifyKeyLinks, validateConsistency, validateHealth, validateAgents } from './validate.js';
import {
phaseAdd, phaseInsert, phaseRemove, phaseComplete,
phaseAdd, phaseAddBatch, phaseInsert, phaseRemove, phaseComplete,
phaseScaffold, phasesClear, phasesArchive,
phasesList, phaseNextDecimal,
} from './phase-lifecycle.js';
@@ -48,28 +53,46 @@ import {
} from './init.js';
import { initNewProject, initProgress, initManager } from './init-complex.js';
import { agentSkills } from './skills.js';
import { roadmapUpdatePlanProgress, requirementsMarkComplete } from './roadmap.js';
import { requirementsMarkComplete } from './roadmap.js';
import { roadmapUpdatePlanProgress } from './roadmap-update-plan-progress.js';
import { statePlannedPhase } from './state-mutation.js';
import { verifySchemaDrift } from './verify.js';
import { todoMatchPhase, statsJson, progressBar, listTodos, todoComplete } from './progress.js';
import {
todoMatchPhase, statsJson, statsTable, progressBar, progressTable, listTodos, todoComplete,
} from './progress.js';
import { milestoneComplete } from './phase-lifecycle.js';
import { summaryExtract, historyDigest } from './summary.js';
import { commitToSubrepo } from './commit.js';
import {
workstreamList, workstreamCreate, workstreamSet, workstreamStatus,
workstreamGet, workstreamList, workstreamCreate, workstreamSet, workstreamStatus,
workstreamComplete, workstreamProgress,
} from './workstream.js';
import { docsInit } from './init.js';
import { docsInit } from './docs-init.js';
import { uatRenderCheckpoint, auditUat } from './uat.js';
import { websearch } from './websearch.js';
import {
intelStatus, intelDiff, intelSnapshot, intelValidate, intelQuery,
intelExtractExports, intelPatchMeta,
intelExtractExports, intelPatchMeta, intelUpdate,
} from './intel.js';
import {
learningsCopy, learningsQuery, extractMessages, scanSessions, profileSample, profileQuestionnaire,
writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd,
learningsCopy, learningsQuery, learningsListHandler, learningsPrune, learningsDelete,
extractMessages, scanSessions, profileSample, profileQuestionnaire,
} from './profile.js';
import {
writeProfile, generateClaudeProfile, generateDevPreferences, generateClaudeMd,
} from './profile-output.js';
import { skillManifest } from './skill-manifest.js';
import { auditOpen } from './audit-open.js';
import { detectCustomFiles } from './detect-custom-files.js';
import { checkConfigGates } from './config-gates.js';
import { checkAutoMode } from './check-auto-mode.js';
import { checkPhaseReady } from './phase-ready.js';
import { routeNextAction } from './route-next-action.js';
import { detectPhaseType } from './detect-phase-type.js';
import { checkCompletion } from './check-completion.js';
import { checkGates } from './check-gates.js';
import { checkVerificationStatus } from './check-verification-status.js';
import { checkShipReady } from './check-ship-ready.js';
import { GSDEventStream } from '../event-stream.js';
import {
GSDEventType,
@@ -86,6 +109,8 @@ import type { QueryHandler, QueryResult } from './utils.js';
export type { QueryResult, QueryHandler } from './utils.js';
export { extractField } from './registry.js';
/** Same argv normalization as `gsd-sdk query` — use when calling `registry.dispatch()` with CLI-style `command` + `args`. */
export { normalizeQueryCommand } from './normalize-query-command.js';
// ─── Mutation commands set ────────────────────────────────────────────────
@@ -102,14 +127,18 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
'state.record-metric', 'state.update-progress', 'state.add-decision',
'state.add-blocker', 'state.resolve-blocker', 'state.record-session',
'state.planned-phase', 'state planned-phase',
'state.signal-waiting', 'state signal-waiting',
'state.signal-resume', 'state signal-resume',
'state.sync', 'state sync',
'state.prune', 'state prune',
'frontmatter.set', 'frontmatter.merge', 'frontmatter.validate', 'frontmatter validate',
'config-set', 'config-set-model-profile', 'config-new-project', 'config-ensure-section',
'commit', 'check-commit', 'commit-to-subrepo',
'template.fill', 'template.select', 'template select',
'validate.health', 'validate health',
'phase.add', 'phase.insert', 'phase.remove', 'phase.complete',
'phase.add', 'phase.add-batch', 'phase.insert', 'phase.remove', 'phase.complete',
'phase.scaffold', 'phases.clear', 'phases.archive',
'phase add', 'phase insert', 'phase remove', 'phase complete',
'phase add', 'phase add-batch', 'phase insert', 'phase remove', 'phase complete',
'phase scaffold', 'phases clear', 'phases archive',
'roadmap.update-plan-progress', 'roadmap update-plan-progress',
'requirements.mark-complete', 'requirements mark-complete',
@@ -119,6 +148,8 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
'workstream create', 'workstream set', 'workstream complete', 'workstream progress',
'docs-init',
'learnings.copy', 'learnings copy',
'learnings.prune', 'learnings prune',
'learnings.delete', 'learnings delete',
'intel.snapshot', 'intel.patch-meta', 'intel snapshot', 'intel patch-meta',
'write-profile', 'generate-claude-profile', 'generate-dev-preferences', 'generate-claude-md',
]);
@@ -128,13 +159,17 @@ export const QUERY_MUTATION_COMMANDS = new Set<string>([
/**
* Build a mutation event based on the command prefix and result.
*
* `sessionId` is empty until a future phase wires session correlation into
* the query layer; see QUERY-HANDLERS.md.
* @param correlationSessionId - Optional session correlation id (from {@link createRegistry})
*/
function buildMutationEvent(cmd: string, args: string[], result: QueryResult): GSDEvent {
function buildMutationEvent(
correlationSessionId: string,
cmd: string,
args: string[],
result: QueryResult,
): GSDEvent {
const base = {
timestamp: new Date().toISOString(),
sessionId: '',
sessionId: correlationSessionId,
};
if (cmd.startsWith('template.') || cmd.startsWith('template ')) {
@@ -226,22 +261,36 @@ function buildMutationEvent(cmd: string, args: string[], result: QueryResult): G
* Create a fully-wired QueryRegistry with all native handlers registered.
*
* @param eventStream - Optional event stream for mutation event emission
* @param correlationSessionId - Optional session id threaded into mutation-related events
* @returns A QueryRegistry instance with all handlers registered
*/
export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
export function createRegistry(
eventStream?: GSDEventStream,
correlationSessionId?: string,
): QueryRegistry {
const mutationSessionId = correlationSessionId ?? '';
const registry = new QueryRegistry();
registry.register('generate-slug', generateSlug);
registry.register('current-timestamp', currentTimestamp);
registry.register('frontmatter.get', frontmatterGet);
registry.register('config-get', configGet);
registry.register('config-path', configPath);
registry.register('resolve-model', resolveModel);
registry.register('state.load', stateLoad);
registry.register('state.json', stateLoad);
registry.register('state.load', stateProjectLoad);
registry.register('state.json', stateJson);
registry.register('state.get', stateGet);
registry.register('state-snapshot', stateSnapshot);
registry.register('find-phase', findPhase);
registry.register('phase-plan-index', phasePlanIndex);
registry.register('phase.list-plans', phaseListPlans);
registry.register('phase list-plans', phaseListPlans);
registry.register('phase.list-artifacts', phaseListArtifacts);
registry.register('phase list-artifacts', phaseListArtifacts);
registry.register('plan.task-structure', planTaskStructure);
registry.register('plan task-structure', planTaskStructure);
registry.register('requirements.extract-from-plans', requirementsExtractFromPlans);
registry.register('requirements extract-from-plans', requirementsExtractFromPlans);
registry.register('roadmap.analyze', roadmapAnalyze);
registry.register('roadmap.get-phase', roadmapGetPhase);
registry.register('progress', progressJson);
@@ -264,6 +313,16 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('state.add-blocker', stateAddBlocker);
registry.register('state.resolve-blocker', stateResolveBlocker);
registry.register('state.record-session', stateRecordSession);
registry.register('state.signal-waiting', stateSignalWaiting);
registry.register('state.signal-resume', stateSignalResume);
registry.register('state.validate', stateValidate);
registry.register('state.sync', stateSync);
registry.register('state.prune', statePrune);
registry.register('state signal-waiting', stateSignalWaiting);
registry.register('state signal-resume', stateSignalResume);
registry.register('state validate', stateValidate);
registry.register('state sync', stateSync);
registry.register('state prune', statePrune);
// Config mutation handlers
registry.register('config-set', configSet);
@@ -303,9 +362,32 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('validate consistency', validateConsistency);
registry.register('validate.health', validateHealth);
registry.register('validate health', validateHealth);
registry.register('validate.agents', validateAgents);
registry.register('validate agents', validateAgents);
// Decision routing (SDK-only — no `gsd-tools.cjs` mirror yet; see QUERY-HANDLERS.md)
registry.register('check.config-gates', checkConfigGates);
registry.register('check config-gates', checkConfigGates);
registry.register('check.auto-mode', checkAutoMode);
registry.register('check auto-mode', checkAutoMode);
registry.register('check.phase-ready', checkPhaseReady);
registry.register('check phase-ready', checkPhaseReady);
registry.register('route.next-action', routeNextAction);
registry.register('route next-action', routeNextAction);
registry.register('detect.phase-type', detectPhaseType);
registry.register('detect phase-type', detectPhaseType);
registry.register('check.completion', checkCompletion);
registry.register('check completion', checkCompletion);
registry.register('check.gates', checkGates);
registry.register('check gates', checkGates);
registry.register('check.verification-status', checkVerificationStatus);
registry.register('check verification-status', checkVerificationStatus);
registry.register('check.ship-ready', checkShipReady);
registry.register('check ship-ready', checkShipReady);
// Phase lifecycle handlers
registry.register('phase.add', phaseAdd);
registry.register('phase.add-batch', phaseAddBatch);
registry.register('phase.insert', phaseInsert);
registry.register('phase.remove', phaseRemove);
registry.register('phase.complete', phaseComplete);
@@ -316,6 +398,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('phase.next-decimal', phaseNextDecimal);
// Space-delimited aliases for CJS compatibility
registry.register('phase add', phaseAdd);
registry.register('phase add-batch', phaseAddBatch);
registry.register('phase insert', phaseInsert);
registry.register('phase remove', phaseRemove);
registry.register('phase complete', phaseComplete);
@@ -388,11 +471,18 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('history.digest', historyDigest);
registry.register('history digest', historyDigest);
registry.register('history-digest', historyDigest);
registry.register('stats', statsJson);
registry.register('stats.json', statsJson);
registry.register('stats json', statsJson);
registry.register('stats.table', statsTable);
registry.register('stats table', statsTable);
registry.register('commit-to-subrepo', commitToSubrepo);
registry.register('progress.bar', progressBar);
registry.register('progress bar', progressBar);
registry.register('progress.table', progressTable);
registry.register('progress table', progressTable);
registry.register('workstream.get', workstreamGet);
registry.register('workstream get', workstreamGet);
registry.register('workstream.list', workstreamList);
registry.register('workstream list', workstreamList);
registry.register('workstream.create', workstreamCreate);
@@ -411,6 +501,17 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('learnings copy', learningsCopy);
registry.register('learnings.query', learningsQuery);
registry.register('learnings query', learningsQuery);
registry.register('learnings.list', learningsListHandler);
registry.register('learnings list', learningsListHandler);
registry.register('learnings.prune', learningsPrune);
registry.register('learnings prune', learningsPrune);
registry.register('learnings.delete', learningsDelete);
registry.register('learnings delete', learningsDelete);
registry.register('skill-manifest', skillManifest);
registry.register('skill manifest', skillManifest);
registry.register('audit-open', auditOpen);
registry.register('audit open', auditOpen);
registry.register('detect-custom-files', detectCustomFiles);
registry.register('extract-messages', extractMessages);
registry.register('extract.messages', extractMessages);
registry.register('audit-uat', auditUat);
@@ -430,6 +531,8 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register('intel extract-exports', intelExtractExports);
registry.register('intel.patch-meta', intelPatchMeta);
registry.register('intel patch-meta', intelPatchMeta);
registry.register('intel.update', intelUpdate);
registry.register('intel update', intelUpdate);
registry.register('generate-claude-profile', generateClaudeProfile);
registry.register('generate-dev-preferences', generateDevPreferences);
registry.register('write-profile', writeProfile);
@@ -446,7 +549,7 @@ export function createRegistry(eventStream?: GSDEventStream): QueryRegistry {
registry.register(cmd, async (args: string[], projectDir: string) => {
const result = await original(args, projectDir);
try {
const event = buildMutationEvent(cmd, args, result);
const event = buildMutationEvent(mutationSessionId, cmd, args, result);
eventStream.emitEvent(event);
} catch {
// T-11-12: Event emission is fire-and-forget; never block mutation success

View File

@@ -23,7 +23,7 @@ import { join, relative, basename } from 'node:path';
import { execSync } from 'node:child_process';
import { homedir } from 'node:os';
import { loadConfig } from '../config.js';
import { loadConfig, type GSDConfig } from '../config.js';
import { resolveModel, MODEL_PROFILES } from './config-query.js';
import { findPhase } from './phase.js';
import { roadmapGetPhase, getMilestoneInfo } from './roadmap.js';
@@ -119,10 +119,19 @@ async function getPhaseInfoWithFallback(
): Promise<{ phaseInfo: Record<string, unknown> | null; roadmapPhase: Record<string, unknown> | null }> {
const phaseResult = await findPhase([phase], projectDir);
let phaseInfo = phaseResult.data as Record<string, unknown> | null;
// findPhase returns { found: false } when missing; findPhaseInternal returns null — align for init parity.
if (phaseInfo && phaseInfo.found === false) {
phaseInfo = null;
}
const roadmapResult = await roadmapGetPhase([phase], projectDir);
const roadmapPhase = roadmapResult.data as Record<string, unknown> | null;
// Match init.cjs: drop archived disk match when the phase is listed in the current ROADMAP
if (phaseInfo?.archived && roadmapPhase?.found) {
phaseInfo = null;
}
// Fallback to ROADMAP.md if no phase directory exists yet
if ((!phaseInfo || !phaseInfo.found) && roadmapPhase?.found) {
const phaseName = roadmapPhase.phase_name as string;
@@ -145,6 +154,48 @@ async function getPhaseInfoWithFallback(
return { phaseInfo, roadmapPhase };
}
/**
* Phase resolution for `init verify-work` — matches init.cjs cmdInitVerifyWork (archived + fallback).
*/
async function getPhaseInfoForVerifyWork(
phase: string,
projectDir: string,
): Promise<{ phaseInfo: Record<string, unknown> | null }> {
const phaseResult = await findPhase([phase], projectDir);
let phaseInfo = phaseResult.data as Record<string, unknown> | null;
if (phaseInfo && phaseInfo.found === false) {
phaseInfo = null;
}
const roadmapResult = await roadmapGetPhase([phase], projectDir);
const roadmapPhase = roadmapResult.data as Record<string, unknown> | null;
if (phaseInfo?.archived && roadmapPhase?.found) {
phaseInfo = null;
}
if (!phaseInfo && roadmapPhase?.found) {
const phaseName = roadmapPhase.phase_name as string;
phaseInfo = {
found: true,
directory: null,
phase_number: roadmapPhase.phase_number,
phase_name: phaseName,
phase_slug: phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: null,
plans: [],
summaries: [],
incomplete_plans: [],
has_research: false,
has_context: false,
has_verification: false,
};
}
return { phaseInfo };
}
/**
* Extract requirement IDs from roadmap section text.
*/
@@ -163,7 +214,7 @@ function extractReqIds(roadmapPhase: Record<string, unknown> | null): string | n
* Inject project_root, agents_installed, missing_agents, and response_language
* into an init result object.
*
* Port of withProjectRoot from init.cjs lines 32-48.
* Port of withProjectRoot from init.cjs lines 32-63.
*
* @param projectDir - Absolute project root path
* @param result - The result object to augment
@@ -186,6 +237,24 @@ export function withProjectRoot(
result.response_language = responseLang;
}
const projectCode = config?.project_code;
if (projectCode) {
result.project_code = projectCode;
}
const projectMdPath = join(projectDir, '.planning', 'PROJECT.md');
try {
if (existsSync(projectMdPath)) {
const content = readFileSync(projectMdPath, 'utf-8');
const h1Match = content.match(/^#\s+(.+)$/m);
if (h1Match) {
result.project_title = h1Match[1].trim();
}
}
} catch {
/* intentionally empty */
}
return result;
}
@@ -214,7 +283,6 @@ export const initExecutePhase: QueryHandler = async (args, projectDir) => {
const milestone = await getMilestoneInfo(projectDir);
const phaseFound = !!(phaseInfo && phaseInfo.found);
const phaseNumber = (phaseInfo?.phase_number as string) || null;
const phaseSlug = (phaseInfo?.phase_slug as string) || null;
const plans = (phaseInfo?.plans || []) as string[];
@@ -225,6 +293,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir) => {
const result: Record<string, unknown> = {
executor_model: executorModel,
verifier_model: verifierModel,
tdd_mode: config.workflow.tdd_mode ?? false,
commit_docs: config.commit_docs,
sub_repos: (config as Record<string, unknown>).sub_repos ?? [],
parallelization: config.parallelization,
@@ -233,7 +302,7 @@ export const initExecutePhase: QueryHandler = async (args, projectDir) => {
phase_branch_template: config.git.phase_branch_template,
milestone_branch_template: config.git.milestone_branch_template,
verifier_enabled: config.workflow.verifier,
phase_found: phaseFound,
phase_found: !!phaseInfo,
phase_dir: (phaseInfo?.directory as string) ?? null,
phase_number: phaseNumber,
phase_name: (phaseInfo?.phase_name as string) ?? null,
@@ -292,20 +361,24 @@ export const initPlanPhase: QueryHandler = async (args, projectDir) => {
getModelAlias('gsd-plan-checker', projectDir),
]);
const phaseFound = !!(phaseInfo && phaseInfo.found);
const phaseNumber = (phaseInfo?.phase_number as string) || null;
const plans = (phaseInfo?.plans || []) as string[];
const cfg = config as GSDConfig;
const result: Record<string, unknown> = {
researcher_model: researcherModel,
planner_model: plannerModel,
checker_model: checkerModel,
tdd_mode: config.workflow.tdd_mode ?? false,
research_enabled: config.workflow.research,
plan_checker_enabled: config.workflow.plan_check,
nyquist_validation_enabled: config.workflow.nyquist_validation,
commit_docs: config.commit_docs,
text_mode: config.workflow.text_mode,
phase_found: phaseFound,
auto_advance: !!config.workflow.auto_advance,
auto_chain_active: !!cfg._auto_chain_active,
mode: cfg.mode ?? 'interactive',
phase_found: !!phaseInfo,
phase_dir: (phaseInfo?.directory as string) ?? null,
phase_number: phaseNumber,
phase_name: (phaseInfo?.phase_name as string) ?? null,
@@ -322,6 +395,7 @@ export const initPlanPhase: QueryHandler = async (args, projectDir) => {
state_path: toPosixPath(relative(projectDir, join(planningDir, 'STATE.md'))),
roadmap_path: toPosixPath(relative(projectDir, join(planningDir, 'ROADMAP.md'))),
requirements_path: toPosixPath(relative(projectDir, join(planningDir, 'REQUIREMENTS.md'))),
patterns_path: null,
};
// Add artifact paths if phase directory exists
@@ -339,6 +413,8 @@ export const initPlanPhase: QueryHandler = async (args, projectDir) => {
if (uatFile) result.uat_path = toPosixPath(join(phaseInfo.directory as string, uatFile));
const reviewsFile = files.find(f => f.endsWith('-REVIEWS.md') || f === 'REVIEWS.md');
if (reviewsFile) result.reviews_path = toPosixPath(join(phaseInfo.directory as string, reviewsFile));
const patternsFile = files.find(f => f.endsWith('-PATTERNS.md') || f === 'PATTERNS.md');
if (patternsFile) result.patterns_path = toPosixPath(join(phaseInfo.directory as string, patternsFile));
} catch { /* intentionally empty */ }
}
@@ -500,7 +576,7 @@ export const initVerifyWork: QueryHandler = async (args, projectDir) => {
}
const config = await loadConfig(projectDir);
const { phaseInfo } = await getPhaseInfoWithFallback(phase, projectDir);
const { phaseInfo } = await getPhaseInfoForVerifyWork(phase, projectDir);
const [plannerModel, checkerModel] = await Promise.all([
getModelAlias('gsd-planner', projectDir),
@@ -511,7 +587,7 @@ export const initVerifyWork: QueryHandler = async (args, projectDir) => {
planner_model: plannerModel,
checker_model: checkerModel,
commit_docs: config.commit_docs,
phase_found: !!(phaseInfo && phaseInfo.found),
phase_found: !!phaseInfo,
phase_dir: (phaseInfo?.directory as string) ?? null,
phase_number: (phaseInfo?.phase_number as string) ?? null,
phase_name: (phaseInfo?.phase_name as string) ?? null,
@@ -949,7 +1025,6 @@ export const initRemoveWorkspace: QueryHandler = async (args, _projectDir) => {
return { data: result };
};
// ─── initIngestDocs ───────────────────────────────────────────────────────
/**
@@ -969,16 +1044,3 @@ export const initIngestDocs: QueryHandler = async (_args, projectDir) => {
};
return { data: withProjectRoot(projectDir, result, config as Record<string, unknown>) };
};
// ─── docsInit ────────────────────────────────────────────────────────────
export const docsInit: QueryHandler = async (_args, projectDir) => {
return {
data: {
project_exists: existsSync(join(projectDir, '.planning', 'PROJECT.md')),
roadmap_exists: existsSync(join(projectDir, '.planning', 'ROADMAP.md')),
docs_dir: '.planning/docs',
project_root: projectDir,
},
};
};

View File

@@ -18,10 +18,10 @@
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { planningPaths } from './helpers.js';
import { planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
// ─── Constants ───────────────────────────────────────────────────────────
@@ -116,9 +116,11 @@ function searchArchMd(filePath: string, term: string): string[] {
// ─── Handlers ────────────────────────────────────────────────────────────
const INTEL_DISABLED_MSG = 'Intel system disabled. Set intel.enabled=true in config.json to activate.';
export const intelStatus: QueryHandler = async (_args, projectDir) => {
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: 'Intel system disabled. Set intel.enabled=true in config.json to activate.' } };
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
const now = Date.now();
const files: Record<string, unknown> = {};
@@ -149,7 +151,7 @@ export const intelStatus: QueryHandler = async (_args, projectDir) => {
export const intelDiff: QueryHandler = async (_args, projectDir) => {
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: 'Intel system disabled.' } };
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
const snapshotPath = intelFilePath(projectDir, '.last-refresh.json');
const snapshot = safeReadJson(snapshotPath) as Record<string, unknown> | null;
@@ -172,7 +174,7 @@ export const intelDiff: QueryHandler = async (_args, projectDir) => {
export const intelSnapshot: QueryHandler = async (_args, projectDir) => {
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: 'Intel system disabled.' } };
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
const dir = intelDir(projectDir);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -192,7 +194,7 @@ export const intelSnapshot: QueryHandler = async (_args, projectDir) => {
export const intelValidate: QueryHandler = async (_args, projectDir) => {
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: 'Intel system disabled.' } };
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
const errors: string[] = [];
const warnings: string[] = [];
@@ -220,7 +222,7 @@ export const intelValidate: QueryHandler = async (_args, projectDir) => {
export const intelQuery: QueryHandler = async (args, projectDir) => {
const term = args[0] || '';
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: 'Intel system disabled.' } };
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
const matches: unknown[] = [];
let total = 0;
@@ -241,9 +243,22 @@ export const intelQuery: QueryHandler = async (args, projectDir) => {
return { data: { matches, term, total } };
};
/**
* Extract exports from a JS/CJS/ESM file — port of `intelExtractExports` in `intel.cjs` (lines 502–614).
* Returns `{ file, exports, method }` with `file` as a resolved absolute path (matches `gsd-tools.cjs`).
*/
export const intelExtractExports: QueryHandler = async (args, projectDir) => {
const filePath = args[0] ? resolve(projectDir, args[0]) : '';
if (!filePath || !existsSync(filePath)) {
const raw = args[0];
if (!raw) {
return { data: { file: '', exports: [], method: 'none' } };
}
let filePath: string;
try {
filePath = await resolvePathUnderProject(projectDir, raw);
} catch {
return { data: { file: raw, exports: [], method: 'none' } };
}
if (!existsSync(filePath)) {
return { data: { file: filePath, exports: [], method: 'none' } };
}
@@ -253,9 +268,10 @@ export const intelExtractExports: QueryHandler = async (args, projectDir) => {
const allMatches = [...content.matchAll(/module\.exports\s*=\s*\{/g)];
if (allMatches.length > 0) {
const lastMatch = allMatches[allMatches.length - 1];
const lastMatch = allMatches[allMatches.length - 1]!;
const startIdx = lastMatch.index! + lastMatch[0].length;
let depth = 1; let endIdx = startIdx;
let depth = 1;
let endIdx = startIdx;
while (endIdx < content.length && depth > 0) {
if (content[endIdx] === '{') depth++;
else if (content[endIdx] === '}') depth--;
@@ -264,40 +280,90 @@ export const intelExtractExports: QueryHandler = async (args, projectDir) => {
const block = content.substring(startIdx, endIdx);
method = 'module.exports';
for (const line of block.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('//') || t.startsWith('*')) continue;
const k = t.match(/^(\w+)\s*[,}:]/) || t.match(/^(\w+)$/);
if (k) exports.push(k[1]);
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
const keyMatch = trimmed.match(/^(\w+)\s*[,}:]/) || trimmed.match(/^(\w+)$/);
if (keyMatch) exports.push(keyMatch[1]!);
}
}
for (const m of content.matchAll(/^exports\.(\w+)\s*=/gm)) {
if (!exports.includes(m[1])) { exports.push(m[1]); if (method === 'none') method = 'exports.X'; }
const individualPattern = /^exports\.(\w+)\s*=/gm;
let im: RegExpExecArray | null;
while ((im = individualPattern.exec(content)) !== null) {
if (!exports.includes(im[1]!)) {
exports.push(im[1]!);
if (method === 'none') method = 'exports.X';
}
}
const hadCjs = exports.length > 0;
const esmExports: string[] = [];
for (const m of content.matchAll(/^export\s+(?:default\s+)?(?:async\s+)?(?:function|class)\s+(\w+)/gm)) {
if (!esmExports.includes(m[1])) esmExports.push(m[1]);
const defaultNamedPattern = /^export\s+default\s+(?:function|class)\s+(\w+)/gm;
let em: RegExpExecArray | null;
while ((em = defaultNamedPattern.exec(content)) !== null) {
if (!esmExports.includes(em[1]!)) esmExports.push(em[1]!);
}
for (const m of content.matchAll(/^export\s+(?:const|let|var)\s+(\w+)\s*=/gm)) {
if (!esmExports.includes(m[1])) esmExports.push(m[1]);
const defaultAnonPattern = /^export\s+default\s+(?!function\s|class\s)/gm;
if (defaultAnonPattern.test(content) && esmExports.length === 0) {
if (!esmExports.includes('default')) esmExports.push('default');
}
for (const m of content.matchAll(/^export\s*\{([^}]+)\}/gm)) {
for (const item of m[1].split(',')) {
const name = item.trim().split(/\s+as\s+/)[0].trim();
const exportFnPattern = /^export\s+(?:async\s+)?function\s+(\w+)\s*\(/gm;
while ((em = exportFnPattern.exec(content)) !== null) {
if (!esmExports.includes(em[1]!)) esmExports.push(em[1]!);
}
const exportVarPattern = /^export\s+(?:const|let|var)\s+(\w+)\s*=/gm;
while ((em = exportVarPattern.exec(content)) !== null) {
if (!esmExports.includes(em[1]!)) esmExports.push(em[1]!);
}
const exportClassPattern = /^export\s+class\s+(\w+)/gm;
while ((em = exportClassPattern.exec(content)) !== null) {
if (!esmExports.includes(em[1]!)) esmExports.push(em[1]!);
}
const exportBlockPattern = /^export\s*\{([^}]+)\}/gm;
while ((em = exportBlockPattern.exec(content)) !== null) {
const items = em[1]!.split(',');
for (const item of items) {
const trimmed = item.trim();
if (!trimmed) continue;
const name = trimmed.split(/\s+as\s+/)[0]!.trim();
if (name && !esmExports.includes(name)) esmExports.push(name);
}
}
for (const e of esmExports) {
if (!exports.includes(e)) exports.push(e);
}
if (esmExports.length > 0 && exports.length > esmExports.length) method = 'mixed';
else if (esmExports.length > 0 && method === 'none') method = 'esm';
return { data: { file: args[0], exports, method } };
const hadEsm = esmExports.length > 0;
if (hadCjs && hadEsm) {
method = 'mixed';
} else if (hadEsm && !hadCjs) {
method = 'esm';
}
return { data: { file: filePath, exports, method } };
};
export const intelPatchMeta: QueryHandler = async (args, projectDir) => {
const filePath = args[0] ? resolve(projectDir, args[0]) : '';
if (!filePath || !existsSync(filePath)) {
const raw = args[0];
if (!raw) {
return { data: { patched: false, error: 'File not found' } };
}
let filePath: string;
try {
filePath = await resolvePathUnderProject(projectDir, raw);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { data: { patched: false, error: msg } };
}
if (!existsSync(filePath)) {
return { data: { patched: false, error: `File not found: ${filePath}` } };
}
try {
@@ -309,8 +375,30 @@ export const intelPatchMeta: QueryHandler = async (args, projectDir) => {
meta.updated_at = timestamp;
meta.version = ((meta.version as number) || 0) + 1;
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
return { data: { patched: true, file: args[0], timestamp } };
return { data: { patched: true, file: filePath, timestamp } };
} catch (err) {
return { data: { patched: false, error: String(err) } };
}
};
// ─── intelUpdate ───────────────────────────────────────────────────────────
/**
* `gsd-tools intel update` entry point: returns the same JSON as `intel.cjs` `intelUpdate`.
* Does not run the full graph refresh in-process — that work is done by the
* **gsd-intel-updater** agent after spawn. When `.planning/intel/` is disabled in config,
* returns `{ disabled: true, message }` so SDK output matches the CJS CLI.
*
* Port of `intelUpdate` from `intel.cjs` lines 314–321.
*/
export const intelUpdate: QueryHandler = async (_args, projectDir) => {
if (!isIntelEnabled(projectDir)) {
return { data: { disabled: true, message: INTEL_DISABLED_MSG } };
}
return {
data: {
action: 'spawn_agent',
message: 'Run gsd-tools intel update or spawn gsd-intel-updater agent for full refresh',
},
};
};

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { normalizeQueryCommand } from './normalize-query-command.js';
describe('normalizeQueryCommand', () => {
it('merges nested gsd-tools-style state + subcommand', () => {
expect(normalizeQueryCommand('state', ['json'])).toEqual(['state.json', []]);
expect(normalizeQueryCommand('state', ['validate'])).toEqual(['state.validate', []]);
});
it('maps bare state to state.load', () => {
expect(normalizeQueryCommand('state', [])).toEqual(['state.load', []]);
});
it('merges init workflows', () => {
expect(normalizeQueryCommand('init', ['execute-phase', '9'])).toEqual(['init.execute-phase', ['9']]);
expect(normalizeQueryCommand('init', ['new-project'])).toEqual(['init.new-project', []]);
});
it('maps scaffold to phase.scaffold', () => {
expect(normalizeQueryCommand('scaffold', ['phase-dir', '--phase', '1'])).toEqual([
'phase.scaffold',
['phase-dir', '--phase', '1'],
]);
});
it('merges progress and stats subcommands', () => {
expect(normalizeQueryCommand('progress', ['bar'])).toEqual(['progress.bar', []]);
expect(normalizeQueryCommand('stats', ['json'])).toEqual(['stats.json', []]);
});
it('passes through single-token commands', () => {
expect(normalizeQueryCommand('config-get', ['model_profile'])).toEqual(['config-get', ['model_profile']]);
expect(normalizeQueryCommand('generate-slug', ['Hello'])).toEqual(['generate-slug', ['Hello']]);
});
it('merges phase add-batch for future handler', () => {
expect(normalizeQueryCommand('check', ['config-gates', 'plan-phase'])).toEqual([
'check.config-gates',
['plan-phase'],
]);
expect(normalizeQueryCommand('check', ['phase-ready', '3'])).toEqual(['check.phase-ready', ['3']]);
expect(normalizeQueryCommand('check', ['auto-mode'])).toEqual(['check.auto-mode', []]);
expect(normalizeQueryCommand('route', ['next-action'])).toEqual(['route.next-action', []]);
expect(normalizeQueryCommand('phase', ['add-batch', '--descriptions', '[]'])).toEqual([
'phase.add-batch',
['--descriptions', '[]'],
]);
});
});

View File

@@ -1,7 +1,7 @@
/**
* Unit tests for phase lifecycle handlers.
*
* Tests phaseAdd, phaseInsert, phaseScaffold, replaceInCurrentMilestone,
* Tests phaseAdd, phaseAddBatch, phaseInsert, phaseScaffold, replaceInCurrentMilestone,
* and readModifyWriteRoadmapMd.
*/
@@ -247,6 +247,53 @@ describe('phaseAdd', () => {
});
});
// ─── phaseAddBatch ─────────────────────────────────────────────────────
describe('phaseAddBatch', () => {
it('adds multiple sequential phases in one pass', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, {
phases: ['09-foundation', '10-read-only-queries'],
});
const result = await phaseAddBatch(['Alpha', 'Beta'], tmpDir);
const data = result.data as { phases: Array<Record<string, unknown>>; count: number };
expect(data.count).toBe(2);
expect(data.phases[0].phase_number).toBe(11);
expect(data.phases[0].name).toBe('Alpha');
expect(data.phases[1].phase_number).toBe(12);
expect(data.phases[1].name).toBe('Beta');
const roadmap = await readFile(join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
expect(roadmap).toContain('### Phase 11: Alpha');
expect(roadmap).toContain('### Phase 12: Beta');
const phasesDir = join(tmpDir, '.planning', 'phases');
expect(existsSync(join(phasesDir, '11-alpha', '.gitkeep'))).toBe(true);
expect(existsSync(join(phasesDir, '12-beta', '.gitkeep'))).toBe(true);
});
it('accepts --descriptions JSON array', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir, { phases: ['09-foundation', '10-read-only-queries'] });
const result = await phaseAddBatch(
['--descriptions', JSON.stringify(['One', 'Two'])],
tmpDir,
);
const data = result.data as { count: number };
expect(data.count).toBe(2);
});
it('throws when no descriptions', async () => {
const { phaseAddBatch } = await import('./phase-lifecycle.js');
await setupTestProject(tmpDir);
await expect(phaseAddBatch([], tmpDir)).rejects.toThrow('descriptions array required');
});
});
// ─── phaseInsert ────────────────────────────────────────────────────────
describe('phaseInsert', () => {

View File

@@ -2,8 +2,8 @@
* Phase lifecycle handlers — add, insert, scaffold operations.
*
* Ported from get-shit-done/bin/lib/phase.cjs and commands.cjs.
* Provides phaseAdd (append phase), phaseInsert (decimal phase insertion),
* and phaseScaffold (template file/directory creation).
* Provides phaseAdd (append phase), phaseAddBatch (append multiple phases),
* phaseInsert (decimal phase insertion), and phaseScaffold (template file/directory creation).
*
* Shared helpers replaceInCurrentMilestone and readModifyWriteRoadmapMd
* are exported for use by downstream handlers (phaseComplete in Plan 03).
@@ -24,6 +24,7 @@ import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import {
escapeRegex,
normalizeMd,
normalizePhaseName,
comparePhaseNum,
phaseTokenMatches,
@@ -31,9 +32,15 @@ import {
planningPaths,
stateExtractField,
} from './helpers.js';
import { extractFrontmatter } from './frontmatter.js';
import { extractCurrentMilestone } from './roadmap.js';
import { getMilestonePhaseFilter } from './state.js';
import { acquireStateLock, releaseStateLock, stateReplaceField } from './state-mutation.js';
import {
acquireStateLock,
readModifyWriteStateMdFull,
releaseStateLock,
stateReplaceField,
} from './state-mutation.js';
import type { QueryHandler } from './utils.js';
// ─── Null byte validation ────────────────────────────────────────────────
@@ -238,6 +245,145 @@ export const phaseAdd: QueryHandler = async (args, projectDir) => {
return { data: result };
};
// ─── phaseAddBatch handler ────────────────────────────────────────────────
/**
* Query handler for phase.add-batch.
*
* Port of cmdPhaseAddBatch from phase.cjs lines 411-478.
* Appends multiple phases in one locked ROADMAP pass (sequential or custom naming).
*
* @param args - Either `--descriptions` followed by a JSON array string, or one description per arg (`--raw` ignored)
*/
export const phaseAddBatch: QueryHandler = async (args, projectDir) => {
let descriptions: string[];
const descIdx = args.indexOf('--descriptions');
if (descIdx !== -1 && args[descIdx + 1] !== undefined) {
try {
const parsed = JSON.parse(args[descIdx + 1]) as unknown;
if (!Array.isArray(parsed)) {
throw new GSDError('--descriptions must be a JSON array', ErrorClassification.Validation);
}
descriptions = parsed.map((x) => String(x));
} catch (e) {
if (e instanceof GSDError) throw e;
throw new GSDError('--descriptions must be a valid JSON array', ErrorClassification.Validation);
}
} else {
descriptions = args.filter((a) => a !== '--raw');
}
if (descriptions.length === 0) {
throw new GSDError('descriptions array required for phase add-batch', ErrorClassification.Validation);
}
for (const d of descriptions) {
assertNoNullBytes(d, 'description');
if (!d.trim()) {
throw new GSDError('description must be non-empty', ErrorClassification.Validation);
}
}
const roadmapPath = planningPaths(projectDir).roadmap;
if (!existsSync(roadmapPath)) {
throw new GSDError('ROADMAP.md not found', ErrorClassification.Validation);
}
let config: Record<string, unknown> = {};
try {
config = JSON.parse(await readFile(planningPaths(projectDir).config, 'utf-8'));
} catch { /* use defaults */ }
const projectCode = (config.project_code as string) || '';
assertSafeProjectCode(projectCode);
const prefix = projectCode ? `${projectCode}-` : '';
const added: Array<{
phase_number: string | number;
padded: string;
name: string;
slug: string;
directory: string;
naming_mode: unknown;
}> = [];
await readModifyWriteRoadmapMd(projectDir, async (initialContent) => {
let rawContent = initialContent;
const content = await extractCurrentMilestone(rawContent, projectDir);
let maxPhase = 0;
if (config.phase_naming !== 'custom') {
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(content)) !== null) {
const num = parseInt(m[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
const phasesOnDisk = planningPaths(projectDir).phases;
if (existsSync(phasesOnDisk)) {
const entries = await readdir(phasesOnDisk, { withFileTypes: true });
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)-/;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const match = entry.name.match(dirNumPattern);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
}
}
for (const description of descriptions) {
const slug = generateSlugInternal(description);
let newPhaseId: number | string;
let dirName: string;
if (config.phase_naming === 'custom') {
// Match CJS cmdPhaseAddBatch: slug.toUpperCase().replace(/-/g, '-') (identity on hyphens)
newPhaseId = slug.toUpperCase();
dirName = `${prefix}${newPhaseId}-${slug}`;
} else {
maxPhase += 1;
newPhaseId = maxPhase;
dirName = `${prefix}${String(newPhaseId).padStart(2, '0')}-${slug}`;
}
assertSafePhaseDirName(dirName);
const dirPath = join(planningPaths(projectDir).phases, dirName);
await mkdir(dirPath, { recursive: true });
await writeFile(join(dirPath, '.gitkeep'), '', 'utf-8');
const dependsOn =
config.phase_naming === 'custom'
? ''
: `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${newPhaseId} to break down)\n`;
const lastSeparator = rawContent.lastIndexOf('\n---');
rawContent =
lastSeparator > 0
? rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator)
: rawContent + phaseEntry;
added.push({
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
padded: typeof newPhaseId === 'number' ? String(newPhaseId).padStart(2, '0') : String(newPhaseId),
name: description,
slug,
directory: toPosixPath(relative(projectDir, join(planningPaths(projectDir).phases, dirName))),
naming_mode: config.phase_naming || 'sequential',
});
}
return rawContent;
});
return { data: { phases: added, count: added.length } };
};
// ─── phaseInsert handler ────────────────────────────────────────────────
/**
@@ -402,14 +548,36 @@ async function findPhaseDir(
* Port of cmdScaffold from commands.cjs lines 750-806.
* Creates template files (context, uat, verification) or phase directories.
*
* @param args - args[0]: type (required), args[1]: phase (required), args[2]: name (optional)
* @param args - Positional `[type, phase, name?]` **or** gsd-tools style
* `[type, '--phase', N, '--name', title]` (name may be multiple words).
* @param projectDir - Project root directory
* @returns QueryResult with { created, path } or { created: false, reason: 'already_exists' }
*/
export const phaseScaffold: QueryHandler = async (args, projectDir) => {
function normalizeScaffoldArgs(args: string[]): string[] {
const type = args[0];
const phase = args[1];
const name = args[2] || undefined;
if (!type || !args.includes('--phase')) {
return args;
}
const phaseIdx = args.indexOf('--phase');
const phase = phaseIdx !== -1 && args[phaseIdx + 1] && !args[phaseIdx + 1].startsWith('--')
? args[phaseIdx + 1]
: '';
const nameIdx = args.indexOf('--name');
let name: string | undefined;
if (nameIdx !== -1) {
const tail = args.slice(nameIdx + 1);
const stop = tail.findIndex(a => a.startsWith('--'));
const parts = stop === -1 ? tail : tail.slice(0, stop);
name = parts.join(' ').trim() || undefined;
}
return [type, phase, ...(name !== undefined && name !== '' ? [name] : [])];
}
export const phaseScaffold: QueryHandler = async (args, projectDir) => {
const normalized = normalizeScaffoldArgs(args);
const type = normalized[0];
const phase = normalized[1];
const name = normalized[2] || undefined;
if (!type) {
throw new GSDError('type required for scaffold', ErrorClassification.Validation);
@@ -1436,18 +1604,196 @@ export const phasesArchive: QueryHandler = async (args, projectDir) => {
// ─── milestoneComplete ────────────────────────────────────────────────────
export const milestoneComplete: QueryHandler = async (args, projectDir) => {
const version = args[0] || 'current';
try {
const archiveResult = await phasesArchive([], projectDir);
return {
data: {
completed: true,
version,
archive: archiveResult.data,
},
};
} catch (err) {
return { data: { completed: false, reason: String(err) } };
/** Port of `parseMultiwordArg` in `gsd-tools.cjs`. */
function parseMultiwordArg(args: string[], flag: string): string | null {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null;
const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) {
if (args[i]!.startsWith('--')) break;
tokens.push(args[i]!);
}
return tokens.length > 0 ? tokens.join(' ') : null;
}
/** Port of `extractOneLinerFromBody` from `core.cjs` / `summary.ts`. */
function extractOneLinerFromBody(content: string): string | null {
if (!content) return null;
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1]!.trim() : null;
}
/**
* Query handler for `milestone.complete` — port of `cmdMilestoneComplete` from `milestone.cjs`.
*/
export const milestoneComplete: QueryHandler = async (args, projectDir) => {
const version = args[0];
if (!version) {
throw new GSDError('version required for milestone complete (e.g., v1.0)', ErrorClassification.Validation);
}
assertNoNullBytes(version, 'version');
const nameOpt = parseMultiwordArg(args, 'name');
const archivePhases = args.includes('--archive-phases');
const paths = planningPaths(projectDir);
const roadmapPath = paths.roadmap;
const reqPath = paths.requirements;
const statePath = paths.state;
const milestonesPath = join(paths.planning, 'MILESTONES.md');
const archiveDir = join(paths.planning, 'milestones');
const phasesDir = paths.phases;
const today = new Date().toISOString().split('T')[0]!;
const milestoneName = nameOpt || version;
await mkdir(archiveDir, { recursive: true });
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
let phaseCount = 0;
let totalPlans = 0;
let totalTasks = 0;
const accomplishments: string[] = [];
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
for (const dir of dirs) {
if (!isDirInMilestone(dir)) continue;
phaseCount++;
const phaseFiles = await readdir(join(phasesDir, dir));
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter((f) => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
totalPlans += plans.length;
for (const s of summaries) {
try {
const content = await readFile(join(phasesDir, dir, s), 'utf-8');
const fm = extractFrontmatter(content);
const oneLiner =
(fm['one-liner'] as string | undefined) || extractOneLinerFromBody(content);
if (oneLiner) {
accomplishments.push(oneLiner);
}
const tasksFieldMatch = content.match(/\*\*Tasks:\*\*\s*(\d+)/);
if (tasksFieldMatch) {
totalTasks += parseInt(tasksFieldMatch[1]!, 10);
} else {
const xmlTaskMatches = content.match(/<task[\s>]/gi) || [];
const mdTaskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
totalTasks += xmlTaskMatches.length || mdTaskMatches.length;
}
} catch {
/* intentionally empty */
}
}
}
} catch {
/* intentionally empty */
}
if (existsSync(roadmapPath)) {
const roadmapContent = await readFile(roadmapPath, 'utf-8');
await writeFile(join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
}
if (existsSync(reqPath)) {
const reqContent = await readFile(reqPath, 'utf-8');
const archiveHeader =
`# Requirements Archive: ${version} ${milestoneName}\n\n` +
`**Archived:** ${today}\n**Status:** SHIPPED\n\n` +
`For current requirements, see \`.planning/REQUIREMENTS.md\`.\n\n---\n\n`;
await writeFile(join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
}
const auditFile = join(projectDir, '.planning', `${version}-MILESTONE-AUDIT.md`);
if (existsSync(auditFile)) {
await rename(auditFile, join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
}
const accomplishmentsList = accomplishments.map((a) => `- ${a}`).join('\n');
const milestoneEntry =
`## ${version} ${milestoneName} (Shipped: ${today})\n\n` +
`**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n` +
`**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
if (existsSync(milestonesPath)) {
const existing = await readFile(milestonesPath, 'utf-8');
if (!existing.trim()) {
await writeFile(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
} else {
const headerMatch = existing.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
if (headerMatch) {
const header = headerMatch[1]!;
const rest = existing.slice(header.length);
await writeFile(milestonesPath, normalizeMd(header + milestoneEntry + rest), 'utf-8');
} else {
await writeFile(milestonesPath, normalizeMd(milestoneEntry + existing), 'utf-8');
}
}
} else {
await writeFile(milestonesPath, normalizeMd(`# Milestones\n\n${milestoneEntry}`), 'utf-8');
}
if (existsSync(statePath)) {
await readModifyWriteStateMdFull(projectDir, (stateContent) => {
let next = stateReplaceFieldWithFallback(
stateContent,
'Status',
null,
`${version} milestone complete`,
);
next = stateReplaceFieldWithFallback(next, 'Last Activity', 'Last activity', today);
next = stateReplaceFieldWithFallback(
next,
'Last Activity Description',
null,
`${version} milestone completed and archived`,
);
return next;
});
}
let phasesArchived = false;
if (archivePhases) {
try {
const phaseArchiveDir = join(archiveDir, `${version}-phases`);
await mkdir(phaseArchiveDir, { recursive: true });
const phaseEntries = await readdir(phasesDir, { withFileTypes: true });
const phaseDirNames = phaseEntries.filter((e) => e.isDirectory()).map((e) => e.name);
let archivedCount = 0;
for (const dir of phaseDirNames) {
if (!isDirInMilestone(dir)) continue;
await rename(join(phasesDir, dir), join(phaseArchiveDir, dir));
archivedCount++;
}
phasesArchived = archivedCount > 0;
} catch {
/* intentionally empty */
}
}
return {
data: {
version,
name: milestoneName,
date: today,
phases: phaseCount,
plans: totalPlans,
tasks: totalTasks,
accomplishments,
archived: {
roadmap: existsSync(join(archiveDir, `${version}-ROADMAP.md`)),
requirements: existsSync(join(archiveDir, `${version}-REQUIREMENTS.md`)),
audit: existsSync(join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
phases: phasesArchived,
},
milestones_updated: true,
state_updated: existsSync(statePath),
},
};
};

View File

@@ -0,0 +1,88 @@
/**
* Unit tests for phase.list-plans and phase.list-artifacts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError } from '../errors.js';
import { phaseListPlans, phaseListArtifacts } from './phase-list-queries.js';
const PLAN_A = `---
phase: 09-foundation
plan: 01
wave: 1
must_haves:
truths: []
---
<objective>
A
</objective>
<tasks><task type="auto"><name>T</name></task></tasks>
`;
const PLAN_B = `---
phase: 09-foundation
plan: 02
wave: 1
---
<objective>
B
</objective>
<tasks><task type="auto"><name>T</name></task></tasks>
`;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-plans-'));
const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '09-01-PLAN.md'), PLAN_A);
await writeFile(join(phaseDir, '09-02-PLAN.md'), PLAN_B);
await writeFile(join(phaseDir, '09-CONTEXT.md'), 'ctx');
await writeFile(join(phaseDir, '09-RESEARCH.md'), 'res');
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe('phaseListPlans', () => {
it('lists all plans in phase', async () => {
const r = await phaseListPlans(['9'], tmpDir);
const data = r.data as { plans: Array<{ id: string }> };
expect(data.plans.map((p) => p.id).sort()).toEqual(['09-01', '09-02']);
});
it('filters with --with-schema', async () => {
const r = await phaseListPlans(['9', '--with-schema', 'must_haves'], tmpDir);
const data = r.data as { plans: Array<{ id: string }> };
expect(data.plans.map((p) => p.id)).toEqual(['09-01']);
});
it('throws when phase missing', async () => {
await expect(phaseListPlans([], tmpDir)).rejects.toThrow(GSDError);
});
});
describe('phaseListArtifacts', () => {
it('lists context artifacts', async () => {
const r = await phaseListArtifacts(['9', '--type', 'context'], tmpDir);
const data = r.data as { artifacts: string[] };
expect(data.artifacts.some((a) => a.endsWith('09-CONTEXT.md'))).toBe(true);
});
it('lists research artifacts', async () => {
const r = await phaseListArtifacts(['9', '--type', 'research'], tmpDir);
const data = r.data as { artifacts: string[] };
expect(data.artifacts.some((a) => a.endsWith('09-RESEARCH.md'))).toBe(true);
});
it('throws without --type', async () => {
await expect(phaseListArtifacts(['9'], tmpDir)).rejects.toThrow(GSDError);
});
});

View File

@@ -0,0 +1,152 @@
/**
* Handlers: phase.list-plans, phase.list-artifacts — deterministic plan/artifact listing
* for agents (replaces shell `ls` / `find` patterns). SDK-only; no gsd-tools.cjs mirror.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter } from './frontmatter.js';
import {
normalizePhaseName,
comparePhaseNum,
phaseTokenMatches,
toPosixPath,
planningPaths,
} from './helpers.js';
import type { QueryHandler } from './utils.js';
/** Resolve `.planning/phases/<dir>` for a phase token, or null. */
async function resolvePhaseDir(phase: string, projectDir: string): Promise<string | null> {
const phasesDir = planningPaths(projectDir).phases;
const normalized = normalizePhaseName(phase);
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => phaseTokenMatches(d, normalized));
return match ? join(phasesDir, match) : null;
} catch {
return null;
}
}
type ArtifactType = 'context' | 'summary' | 'verification' | 'research';
/**
* phase.list-artifacts — list CONTEXT / SUMMARY / VERIFICATION / RESEARCH files in a phase directory.
*
* Args: `<phase>` `--type` `<context|summary|verification|research>`
*/
export const phaseListArtifacts: QueryHandler = async (args, projectDir) => {
if (!args[0]) {
throw new GSDError('phase required', ErrorClassification.Validation);
}
const typeIdx = args.indexOf('--type');
if (typeIdx === -1 || !args[typeIdx + 1]) {
throw new GSDError('--type context|summary|verification|research required', ErrorClassification.Validation);
}
const phase = args[0];
const rawType = args[typeIdx + 1].toLowerCase();
const allowed: ArtifactType[] = ['context', 'summary', 'verification', 'research'];
if (!allowed.includes(rawType as ArtifactType)) {
throw new GSDError(`invalid --type ${rawType}`, ErrorClassification.Validation);
}
const artifactType = rawType as ArtifactType;
const phaseDir = await resolvePhaseDir(phase, projectDir);
if (!phaseDir) {
return { data: { phase: normalizePhaseName(phase), type: artifactType, artifacts: [], error: 'Phase not found' } };
}
const files = await readdir(phaseDir);
const baseNames = files.filter((f) => {
if (artifactType === 'context') {
return f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md';
}
if (artifactType === 'summary') {
return f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md';
}
if (artifactType === 'verification') {
return f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md';
}
return f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md';
});
const artifacts = baseNames.sort().map((f) =>
toPosixPath(relative(projectDir, join(phaseDir, f))),
);
return {
data: {
phase: normalizePhaseName(phase),
type: artifactType,
artifacts,
},
};
};
/**
* phase.list-plans — list PLAN files in a phase with optional frontmatter key filter.
*
* Args: `<phase>` [`--with-schema` `<yamlKey>`]
*/
export const phaseListPlans: QueryHandler = async (args, projectDir) => {
if (!args[0]) {
throw new GSDError('phase required', ErrorClassification.Validation);
}
let schemaKey: string | null = null;
const wsIdx = args.indexOf('--with-schema');
if (wsIdx !== -1) {
schemaKey = args[wsIdx + 1] ?? null;
if (!schemaKey) {
throw new GSDError('--with-schema requires a field name', ErrorClassification.Validation);
}
}
const phase = args[0];
const normalized = normalizePhaseName(phase);
const phaseDir = await resolvePhaseDir(phase, projectDir);
if (!phaseDir) {
return {
data: {
phase: normalized,
plans: [] as Array<Record<string, unknown>>,
error: 'Phase not found',
},
};
}
const phaseFiles = await readdir(phaseDir);
const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const plans: Array<Record<string, unknown>> = [];
for (const planFile of planFiles) {
const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
const planPath = join(phaseDir, planFile);
const content = await readFile(planPath, 'utf-8');
const fm = extractFrontmatter(content) as Record<string, unknown>;
if (schemaKey && !(schemaKey in fm)) {
continue;
}
plans.push({
id: planId,
file: toPosixPath(planFile),
wave: parseInt(String(fm.wave ?? '1'), 10) || 1,
autonomous: fm.autonomous !== false && fm.autonomous !== 'false',
frontmatter_keys: Object.keys(fm).sort(),
});
}
return {
data: {
phase: normalized,
with_schema: schemaKey,
plans,
},
};
};

View File

@@ -0,0 +1,65 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect } from 'vitest';
import { checkPhaseReady } from './phase-ready.js';
async function writeMinimalRoadmap(root: string): Promise<void> {
await mkdir(join(root, '.planning'), { recursive: true });
await writeFile(
join(root, '.planning', 'STATE.md'),
`---
milestone: v1.0
---
# State
`,
'utf-8',
);
await writeFile(
join(root, '.planning', 'ROADMAP.md'),
`## Milestone v1.0 — Test
### Phase 3: Sample Phase
**Goal:** Test goal
`,
'utf-8',
);
}
describe('checkPhaseReady', () => {
it('throws when phase is missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-pr-'));
await mkdir(join(dir, '.planning'), { recursive: true });
await expect(checkPhaseReady([], dir)).rejects.toThrow(/phase number required/);
});
it('returns discuss next_step when phase directory is missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-pr-'));
await writeMinimalRoadmap(dir);
const { data } = await checkPhaseReady(['3'], dir);
expect(data).toMatchObject({
found: false,
next_step: 'discuss',
ready: false,
});
});
it('returns plan when context exists but no plans', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-pr-'));
await writeMinimalRoadmap(dir);
const phaseDir = join(dir, '.planning', 'phases', '03-sample-phase');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '03-CONTEXT.md'), '# Ctx\n', 'utf-8');
const { data } = await checkPhaseReady(['3'], dir);
expect(data).toMatchObject({
found: true,
has_context: true,
plan_count: 0,
next_step: 'plan',
ready: true,
});
});
});

View File

@@ -0,0 +1,158 @@
/**
* Phase readiness snapshot (`check.phase-ready`).
*
* Deterministic file + plan/summary counts and a suggested `next_step` for orchestration.
* See `.planning/research/decision-routing-audit.md` §3.4.
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { existsSync, readdirSync } from 'node:fs';
import { GSDError, ErrorClassification } from '../errors.js';
import { comparePhaseNum, escapeRegex, normalizePhaseName, planningPaths } from './helpers.js';
import { findPhase } from './phase.js';
import { roadmapAnalyze } from './roadmap.js';
import type { QueryHandler } from './utils.js';
const UI_INDICATOR_RE = /UI|interface|frontend|component|layout|page|screen|view|form|dashboard|widget/i;
/**
* True if ROADMAP phase heading line for this phase matches UI_INDICATOR_RE.
*/
async function roadmapPhaseLineHasUiIndicators(
projectDir: string,
phaseNum: string,
): Promise<boolean> {
const roadmapPath = planningPaths(projectDir).roadmap;
let content: string;
try {
content = await readFile(roadmapPath, 'utf-8');
} catch {
return false;
}
const re = new RegExp(
`#{2,4}\\s*Phase\\s+${escapeRegex(phaseNum)}\\s*:[^\\n]*`,
'i',
);
const m = content.match(re);
if (!m) return false;
return UI_INDICATOR_RE.test(m[0]);
}
function hasUiSpecFile(phaseDirFull: string): boolean {
if (!existsSync(phaseDirFull)) return false;
try {
const files = readdirSync(phaseDirFull);
return files.some(f => f === 'UI-SPEC.md' || f.endsWith('-UI-SPEC.md'));
} catch {
return false;
}
}
/**
* Whether all roadmap phases strictly before `phaseNum` are complete on disk / roadmap.
*/
function dependenciesMet(
phases: Array<Record<string, unknown>>,
phaseNum: string,
): boolean {
const sorted = [...phases].sort((a, b) =>
comparePhaseNum(String(a.number), String(b.number)),
);
const idx = sorted.findIndex(p => normalizePhaseName(String(p.number)) === normalizePhaseName(phaseNum));
if (idx <= 0) return true;
for (let i = 0; i < idx; i++) {
const p = sorted[i];
const complete =
p.roadmap_complete === true ||
p.disk_status === 'complete';
if (!complete) return false;
}
return true;
}
type NextStep = 'discuss' | 'plan' | 'execute' | 'verify' | 'complete';
function inferNextStep(params: {
found: boolean;
has_context: boolean;
has_research: boolean;
plan_count: number;
incomplete_plans: string[];
has_verification: boolean;
}): NextStep {
if (!params.found) return 'discuss';
if (!params.has_context && !params.has_research) return 'discuss';
if (params.plan_count === 0) return 'plan';
if (params.incomplete_plans.length > 0) return 'execute';
if (!params.has_verification) return 'verify';
return 'complete';
}
export const checkPhaseReady: QueryHandler = async (args, projectDir) => {
const raw = args[0];
if (!raw) {
throw new GSDError('phase number required for check phase-ready', ErrorClassification.Validation);
}
const phaseArg = normalizePhaseName(raw);
const phaseRes = await findPhase([raw], projectDir);
const pdata = phaseRes.data as Record<string, unknown>;
const found = Boolean(pdata.found);
const planCount = (pdata.plans as string[] | undefined)?.length ?? 0;
const incomplete = (pdata.incomplete_plans as string[] | undefined) ?? [];
const has_context = Boolean(pdata.has_context);
const has_research = Boolean(pdata.has_research);
const has_verification = Boolean(pdata.has_verification);
let has_ui_spec = false;
let phaseDirFull: string | null = null;
if (found && pdata.directory) {
phaseDirFull = join(projectDir, pdata.directory as string);
has_ui_spec = hasUiSpecFile(phaseDirFull);
}
const phaseNumForRoadmap = (pdata.phase_number as string) || phaseArg;
const has_ui_indicators =
(await roadmapPhaseLineHasUiIndicators(projectDir, phaseNumForRoadmap)) ||
(phaseNumForRoadmap !== phaseArg ? await roadmapPhaseLineHasUiIndicators(projectDir, phaseArg) : false);
const analysis = await roadmapAnalyze([], projectDir);
const adata = analysis.data as { phases?: Array<Record<string, unknown>> };
const phases = adata.phases ?? [];
const deps = dependenciesMet(phases, phaseArg);
const next_step = inferNextStep({
found,
has_context,
has_research,
plan_count: planCount,
incomplete_plans: incomplete,
has_verification,
});
/** Phase exists on disk and prior roadmap phases are complete — safe to focus on `next_step`. */
const ready = found && deps;
return {
data: {
found,
ready,
phase: phaseArg,
phase_name: (pdata.phase_name as string) ?? null,
phase_dir: (pdata.directory as string) ?? null,
has_context,
has_research,
has_plans: planCount > 0,
plan_count: planCount,
incomplete_plans: incomplete.length,
has_verification,
has_ui_spec,
has_ui_indicators,
dependencies_met: deps,
blockers: [] as string[],
next_step,
},
};
};

View File

@@ -0,0 +1,65 @@
/**
* Unit tests for plan.task-structure.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { planTaskStructure } from './plan-task-structure.js';
const PLAN = `---
phase: 09-foundation
plan: "01"
wave: 2
depends_on: []
autonomous: false
---
<objective>
Test objective
</objective>
<tasks>
<task type="auto">
<name>First</name>
</task>
<task type="checkpoint">
<name>Gate</name>
</task>
</tasks>
`;
let tmpDir: string;
let planPath: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-pts-'));
const rel = join('.planning', 'phases', '09-x', '09-01-PLAN.md');
planPath = join(tmpDir, rel);
await mkdir(join(tmpDir, '.planning', 'phases', '09-x'), { recursive: true });
await writeFile(planPath, PLAN);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe('planTaskStructure', () => {
it('returns wave, tasks, and checkpoints', async () => {
const rel = join('.planning', 'phases', '09-x', '09-01-PLAN.md');
const r = await planTaskStructure([rel], tmpDir);
const d = r.data as {
wave: number;
autonomous: boolean;
task_count: number;
checkpoint_count: number;
tasks: Array<{ is_checkpoint: boolean }>;
};
expect(d.wave).toBe(2);
expect(d.autonomous).toBe(false);
expect(d.task_count).toBe(2);
expect(d.checkpoint_count).toBe(1);
expect(d.tasks.filter((t) => t.is_checkpoint).length).toBe(1);
});
});

View File

@@ -0,0 +1,63 @@
/**
* plan.task-structure — structured task / checkpoint / wave metadata from a PLAN.md file.
*/
import { readFile } from 'node:fs/promises';
import { GSDError, ErrorClassification } from '../errors.js';
import { parsePlan } from '../plan-parser.js';
import { resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
/**
* Args: `<path-to-PLAN.md>` (repo-relative or absolute under projectDir)
*/
export const planTaskStructure: QueryHandler = async (args, projectDir) => {
const rel = args[0];
if (!rel) {
throw new GSDError('PLAN.md path required', ErrorClassification.Validation);
}
let path: string;
try {
path = await resolvePathUnderProject(projectDir, rel);
} catch (err) {
if (err instanceof GSDError) {
throw new GSDError(`cannot read plan file: ${err.message}`, ErrorClassification.Blocked);
}
throw err;
}
let content: string;
try {
content = await readFile(path, 'utf-8');
} catch {
throw new GSDError(`cannot read plan file: ${rel}`, ErrorClassification.Blocked);
}
const parsed = parsePlan(content);
const fm = parsed.frontmatter;
const checkpoints = parsed.tasks.filter((t) => t.type === 'checkpoint');
return {
data: {
path: rel,
plan: fm.plan || null,
phase: fm.phase || null,
wave: fm.wave ?? 1,
depends_on: fm.depends_on ?? [],
autonomous: fm.autonomous !== false,
task_count: parsed.tasks.length,
checkpoint_count: checkpoints.length,
tasks: parsed.tasks.map((t, i) => ({
index: i + 1,
type: t.type,
name: t.name,
is_checkpoint: t.type === 'checkpoint',
})),
checkpoints: checkpoints.map((t, i) => ({
index: i + 1,
name: t.name,
})),
},
};
};

View File

@@ -0,0 +1,247 @@
/**
* `extract-messages` — parity with `get-shit-done/bin/lib/profile-pipeline.cjs` `cmdExtractMessages`.
* Writes JSONL to a temp file and returns metadata (same shape as CJS stdout JSON).
*/
import { appendFileSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { basename, join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { getScanSessionsRoot, scanProjectDir, readSessionIndex, getProjectName } from './profile-scan-sessions.js';
export type ExtractMessagesResult = {
output_file: string;
project: string;
sessions_processed: number;
sessions_skipped: number;
messages_extracted: number;
messages_truncated: number;
};
/** JSONL line shape from session exports — shared by filters and stream parser. */
export type SessionJsonlRecord = {
type?: string;
userType?: string;
isMeta?: boolean;
isSidechain?: boolean;
message?: { content?: string };
cwd?: string;
timestamp?: string | number;
};
/** Same filter as CJS `isGenuineUserMessage` in profile-pipeline.cjs. */
export function isGenuineUserMessage(record: SessionJsonlRecord): boolean {
if (record.type !== 'user') return false;
if (record.userType !== 'external') return false;
if (record.isMeta === true) return false;
if (record.isSidechain === true) return false;
const content = record.message?.content;
if (typeof content !== 'string') return false;
if (content.length === 0) return false;
if (content.startsWith('<local-command')) return false;
if (content.startsWith('<command-')) return false;
if (content.startsWith('<task-notification')) return false;
if (content.startsWith('<local-command-stdout')) return false;
return true;
}
/** Default maxLen 2000 matches CJS `truncateContent` for stream extraction. */
export function truncateContent(content: string, maxLen = 2000): string {
if (content.length <= maxLen) return content;
return content.substring(0, maxLen) + '... [truncated]';
}
/** Line-delimited JSONL reader — same behavior as CJS `streamExtractMessages`. */
export async function streamExtractMessages(
filePath: string,
filterFn: (r: SessionJsonlRecord) => boolean,
maxMessages: number,
): Promise<
Array<{
sessionId: string;
projectPath: string | null;
timestamp: string | number | null;
content: string;
}>
> {
const rl = createInterface({
input: createReadStream(filePath),
crlfDelay: Infinity,
terminal: false,
});
const messages: Array<{
sessionId: string;
projectPath: string | null;
timestamp: string | number | null;
content: string;
}> = [];
const sessionId = basename(filePath, '.jsonl');
for await (const line of rl) {
if (messages.length >= maxMessages) break;
let record: SessionJsonlRecord;
try {
record = JSON.parse(line) as SessionJsonlRecord;
} catch {
continue;
}
if (!filterFn(record)) continue;
const content = record.message?.content;
if (typeof content !== 'string') continue;
messages.push({
sessionId,
projectPath: record.cwd ?? null,
timestamp: record.timestamp ?? null,
content: truncateContent(content),
});
}
return messages;
}
/**
* Port of `cmdExtractMessages` — same JSON result as `gsd-tools extract-messages` (stdout object;
* message lines are in `output_file` JSONL, not inlined).
*/
export async function runExtractMessages(
projectArg: string,
options: { sessionId: string | null; limit: number | null },
overridePath: string | null,
): Promise<ExtractMessagesResult> {
const sessionsDir = getScanSessionsRoot(overridePath);
if (!sessionsDir) {
const searchedPath = overridePath || '~/.claude/projects';
throw new GSDError(
`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`,
ErrorClassification.Validation,
);
}
let projectDirs: string[];
try {
projectDirs = readdirSync(sessionsDir).filter((entry) => {
const fullPath = join(sessionsDir, entry);
try {
return statSync(fullPath).isDirectory();
} catch {
return false;
}
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Cannot read sessions directory: ${msg}`, ErrorClassification.Validation);
}
let matchedDir: string | null = null;
let matchedName: string | null = null;
for (const dirName of projectDirs) {
if (dirName === projectArg) {
matchedDir = dirName;
break;
}
}
if (!matchedDir) {
const lowerArg = projectArg.toLowerCase();
const matches = projectDirs.filter((d) => d.toLowerCase().includes(lowerArg));
if (matches.length === 1) {
matchedDir = matches[0]!;
} else if (matches.length > 1) {
const exactNameMatches: Array<{ dirName: string; name: string }> = [];
for (const dirName of matches) {
const indexData = readSessionIndex(join(sessionsDir, dirName));
const pName = getProjectName(dirName, indexData);
if (pName.toLowerCase() === lowerArg) {
exactNameMatches.push({ dirName, name: pName });
}
}
if (exactNameMatches.length === 1) {
matchedDir = exactNameMatches[0]!.dirName;
matchedName = exactNameMatches[0]!.name;
} else {
const names = matches.map((d) => {
const idx = readSessionIndex(join(sessionsDir, d));
return ` - ${getProjectName(d, idx)} (${d})`;
});
throw new GSDError(
`Multiple projects match "${projectArg}":\n${names.join('\n')}\nBe more specific.`,
ErrorClassification.Validation,
);
}
}
}
if (!matchedDir) {
const available = projectDirs.map((d) => {
const idx = readSessionIndex(join(sessionsDir, d));
return ` - ${getProjectName(d, idx)}`;
});
throw new GSDError(
`No project matching "${projectArg}". Available projects:\n${available.join('\n')}`,
ErrorClassification.Validation,
);
}
const projectPath = join(sessionsDir, matchedDir);
const indexData = readSessionIndex(projectPath);
const projectName = matchedName || getProjectName(matchedDir, indexData);
let sessions = scanProjectDir(projectPath);
if (options.sessionId) {
sessions = sessions.filter((s) => s.sessionId === options.sessionId);
if (sessions.length === 0) {
throw new GSDError(
`Session "${options.sessionId}" not found in project "${projectName}".`,
ErrorClassification.Validation,
);
}
}
if (options.limit !== null && options.limit !== undefined && options.limit > 0) {
sessions = sessions.slice(0, options.limit);
}
const tmpDir = mkdtempSync(join(tmpdir(), 'gsd-pipeline-'));
const outputPath = join(tmpDir, 'extracted-messages.jsonl');
appendFileSync(outputPath, '');
let sessionsProcessed = 0;
let sessionsSkipped = 0;
let messagesExtracted = 0;
let messagesTruncated = 0;
const batchLimit = 300;
for (let i = 0; i < sessions.length; i++) {
if (messagesExtracted >= batchLimit) break;
const session = sessions[i]!;
try {
const remaining = batchLimit - messagesExtracted;
const msgs = await streamExtractMessages(session.filePath, isGenuineUserMessage, remaining);
for (const msg of msgs) {
appendFileSync(outputPath, JSON.stringify(msg) + '\n');
messagesExtracted++;
if (msg.content.endsWith('... [truncated]')) {
messagesTruncated++;
}
}
sessionsProcessed++;
} catch {
sessionsSkipped++;
}
}
return {
output_file: outputPath,
project: projectName,
sessions_processed: sessionsProcessed,
sessions_skipped: sessionsSkipped,
messages_extracted: messagesExtracted,
messages_truncated: messagesTruncated,
};
}

View File

@@ -0,0 +1,908 @@
/**
* Profile output handlers — USER-PROFILE.md, dev-preferences, CLAUDE.md sections.
* Ported from `get-shit-done/bin/lib/profile-output.cjs` (`cmdWriteProfile`,
* `cmdGenerateDevPreferences`, `cmdGenerateClaudeProfile`, `cmdGenerateClaudeMd`).
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from 'node:fs';
import { homedir } from 'node:os';
import { dirname, isAbsolute, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from '../config.js';
import { GSDError, ErrorClassification } from '../errors.js';
import { CLAUDE_INSTRUCTIONS } from './profile-questionnaire-data.js';
import type { QueryHandler } from './utils.js';
const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), '../../../get-shit-done/templates');
const DIMENSION_KEYS = [
'communication_style',
'decision_speed',
'explanation_depth',
'debugging_approach',
'ux_philosophy',
'vendor_philosophy',
'frustration_triggers',
'learning_style',
] as const;
const CLAUDE_MD_FALLBACKS = {
project: 'Project not yet initialized. Run /gsd-new-project to set up.',
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
skills:
'No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file.',
};
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills', '.codex/skills'];
const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
'',
'Use these entry points:',
'- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks',
'- `/gsd-debug` for investigation and bug fixing',
'- `/gsd-execute-phase` for planned phase work',
'',
'Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.',
].join('\n');
const CLAUDE_MD_PROFILE_PLACEHOLDER = [
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
'> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.',
'> This section is managed by `generate-claude-profile` -- do not edit manually.',
'<!-- GSD:profile-end -->',
].join('\n');
function safeReadFile(filePath: string): string | null {
try {
return existsSync(filePath) ? readFileSync(filePath, 'utf-8') : null;
} catch {
return null;
}
}
function extractMarkdownSection(content: string, sectionName: string): string | null {
if (!content) return null;
const lines = content.split('\n');
let capturing = false;
const result: string[] = [];
const headingPattern = new RegExp(`^## ${sectionName}\\s*$`);
for (const line of lines) {
if (headingPattern.test(line)) {
capturing = true;
result.push(line);
continue;
}
if (capturing && /^## /.test(line)) break;
if (capturing) result.push(line);
}
return result.length > 0 ? result.join('\n').trim() : null;
}
function extractSectionContent(fileContent: string, sectionName: string): string | null {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
const endIdx = fileContent.indexOf(endMarker);
if (startIdx === -1 || endIdx === -1) return null;
const startTagEnd = fileContent.indexOf('-->', startIdx);
if (startTagEnd === -1) return null;
return fileContent.substring(startTagEnd + 3, endIdx);
}
function buildSection(sectionName: string, sourceFile: string, content: string): string {
return [`<!-- GSD:${sectionName}-start source:${sourceFile} -->`, content, `<!-- GSD:${sectionName}-end -->`].join('\n');
}
function updateSection(
fileContent: string,
sectionName: string,
newContent: string,
): { content: string; action: string } {
const startMarker = `<!-- GSD:${sectionName}-start`;
const endMarker = `<!-- GSD:${sectionName}-end -->`;
const startIdx = fileContent.indexOf(startMarker);
const endIdx = fileContent.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const before = fileContent.substring(0, startIdx);
const after = fileContent.substring(endIdx + endMarker.length);
return { content: before + newContent + after, action: 'replaced' };
}
return { content: fileContent.trimEnd() + '\n\n' + newContent + '\n', action: 'appended' };
}
function detectManualEdit(fileContent: string, sectionName: string, expectedContent: string): boolean {
const currentContent = extractSectionContent(fileContent, sectionName);
if (currentContent === null) return false;
const normalize = (s: string) => s.trim().replace(/\n{3,}/g, '\n\n');
return normalize(currentContent) !== normalize(expectedContent);
}
function generateProjectSection(cwd: string): { content: string; source: string; hasFallback: boolean } {
const projectPath = join(cwd, '.planning', 'PROJECT.md');
const content = safeReadFile(projectPath);
if (!content) {
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', hasFallback: true };
}
const parts: string[] = [];
const h1Match = content.match(/^# (.+)$/m);
if (h1Match) parts.push(`**${h1Match[1]}**`);
const whatThisIs = extractMarkdownSection(content, 'What This Is');
if (whatThisIs) {
const body = whatThisIs.replace(/^## What This Is\s*/i, '').trim();
if (body) parts.push(body);
}
const coreValue = extractMarkdownSection(content, 'Core Value');
if (coreValue) {
const body = coreValue.replace(/^## Core Value\s*/i, '').trim();
if (body) parts.push(`**Core Value:** ${body}`);
}
const constraints = extractMarkdownSection(content, 'Constraints');
if (constraints) {
const body = constraints.replace(/^## Constraints\s*/i, '').trim();
if (body) parts.push(`### Constraints\n\n${body}`);
}
if (parts.length === 0) {
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', hasFallback: true };
}
return { content: parts.join('\n\n'), source: 'PROJECT.md', hasFallback: false };
}
function generateStackSection(cwd: string): { content: string; source: string; hasFallback: boolean } {
const codebasePath = join(cwd, '.planning', 'codebase', 'STACK.md');
const researchPath = join(cwd, '.planning', 'research', 'STACK.md');
let content = safeReadFile(codebasePath);
let source = 'codebase/STACK.md';
if (!content) {
content = safeReadFile(researchPath);
source = 'research/STACK.md';
}
if (!content) {
return { content: CLAUDE_MD_FALLBACKS.stack, source: 'STACK.md', hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
let inTable = false;
for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('# ') || summaryLines.length > 0) summaryLines.push(line);
continue;
}
if (line.startsWith('|')) {
inTable = true;
summaryLines.push(line);
continue;
}
if (inTable && line.trim() === '') inTable = false;
if (line.startsWith('- ') || line.startsWith('* ')) summaryLines.push(line);
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source, hasFallback: false };
}
function generateConventionsSection(cwd: string): { content: string; source: string; hasFallback: boolean } {
const conventionsPath = join(cwd, '.planning', 'codebase', 'CONVENTIONS.md');
const content = safeReadFile(conventionsPath);
if (!content) {
return { content: CLAUDE_MD_FALLBACKS.conventions, source: 'CONVENTIONS.md', hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('# ')) summaryLines.push(line);
continue;
}
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|')) summaryLines.push(line);
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source: 'CONVENTIONS.md', hasFallback: false };
}
function generateArchitectureSection(cwd: string): { content: string; source: string; hasFallback: boolean } {
const architecturePath = join(cwd, '.planning', 'codebase', 'ARCHITECTURE.md');
const content = safeReadFile(architecturePath);
if (!content) {
return { content: CLAUDE_MD_FALLBACKS.architecture, source: 'ARCHITECTURE.md', hasFallback: true };
}
const lines = content.split('\n');
const summaryLines: string[] = [];
for (const line of lines) {
if (line.startsWith('#')) {
if (!line.startsWith('# ')) summaryLines.push(line);
continue;
}
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|') || line.startsWith('```')) {
summaryLines.push(line);
}
}
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
return { content: summary, source: 'ARCHITECTURE.md', hasFallback: false };
}
function generateWorkflowSection(): { content: string; source: string; hasFallback: boolean } {
return { content: CLAUDE_MD_WORKFLOW_ENFORCEMENT, source: 'GSD defaults', hasFallback: false };
}
function extractSkillFrontmatter(content: string): { name: string; description: string } {
const result = { name: '', description: '' };
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!fmMatch) return result;
const fmBlock = fmMatch[1]!;
const lines = fmBlock.split('\n');
let currentKey = '';
for (const line of lines) {
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
if (kvMatch) {
currentKey = kvMatch[1]!;
const value = kvMatch[2]!.trim();
if (currentKey === 'name') result.name = value;
if (currentKey === 'description') result.description = value;
continue;
}
if (currentKey === 'description' && /^\s+/.test(line)) {
result.description += ` ${line.trim()}`;
} else {
currentKey = '';
}
}
return result;
}
function generateSkillsSection(cwd: string): { content: string; source: string; hasFallback: boolean } {
const discovered: Array<{ name: string; description: string; path: string }> = [];
for (const dir of SKILL_SEARCH_DIRS) {
const absDir = join(cwd, dir);
if (!existsSync(absDir)) continue;
let entries;
try {
entries = readdirSync(absDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('gsd-')) continue;
const skillMdPath = join(absDir, entry.name, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
const content = safeReadFile(skillMdPath);
if (!content) continue;
const frontmatter = extractSkillFrontmatter(content);
const name = frontmatter.name || entry.name;
const description = frontmatter.description || '';
if (discovered.some((s) => s.name === name)) continue;
discovered.push({ name, description, path: `${dir}/${entry.name}` });
}
}
if (discovered.length === 0) {
return { content: CLAUDE_MD_FALLBACKS.skills, source: 'skills/', hasFallback: true };
}
const lines = ['| Skill | Description | Path |', '|-------|-------------|------|'];
for (const skill of discovered) {
const desc = skill.description.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
const safeName = skill.name.replace(/\|/g, '\\|');
lines.push(`| ${safeName} | ${desc} | \`${skill.path}/SKILL.md\` |`);
}
return { content: lines.join('\n'), source: 'skills/', hasFallback: false };
}
const SENSITIVE_PATTERNS = [
/sk-[a-zA-Z0-9]{20,}/g,
/Bearer\s+[a-zA-Z0-9._-]+/gi,
/password\s*[:=]\s*\S+/gi,
/secret\s*[:=]\s*\S+/gi,
/token\s*[:=]\s*\S+/gi,
/api[_-]?key\s*[:=]\s*\S+/gi,
/\/Users\/[a-zA-Z0-9._-]+\//g,
/\/home\/[a-zA-Z0-9._-]+\//g,
/ghp_[a-zA-Z0-9]{36}/g,
/gho_[a-zA-Z0-9]{36}/g,
/xoxb-[a-zA-Z0-9-]+/g,
];
function cmdWriteProfileLogic(
cwd: string,
options: { input: string; output?: string | null },
): Record<string, unknown> {
let analysisPath = options.input;
if (!isAbsolute(analysisPath)) analysisPath = join(cwd, analysisPath);
if (!existsSync(analysisPath)) {
throw new GSDError(`Analysis file not found: ${analysisPath}`, ErrorClassification.Validation);
}
let analysis: Record<string, unknown>;
try {
analysis = JSON.parse(readFileSync(analysisPath, 'utf-8')) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Failed to parse analysis JSON: ${msg}`, ErrorClassification.Validation);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
throw new GSDError('Analysis JSON must contain a "dimensions" object', ErrorClassification.Validation);
}
if (!analysis.profile_version) {
throw new GSDError('Analysis JSON must contain "profile_version"', ErrorClassification.Validation);
}
let redactedCount = 0;
function redactSensitive(text: string): string {
if (typeof text !== 'string') return text;
let result = text;
for (const pattern of SENSITIVE_PATTERNS) {
pattern.lastIndex = 0;
const matches = result.match(pattern);
if (matches) {
redactedCount += matches.length;
result = result.replace(pattern, '[REDACTED]');
}
}
return result;
}
const dimensions = analysis.dimensions as Record<string, Record<string, unknown>>;
for (const dimKey of Object.keys(dimensions)) {
const dim = dimensions[dimKey];
if (!dim) continue;
if (dim.evidence && Array.isArray(dim.evidence)) {
for (const ev of dim.evidence as Array<Record<string, unknown>>) {
if (ev.quote) ev.quote = redactSensitive(String(ev.quote));
if (ev.example) ev.example = redactSensitive(String(ev.example));
if (ev.signal) ev.signal = redactSensitive(String(ev.signal));
}
}
}
if (redactedCount > 0) {
process.stderr.write(`Sensitive content redacted: ${redactedCount} pattern(s) removed from evidence quotes\n`);
}
const templatePath = join(TEMPLATE_DIR, 'user-profile.md');
if (!existsSync(templatePath)) {
throw new GSDError(`Template not found: ${templatePath}`, ErrorClassification.Validation);
}
let template = readFileSync(templatePath, 'utf-8');
const dimensionLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Philosophy',
vendor_philosophy: 'Vendor Philosophy',
frustration_triggers: 'Frustration Triggers',
learning_style: 'Learning Style',
};
const summaryLines: string[] = [];
let highCount = 0;
let mediumCount = 0;
let lowCount = 0;
let dimensionsScored = 0;
for (const dimKey of DIMENSION_KEYS) {
const dim = dimensions[dimKey];
if (!dim) continue;
const conf = String(dim.confidence ?? '').toUpperCase();
if (conf === 'HIGH' || conf === 'MEDIUM' || conf === 'LOW') dimensionsScored++;
if (conf === 'HIGH') {
highCount++;
if (dim.claude_instruction) {
summaryLines.push(`- **${dimensionLabels[dimKey] || dimKey}:** ${dim.claude_instruction} (HIGH)`);
}
} else if (conf === 'MEDIUM') {
mediumCount++;
if (dim.claude_instruction) {
summaryLines.push(`- **${dimensionLabels[dimKey] || dimKey}:** ${dim.claude_instruction} (MEDIUM)`);
}
} else if (conf === 'LOW') {
lowCount++;
}
}
const summaryInstructions =
summaryLines.length > 0 ? summaryLines.join('\n') : '- No high or medium confidence dimensions scored yet.';
const projectsList = (analysis.projects_list ?? analysis.projects_analyzed) as unknown[] | undefined;
const projectsArr = Array.isArray(projectsList) ? projectsList : [];
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, String(analysis.data_source ?? 'session_analysis'));
template = template.replace(/\{\{projects_list\}\}/g, projectsArr.join(', '));
template = template.replace(/\{\{message_count\}\}/g, String(analysis.message_count ?? analysis.messages_analyzed ?? 0));
template = template.replace(/\{\{summary_instructions\}\}/g, summaryInstructions);
template = template.replace(/\{\{profile_version\}\}/g, String(analysis.profile_version));
template = template.replace(/\{\{projects_count\}\}/g, String(projectsArr.length));
template = template.replace(/\{\{dimensions_scored\}\}/g, String(dimensionsScored));
template = template.replace(/\{\{high_confidence_count\}\}/g, String(highCount));
template = template.replace(/\{\{medium_confidence_count\}\}/g, String(mediumCount));
template = template.replace(/\{\{low_confidence_count\}\}/g, String(lowCount));
template = template.replace(
/\{\{sensitive_excluded_summary\}\}/g,
redactedCount > 0 ? `${redactedCount} pattern(s) redacted` : 'None detected',
);
for (const dimKey of DIMENSION_KEYS) {
const dim = dimensions[dimKey] || {};
const rating = String(dim.rating ?? 'UNSCORED');
const confidence = String(dim.confidence ?? 'UNSCORED');
const instruction = String(
dim.claude_instruction ??
'No strong preference detected. Ask the developer when this dimension is relevant.',
);
const summary = String(dim.summary ?? '');
let evidenceBlock = '';
const evidenceArr = (dim.evidence_quotes ?? dim.evidence) as unknown;
if (evidenceArr && Array.isArray(evidenceArr) && evidenceArr.length > 0) {
const evidenceLines = (evidenceArr as Array<Record<string, unknown>>).map((ev) => {
const signal = String(ev.signal ?? ev.pattern ?? '');
const quote = String(ev.quote ?? ev.example ?? '');
const project = String(ev.project ?? 'unknown');
return `- **Signal:** ${signal} / **Example:** "${quote}" -- project: ${project}`;
});
evidenceBlock = evidenceLines.join('\n');
} else {
evidenceBlock = '- No evidence collected for this dimension.';
}
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.rating\\}\\}`, 'g'), rating);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.confidence\\}\\}`, 'g'), confidence);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.claude_instruction\\}\\}`, 'g'), instruction);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.summary\\}\\}`, 'g'), summary);
template = template.replace(new RegExp(`\\{\\{${dimKey}\\.evidence\\}\\}`, 'g'), evidenceBlock);
}
let outputPath = options.output;
if (!outputPath) {
outputPath = join(homedir(), '.claude', 'get-shit-done', 'USER-PROFILE.md');
} else if (!isAbsolute(outputPath)) {
outputPath = join(cwd, outputPath);
}
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, template, 'utf-8');
return {
profile_path: outputPath,
dimensions_scored: dimensionsScored,
high_confidence: highCount,
medium_confidence: mediumCount,
low_confidence: lowCount,
sensitive_redacted: redactedCount,
source: String(analysis.data_source ?? 'session_analysis'),
};
}
export const writeProfile: QueryHandler = async (args, projectDir) => {
const inputFlag = args.indexOf('--input');
const inputPath = inputFlag >= 0 ? args[inputFlag + 1] : null;
const outputFlag = args.indexOf('--output');
const outputPath = outputFlag >= 0 ? args[outputFlag + 1] : null;
if (!inputPath) {
throw new GSDError('--input <analysis-json-path> is required', ErrorClassification.Validation);
}
const data = cmdWriteProfileLogic(projectDir, { input: inputPath, output: outputPath ?? null });
return { data };
};
export const generateDevPreferences: QueryHandler = async (args, projectDir) => {
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx >= 0 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPathOpt = outputIdx >= 0 ? args[outputIdx + 1] : null;
const stackIdx = args.indexOf('--stack');
const stackOpt = stackIdx >= 0 ? args[stackIdx + 1] : null;
if (!analysisPath) {
throw new GSDError('--analysis <path> is required', ErrorClassification.Validation);
}
let ap = analysisPath;
if (!isAbsolute(ap)) ap = join(projectDir, ap);
if (!existsSync(ap)) {
throw new GSDError(`Analysis file not found: ${ap}`, ErrorClassification.Validation);
}
let analysis: Record<string, unknown>;
try {
analysis = JSON.parse(readFileSync(ap, 'utf-8')) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Failed to parse analysis JSON: ${msg}`, ErrorClassification.Validation);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
throw new GSDError('Analysis JSON must contain a "dimensions" object', ErrorClassification.Validation);
}
const devPrefLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decision Support',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Approach',
vendor_philosophy: 'Library & Tool Choices',
frustration_triggers: 'Boundaries',
learning_style: 'Learning Support',
};
const templatePath = join(TEMPLATE_DIR, 'dev-preferences.md');
if (!existsSync(templatePath)) {
throw new GSDError(`Template not found: ${templatePath}`, ErrorClassification.Validation);
}
let template = readFileSync(templatePath, 'utf-8');
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
const dimensions = analysis.dimensions as Record<string, Record<string, unknown>>;
for (const dimKey of DIMENSION_KEYS) {
const dim = dimensions[dimKey];
if (!dim) continue;
const label = devPrefLabels[dimKey] || dimKey;
const confidence = String(dim.confidence ?? 'UNSCORED');
let instruction = dim.claude_instruction as string | undefined;
if (!instruction) {
const lookup = CLAUDE_INSTRUCTIONS[dimKey];
const rating = dim.rating as string | undefined;
if (lookup && rating && lookup[rating]) {
instruction = lookup[rating];
} else {
instruction = `Adapt to this developer's ${dimKey.replace(/_/g, ' ')} preference.`;
}
}
directiveLines.push(`### ${label}\n${instruction} (${confidence} confidence)\n`);
dimensionsIncluded.push(dimKey);
}
const directivesBlock = directiveLines.join('\n').trim();
template = template.replace(/\{\{behavioral_directives\}\}/g, directivesBlock);
template = template.replace(/\{\{generated_at\}\}/g, new Date().toISOString());
template = template.replace(/\{\{data_source\}\}/g, String(analysis.data_source ?? 'session_analysis'));
let stackBlock: string;
if (analysis.data_source === 'questionnaire') {
stackBlock =
'Stack preferences not available (questionnaire-only profile). Run `/gsd-profile-user --refresh` with session data to populate.';
} else if (stackOpt) {
stackBlock = stackOpt;
} else {
stackBlock = 'Stack preferences will be populated from session analysis.';
}
template = template.replace(/\{\{stack_preferences\}\}/g, stackBlock);
let outPath = outputPathOpt;
if (!outPath) {
outPath = join(homedir(), '.claude', 'commands', 'gsd', 'dev-preferences.md');
} else if (!isAbsolute(outPath)) {
outPath = join(projectDir, outPath);
}
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, template, 'utf-8');
return {
data: {
command_path: outPath,
command_name: '/gsd-dev-preferences',
dimensions_included: dimensionsIncluded,
source: String(analysis.data_source ?? 'session_analysis'),
},
};
};
export const generateClaudeProfile: QueryHandler = async (args, projectDir) => {
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx >= 0 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPathOpt = outputIdx >= 0 ? args[outputIdx + 1] : null;
const globalFlag = args.includes('--global');
if (!analysisPath) {
throw new GSDError('--analysis <path> is required', ErrorClassification.Validation);
}
let ap = analysisPath;
if (!isAbsolute(ap)) ap = join(projectDir, ap);
if (!existsSync(ap)) {
throw new GSDError(`Analysis file not found: ${ap}`, ErrorClassification.Validation);
}
let analysis: Record<string, unknown>;
try {
analysis = JSON.parse(readFileSync(ap, 'utf-8')) as Record<string, unknown>;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Failed to parse analysis JSON: ${msg}`, ErrorClassification.Validation);
}
if (!analysis.dimensions || typeof analysis.dimensions !== 'object') {
throw new GSDError('Analysis JSON must contain a "dimensions" object', ErrorClassification.Validation);
}
const profileLabels: Record<string, string> = {
communication_style: 'Communication',
decision_speed: 'Decisions',
explanation_depth: 'Explanations',
debugging_approach: 'Debugging',
ux_philosophy: 'UX Philosophy',
vendor_philosophy: 'Vendor Choices',
frustration_triggers: 'Frustrations',
learning_style: 'Learning',
};
const dataSource = String(analysis.data_source ?? 'session_analysis');
const tableRows: string[] = [];
const directiveLines: string[] = [];
const dimensionsIncluded: string[] = [];
const dimensions = analysis.dimensions as Record<string, Record<string, unknown>>;
for (const dimKey of DIMENSION_KEYS) {
const dim = dimensions[dimKey];
if (!dim) continue;
const label = profileLabels[dimKey] || dimKey;
const rating = String(dim.rating ?? 'UNSCORED');
const confidence = String(dim.confidence ?? 'UNSCORED');
tableRows.push(`| ${label} | ${rating} | ${confidence} |`);
let instruction = dim.claude_instruction as string | undefined;
if (!instruction) {
const lookup = CLAUDE_INSTRUCTIONS[dimKey];
const r = dim.rating as string | undefined;
if (lookup && r && lookup[r]) {
instruction = lookup[r];
} else {
instruction = `Adapt to this developer's ${dimKey.replace(/_/g, ' ')} preference.`;
}
}
directiveLines.push(`- **${label}:** ${instruction}`);
dimensionsIncluded.push(dimKey);
}
const sectionLines = [
'<!-- GSD:profile-start -->',
'## Developer Profile',
'',
`> Generated by GSD from ${dataSource}. Run \`/gsd-profile-user --refresh\` to update.`,
'',
'| Dimension | Rating | Confidence |',
'|-----------|--------|------------|',
...tableRows,
'',
'**Directives:**',
...directiveLines,
'<!-- GSD:profile-end -->',
];
const sectionContent = sectionLines.join('\n');
let targetPath: string;
if (globalFlag) {
targetPath = join(homedir(), '.claude', 'CLAUDE.md');
} else if (outputPathOpt) {
targetPath = isAbsolute(outputPathOpt) ? outputPathOpt : join(projectDir, outputPathOpt);
} else {
let configClaudeMdPath = './CLAUDE.md';
try {
const config = await loadConfig(projectDir);
const p = config.claude_md_path;
if (typeof p === 'string' && p) configClaudeMdPath = p;
} catch {
/* default */
}
targetPath = isAbsolute(configClaudeMdPath)
? configClaudeMdPath
: join(projectDir, configClaudeMdPath);
}
let action: string;
if (existsSync(targetPath)) {
let existingContent = readFileSync(targetPath, 'utf-8');
const startMarker = '<!-- GSD:profile-start -->';
const endMarker = '<!-- GSD:profile-end -->';
const startIdx = existingContent.indexOf(startMarker);
const endIdx = existingContent.indexOf(endMarker);
if (startIdx !== -1 && endIdx !== -1) {
const before = existingContent.substring(0, startIdx);
const after = existingContent.substring(endIdx + endMarker.length);
existingContent = before + sectionContent + after;
action = 'updated';
} else {
existingContent = existingContent.trimEnd() + '\n\n' + sectionContent + '\n';
action = 'appended';
}
writeFileSync(targetPath, existingContent, 'utf-8');
} else {
mkdirSync(dirname(targetPath), { recursive: true });
writeFileSync(targetPath, `${sectionContent}\n`, 'utf-8');
action = 'created';
}
return {
data: {
claude_md_path: targetPath,
action,
dimensions_included: dimensionsIncluded,
is_global: globalFlag,
},
};
};
export const generateClaudeMd: QueryHandler = async (args, projectDir) => {
const outputIdx = args.indexOf('--output');
const outputPathOpt = outputIdx >= 0 ? args[outputIdx + 1] : null;
const autoFlag = args.includes('--auto');
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'] as const;
const generators: Record<
(typeof MANAGED_SECTIONS)[number],
(cwd: string) => { content: string; source: string; hasFallback: boolean }
> = {
project: generateProjectSection,
stack: generateStackSection,
conventions: generateConventionsSection,
architecture: generateArchitectureSection,
skills: generateSkillsSection,
workflow: () => generateWorkflowSection(),
};
const sectionHeadings: Record<(typeof MANAGED_SECTIONS)[number], string> = {
project: '## Project',
stack: '## Technology Stack',
conventions: '## Conventions',
architecture: '## Architecture',
skills: '## Project Skills',
workflow: '## GSD Workflow Enforcement',
};
const generated: Record<
string,
{ content: string; source: string; hasFallback: boolean }
> = {};
const sectionsGenerated: string[] = [];
const sectionsFallback: string[] = [];
const sectionsSkipped: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generators[name](projectDir);
generated[name] = gen;
if (gen.hasFallback) {
sectionsFallback.push(name);
} else {
sectionsGenerated.push(name);
}
}
let outputPath: string;
if (!outputPathOpt) {
let configClaudeMdPath = './CLAUDE.md';
try {
const config = await loadConfig(projectDir);
const p = config.claude_md_path;
if (typeof p === 'string' && p) configClaudeMdPath = p;
} catch {
/* default */
}
outputPath = isAbsolute(configClaudeMdPath)
? configClaudeMdPath
: join(projectDir, configClaudeMdPath);
} else if (!isAbsolute(outputPathOpt)) {
outputPath = join(projectDir, outputPathOpt);
} else {
outputPath = outputPathOpt;
}
let existingContent = safeReadFile(outputPath);
let action: string;
if (existingContent === null) {
const sections: string[] = [];
for (const name of MANAGED_SECTIONS) {
const gen = generated[name]!;
const heading = sectionHeadings[name];
const body = `${heading}\n\n${gen.content}`;
sections.push(buildSection(name, gen.source, body));
}
sections.push('');
sections.push(CLAUDE_MD_PROFILE_PLACEHOLDER);
existingContent = `${sections.join('\n\n')}\n`;
action = 'created';
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, existingContent, 'utf-8');
} else {
action = 'updated';
let fileContent = existingContent;
for (const name of MANAGED_SECTIONS) {
const gen = generated[name]!;
const heading = sectionHeadings[name];
const body = `${heading}\n\n${gen.content}`;
const fullSection = buildSection(name, gen.source, body);
const hasMarkers = fileContent.indexOf(`<!-- GSD:${name}-start`) !== -1;
if (hasMarkers) {
if (autoFlag) {
const expectedBody = `${heading}\n\n${gen.content}`;
if (detectManualEdit(fileContent, name, expectedBody)) {
sectionsSkipped.push(name);
const genIdx = sectionsGenerated.indexOf(name);
if (genIdx !== -1) sectionsGenerated.splice(genIdx, 1);
const fbIdx = sectionsFallback.indexOf(name);
if (fbIdx !== -1) sectionsFallback.splice(fbIdx, 1);
continue;
}
}
const result = updateSection(fileContent, name, fullSection);
fileContent = result.content;
} else {
const result = updateSection(fileContent, name, fullSection);
fileContent = result.content;
}
}
if (!autoFlag && fileContent.indexOf('<!-- GSD:profile-start') === -1) {
fileContent = `${fileContent.trimEnd()}\n\n${CLAUDE_MD_PROFILE_PLACEHOLDER}\n`;
}
writeFileSync(outputPath, fileContent, 'utf-8');
}
const finalContent = safeReadFile(outputPath);
let profileStatus: string;
if (finalContent && finalContent.indexOf('<!-- GSD:profile-start') !== -1) {
if (action === 'created' || existingContent.indexOf('<!-- GSD:profile-start') === -1) {
profileStatus = 'placeholder_added';
} else {
profileStatus = 'exists';
}
} else {
profileStatus = 'already_present';
}
const genCount = sectionsGenerated.length;
const totalManaged = MANAGED_SECTIONS.length;
let message = `Generated ${genCount}/${totalManaged} sections.`;
if (sectionsFallback.length > 0) message += ` Fallback: ${sectionsFallback.join(', ')}.`;
if (sectionsSkipped.length > 0) message += ` Skipped (manually edited): ${sectionsSkipped.join(', ')}.`;
if (profileStatus === 'placeholder_added') message += ' Run /gsd-profile-user to unlock Developer Profile.';
return {
data: {
claude_md_path: outputPath,
action,
sections_generated: sectionsGenerated,
sections_fallback: sectionsFallback,
sections_skipped: sectionsSkipped,
sections_total: totalManaged,
profile_status: profileStatus,
message,
},
};
};

View File

@@ -0,0 +1,181 @@
/**
* Synced from get-shit-done/bin/lib/profile-output.cjs (PROFILING_QUESTIONS, CLAUDE_INSTRUCTIONS).
* Used by profileQuestionnaire for parity with cmdProfileQuestionnaire.
*/
export type ProfilingOption = { label: string; value: string; rating: string };
export type ProfilingQuestion = {
dimension: string;
header: string;
context: string;
question: string;
options: ProfilingOption[];
};
export const PROFILING_QUESTIONS: ProfilingQuestion[] = [
{
dimension: 'communication_style',
header: 'Communication Style',
context: 'Think about the last few times you asked Claude to build or change something. How did you frame the request?',
question: 'When you ask Claude to build something, how much context do you typically provide?',
options: [
{ label: 'Minimal -- "fix the bug", "add dark mode", just say what\'s needed', value: 'a', rating: 'terse-direct' },
{ label: 'Some context -- explain what and why in a paragraph or two', value: 'b', rating: 'conversational' },
{ label: 'Detailed specs -- headers, numbered lists, problem analysis, constraints', value: 'c', rating: 'detailed-structured' },
{ label: 'It depends on the task -- simple tasks get short prompts, complex ones get detailed specs', value: 'd', rating: 'mixed' },
],
},
{
dimension: 'decision_speed',
header: 'Decision Making',
context: 'Think about times when Claude presented you with multiple options -- like choosing a library, picking an architecture, or selecting an approach.',
question: 'When Claude presents you with options, how do you typically decide?',
options: [
{ label: 'Pick quickly based on gut feeling or past experience', value: 'a', rating: 'fast-intuitive' },
{ label: 'Ask for a comparison table or pros/cons, then decide', value: 'b', rating: 'deliberate-informed' },
{ label: 'Research independently (read docs, check GitHub stars) before deciding', value: 'c', rating: 'research-first' },
{ label: 'Let Claude recommend -- I generally trust the suggestion', value: 'd', rating: 'delegator' },
],
},
{
dimension: 'explanation_depth',
header: 'Explanation Preferences',
context: 'Think about when Claude explains code it wrote or an approach it took. How much detail feels right?',
question: 'When Claude explains something, how much detail do you want?',
options: [
{ label: 'Just the code -- I\'ll read it and figure it out myself', value: 'a', rating: 'code-only' },
{ label: 'Brief explanation with the code -- a sentence or two about the approach', value: 'b', rating: 'concise' },
{ label: 'Detailed walkthrough -- explain the approach, trade-offs, and code structure', value: 'c', rating: 'detailed' },
{ label: 'Deep dive -- teach me the concepts behind it so I understand the fundamentals', value: 'd', rating: 'educational' },
],
},
{
dimension: 'debugging_approach',
header: 'Debugging Style',
context: 'Think about the last few times something broke in your code. How did you approach it with Claude?',
question: 'When something breaks, how do you typically approach debugging with Claude?',
options: [
{ label: 'Paste the error and say "fix it" -- get it working fast', value: 'a', rating: 'fix-first' },
{ label: 'Share the error plus context, ask Claude to diagnose what went wrong', value: 'b', rating: 'diagnostic' },
{ label: 'Investigate myself first, then ask Claude about my specific theories', value: 'c', rating: 'hypothesis-driven' },
{ label: 'Walk through the code together step by step to understand the issue', value: 'd', rating: 'collaborative' },
],
},
{
dimension: 'ux_philosophy',
header: 'UX Philosophy',
context: 'Think about user-facing features you have built recently. How did you balance functionality with design?',
question: 'When building user-facing features, what do you prioritize?',
options: [
{ label: 'Get it working first, polish the UI later (or never)', value: 'a', rating: 'function-first' },
{ label: 'Basic usability from the start -- nothing ugly, but no pixel-perfection', value: 'b', rating: 'pragmatic' },
{ label: 'Design and UX are as important as functionality -- I care about the experience', value: 'c', rating: 'design-conscious' },
{ label: 'I mostly build backend, CLI, or infrastructure -- UX is minimal', value: 'd', rating: 'backend-focused' },
],
},
{
dimension: 'vendor_philosophy',
header: 'Library & Vendor Choices',
context: 'Think about the last time you needed a library or service for a project. How did you go about choosing it?',
question: 'When choosing libraries or services, what is your typical approach?',
options: [
{ label: 'Use whatever Claude suggests -- speed matters more than the perfect choice', value: 'a', rating: 'pragmatic-fast' },
{ label: 'Prefer well-known, battle-tested options (React, PostgreSQL, Express)', value: 'b', rating: 'conservative' },
{ label: 'Research alternatives, read docs, compare benchmarks before committing', value: 'c', rating: 'thorough-evaluator' },
{ label: 'Strong opinions -- I already know what I like and I stick with it', value: 'd', rating: 'opinionated' },
],
},
{
dimension: 'frustration_triggers',
header: 'Frustration Triggers',
context: 'Think about moments when working with AI coding assistants that made you frustrated or annoyed.',
question: 'What frustrates you most when working with AI coding assistants?',
options: [
{ label: 'Doing things I didn\'t ask for -- adding features, refactoring code, scope creep', value: 'a', rating: 'scope-creep' },
{ label: 'Not following instructions precisely -- ignoring constraints or requirements I stated', value: 'b', rating: 'instruction-adherence' },
{ label: 'Over-explaining or being too verbose -- just give me the code and move on', value: 'c', rating: 'verbosity' },
{ label: 'Breaking working code while fixing something else -- regressions', value: 'd', rating: 'regression' },
],
},
{
dimension: 'learning_style',
header: 'Learning Preferences',
context: 'Think about encountering something new -- an unfamiliar library, a codebase you inherited, a concept you hadn\'t used before.',
question: 'When you encounter something new in your codebase, how do you prefer to learn about it?',
options: [
{ label: 'Read the code directly -- I figure things out by reading and experimenting', value: 'a', rating: 'self-directed' },
{ label: 'Ask Claude to explain the relevant parts to me', value: 'b', rating: 'guided' },
{ label: 'Read official docs and tutorials first, then try things', value: 'c', rating: 'documentation-first' },
{ label: 'See a working example, then modify it to understand how it works', value: 'd', rating: 'example-driven' },
],
},
];
export const CLAUDE_INSTRUCTIONS: Record<string, Record<string, string>> = {
communication_style: {
'terse-direct': 'Keep responses concise and action-oriented. Skip lengthy preambles. Match this developer\'s direct style.',
'conversational': 'Use a natural conversational tone. Explain reasoning briefly alongside code. Engage with the developer\'s questions.',
'detailed-structured': 'Match this developer\'s structured communication: use headers for sections, numbered lists for steps, and acknowledge provided context before responding.',
'mixed': 'Adapt response detail to match the complexity of each request. Brief for simple tasks, detailed for complex ones.',
},
decision_speed: {
'fast-intuitive': 'Present a single strong recommendation with brief justification. Skip lengthy comparisons unless asked.',
'deliberate-informed': 'Present options in a structured comparison table with pros/cons. Let the developer make the final call.',
'research-first': 'Include links to docs, GitHub repos, or benchmarks when recommending tools. Support the developer\'s research process.',
'delegator': 'Make clear recommendations with confidence. Explain your reasoning briefly, but own the suggestion.',
},
explanation_depth: {
'code-only': 'Prioritize code output. Add comments inline rather than prose explanations. Skip walkthroughs unless asked.',
'concise': 'Pair code with a brief explanation (1-2 sentences) of the approach. Keep prose minimal.',
'detailed': 'Explain the approach, key trade-offs, and code structure alongside the implementation. Use headers to organize.',
'educational': 'Teach the underlying concepts and principles, not just the implementation. Relate new patterns to fundamentals.',
},
debugging_approach: {
'fix-first': 'Prioritize the fix. Show the corrected code first, then optionally explain what was wrong. Minimize diagnostic preamble.',
'diagnostic': 'Diagnose the root cause before presenting the fix. Explain what went wrong and why the fix addresses it.',
'hypothesis-driven': 'Engage with the developer\'s theories. Validate or refine their hypotheses before jumping to solutions.',
'collaborative': 'Walk through the debugging process step by step. Explain the investigation approach, not just the conclusion.',
},
ux_philosophy: {
'function-first': 'Focus on functionality and correctness. Keep UI minimal and functional. Skip design polish unless requested.',
'pragmatic': 'Build clean, usable interfaces without over-engineering. Apply basic design principles (spacing, alignment, contrast).',
'design-conscious': 'Invest in UX quality: thoughtful spacing, smooth transitions, responsive layouts. Treat design as a first-class concern.',
'backend-focused': 'Optimize for developer experience (clear APIs, good error messages, helpful CLI output) over visual design.',
},
vendor_philosophy: {
'pragmatic-fast': 'Suggest libraries quickly based on popularity and reliability. Don\'t over-analyze choices for non-critical dependencies.',
'conservative': 'Recommend well-established, widely-adopted tools with strong community support. Avoid bleeding-edge options.',
'thorough-evaluator': 'Compare alternatives with specific metrics (bundle size, GitHub stars, maintenance activity). Support informed decisions.',
'opinionated': 'Respect the developer\'s existing tool preferences. Ask before suggesting alternatives to their preferred stack.',
},
frustration_triggers: {
'scope-creep': 'Do exactly what is asked -- nothing more. Never add unrequested features, refactoring, or "improvements". Ask before expanding scope.',
'instruction-adherence': 'Follow instructions precisely. Re-read constraints before responding. If requirements conflict, flag the conflict rather than silently choosing.',
'verbosity': 'Be concise. Lead with code, follow with brief explanation only if needed. Avoid restating the problem or unnecessary context.',
'regression': 'Before modifying working code, verify the change is safe. Run existing tests mentally. Flag potential regression risks explicitly.',
},
learning_style: {
'self-directed': 'Point to relevant code sections and let the developer explore. Add signposts (file paths, function names) rather than full explanations.',
'guided': 'Explain concepts in context of the developer\'s codebase. Use their actual code as examples when teaching.',
'documentation-first': 'Link to official documentation and relevant sections. Structure explanations like reference material.',
'example-driven': 'Lead with working code examples. Show a minimal example first, then explain how to extend or modify it.',
},
};
export function isAmbiguousAnswer(dimension: string, value: string): boolean {
if (dimension === 'communication_style' && value === 'd') return true;
const question = PROFILING_QUESTIONS.find((q) => q.dimension === dimension);
if (!question) return false;
const option = question.options.find((o) => o.value === value);
if (!option) return false;
return option.rating === 'mixed';
}
export function generateClaudeInstruction(dimension: string, rating: string): string {
const dimInstructions = CLAUDE_INSTRUCTIONS[dimension];
if (dimInstructions && dimInstructions[rating]) {
return dimInstructions[rating]!;
}
return `Adapt to this developer's ${dimension.replace(/_/g, ' ')} preference: ${rating}.`;
}

View File

@@ -0,0 +1,184 @@
/**
* `profile-sample` — parity with `get-shit-done/bin/lib/profile-pipeline.cjs` `cmdProfileSample`.
*/
import { appendFileSync, mkdtempSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSDError, ErrorClassification } from '../errors.js';
import { getScanSessionsRoot, scanProjectDir, readSessionIndex, getProjectName } from './profile-scan-sessions.js';
import { isGenuineUserMessage, streamExtractMessages, truncateContent } from './profile-extract-messages.js';
export type ProfileSampleResult = {
output_file: string;
projects_sampled: number;
messages_sampled: number;
per_project_cap: number;
message_char_limit: number;
skipped_context_dumps: number;
project_breakdown: Array<{ project: string; messages: number; sessions: number }>;
};
/**
* Port of `cmdProfileSample` — same JSON + JSONL file shape as `gsd-tools profile-sample`.
*/
export async function runProfileSample(
overridePath: string | null,
options: { limit: number; maxPerProject: number | null; maxChars: number },
): Promise<ProfileSampleResult> {
const sessionsDir = getScanSessionsRoot(overridePath);
if (!sessionsDir) {
const searchedPath = overridePath || '~/.claude/projects';
throw new GSDError(
`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`,
ErrorClassification.Validation,
);
}
const limit = options.limit || 150;
const maxChars = options.maxChars || 500;
const maxPerProject = options.maxPerProject;
let projectDirs: string[];
try {
projectDirs = readdirSync(sessionsDir).filter((entry) => {
const fullPath = join(sessionsDir, entry);
try {
return statSync(fullPath).isDirectory();
} catch {
return false;
}
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new GSDError(`Cannot read sessions directory: ${msg}`, ErrorClassification.Validation);
}
if (projectDirs.length === 0) {
throw new GSDError('No project directories found in sessions directory.', ErrorClassification.Validation);
}
const projectMeta: Array<{
dirName: string;
projectPath: string;
sessions: ReturnType<typeof scanProjectDir>;
projectName: string;
lastActive: Date;
}> = [];
for (const dirName of projectDirs) {
const projectPath = join(sessionsDir, dirName);
const sessions = scanProjectDir(projectPath);
if (sessions.length === 0) continue;
const indexData = readSessionIndex(projectPath);
const projectName = getProjectName(dirName, indexData);
const lastActive = sessions[0]!.modified;
projectMeta.push({ dirName, projectPath, sessions, projectName, lastActive });
}
projectMeta.sort((a, b) => b.lastActive.getTime() - a.lastActive.getTime());
const projectCount = projectMeta.length;
if (projectCount === 0) {
throw new GSDError('No projects with sessions found.', ErrorClassification.Validation);
}
const perProjectCap = maxPerProject || Math.max(5, Math.floor(limit / projectCount));
const recencyThreshold = Date.now() - 30 * 24 * 60 * 60 * 1000;
const allMessages: Array<{
sessionId: string;
projectName: string;
projectPath: string | null;
timestamp: string | number | null;
content: string;
}> = [];
let skippedContextDumps = 0;
const projectBreakdown: Array<{ project: string; messages: number; sessions: number }> = [];
for (const proj of projectMeta) {
if (allMessages.length >= limit) break;
const cappedSessions = proj.sessions.slice(0, perProjectCap);
let projectMessages = 0;
let projectSessionsUsed = 0;
for (const session of cappedSessions) {
if (allMessages.length >= limit) break;
const isRecent = session.modified.getTime() >= recencyThreshold;
const perSessionMax = isRecent ? 10 : 3;
const remaining = Math.min(perSessionMax, limit - allMessages.length);
try {
const msgs = await streamExtractMessages(session.filePath, isGenuineUserMessage, remaining);
let sessionUsed = false;
for (const msg of msgs) {
if (allMessages.length >= limit) break;
const content = msg.content || '';
if (content.startsWith('This session is being continued')) {
skippedContextDumps++;
continue;
}
const lines = content.split('\n').filter((l) => l.trim().length > 0);
if (lines.length > 3) {
const logPattern = /^\[?(DEBUG|INFO|WARN|ERROR|LOG)\]?/i;
const timestampPattern = /^\d{4}-\d{2}-\d{2}/;
const logLines = lines.filter(
(l) => logPattern.test(l.trim()) || timestampPattern.test(l.trim()),
);
if (logLines.length / lines.length > 0.8) {
skippedContextDumps++;
continue;
}
}
const truncated = truncateContent(content, maxChars);
allMessages.push({
sessionId: msg.sessionId,
projectName: proj.projectName,
projectPath: msg.projectPath,
timestamp: msg.timestamp,
content: truncated,
});
projectMessages++;
sessionUsed = true;
}
if (sessionUsed) projectSessionsUsed++;
} catch {
continue;
}
}
if (projectMessages > 0) {
projectBreakdown.push({
project: proj.projectName,
messages: projectMessages,
sessions: projectSessionsUsed,
});
}
}
const tmpDir = mkdtempSync(join(tmpdir(), 'gsd-profile-'));
const outputPath = join(tmpDir, 'profile-sample.jsonl');
for (const msg of allMessages) {
appendFileSync(outputPath, JSON.stringify(msg) + '\n');
}
return {
output_file: outputPath,
projects_sampled: projectBreakdown.length,
messages_sampled: allMessages.length,
per_project_cap: perProjectCap,
message_char_limit: maxChars,
skipped_context_dumps: skippedContextDumps,
project_breakdown: projectBreakdown,
};
}

View File

@@ -0,0 +1,174 @@
/**
* Session scan — parity with `get-shit-done/bin/lib/profile-pipeline.cjs` `cmdScanSessions`.
* Used by `scanSessions` query handler so SDK JSON matches `gsd-tools.cjs scan-sessions --json`.
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import { homedir } from 'node:os';
/** One project entry in the JSON array emitted by `scan-sessions --json`. */
export type ScanSessionsProject = {
name: string;
directory: string;
sessionCount: number;
totalSize: number;
totalSizeHuman: string;
lastActive: string;
dateRange: { first: string; last: string };
sessions?: Array<{
sessionId: string;
size: number;
sizeHuman: string;
/** Full ISO-8601, same as CJS `scan-sessions --json --verbose`. */
modified: string;
summary?: string;
messageCount?: number;
created?: string;
}>;
};
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(1)} GB`;
}
/** Same as CJS `scanProjectDir` in profile-pipeline.cjs (sessions sorted newest-first). */
export function scanProjectDir(projectDirPath: string): Array<{
sessionId: string;
filePath: string;
size: number;
modified: Date;
}> {
const entries = readdirSync(projectDirPath);
const sessions: Array<{ sessionId: string; filePath: string; size: number; modified: Date }> = [];
for (const entry of entries) {
if (!entry.endsWith('.jsonl')) continue;
const sessionId = entry.replace('.jsonl', '');
const filePath = join(projectDirPath, entry);
const stat = statSync(filePath);
sessions.push({
sessionId,
filePath,
size: stat.size,
modified: stat.mtime,
});
}
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
return sessions;
}
export function readSessionIndex(projectDirPath: string): {
originalPath: string | null;
entries: Map<string, { sessionId?: string; summary?: string; messageCount?: number; created?: string }>;
} {
try {
const indexPath = join(projectDirPath, 'sessions-index.json');
const raw = readFileSync(indexPath, 'utf-8');
const parsed = JSON.parse(raw) as { entries?: unknown[]; originalPath?: string | null };
const entries = new Map<string, { sessionId?: string; summary?: string; messageCount?: number; created?: string }>();
for (const entry of parsed.entries || []) {
const e = entry as { sessionId?: string; summary?: string; messageCount?: number; created?: string };
if (e.sessionId) {
entries.set(e.sessionId, e);
}
}
return { originalPath: parsed.originalPath ?? null, entries };
} catch {
return { originalPath: null, entries: new Map() };
}
}
export function getProjectName(projectDirName: string, indexData: ReturnType<typeof readSessionIndex>): string {
if (indexData.originalPath) {
return basename(indexData.originalPath);
}
return projectDirName;
}
/** Same resolution as CJS `getSessionsDir` in profile-pipeline.cjs. */
export function getScanSessionsRoot(overridePath: string | null): string | null {
const dir = overridePath || join(homedir(), '.claude', 'projects');
if (!existsSync(dir)) return null;
return dir;
}
/**
* Build the same project array as CJS `cmdScanSessions` (stdout JSON when `--json`).
*/
export function buildScanSessionsProjects(
overridePath: string | null,
options: { verbose: boolean },
): ScanSessionsProject[] {
const sessionsDir = getScanSessionsRoot(overridePath);
if (!sessionsDir) {
return [];
}
let projectDirs: string[];
try {
projectDirs = readdirSync(sessionsDir).filter((entry) => {
const fullPath = join(sessionsDir, entry);
try {
return statSync(fullPath).isDirectory();
} catch {
return false;
}
});
} catch {
return [];
}
const projects: ScanSessionsProject[] = [];
for (const dirName of projectDirs) {
const projectPath = join(sessionsDir, dirName);
const sessions = scanProjectDir(projectPath);
if (sessions.length === 0) continue;
const indexData = readSessionIndex(projectPath);
const projectName = getProjectName(dirName, indexData);
const totalSize = sessions.reduce((sum, s) => sum + s.size, 0);
const lastActive = sessions[0].modified.toISOString();
const oldest = sessions[sessions.length - 1].modified.toISOString();
const newest = sessions[0].modified.toISOString();
const project: ScanSessionsProject = {
name: projectName,
directory: dirName,
sessionCount: sessions.length,
totalSize,
totalSizeHuman: formatBytes(totalSize),
lastActive: lastActive.replace('T', ' ').substring(0, 19),
dateRange: { first: oldest, last: newest },
};
if (options.verbose) {
project.sessions = sessions.map((s) => {
const indexed = indexData.entries.get(s.sessionId);
const session: NonNullable<ScanSessionsProject['sessions']>[number] = {
sessionId: s.sessionId,
size: s.size,
sizeHuman: formatBytes(s.size),
modified: s.modified.toISOString(),
};
if (indexed) {
if (indexed.summary) session.summary = indexed.summary;
if (indexed.messageCount !== undefined) session.messageCount = indexed.messageCount;
if (indexed.created) session.created = indexed.created;
}
return session;
});
}
projects.push(project);
}
projects.sort((a, b) => b.dateRange.last.localeCompare(a.dateRange.last));
return projects;
}

View File

@@ -7,7 +7,8 @@ import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { writeProfile, learningsCopy } from './profile.js';
import { writeProfile } from './profile-output.js';
import { learningsCopy } from './profile.js';
describe('writeProfile', () => {
let tmpDir: string;
@@ -21,14 +22,32 @@ describe('writeProfile', () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('writes USER-PROFILE.md from --input JSON', async () => {
it('writes USER-PROFILE.md from --input JSON (CJS template + dimensions shape)', async () => {
const analysisPath = join(tmpDir, 'analysis.json');
await writeFile(analysisPath, JSON.stringify({ communication_style: 'terse' }), 'utf-8');
const result = await writeProfile(['--input', analysisPath], tmpDir);
const outPath = join(tmpDir, '.planning', 'USER-PROFILE.md');
await writeFile(
analysisPath,
JSON.stringify({
profile_version: '1.0',
data_source: 'test',
dimensions: {
communication_style: {
rating: 'terse-direct',
confidence: 'HIGH',
claude_instruction: 'Keep it short.',
summary: 'Test summary.',
evidence: [],
},
},
}),
'utf-8',
);
const result = await writeProfile(['--input', analysisPath, '--output', outPath], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.written).toBe(true);
const md = await readFile(join(tmpDir, '.planning', 'USER-PROFILE.md'), 'utf-8');
expect(md).toContain('User Developer Profile');
expect(data.profile_path).toBe(outPath);
expect(data.dimensions_scored).toBe(1);
const md = await readFile(outPath, 'utf-8');
expect(md).toContain('Developer Profile');
expect(md).toMatch(/Communication Style/i);
});
});
@@ -45,10 +64,11 @@ describe('learningsCopy', () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('returns copied:false when LEARNINGS.md is missing', async () => {
it('returns zero counts when LEARNINGS.md is missing (matches learnings.cjs)', async () => {
const result = await learningsCopy([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.copied).toBe(false);
expect(data.reason).toContain('LEARNINGS');
expect(data.total).toBe(0);
expect(data.created).toBe(0);
expect(data.skipped).toBe(0);
});
});

View File

@@ -13,18 +13,26 @@
* // { data: { projects: [...], project_count: 5, session_count: 42 } }
*
* await profileQuestionnaire([], '/project');
* // { data: { questions: [...], total: 3 } }
* // { data: { mode: 'interactive', questions: [...] } } — same shape as gsd-tools.cjs
* ```
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join, relative, basename, resolve } from 'node:path';
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
import { join, basename, resolve } from 'node:path';
import { homedir } from 'node:os';
import { createHash, randomBytes } from 'node:crypto';
import { planningPaths, toPosixPath } from './helpers.js';
import { planningPaths } from './helpers.js';
import { GSDError, ErrorClassification } from '../errors.js';
import type { QueryHandler } from './utils.js';
import { buildScanSessionsProjects, getScanSessionsRoot } from './profile-scan-sessions.js';
import { runExtractMessages } from './profile-extract-messages.js';
import { runProfileSample } from './profile-sample.js';
import {
PROFILING_QUESTIONS,
generateClaudeInstruction,
isAmbiguousAnswer,
} from './profile-questionnaire-data.js';
// ─── Learnings — ~/.gsd/knowledge/ knowledge store ───────────────────────
@@ -62,6 +70,16 @@ function learningsList(): Array<Record<string, unknown>> {
return results;
}
/**
* List all entries in the global learnings store (`~/.gsd/knowledge/`).
*
* Port of `cmdLearningsList` from learnings.cjs.
*/
export const learningsListHandler: QueryHandler = async () => {
const learnings = learningsList();
return { data: { learnings, count: learnings.length } };
};
/**
* Query learnings from the global knowledge store, optionally filtered by tag.
*
@@ -109,259 +127,211 @@ export const learningsCopy: QueryHandler = async (_args, projectDir) => {
return { data: { copied: true, total: created + skipped, created, skipped } };
};
/**
* Prune learnings older than duration (e.g. `90d`). Port of `learningsPrune` from learnings.cjs.
*/
function learningsPruneStore(olderThan: string): { removed: number; kept: number } {
const match = /^(\d+)d$/.exec(olderThan);
if (!match) {
throw new Error(`Invalid duration format: "${olderThan}" — expected format like "90d"`);
}
const days = parseInt(match[1], 10);
const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
if (!existsSync(STORE_DIR)) return { removed: 0, kept: 0 };
const files = readdirSync(STORE_DIR).filter(f => f.endsWith('.json'));
let removed = 0;
let kept = 0;
for (const file of files) {
const filePath = join(STORE_DIR, file);
let record: Record<string, unknown> | null = null;
try {
record = JSON.parse(readFileSync(filePath, 'utf-8')) as Record<string, unknown>;
} catch {
continue;
}
if (!record?.date) continue;
const recordDate = new Date(record.date as string);
if (recordDate < cutoff) {
unlinkSync(filePath);
removed++;
} else {
kept++;
}
}
return { removed, kept };
}
/** Port of `cmdLearningsPrune`. */
export const learningsPrune: QueryHandler = async (args) => {
const olderIdx = args.indexOf('--older-than');
const olderThan = olderIdx !== -1 ? args[olderIdx + 1] : null;
if (!olderThan) {
throw new GSDError('Usage: learnings prune --older-than <duration>', ErrorClassification.Validation);
}
try {
return { data: learningsPruneStore(olderThan) };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new GSDError(msg, ErrorClassification.Validation);
}
};
/** Port of `cmdLearningsDelete`. */
export const learningsDelete: QueryHandler = async (args) => {
const id = args[0];
if (!id) {
throw new GSDError('Usage: learnings delete <id>', ErrorClassification.Validation);
}
if (!/^[a-z0-9]+-[a-f0-9]+$/.test(id)) {
throw new GSDError(`Invalid learning ID: "${id}"`, ErrorClassification.Validation);
}
const filePath = join(STORE_DIR, `${id}.json`);
if (!existsSync(filePath)) {
return { data: { id, deleted: false } };
}
unlinkSync(filePath);
return { data: { id, deleted: true } };
};
// ─── extractMessages — session message extraction for profiling ───────────
/**
* Extract user messages from Claude Code session files for a given project.
*
* Port of `cmdExtractMessages` from profile-pipeline.cjs lines 252-391.
* Simplified to use the SDK's existing session scanning infrastructure.
* Port of `cmdExtractMessages` from profile-pipeline.cjs — JSON matches `gsd-tools extract-messages`
* (`output_file` JSONL + metadata). Uses `--session` (CJS); `--session-id` is accepted as an alias.
*
* @param args - args[0]: project name/keyword (required), --limit N, --session-id ID
* @param args - args[0]: project name/keyword (required), `--session <id>`, `--limit N`, `--path <dir>`
*/
export const extractMessages: QueryHandler = async (args) => {
const projectArg = args[0];
if (!projectArg) {
return { data: { error: 'project name required', messages: [], total: 0 } };
}
const sessionsBase = join(homedir(), '.claude', 'projects');
if (!existsSync(sessionsBase)) {
return { data: { error: 'No Claude Code sessions found', messages: [], total: 0 } };
}
const pathIdx = args.indexOf('--path');
const overridePath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const sessionIdx =
args.indexOf('--session') !== -1 ? args.indexOf('--session') : args.indexOf('--session-id');
const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1]! : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) || 300 : 300;
const sessionIdIdx = args.indexOf('--session-id');
const sessionIdFilter = sessionIdIdx !== -1 ? args[sessionIdIdx + 1] : null;
let projectDirs: string[];
try {
projectDirs = readdirSync(sessionsBase, { withFileTypes: true })
.filter((e: { isDirectory(): boolean }) => e.isDirectory())
.map((e: { name: string }) => e.name);
} catch {
return { data: { error: 'Cannot read sessions directory', messages: [], total: 0 } };
const limit = limitIdx !== -1 ? (parseInt(args[limitIdx + 1]!, 10) || null) : null;
const projectArg = args[0];
if (!projectArg || projectArg.startsWith('--')) {
throw new GSDError(
'Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.',
ErrorClassification.Validation,
);
}
const lowerArg = projectArg.toLowerCase();
const matchedDir = projectDirs.find(d => d === projectArg)
|| projectDirs.find(d => d.toLowerCase().includes(lowerArg));
if (!matchedDir) {
return { data: { error: `No project matching "${projectArg}"`, available: projectDirs.slice(0, 10), messages: [], total: 0 } };
}
const projectPath = join(sessionsBase, matchedDir);
let sessionFiles = readdirSync(projectPath).filter(f => f.endsWith('.jsonl'));
if (sessionIdFilter) {
sessionFiles = sessionFiles.filter(f => f.includes(sessionIdFilter));
}
const messages: Array<{ role: string; content: string; session: string }> = [];
let sessionsProcessed = 0;
let sessionsSkipped = 0;
for (const sessionFile of sessionFiles) {
if (messages.length >= limit) break;
try {
const content = readFileSync(join(projectPath, sessionFile), 'utf-8');
for (const line of content.split('\n').filter(Boolean)) {
if (messages.length >= limit) break;
try {
const record = JSON.parse(line);
if (record.type === 'user' && typeof record.message?.content === 'string') {
const text = record.message.content;
if (text.length > 3 && !text.startsWith('/') && !/^\s*(y|n|yes|no|ok)\s*$/i.test(text)) {
messages.push({
role: 'user',
content: text.length > 2000 ? text.slice(0, 2000) + '... [truncated]' : text,
session: sessionFile.replace('.jsonl', ''),
});
}
}
} catch { /* skip malformed line */ }
}
sessionsProcessed++;
} catch {
sessionsSkipped++;
}
}
return {
data: {
project: matchedDir,
sessions_processed: sessionsProcessed,
sessions_skipped: sessionsSkipped,
messages_extracted: messages.length,
messages,
},
};
const data = await runExtractMessages(projectArg, { sessionId, limit }, overridePath ?? null);
return { data };
};
// ─── Profile — session scanning and profile generation ────────────────────
const SESSIONS_DIR = join(homedir(), '.claude', 'projects');
export const scanSessions: QueryHandler = async (args) => {
const pathIdx = args.indexOf('--path');
const overridePath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const verboseFlag = args.includes('--verbose');
export const scanSessions: QueryHandler = async (_args, _projectDir) => {
if (!existsSync(SESSIONS_DIR)) {
return { data: { projects: [], project_count: 0, session_count: 0 } };
if (getScanSessionsRoot(overridePath) === null) {
const searchedPath = overridePath || '~/.claude/projects';
throw new GSDError(
`No Claude Code sessions found at ${searchedPath}.${overridePath ? '' : ' Is Claude Code installed?'}`,
ErrorClassification.Validation,
);
}
const projects: Record<string, unknown>[] = [];
let sessionCount = 0;
try {
const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true });
for (const pDir of projectDirs.filter(e => e.isDirectory())) {
const pPath = join(SESSIONS_DIR, pDir.name);
const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl'));
sessionCount += sessions.length;
projects.push({ name: pDir.name, path: toPosixPath(pPath), session_count: sessions.length });
}
} catch { /* skip */ }
return { data: { projects, project_count: projects.length, session_count: sessionCount } };
const projects = buildScanSessionsProjects(overridePath, { verbose: verboseFlag });
return { data: projects };
};
export const profileSample: QueryHandler = async (_args, _projectDir) => {
if (!existsSync(SESSIONS_DIR)) {
return { data: { messages: [], total: 0, projects_sampled: 0 } };
}
const messages: string[] = [];
let projectsSampled = 0;
try {
const projectDirs = readdirSync(SESSIONS_DIR, { withFileTypes: true });
for (const pDir of projectDirs.filter(e => e.isDirectory()).slice(0, 5)) {
const pPath = join(SESSIONS_DIR, pDir.name);
const sessions = readdirSync(pPath).filter(f => f.endsWith('.jsonl')).slice(0, 3);
for (const session of sessions) {
try {
const content = readFileSync(join(pPath, session), 'utf-8');
for (const line of content.split('\n').filter(Boolean)) {
try {
const record = JSON.parse(line);
if (record.type === 'user' && typeof record.message?.content === 'string') {
messages.push(record.message.content.slice(0, 500));
if (messages.length >= 50) break;
}
} catch { /* skip malformed */ }
}
} catch { /* skip */ }
if (messages.length >= 50) break;
}
projectsSampled++;
if (messages.length >= 50) break;
}
} catch { /* skip */ }
return { data: { messages, total: messages.length, projects_sampled: projectsSampled } };
/**
* Multi-project session sampling for profiling — port of `cmdProfileSample` (`profile-pipeline.cjs`).
* JSON matches `gsd-tools profile-sample` (`output_file` JSONL + metadata).
*/
export const profileSample: QueryHandler = async (args) => {
const pathIdx = args.indexOf('--path');
const overridePath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1]!, 10) : 150;
const maxPerIdx = args.indexOf('--max-per-project');
const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1]!, 10) : null;
const maxCharsIdx = args.indexOf('--max-chars');
const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1]!, 10) : 500;
const data = await runProfileSample(overridePath ?? null, {
limit,
maxPerProject,
maxChars,
});
return { data };
};
const PROFILING_QUESTIONS = [
{ dimension: 'communication_style', header: 'Communication Style', question: 'When you ask Claude to build something, how much context do you typically provide?', options: [{ label: 'Minimal', value: 'a', rating: 'terse-direct' }, { label: 'Some context', value: 'b', rating: 'conversational' }, { label: 'Detailed specs', value: 'c', rating: 'detailed-structured' }, { label: 'It depends', value: 'd', rating: 'mixed' }] },
{ dimension: 'decision_speed', header: 'Decision Making', question: 'When Claude presents you with options, how do you typically decide?', options: [{ label: 'Pick quickly', value: 'a', rating: 'fast-intuitive' }, { label: 'Ask for comparison', value: 'b', rating: 'deliberate-informed' }, { label: 'Research independently', value: 'c', rating: 'research-first' }, { label: 'Let Claude recommend', value: 'd', rating: 'delegator' }] },
{ dimension: 'explanation_depth', header: 'Explanation Preferences', question: 'When Claude explains something, how much detail do you want?', options: [{ label: 'Just the code', value: 'a', rating: 'code-only' }, { label: 'Brief explanation', value: 'b', rating: 'concise' }, { label: 'Detailed walkthrough', value: 'c', rating: 'detailed' }, { label: 'Deep dive', value: 'd', rating: 'educational' }] },
];
/**
* Profile questionnaire — port of `cmdProfileQuestionnaire` from profile-output.cjs.
* Interactive: `{ mode: 'interactive', questions }` (options omit `rating`).
* With `--answers a,b,c,...` (8 comma-separated values, order matches questions): full analysis object (includes volatile `analyzed_at`).
*/
export const profileQuestionnaire: QueryHandler = async (args, _projectDir) => {
const answersFlag = args.indexOf('--answers');
if (answersFlag >= 0 && args[answersFlag + 1]) {
try {
const answers = JSON.parse(readFileSync(resolve(args[answersFlag + 1]), 'utf-8')) as Record<string, string>;
const analysis: Record<string, string> = {};
for (const q of PROFILING_QUESTIONS) {
const answer = answers[q.dimension];
const option = q.options.find(o => o.value === answer);
analysis[q.dimension] = option?.rating ?? 'unknown';
}
return { data: { analysis, answered: Object.keys(answers).length, questions_total: PROFILING_QUESTIONS.length } };
} catch {
return { data: { error: 'Failed to read answers file', path: args[answersFlag + 1] } };
}
}
return { data: { questions: PROFILING_QUESTIONS, total: PROFILING_QUESTIONS.length } };
};
const answersIdx = args.indexOf('--answers');
const answersStr = answersIdx !== -1 ? args[answersIdx + 1] : null;
export const writeProfile: QueryHandler = async (args, projectDir) => {
const inputFlag = args.indexOf('--input');
const inputPath = inputFlag >= 0 ? args[inputFlag + 1] : null;
if (!inputPath || !existsSync(resolve(inputPath))) {
return { data: { written: false, reason: 'No --input analysis file provided' } };
}
try {
const analysis = JSON.parse(readFileSync(resolve(inputPath), 'utf-8')) as Record<string, unknown>;
const profilePath = join(projectDir, '.planning', 'USER-PROFILE.md');
const lines = ['# User Developer Profile', '', `*Generated: ${new Date().toISOString()}*`, ''];
for (const [key, value] of Object.entries(analysis)) {
lines.push(`## ${key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}`);
lines.push('');
lines.push(String(value));
lines.push('');
}
await writeFile(profilePath, lines.join('\n'), 'utf-8');
return { data: { written: true, path: toPosixPath(relative(projectDir, profilePath)) } };
} catch (err) {
return { data: { written: false, reason: String(err) } };
}
};
export const generateClaudeProfile: QueryHandler = async (args, _projectDir) => {
const analysisFlag = args.indexOf('--analysis');
const analysisPath = analysisFlag >= 0 ? args[analysisFlag + 1] : null;
let profile = '> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.\n> This section is managed by `generate-claude-profile` -- do not edit manually.';
if (analysisPath && existsSync(resolve(analysisPath))) {
try {
const analysis = JSON.parse(readFileSync(resolve(analysisPath), 'utf-8')) as Record<string, unknown>;
const lines = ['## Developer Profile', ''];
for (const [key, value] of Object.entries(analysis)) {
lines.push(`- **${key.replace(/_/g, ' ')}**: ${value}`);
}
profile = lines.join('\n');
} catch { /* use fallback */ }
if (!answersStr) {
const questionsOutput = {
mode: 'interactive' as const,
questions: PROFILING_QUESTIONS.map((q) => ({
dimension: q.dimension,
header: q.header,
context: q.context,
question: q.question,
options: q.options.map((o) => ({ label: o.label, value: o.value })),
})),
};
return { data: questionsOutput };
}
return { data: { profile, generated: true } };
};
export const generateDevPreferences: QueryHandler = async (args, projectDir) => {
const analysisFlag = args.indexOf('--analysis');
const analysisPath = analysisFlag >= 0 ? args[analysisFlag + 1] : null;
const prefs: Record<string, unknown> = {};
if (analysisPath && existsSync(resolve(analysisPath))) {
try {
const analysis = JSON.parse(readFileSync(resolve(analysisPath), 'utf-8')) as Record<string, unknown>;
Object.assign(prefs, analysis);
} catch { /* use empty */ }
const answerValues = answersStr.split(',').map((a) => a.trim());
if (answerValues.length !== PROFILING_QUESTIONS.length) {
throw new GSDError(
`Expected ${PROFILING_QUESTIONS.length} answers (comma-separated), got ${answerValues.length}`,
ErrorClassification.Validation,
);
}
const prefsPath = join(projectDir, '.planning', 'dev-preferences.md');
const lines = ['# Developer Preferences', '', `*Generated: ${new Date().toISOString()}*`, ''];
for (const [key, value] of Object.entries(prefs)) {
lines.push(`- **${key}**: ${value}`);
}
await writeFile(prefsPath, lines.join('\n'), 'utf-8');
return { data: { written: true, path: toPosixPath(relative(projectDir, prefsPath)), preferences: prefs } };
};
export const generateClaudeMd: QueryHandler = async (_args, projectDir) => {
const safeRead = (path: string): string | null => {
try { return existsSync(path) ? readFileSync(path, 'utf-8') : null; } catch { return null; }
const dimensions: Record<string, unknown> = {};
const analysis: Record<string, unknown> = {
profile_version: '1.0',
analyzed_at: new Date().toISOString(),
data_source: 'questionnaire',
projects_analyzed: [] as unknown[],
messages_analyzed: 0,
message_threshold: 'questionnaire',
sensitive_excluded: [] as unknown[],
dimensions,
};
const sections: string[] = [];
const projectContent = safeRead(join(projectDir, '.planning', 'PROJECT.md'));
if (projectContent) {
const h1 = projectContent.match(/^# (.+)$/m);
if (h1) sections.push(`## Project\n\n${h1[1]}\n`);
for (let i = 0; i < PROFILING_QUESTIONS.length; i++) {
const question = PROFILING_QUESTIONS[i]!;
const answerValue = answerValues[i]!;
const selectedOption = question.options.find((o) => o.value === answerValue);
if (!selectedOption) {
throw new GSDError(
`Invalid answer "${answerValue}" for ${question.dimension}. Valid values: ${question.options.map((o) => o.value).join(', ')}`,
ErrorClassification.Validation,
);
}
const ambiguous = isAmbiguousAnswer(question.dimension, answerValue);
dimensions[question.dimension] = {
rating: selectedOption.rating,
confidence: ambiguous ? 'LOW' : 'MEDIUM',
evidence_count: 1,
cross_project_consistent: null,
evidence: [
{
signal: 'Self-reported via questionnaire',
quote: selectedOption.label,
project: 'N/A (questionnaire)',
},
],
summary: `Developer self-reported as ${selectedOption.rating} for ${question.header.toLowerCase()}.`,
claude_instruction: generateClaudeInstruction(question.dimension, selectedOption.rating),
};
}
const stackContent = safeRead(join(projectDir, '.planning', 'codebase', 'STACK.md')) ?? safeRead(join(projectDir, '.planning', 'research', 'STACK.md'));
if (stackContent) sections.push(`## Technology Stack\n\n${stackContent.slice(0, 1000)}\n`);
return { data: { sections, generated: true, section_count: sections.length } };
return { data: analysis };
};

View File

@@ -19,7 +19,9 @@ import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, unlink
import { join, relative } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { comparePhaseNum, normalizePhaseName, planningPaths, toPosixPath } from './helpers.js';
import { getMilestoneInfo, roadmapAnalyze } from './roadmap.js';
import { getMilestoneInfo, extractCurrentMilestone, roadmapGetPhase } from './roadmap.js';
import { getMilestonePhaseFilter } from './state.js';
import { findPhase } from './phase.js';
import type { QueryHandler } from './utils.js';
// ─── Internal helpers ─────────────────────────────────────────────────────
@@ -34,8 +36,13 @@ import type { QueryHandler } from './utils.js';
* @param phaseDir - Absolute path to the phase directory
* @returns Status string: Pending, Planned, In Progress, Executed, Complete, Needs Review
*/
export async function determinePhaseStatus(plans: number, summaries: number, phaseDir: string): Promise<string> {
if (plans === 0) return 'Pending';
export async function determinePhaseStatus(
plans: number,
summaries: number,
phaseDir: string,
defaultWhenNoPlans: string = 'Pending',
): Promise<string> {
if (plans === 0) return defaultWhenNoPlans;
if (summaries < plans && summaries > 0) return 'In Progress';
if (summaries < plans) return 'Planned';
@@ -117,77 +124,369 @@ export const progressJson: QueryHandler = async (_args, projectDir) => {
// ─── progressBar ─────────────────────────────────────────────────────────
/**
* Progress bar line — port of `cmdProgressRender` `format === 'bar'` from commands.cjs (lines 588–593).
* Uses the same plan/summary counts as `progressJson` / CJS (not `roadmap.analyze` percent).
*/
export const progressBar: QueryHandler = async (_args, projectDir) => {
const analysis = await roadmapAnalyze([], projectDir);
const data = analysis.data as Record<string, unknown>;
const percent = (data.progress_percent as number) || 0;
const total = 20;
const filled = Math.round((percent / 100) * total);
const bar = '[' + '#'.repeat(filled) + '-'.repeat(total - filled) + ']';
return { data: { bar: `${bar} ${percent}%`, percent } };
const json = await progressJson([], projectDir);
const d = json.data as {
total_plans: number;
total_summaries: number;
percent: number;
};
const totalPlans = d.total_plans;
const totalSummaries = d.total_summaries;
const percent = d.percent;
const barWidth = 20;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
return { data: { bar: text, percent, completed: totalSummaries, total: totalPlans } };
};
/**
* Markdown progress table — port of `cmdProgressRender` `format === 'table'` from commands.cjs (lines 575–587).
*/
export const progressTable: QueryHandler = async (_args, projectDir) => {
const json = await progressJson([], projectDir);
const d = json.data as {
milestone_version: string;
milestone_name: string;
phases: Array<{
number: string;
name: string;
plans: number;
summaries: number;
status: string;
}>;
total_plans: number;
total_summaries: number;
percent: number;
};
const { milestone_version, milestone_name, phases, total_plans, total_summaries, percent } = d;
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone_version} ${milestone_name}\n\n`;
out += `**Progress:** [${bar}] ${total_summaries}/${total_plans} plans (${percent}%)\n\n`;
out += '| Phase | Name | Plans | Status |\n';
out += '|-------|------|-------|--------|\n';
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
}
return { data: { rendered: out } };
};
// ─── statsJson ───────────────────────────────────────────────────────────
export const statsJson: QueryHandler = async (_args, projectDir) => {
const paths = planningPaths(projectDir);
let phasesTotal = 0;
let plansTotal = 0;
let summariesTotal = 0;
let completedPhases = 0;
/**
* Statistics aggregate — port of `cmdStats` JSON/table output from commands.cjs lines 816–971.
*/
export const statsJson: QueryHandler = async (args, projectDir) => {
const format = args[0] || 'json';
const phasesDir = planningPaths(projectDir).phases;
const roadmapPath = planningPaths(projectDir).roadmap;
const reqPath = planningPaths(projectDir).requirements;
const statePath = planningPaths(projectDir).state;
const milestone = await getMilestoneInfo(projectDir);
const isDirInMilestone = await getMilestonePhaseFilter(projectDir);
if (existsSync(paths.phases)) {
try {
const entries = readdirSync(paths.phases, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
phasesTotal++;
const phaseDir = join(paths.phases, entry.name);
const files = readdirSync(phaseDir);
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
plansTotal += plans.length;
summariesTotal += summaries.length;
if (summaries.length >= plans.length && plans.length > 0) completedPhases++;
}
} catch { /* skip */ }
const phasesByNumber = new Map<
string,
{ number: string; name: string; plans: number; summaries: number; status: string }
>();
let totalPlans = 0;
let totalSummaries = 0;
try {
const roadmapContent = await extractCurrentMilestone(await readFile(roadmapPath, 'utf-8'), projectDir);
const headingPattern = /#{2,4}\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:\s*([^\n]+)/gi;
let match: RegExpExecArray | null;
while ((match = headingPattern.exec(roadmapContent)) !== null) {
const key = normalizePhaseName(match[1]);
phasesByNumber.set(key, {
number: key,
name: match[2].replace(/\(INSERTED\)/i, '').trim(),
plans: 0,
summaries: 0,
status: 'Not Started',
});
}
} catch { /* intentionally empty */ }
try {
const entries = readdirSync(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.filter(isDirInMilestone)
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of dirs) {
const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
const phaseNum = dm ? dm[1] : dir;
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
const phaseFiles = readdirSync(join(phasesDir, dir));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
totalPlans += plans;
totalSummaries += summaries;
const status = await determinePhaseStatus(plans, summaries, join(phasesDir, dir), 'Not Started');
const normalizedNum = normalizePhaseName(phaseNum);
const existing = phasesByNumber.get(normalizedNum);
phasesByNumber.set(normalizedNum, {
number: normalizedNum,
name: existing?.name || phaseName,
plans: (existing?.plans || 0) + plans,
summaries: (existing?.summaries || 0) + summaries,
status,
});
}
} catch { /* intentionally empty */ }
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
const completedPhases = phases.filter(p => p.status === 'Complete').length;
const planPercent = totalPlans > 0 ? Math.min(100, Math.round((totalSummaries / totalPlans) * 100)) : 0;
const percent =
phases.length > 0 ? Math.min(100, Math.round((completedPhases / phases.length) * 100)) : 0;
let requirementsTotal = 0;
let requirementsComplete = 0;
try {
if (existsSync(reqPath)) {
const reqContent = readFileSync(reqPath, 'utf-8');
const checked = reqContent.match(/^- \[x\] \*\*/gm);
const unchecked = reqContent.match(/^- \[ \] \*\*/gm);
requirementsComplete = checked ? checked.length : 0;
requirementsTotal = requirementsComplete + (unchecked ? unchecked.length : 0);
}
} catch { /* intentionally empty */ }
let lastActivity: string | null = null;
try {
if (existsSync(statePath)) {
const stateContent = readFileSync(statePath, 'utf-8');
const activityMatch =
stateContent.match(/^last_activity:\s*(.+)$/im)
|| stateContent.match(/\*\*Last Activity:\*\*\s*(.+)/i)
|| stateContent.match(/^Last Activity:\s*(.+)$/im)
|| stateContent.match(/^Last activity:\s*(.+)$/im);
if (activityMatch) lastActivity = activityMatch[1].trim();
}
} catch { /* intentionally empty */ }
const { execGit } = await import('./commit.js');
let gitCommits = 0;
let gitFirstCommitDate: string | null = null;
const commitCount = execGit(projectDir, ['rev-list', '--count', 'HEAD']);
if (commitCount.exitCode === 0) {
gitCommits = parseInt(commitCount.stdout, 10) || 0;
}
const rootHash = execGit(projectDir, ['rev-list', '--max-parents=0', 'HEAD']);
if (rootHash.exitCode === 0 && rootHash.stdout) {
const firstCommit = rootHash.stdout.split('\n')[0].trim();
const firstDate = execGit(projectDir, ['show', '-s', '--format=%as', firstCommit]);
if (firstDate.exitCode === 0) {
gitFirstCommitDate = firstDate.stdout.trim() || null;
}
}
const progressPercent = phasesTotal > 0 ? Math.round((completedPhases / phasesTotal) * 100) : 0;
return {
data: {
phases_total: phasesTotal,
plans_total: plansTotal,
summaries_total: summariesTotal,
completed_phases: completedPhases,
in_progress_phases: phasesTotal - completedPhases,
progress_percent: progressPercent,
},
const result = {
milestone_version: milestone.version,
milestone_name: milestone.name,
phases,
phases_completed: completedPhases,
phases_total: phases.length,
total_plans: totalPlans,
total_summaries: totalSummaries,
percent,
plan_percent: planPercent,
requirements_total: requirementsTotal,
requirements_complete: requirementsComplete,
git_commits: gitCommits,
git_first_commit_date: gitFirstCommitDate,
last_activity: lastActivity,
};
if (format === 'table') {
const barWidth = 10;
const filled = Math.round((percent / 100) * barWidth);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
let out = `# ${milestone.version} ${milestone.name} \u2014 Statistics\n\n`;
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases (${percent}%)\n`;
if (totalPlans > 0) {
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
}
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
if (requirementsTotal > 0) {
out += `**Requirements:** ${requirementsComplete}/${requirementsTotal} complete\n`;
}
out += '\n';
out += '| Phase | Name | Plans | Completed | Status |\n';
out += '|-------|------|-------|-----------|--------|\n';
for (const p of phases) {
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
}
if (gitCommits > 0) {
out += `\n**Git:** ${gitCommits} commits`;
if (gitFirstCommitDate) out += ` (since ${gitFirstCommitDate})`;
out += '\n';
}
if (lastActivity) out += `**Last activity:** ${lastActivity}\n`;
return { data: { rendered: out } };
}
return { data: result };
};
/**
* Markdown statistics table — port of `cmdStats` `format === 'table'` from commands.cjs (lines 942–967).
* Delegates to `statsJson` with `['table']` (same `rendered` string as CJS).
*/
export const statsTable: QueryHandler = async (_args, projectDir) => {
return statsJson(['table'], projectDir);
};
// ─── todoMatchPhase ──────────────────────────────────────────────────────
/**
* Match pending todos against a phase — port of `cmdTodoMatchPhase` from commands.cjs lines 612–729.
*/
export const todoMatchPhase: QueryHandler = async (args, projectDir) => {
const phase = args[0];
const todosDir = join(projectDir, '.planning', 'todos');
const todos: Array<{ file: string; phase: string }> = [];
if (!existsSync(todosDir)) {
return { data: { todos: [], count: 0, phase: phase || null } };
if (!phase) {
throw new GSDError('phase required for todo match-phase', ErrorClassification.Validation);
}
const pendingDir = join(projectDir, '.planning', 'todos', 'pending');
const todos: Array<{
file: string;
title: string;
area: string;
files: string[];
body: string;
}> = [];
try {
const files = readdirSync(todosDir).filter(f => f.endsWith('.md') || f.endsWith('.json'));
const files = readdirSync(pendingDir).filter(f => f.endsWith('.md'));
for (const file of files) {
if (!phase || file.includes(normalizePhaseName(phase)) || file.includes(phase)) {
todos.push({ file: toPosixPath(join('.planning', 'todos', file)), phase: phase || 'all' });
}
try {
const content = readFileSync(join(pendingDir, file), 'utf-8');
const titleMatch = content.match(/^title:\s*(.+)$/m);
const areaMatch = content.match(/^area:\s*(.+)$/m);
const filesMatch = content.match(/^files:\s*(.+)$/m);
const body = content.replace(/^(title|area|files|created|priority):.*$/gm, '').trim();
todos.push({
file,
title: titleMatch ? titleMatch[1].trim() : 'Untitled',
area: areaMatch ? areaMatch[1].trim() : 'general',
files: filesMatch ? filesMatch[1].trim().split(/[,\s]+/).filter(Boolean) : [],
body: body.slice(0, 200),
});
} catch { /* skip */ }
}
} catch { /* skip */ }
return { data: { todos, count: todos.length, phase: phase || null } };
if (todos.length === 0) {
return { data: { phase, matches: [], todo_count: 0 } };
}
const rp = await roadmapGetPhase([phase], projectDir);
const pd = rp.data as Record<string, unknown>;
let phaseName = '';
let phaseGoal = '';
let phaseSection = '';
if (pd && pd.found === true) {
phaseName = String(pd.phase_name || '');
phaseGoal = pd.goal != null ? String(pd.goal) : '';
phaseSection = String(pd.section || '');
}
const phaseText = `${phaseName} ${phaseGoal} ${phaseSection}`.toLowerCase();
const stopWords = new Set([
'the', 'and', 'for', 'with', 'from', 'that', 'this', 'will', 'are', 'was', 'has', 'have',
'been', 'not', 'but', 'all', 'can', 'into', 'each', 'when', 'any', 'use', 'new',
]);
const phaseKeywords = new Set(
phaseText
.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w)),
);
const fp = await findPhase([phase], projectDir);
const phaseInfoDisk = fp.data as Record<string, unknown>;
const phasePlans: string[] = [];
if (phaseInfoDisk && phaseInfoDisk.found) {
try {
const phaseDir = join(projectDir, phaseInfoDisk.directory as string);
const planFiles = readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
for (const pf of planFiles) {
try {
const planContent = readFileSync(join(phaseDir, pf), 'utf-8');
const fmFiles = planContent.match(/files_modified:\s*\[([^\]]*)\]/);
if (fmFiles) {
phasePlans.push(
...fmFiles[1].split(',').map(s => s.trim().replace(/['"]/g, '')).filter(Boolean),
);
}
} catch { /* skip */ }
}
} catch { /* skip */ }
}
const matches: Array<{ file: string; title: string; area: string; score: number; reasons: string[] }> = [];
for (const todo of todos) {
let score = 0;
const reasons: string[] = [];
const todoWords = `${todo.title} ${todo.body}`
.toLowerCase()
.split(/[\s\-_/.,;:()\[\]{}|]+/)
.map(w => w.replace(/[^a-z0-9]/g, ''))
.filter(w => w.length > 2 && !stopWords.has(w));
const matchedKeywords = todoWords.filter(w => phaseKeywords.has(w));
if (matchedKeywords.length > 0) {
score += Math.min(matchedKeywords.length * 0.2, 0.6);
reasons.push(`keywords: ${[...new Set(matchedKeywords)].slice(0, 5).join(', ')}`);
}
if (todo.area !== 'general' && phaseText.includes(todo.area.toLowerCase())) {
score += 0.3;
reasons.push(`area: ${todo.area}`);
}
if (todo.files.length > 0 && phasePlans.length > 0) {
const fileOverlap = todo.files.filter(f =>
phasePlans.some(pf => pf.includes(f) || f.includes(pf)),
);
if (fileOverlap.length > 0) {
score += 0.4;
reasons.push(`files: ${fileOverlap.slice(0, 3).join(', ')}`);
}
}
if (score > 0) {
matches.push({
file: todo.file,
title: todo.title,
area: todo.area,
score: Math.round(score * 100) / 100,
reasons,
});
}
}
matches.sort((a, b) => b.score - a.score);
return { data: { phase, matches, todo_count: todos.length } };
};
// ─── listTodos ──────────────────────────────────────────────────────────

View File

@@ -61,8 +61,8 @@ export function extractField(obj: unknown, fieldPath: string): unknown {
* Flat command registry that routes query commands to native handlers.
*
* `dispatch()` throws `GSDError` for unknown command keys. The `gsd-sdk query`
* CLI uses `resolveQueryArgv()` to map argv to a registered handler; there is
* no passthrough to `gsd-tools.cjs` — CJS-only commands stay on the legacy CLI.
* CLI uses `resolveQueryArgv()` first; when no handler matches, it may shell out
* to `gsd-tools.cjs` (see `cli.ts` and `QUERY-HANDLERS.md` fallback policy).
*/
export class QueryRegistry {
private handlers = new Map<string, QueryHandler>();

View File

@@ -0,0 +1,58 @@
/**
* Unit tests for requirements.extract-from-plans.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { requirementsExtractFromPlans } from './requirements-extract-from-plans.js';
const P1 = `---
phase: 09-foundation
requirements:
- REQ-A
- REQ-B
---
<objective>
O
</objective>
<tasks><task type="auto"><name>T</name></task></tasks>
`;
const P2 = `---
phase: 09-foundation
requirements:
- REQ-B
---
<objective>
O2
</objective>
<tasks><task type="auto"><name>T</name></task></tasks>
`;
let tmpDir: string;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-req-'));
const phaseDir = join(tmpDir, '.planning', 'phases', '09-foundation');
await mkdir(phaseDir, { recursive: true });
await writeFile(join(phaseDir, '09-01-PLAN.md'), P1);
await writeFile(join(phaseDir, '09-02-PLAN.md'), P2);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe('requirementsExtractFromPlans', () => {
it('dedupes requirements across plans', async () => {
const r = await requirementsExtractFromPlans(['9'], tmpDir);
const d = r.data as { requirements: string[]; by_plan: Record<string, string[]> };
expect(d.requirements.sort()).toEqual(['REQ-A', 'REQ-B']);
expect(d.by_plan['09-01'].sort()).toEqual(['REQ-A', 'REQ-B']);
expect(d.by_plan['09-02']).toEqual(['REQ-B']);
});
});

View File

@@ -0,0 +1,86 @@
/**
* requirements.extract-from-plans — aggregate `requirements` frontmatter across all plans in a phase.
*/
import { readFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
import { extractFrontmatter } from './frontmatter.js';
import {
normalizePhaseName,
comparePhaseNum,
phaseTokenMatches,
planningPaths,
} from './helpers.js';
import type { QueryHandler } from './utils.js';
async function resolvePhaseDir(phase: string, projectDir: string): Promise<string | null> {
const phasesDir = planningPaths(projectDir).phases;
const normalized = normalizePhaseName(phase);
try {
const entries = await readdir(phasesDir, { withFileTypes: true });
const dirs = entries
.filter(e => e.isDirectory())
.map(e => e.name)
.sort((a, b) => comparePhaseNum(a, b));
const match = dirs.find(d => phaseTokenMatches(d, normalized));
return match ? join(phasesDir, match) : null;
} catch {
return null;
}
}
function normalizeReqList(v: unknown): string[] {
if (!v) return [];
if (Array.isArray(v)) return v.map((x) => String(x));
if (typeof v === 'string') return [v];
return [];
}
/**
* Args: `<phase>`
*/
export const requirementsExtractFromPlans: QueryHandler = async (args, projectDir) => {
const phase = args[0];
if (!phase) {
throw new GSDError('phase required', ErrorClassification.Validation);
}
const normalized = normalizePhaseName(phase);
const phaseDir = await resolvePhaseDir(phase, projectDir);
if (!phaseDir) {
return {
data: {
phase: normalized,
requirements: [] as string[],
by_plan: {} as Record<string, string[]>,
error: 'Phase not found',
},
};
}
const files = await readdir(phaseDir);
const planFiles = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
const byPlan: Record<string, string[]> = {};
const seen = new Set<string>();
for (const planFile of planFiles) {
const planId =
planFile === 'PLAN.md' ? 'PLAN' : planFile.replace(/-PLAN\.md$/i, '').replace(/PLAN\.md$/i, '');
const content = await readFile(join(phaseDir, planFile), 'utf-8');
const fm = extractFrontmatter(content) as Record<string, unknown>;
const list = normalizeReqList(fm.requirements);
byPlan[planId] = list;
for (const r of list) {
seen.add(r);
}
}
return {
data: {
phase: normalized,
requirements: [...seen].sort(),
by_plan: byPlan,
},
};
};

View File

@@ -0,0 +1,132 @@
/**
* roadmap.update-plan-progress — sync ROADMAP.md progress table + plan checkboxes
* from on-disk PLAN/SUMMARY counts for a phase.
*
* Port of `cmdRoadmapUpdatePlanProgress` from get-shit-done/bin/lib/roadmap.cjs
* (lines 257–354). Uses `findPhase` for disk stats and `readModifyWriteRoadmapMd`
* for atomic writes (same pattern as `phase.complete`).
*/
import { findPhase } from './phase.js';
import { readModifyWriteRoadmapMd, replaceInCurrentMilestone } from './phase-lifecycle.js';
import { existsSync } from 'node:fs';
import { escapeRegex, planningPaths } from './helpers.js';
import { GSDError, ErrorClassification } from '../errors.js';
import type { QueryHandler } from './utils.js';
export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => {
const phaseNum = args[0];
if (!phaseNum) {
throw new GSDError('phase number required for roadmap update-plan-progress', ErrorClassification.Validation);
}
const phaseResult = await findPhase([phaseNum], projectDir);
const info = phaseResult.data as {
found: boolean;
plans: string[];
summaries: string[];
};
if (!info.found) {
throw new GSDError(`Phase ${phaseNum} not found`, ErrorClassification.Validation);
}
const planCount = info.plans.length;
const summaryCount = info.summaries.length;
if (planCount === 0) {
return {
data: {
updated: false,
reason: 'No plans found',
plan_count: 0,
summary_count: 0,
},
};
}
const isComplete = summaryCount >= planCount;
const status = isComplete ? 'Complete' : summaryCount > 0 ? 'In Progress' : 'Planned';
const today = new Date().toISOString().split('T')[0]!;
const roadmapPath = planningPaths(projectDir).roadmap;
if (!existsSync(roadmapPath)) {
return {
data: {
updated: false,
reason: 'ROADMAP.md not found',
plan_count: planCount,
summary_count: summaryCount,
},
};
}
await readModifyWriteRoadmapMd(projectDir, (roadmapContent) => {
const phaseEscaped = escapeRegex(phaseNum);
const tableRowPattern = new RegExp(
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
'im',
);
const dateField = isComplete ? ` ${today} ` : ' ';
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
const cells = fullRow.split('|').slice(1, -1);
if (cells.length === 5) {
cells[2] = ` ${summaryCount}/${planCount} `;
cells[3] = ` ${status.padEnd(11)}`;
cells[4] = dateField;
} else if (cells.length === 4) {
cells[1] = ` ${summaryCount}/${planCount} `;
cells[2] = ` ${status.padEnd(11)}`;
cells[3] = dateField;
}
return '|' + cells.join('|') + '|';
});
const planCountPattern = new RegExp(
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
'i',
);
const planCountText = isComplete
? `${summaryCount}/${planCount} plans complete`
: `${summaryCount}/${planCount} plans executed`;
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
if (isComplete) {
const checkboxPattern = new RegExp(
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
'i',
);
roadmapContent = replaceInCurrentMilestone(
roadmapContent,
checkboxPattern,
`$1x$2 (completed ${today})`,
);
}
const summaries = info.summaries;
for (const summaryFile of summaries) {
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
if (!planId) continue;
const planEscaped = escapeRegex(planId);
const planCheckboxPattern = new RegExp(
`(-\\s*\\[) (\\]\\s*(?:\\*\\*)?${planEscaped}(?:\\*\\*)?)`,
'i',
);
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
}
return roadmapContent;
});
return {
data: {
updated: true,
phase: phaseNum,
plan_count: planCount,
summary_count: summaryCount,
status,
complete: isComplete,
},
};
};

View File

@@ -120,7 +120,40 @@ describe('getMilestoneInfo', () => {
expect(info.name).toBe('Belgium');
});
it('falls back to v1.0 when ROADMAP.md missing', async () => {
it('extracts from yellow-circle in-flight marker (GSD ROADMAP template)', async () => {
const roadmap = '- 🟡 **v3.1 Upstream Landing** — Phase 15 (in flight)';
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v3.1');
expect(info.name).toBe('Upstream Landing');
});
it('uses last **vX.Y Title** in milestone list before ## Phases when no emoji match', async () => {
const roadmap = `## Milestones
- ✅ **v1.0 A**
- ✅ **v3.0 B**
- ✅ **v3.1 Current Name**
## Phases
`;
await writeFile(join(tmpDir, '.planning', 'ROADMAP.md'), roadmap);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v3.1');
expect(info.name).toBe('Current Name');
});
it('falls back to STATE.md milestone when ROADMAP.md is missing', async () => {
await writeFile(
join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v4.2\nmilestone_name: From State\n---\n\n# State\n',
);
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v4.2');
expect(info.name).toBe('From State');
});
it('falls back to v1.0 when ROADMAP.md and STATE.md lack milestone', async () => {
const info = await getMilestoneInfo(tmpDir);
expect(info.version).toBe('v1.0');
expect(info.name).toBe('milestone');

View File

@@ -17,6 +17,7 @@
* ```
*/
import { existsSync } from 'node:fs';
import { readFile, writeFile, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { GSDError, ErrorClassification } from '../errors.js';
@@ -53,9 +54,30 @@ export function stripShippedMilestones(content: string): string {
}
/**
* Get milestone version and name from ROADMAP.md.
* Read milestone + name from STATE.md frontmatter when ROADMAP does not encode them.
*/
async function parseMilestoneFromState(projectDir: string): Promise<{ version: string; name: string } | null> {
try {
const stateRaw = await readFile(planningPaths(projectDir).state, 'utf-8');
const vm = stateRaw.match(/^milestone:\s*(.+)$/m);
if (!vm) return null;
const version = vm[1].trim().replace(/^["']|["']$/g, '');
const nm = stateRaw.match(/^milestone_name:\s*(.+)$/m);
const name = nm ? nm[1].trim().replace(/^["']|["']$/g, '') : 'milestone';
return { version, name };
} catch {
return null;
}
}
/**
* Get milestone version and name from ROADMAP.md (and optionally STATE.md).
*
* Port of getMilestoneInfo from core.cjs lines 1367-1402.
* Port of getMilestoneInfo from core.cjs lines 1367-1402, extended for:
* - 🟡 in-flight marker (same list shape as 🚧)
* - milestone bullets `**vX.Y Title**` before `## Phases` (last = current when listed in semver order)
* - STATE.md frontmatter when ROADMAP has no parseable milestone
* - **last** bare `vX.Y` fallback (first match was often v1.0 from the shipped list)
*
* @param projectDir - Project root directory
* @returns Object with version and name
@@ -64,26 +86,48 @@ export async function getMilestoneInfo(projectDir: string): Promise<{ version: s
try {
const roadmap = await readFile(planningPaths(projectDir).roadmap, 'utf-8');
// First: check for list-format using in-progress marker
const inProgressMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (inProgressMatch) {
return { version: 'v' + inProgressMatch[1], name: inProgressMatch[2].trim() };
// List-format: construction / blocked (legacy emoji)
const barricadeMatch = roadmap.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (barricadeMatch) {
return { version: 'v' + barricadeMatch[1], name: barricadeMatch[2].trim() };
}
// Second: heading-format — strip shipped milestones
// List-format: in flight / active (GSD ROADMAP template uses 🟡 for current milestone)
const inFlightMatch = roadmap.match(/🟡\s*\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/);
if (inFlightMatch) {
return { version: 'v' + inFlightMatch[1], name: inFlightMatch[2].trim() };
}
// Heading-format — strip shipped <details> blocks first
const cleaned = stripShippedMilestones(roadmap);
const headingMatch = cleaned.match(/## .*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
const headingMatch = cleaned.match(/##\s+.*v(\d+(?:\.\d+)+)[:\s]+([^\n(]+)/);
if (headingMatch) {
return { version: 'v' + headingMatch[1], name: headingMatch[2].trim() };
}
// Fallback: bare version match
const versionMatch = cleaned.match(/v(\d+(?:\.\d+)+)/);
return {
version: versionMatch ? versionMatch[0] : 'v1.0',
name: 'milestone',
};
// Milestone bullet list (## Milestones … ## Phases): use last **vX.Y Title** — typically the current row
const beforePhases = roadmap.split(/^##\s+Phases\b/m)[0] ?? roadmap;
const boldMatches = [...beforePhases.matchAll(/\*\*v(\d+(?:\.\d+)+)\s+([^*]+)\*\*/g)];
if (boldMatches.length > 0) {
const last = boldMatches[boldMatches.length - 1];
return { version: 'v' + last[1], name: last[2].trim() };
}
const fromState = await parseMilestoneFromState(projectDir);
if (fromState) {
return fromState;
}
const allBare = [...cleaned.matchAll(/\bv(\d+(?:\.\d+)+)\b/g)];
if (allBare.length > 0) {
const lastBare = allBare[allBare.length - 1];
return { version: lastBare[0], name: 'milestone' };
}
return { version: 'v1.0', name: 'milestone' };
} catch {
const fromState = await parseMilestoneFromState(projectDir);
if (fromState) return fromState;
return { version: 'v1.0', name: 'milestone' };
}
}
@@ -110,7 +154,7 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
// Fallback: derive from ROADMAP in-progress marker
if (!version) {
const inProgressMatch = content.match(/🚧\s*\*\*v(\d+(?:\.\d+)+)\s/);
const inProgressMatch = content.match(/(?:🚧|🟡)\s*\*\*v(\d+(?:\.\d+)+)\s/);
if (inProgressMatch) {
version = 'v' + inProgressMatch[1];
}
@@ -135,14 +179,13 @@ export async function extractCurrentMilestone(content: string, projectDir: strin
const headingLevelMatch = sectionMatch[1].match(/^(#{1,3})\s/);
const headingLevel = headingLevelMatch ? headingLevelMatch[1].length : 2;
const restContent = content.slice(sectionStart + sectionMatch[0].length);
// Extract current version so same-version sub-headings are not treated as boundaries.
// Capture full semver (major.minor.patch) so v2.0.1 is not collapsed to "2.0".
const currentVersionMatch = version ? version.match(/v(\d+(?:\.\d+)+)/i) : null;
const currentVersionStr = currentVersionMatch ? currentVersionMatch[1] : '';
const nextMilestoneRegex = new RegExp(
`^#{1,${headingLevel}}\\s+(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧))`,
`^#{1,${headingLevel}}\\s+(?:.*v(\\d+(?:\\.\\d+)+)[^\\n]*|.*(?:✅|📋|🚧|🟡))`,
'gm'
);
@@ -419,57 +462,83 @@ export const roadmapAnalyze: QueryHandler = async (_args, projectDir) => {
return { data: result };
};
// ─── roadmapUpdatePlanProgress ────────────────────────────────────────────
export const roadmapUpdatePlanProgress: QueryHandler = async (args, projectDir) => {
const phase = args[0];
const paths = planningPaths(projectDir);
if (!phase) {
return { data: { updated: false, reason: 'phase argument required' } };
}
try {
let content = await readFile(paths.roadmap, 'utf-8');
const phaseNum = normalizePhaseName(phase);
const updated = content.replace(
/(-\s*\[\s*\]\s*(?:Plan\s+\d+|plan\s+\d+|\*\*Plan))/gi,
(match) => match.replace('[ ]', '[x]'),
);
if (updated !== content) {
await writeFile(paths.roadmap, updated, 'utf-8');
return { data: { updated: true, phase: phaseNum } };
}
return { data: { updated: false, phase: phaseNum, reason: 'no matching checkbox found' } };
} catch {
return { data: { updated: false, reason: 'ROADMAP.md not found or unreadable' } };
}
};
// ─── requirementsMarkComplete ─────────────────────────────────────────────
/**
* Mark requirement IDs complete in REQUIREMENTS.md (checkbox + traceability table).
* Port of `cmdRequirementsMarkComplete` from milestone.cjs lines 11–87.
*/
export const requirementsMarkComplete: QueryHandler = async (args, projectDir) => {
const reqIds = args;
const paths = planningPaths(projectDir);
if (args.length === 0) {
throw new GSDError(
'requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02',
ErrorClassification.Validation,
);
}
const reqIds = args
.join(' ')
.replace(/[\[\]]/g, '')
.split(/[,\s]+/)
.map(r => r.trim())
.filter(Boolean);
if (reqIds.length === 0) {
return { data: { marked: false, reason: 'requirement IDs required' } };
throw new GSDError('no valid requirement IDs found', ErrorClassification.Validation);
}
try {
let content = await readFile(paths.requirements, 'utf-8');
let changeCount = 0;
const paths = planningPaths(projectDir);
if (!existsSync(paths.requirements)) {
return { data: { updated: false, reason: 'REQUIREMENTS.md not found', ids: reqIds } };
}
for (const id of reqIds) {
const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(-\\s*\\[\\s*\\]\\s*)([^\\n]*${escaped})`, 'gi');
content = content.replace(pattern, (_m, _bracket, rest) => `- [x] ${rest}`.trim() + '\n' || `- [x] ${rest}`);
if (content.includes(`[x]`) && content.includes(id)) changeCount++;
let reqContent = (await readFile(paths.requirements, 'utf-8')).replace(/\r\n/g, '\n');
const updated: string[] = [];
const alreadyComplete: string[] = [];
const notFound: string[] = [];
for (const reqId of reqIds) {
let found = false;
const reqEscaped = escapeRegex(reqId);
const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi');
const afterCheckbox = reqContent.replace(checkboxPattern, '$1x$2');
if (afterCheckbox !== reqContent) {
reqContent = afterCheckbox;
found = true;
}
await writeFile(paths.requirements, content, 'utf-8');
return { data: { marked: true, ids: reqIds, changed: changeCount } };
} catch {
return { data: { marked: false, reason: 'REQUIREMENTS.md not found or unreadable' } };
const tablePattern = new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*Pending\\s*(\\|)`, 'gi');
const afterTable = reqContent.replace(tablePattern, '$1 Complete $2');
if (afterTable !== reqContent) {
reqContent = afterTable;
found = true;
}
if (found) {
updated.push(reqId);
} else {
const doneCheckbox = new RegExp(`-\\s*\\[x\\]\\s*\\*\\*${reqEscaped}\\*\\*`, 'i');
const doneTable = new RegExp(`\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|\\s*Complete\\s*\\|`, 'i');
if (doneCheckbox.test(reqContent) || doneTable.test(reqContent)) {
alreadyComplete.push(reqId);
} else {
notFound.push(reqId);
}
}
}
if (updated.length > 0) {
await writeFile(paths.requirements, reqContent, 'utf-8');
}
return {
data: {
updated: updated.length > 0,
marked_complete: updated,
already_complete: alreadyComplete,
not_found: notFound,
total: reqIds.length,
},
};
};

View File

@@ -0,0 +1,61 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { describe, it, expect } from 'vitest';
import { routeNextAction } from './route-next-action.js';
describe('routeNextAction', () => {
it('suggests new-project when STATE.md is missing', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-rna-'));
await mkdir(join(dir, '.planning'), { recursive: true });
const { data } = await routeNextAction([], dir);
expect(data).toMatchObject({
command: '/gsd-new-project',
reason: expect.stringContaining('STATE.md'),
});
});
it('routes to resume-work when paused', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-rna-'));
await mkdir(join(dir, '.planning'), { recursive: true });
await writeFile(
join(dir, '.planning', 'STATE.md'),
`---
milestone: v1.0
---
**Paused At:** Phase 2
`,
'utf-8',
);
await writeFile(join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
const { data } = await routeNextAction([], dir);
expect(data).toMatchObject({
command: '/gsd-resume-work',
});
});
it('blocks when .continue-here.md exists', async () => {
const dir = await mkdtemp(join(tmpdir(), 'gsd-rna-'));
await mkdir(join(dir, '.planning'), { recursive: true });
await writeFile(join(dir, '.planning', '.continue-here.md'), 'checkpoint\n', 'utf-8');
await writeFile(
join(dir, '.planning', 'STATE.md'),
`---
milestone: v1.0
---
**Current Phase:** 3
`,
'utf-8',
);
await writeFile(join(dir, '.planning', 'ROADMAP.md'), '# Roadmap\n', 'utf-8');
const { data } = await routeNextAction([], dir);
expect(data).toMatchObject({
command: '',
gates: expect.objectContaining({ continue_here: true }),
});
});
});

View File

@@ -0,0 +1,345 @@
/**
* Next slash-command suggestion for `/gsd-next`-style routing (`route.next-action`).
*
* Deterministic routing from STATE.md, ROADMAP, and phase directories.
* See `.planning/research/decision-routing-audit.md` §3.1 and `get-shit-done/workflows/next.md`.
*/
import { readFile, readdir } from 'node:fs/promises';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { planningPaths, normalizePhaseName, comparePhaseNum } from './helpers.js';
import { stateJson } from './state.js';
import { roadmapAnalyze } from './roadmap.js';
import { findPhase } from './phase.js';
import type { QueryHandler } from './utils.js';
function readConsecutiveCallCount(planningDir: string): number {
try {
const raw = readFileSync(join(planningDir, '.next-call-count'), 'utf-8');
return parseInt(raw.trim(), 10) || 0;
} catch {
return 0;
}
}
/** Unresolved FAIL rows in phase VERIFICATION.md (lightweight gate). */
async function hasUnresolvedVerificationFails(phaseDirAbs: string): Promise<boolean> {
try {
const files = await readdir(phaseDirAbs);
const vf = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
if (!vf) return false;
const content = await readFile(join(phaseDirAbs, vf), 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
if (/\|\s*FAIL\s*\|/i.test(line) && !/override/i.test(line)) return true;
}
return false;
} catch {
return false;
}
}
async function verificationPassed(phaseDirAbs: string): Promise<boolean> {
try {
const files = await readdir(phaseDirAbs);
const vf = files.find(f => f === 'VERIFICATION.md' || f.endsWith('-VERIFICATION.md'));
if (!vf) return false;
const content = await readFile(join(phaseDirAbs, vf), 'utf-8');
return /status:\s*passed/i.test(content);
} catch {
return false;
}
}
export const routeNextAction: QueryHandler = async (_args, projectDir) => {
const planning = planningPaths(projectDir).planning;
const continueHere = existsSync(join(planning, '.continue-here.md'));
const sj = await stateJson([], projectDir);
const sjd = sj.data as Record<string, unknown>;
if (sjd.error) {
return {
data: {
command: '/gsd-new-project',
args: '',
reason: 'No STATE.md — initialize a GSD project first',
current_phase: null,
phase_name: null,
gates: {
continue_here: continueHere,
error_state: false,
unresolved_verification: false,
consecutive_calls: 0,
},
context: {},
},
};
}
const status = String(sjd.status ?? '');
const errorState = /\b(error|failed)\b/i.test(status);
const pausedAt = sjd.paused_at ? String(sjd.paused_at) : null;
let currentPhase = sjd.current_phase ? String(sjd.current_phase) : null;
const phaseName = sjd.current_phase_name ? String(sjd.current_phase_name) : null;
const consecutiveCalls = readConsecutiveCallCount(planning);
const ra = await roadmapAnalyze([], projectDir);
const raData = ra.data as { phases?: Array<Record<string, unknown>> };
const phases = raData.phases ?? [];
const phasesDir = planningPaths(projectDir).phases;
let dirCount = 0;
try {
dirCount = readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory()).length;
} catch { /* no phases dir */ }
let unresolvedVerification = false;
if (currentPhase) {
const fp = await findPhase([currentPhase], projectDir);
const fd = fp.data as Record<string, unknown>;
if (fd.found && fd.directory) {
unresolvedVerification = await hasUnresolvedVerificationFails(
join(projectDir, fd.directory as string),
);
}
}
const gates = {
continue_here: continueHere,
error_state: errorState,
unresolved_verification: unresolvedVerification,
consecutive_calls: consecutiveCalls,
};
const buildContext = async (cp: string | null) => {
if (!cp) {
return {
has_context: false,
has_research: false,
has_plans: false,
plan_count: 0,
summary_count: 0,
has_verification: false,
paused_at: pausedAt,
uat_gaps: 0,
};
}
const fp = await findPhase([cp], projectDir);
const d = fp.data as Record<string, unknown>;
const plans = (d.plans as string[]) ?? [];
const summaries = (d.summaries as string[]) ?? [];
return {
has_context: Boolean(d.has_context),
has_research: Boolean(d.has_research),
has_plans: plans.length > 0,
plan_count: plans.length,
summary_count: summaries.length,
has_verification: Boolean(d.has_verification),
paused_at: pausedAt,
uat_gaps: 0,
};
};
if (pausedAt) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-resume-work',
args: '',
reason: 'Paused — resume work before other routing',
current_phase: currentPhase,
phase_name: phaseName,
gates,
context: { ...ctx, paused_at: pausedAt },
},
};
}
if (continueHere || errorState || unresolvedVerification) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '',
args: '',
reason: continueHere
? 'Blocked: .planning/.continue-here.md exists'
: errorState
? 'Blocked: STATE.md status is error or failed'
: 'Blocked: unresolved VERIFICATION FAIL items',
current_phase: currentPhase,
phase_name: phaseName,
gates,
context: ctx,
},
};
}
// Route 1 — ROADMAP lists phases but no phase directories
if (phases.length > 0 && dirCount === 0) {
const first = String(phases[0].number);
const ctx = await buildContext(first);
return {
data: {
command: '/gsd-discuss-phase',
args: first,
reason: 'ROADMAP has phases but no phase directories on disk yet',
current_phase: first,
phase_name: String(phases[0].name ?? ''),
gates,
context: ctx,
},
};
}
if (!currentPhase && phases.length > 0) {
currentPhase = String(phases[0].number);
}
if (!currentPhase) {
const ctx = await buildContext(null);
return {
data: {
command: '',
args: '',
reason: 'No current phase in STATE.md and no roadmap phases',
current_phase: null,
phase_name: null,
gates,
context: ctx,
},
};
}
const fp = await findPhase([currentPhase], projectDir);
const pd = fp.data as Record<string, unknown>;
const found = Boolean(pd.found);
const cp = normalizePhaseName(currentPhase);
const displayName = (pd.phase_name as string) || phaseName || '';
const sorted = [...phases].sort((a, b) =>
comparePhaseNum(String(a.number), String(b.number)),
);
if (!found) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-discuss-phase',
args: cp,
reason: 'Phase directory not found — start with discuss',
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
}
const plans = (pd.plans as string[]) ?? [];
const incomplete = (pd.incomplete_plans as string[]) ?? [];
const hasContext = Boolean(pd.has_context);
const hasResearch = Boolean(pd.has_research);
const phaseDirAbs = pd.directory ? join(projectDir, pd.directory as string) : '';
// Route 2
if (!hasContext && !hasResearch) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-discuss-phase',
args: cp,
reason: 'No CONTEXT.md or RESEARCH.md for this phase',
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
}
// Route 3
if (plans.length === 0) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-plan-phase',
args: cp,
reason: 'Context exists but no PLAN.md files',
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
}
// Route 4
if (incomplete.length > 0) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-execute-phase',
args: cp,
reason: `${incomplete.length} plan(s) still need SUMMARY.md`,
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
}
// Summaries match plans — verification / advance
const verPassed = phaseDirAbs ? await verificationPassed(phaseDirAbs) : false;
const hasVerFile = Boolean(pd.has_verification);
if (!hasVerFile || !verPassed) {
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-verify-work',
args: '',
reason: 'All plans have summaries — run verification',
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
}
// Phase verified — Route 6 vs 7 handled by allComplete above; find next incomplete phase
const idx = sorted.findIndex(p => normalizePhaseName(String(p.number)) === cp);
const next = idx >= 0 ? sorted.slice(idx + 1).find(p => p.disk_status !== 'complete' && !p.roadmap_complete) : null;
if (next) {
const nextNum = String(next.number);
const ctx = await buildContext(nextNum);
return {
data: {
command: '/gsd-discuss-phase',
args: nextNum,
reason: 'Current phase verified — advance to next phase',
current_phase: nextNum,
phase_name: String(next.name ?? ''),
gates,
context: ctx,
},
};
}
const ctx = await buildContext(currentPhase);
return {
data: {
command: '/gsd-complete-milestone',
args: '',
reason: 'Verified phase with no further phases — complete milestone',
current_phase: currentPhase,
phase_name: displayName,
gates,
context: ctx,
},
};
};

View File

@@ -0,0 +1,189 @@
/**
* Schema drift detection — ports `get-shit-done/bin/lib/schema-detect.cjs`.
* Used by `verify.schema-drift` to match gsd-tools.cjs JSON output.
*/
// ─── ORM patterns ─────────────────────────────────────────────────────────
const SCHEMA_PATTERNS: Array<{ pattern: RegExp; orm: string }> = [
{ pattern: /^src\/collections\/.*\.ts$/, orm: 'payload' },
{ pattern: /^src\/globals\/.*\.ts$/, orm: 'payload' },
{ pattern: /^prisma\/schema\.prisma$/, orm: 'prisma' },
{ pattern: /^prisma\/schema\/.*\.prisma$/, orm: 'prisma' },
{ pattern: /^drizzle\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^src\/db\/schema\.ts$/, orm: 'drizzle' },
{ pattern: /^drizzle\/.*\.ts$/, orm: 'drizzle' },
{ pattern: /^supabase\/migrations\/.*\.sql$/, orm: 'supabase' },
{ pattern: /^src\/entities\/.*\.ts$/, orm: 'typeorm' },
{ pattern: /^src\/migrations\/.*\.ts$/, orm: 'typeorm' },
];
const ORM_INFO: Record<
string,
{
pushCommand: string;
envHint: string | null;
interactiveWarning: string | null;
evidencePatterns: RegExp[];
}
> = {
payload: {
pushCommand: 'npx payload migrate',
envHint: 'CI=true PAYLOAD_MIGRATING=true npx payload migrate',
interactiveWarning:
'Payload migrate may require interactive prompts — use CI=true PAYLOAD_MIGRATING=true to suppress',
evidencePatterns: [/payload\s+migrate/i, /PAYLOAD_MIGRATING/],
},
prisma: {
pushCommand: 'npx prisma db push',
envHint: 'npx prisma db push --accept-data-loss (if destructive changes are intended)',
interactiveWarning:
'Prisma db push may prompt for confirmation on destructive changes — use --accept-data-loss to bypass',
evidencePatterns: [/prisma\s+db\s+push/i, /prisma\s+migrate\s+deploy/i, /prisma\s+migrate\s+dev/i],
},
drizzle: {
pushCommand: 'npx drizzle-kit push',
envHint: 'npx drizzle-kit push',
interactiveWarning: null,
evidencePatterns: [/drizzle-kit\s+push/i, /drizzle-kit\s+migrate/i],
},
supabase: {
pushCommand: 'supabase db push',
envHint: 'supabase db push',
interactiveWarning:
'Supabase db push may require authentication — ensure SUPABASE_ACCESS_TOKEN is set',
evidencePatterns: [/supabase\s+db\s+push/i, /supabase\s+migration\s+up/i],
},
typeorm: {
pushCommand: 'npx typeorm migration:run',
envHint: 'npx typeorm migration:run -d src/data-source.ts',
interactiveWarning: null,
evidencePatterns: [/typeorm\s+migration:run/i, /typeorm\s+schema:sync/i],
},
};
// ─── Public API ───────────────────────────────────────────────────────────
export function detectSchemaFiles(files: string[]): {
detected: boolean;
matches: string[];
orms: string[];
} {
const matches: string[] = [];
const orms = new Set<string>();
for (const rawFile of files) {
const file = rawFile.replace(/\\/g, '/');
for (const { pattern, orm } of SCHEMA_PATTERNS) {
if (pattern.test(file)) {
matches.push(rawFile);
orms.add(orm);
break;
}
}
}
return {
detected: matches.length > 0,
matches,
orms: [...orms],
};
}
export function checkSchemaDrift(
changedFiles: string[],
executionLog: string,
options: { skipCheck?: boolean } = {},
): {
driftDetected: boolean;
blocking: boolean;
schemaFiles: string[];
orms: string[];
unpushedOrms: string[];
message: string;
skipped?: boolean;
} {
const { skipCheck = false } = options;
const detection = detectSchemaFiles(changedFiles);
if (!detection.detected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: [],
orms: [],
unpushedOrms: [],
message: '',
};
}
const pushedOrms = new Set<string>();
const unpushedOrms: string[] = [];
for (const orm of detection.orms) {
const info = ORM_INFO[orm];
if (!info) continue;
const hasPushEvidence = info.evidencePatterns.some(p => p.test(executionLog));
if (hasPushEvidence) {
pushedOrms.add(orm);
} else {
unpushedOrms.push(orm);
}
}
const driftDetected = unpushedOrms.length > 0;
if (!driftDetected) {
return {
driftDetected: false,
blocking: false,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms: [],
message: '',
};
}
const pushCommands = unpushedOrms
.map(orm => {
const info = ORM_INFO[orm];
return info ? ` ${orm}: ${info.envHint || info.pushCommand}` : null;
})
.filter(Boolean)
.join('\n');
const message = [
'Schema drift detected: schema-relevant files changed but no database push was executed.',
'',
`Schema files changed: ${detection.matches.join(', ')}`,
`ORMs requiring push: ${unpushedOrms.join(', ')}`,
'',
'Required push commands:',
pushCommands,
'',
'Run the appropriate push command, or set GSD_SKIP_SCHEMA_CHECK=true to bypass this gate.',
].join('\n');
if (skipCheck) {
return {
driftDetected: true,
blocking: false,
skipped: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message: 'Schema drift detected but check was skipped (GSD_SKIP_SCHEMA_CHECK=true).',
};
}
return {
driftDetected: true,
blocking: true,
schemaFiles: detection.matches,
orms: detection.orms,
unpushedOrms,
message,
};
}

View File

@@ -0,0 +1,214 @@
/**
* Skill manifest — multi-root skill discovery scan.
*
* Full port of `buildSkillManifest` / `cmdSkillManifest` from
* `get-shit-done/bin/lib/init.cjs` (lines 1640–1847).
* Uses {@link extractFrontmatterLeading} — same as CJS `frontmatter.cjs` `extractFrontmatter`
* (first `---` block only; skills with later `---` rules must not use TS `extractFrontmatter`'s last-block rule).
*/
import { existsSync, readdirSync, readFileSync, writeFileSync, type Dirent } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { extractFrontmatterLeading } from './frontmatter.js';
import type { QueryHandler } from './utils.js';
export interface SkillManifestSkill {
name: string;
description: string;
triggers: string[];
path: string;
file_path: string;
root: string;
scope: string;
installed: boolean;
deprecated: boolean;
}
export interface SkillManifestRoot {
root: string;
path: string;
scope: string;
present: boolean;
deprecated?: boolean;
skill_count?: number;
command_count?: number;
}
export interface SkillManifestJson {
skills: SkillManifestSkill[];
roots: SkillManifestRoot[];
installation: {
gsd_skills_installed: boolean;
legacy_claude_commands_installed: boolean;
};
counts: { skills: number; roots: number };
}
/**
* Scan canonical skill roots and build manifest JSON (same shape as gsd-tools.cjs).
*/
export function buildSkillManifest(cwd: string, skillsDir: string | null = null): SkillManifestJson {
const canonicalRoots = skillsDir
? [{
root: resolve(skillsDir),
path: resolve(skillsDir),
scope: 'custom',
present: existsSync(skillsDir),
kind: 'skills' as const,
}]
: [
{ root: '.claude/skills', path: join(cwd, '.claude', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.agents/skills', path: join(cwd, '.agents', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.cursor/skills', path: join(cwd, '.cursor', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.github/skills', path: join(cwd, '.github', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '.codex/skills', path: join(cwd, '.codex', 'skills'), scope: 'project', kind: 'skills' as const },
{ root: '~/.claude/skills', path: join(homedir(), '.claude', 'skills'), scope: 'global', kind: 'skills' as const },
{ root: '~/.codex/skills', path: join(homedir(), '.codex', 'skills'), scope: 'global', kind: 'skills' as const },
{
root: '.claude/get-shit-done/skills',
path: join(homedir(), '.claude', 'get-shit-done', 'skills'),
scope: 'import-only',
kind: 'skills' as const,
deprecated: true,
},
{
root: '.claude/commands/gsd',
path: join(homedir(), '.claude', 'commands', 'gsd'),
scope: 'legacy-commands',
kind: 'commands' as const,
deprecated: true,
},
];
const skills: SkillManifestSkill[] = [];
const roots: SkillManifestRoot[] = [];
let legacyClaudeCommandsInstalled = false;
for (const rootInfo of canonicalRoots) {
const rootPath = rootInfo.path;
const rootSummary: SkillManifestRoot = {
root: rootInfo.root,
path: rootPath,
scope: rootInfo.scope,
present: existsSync(rootPath),
deprecated: 'deprecated' in rootInfo ? !!rootInfo.deprecated : false,
};
if (!rootSummary.present) {
roots.push(rootSummary);
continue;
}
if (rootInfo.kind === 'commands') {
let entries: Dirent[];
try {
entries = readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
const commandFiles = entries.filter(e => e.isFile() && e.name.endsWith('.md'));
rootSummary.command_count = commandFiles.length;
if (rootSummary.command_count > 0) legacyClaudeCommandsInstalled = true;
roots.push(rootSummary);
continue;
}
let entries: Dirent[];
try {
entries = readdirSync(rootPath, { withFileTypes: true });
} catch {
roots.push(rootSummary);
continue;
}
let skillCount = 0;
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const entryName = entry.name.toString();
const skillMdPath = join(rootPath, entryName, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
let content: string;
try {
content = readFileSync(skillMdPath, 'utf-8');
} catch {
continue;
}
const frontmatter = extractFrontmatterLeading(content);
const name = (frontmatter.name as string) || entryName;
const description = (frontmatter.description as string) || '';
const triggers: string[] = [];
const bodyMatch = content.match(/^---[\s\S]*?---\s*\n([\s\S]*)$/);
if (bodyMatch) {
const body = bodyMatch[1];
const triggerLines = body.match(/^TRIGGER\s+when:\s*(.+)$/gim);
if (triggerLines) {
for (const line of triggerLines) {
const m = line.match(/^TRIGGER\s+when:\s*(.+)$/i);
if (m) triggers.push(m[1].trim());
}
}
}
skills.push({
name,
description,
triggers,
path: entryName,
file_path: `${entryName}/SKILL.md`,
root: rootInfo.root,
scope: rootInfo.scope,
installed: rootInfo.scope !== 'import-only',
deprecated: !!('deprecated' in rootInfo && rootInfo.deprecated),
});
skillCount++;
}
rootSummary.skill_count = skillCount;
roots.push(rootSummary);
}
skills.sort((a, b) => {
const rootCmp = a.root.localeCompare(b.root);
return rootCmp !== 0 ? rootCmp : a.name.localeCompare(b.name);
});
const gsdSkillsInstalled = skills.some(skill => skill.name.startsWith('gsd-'));
return {
skills,
roots,
installation: {
gsd_skills_installed: gsdSkillsInstalled,
legacy_claude_commands_installed: legacyClaudeCommandsInstalled,
},
counts: {
skills: skills.length,
roots: roots.length,
},
};
}
/**
* `skill-manifest` — same flags as gsd-tools: `--skills-dir`, `--write`.
*/
export const skillManifest: QueryHandler = async (args, projectDir) => {
const skillsDirIdx = args.indexOf('--skills-dir');
const skillsDir = skillsDirIdx >= 0 && args[skillsDirIdx + 1] ? args[skillsDirIdx + 1] : null;
const manifest = buildSkillManifest(projectDir, skillsDir);
if (args.includes('--write')) {
const planningDir = join(projectDir, '.planning');
if (existsSync(planningDir)) {
const manifestPath = join(planningDir, 'skill-manifest.json');
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
}
}
return { data: manifest };
};

View File

@@ -25,6 +25,11 @@ describe('agentSkills', () => {
let tmpDir: string;
let homeDir: string;
it('returns empty string when no agent type (matches gsd-tools)', async () => {
const r = await agentSkills([], tmpdir());
expect(r.data).toBe('');
});
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gsd-skills-'));
homeDir = await mkdtemp(join(tmpdir(), 'gsd-skills-home-'));
@@ -35,6 +40,8 @@ describe('agentSkills', () => {
await writeSkill(join(homeDir, '.codex', 'skills'), 'global-codex');
await writeSkill(join(homeDir, '.claude', 'get-shit-done', 'skills'), 'legacy-import');
vi.stubEnv('HOME', homeDir);
// Windows `os.homedir()` reads USERPROFILE, not HOME
vi.stubEnv('USERPROFILE', homeDir);
});
afterEach(async () => {

View File

@@ -11,6 +11,8 @@
*
* await agentSkills(['gsd-executor'], '/project');
* // { data: { agent_type: 'gsd-executor', skills: ['plan', 'verify'], skill_count: 2 } }
* await agentSkills([], '/project');
* // { data: '' } — matches gsd-tools when no agent type is passed
* ```
*/
@@ -21,7 +23,11 @@ import { homedir } from 'node:os';
import type { QueryHandler } from './utils.js';
export const agentSkills: QueryHandler = async (args, projectDir) => {
const agentType = args[0] || '';
const agentType = (args[0] || '').trim();
// Match gsd-tools `cmdAgentSkills`: no agent type → empty string (JSON `""`), not a structured object.
if (!agentType) {
return { data: '' };
}
const skillDirs = [
join(projectDir, '.claude', 'skills'),
join(projectDir, '.agents', 'skills'),

View File

@@ -223,14 +223,14 @@ describe('stateUpdate', () => {
it('updates a single field and round-trips through stateLoad', async () => {
const { stateUpdate } = await import('./state-mutation.js');
const { stateLoad } = await import('./state.js');
const { stateJson } = await import('./state.js');
const result = await stateUpdate(['Status', 'Phase complete'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.updated).toBe(true);
// Verify round-trip
const loaded = await stateLoad([], tmpDir);
const loaded = await stateJson([], tmpDir);
const loadedData = loaded.data as Record<string, unknown>;
// Status gets normalized by buildStateFrontmatter
expect(loadedData.status).toBeTruthy();
@@ -271,7 +271,7 @@ describe('statePatch', () => {
const patches = JSON.stringify({ Status: 'done', Progress: '100%' });
const result = await statePatch([patches], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.patched).toBe(true);
expect((data.updated as string[]).length).toBeGreaterThan(0);
// Verify file was updated
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -319,7 +319,7 @@ describe('stateBeginPhase', () => {
// Must return the actual values, not the flag names
expect(data.phase).toBe('99');
expect(data.name).toBe('probe-test');
expect(data.plan_count).toBe('1');
expect(data.plan_count).toBe(1);
// STATE.md must contain clean output, not literal "--phase"
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
@@ -337,7 +337,7 @@ describe('stateBeginPhase', () => {
const data = result.data as Record<string, unknown>;
expect(data.phase).toBe('42');
expect(data.name).toBe('Positional Test');
expect(data.plan_count).toBe('5');
expect(data.plan_count).toBe(5);
});
it('bug-2420: flag parser throws when a flag value is missing (next token is a flag)', async () => {
@@ -348,6 +348,19 @@ describe('stateBeginPhase', () => {
stateBeginPhase(['--phase', '--name', 'Title', '--plans', '1'], tmpDir)
).rejects.toThrow('missing value for --phase');
});
it('does not treat argv after named flags as positional name/plans', async () => {
const { stateBeginPhase } = await import('./state-mutation.js');
const result = await stateBeginPhase(['--phase', '2', '--plans', '3'], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.phase).toBe('2');
expect(data.phase_name).toBeFalsy();
expect(data.plan_count).toBe(3);
const content = await readFile(join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
expect(content).toContain('Plan: 1 of 3');
});
});
// ─── stateAdvancePlan ───────────────────────────────────────────────────────
@@ -391,7 +404,10 @@ describe('stateAddDecision', () => {
it('appends decision and removes placeholder', async () => {
const { stateAddDecision } = await import('./state-mutation.js');
const result = await stateAddDecision(['[Phase 10]: Use lockfile atomicity'], tmpDir);
const result = await stateAddDecision(
['--phase', '10', '--summary', 'Use lockfile atomicity'],
tmpDir,
);
const data = result.data as Record<string, unknown>;
expect(data.added).toBe(true);
@@ -422,8 +438,8 @@ describe('stateRecordSession', () => {
const { stateRecordSession } = await import('./state-mutation.js');
const result = await stateRecordSession(
['2026-04-08T12:00:00Z', 'Completed 11-01-PLAN.md'],
tmpDir
['--stopped-at', 'Completed 11-01-PLAN.md', '--resume-file', 'None'],
tmpDir,
);
const data = result.data as Record<string, unknown>;
expect(data.recorded).toBe(true);

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
/**
* Unit tests for state query handlers.
*
* Tests stateLoad, stateGet, and stateSnapshot handlers.
* Tests stateJson, stateGet, and stateSnapshot handlers.
* Uses temp directories with real .planning/ structures.
*/
@@ -11,7 +11,7 @@ import { join } from 'node:path';
import { tmpdir } from 'node:os';
// Will be imported once implemented
import { stateLoad, stateGet, stateSnapshot } from './state.js';
import { stateJson, stateGet, stateSnapshot } from './state.js';
// ─── Fixtures ──────────────────────────────────────────────────────────────
@@ -130,11 +130,11 @@ afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
// ─── stateLoad ─────────────────────────────────────────────────────────────
// ─── stateJson (state json / state.json) ───────────────────────────────────
describe('stateLoad', () => {
describe('stateJson', () => {
it('rebuilds frontmatter from body + disk', async () => {
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.gsd_state_version).toBe('1.0');
@@ -145,7 +145,7 @@ describe('stateLoad', () => {
});
it('returns progress with disk-scanned counts', async () => {
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;
@@ -160,7 +160,7 @@ describe('stateLoad', () => {
});
it('preserves stopped_at from existing frontmatter', async () => {
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.stopped_at).toBe('Completed 10-01-PLAN.md');
@@ -180,7 +180,7 @@ Plan: 2 of 3
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
// Body has no Status field -> derived is 'unknown', should preserve frontmatter 'paused'
@@ -191,7 +191,7 @@ Plan: 2 of 3
const emptyDir = await mkdtemp(join(tmpdir(), 'gsd-state-empty-'));
await mkdir(join(emptyDir, '.planning'), { recursive: true });
const result = await stateLoad([], emptyDir);
const result = await stateJson([], emptyDir);
const data = result.data as Record<string, unknown>;
expect(data.error).toBe('STATE.md not found');
@@ -209,7 +209,7 @@ Status: In Progress
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
expect(data.status).toBe('executing');
@@ -228,7 +228,7 @@ Progress: [░░░░░░░░░░] 0%
`;
await writeFile(join(tmpDir, '.planning', 'STATE.md'), stateContent);
const result = await stateLoad([], tmpDir);
const result = await stateJson([], tmpDir);
const data = result.data as Record<string, unknown>;
const progress = data.progress as Record<string, unknown>;

View File

@@ -2,14 +2,14 @@
* State query handlers — STATE.md loading, field extraction, and snapshots.
*
* Ported from get-shit-done/bin/lib/state.cjs and core.cjs.
* Provides state.load (rebuild frontmatter from body + disk), state.get
* Provides `state json` / `state.json` (rebuilt frontmatter JSON, `stateJson`), `state.get`
* (field/section extraction), and state-snapshot (structured snapshot).
*
* @example
* ```typescript
* import { stateLoad, stateGet, stateSnapshot } from './state.js';
* import { stateJson, stateGet, stateSnapshot } from './state.js';
*
* const loaded = await stateLoad([], '/project');
* const loaded = await stateJson([], '/project');
* // { data: { gsd_state_version: '1.0', milestone: 'v3.0', ... } }
*
* const field = await stateGet(['Status'], '/project');
@@ -187,7 +187,7 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
// ─── Exported handlers ─────────────────────────────────────────────────────
/**
* Query handler for state.load / state.json.
* Query handler for `state json` / `state.json` (CJS `cmdStateJson`).
*
* Reads STATE.md, rebuilds frontmatter from body + disk scanning.
* Returns cached frontmatter-only fields (stopped_at, paused_at) when not in body.
@@ -198,7 +198,7 @@ export async function buildStateFrontmatter(bodyContent: string, projectDir: str
* @param projectDir - Project root directory
* @returns QueryResult with rebuilt state frontmatter
*/
export const stateLoad: QueryHandler = async (_args, projectDir) => {
export const stateJson: QueryHandler = async (_args, projectDir) => {
const statePath = planningPaths(projectDir).state;
let content: string;
@@ -319,10 +319,12 @@ export const stateSnapshot: QueryHandler = async (_args, projectDir) => {
// Parse numeric fields
const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
const progressPercent = progressRaw ? (() => {
const m = progressRaw.match(/(\d+)%/);
return m ? parseInt(m[1], 10) : null;
})() : null;
// Match gsd-tools `cmdStateSnapshot` (state.cjs): parseInt(progressRaw.replace('%',''), 10) — NaN → null
let progressPercent: number | null = null;
if (progressRaw) {
const n = parseInt(progressRaw.replace(/%/g, ''), 10);
progressPercent = Number.isFinite(n) ? n : null;
}
// Extract decisions table
const decisions: Array<{ phase: string; summary: string; rationale: string }> = [];

View File

@@ -21,16 +21,55 @@ describe('summaryExtract', () => {
await rm(tmpDir, { recursive: true, force: true });
});
it('extracts headings from a summary file', async () => {
it('returns structured fields from SUMMARY frontmatter (CJS parity)', async () => {
const rel = '.planning/phases/01-x/01-SUMMARY.md';
await writeFile(
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
'# Summary\n\n## What Was Done\n\nBuilt the thing.\n\n## Tests\n\nUnit tests pass.\n',
[
'---',
'phase: "01"',
'name: Test Phase',
'one-liner: From YAML',
'key-files:',
' - a.ts',
'key-decisions:',
' - "Choice: because reasons"',
'patterns-established:',
' - "Pattern one"',
'tech-stack:',
' added:',
' - vitest',
'requirements-completed:',
' - R1',
'---',
'',
'# Summary',
'',
'**Body one-liner ignored when FM has one-liner**',
'',
].join('\n'),
'utf-8',
);
const r = await summaryExtract([rel], tmpDir);
const data = r.data as Record<string, Record<string, string>>;
expect(data.sections.what_was_done).toContain('Built');
const data = r.data as Record<string, unknown>;
expect(data.path).toBe(rel);
expect(data.one_liner).toBe('From YAML');
expect(data.key_files).toEqual(['a.ts']);
expect(data.requirements_completed).toEqual(['R1']);
expect(Array.isArray(data.decisions)).toBe(true);
});
it('filters with --fields', async () => {
const rel = '.planning/phases/01-x/01-SUMMARY.md';
await writeFile(
join(tmpDir, '.planning', 'phases', '01-x', '01-SUMMARY.md'),
['---', 'phase: "01"', 'one-liner: X', 'key-files:', ' - z.ts', '---', ''].join('\n'),
'utf-8',
);
const r = await summaryExtract([rel, '--fields', 'path,one_liner'], tmpDir);
const data = r.data as Record<string, unknown>;
expect(Object.keys(data).sort()).toEqual(['one_liner', 'path'].sort());
expect(data.one_liner).toBe('X');
});
});
@@ -49,7 +88,8 @@ describe('historyDigest', () => {
it('returns digest object for project without phases', async () => {
const r = await historyDigest([], tmpDir);
const data = r.data as Record<string, unknown>;
expect(data.phases).toBeDefined();
expect(data.decisions).toBeDefined();
expect(data.phases).toEqual({});
expect(data.decisions).toEqual([]);
expect(data.tech_stack).toEqual([]);
});
});

View File

@@ -2,177 +2,295 @@
* Summary query handlers — extract sections and history from SUMMARY.md files.
*
* Ported from get-shit-done/bin/lib/commands.cjs (cmdSummaryExtract, cmdHistoryDigest).
* Provides summary section parsing and condensed phase history generation.
* Uses `extractFrontmatterLeading` for parity with `frontmatter.cjs` (first `---` block only).
*
* @example
* ```typescript
* import { summaryExtract, historyDigest } from './summary.js';
*
* await summaryExtract(['.planning/phases/09-foundation/09-01-SUMMARY.md'], '/project');
* // { data: { sections: { what_was_done: '...', tests: '...' }, file: '...' } }
*
* await summaryExtract(['path/to/SUMMARY.md'], '/project');
* await historyDigest([], '/project');
* // { data: { phases: [...], count: 5 } }
* ```
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { join } from 'node:path';
import { planningPaths, toPosixPath } from './helpers.js';
import { extractFrontmatterLeading } from './frontmatter.js';
import { comparePhaseNum, planningPaths, resolvePathUnderProject } from './helpers.js';
import type { QueryHandler } from './utils.js';
export const summaryExtract: QueryHandler = async (args, projectDir) => {
const filePath = args[0] ? join(projectDir, args[0]) : null;
// ─── extractOneLinerFromBody ────────────────────────────────────────────────
if (!filePath || !existsSync(filePath)) {
return { data: { sections: {}, error: 'file not found' } };
/**
* Extract a one-liner from the summary body when it is not in frontmatter.
* Port of `extractOneLinerFromBody` from `get-shit-done/bin/lib/core.cjs`.
*/
function extractOneLinerFromBody(content: string): string | null {
if (!content) return null;
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1].trim() : null;
}
/** Normalize frontmatter list fields — scalars become single-element arrays. */
function coerceFmArray(v: unknown): unknown[] {
if (v === undefined || v === null) return [];
if (Array.isArray(v)) return v;
return [v];
}
function parseDecisions(decisionsList: unknown): Array<{ summary: string; rationale: string | null }> {
if (!decisionsList || !Array.isArray(decisionsList)) return [];
return decisionsList.map((d: unknown) => {
const s = String(d);
const colonIdx = s.indexOf(':');
if (colonIdx > 0) {
return {
summary: s.substring(0, colonIdx).trim(),
rationale: s.substring(colonIdx + 1).trim(),
};
}
return { summary: s, rationale: null };
});
}
function readSubdirectories(dirPath: string, sort: boolean): string[] {
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
return sort ? dirs.sort((a, b) => comparePhaseNum(a, b)) : dirs;
} catch {
return [];
}
}
/** Match `getArchivedPhaseDirs` from core.cjs (newest milestone archive first). */
function getArchivedPhaseDirs(cwd: string): Array<{ name: string; fullPath: string; milestone: string }> {
const milestonesDir = join(cwd, '.planning', 'milestones');
const results: Array<{ name: string; fullPath: string; milestone: string }> = [];
if (!existsSync(milestonesDir)) return results;
try {
const content = await readFile(filePath, 'utf-8');
const sections: Record<string, string> = {};
const headingPattern = /^#{1,3}\s+(.+?)[\r\n]+([\s\S]*?)(?=^#{1,3}\s|\Z)/gm;
let m: RegExpExecArray | null;
while ((m = headingPattern.exec(content)) !== null) {
const key = m[1].trim().toLowerCase().replace(/\s+/g, '_');
sections[key] = m[2].trim();
const milestoneEntries = readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch ? versionMatch[1] : archiveName;
const archivePath = join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
fullPath: join(archivePath, dir),
});
}
}
return { data: { sections, file: args[0] } };
} catch {
return { data: { sections: {}, error: 'unreadable file' } };
/* intentionally empty */
}
return results;
}
export const summaryExtract: QueryHandler = async (args, projectDir) => {
const fieldsIdx = args.indexOf('--fields');
const pathArgs = fieldsIdx === -1 ? args : args.slice(0, fieldsIdx);
const summaryPath = pathArgs[0] ?? '';
if (!summaryPath) {
return { data: { error: 'summary-path required for summary-extract' } };
}
if (summaryPath.includes('\0')) {
return { data: { error: 'Invalid path', path: summaryPath } };
}
const fields =
fieldsIdx !== -1 && args[fieldsIdx + 1] ? args[fieldsIdx + 1].split(',').map(f => f.trim()) : null;
let fullPath: string;
try {
fullPath = await resolvePathUnderProject(projectDir, summaryPath);
} catch {
return { data: { error: 'File not found', path: summaryPath } };
}
if (!existsSync(fullPath)) {
return { data: { error: 'File not found', path: summaryPath } };
}
let content: string;
try {
content = await readFile(fullPath, 'utf-8');
} catch {
return { data: { error: 'File not found', path: summaryPath } };
}
const fm = extractFrontmatterLeading(content) as Record<string, unknown>;
const techStackRaw = fm['tech-stack'] as { added?: unknown[] } | undefined;
const techAdded = (techStackRaw && Array.isArray(techStackRaw.added) ? techStackRaw.added : []) as unknown[];
const fullResult: Record<string, unknown> = {
path: summaryPath,
one_liner: (fm['one-liner'] as string | undefined) || extractOneLinerFromBody(content) || null,
key_files: coerceFmArray(fm['key-files']),
tech_added: techAdded,
patterns: coerceFmArray(fm['patterns-established']),
decisions: parseDecisions(fm['key-decisions']),
requirements_completed: coerceFmArray(fm['requirements-completed']),
};
if (fields && fields.length > 0) {
const filtered: Record<string, unknown> = { path: summaryPath };
for (const field of fields) {
if (fullResult[field] !== undefined) {
filtered[field] = fullResult[field];
}
}
return { data: filtered };
}
return { data: fullResult };
};
export const historyDigest: QueryHandler = async (_args, projectDir) => {
const paths = planningPaths(projectDir);
const phasesDir = planningPaths(projectDir).phases;
const digest: {
phases: Record<string, { name: string; provides: string[]; affects: string[]; patterns: string[] }>;
phases: Record<
string,
{
name: string;
provides: Set<string>;
affects: Set<string>;
patterns: Set<string>;
}
>;
decisions: Array<{ phase: string; decision: string }>;
tech_stack: string[];
} = { phases: {}, decisions: [], tech_stack: [] };
tech_stack: Set<string>;
} = { phases: {}, decisions: [], tech_stack: new Set() };
const techStackSet = new Set<string>();
// Collect all phase directories: archived milestones + current
const allPhaseDirs: Array<{ name: string; fullPath: string }> = [];
// Archived phases from milestones/
const milestonesDir = join(projectDir, '.planning', 'milestones');
if (existsSync(milestonesDir)) {
const archived = getArchivedPhaseDirs(projectDir);
for (const a of archived) {
allPhaseDirs.push({ name: a.name, fullPath: a.fullPath });
}
if (existsSync(phasesDir)) {
try {
const milestoneEntries = readdirSync(milestonesDir, { withFileTypes: true });
const archivedPhaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
const currentDirs = readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
for (const archiveName of archivedPhaseDirs) {
const archivePath = join(milestonesDir, archiveName);
try {
const dirs = readdirSync(archivePath, { withFileTypes: true });
for (const d of dirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
allPhaseDirs.push({ name: d.name, fullPath: join(archivePath, d.name) });
}
} catch { /* skip */ }
.sort((a, b) => comparePhaseNum(a, b));
for (const dir of currentDirs) {
allPhaseDirs.push({ name: dir, fullPath: join(phasesDir, dir) });
}
} catch { /* skip */ }
}
// Current phases
if (existsSync(paths.phases)) {
try {
const currentDirs = readdirSync(paths.phases, { withFileTypes: true });
for (const d of currentDirs.filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
allPhaseDirs.push({ name: d.name, fullPath: join(paths.phases, d.name) });
}
} catch { /* skip */ }
}
if (allPhaseDirs.length === 0) {
return { data: digest };
}
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
const summaries = readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
for (const summary of summaries) {
try {
const content = readFileSync(join(dirPath, summary), 'utf-8');
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) continue;
const fmBlock = fmMatch[1];
const phaseMatch = fmBlock.match(/^phase:\s*(.+)$/m);
const nameMatch = fmBlock.match(/^name:\s*(.+)$/m);
const phaseNum = phaseMatch ? phaseMatch[1].trim() : dir.split('-')[0];
if (!digest.phases[phaseNum]) {
const phaseName = nameMatch
? nameMatch[1].trim()
: dir.split('-').slice(1).join(' ') || 'Unknown';
digest.phases[phaseNum] = { name: phaseName, provides: [], affects: [], patterns: [] };
}
const providesSet = new Set(digest.phases[phaseNum].provides);
const affectsSet = new Set(digest.phases[phaseNum].affects);
const patternsSet = new Set(digest.phases[phaseNum].patterns);
// Parse provides from dependency-graph or top-level
for (const m of fmBlock.matchAll(/^\s+-\s+(.+)$/gm)) {
const line = m[1].trim();
if (fmBlock.indexOf(m[0]) > fmBlock.indexOf('provides:') &&
(fmBlock.indexOf('affects:') === -1 || fmBlock.indexOf(m[0]) < fmBlock.indexOf('affects:'))) {
providesSet.add(line);
}
}
// Parse key-decisions
const decisionsStart = fmBlock.indexOf('key-decisions:');
if (decisionsStart !== -1) {
const rest = fmBlock.slice(decisionsStart + 'key-decisions:'.length);
for (const line of rest.split('\n')) {
const item = line.match(/^\s+-\s+(.+)$/);
if (item) {
digest.decisions.push({ phase: phaseNum, decision: item[1].trim() });
} else if (/^\S/.test(line) && line.trim()) {
break;
}
}
}
// Parse patterns-established
const patternsStart = fmBlock.indexOf('patterns-established:');
if (patternsStart !== -1) {
const rest = fmBlock.slice(patternsStart + 'patterns-established:'.length);
for (const line of rest.split('\n')) {
const item = line.match(/^\s+-\s+(.+)$/);
if (item) patternsSet.add(item[1].trim());
else if (/^\S/.test(line) && line.trim()) break;
}
}
// Parse tech-stack.added
const techStart = fmBlock.indexOf('tech-stack:');
if (techStart !== -1) {
const addedStart = fmBlock.indexOf('added:', techStart);
if (addedStart !== -1) {
const rest = fmBlock.slice(addedStart + 'added:'.length);
for (const line of rest.split('\n')) {
const item = line.match(/^\s+-\s+(?:name:\s*)?(.+)$/);
if (item) techStackSet.add(item[1].trim());
else if (/^\S/.test(line) && line.trim()) break;
}
}
}
digest.phases[phaseNum].provides = [...providesSet];
digest.phases[phaseNum].affects = [...affectsSet];
digest.phases[phaseNum].patterns = [...patternsSet];
} catch { /* skip malformed summaries */ }
} catch {
/* intentionally empty */
}
}
digest.tech_stack = [...techStackSet];
return { data: digest };
if (allPhaseDirs.length === 0) {
return { data: { phases: {}, decisions: [], tech_stack: [] } };
}
try {
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
const summaries = readdirSync(dirPath)
.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md')
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
for (const summary of summaries) {
try {
const content = readFileSync(join(dirPath, summary), 'utf-8');
const fm = extractFrontmatterLeading(content) as Record<string, unknown>;
const phaseRaw = fm.phase;
const phaseNum =
typeof phaseRaw === 'string' || typeof phaseRaw === 'number'
? String(phaseRaw)
: dir.split('-')[0];
if (!digest.phases[phaseNum]) {
digest.phases[phaseNum] = {
name:
(typeof fm.name === 'string' ? fm.name : null) ||
dir.split('-').slice(1).join(' ') ||
'Unknown',
provides: new Set(),
affects: new Set(),
patterns: new Set(),
};
}
const depGraph = fm['dependency-graph'] as
| { provides?: string[]; affects?: string[] }
| undefined;
if (depGraph && Array.isArray(depGraph.provides)) {
depGraph.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
} else if (Array.isArray(fm.provides)) {
(fm.provides as string[]).forEach(p => digest.phases[phaseNum].provides.add(p));
}
if (depGraph && Array.isArray(depGraph.affects)) {
depGraph.affects.forEach(a => digest.phases[phaseNum].affects.add(a));
}
if (Array.isArray(fm['patterns-established'])) {
(fm['patterns-established'] as string[]).forEach(p => digest.phases[phaseNum].patterns.add(p));
}
if (Array.isArray(fm['key-decisions'])) {
(fm['key-decisions'] as string[]).forEach(d => {
digest.decisions.push({ phase: phaseNum, decision: d });
});
}
const techStack = fm['tech-stack'] as { added?: unknown[] } | undefined;
if (techStack && Array.isArray(techStack.added)) {
techStack.added.forEach(t => {
const s = typeof t === 'string' ? t : (t as { name?: string }).name;
if (s) digest.tech_stack.add(s);
});
}
} catch {
/* Skip malformed summaries */
}
}
}
const phasesOut: Record<
string,
{ name: string; provides: string[]; affects: string[]; patterns: string[] }
> = {};
for (const p of Object.keys(digest.phases)) {
phasesOut[p] = {
name: digest.phases[p].name,
provides: [...digest.phases[p].provides],
affects: [...digest.phases[p].affects],
patterns: [...digest.phases[p].patterns],
};
}
return {
data: {
phases: phasesOut,
decisions: digest.decisions,
tech_stack: [...digest.tech_stack],
},
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { data: { error: `Failed to generate history digest: ${msg}` } };
}
};

Some files were not shown because too many files have changed in this diff Show More