Build path becomes a config key existing projects can actually reach

The build-path preference shipped as a question only `init` asks, written to
a file only `init` writes. Nothing routes an initialized project back through
init, so every existing project took the comp-first default without anyone
choosing it, and the only recourse was a footer toggle that binds one session.

Neither the setting nor the round that preceded it ever reached a release
(skill-v4.0.4 has no `buildPath`, no `comp-led`, no `.impeccable/settings.json`),
so the PRODUCT.md standing-commitment fallback describes an era that never
existed publicly. It is deleted rather than honored: told a field might exist,
models go hunting for it and preserve it.

- `buildPath` moves from `.impeccable/settings.json` into the unified
  `.impeccable/config.json`, which already has a known-keys registry, doctor
  coverage, and a gitignored `config.local.json` override. Whether a machine
  has an image tool is a property of that machine, so the local file wins.
- new-work captures the answer from behavior instead of an interview: a toggle
  flip on a project recording nothing asks once, after the round closes,
  whether to keep it. The answer is written either way, because a declined
  offer nothing writes down is an offer the next session makes again.
- Two findings: `config-invalid-build-path` (an unread value rides the default
  rather than the opposite path) and `config-build-path-unset`, gated on a
  product record plus evidence of direction work so polish-and-audit projects
  never hear about a setting they do not use.
- init treats a recorded value as a confirmed answer, resolving its conflict
  with Step 1's "do not reopen confirmed fields".
- The setting was undocumented in the README and doctor.md. Both now cover it.

Also records a measured skill-behavior baseline. Three cells fail on unmodified
main (scenarios 9 and 15, `initialized natural build`), verified against a clean
worktree; the suite README now says so, so the next person does not spend the
hour attributing them to their own branch.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-13 21:52:17 -04:00
co-authored by Claude Opus 5
parent ddd23b1807
commit c489335799
11 changed files with 254 additions and 27 deletions
+1 -1
View File
@@ -434,7 +434,7 @@ export function renderConceptSeed({
lead carrying kicker THE ROLL, with steer and re-roll, and let the user
lock one in; the world is already settled, so this choice is composition.
Visualize every dealt card: with image generation available and a
comp-led default (.impeccable/settings.json buildPath; the page toggle
comp-led default (.impeccable/config.json buildPath; the page toggle
handles the exception), declare a comp per card and generate after
serving, lead first; otherwise author each card's wireframe field (see
serve-question --schema) and the page draws the schematic. Carry the
+29 -12
View File
@@ -1142,7 +1142,7 @@ async function cli() {
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts);
appendBuildPathDirective(parts, ctx);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1162,7 +1162,7 @@ async function cli() {
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts);
appendBuildPathDirective(parts, ctx);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1271,17 +1271,34 @@ function automaticHookMode(ctx) {
}
// Build-path preference: a workflow setting (comp-led vs code-led), read
// here so every session starts knowing it without a file hunt. Absence
// stays silent; new-work's own default applies, and the decision page
// toggle can flip the value for a single session.
function appendBuildPathDirective(parts) {
try {
const settings = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.impeccable', 'settings.json'), 'utf8'));
if (settings.buildPath === 'comp' || settings.buildPath === 'code') {
parts.push(`BUILD_PATH_DEFAULT: ${settings.buildPath} (from .impeccable/settings.json). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back to settings.`);
// Build-path preference: a workflow setting (comp-led vs code-led), read here
// so every session starts knowing it without a file hunt. It rides the unified
// config beside the hook and detector settings, and the gitignored local file
// wins, because whether a machine has an image tool is a property of that
// machine, not of the team's committed default. Absence stays silent;
// new-work's own default applies, and the decision page toggle can flip the
// value for a single session.
function readBuildPathAt(root) {
let value = null;
let source = null;
for (const name of ['config.json', 'config.local.json']) {
const raw = readJson(path.join(root, '.impeccable', name));
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') {
value = raw.buildPath;
source = `.impeccable/${name}`;
}
} catch { /* no settings file */ }
}
return value ? { value, source } : null;
}
function appendBuildPathDirective(parts, ctx) {
const roots = [...new Set([ctx?.projectRoot, process.cwd()].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
const found = readBuildPathAt(root);
if (!found) continue;
parts.push(`BUILD_PATH_DEFAULT: ${found.value} (from ${found.source}). Author direction and surface rounds with this as buildPath.value and toggle: true; a flip on the page binds that session only and is never written back to the config.`);
return;
}
}
// Image generation availability: harness-native tools always win, but when the
+73 -2
View File
@@ -47,18 +47,33 @@ import {
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
// are allowed as conventional metadata nobody reads.
// monorepo resolution), `buildPath` (context.mjs's build-path directive), plus
// `stalenessCheck` below. `$schema` and `version` are allowed as conventional
// metadata nobody reads.
const KNOWN_CONFIG_KEYS = new Set([
'hook',
'detector',
'updateCheck',
'stalenessCheck',
'projectRoots',
'buildPath',
'$schema',
'version',
]);
// The only two values context.mjs and new-work honor. A near miss reads as a
// working preference and silently rides the opposite path, so it is worth
// reporting rather than coercing.
const BUILD_PATH_VALUES = Object.freeze(['comp', 'code']);
// Evidence that this project does the kind of work `buildPath` governs. A
// project that only ever ran polish or audit has no use for the setting and
// should never be told it exists. Two stats, so Tier 1 can afford it.
const DIRECTION_WORK_PATHS = Object.freeze([
path.join('.impeccable', 'surfaces'),
path.join('.impeccable', 'mocks', 'decision'),
]);
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
// checked: it carries runtime settings from several writers and the false
// positive rate would outweigh the catch.
@@ -325,6 +340,20 @@ export function checkConfig({ projectRoot, repoRoot }) {
}));
}
if (Object.prototype.hasOwnProperty.call(raw, 'buildPath')
&& !BUILD_PATH_VALUES.includes(raw.buildPath)) {
findings.push(finding({
id: 'config-invalid-build-path',
artifact: 'config.json',
filePath: rel,
severity: 'mention',
summary: `${rel} sets \`buildPath\` to ${JSON.stringify(raw.buildPath)}, which nothing reads. `
+ `The values are ${BUILD_PATH_VALUES.map((value) => `\`${value}\``).join(' and ')}.`,
fix: 'Report the value. An unread `buildPath` does not fall back to the other path; '
+ 'it falls back to the default, so a project meaning `code` has been building comp-led.',
}));
}
const detector = raw.detector;
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
@@ -345,6 +374,47 @@ export function checkConfig({ projectRoot, repoRoot }) {
return findings;
}
/**
* No recorded build-path preference on a project that plainly does visual
* direction work. Not drift in the usual sense: the setting is newer than the
* project, so every project that predates it lands here at once. That is why
* it is gated twice, on a product record and on evidence of the work the
* setting governs, and why it says the choice rather than assuming a harness
* can make it. Image generation is the real precondition and this module
* cannot see it: a harness-native image tool leaves no trace on disk, so the
* finding hands the question to the one reader that knows.
*/
export function checkBuildPathUnset({ projectRoot, repoRoot, product }) {
if (!projectRoot || !product) return [];
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
for (const root of roots) {
for (const name of ['config.json', 'config.local.json']) {
const raw = readJson(path.join(root, '.impeccable', name));
// Any declared value ends this, valid or not: an invalid one already has
// its own finding and two reports of one key is noise.
if (raw && Object.prototype.hasOwnProperty.call(raw, 'buildPath')) return [];
}
}
const evidence = DIRECTION_WORK_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
if (!evidence.length) return [];
return [finding({
id: 'config-build-path-unset',
artifact: 'config.json',
filePath: '.impeccable/config.json',
severity: 'mention',
summary: 'This project has run visual direction work but records no `buildPath`, '
+ 'so every direction round takes the comp-first default without anyone having chosen it.',
fix: 'Only when image generation exists in your tool surface, offer the choice once: '
+ '**comp-first** (an image sets the bar before any code; bolder composition, slower) or '
+ '**code-first** (build directly; ambition carried by the direction contract; leaner, faster). '
+ 'Write the answer to `.impeccable/config.json` as `"buildPath": "comp"` or `"buildPath": "code"`, '
+ 'merging with the keys already there. Without image generation there is no choice to record: stay silent.',
})];
}
// ─── Surface briefs ────────────────────────────────────────────────────────
/**
@@ -446,6 +516,7 @@ export function collectBootFindings(ctx, extras = {}) {
projectRoot,
}),
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
? checkProjectRoots({
+1 -1
View File
@@ -199,7 +199,7 @@ if (hasFlag('schema')) {
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', comp: '.impeccable/mocks/decision/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdicts call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLES PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challengers system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the cards full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/settings.json, or a PRODUCT.md standing commitment as fallback) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/comp accept URLs or local paths; comp slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the comp, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no comp (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdicts call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "IMPECCABLES PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challengers system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. A card may instead declare "wireframe" ({"cols":12,"rows":10,"regions":[{"label":"nav rail","x":0,"y":0,"w":3,"h":10,"accent":true}]}): the page draws it as a layout schematic in the media slot; surface-scope rounds use it on code-led builds, it never counts toward salience, and the card keeps its full read on the front. The comp slot carries the cards full-fidelity direction comp (the legacy key "sketch" is accepted as an alias). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. buildPath rides the payload as { "value": "comp"|"code", "toggle": true }: the value is the recorded default (.impeccable/config.json buildPath, or .impeccable/config.local.json where one machine differs) and the toggle renders a footer switch whose flip binds that session only; the ANSWER then carries buildPath plus buildPathFlipped. On a code-led round each card still declares its comp path as a flip reserve: wireframes render, and a flip to comp makes --wait return once with BUILD PATH FLIPPED so you generate the comps into the declared slots while the round stays open; a flip back to code is free, and a comp that already landed stays as the critique reference. The toggle may only be offered when image generation exists: a harness with no image tool and no API key never sets toggle: true, so the choice never renders where comps cannot be made, and code-led simply rides as the untoggleable value. followup: true keeps the table open after a pick for a second round via --update; send the next payload immediately, the page is waiting on it.');
process.exit(0);
}