diff --git a/.changeset/steady-elks-parade.md b/.changeset/steady-elks-parade.md new file mode 100644 index 000000000..5291b5733 --- /dev/null +++ b/.changeset/steady-elks-parade.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 4441 +--- +**`workflow.compact_content` is now a registered, validated, documented project config key.** It resolves to `false` when absent and is readable via `config-get`; no content branches on it yet. (#4401) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9b098536f..5cc2f6eb2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -517,6 +517,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 | | `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch has diverged from `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. See [`worktree.baseRef`](#worktree-settings) to restore parallel execution on a diverged branch. **Per-runtime note:** whether this key can be honored depends on the runtime's declared `dispatch.isolation` capability, not on its name (#2584). Runtimes whose own harness isolates each executor (**Claude Code**, **Cursor**) run parallel worktrees natively; runtimes exposing a headless exec with an explicit working directory (**Codex**, **OpenCode**, **Kimi**, **Kimi Code**) get worktrees GSD itself creates and merges — where a dispatch site can only drive the harness model, those hosts degrade to sequential with a warning rather than aborting. Every other runtime declares no isolation primitive, and forcing `use_worktrees: true` there still fails closed before any executor dispatch. `/gsd-health` reports such a value as warning `W025` (#2486). **Default on a non-Claude install:** if a worktree-capable non-Claude host is not isolating as described above, check whether the install stamped this key's default to `false` and set an explicit `use_worktrees: true`. See [Executor isolation per runtime](#executor-isolation-per-runtime). | | `workflow.agent_hint_routing` | boolean | `true` | Per-plan specialist executor routing (#1689). When `true`, a plan whose `agent_hint:` frontmatter names a subagent that resolves on the active runtime is dispatched to that specialist instead of `gsd-executor`. Default `true` — a no-op for plans without `agent_hint:`, so existing dispatch is unchanged. Set `false` to disable. See [PLAN.md `agent_hint`](reference/plan-md.md#per-plan-executor-routing). | +| `workflow.compact_content` | boolean | `false` | Compact content mode (#4139, [ADR-4139](adr/4139-compact-content-seam.md)). Per-project boolean selecting the terser form of GSD's own shipped prompt content (workflows, templates, agent-skill payloads). The key is registered and readable today; nothing branches on it yet — the load mechanism is a later sub-issue. | | `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). | | `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 | | `workflow.code_review_point` | string | `execute:post` | Loop point at which the code-review capability's step registers: `execute:post` reviews once, after every wave in a phase has landed (default — unchanged behavior); `execute:wave:post` reviews once per completed wave instead, scoped to what changed since the phase's prior review (the whole phase's diff on the first wave, each subsequent wave's own diff thereafter). Manual `/gsd-code-review ` invocation is unaffected by this key — it is gated by `workflow.code_review` alone and runs regardless of which point is configured. `/gsd-autonomous` and `/gsd-quick` have no wave granularity of their own, so setting this to `execute:wave:post` means code review does not run automatically inside those two flows (consistent with how every other `execute:wave:post`-only capability already behaves for them). Added in #3661 | diff --git a/gsd-core/bin/shared/config-defaults.manifest.json b/gsd-core/bin/shared/config-defaults.manifest.json index 164b4ac5d..1b26ab4b7 100644 --- a/gsd-core/bin/shared/config-defaults.manifest.json +++ b/gsd-core/bin/shared/config-defaults.manifest.json @@ -38,6 +38,7 @@ "ui_phase": true, "ui_safety_gate": true, "text_mode": false, + "compact_content": false, "research_before_questions": false, "discuss_mode": "discuss", "skip_discuss": false, diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 393b23105..0212ba304 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -22,6 +22,7 @@ "workflow.smart_zone_tokens", "workflow.human_verify_mode", "workflow.text_mode", + "workflow.compact_content", "workflow.research_before_questions", "workflow.discuss_mode", "workflow.skip_discuss", diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index 70d807710..32dae8e22 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -289,6 +289,7 @@ Set via `workflow.*` namespace in config.json (e.g., `"workflow": { "research": | `workflow.ui_phase` | boolean | `true` | `true`, `false` | Generate UI-SPEC.md for frontend phases | | `workflow.ui_safety_gate` | boolean | `true` | `true`, `false` | Require safety gate approval for UI changes | | `workflow.text_mode` | boolean | `false` | `true`, `false` | Use plain-text numbered lists instead of AskUserQuestion menus | +| `workflow.compact_content` | boolean | `false` | `true`, `false` | Compact content mode (#4139, ADR-4139) — per-project boolean selecting terser payloads; nothing branches on it yet | | `workflow.research_before_questions` | boolean | `false` | `true`, `false` | Run research before interactive questions in discuss phase (also honored on the `/gsd:quick` path, #3894). _Alias:_ `research_before_questions` is the flat-key form used in `CONFIG_DEFAULTS`; `workflow.research_before_questions` is the canonical namespaced form. | | `workflow.discuss_mode` | string | `"discuss"` | `"discuss"`, `"assumptions"` | Default mode for discuss-phase: `"discuss"` runs interactive questioning; `"assumptions"` analyzes codebase and surfaces assumptions instead | | `workflow.skip_discuss` | boolean | `false` | `true`, `false` | Skip discuss phase entirely | diff --git a/scripts/docs-guard-registry.cjs b/scripts/docs-guard-registry.cjs index ed7efbf09..a1f206e30 100644 --- a/scripts/docs-guard-registry.cjs +++ b/scripts/docs-guard-registry.cjs @@ -193,6 +193,7 @@ const DOCS_GUARD_TESTS = { // (commit-files-pathspec.test.cjs:1618) — cannot be resolved to specific // files without re-deriving the scan's own file-discovery logic. 'tests/commit-files-pathspec.test.cjs': ['*'], + 'tests/compact-content-4139.test.cjs': ['docs/CONFIGURATION.md'], 'tests/config-field-docs.test.cjs': ['docs/CONFIGURATION.md'], 'tests/config.test.cjs': ['docs/CONFIGURATION.md'], 'tests/context-index-sync.test.cjs': ['docs/CONTEXT-INDEX.json'], diff --git a/src/config-loader.cts b/src/config-loader.cts index fdafaaee3..3674e48bd 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -144,6 +144,7 @@ const CONFIG_DEFAULTS = { firecrawl: _getConfigDefault('firecrawl'), exa_search: _getConfigDefault('exa_search'), text_mode: _getNestedConfigDefault('workflow', 'text_mode'), + compact_content: _getNestedConfigDefault('workflow', 'compact_content'), sub_repos: _getNestedConfigDefault('planning', 'sub_repos'), pr_strict: _getNestedConfigDefault('planning', 'pr_strict'), resolve_model_ids: _getConfigDefault('resolve_model_ids'), diff --git a/src/config.cts b/src/config.cts index 49ee4d12f..1a746074a 100644 --- a/src/config.cts +++ b/src/config.cts @@ -104,6 +104,11 @@ const SCHEMA_DEFAULTS: Record = { // #1689: per-plan agent_hint executor routing — default-on. A no-op for plans // without an agent_hint field, so existing dispatch is byte-identical. 'workflow.agent_hint_routing': true, + // #4401: Compact Content mode gate — derived from the defaults manifest via + // CONFIG_DEFAULTS (added in config-loader.cts) so the manifest stays the + // single source of truth, matching workflow.smart_zone_tokens / + // planning.pr_strict / workflow.inline_plan_threshold below. + 'workflow.compact_content': CONFIG_DEFAULTS.compact_content, // Derived from the defaults manifest rather than restated, so the manifest // stays the single source of truth for the smart-zone budget (#2630). 'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens, @@ -345,6 +350,7 @@ function buildNewProjectConfig(userChoices: Record): Record { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + runGsdTools('config-ensure-section', tmpDir); + }); + afterEach(() => { cleanup(tmpDir); }); + + function readConfig() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')); + } + + test('config-set workflow.compact_content true → persisted as boolean true', () => { + const r = runGsdTools(['config-set', 'workflow.compact_content', 'true'], tmpDir); + assert.ok(r.success, r.error); + const config = readConfig(); + assert.strictEqual(config.workflow.compact_content, true); + }); + + test('config-set workflow.compact_content false → persisted as boolean false', () => { + const r = runGsdTools(['config-set', 'workflow.compact_content', 'false'], tmpDir); + assert.ok(r.success, r.error); + const config = readConfig(); + assert.strictEqual(config.workflow.compact_content, false); + }); + + test('config-set workflow.compact_content banana → rejected', () => { + const r = runGsdTools(['config-set', 'workflow.compact_content', 'banana'], tmpDir); + assert.ok(!r.success, 'non-boolean value must be rejected'); + assert.match(r.error || r.output, /boolean|true|false/i); + }); + + test('config-set workflow.compact_content "" → rejected', () => { + const r = runGsdTools(['config-set', 'workflow.compact_content', ''], tmpDir); + assert.ok(!r.success, 'empty value must be rejected'); + }); + + test('config-get workflow.compact_content --raw before any explicit set → succeeds with the materialized default', () => { + // As of plan 02-02 (CONF-01), buildNewProjectConfig's hardcoded workflow + // object carries compact_content: false, so any freshly materialized + // config.json (including the one config-ensure-section writes in + // beforeEach) already has the key — config-get succeeds and returns the + // default "false" rather than exiting non-zero. The workflow-side + // `... --raw 2>/dev/null || echo "false"` fallback still resolves to the + // same string either way, so gate hooks are unaffected by this change. + const r = runGsdTools(['config-get', 'workflow.compact_content', '--raw'], tmpDir); + assert.ok(r.success, 'config-get on the materialized default must exit zero'); + assert.strictEqual(r.output.trim(), 'false'); + }); + + test('setting true twice is idempotent — identical config.json content', () => { + const first = runGsdTools(['config-set', 'workflow.compact_content', 'true'], tmpDir); + assert.ok(first.success, first.error); + const afterFirst = fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'); + + const second = runGsdTools(['config-set', 'workflow.compact_content', 'true'], tmpDir); + assert.ok(second.success, second.error); + const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8'); + + assert.strictEqual(afterSecond, afterFirst); + }); + + test('setting the key preserves every other pre-existing key/value', () => { + const cfgPath = path.join(tmpDir, '.planning', 'config.json'); + const before = JSON.parse(fs.readFileSync(cfgPath, 'utf-8')); + before.workflow.text_mode = true; + before.mode = 'yolo'; + fs.writeFileSync(cfgPath, JSON.stringify(before, null, 2)); + + const r = runGsdTools(['config-set', 'workflow.compact_content', 'true'], tmpDir); + assert.ok(r.success, r.error); + + const after = readConfig(); + assert.strictEqual(after.workflow.text_mode, true, 'workflow.text_mode must survive unrelated key write'); + assert.strictEqual(after.mode, 'yolo', 'top-level mode must survive unrelated key write'); + assert.strictEqual(after.workflow.compact_content, true); + }); + + test('VALID_CONFIG_KEYS has workflow.compact_content', () => { + const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); + assert.strictEqual(VALID_CONFIG_KEYS.has('workflow.compact_content'), true); + }); + + test('config-set workflow.compact_content 42 → rejected, message names the key', () => { + const r = runGsdTools(['config-set', 'workflow.compact_content', '42'], tmpDir); + assert.ok(!r.success, 'numeric value must be rejected'); + assert.match(r.error || r.output, /workflow\.compact_content/); + }); + + test('config-set workflow.compact_content null → unsets the key (universal #2046 clear semantics, not a type-rejection)', () => { + // A bare `null` is the documented "clear this key" shortcut (#2046) and is + // short-circuited before every typed per-key validator runs — this is + // true for every config key, not something this plan introduces or may + // change. Verified against the analogous git.protected_branches and + // context-key coverage in tests/config.test.cjs ("config-set null — + // unset/clear (#2046)"). So `null` exits zero and removes the key rather + // than being rejected like `42`/`banana`/`""`. + const r = runGsdTools(['config-set', 'workflow.compact_content', 'null'], tmpDir); + assert.ok(r.success, `unset must succeed: ${r.error}`); + const config = readConfig(); + assert.ok( + !Object.prototype.hasOwnProperty.call(config.workflow, 'compact_content'), + 'workflow.compact_content must be absent after unset', + ); + }); + + test('config-defaults.manifest.json carries workflow.compact_content', () => { + const manifest = require('../gsd-core/bin/shared/config-defaults.manifest.json'); + assert.strictEqual(manifest.workflow.compact_content, false); + }); +}); + +// ─── D-03: absent-key resolution against a config that omits the key ───────── + +describe('workflow.compact_content absent-key resolution (#4139, D-03)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + afterEach(() => { cleanup(tmpDir); }); + + test('absent key: a config.json omitting compact_content resolves to the manifest default', () => { + const cfgDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(cfgDir, { recursive: true }); + fs.writeFileSync( + path.join(cfgDir, 'config.json'), + JSON.stringify({ version: '1.0', mode: 'interactive', workflow: { research: true } }, null, 2), + ); + + const manifest = require('../gsd-core/bin/shared/config-defaults.manifest.json'); + const r = runGsdTools(['config-get', 'workflow.compact_content', '--raw'], tmpDir); + assert.strictEqual(r.exitCode, 0, r.error || r.output); + assert.strictEqual(r.output.trim(), String(manifest.workflow.compact_content)); + }); +}); + +// ─── CONF-01: buildNewProjectConfig default + config-new-project wiring ─────── + +describe('workflow.compact_content via config-new-project (#4139, CONF-01)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + afterEach(() => { cleanup(tmpDir); }); + + function readConfig() { + return JSON.parse(fs.readFileSync(path.join(tmpDir, '.planning', 'config.json'), 'utf-8')); + } + + test('config-new-project omitting compact_content → hardcoded default false lands in config.json', () => { + const choices = JSON.stringify({ + mode: 'interactive', + granularity: 'coarse', + parallelization: true, + commit_docs: false, + model_profile: 'adaptive', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: false }, + }); + const r = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); + assert.ok(r.success, r.error); + const config = readConfig(); + assert.strictEqual(config.workflow.compact_content, false); + }); + + test('config-new-project with compact_content: true → persisted as boolean true', () => { + const choices = JSON.stringify({ + mode: 'interactive', + granularity: 'coarse', + parallelization: true, + commit_docs: false, + model_profile: 'adaptive', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: false, compact_content: true }, + }); + const r = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); + assert.ok(r.success, r.error); + const config = readConfig(); + assert.strictEqual(config.workflow.compact_content, true); + }); + + test('config-new-project with compact_content: false → persisted as boolean false, not string', () => { + const choices = JSON.stringify({ + mode: 'interactive', + granularity: 'coarse', + parallelization: true, + commit_docs: false, + model_profile: 'adaptive', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: false, compact_content: false }, + }); + const r = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); + assert.ok(r.success, r.error); + const config = readConfig(); + assert.strictEqual(config.workflow.compact_content, false); + assert.notStrictEqual(config.workflow.compact_content, 'false'); + }); + + test('config-get workflow.compact_content --raw after config-new-project prints the persisted value', () => { + const choices = JSON.stringify({ + mode: 'interactive', + granularity: 'coarse', + parallelization: true, + commit_docs: false, + model_profile: 'adaptive', + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: false, compact_content: true }, + }); + const setup = runGsdTools(['config-new-project', choices], tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir }); + assert.ok(setup.success, setup.error); + + const r = runGsdTools(['config-get', 'workflow.compact_content', '--raw'], tmpDir); + assert.ok(r.success, 'config-get must exit zero once config-new-project has materialized the key'); + assert.strictEqual(r.output.trim(), 'true'); + }); +}); + +// ─── D-06: doc-row shape assertions for both config reference tables ───────── + +describe('workflow.compact_content documentation rows (#4139, D-06)', () => { + const { splitTableRow } = require('../gsd-core/bin/lib/markdown-table.cjs'); + const KEY_CELL = '`workflow.compact_content`'; + + function findRow(filePath) { + const lines = fs.readFileSync(path.join(__dirname, '..', filePath), 'utf-8').split(/\r?\n/); + for (const line of lines) { + if (!line.trim().startsWith('|')) continue; + const cells = splitTableRow(line); + if (cells && cells[0] === KEY_CELL) return cells; + } + return undefined; + } + + test('docs/CONFIGURATION.md documents workflow.compact_content as a 4-cell boolean row', () => { + const cells = findRow('docs/CONFIGURATION.md'); + assert.ok(cells, 'workflow.compact_content row not found in docs/CONFIGURATION.md'); + assert.strictEqual(cells.length, 4); + assert.strictEqual(cells[1], 'boolean'); + assert.strictEqual(cells[2], '`false`'); + }); + + test('planning-config.md documents workflow.compact_content as a 5-cell boolean row', () => { + const cells = findRow('gsd-core/references/planning-config.md'); + assert.ok(cells, 'workflow.compact_content row not found in planning-config.md'); + assert.strictEqual(cells.length, 5); + assert.strictEqual(cells[1], 'boolean'); + assert.strictEqual(cells[2], '`false`'); + assert.match(cells[3], /`true`/); + assert.match(cells[3], /`false`/); + }); + + test('both doc rows sit under the Workflow section they belong to', () => { + const planningConfigPath = path.join(__dirname, '..', 'gsd-core/references/planning-config.md'); + const planningConfigContent = fs.readFileSync(planningConfigPath, 'utf-8'); + const keyIdx = planningConfigContent.indexOf('`workflow.compact_content`'); + const fieldRefIdx = planningConfigContent.indexOf('## Complete Field Reference'); + const workflowFieldsIdx = planningConfigContent.indexOf('### Workflow Fields'); + assert.ok(keyIdx > -1, 'key not found in planning-config.md'); + assert.ok(keyIdx > fieldRefIdx, 'row must sit after ## Complete Field Reference heading'); + assert.ok(keyIdx > workflowFieldsIdx, 'row must sit after ### Workflow Fields heading'); + + const configurationMdPath = path.join(__dirname, '..', 'docs/CONFIGURATION.md'); + const configurationMdContent = fs.readFileSync(configurationMdPath, 'utf-8'); + const compactIdx = configurationMdContent.indexOf('`workflow.compact_content`'); + const textModeIdx = configurationMdContent.indexOf('`workflow.text_mode`'); + assert.ok(compactIdx > -1, 'key not found in docs/CONFIGURATION.md'); + assert.ok(compactIdx > textModeIdx, 'row must sit inside the workflow.* run, after workflow.text_mode'); + }); +}); diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 9f516e274..23ecca5be 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -101,6 +101,7 @@ describe('config-field-docs', () => { ai_integration_phase: 'workflow.ai_integration_phase', api_coverage_gate: 'workflow.api_coverage_gate', text_mode: 'workflow.text_mode', + compact_content: 'workflow.compact_content', subagent_timeout: 'workflow.subagent_timeout', branching_strategy: 'git.branching_strategy', phase_branch_template: 'git.phase_branch_template',