W3 T3/T4/E2: design-doc discovery runs bin/gstack-design-doc-find on the state root; drop DESIGN_DOC_HELD rows

This commit is contained in:
garrytan committed 2026-10-03 20:44:55 +00:00
1 parent 769ed52eb1
commit dfb7330a8c
9 files changed
+183 -43

No files matched your search

+1 -1
View File
@@ -224,7 +224,7 @@ Re-run: copy RESTORE_PATH's bytes to SOURCE_PLAN, then /autoplan.
### Step 2: Read context
- Read CLAUDE.md, TODOS.md, git log -30, git diff against the base branch --stat
- Discover design docs: `ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1`
- Discover design docs: `~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH"` (prints the doc path, or nothing)
- Detect UI scope: grep the plan for view/rendering terms (component, screen, form,
button, modal, layout, dashboard, sidebar, nav, dialog). Require 2+ matches. Exclude
false positives ("page" alone, "UI" in acronyms).
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# gstack-design-doc-find — print the design doc a plan review should read.
#
# Usage: gstack-design-doc-find <slug> <branch>
#
# Prints one absolute path, or nothing when no design doc exists. Exit 0 either
# way; exit 2 on a usage error.
#
# Precedence:
# 1. newest $GSTACK_STATE_ROOT/projects/<slug>/*-<branch>-design-*.md
# 2. else newest $GSTACK_STATE_ROOT/projects/<slug>/*-design-*.md
# 3. a repo doc ($TOP/DESIGN.md, else newest $TOP/docs/designs/*.md) wins when
# no state-root doc exists or it is newer than the state-root doc.
# office-hours writes both copies; the committed copy is what teammates
# see, but a stale repo doc never shadows a newer private session.
set -u
if [ "$#" -ne 2 ] || [ -z "$1" ]; then
echo "usage: gstack-design-doc-find <slug> <branch>" >&2
exit 2
fi
SLUG=$1
BRANCH=$2
eval "$("$(dirname "$0")/gstack-paths")"; : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}"
newest() {
ls -t "$@" 2>/dev/null | head -1
}
PROJECT_DIR="$GSTACK_STATE_ROOT/projects/$SLUG"
LOCALDOC=$(newest "$PROJECT_DIR"/*-"$BRANCH"-design-*.md)
[ -z "$LOCALDOC" ] && LOCALDOC=$(newest "$PROJECT_DIR"/*-design-*.md)
REPODOC=""
TOP=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$TOP" ]; then
if [ -f "$TOP/DESIGN.md" ]; then
REPODOC="$TOP/DESIGN.md"
else
REPODOC=$(newest "$TOP"/docs/designs/*.md)
fi
fi
DESIGN="$LOCALDOC"
if [ -n "$REPODOC" ] && { [ -z "$LOCALDOC" ] || [ "$REPODOC" -nt "$LOCALDOC" ]; }; then
DESIGN="$REPODOC"
fi
[ -n "$DESIGN" ] && printf '%s\n' "$DESIGN"
exit 0
+12 -33
View File
@@ -1,47 +1,26 @@
/**
* {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block (#703).
* {{DESIGN_DOC_DISCOVERY}} — the canonical design-doc discovery block.
*
* Finds the design doc a plan review should read: newest branch-scoped doc
* under ~/.gstack/projects/<slug>/, falling back to newest project-scoped
* doc, then lets a repo-local doc (DESIGN.md or docs/designs/*.md) win when
* it is at least as fresh. office-hours dual-writes docs/designs/ alongside
* ~/.gstack, and the committed copy is what teammates see — but a stale old
* repo doc must never shadow a newer private session.
*
* Single source of truth for the freshness-preference logic that previously
* lived verbatim in plan-ceo-review, plan-eng-review, plan-devex-review, and
* the prerequisite-skill re-check in review.ts (GStack 2 fork-port wave,
* time-attack/gstack). Drift between copies meant plan reviews could
* disagree about which design doc wins.
* One invocation of bin/gstack-design-doc-find, which owns the precedence
* (newest branch-scoped doc in the state root's project directory, then the
* newest project-scoped doc, then a repo-local DESIGN.md or docs/designs/*.md
* when it is at least as fresh) and resolves the state root through
* bin/gstack-paths. Plan reviews, autoplan and the prerequisite-skill re-check
* in spec-review.ts all render this one block, so they agree on which doc wins.
*
* The fragment carries no code fences — the {{DESIGN_DOC_DISCOVERY}} token
* sits inside each caller's ```bash block. Callers must set $SLUG and
* $BRANCH first (and `setopt +o nomatch` for zsh); the block sets $DESIGN
* and prints "Design doc found: ..." or "No design doc found".
* $BRANCH first; the block sets $DESIGN and prints "Design doc found: ..." or
* "No design doc found".
*/
import type { TemplateContext } from './types';
/**
* Raw canonical fragment, exported so TS resolvers (review.ts's prerequisite
* re-check) can interpolate it into their own template strings instead of
* embedding a drifting copy.
* Raw canonical fragment, exported so TS resolvers (spec-review.ts's
* prerequisite re-check) can interpolate it into their own template strings.
*/
export const DESIGN_DOC_DISCOVERY_BLOCK = `_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md 2>/dev/null | head -1)
[ -z "$_LOCALDOC" ] && _LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md 2>/dev/null | head -1)
# Repo-local docs win when at least as fresh (#703): office-hours dual-writes
# docs/designs/ alongside ~/.gstack, and the committed copy is what teammates
# see. A stale old repo doc never shadows a newer private session.
_REPOTOP=$(git rev-parse --show-toplevel 2>/dev/null || echo "")
_REPODOC=""
if [ -n "$_REPOTOP" ]; then
[ -f "$_REPOTOP/DESIGN.md" ] && _REPODOC="$_REPOTOP/DESIGN.md"
[ -z "$_REPODOC" ] && _REPODOC=$(ls -t "$_REPOTOP"/docs/designs/*.md 2>/dev/null | head -1)
fi
DESIGN="$_LOCALDOC"
if [ -n "$_REPODOC" ] && { [ -z "$_LOCALDOC" ] || [ "$_REPODOC" -nt "$_LOCALDOC" ]; }; then
DESIGN="$_REPODOC"
fi
export const DESIGN_DOC_DISCOVERY_BLOCK = `DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find "$SLUG" "$BRANCH")
[ -n "$DESIGN" ] && echo "Design doc found: $DESIGN" || echo "No design doc found"`;
export function generateDesignDocDiscovery(_ctx: TemplateContext): string {
@@ -23,7 +23,7 @@ function fixture(plan = original) {
const env = { PATH: process.env.PATH!, HOME: home, GSTACK_HOME: state, GSTACK_STATE_ROOT: state };
const start = () => execFileSync(join(root, 'bin/gstack-skill-start'), ['--skill', 'autoplan'],
{ cwd, env, encoding: 'utf8', timeout: 30_000 });
const discover = () => execFileSync('bash', ['-c', DESIGN_DOC_DISCOVERY_BLOCK],
const discover = () => execFileSync('bash', ['-c', DESIGN_DOC_DISCOVERY_BLOCK.replaceAll('~/.claude/skills/gstack/bin/', `${root}/bin/`)],
{ cwd, env: { ...env, SLUG: 'chain-fixture', BRANCH: 'main' }, encoding: 'utf8', timeout: 5000 }).trim();
return { cwd, home, state, planFile, start, discover, cleanup: () => rmSync(temp, { recursive: true, force: true }) };
}
+2 -1
View File
@@ -301,7 +301,8 @@ describe('CEO finding fixture establishes scope before launch', () => {
const plan = '# Export saved settings\nReview the CSV formatter before implementation.\n';
const design = '# Settings export design\n\n## Problem\nOperators need saved settings in a spreadsheet for offline comparison.\n\n## Approach\nReuse the settings API and escape commas, quotes, and newlines in a CSV formatter.\n';
seedCeoFindingProject(cwd, plan, design);
const output = execFileSync('bash', ['-c', `SLUG=fixture; BRANCH=main; ${DESIGN_DOC_DISCOVERY_BLOCK}`], {
const block = DESIGN_DOC_DISCOVERY_BLOCK.replaceAll('~/.claude/skills/gstack/bin/', `${path.resolve(import.meta.dir, '..')}/bin/`);
const output = execFileSync('bash', ['-c', `SLUG=fixture; BRANCH=main; ${block}`], {
cwd, env: { PATH: process.env.PATH!, HOME: home }, encoding: 'utf8', timeout: 10_000,
});
expect(output).toBe(`Design doc found: ${path.join(cwd, 'DESIGN.md')}\n`);
+4
View File
@@ -1927,6 +1927,10 @@ describe('BENEFITS_FROM resolver', () => {
fs.chmodSync(helper, 0o755);
fs.copyFileSync(path.join(ROOT, 'bin/gstack-state-root.sh'), path.join(path.dirname(helper), 'gstack-state-root.sh'));
fs.copyFileSync(path.join(ROOT, 'bin/gstack-remote-identity.sh'), path.join(path.dirname(helper), 'gstack-remote-identity.sh'));
for (const bin of ['gstack-paths', 'gstack-design-doc-find']) {
fs.copyFileSync(path.join(ROOT, 'bin', bin), path.join(path.dirname(helper), bin));
fs.chmodSync(path.join(path.dirname(helper), bin), 0o755);
}
const expected = path.join(home, '.gstack/projects/canonical-override/session-unknown-design-current.md');
const wrong = path.join(home, '.gstack/projects/project/session-unknown-design-wrong.md');
for (const file of [expected, wrong]) {
+111
View File
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { execFileSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin/gstack-design-doc-find');
const temps: string[] = [];
afterEach(() => {
for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
function fixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'design-doc-find-'));
temps.push(dir);
const home = path.join(dir, 'home');
const repo = path.join(dir, 'repo');
fs.mkdirSync(home, { recursive: true });
fs.mkdirSync(repo, { recursive: true });
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo, timeout: 10_000 });
const baseEnv = { PATH: process.env.PATH!, HOME: home, GIT_CEILING_DIRECTORIES: dir };
const write = (file: string, ageSeconds: number) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, '# Design\n');
const t = Date.now() / 1000 - ageSeconds;
fs.utimesSync(file, t, t);
return file;
};
const find = (env: Record<string, string> = {}, args = ['proj', 'feat-x']) =>
execFileSync(BIN, args, { cwd: repo, env: { ...baseEnv, ...env }, encoding: 'utf8', timeout: 10_000 }).trim();
return { dir, home, repo, write, find };
}
describe('gstack-design-doc-find', () => {
test('default root is ~/.gstack and prints nothing when no doc exists', () => {
const f = fixture();
expect(f.find()).toBe('');
const doc = f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-20260101.md'), 100);
expect(f.find()).toBe(doc);
});
test('GSTACK_HOME moves the state root', () => {
const f = fixture();
f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-a.md'), 10);
const custom = path.join(f.dir, 'custom-state');
const doc = f.write(path.join(custom, 'projects/proj/user-main-design-b.md'), 100);
expect(f.find({ GSTACK_HOME: custom })).toBe(doc);
});
test('a branch-specific doc wins over a newer generic doc', () => {
const f = fixture();
const branch = f.write(path.join(f.home, '.gstack/projects/proj/user-feat-x-design-1.md'), 500);
f.write(path.join(f.home, '.gstack/projects/proj/user-main-design-2.md'), 10);
expect(f.find()).toBe(branch);
expect(f.find({}, ['proj', 'other'])).toBe(path.join(f.home, '.gstack/projects/proj/user-main-design-2.md'));
});
test('repo DESIGN.md wins only when at least as fresh, and beats docs/designs', () => {
const f = fixture();
const local = f.write(path.join(f.home, '.gstack/projects/proj/user-feat-x-design-1.md'), 100);
const designs = f.write(path.join(f.repo, 'docs/designs/plan.md'), 10);
expect(f.find()).toBe(designs);
const top = f.write(path.join(f.repo, 'DESIGN.md'), 1000);
expect(f.find()).toBe(local);
fs.utimesSync(top, Date.now() / 1000, Date.now() / 1000);
expect(f.find()).toBe(top);
});
test('a repo doc is used when the state root has none', () => {
const f = fixture();
const designs = f.write(path.join(f.repo, 'docs/designs/plan.md'), 1000);
expect(f.find()).toBe(designs);
});
test('usage errors exit 2', () => {
const f = fixture();
expect(() => f.find({}, [])).toThrow();
});
});
describe('rendered Design Doc Check block', () => {
const rendered = fs.readFileSync(path.join(ROOT, 'plan-ceo-review/SKILL.md'), 'utf8');
const block = rendered.slice(rendered.indexOf('DESIGN=$(~/.claude/skills/gstack/bin/gstack-design-doc-find'))
.split('\n').slice(0, 2).join('\n');
test('the generated skill renders one helper invocation', () => {
expect(block).toContain('gstack-design-doc-find "$SLUG" "$BRANCH"');
expect(block).toContain('echo "Design doc found: $DESIGN"');
expect(block).toContain('echo "No design doc found"');
});
test('finds an office-hours doc under a custom GSTACK_HOME and keeps the default-root result', () => {
const f = fixture();
const installed = path.join(f.home, '.claude/skills/gstack/bin');
fs.mkdirSync(installed, { recursive: true });
for (const bin of ['gstack-design-doc-find', 'gstack-paths', 'gstack-state-root.sh']) {
fs.copyFileSync(path.join(ROOT, 'bin', bin), path.join(installed, bin));
fs.chmodSync(path.join(installed, bin), 0o755);
}
const run = (env: Record<string, string>) => execFileSync('bash', ['-c', `SLUG=proj; BRANCH=main; ${block}`],
{ cwd: f.repo, env: { PATH: process.env.PATH!, HOME: f.home, GIT_CEILING_DIRECTORIES: f.dir, ...env }, encoding: 'utf8', timeout: 10_000 }).trim();
const custom = path.join(f.dir, 'custom-state');
const doc = f.write(path.join(custom, 'projects/proj/garry-main-design-20261003-120000.md'), 60);
expect(run({ GSTACK_HOME: custom })).toBe(`Design doc found: ${doc}`);
expect(run({})).toBe('No design doc found');
const legacy = f.write(path.join(f.home, '.gstack/projects/proj/garry-main-design-20261001-120000.md'), 60);
expect(run({})).toBe(`Design doc found: ${legacy}`);
});
});
+2 -2
View File
@@ -269,7 +269,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
// (--max-concurrency, no retries), so worst-case cost is one pass of
// each, sharing the API budget with sibling tests — not the
// sequential ~+10min a local read suggests.
'plan-mode-no-op': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'test/fixtures/auto-decide-recommendation-361c.json',
'plan-mode-no-op': ['bin/gstack-state-root.sh', 'lib/state-root.ts', 'bin/gstack-design-doc-find', 'scripts/resolvers/design-doc-discovery.ts', 'scripts/resolvers/spec-review.ts', 'plan-devex-review/**', 'test/fixtures/auto-decide-recommendation-361c.json',
'test/fixtures/auto-decide-target-361c.json',
@@ -838,7 +838,7 @@ export const E2E_TOUCHFILES: Record<string, string[]> = {
'test/fixtures/outside-async-task-m-events.json',
'test/fixtures/autoplan-amend-input-77.json',
'test/fixtures/autoplan-phase-handoff-6714.json','scripts/resolvers/learnings.ts', 'scripts/resolvers/preamble/generate-completion-status.ts', 'scripts/resolvers/preamble/generate-preamble-bash.ts', 'test/fixtures/plan-scope-recovery-av.json', 'test/fixtures/design-scope-checkpoint-at.json', 'scripts/resolvers/composition.ts', 'autoplan/**', 'codex/**', 'bin/gstack-codex-probe', 'scripts/resolvers/review-dashboard.ts', 'scripts/resolvers/plan-gates.ts', 'scripts/resolvers/spec-review.ts', 'scripts/resolvers/outside-voice-steps.ts', 'scripts/resolvers/review-scope.ts', 'scripts/resolvers/outside-voice.ts', 'scripts/resolvers/design.ts', 'test/skill-e2e-autoplan-dual-voice.test.ts', 'bin/gstack-autoplan-snapshot.ts', 'test/fixtures/autoplan/t-ceo-omitted-obligations.json', 'test/fixtures/autoplan/u-ceo-original-loss.json', 'test/fixtures/autoplan/v-ceo-dangling-references.json',
'scripts/resolvers/design-doc-discovery.ts', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'],
'scripts/resolvers/design-doc-discovery.ts', 'bin/gstack-design-doc-find', 'plan-ceo-review/**', 'plan-eng-review/**', 'plan-design-review/**', 'plan-devex-review/**', 'scripts/resolvers/testing.ts', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/tasks-section.ts', 'test/helpers/autoplan-phase-observer.ts', 'scripts/resolve-codex-generation-model.ts', 'scripts/resolvers/constants.ts'],
// Multi-provider benchmark adapters — live API smoke against real claude/codex/gemini CLIs
'benchmark-providers-live': ['bin/gstack-model-benchmark', 'test/helpers/providers/**', 'test/helpers/benchmark-runner.ts', 'test/helpers/pricing.ts', 'test/skill-e2e-benchmark-providers.test.ts'],
-5
View File
@@ -169,10 +169,8 @@ const RATCHET_ALLOWLIST = JSON.parse(read('test/state-root-ratchet.allowlist.jso
const readinessEvalReason = RATCHET_ALLOWLIST.find(entry => entry.path === 'land-and-deploy/sections/readiness-gate.md.tmpl')!.reason;
const DESCRIPTION = 'user-facing description of where the data lives; no command reads or writes this literal';
const SOURCE_COMMENT = 'source comment, not rendered into any skill';
const DESIGN_DOC_HELD = 'held at the literal path: converting design-doc discovery to $GSTACK_STATE_ROOT made plan-eng-review ask its scope gate in prose instead of AskUserQuestion (plan-mode no-op eval: 5 of 5 runs failed with it, 6 of 6 passed without); see TODOS.md';
const STATE_ROOT_ALLOWLIST: Array<{ file: string; match: string; reason: string }> = [
{ file: 'autoplan/SKILL.md.tmpl', match: 'ls -t ~/.gstack/projects/$SLUG/*-design-*.md', reason: DESIGN_DOC_HELD },
{ file: 'autoplan/SKILL.md.tmpl', match: 'test plan on disk at ~/.gstack/projects/$SLUG/', reason: DESCRIPTION },
{ file: 'careful/SKILL.md.tmpl', match: '`~/.gstack/projects/<slug>/careful-patterns.txt` (per-project)', reason: DESCRIPTION },
{ file: 'design-shotgun/SKILL.md.tmpl', match: 'v1 schema at `~/.gstack/projects/$SLUG/taste-profile.json`', reason: DESCRIPTION },
@@ -183,9 +181,6 @@ const STATE_ROOT_ALLOWLIST: Array<{ file: string; match: string; reason: string
{ file: 'plan-ceo-review/sections/review-sections.md.tmpl', match: 'Keep in `~/.gstack/projects/` only (local, personal reference)', reason: DESCRIPTION },
{ file: 'plan-tune/SKILL.md.tmpl', match: 'Logs stay local (`~/.gstack/projects/<slug>/question-log.jsonl`)', reason: DESCRIPTION },
{ file: 'plan-tune/SKILL.md.tmpl', match: '`~/.gstack/projects/<slug>/question-log.jsonl` — nothing leaves your', reason: DESCRIPTION },
{ file: 'scripts/resolvers/design-doc-discovery.ts', match: '* under ~/.gstack/projects/<slug>/, falling back', reason: SOURCE_COMMENT },
{ file: 'scripts/resolvers/design-doc-discovery.ts', match: '_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-$BRANCH-design-*.md', reason: DESIGN_DOC_HELD },
{ file: 'scripts/resolvers/design-doc-discovery.ts', match: '_LOCALDOC=$(ls -t ~/.gstack/projects/$SLUG/*-design-*.md', reason: DESIGN_DOC_HELD },
{ file: 'scripts/resolvers/learnings.ts', match: '* Learnings are stored per-project at ~/.gstack/projects/{slug}/learnings.jsonl', reason: SOURCE_COMMENT },
{ file: 'scripts/resolvers/preamble.ts', match: 'local JSONL append to ~/.gstack/analytics/ (inline, inspectable)', reason: SOURCE_COMMENT },
];