chore: auto-commit before switching to gsd/M002/S01

This commit is contained in:
Lex Christopherson 2026-03-12 14:34:22 -06:00
parent 488d0fd4fb
commit 1c68dc2906
14 changed files with 701 additions and 2 deletions

View file

@ -1,6 +1,7 @@
# GSD State
**Active Milestone:** M002 — Proactive Secret Management
<<<<<<< HEAD
**Active Slice:** —
**Active Task:** —
**Phase:** pre-planning
@ -21,3 +22,21 @@
## Blockers
- (none)
=======
**Active Slice:** S02 — Enhanced Collection UX
**Phase:** planning
**Requirements Status:** 10 active · 0 validated · 2 deferred · 2 out of scope
## Milestone Registry
- ✅ **M001:** M001: Deterministic GitService
- 🔄 **M002:** Proactive Secret Management
## Recent Decisions
- None recorded
## Blockers
- None
## Next Action
Plan slice S02 (Enhanced Collection UX).
>>>>>>> gsd/M002/S01

View file

@ -0,0 +1,74 @@
# S01: Secret Forecasting & Manifest
**Goal:** Establish the secrets manifest format, types, parser/writer, and planning prompt instructions so that milestone planning produces a parseable `M00x-SECRETS.md` file with predicted API keys and step-by-step guidance.
**Demo:** Running `plan-milestone` on a project involving external APIs produces a `.gsd/milestones/M00x/M00x-SECRETS.md` manifest file. The manifest parser handles well-formed, edge-case, and empty-secrets output. The template loads with the new `secretsOutputPath` variable. Build and all tests pass.
## Must-Haves
- `SecretsManifestEntry` and `SecretsManifest` interfaces in `types.ts` with key, service, dashboardUrl, guidance (string[]), formatHint, status (`pending`/`collected`/`skipped`), and destination fields
- `parseSecretsManifest()` in `files.ts` — forgiving regex-based parser using existing `extractSection`/`extractBoldField`/`parseBullets` helpers
- `formatSecretsManifest()` in `files.ts` — round-trip writer producing the canonical manifest markdown
- `templates/secrets-manifest.md` template defining the manifest format
- Parser tests covering: full manifest, single-key manifest, no-secrets manifest, missing optional fields, status values
- `plan-milestone.md` and `guided-plan-milestone.md` updated with secret forecasting instructions
- `buildPlanMilestonePrompt()` in `auto.ts` passes `secretsOutputPath` template variable
- `npm run build` passes
- `npm run test` passes (no new failures)
## Proof Level
- This slice proves: contract
- Real runtime required: no (parser/writer tested with fixture data; prompt instructions verified by template loading)
- Human/UAT required: no (prompt compliance tested in S04 end-to-end)
## Verification
- `npm run test` — all existing tests plus new manifest parser tests pass
- `npm run build` — TypeScript compilation succeeds with new types/functions
- Manifest parser tests in `src/resources/extensions/gsd/tests/parsers.test.ts` covering:
- Full manifest with multiple keys parses correctly
- Single-key manifest parses correctly
- No-secrets / empty manifest returns zero entries
- Missing optional fields (dashboardUrl, formatHint) default gracefully
- Status field parses all three values (`pending`/`collected`/`skipped`)
- Round-trip: `formatSecretsManifest(parseSecretsManifest(content))` preserves semantic content
## Observability / Diagnostics
- Runtime signals: none — this is a parser/formatter contract, no runtime behavior
- Inspection surfaces: parser tests exercise all code paths; `parseSecretsManifest` returns typed objects that are directly inspectable
- Failure visibility: parser returns empty arrays for unparseable sections rather than throwing, matching `parseRoadmap` convention; malformed entries are silently skipped
- Redaction constraints: none — manifest contains key names and guidance, not actual secret values
## Integration Closure
- Upstream surfaces consumed: `extractSection`, `extractAllSections`, `extractBoldField`, `parseBullets` from `files.ts`; `resolveMilestoneFile` from `paths.ts`; `loadPrompt` from `prompt-loader.ts`
- New wiring introduced in this slice: `buildPlanMilestonePrompt()` passes `secretsOutputPath` to the prompt template; prompt templates instruct LLM to write the manifest file
- What remains before the milestone is truly usable end-to-end: S02 (enhanced collection UX with guidance display), S03 (auto-mode dispatches collect-secrets phase), S04 (end-to-end verification with real LLM planning)
## Tasks
- [x] **T01: Manifest types, parser/writer, template, and tests** `est:45m`
- Why: Establishes the contract that S02 and S03 depend on — types, parsing, formatting, and the template file. Tests prove the parser handles LLM output variations correctly.
- Files: `src/resources/extensions/gsd/types.ts`, `src/resources/extensions/gsd/files.ts`, `src/resources/extensions/gsd/templates/secrets-manifest.md`, `src/resources/extensions/gsd/tests/parsers.test.ts`
- Do: Add `SecretsManifestEntry` and `SecretsManifest` interfaces to `types.ts`. Implement `parseSecretsManifest()` and `formatSecretsManifest()` in `files.ts` using existing helpers. Create `templates/secrets-manifest.md`. Add comprehensive parser tests to `parsers.test.ts`. Parser must be forgiving — regex-based, tolerant of whitespace and missing optional fields.
- Verify: `npm run build` passes, `npm run test` passes with new manifest parser tests
- Done when: `parseSecretsManifest` correctly parses full, single-key, empty, and edge-case manifests; `formatSecretsManifest` produces valid markdown; round-trip preserves data; build succeeds
- [x] **T02: Planning prompt modifications and auto.ts wiring** `est:30m`
- Why: Without prompt instructions, the LLM won't forecast secrets during planning. Without the template variable, the prompt can't tell the LLM where to write the manifest. This delivers R001 and R009.
- Files: `src/resources/extensions/gsd/prompts/plan-milestone.md`, `src/resources/extensions/gsd/prompts/guided-plan-milestone.md`, `src/resources/extensions/gsd/auto.ts`, `src/resources/extensions/gsd/guided-flow.ts`
- Do: Append secret forecasting instructions section to both prompt templates with `{{secretsOutputPath}}` variable. Update `buildPlanMilestonePrompt()` to compute and pass `secretsOutputPath`. Update guided flow's `loadPrompt` call to also pass `secretsOutputPath`. Instructions must be clearly separated from roadmap instructions, conditional on external APIs ("skip if no external services"), and reference the template format.
- Verify: `npm run build` passes, `npm run test` passes, `loadPrompt("plan-milestone", {...})` succeeds with the new variable
- Done when: Both prompt templates contain forecasting instructions, `buildPlanMilestonePrompt` and guided flow pass `secretsOutputPath`, build and all tests pass
## Files Likely Touched
- `src/resources/extensions/gsd/types.ts`
- `src/resources/extensions/gsd/files.ts`
- `src/resources/extensions/gsd/templates/secrets-manifest.md`
- `src/resources/extensions/gsd/tests/parsers.test.ts`
- `src/resources/extensions/gsd/prompts/plan-milestone.md`
- `src/resources/extensions/gsd/prompts/guided-plan-milestone.md`
- `src/resources/extensions/gsd/auto.ts`
- `src/resources/extensions/gsd/guided-flow.ts`

View file

@ -0,0 +1,62 @@
---
estimated_steps: 5
estimated_files: 4
---
# T01: Manifest types, parser/writer, template, and tests
**Slice:** S01 — Secret Forecasting & Manifest
**Milestone:** M002
## Description
Establish the complete secrets manifest contract: TypeScript interfaces for manifest entries and the manifest itself, a forgiving regex-based parser, a canonical markdown writer, a template file defining the format, and comprehensive tests proving the parser handles LLM output variations. This is the foundation that S02 (collection UX) and S03 (auto-mode integration) depend on.
## Steps
1. **Add types to `types.ts`** — Define `SecretsManifestEntryStatus` literal union (`'pending' | 'collected' | 'skipped'`), `SecretsManifestEntry` interface (key, service, dashboardUrl, guidance: string[], formatHint, status, destination), and `SecretsManifest` interface (milestone, generatedAt, entries: SecretsManifestEntry[]). Place after the existing `Roadmap`/`SlicePlan` types section.
2. **Create `templates/secrets-manifest.md`** — Define the canonical manifest format with H1 title, bold metadata fields (Milestone, Generated), and H3 sections per key containing bold fields (Service, Dashboard, Format hint, Status, Destination) and a numbered Guidance list. Include comments explaining the format for both LLM and human readers.
3. **Implement `parseSecretsManifest()` in `files.ts`** — Use `extractAllSections(content, 3)` to find per-key sections. For each H3 section: extract the env var name from the heading (e.g. `### OPENAI_API_KEY`), use `extractBoldField` for service/dashboardUrl/formatHint/status/destination, and extract the Guidance subsection's numbered list. Default missing optional fields gracefully (empty string for dashboardUrl/formatHint, `'pending'` for status, `'dotenv'` for destination). Extract milestone and generatedAt from the top-level bold fields.
4. **Implement `formatSecretsManifest()` in `files.ts`** — Write the manifest back to canonical markdown format matching the template. H1 with "Secrets Manifest", bold Milestone and Generated fields, then H3 sections per entry with all fields and numbered guidance steps.
5. **Add parser tests to `parsers.test.ts`** — Following the existing `assert`/`assertEq` pattern: (a) full manifest with 3 keys — verify all fields parse, (b) single-key manifest — verify it works, (c) empty/no-secrets manifest with no H3 sections — returns empty entries array, (d) missing optional fields (no Dashboard, no Format hint) — defaults correctly, (e) all three status values parse, (f) round-trip test — `formatSecretsManifest(parseSecretsManifest(content))` then re-parse and compare fields.
## Must-Haves
- [ ] `SecretsManifestEntry` and `SecretsManifest` interfaces exported from `types.ts`
- [ ] `parseSecretsManifest()` exported from `files.ts` — forgiving, regex-based, uses existing helpers
- [ ] `formatSecretsManifest()` exported from `files.ts` — produces canonical markdown
- [ ] `templates/secrets-manifest.md` exists with the canonical format
- [ ] Parser handles missing optional fields without throwing
- [ ] Parser returns empty entries array for manifests with no keys
- [ ] All parser tests pass
- [ ] `npm run build` passes
## Verification
- `npm run build` — TypeScript compiles with new types and functions
- `npm run test` — all tests pass, including new manifest parser tests
- New test output shows: full manifest parse, single-key, empty, missing fields, status values, round-trip
## Observability Impact
- Signals added/changed: None — pure data contract, no runtime behavior
- How a future agent inspects this: Read `types.ts` for interfaces, read `templates/secrets-manifest.md` for format, run parser tests for validation
- Failure state exposed: Parser returns empty/default values for unparseable sections rather than throwing — consistent with `parseRoadmap` convention
## Inputs
- `src/resources/extensions/gsd/types.ts` — existing type definitions to extend
- `src/resources/extensions/gsd/files.ts` — existing parsing helpers to reuse (`extractSection`, `extractAllSections`, `extractBoldField`, `parseBullets`)
- `src/resources/extensions/gsd/tests/parsers.test.ts` — existing test file pattern to follow
- `src/resources/extensions/get-secrets-from-user.ts` — reference for key schema (`{ key, hint, required }`) for forward-compatibility
## Expected Output
- `src/resources/extensions/gsd/types.ts` — extended with `SecretsManifestEntryStatus`, `SecretsManifestEntry`, `SecretsManifest`
- `src/resources/extensions/gsd/files.ts` — extended with `parseSecretsManifest()` and `formatSecretsManifest()`
- `src/resources/extensions/gsd/templates/secrets-manifest.md` — new template file
- `src/resources/extensions/gsd/tests/parsers.test.ts` — extended with manifest parser test suite

View file

@ -0,0 +1,60 @@
---
id: T01
parent: S01
milestone: M002
provides:
- SecretsManifestEntry and SecretsManifest types
- parseSecretsManifest() parser
- formatSecretsManifest() writer
- secrets-manifest.md template
key_files:
- src/resources/extensions/gsd/types.ts
- src/resources/extensions/gsd/files.ts
- src/resources/extensions/gsd/templates/secrets-manifest.md
- src/resources/extensions/gsd/tests/parsers.test.ts
key_decisions:
- Numbered list extraction via regex rather than reusing parseBullets (which strips numbers)
patterns_established:
- Secrets manifest uses H3 headings per env var key with bold metadata fields
- Parser defaults missing optional fields (dashboardUrl, formatHint → empty string; status → pending; destination → dotenv)
- Invalid status values silently default to pending
observability_surfaces:
- Parser tests exercise all code paths — 312 assertions across 7 manifest test groups
duration: 15min
verification_result: passed
completed_at: 2026-03-12T19:40:00Z
blocker_discovered: false
---
# T01: Manifest types, parser/writer, template, and tests
**Added SecretsManifest types, forgiving parser, canonical formatter, template file, and 7 test groups (312 assertions) to the GSD extension.**
## What Happened
Added `SecretsManifestEntryStatus`, `SecretsManifestEntry`, and `SecretsManifest` interfaces to `types.ts`. Implemented `parseSecretsManifest()` using existing `extractAllSections(content, 3)`, `extractBoldField`, and regex-based numbered list extraction. Implemented `formatSecretsManifest()` that produces canonical markdown with conditional Dashboard/Format hint fields. Created `templates/secrets-manifest.md` with the canonical format and inline comments. Added 7 test groups to `parsers.test.ts`: full 3-key manifest, single-key, empty/no-secrets, missing optional fields, all three status values, invalid status defaulting, and round-trip parse→format→re-parse.
## Verification
- `npm run build` — passes clean
- `npm run test` — parsers.test.ts: 312 passed, 0 failed. All 7 manifest test groups pass.
- Pre-existing 2 failures in `app-smoke.test.ts` (AGENTS.md sync) are unrelated
## Diagnostics
Parser tests are the inspection surface. Run `npm run test` and look for `parseSecretsManifest` test groups. Parser returns empty arrays for unparseable sections and defaults missing fields rather than throwing.
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `src/resources/extensions/gsd/types.ts` — added SecretsManifestEntryStatus, SecretsManifestEntry, SecretsManifest types
- `src/resources/extensions/gsd/files.ts` — added parseSecretsManifest() and formatSecretsManifest() with imports
- `src/resources/extensions/gsd/templates/secrets-manifest.md` — new canonical manifest template
- `src/resources/extensions/gsd/tests/parsers.test.ts` — added 7 manifest parser/formatter test groups

View file

@ -0,0 +1,66 @@
---
estimated_steps: 5
estimated_files: 4
---
# T02: Planning prompt modifications and auto.ts wiring
**Slice:** S01 — Secret Forecasting & Manifest
**Milestone:** M002
## Description
Modify the milestone planning prompts to instruct the LLM to forecast which external API keys a milestone will need and write a secrets manifest file. Wire the `secretsOutputPath` template variable through `buildPlanMilestonePrompt()` so the prompt can tell the LLM where to write the manifest. This delivers R001 (secret forecasting) and R009 (planning prompts instruct forecasting).
## Steps
1. **Append secret forecasting section to `plan-milestone.md`** — Add a new section after the existing Planning Doctrine section (before the final `When done` line). The section should: instruct the LLM to analyze each slice and its boundary map for external service dependencies; tell it to write a `{{secretsOutputPath}}` file using the format from `~/.gsd/agent/extensions/gsd/templates/secrets-manifest.md`; include clear skip instruction ("If this milestone does not require any external API keys or secrets, skip this step entirely — do not create an empty manifest"); reference the template for format guidance; and emphasize that guidance should include dashboard URL, numbered navigation steps, and format hints.
2. **Append equivalent instructions to `guided-plan-milestone.md`** — Same forecasting instructions adapted for the guided flow format (single paragraph, no inlined context). Include the `{{secretsOutputPath}}` variable. Keep the instruction self-contained.
3. **Update `buildPlanMilestonePrompt()` in `auto.ts`** — Compute `secretsOutputPath` using `relMilestoneFile(base, mid, "SECRETS")`. The file won't exist yet (it's being created by the LLM during planning), so use the relative path format `milestoneId + "-SECRETS.md"` resolved to the milestone directory. Add `secretsOutputPath` to the vars object passed to `loadPrompt("plan-milestone", {...})`.
4. **Update guided flow in `guided-flow.ts`** — The guided flow's `loadPrompt("guided-plan-milestone", {...})` call at ~line 612 currently passes only `milestoneId` and `milestoneTitle`. Add `secretsOutputPath` to this vars object, computed the same way as in auto.ts. This ensures the guided `/gsd` wizard also tells the LLM to write the manifest.
5. **Verify full build and test suite** — Run `npm run build` to confirm TypeScript compiles with all changes. Run `npm run test` to confirm no regressions. Manually verify `loadPrompt("plan-milestone", {...})` won't throw by confirming all `{{vars}}` in the template have matching keys in the vars object.
## Must-Haves
- [ ] `plan-milestone.md` contains secret forecasting instructions with `{{secretsOutputPath}}` variable
- [ ] `guided-plan-milestone.md` contains equivalent secret forecasting instructions with `{{secretsOutputPath}}`
- [ ] `buildPlanMilestonePrompt()` computes and passes `secretsOutputPath` to `loadPrompt`
- [ ] Instructions clearly say to skip manifest creation when no external APIs are needed
- [ ] Instructions reference the `secrets-manifest.md` template
- [ ] `guided-flow.ts` passes `secretsOutputPath` to the guided plan-milestone prompt
- [ ] `npm run build` passes
- [ ] `npm run test` passes (no new failures)
## Verification
- `npm run build` — TypeScript compiles cleanly
- `npm run test` — all existing + new parser tests pass
- Grep `plan-milestone.md` for `{{secretsOutputPath}}` — present
- Grep `guided-plan-milestone.md` for `{{secretsOutputPath}}` — present
- Grep `auto.ts` `buildPlanMilestonePrompt` for `secretsOutputPath` — present in vars object
## Observability Impact
- Signals added/changed: None — prompt template changes have no runtime signals
- How a future agent inspects this: Read the prompt templates to see forecasting instructions; read `auto.ts` to see the variable wiring
- Failure state exposed: `loadPrompt` throws with a clear error message if `{{secretsOutputPath}}` is declared in the template but not provided in vars — this is the existing prompt-loader safeguard
## Inputs
- `src/resources/extensions/gsd/prompts/plan-milestone.md` — existing prompt to extend
- `src/resources/extensions/gsd/prompts/guided-plan-milestone.md` — existing guided prompt to extend
- `src/resources/extensions/gsd/auto.ts``buildPlanMilestonePrompt()` at ~line 1347
- `src/resources/extensions/gsd/guided-flow.ts` — guided flow `loadPrompt` call at ~line 612
- `src/resources/extensions/gsd/templates/secrets-manifest.md` — template created in T01 (referenced by prompt instructions)
- T01 output: types and parser are in place, template exists
## Expected Output
- `src/resources/extensions/gsd/prompts/plan-milestone.md` — extended with secret forecasting section
- `src/resources/extensions/gsd/prompts/guided-plan-milestone.md` — extended with secret forecasting instructions
- `src/resources/extensions/gsd/auto.ts``buildPlanMilestonePrompt()` passes `secretsOutputPath` variable
- `src/resources/extensions/gsd/guided-flow.ts` — guided flow passes `secretsOutputPath` to prompt

View file

@ -21,6 +21,7 @@ function run(cmd, options = {}) {
})
})
}
<<<<<<< HEAD
// ---------------------------------------------------------------------------
// Redirect stdout → stderr so npm always shows postinstall output.
@ -57,19 +58,46 @@ const banner =
let p, pc
=======
// ---------------------------------------------------------------------------
// Redirect stdout → stderr so npm always shows postinstall output.
// npm ≥7 suppresses stdout from lifecycle scripts by default; stderr is
// always forwarded. Clack writes to process.stdout, so we reroute it.
// ---------------------------------------------------------------------------
process.stdout.write = process.stderr.write.bind(process.stderr)
// ---------------------------------------------------------------------------
// Main — wrapped in async IIFE, with graceful fallback if clack fails
// ---------------------------------------------------------------------------
;(async () => {
let p, pc
>>>>>>> gsd/M002/S01
try {
p = await import('@clack/prompts')
pc = (await import('picocolors')).default
} catch {
// Clack or picocolors unavailable — fall back to minimal output
<<<<<<< HEAD
process.stderr.write(` Run gsd to get started.\n\n`)
await run('npx patch-package')
await run('npx playwright install chromium')
=======
process.stderr.write(`\n GSD v${pkg.version} installed.\n Run gsd to get started.\n\n`)
await run('npx patch-package')
const args = os.platform() === 'linux' ? '--with-deps' : ''
await run(`npx playwright install chromium ${args}`)
>>>>>>> gsd/M002/S01
return
}
// --- Branded intro -------------------------------------------------------
<<<<<<< HEAD
p.intro('Setup')
=======
p.intro(pc.bgCyan(pc.black(' gsd ')) + ' ' + pc.dim(`v${pkg.version}`))
>>>>>>> gsd/M002/S01
const results = []
const s = p.spinner()
@ -89,14 +117,21 @@ const banner =
}
// --- Step 2: Playwright browser ------------------------------------------
<<<<<<< HEAD
// Avoid --with-deps: install scripts should not block on interactive sudo
// prompts. If Linux libs are missing, suggest the explicit follow-up.
s.start('Setting up browser tools…')
const pwResult = await run('npx playwright install chromium')
=======
s.start('Setting up browser tools…')
const pwArgs = os.platform() === 'linux' ? ' --with-deps' : ''
const pwResult = await run(`npx playwright install chromium${pwArgs}`)
>>>>>>> gsd/M002/S01
if (pwResult.ok) {
s.stop('Browser tools ready')
results.push({ label: 'Browser tools ready', ok: true })
} else {
<<<<<<< HEAD
const output = `${pwResult.stdout ?? ''}${pwResult.stderr ?? ''}`
if (os.platform() === 'linux' && output.includes('Host system is missing dependencies to run browsers.')) {
s.stop(pc.yellow('Browser downloaded, missing Linux deps'))
@ -111,6 +146,13 @@ const banner =
ok: false,
})
}
=======
s.stop(pc.yellow('Browser tools — skipped (non-fatal)'))
results.push({
label: 'Browser tools unavailable — run ' + pc.cyan('npx playwright install chromium'),
ok: false,
})
>>>>>>> gsd/M002/S01
}
// --- Summary note --------------------------------------------------------

View file

@ -1634,6 +1634,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
return loadPrompt("plan-milestone", {
milestoneId: mid, milestoneTitle: midTitle,
milestonePath: relMilestonePath(base, mid),
@ -1641,6 +1642,7 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
researchPath: researchRel,
outputPath: outputRelPath,
outputAbsPath,
secretsOutputPath,
inlinedContext,
});
}

View file

@ -13,6 +13,7 @@ import type {
Summary, SummaryFrontmatter, SummaryRequires, FileModified,
Continue, ContinueFrontmatter, ContinueStatus,
RequirementCounts,
SecretsManifest, SecretsManifestEntry, SecretsManifestEntryStatus,
} from './types.ts';
// ─── Helpers ───────────────────────────────────────────────────────────────
@ -263,6 +264,75 @@ export function parseRoadmap(content: string): Roadmap {
return { title, vision, successCriteria, slices, boundaryMap };
}
// ─── Secrets Manifest Parser ───────────────────────────────────────────────
const VALID_STATUSES = new Set<SecretsManifestEntryStatus>(['pending', 'collected', 'skipped']);
export function parseSecretsManifest(content: string): SecretsManifest {
const milestone = extractBoldField(content, 'Milestone') || '';
const generatedAt = extractBoldField(content, 'Generated') || '';
const h3Sections = extractAllSections(content, 3);
const entries: SecretsManifestEntry[] = [];
for (const [heading, sectionContent] of h3Sections) {
const key = heading.trim();
if (!key) continue;
const service = extractBoldField(sectionContent, 'Service') || '';
const dashboardUrl = extractBoldField(sectionContent, 'Dashboard') || '';
const formatHint = extractBoldField(sectionContent, 'Format hint') || '';
const rawStatus = (extractBoldField(sectionContent, 'Status') || 'pending').toLowerCase().trim() as SecretsManifestEntryStatus;
const status: SecretsManifestEntryStatus = VALID_STATUSES.has(rawStatus) ? rawStatus : 'pending';
const destination = extractBoldField(sectionContent, 'Destination') || 'dotenv';
// Extract numbered guidance list (lines matching "1. ...", "2. ...", etc.)
const guidance: string[] = [];
for (const line of sectionContent.split('\n')) {
const numMatch = line.match(/^\s*\d+\.\s+(.+)/);
if (numMatch) {
guidance.push(numMatch[1].trim());
}
}
entries.push({ key, service, dashboardUrl, guidance, formatHint, status, destination });
}
return { milestone, generatedAt, entries };
}
// ─── Secrets Manifest Formatter ───────────────────────────────────────────
export function formatSecretsManifest(manifest: SecretsManifest): string {
const lines: string[] = [];
lines.push('# Secrets Manifest');
lines.push('');
lines.push(`**Milestone:** ${manifest.milestone}`);
lines.push(`**Generated:** ${manifest.generatedAt}`);
for (const entry of manifest.entries) {
lines.push('');
lines.push(`### ${entry.key}`);
lines.push('');
lines.push(`**Service:** ${entry.service}`);
if (entry.dashboardUrl) {
lines.push(`**Dashboard:** ${entry.dashboardUrl}`);
}
if (entry.formatHint) {
lines.push(`**Format hint:** ${entry.formatHint}`);
}
lines.push(`**Status:** ${entry.status}`);
lines.push(`**Destination:** ${entry.destination}`);
lines.push('');
for (let i = 0; i < entry.guidance.length; i++) {
lines.push(`${i + 1}. ${entry.guidance[i]}`);
}
}
return lines.join('\n') + '\n';
}
// ─── Slice Plan Parser ─────────────────────────────────────────────────────
export function parsePlan(content: string): SlicePlan {

View file

@ -611,8 +611,9 @@ export async function showSmartEntry(
});
if (choice === "plan") {
const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
milestoneId, milestoneTitle,
milestoneId, milestoneTitle, secretsOutputPath,
}));
} else if (choice === "discuss") {
dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {

View file

@ -21,3 +21,7 @@ Plan milestone {{milestoneId}} ("{{milestoneTitle}}"). Read `.gsd/DECISIONS.md`
- **Don't invent risks.** If the project is straightforward, skip the proof strategy and just ship value in smart order. Not everything has major unknowns.
- **Ship features, not proofs.** A completed slice should leave the product in a state where the new capability is actually usable through its real interface. A login flow slice ends with a working login page, not a middleware function. An API slice ends with endpoints that return real data from a real store, not hardcoded fixtures. A dashboard slice ends with a real dashboard rendering real data, not a component that renders mock props. If a slice can't ship the real thing yet because a dependency isn't built, it should ship with realistic stubs that are clearly marked for replacement — but the user-facing surface must be real.
- **Ambition matches the milestone.** The number and depth of slices should match the milestone's ambition. A milestone promising "core platform with auth, data model, and primary user loop" should have enough slices to actually deliver all three as working features — not two proof-of-concept slices and a note that "the rest will come in the next milestone." If the milestone's context promises an outcome, the roadmap must deliver it.
## Secret Forecasting
After writing the roadmap, analyze the slices and their boundary maps for external service dependencies (third-party APIs, SaaS platforms, cloud providers, databases requiring credentials, OAuth providers, etc.). If this milestone requires any external API keys or secrets, read the template at `~/.gsd/agent/extensions/gsd/templates/secrets-manifest.md` for the expected format and write `{{secretsOutputPath}}` listing every predicted secret as an H3 section with the Service name, a direct Dashboard URL to the console page where the key is created, a Format hint showing what the key looks like, Status set to `pending`, and Destination (`dotenv`, `vercel`, or `convex`). Include numbered step-by-step guidance for obtaining each key. If this milestone does not require any external API keys or secrets, skip this step entirely — do not create an empty manifest.

View file

@ -59,6 +59,23 @@ If the roadmap has only one slice, also write the slice plan and task plans inli
This eliminates a separate research-slice + plan-slice cycle when the work is straightforward.
## Secret Forecasting
After writing the roadmap, analyze the slices and their boundary maps for external service dependencies (third-party APIs, SaaS platforms, cloud providers, databases requiring credentials, OAuth providers, etc.).
If this milestone requires any external API keys or secrets:
1. Read the template at `~/.gsd/agent/extensions/gsd/templates/secrets-manifest.md` for the expected format
2. Write `{{secretsOutputPath}}` listing every predicted secret as an H3 section with:
- **Service** — the external service name
- **Dashboard** — direct URL to the console/dashboard page where the key is created (not a generic homepage)
- **Format hint** — what the key looks like (e.g. `sk-...`, `ghp_...`, 40-char hex, UUID)
- **Status** — always `pending` during planning
- **Destination**`dotenv`, `vercel`, or `convex` depending on where the key will be consumed
- Numbered step-by-step guidance for obtaining the key (navigate to dashboard → create project → generate key → copy)
If this milestone does not require any external API keys or secrets, skip this step entirely — do not create an empty manifest.
**You MUST write the file `{{outputAbsPath}}` before finishing.**
When done, say: "Milestone {{milestoneId}} planned."

View file

@ -0,0 +1,22 @@
# Secrets Manifest
<!-- This file lists predicted API keys and secrets for the milestone.
Each H3 section defines one secret with setup guidance.
The parser extracts entries by H3 heading (the env var name).
Bold fields: Service, Dashboard, Format hint, Status, Destination.
Guidance is a numbered list under each entry. -->
**Milestone:** {{milestone}}
**Generated:** {{generatedAt}}
### {{ENV_VAR_NAME}}
**Service:** {{serviceName}}
**Dashboard:** {{dashboardUrl}}
**Format hint:** {{formatHint}}
**Status:** pending
**Destination:** dotenv
1. {{Step 1 guidance}}
2. {{Step 2 guidance}}
3. {{Step 3 guidance}}

View file

@ -1,4 +1,4 @@
import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts } from '../files.ts';
import { parseRoadmap, parsePlan, parseSummary, parseContinue, parseRequirementCounts, parseSecretsManifest, formatSecretsManifest } from '../files.ts';
let passed = 0;
let failed = 0;
@ -1249,6 +1249,7 @@ console.log('\n=== parseRequirementCounts: total is sum of all section counts ==
}
// ═══════════════════════════════════════════════════════════════════════════
<<<<<<< HEAD
// parseSummary: bare scalar frontmatter fields (regression test for #91)
// ═══════════════════════════════════════════════════════════════════════════
@ -1340,6 +1341,245 @@ Nothing.
assertEq(s.frontmatter.patterns_established.length, 0, 'missing patterns_established = empty array');
assertEq(s.frontmatter.drill_down_paths.length, 0, 'missing drill_down_paths = empty array');
assertEq(s.frontmatter.observability_surfaces.length, 0, 'missing observability_surfaces = empty array');
=======
// parseSecretsManifest / formatSecretsManifest tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== parseSecretsManifest: full manifest with 3 keys ===');
{
const content = `# Secrets Manifest
**Milestone:** M003
**Generated:** 2025-06-15T10:00:00Z
### OPENAI_API_KEY
**Service:** OpenAI
**Dashboard:** https://platform.openai.com/api-keys
**Format hint:** starts with sk-
**Status:** pending
**Destination:** dotenv
1. Go to https://platform.openai.com/api-keys
2. Click "Create new secret key"
3. Copy the key immediately it won't be shown again
### STRIPE_SECRET_KEY
**Service:** Stripe
**Dashboard:** https://dashboard.stripe.com/apikeys
**Format hint:** starts with sk_test_ or sk_live_
**Status:** collected
**Destination:** dotenv
1. Go to https://dashboard.stripe.com/apikeys
2. Reveal the secret key
3. Copy it
### SUPABASE_URL
**Service:** Supabase
**Dashboard:** https://app.supabase.com/project/settings/api
**Format hint:** https://<project-ref>.supabase.co
**Status:** skipped
**Destination:** vercel
1. Go to project settings in Supabase
2. Copy the URL from the API section
`;
const m = parseSecretsManifest(content);
assertEq(m.milestone, 'M003', 'manifest milestone');
assertEq(m.generatedAt, '2025-06-15T10:00:00Z', 'manifest generatedAt');
assertEq(m.entries.length, 3, 'three entries');
// First entry
assertEq(m.entries[0].key, 'OPENAI_API_KEY', 'entry 0 key');
assertEq(m.entries[0].service, 'OpenAI', 'entry 0 service');
assertEq(m.entries[0].dashboardUrl, 'https://platform.openai.com/api-keys', 'entry 0 dashboardUrl');
assertEq(m.entries[0].formatHint, 'starts with sk-', 'entry 0 formatHint');
assertEq(m.entries[0].status, 'pending', 'entry 0 status');
assertEq(m.entries[0].destination, 'dotenv', 'entry 0 destination');
assertEq(m.entries[0].guidance.length, 3, 'entry 0 guidance count');
assertEq(m.entries[0].guidance[0], 'Go to https://platform.openai.com/api-keys', 'entry 0 guidance[0]');
assertEq(m.entries[0].guidance[2], 'Copy the key immediately — it won\'t be shown again', 'entry 0 guidance[2]');
// Second entry
assertEq(m.entries[1].key, 'STRIPE_SECRET_KEY', 'entry 1 key');
assertEq(m.entries[1].service, 'Stripe', 'entry 1 service');
assertEq(m.entries[1].status, 'collected', 'entry 1 status');
assertEq(m.entries[1].formatHint, 'starts with sk_test_ or sk_live_', 'entry 1 formatHint');
assertEq(m.entries[1].guidance.length, 3, 'entry 1 guidance count');
// Third entry
assertEq(m.entries[2].key, 'SUPABASE_URL', 'entry 2 key');
assertEq(m.entries[2].status, 'skipped', 'entry 2 status');
assertEq(m.entries[2].destination, 'vercel', 'entry 2 destination');
assertEq(m.entries[2].guidance.length, 2, 'entry 2 guidance count');
}
console.log('\n=== parseSecretsManifest: single-key manifest ===');
{
const content = `# Secrets Manifest
**Milestone:** M001
**Generated:** 2025-06-15T12:00:00Z
### DATABASE_URL
**Service:** PostgreSQL
**Dashboard:** https://console.neon.tech
**Format hint:** postgresql://...
**Status:** pending
**Destination:** dotenv
1. Create a database on Neon
2. Copy the connection string
`;
const m = parseSecretsManifest(content);
assertEq(m.milestone, 'M001', 'single-key milestone');
assertEq(m.entries.length, 1, 'single entry');
assertEq(m.entries[0].key, 'DATABASE_URL', 'single entry key');
assertEq(m.entries[0].service, 'PostgreSQL', 'single entry service');
assertEq(m.entries[0].guidance.length, 2, 'single entry guidance count');
}
console.log('\n=== parseSecretsManifest: empty/no-secrets manifest ===');
{
const content = `# Secrets Manifest
**Milestone:** M002
**Generated:** 2025-06-15T14:00:00Z
`;
const m = parseSecretsManifest(content);
assertEq(m.milestone, 'M002', 'empty manifest milestone');
assertEq(m.generatedAt, '2025-06-15T14:00:00Z', 'empty manifest generatedAt');
assertEq(m.entries.length, 0, 'no entries in empty manifest');
}
console.log('\n=== parseSecretsManifest: missing optional fields default correctly ===');
{
const content = `# Secrets Manifest
**Milestone:** M004
**Generated:** 2025-06-15T16:00:00Z
### SOME_API_KEY
**Service:** SomeService
1. Get the key from the dashboard
`;
const m = parseSecretsManifest(content);
assertEq(m.entries.length, 1, 'one entry with missing fields');
assertEq(m.entries[0].key, 'SOME_API_KEY', 'key parsed');
assertEq(m.entries[0].service, 'SomeService', 'service parsed');
assertEq(m.entries[0].dashboardUrl, '', 'missing dashboardUrl defaults to empty string');
assertEq(m.entries[0].formatHint, '', 'missing formatHint defaults to empty string');
assertEq(m.entries[0].status, 'pending', 'missing status defaults to pending');
assertEq(m.entries[0].destination, 'dotenv', 'missing destination defaults to dotenv');
assertEq(m.entries[0].guidance.length, 1, 'guidance still parsed');
}
console.log('\n=== parseSecretsManifest: all three status values parse ===');
{
for (const status of ['pending', 'collected', 'skipped'] as const) {
const content = `# Secrets Manifest
**Milestone:** M005
**Generated:** 2025-06-15T18:00:00Z
### TEST_KEY
**Service:** TestService
**Status:** ${status}
1. Do something
`;
const m = parseSecretsManifest(content);
assertEq(m.entries[0].status, status, `status variant: ${status}`);
}
}
console.log('\n=== parseSecretsManifest: invalid status defaults to pending ===');
{
const content = `# Secrets Manifest
**Milestone:** M006
**Generated:** 2025-06-15T20:00:00Z
### BAD_STATUS_KEY
**Service:** TestService
**Status:** invalid_value
1. Some step
`;
const m = parseSecretsManifest(content);
assertEq(m.entries[0].status, 'pending', 'invalid status defaults to pending');
}
console.log('\n=== parseSecretsManifest + formatSecretsManifest: round-trip ===');
{
const original = `# Secrets Manifest
**Milestone:** M007
**Generated:** 2025-06-16T10:00:00Z
### OPENAI_API_KEY
**Service:** OpenAI
**Dashboard:** https://platform.openai.com/api-keys
**Format hint:** starts with sk-
**Status:** pending
**Destination:** dotenv
1. Go to the API keys page
2. Create a new key
3. Copy it
### REDIS_URL
**Service:** Upstash
**Dashboard:** https://console.upstash.com
**Format hint:** redis://...
**Status:** collected
**Destination:** vercel
1. Open Upstash console
2. Copy the Redis URL
`;
const parsed1 = parseSecretsManifest(original);
const formatted = formatSecretsManifest(parsed1);
const parsed2 = parseSecretsManifest(formatted);
// Verify semantic equality after round-trip
assertEq(parsed2.milestone, parsed1.milestone, 'round-trip milestone');
assertEq(parsed2.generatedAt, parsed1.generatedAt, 'round-trip generatedAt');
assertEq(parsed2.entries.length, parsed1.entries.length, 'round-trip entry count');
for (let i = 0; i < parsed1.entries.length; i++) {
const e1 = parsed1.entries[i];
const e2 = parsed2.entries[i];
assertEq(e2.key, e1.key, `round-trip entry ${i} key`);
assertEq(e2.service, e1.service, `round-trip entry ${i} service`);
assertEq(e2.dashboardUrl, e1.dashboardUrl, `round-trip entry ${i} dashboardUrl`);
assertEq(e2.formatHint, e1.formatHint, `round-trip entry ${i} formatHint`);
assertEq(e2.status, e1.status, `round-trip entry ${i} status`);
assertEq(e2.destination, e1.destination, `round-trip entry ${i} destination`);
assertEq(e2.guidance.length, e1.guidance.length, `round-trip entry ${i} guidance length`);
for (let j = 0; j < e1.guidance.length; j++) {
assertEq(e2.guidance[j], e1.guidance[j], `round-trip entry ${i} guidance[${j}]`);
}
}
>>>>>>> gsd/M002/S01
}
// ═══════════════════════════════════════════════════════════════════════════

View file

@ -116,6 +116,26 @@ export interface Continue {
nextAction: string;
}
// ─── Secrets Manifest ──────────────────────────────────────────────────────
export type SecretsManifestEntryStatus = 'pending' | 'collected' | 'skipped';
export interface SecretsManifestEntry {
key: string; // e.g. "OPENAI_API_KEY"
service: string; // e.g. "OpenAI"
dashboardUrl: string; // e.g. "https://platform.openai.com/api-keys" — empty if unknown
guidance: string[]; // numbered setup steps
formatHint: string; // e.g. "starts with sk-" — empty if unknown
status: SecretsManifestEntryStatus;
destination: string; // e.g. "dotenv", "vercel", "convex"
}
export interface SecretsManifest {
milestone: string; // e.g. "M001"
generatedAt: string; // ISO 8601 timestamp
entries: SecretsManifestEntry[];
}
// ─── GSD State (Derived Dashboard) ────────────────────────────────────────
export interface ActiveRef {