Files
msd-core/sdk/src/config.ts
Tom Boucher 25fb81d01e feat(3309): workflow.human_verify_mode = end-of-phase (new default; mid-flight opt-back-in) (#3325)
* test(3309): red — workflow.human_verify_mode contract

New behavioral test file covers:
- workflow.human_verify_mode is a recognized config key (VALID_CONFIG_KEYS)
- defaults to 'mid-flight' (preserves current behavior)
- config-set / config-get round-trips for both values
- persists in config.json as string
- planner agent file references the flag with canonical wording, couples
  end-of-phase mode with the rule that checkpoint:human-verify is not
  emitted, and documents the <verify><human-check> deferred-item shape
- verifier agent file references harvesting <verify><human-check> blocks
- references/checkpoints.md documents the cost-control alternative

Source-text assertions on agent .md files are exempted via
allow-test-rule: source-text-is-the-product — those files ARE the
runtime contract loaded by AI runtimes, so asserting their wording is
the only way to verify the agents will respect the flag.

Fails 10/11 against current source. Will pass after the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(3309): add workflow.human_verify_mode = end-of-phase opt-out

Each mid-flight checkpoint:human-verify halt costs a full executor
cold-start (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on every
respawn) because subagent context is discarded across the pause. A plan
with N human-verify checkpoints pays the cold-start cost N+1 times. The
reporter (rentanything-nb) measured this at "tens of thousands of tokens"
per round-trip and "hundreds of thousands per week."

This adds workflow.human_verify_mode (default 'mid-flight') with an
'end-of-phase' value that:
- instructs gsd-planner to NOT emit <task type="checkpoint:human-verify">
  tasks; verification details go into a <verify><human-check> sub-block
  on the relevant auto task instead
- instructs gsd-verifier (Step 8) to harvest those <verify><human-check>
  blocks at end-of-phase and merge them into its own human-verification
  list
- the existing human_needed → HUMAN-UAT.md flow in execute-phase.md is
  the single sink — no new file/writer is created

checkpoint:decision and checkpoint:human-action are unaffected — those
gate the work itself, not post-hoc verification.

Surfaces touched:
- bin/lib/config-schema.cjs, bin/lib/config.cjs — register key + default
- sdk/src/config.ts, sdk/src/query/config-schema.ts — SDK parity
- agents/gsd-planner.md — slim Detection section + reference link
- agents/gsd-verifier.md — Step 8 harvest instruction
- get-shit-done/references/planner-human-verify-mode.md — full rules,
  loaded conditionally to keep planner.md under its size budget
- get-shit-done/references/checkpoints.md — surface the alternative
- docs/CONFIGURATION.md — config table row
- docs/INVENTORY.md, docs/INVENTORY-MANIFEST.json — track new reference

Tag name <human-check> chosen instead of <human> to avoid the
prompt-injection scan pattern that flags <system|assistant|human> tags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(3309): align changeset pr: to actual PR number

The pr: field was authored as 3319 (a guess at the next number) before
the PR was opened. Actual PR is #3325.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(3309): flip workflow.human_verify_mode default to end-of-phase

Per maintainer direction on PR #3325, end-of-phase is the new project
default. Mid-flight checkpoint:human-verify halts cost a full executor
cold-start (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on respawn) per
round-trip — reported at "tens of thousands of tokens" per round-trip,
"hundreds of thousands per week" on real projects. The cost-control
mode is what new projects should get out of the box.

mid-flight remains a one-line opt-back-in via:

    gsd config-set workflow.human_verify_mode mid-flight

Behavior change for existing projects: the new default takes effect
when .planning/config.json is rewritten (config-set, fresh project).
Existing in-flight PLAN.md files with checkpoint:human-verify tasks
continue to work in either mode — the flag only changes what the
planner emits next time it runs.

Surfaces updated:
- bin/lib/config.cjs, sdk/src/config.ts — default flipped
- sdk/src/config.ts docstring — describes new default + opt-back-in
- agents/gsd-planner.md — Detection section explains new default
- references/planner-human-verify-mode.md — reordered modes; added
  guidance on when to opt back into mid-flight
- references/checkpoints.md — surface the default flip and the why
- docs/CONFIGURATION.md — table row reflects new default + reason
- tests/feat-3309-human-verify-mode.test.cjs — default test asserts
  end-of-phase
- .changeset/fierce-geese-march.md — describes the default flip and
  the migration semantics

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address human verify mode review

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 23:29:11 -04:00

219 lines
7.2 KiB
TypeScript

/**
* Config reader — loads `.planning/config.json` and merges with defaults.
*
* Mirrors the default structure from `get-shit-done/bin/lib/config.cjs`
* `buildNewProjectConfig()`.
*/
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { relPlanningPath } from './workstream-utils.js';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface GitConfig {
branching_strategy: string;
phase_branch_template: string;
milestone_branch_template: string;
quick_branch_template: string | null;
}
export interface WorkflowConfig {
research: boolean;
plan_check: boolean;
verifier: boolean;
nyquist_validation: boolean;
/** Mirrors gsd-tools flat `config.tdd_mode` (from `workflow.tdd_mode`). */
tdd_mode: boolean;
/**
* Issue #3309. `end-of-phase` (default) suppresses mid-flight
* `<task type="checkpoint:human-verify">` task emission; the planner
* embeds verification details into the relevant `auto` task's
* `<verify><human-check>` block and the verifier harvests them at
* end-of-phase into the existing HUMAN-UAT.md path. `mid-flight`
* restores the pre-#3309 behavior where the executor halts at each
* `checkpoint:human-verify` task and pays a full executor cold-start
* cost (CLAUDE.md, MEMORY.md, STATE.md, plan re-read on respawn) per
* round-trip.
*/
human_verify_mode: 'mid-flight' | 'end-of-phase';
auto_advance: boolean;
/** Internal auto-chain flag used by workflow routing. */
_auto_chain_active?: boolean;
node_repair: boolean;
node_repair_budget: number;
ui_phase: boolean;
ui_safety_gate: boolean;
text_mode: boolean;
research_before_questions: boolean;
discuss_mode: string;
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;
/**
* Issue #2492. When true (default), enforces that every trackable decision in
* CONTEXT.md `<decisions>` is referenced by at least one plan (translation
* gate, blocking) and reports decisions not honored by shipped artifacts at
* verify-phase (validation gate, non-blocking). Set false to disable both.
*/
context_coverage_gate: boolean;
}
export interface HooksConfig {
context_warnings: boolean;
}
export interface GSDConfig {
model_profile: string;
commit_docs: boolean;
parallelization: boolean;
search_gitignored: boolean;
brave_search: boolean;
firecrawl: boolean;
exa_search: boolean;
git: GitConfig;
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;
[key: string]: unknown;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
export const CONFIG_DEFAULTS: GSDConfig = {
model_profile: 'balanced',
commit_docs: true,
parallelization: true,
search_gitignored: false,
brave_search: false,
firecrawl: false,
exa_search: false,
git: {
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
quick_branch_template: null,
},
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: true,
tdd_mode: false,
human_verify_mode: 'end-of-phase',
auto_advance: false,
node_repair: true,
node_repair_budget: 2,
ui_phase: true,
ui_safety_gate: true,
text_mode: false,
research_before_questions: false,
discuss_mode: 'discuss',
skip_discuss: false,
max_discuss_passes: 3,
subagent_timeout: 300000,
context_coverage_gate: true,
_auto_chain_active: false,
},
hooks: {
context_warnings: true,
},
agent_skills: {},
project_code: null,
mode: 'interactive',
};
// ─── Loader ──────────────────────────────────────────────────────────────────
/**
* Load project config from `.planning/config.json`, merging with defaults.
* When project config is missing or empty, this returns `mergeDefaults({})`
* (built-in defaults only; no `~/.gsd/defaults.json` layering).
* Throws on malformed JSON with a helpful error message.
*/
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
const rootConfigPath = join(projectDir, '.planning', 'config.json');
let raw: string;
let projectConfigFound = false;
try {
raw = await readFile(configPath, 'utf-8');
projectConfigFound = true;
} catch {
// If workstream config missing, fall back to root config
if (workstream) {
try {
raw = await readFile(rootConfigPath, 'utf-8');
projectConfigFound = true;
} catch {
raw = '';
}
} else {
raw = '';
}
}
// Pre-project context: no .planning/config.json exists.
// Use built-in defaults only so SDK query parity stays stable across machines.
if (!projectConfigFound) {
return mergeDefaults({});
}
const trimmed = raw.trim();
if (trimmed === '') {
// Empty project config — treat as no project config.
return mergeDefaults({});
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} must be a JSON object`);
}
// Project config exists — user-level defaults are ignored (CJS parity).
// `buildNewProjectConfig` already baked them into config.json at /gsd-new-project.
return mergeDefaults(parsed);
}
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
const legacyBranchingStrategy = typeof parsed.branching_strategy === 'string'
? parsed.branching_strategy
: undefined;
return {
...structuredClone(CONFIG_DEFAULTS),
...parsed,
git: {
...CONFIG_DEFAULTS.git,
...(legacyBranchingStrategy ? { branching_strategy: legacyBranchingStrategy } : {}),
...(parsed.git as Partial<GitConfig> ?? {}),
},
workflow: {
...CONFIG_DEFAULTS.workflow,
...(parsed.workflow as Partial<WorkflowConfig> ?? {}),
},
hooks: {
...CONFIG_DEFAULTS.hooks,
...(parsed.hooks as Partial<HooksConfig> ?? {}),
},
agent_skills: {
...CONFIG_DEFAULTS.agent_skills,
...(parsed.agent_skills as Record<string, unknown> ?? {}),
},
};
}