* 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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
237
sdk/HANDOVER-GOLDEN-PARITY.md
Normal file
237
sdk/HANDOVER-GOLDEN-PARITY.md
Normal 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.*
|
||||
97
sdk/HANDOVER-PARITY-DOCS.md
Normal file
97
sdk/HANDOVER-PARITY-DOCS.md
Normal 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
170
sdk/HANDOVER-QUERY-LAYER.md
Normal 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
53
sdk/README.md
Normal 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) |
|
||||
@@ -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:**
|
||||
|
||||
59
sdk/scripts/gen-profile-questionnaire-data.mjs
Normal file
59
sdk/scripts/gen-profile-questionnaire-data.mjs
Normal 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);
|
||||
122
sdk/src/cli.ts
122
sdk/src/cli.ts
@@ -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}`);
|
||||
|
||||
@@ -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
95
sdk/src/golden/capture.ts
Normal 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;
|
||||
}
|
||||
1
sdk/src/golden/fixtures/generate-slug.golden.json
Normal file
1
sdk/src/golden/fixtures/generate-slug.golden.json
Normal file
@@ -0,0 +1 @@
|
||||
{"slug":"my-phase"}
|
||||
@@ -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"}
|
||||
26
sdk/src/golden/fixtures/summary-extract-sample.md
Normal file
26
sdk/src/golden/fixtures/summary-extract-sample.md
Normal 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.
|
||||
15
sdk/src/golden/fixtures/uat-render-checkpoint-sample.md
Normal file
15
sdk/src/golden/fixtures/uat-render-checkpoint-sample.md
Normal 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.
|
||||
30
sdk/src/golden/golden-integration-covered.ts
Normal file
30
sdk/src/golden/golden-integration-covered.ts
Normal 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));
|
||||
7
sdk/src/golden/golden-mutation-covered.ts
Normal file
7
sdk/src/golden/golden-mutation-covered.ts
Normal 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[] = [];
|
||||
8
sdk/src/golden/golden-policy.test.ts
Normal file
8
sdk/src/golden/golden-policy.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
112
sdk/src/golden/golden-policy.ts
Normal file
112
sdk/src/golden/golden-policy.ts
Normal 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')}`);
|
||||
}
|
||||
}
|
||||
373
sdk/src/golden/golden.integration.test.ts
Normal file
373
sdk/src/golden/golden.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
15
sdk/src/golden/init-golden-normalize.ts
Normal file
15
sdk/src/golden/init-golden-normalize.ts
Normal 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;
|
||||
}
|
||||
77
sdk/src/golden/read-only-golden-rows.ts
Normal file
77
sdk/src/golden/read-only-golden-rows.ts
Normal 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;
|
||||
}
|
||||
125
sdk/src/golden/read-only-parity.integration.test.ts
Normal file
125
sdk/src/golden/read-only-parity.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
sdk/src/golden/registry-canonical-commands.ts
Normal file
31
sdk/src/golden/registry-canonical-commands.ts
Normal 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));
|
||||
}
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
722
sdk/src/query/audit-open.ts
Normal 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),
|
||||
},
|
||||
};
|
||||
};
|
||||
77
sdk/src/query/check-auto-mode.test.ts
Normal file
77
sdk/src/query/check-auto-mode.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
50
sdk/src/query/check-auto-mode.ts
Normal file
50
sdk/src/query/check-auto-mode.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
113
sdk/src/query/check-completion.test.ts
Normal file
113
sdk/src/query/check-completion.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
182
sdk/src/query/check-completion.ts
Normal file
182
sdk/src/query/check-completion.ts
Normal 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 };
|
||||
};
|
||||
103
sdk/src/query/check-gates.test.ts
Normal file
103
sdk/src/query/check-gates.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
112
sdk/src/query/check-gates.ts
Normal file
112
sdk/src/query/check-gates.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
77
sdk/src/query/check-ship-ready.test.ts
Normal file
77
sdk/src/query/check-ship-ready.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
103
sdk/src/query/check-ship-ready.ts
Normal file
103
sdk/src/query/check-ship-ready.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
143
sdk/src/query/check-verification-status.test.ts
Normal file
143
sdk/src/query/check-verification-status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
160
sdk/src/query/check-verification-status.ts
Normal file
160
sdk/src/query/check-verification-status.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
89
sdk/src/query/config-gates.test.ts
Normal file
89
sdk/src/query/config-gates.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
69
sdk/src/query/config-gates.ts
Normal file
69
sdk/src/query/config-gates.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
97
sdk/src/query/detect-custom-files.ts
Normal file
97
sdk/src/query/detect-custom-files.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
105
sdk/src/query/detect-phase-type.test.ts
Normal file
105
sdk/src/query/detect-phase-type.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
141
sdk/src/query/detect-phase-type.ts
Normal file
141
sdk/src/query/detect-phase-type.ts
Normal 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
257
sdk/src/query/docs-init.ts
Normal 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 };
|
||||
};
|
||||
14
sdk/src/query/frontmatter-array.test.ts
Normal file
14
sdk/src/query/frontmatter-array.test.ts
Normal 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' }
|
||||
]
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
50
sdk/src/query/normalize-query-command.test.ts
Normal file
50
sdk/src/query/normalize-query-command.test.ts
Normal 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', '[]'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
88
sdk/src/query/phase-list-queries.test.ts
Normal file
88
sdk/src/query/phase-list-queries.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
sdk/src/query/phase-list-queries.ts
Normal file
152
sdk/src/query/phase-list-queries.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
65
sdk/src/query/phase-ready.test.ts
Normal file
65
sdk/src/query/phase-ready.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
158
sdk/src/query/phase-ready.ts
Normal file
158
sdk/src/query/phase-ready.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
65
sdk/src/query/plan-task-structure.test.ts
Normal file
65
sdk/src/query/plan-task-structure.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
63
sdk/src/query/plan-task-structure.ts
Normal file
63
sdk/src/query/plan-task-structure.ts
Normal 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,
|
||||
})),
|
||||
},
|
||||
};
|
||||
};
|
||||
247
sdk/src/query/profile-extract-messages.ts
Normal file
247
sdk/src/query/profile-extract-messages.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
908
sdk/src/query/profile-output.ts
Normal file
908
sdk/src/query/profile-output.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
181
sdk/src/query/profile-questionnaire-data.ts
Normal file
181
sdk/src/query/profile-questionnaire-data.ts
Normal 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}.`;
|
||||
}
|
||||
184
sdk/src/query/profile-sample.ts
Normal file
184
sdk/src/query/profile-sample.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
174
sdk/src/query/profile-scan-sessions.ts
Normal file
174
sdk/src/query/profile-scan-sessions.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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>();
|
||||
|
||||
58
sdk/src/query/requirements-extract-from-plans.test.ts
Normal file
58
sdk/src/query/requirements-extract-from-plans.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
86
sdk/src/query/requirements-extract-from-plans.ts
Normal file
86
sdk/src/query/requirements-extract-from-plans.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
132
sdk/src/query/roadmap-update-plan-progress.ts
Normal file
132
sdk/src/query/roadmap-update-plan-progress.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
61
sdk/src/query/route-next-action.test.ts
Normal file
61
sdk/src/query/route-next-action.test.ts
Normal 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 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
345
sdk/src/query/route-next-action.ts
Normal file
345
sdk/src/query/route-next-action.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
189
sdk/src/query/schema-detect.ts
Normal file
189
sdk/src/query/schema-detect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
214
sdk/src/query/skill-manifest.ts
Normal file
214
sdk/src/query/skill-manifest.ts
Normal 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 };
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 }> = [];
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user