* feat(#2492): add gates ensuring discuss-phase decisions are translated and verified Two gates close the loop between CONTEXT.md `<decisions>` and downstream work, fixing #2492: - Plan-phase **translation gate** (BLOCKING). After requirements coverage, refuses to mark a phase planned when a trackable decision is not cited (by id `D-NN` or by 6+-word phrase) in any plan's `must_haves`, `truths`, or body. Failure message names each missed decision with id, category, text, and remediation paths. - Verify-phase **validation gate** (NON-BLOCKING). Searches plans, SUMMARY.md, files modified, and recent commit subjects for each trackable decision. Misses are written to VERIFICATION.md as a warning section but do not change verification status. Asymmetry is deliberate — fuzzy-match miss should not fail an otherwise green phase. Shared helper `parseDecisions()` lives in `sdk/src/query/decisions.ts` so #2493 can consume the same parser. Decisions opt out of both gates via `### Claude's Discretion` heading or `[informational]` / `[folded]` / `[deferred]` tags. Both gates skip silently when `workflow.context_coverage_gate=false` (default `true`). Closes #2492 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#2492): make plan-phase decision gate actually block (review F1, F8, F9, F10, F15) - F1: replace `${context_path}` with `${CONTEXT_PATH}` in the plan-phase gate snippet so the BLOCKING gate receives a non-empty path. The variable was defined in Step 4 (`CONTEXT_PATH=$(_gsd_field "$INIT" ...)`) and the gate snippet referenced the lowercase form, leaving the gate to run with an empty path argument and silently skip. - F15: wrap the SDK call with `jq -e '.data.passed == true' || exit 1` so failure halts the workflow instead of being printed and ignored. The verify-phase counterpart deliberately keeps no exit-1 (non-blocking by design) and now carries an inline note documenting the asymmetry. - F10: tag the JSON example fence as `json` and the options-list fence as `text` (MD040). - F8/F9: anchor the heading-presence test regexes to `^## 13[a-z]?\\.` so prose substrings like "Requirements Coverage Gate" mentioned in body text cannot satisfy the assertion. Added two new regression tests (variable-name match, exit-1 guard) so a future revert is caught. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#2492): tighten decision-coverage gates against false positives and config drift (review F3,F4,F5,F6,F7,F16,F18,F19) - F3: forward `workstream` arg through both gate handlers so workstream-scoped `workflow.context_coverage_gate=false` actually skips. Added negative test that creates a workstream config disabling the gate while the root config has it enabled and asserts the workstream call is skipped. - F4: restrict the plan-phase haystack to designated sections — front-matter `must_haves` / `truths` / `objective` plus body sections under headings matching `must_haves|truths|tasks|objective`. HTML comments and fenced code blocks are stripped before extraction so a commented-out citation or a literal example never counts as coverage. Verify-phase keeps the broader artifact-wide haystack by design (non-blocking). - F5: reject decisions with fewer than 6 normalized words from soft-matching (previously only rejected when the resulting phrase was under 12 chars AFTER slicing — too lenient). Short decisions now require an explicit `D-NN` citation, with regression tests for the boundary. - F6: walk every `*-SUMMARY.md` independently and use `matchAll` with the `/g` flag so multiple `files_modified:` blocks across multiple summaries are all aggregated. Previously only the first block in the concatenated string was parsed, silently dropping later plans' files. - F7: validate every `files_modified` path stays inside `projectDir` after resolution (rejects absolute paths, `../` traversal). Cap each file read at 256 KB. Skipped paths emit a stderr warning naming the entry. - F16: validate `workflow.context_coverage_gate` is boolean in `loadGateConfig`; warn loudly on numeric or other-shaped values and default to ON. Mirrors the schema-vs-loadConfig validation gap from #2609. - F18: bump verify-phase `git log -n` cap from 50 to 200 so longer-running phases are not undercounted. Documented as a precision-vs-recall tradeoff appropriate for a non-blocking gate. - F19: tighten `QueryResult` / `QueryHandler` to be parameterized (`<T = unknown>`). Drops the `as unknown as Record<string, unknown>` casts in the gate handlers and surfaces shape mismatches at compile time for callers that pass a typed `data` value. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#2492): harden decisions parser and verify-phase glob (review F11,F12,F13,F14,F17,F20) - F11: strip fenced code blocks from CONTEXT.md before searching for `<decisions>` so an example block inside ``` ``` is not mis-parsed. - F12: accept tab-indented continuation lines (previously required a leading space) so decisions split with `\t` continue cleanly. - F13: parse EVERY `<decisions>` block in the file via `matchAll`, not just the first. CONTEXT.md may legitimately carry more than one block. - F14: `decisions.parse` handler now resolves a relative path against `projectDir` — symmetric with the gate handlers — and still accepts absolute paths. - F17: replace `ls "${PHASE_DIR}"/*-CONTEXT.md | head -1` in verify-phase.md with a glob loop (ShellCheck SC2012 fix). Also avoids spawning an extra subprocess and survives filenames with whitespace. - F20: extend the unicode quote-stripping in the discretion-heading match to cover U+2018/2019/201A/201B and the U+201C-F double-quote variants plus backtick, so any rendering of "Claude's Discretion" collapses to the same key. Each fix has a regression test in `decisions.test.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
/**
|
|
* Utility query handlers — pure SDK implementations of simple commands.
|
|
*
|
|
* These handlers are direct TypeScript ports of gsd-tools.cjs functions:
|
|
* - `generateSlug` ← `cmdGenerateSlug` (commands.cjs lines 38-48)
|
|
* - `currentTimestamp` ← `cmdCurrentTimestamp` (commands.cjs lines 50-71)
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { generateSlug, currentTimestamp } from './utils.js';
|
|
*
|
|
* const slug = await generateSlug(['My Phase Name'], '/path/to/project');
|
|
* // { data: { slug: 'my-phase-name' } }
|
|
*
|
|
* const ts = await currentTimestamp(['date'], '/path/to/project');
|
|
* // { data: { timestamp: '2026-04-08' } }
|
|
* ```
|
|
*/
|
|
|
|
import { GSDError, ErrorClassification } from '../errors.js';
|
|
|
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Structured result returned by all query handlers. */
|
|
export interface QueryResult<T = unknown> {
|
|
data: T;
|
|
}
|
|
|
|
/** Signature for a query handler function. */
|
|
export type QueryHandler<T = unknown> = (
|
|
args: string[],
|
|
projectDir: string,
|
|
workstream?: string,
|
|
) => Promise<QueryResult<T>>;
|
|
|
|
// ─── generateSlug ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Converts text into a URL-safe kebab-case slug.
|
|
*
|
|
* Port of `cmdGenerateSlug` from `get-shit-done/bin/lib/commands.cjs`.
|
|
* Algorithm: lowercase, replace non-alphanumeric with hyphens,
|
|
* strip leading/trailing hyphens, truncate to 60 characters.
|
|
*
|
|
* @param args - `args[0]` is the text to slugify
|
|
* @param _projectDir - Unused (pure function)
|
|
* @returns Query result with `{ slug: string }`
|
|
* @throws GSDError with Validation classification if text is missing or empty
|
|
*/
|
|
export const generateSlug: QueryHandler = async (args, _projectDir) => {
|
|
const text = args[0];
|
|
if (!text) {
|
|
throw new GSDError('text required for slug generation', ErrorClassification.Validation);
|
|
}
|
|
|
|
const slug = text
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.substring(0, 60);
|
|
|
|
return { data: { slug } };
|
|
};
|
|
|
|
// ─── currentTimestamp ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Returns the current timestamp in the requested format.
|
|
*
|
|
* Port of `cmdCurrentTimestamp` from `get-shit-done/bin/lib/commands.cjs`.
|
|
* Formats: `'full'` (ISO 8601), `'date'` (YYYY-MM-DD), `'filename'` (colons replaced).
|
|
*
|
|
* @param args - `args[0]` is the format (`'full'` | `'date'` | `'filename'`), defaults to `'full'`
|
|
* @param _projectDir - Unused (pure function)
|
|
* @returns Query result with `{ timestamp: string }`
|
|
*/
|
|
export const currentTimestamp: QueryHandler = async (args, _projectDir) => {
|
|
const format = args[0] || 'full';
|
|
const now = new Date();
|
|
let result: string;
|
|
|
|
switch (format) {
|
|
case 'date':
|
|
result = now.toISOString().split('T')[0];
|
|
break;
|
|
case 'filename':
|
|
result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
|
|
break;
|
|
case 'full':
|
|
default:
|
|
result = now.toISOString();
|
|
break;
|
|
}
|
|
|
|
return { data: { timestamp: result } };
|
|
};
|