enhance(#4401): register workflow.compact_content as a validated config key (#4441)

* feat(#4401): register workflow.compact_content as a validated config key

- Add compact_content: false to the nested workflow object in
  gsd-core/bin/shared/config-defaults.manifest.json
- Add 'workflow.compact_content': false to SCHEMA_DEFAULTS in src/config.cts
  so an absent key resolves to false via config-get --raw
- validKeys entry in config-schema.manifest.json already present

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(#4401): behavioral and boundary tests for workflow.compact_content

- 19 behavioral tests covering config-set/config-get round trip, invalid-shape
  rejection (banana, 42, empty string), the corrected null-unset semantics
  (#2046), absent-key resolution against config-defaults.manifest.json,
  config-new-project wiring, and doc-row shape assertions
- Drops the install-tree fixture-parity block (and its docstring item) that
  asserted gsd-core/references/compact-content-gate.md and
  gsd-core/workflows/compact/map-codebase.md fixture entries — those paths
  belong to #4402 and do not exist on this filtered branch

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* docs(#4401): document workflow.compact_content in both config references

- One 4-cell row in docs/CONFIGURATION.md (workflow.* run)
- One 5-cell row under Workflow Fields in gsd-core/references/planning-config.md
- Both cross-reference ADR-4139

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(#4401): add changeset

- Added-type fragment, pr: 4401 (issue number; backfill to the real PR number
  is a required follow-up once the PR is opened, per D-08 and CHANGESET-PR-
  FIELD-DRIFT)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(#4401): backfill changeset pr field to #4441

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(#4401): derive workflow.compact_content default from CONFIG_DEFAULTS

SCHEMA_DEFAULTS['workflow.compact_content'] hardcoded the literal false
instead of deriving it from CONFIG_DEFAULTS the way 3 of its 8 sibling
entries do (smart_zone_tokens, pr_strict, inline_plan_threshold), leaving
a single-source-of-truth drift risk: a future manifest-only edit to the
default could silently diverge from this literal, only caught later by
the D-03 test if it ever happened to manifest.

Adds compact_content to CONFIG_DEFAULTS in src/config-loader.cts and
derives SCHEMA_DEFAULTS from it in src/config.cts, matching the majority
sibling pattern. Found during maintainer review (review-open-prs) of
this PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4401): map compact_content in config-field-docs NAMESPACE_MAP

The previous commit added compact_content to CONFIG_DEFAULTS in
src/config-loader.cts but missed the matching entry in
tests/config-field-docs.test.cjs's NAMESPACE_MAP, which maps flat
CONFIG_DEFAULTS keys to their namespaced doc form before checking
gsd-core/references/planning-config.md for a match. Without it, the
test looked for a bare `compact_content` doc reference instead of the
actual `workflow.compact_content` row, and failed:
"CONFIG_DEFAULTS keys missing from planning-config.md: compact_content".

Found by actually running gsd-test against the branch rather than
trusting the plausible-looking fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(#4401): register compact-content-4139 test in the docs-guard lane

tests/compact-content-4139.test.cjs's D-06 tests read docs/CONFIGURATION.md
directly (fs.readFileSync) to assert the workflow.compact_content doc row's
shape, which makes it a doc-reading test file under the #3753 docs-guard
lane. It was never added to scripts/docs-guard-registry.cjs's
DOCS_GUARD_TESTS map and carries no docs-guard-exempt marker, so
tests/ci-docs-guard-registry.test.cjs's registration lint correctly failed:
"compact-content-4139.test.cjs reads a docs/ path but is not registered in
the docs-guard lane and carries no docs-guard-exempt marker".

Registers it with ['docs/CONFIGURATION.md'] (the only real docs/-prefixed
path it reads; gsd-core/references/planning-config.md is outside this
registry's docs/ scope, matching the sibling config-field-docs.test.cjs
entry's existing convention).

Found by actually running gsd-test against the branch — this gap predates
the maintainer's config-loader.cts fix and was already present in the
original PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
Co-authored-by: sim <sim@local>
This commit is contained in:
Brenden Smerbeck
2026-09-06 19:52:59 -04:00
committed by GitHub
parent 2cf119f57e
commit e54d3aa159
10 changed files with 331 additions and 0 deletions

View File

@@ -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)

View File

@@ -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.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.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.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.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` | 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 <phase>` 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 | | `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 <phase>` 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 |

View File

@@ -38,6 +38,7 @@
"ui_phase": true, "ui_phase": true,
"ui_safety_gate": true, "ui_safety_gate": true,
"text_mode": false, "text_mode": false,
"compact_content": false,
"research_before_questions": false, "research_before_questions": false,
"discuss_mode": "discuss", "discuss_mode": "discuss",
"skip_discuss": false, "skip_discuss": false,

View File

@@ -22,6 +22,7 @@
"workflow.smart_zone_tokens", "workflow.smart_zone_tokens",
"workflow.human_verify_mode", "workflow.human_verify_mode",
"workflow.text_mode", "workflow.text_mode",
"workflow.compact_content",
"workflow.research_before_questions", "workflow.research_before_questions",
"workflow.discuss_mode", "workflow.discuss_mode",
"workflow.skip_discuss", "workflow.skip_discuss",

View File

@@ -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_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.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.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.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.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 | | `workflow.skip_discuss` | boolean | `false` | `true`, `false` | Skip discuss phase entirely |

View File

@@ -193,6 +193,7 @@ const DOCS_GUARD_TESTS = {
// (commit-files-pathspec.test.cjs:1618) — cannot be resolved to specific // (commit-files-pathspec.test.cjs:1618) — cannot be resolved to specific
// files without re-deriving the scan's own file-discovery logic. // files without re-deriving the scan's own file-discovery logic.
'tests/commit-files-pathspec.test.cjs': ['*'], '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-field-docs.test.cjs': ['docs/CONFIGURATION.md'],
'tests/config.test.cjs': ['docs/CONFIGURATION.md'], 'tests/config.test.cjs': ['docs/CONFIGURATION.md'],
'tests/context-index-sync.test.cjs': ['docs/CONTEXT-INDEX.json'], 'tests/context-index-sync.test.cjs': ['docs/CONTEXT-INDEX.json'],

View File

@@ -144,6 +144,7 @@ const CONFIG_DEFAULTS = {
firecrawl: _getConfigDefault('firecrawl'), firecrawl: _getConfigDefault('firecrawl'),
exa_search: _getConfigDefault('exa_search'), exa_search: _getConfigDefault('exa_search'),
text_mode: _getNestedConfigDefault('workflow', 'text_mode'), text_mode: _getNestedConfigDefault('workflow', 'text_mode'),
compact_content: _getNestedConfigDefault('workflow', 'compact_content'),
sub_repos: _getNestedConfigDefault('planning', 'sub_repos'), sub_repos: _getNestedConfigDefault('planning', 'sub_repos'),
pr_strict: _getNestedConfigDefault('planning', 'pr_strict'), pr_strict: _getNestedConfigDefault('planning', 'pr_strict'),
resolve_model_ids: _getConfigDefault('resolve_model_ids'), resolve_model_ids: _getConfigDefault('resolve_model_ids'),

View File

@@ -104,6 +104,11 @@ const SCHEMA_DEFAULTS: Record<string, unknown> = {
// #1689: per-plan agent_hint executor routing — default-on. A no-op for plans // #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. // without an agent_hint field, so existing dispatch is byte-identical.
'workflow.agent_hint_routing': true, '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 // Derived from the defaults manifest rather than restated, so the manifest
// stays the single source of truth for the smart-zone budget (#2630). // stays the single source of truth for the smart-zone budget (#2630).
'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens, 'workflow.smart_zone_tokens': CONFIG_DEFAULTS.smart_zone_tokens,
@@ -345,6 +350,7 @@ function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<str
human_verify_mode: 'end-of-phase', human_verify_mode: 'end-of-phase',
context_guard_mode: 'warn', context_guard_mode: 'warn',
text_mode: false, text_mode: false,
compact_content: false,
research_before_questions: false, research_before_questions: false,
discuss_mode: 'discuss', discuss_mode: 'discuss',
skip_discuss: false, skip_discuss: false,
@@ -858,6 +864,13 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
} }
} }
// Compact Content mode gate (#4139)
if (kp === 'workflow.compact_content') {
if (typeof parsedValue !== 'boolean') {
error(`Invalid workflow.compact_content '${val}'. Must be a boolean (true or false).`);
}
}
// Per-plan executor routing via agent_hint frontmatter (#1689) // Per-plan executor routing via agent_hint frontmatter (#1689)
if (kp === 'workflow.agent_hint_routing') { if (kp === 'workflow.agent_hint_routing') {
if (typeof parsedValue !== 'boolean') { if (typeof parsedValue !== 'boolean') {

View File

@@ -0,0 +1,306 @@
'use strict';
/**
* Issue #4139: Compact Content mode — workflow.compact_content config key
*
* Verifies:
* 1. config-set workflow.compact_content true → exits success, persisted as
* the boolean `true` (not the string "true").
* 2. config-set workflow.compact_content false → exits success, persisted
* as the boolean `false`.
* 3. config-set workflow.compact_content banana → exits failure, message
* matches /boolean|true|false/i.
* 4. config-set workflow.compact_content "" → exits failure.
* 5. config-get workflow.compact_content --raw before any set → exits
* non-zero (so the documented `|| echo "false"` shell fallback resolves
* to the literal `false`).
* 6. Setting true twice → .planning/config.json content is identical to
* the single-set result (idempotency).
* 7. Setting the key preserves every other pre-existing key/value in
* .planning/config.json (ordering/preservation).
* 8. VALID_CONFIG_KEYS (gsd-core/bin/lib/config-schema.cjs) has
* 'workflow.compact_content'.
* 9. config-new-project omitting compact_content from its workflow object
* still materializes workflow.compact_content === false in
* .planning/config.json (CONF-01 fallback default,
* buildNewProjectConfig's hardcoded workflow object).
* 10. config-new-project with an explicit compact_content: true/false
* persists that boolean (not a string).
* 11. config-get workflow.compact_content --raw after config-new-project
* exits zero and prints the persisted value.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
describe('workflow.compact_content config (#4139)', () => {
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 <key> 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');
});
});

View File

@@ -101,6 +101,7 @@ describe('config-field-docs', () => {
ai_integration_phase: 'workflow.ai_integration_phase', ai_integration_phase: 'workflow.ai_integration_phase',
api_coverage_gate: 'workflow.api_coverage_gate', api_coverage_gate: 'workflow.api_coverage_gate',
text_mode: 'workflow.text_mode', text_mode: 'workflow.text_mode',
compact_content: 'workflow.compact_content',
subagent_timeout: 'workflow.subagent_timeout', subagent_timeout: 'workflow.subagent_timeout',
branching_strategy: 'git.branching_strategy', branching_strategy: 'git.branching_strategy',
phase_branch_template: 'git.phase_branch_template', phase_branch_template: 'git.phase_branch_template',