worclaude 2.6.3 → 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,62 @@ All notable changes to worclaude are documented in this file. Format loosely fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.7.1] — 2026-04-24
8
+
9
+ Three `/setup` UX follow-ups from v2.7.0 confirmation testing, shipped as a single patch PR. The `?` / `help` trigger introduced in v2.7.0 turned out to collide with Claude Code's built-in keyboard-shortcut overlay (pressing `?` opens the shortcut panel before /setup's parser sees the keystroke); switched to the `help` keyword only. `worclaude init`'s `runOptionalExtras` was the only place in the init flow still using `inquirer type: 'confirm'` (rendered as `(y/N)`) — converted to `type: 'list'` arrow-key menus so every yes/no in init behaves consistently. CONFIRM_MEDIUM now invokes `AskUserQuestion` directly when the per-item option count fits the tool's `maxItems: 4` schema cap, with the consequence info ("Will be saved as") carried inside each option's `description` field; falls back to the verbatim text prompt (using `help` instead of `?`) when the count exceeds 4. CONFIRM_HIGH stays text-parse — detection lists routinely exceed 4 items. No consumer-visible schema or CLI surface additions.
10
+
11
+ ### Fixed
12
+
13
+ - **`/setup` `?` help trigger → `help` keyword** (PR #119) — 9 occurrences across CONFIRM_HIGH + CONFIRM_MEDIUM prompt templates, response-parsing bullets, error restates, and the Field-help table intro. Explanatory notes added so future maintainers don't re-add `?`.
14
+ - **`worclaude init` prompt-type consistency** (PR #119) — `runOptionalExtras` (src/commands/init.js:77-93) converted the plugin.json and gtd-memory prompts from `type: 'confirm'` (the only `(y/N)` text inputs in init) to `type: 'list'` with boolean-valued Yes/No choices. Regression test inspects `inquirer.prompt.mock.calls` directly.
15
+
16
+ ### Changed
17
+
18
+ - **`/setup` CONFIRM_MEDIUM (≤4 options) uses `AskUserQuestion`** (PR #119) — State 3 split into Path 1 (AskUserQuestion path) and Path 2 (verbatim text fallback, >4 options). Rule #5 widens the AskUserQuestion permit to include CONFIRM_MEDIUM. Rule #7 picks up an explicit EXCEPTION paragraph. Storage rule from v2.6.5 (`mediumResolved[field]` must be a string) applies uniformly across both paths.
19
+
20
+ ### Docs
21
+
22
+ - **`docs/spec/SPEC.md` `/setup` section** (this /sync) — reflects the `help`-keyword-only trigger and the CONFIRM_MEDIUM ≤4-option AskUserQuestion path.
23
+ - **`docs/spec/PROGRESS.md`** (this /sync) — new v2.7.1 release entry; Stats refreshed (782 → 788 tests).
24
+
25
+ ## [2.7.0] — 2026-04-23
26
+
27
+ `/setup` hardening + UX revamp release. PR #115 closes 8 backend bug clusters surfaced by the v2.6.3 manual test matrix (upgrade-flow correctness, downgrade guard, doctor ghost detection, scaffolder exec bits, Commander routing). PR #116 fixes four `/setup` template failure modes — missing `schemaVersion` in SCAN state, `readme` object-shape mismatch in CONFIRM_MEDIUM, all-22-questions-asked despite detection, accept-off-topic-as-answer — and adds a `--from-file` flag to `worclaude setup-state save` plus `Bash(worclaude:*)` permissions so `/setup` runs without approval-prompt interruption. PR #117 adopts Claude Code's `AskUserQuestion` tool for 10 enumerable interview questions (arrow-key selection instead of free-text), redesigns CONFIRM prompts with a "Will be saved as" consequence sub-line and `?` / `help` command, and drops the 80-char readme truncation so users can read the full description before accepting. Dogfood-relevant: `.claude/commands/setup.md` auto-updates on `worclaude upgrade` to pick up the new template.
28
+
29
+ ### Added
30
+
31
+ - **`worclaude setup-state save --from-file <path>`** (PR #116) — new CLI flag reading JSON state from a file path, mutually exclusive with `--stdin`. `/setup` now uses `Write` → `.claude/cache/setup-state.draft.json` → `--from-file` instead of heredoc-piped `--stdin`, eliminating the shell-interpolation safety prompts that interrupted every state transition.
32
+ - **`Bash(worclaude:*)` permission entries in `templates/settings/base.json`** (PR #116) — scoped for `worclaude`, `worclaude scan`, and `worclaude setup-state`. Freshly init'd projects run `/setup` end-to-end without approval prompts.
33
+ - **`/setup` Interaction mode contract** (PR #117) — four modes: `selectable` (`AskUserQuestion` single-choice), `multi-selectable` (`AskUserQuestion` multi-choice with `None` + `Other`), `hybrid` (detection pre-fill + accept/edit/replace), and `free-text`. Ten interview questions get non-default modes: `arch.classification`, `conventions.errors` / `logging` / `api_format`, `verification.staging` (selectable); `arch.external_apis`, `verification.required_checks` (multi-selectable); `features.core` / `nice_to_have` / `non_goals` (hybrid). Fallback to numbered-list parsing for Claude Code versions without `AskUserQuestion`.
34
+ - **`/setup` Field-help table** (PR #117) — single-source-of-truth reference listing all 14 detection fields + 22 questionIds with plain-English description, target output file/section, and example answer. Drives both the CONFIRM "→ Will be saved as: <target>" consequence line and the `?` / `help` command output.
35
+ - **`/setup` Detection-skip matrix** (PR #116) — auto-fills four questionIds with `"[auto-filled from <field>]"` when the scanner already answered them: `story.problem` (via readme), `arch.classification` (via monorepo), `arch.external_apis` (via externalApis), `workflow.new_dev_steps` (via scripts + readme).
36
+
37
+ ### Changed
38
+
39
+ - **`/setup` CONFIRM prompts** (PR #117) — `readme` render is no longer truncated to 80 chars + `…` (verbatim, 100-char soft-wrap); every detected item shows `→ Will be saved as: <target>` sub-line; `?` / `help` response produces the Field-help block without advancing state.
40
+ - **`/setup` INTERVIEW reply handling** (PR #116) — explicit classifier step with Answer / Skip / Cancel / Back / OFF-TOPIC buckets. OFF-TOPIC MUST restate, MUST NOT record, MUST NOT advance, MUST NOT persist. "Prefer off-topic when uncertain" guidance makes the rule less discretionary.
41
+ - **`/setup` SCAN state** (PR #116) — explicit `schemaVersion: 1` in a worked JSON example. Persist step uses `--from-file` with a `.claude/cache/setup-state.draft.json` staging path.
42
+ - **`/setup` CONFIRM_MEDIUM Storage rule** (PR #116) — `mediumResolved[field]` MUST be a string (never the raw `item.value` object). Applies to `readme` specifically, where the detector returns `{projectDescription, setupInstructions, fullPath}`.
43
+ - **`worclaude setup-state` validator error** (PR #116) — `mediumResolved.<field> must be a string` message now includes the received type and points at the CONFIRM_MEDIUM Storage rule.
44
+ - **`worclaude upgrade` preview** (PR #115) — new "Deleted (removed in current version)" section shows phantom hash entries (`categories.missingUntracked`) that the upgrade will prune. Previously silent.
45
+ - **`scaffoldAgentsMd` helper** (PR #115) — shared between `init.js scaffoldFresh` and `merger.js mergeAgentsMd`. Both call sites preserve pre-existing AGENTS.md and write the template alongside under `.claude/workflow-ref/AGENTS.md`.
46
+ - **`.claude/settings.json`** (PR #116, dogfood) — this repo's own install merges in `Bash(worclaude:*)` permissions so the dev-repo's Claude Code session doesn't hit approval prompts.
47
+
48
+ ### Fixed
49
+
50
+ - **Legacy `*.workflow-ref.md` siblings stranded on version match** (PR #115) — `migrateWorkflowRefLocation` is now called before `upgrade.js`'s version-match early-exit and inside `runRepairOnlyFlow`. Projects with leftover siblings on the current version self-heal on the next `worclaude upgrade`.
51
+ - **Silent future-version downgrade via `worclaude upgrade --yes`** (PR #115) — new `semverGreaterThan` helper refuses downgrades with an actionable message (`Refusing to downgrade: installed vX.Y.Z is newer than CLI vA.B.C`) instead of rewriting meta to an older version with a success banner.
52
+ - **`doctor` missed ghost learnings files** (PR #115) — the `.claude/learnings/` check now warns on `.md` files present on disk but missing from `index.json`. Orphan detection (reverse direction) was already working.
53
+ - **`worclaude upgrade --yes` crashed on hook-conflict prompt** (PR #115) — discovered while scripting real-old-CLI upgrades during the v2.6.3 test cycle. `--yes` now threads into `promptHookConflict` and returns `'keep'` (safest default: never clobbers user customizations). Scripted upgrades across hook-template boundaries no longer require manual answers.
54
+ - **Fresh `worclaude init` silently overwrote existing AGENTS.md** (PR #115) — `scaffoldFresh` gates the write on `fileExists(AGENTS.md)` just like the merge path does. User-authored Cursor/Codex AGENTS.md is preserved byte-for-byte; template goes to `.claude/workflow-ref/AGENTS.md`.
55
+ - **Hook `.cjs` files had inconsistent exec bits post-scaffold** (PR #115) — `scaffoldHooks` now runs `fs.chmod(destPath, 0o755)` after each copy (POSIX-guarded). Previously dependent on template source mode; some files landed as `755`, some as `644`.
56
+ - **`worclaude setup-state <unknown>` exited with Commander's default code 1** (PR #115) — `setupState.on('command:*', ...)` listener emits the spec-matching `Error: unknown setup-state subcommand: <x> (expected one of show, save, reset, resume-info)` and sets `process.exitCode = 2`, aligning with the exit-2 convention `setup-state.js` already uses for bad arguments.
57
+
58
+ ### Docs
59
+
60
+ - **`docs/spec/SPEC.md` `/setup` section** (this /sync) — rewritten to reflect the v2.7.0 surface: `--from-file` persistence path, Detection-skip matrix, Interaction mode contract, Field-help table, OFF-TOPIC classifier, `?` / `help` command, `AskUserQuestion` whitelisted at INTERVIEW states only.
61
+ - **`docs/spec/PROGRESS.md`** (this /sync) — new v2.6.3 and v2.7.0 release entries; Stats refreshed (729 → 782 tests, 53 → 57 test files).
62
+
7
63
  ## [2.6.3] — 2026-04-22
8
64
 
9
65
  Second supply-chain scanner mirrored after Socket. Adds a `.snyk` policy file at the repo root with `exclude.global: [tests/fixtures/**]` so Snyk Open Source — whether invoked via the Snyk CLI, the `snyk/actions/node` GitHub Action, or any future integration — skips the intentionally-outdated fixture manifests under `tests/fixtures/scanner/**` that exist solely as deterministic inputs to the Part A project-scanner detectors (`next@14.2.3`, `vitest@1.4.0`, `prisma@5.10.0`, etc.). The fixtures are never installed (not referenced from root `package.json`), never shipped (excluded from the npm tarball by the `files` whitelist), and never executed. `SECURITY.md` is updated to name `.snyk` alongside `socket.yml` in the fixture-exclusion paragraph. The installed Snyk GitHub App only imports root `package.json` today, so the most immediate effect is keeping local `snyk test` runs honest; the file is also load-bearing for any future workflow that fails the build on high-severity findings. No runtime change for worclaude consumers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worclaude",
3
- "version": "2.6.3",
3
+ "version": "2.7.1",
4
4
  "description": "The Workflow Layer for Claude Code — scaffold agents, commands, skills, hooks, and memory into any project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -770,11 +770,27 @@ async function checkLearnings(projectRoot) {
770
770
  orphans.push(entry.file);
771
771
  }
772
772
  }
773
- if (orphans.length > 0) {
774
- return orphans.map((f) =>
775
- result(WARN, `Learnings entry: ${f}`, `Entry references missing file '${f}'`)
773
+
774
+ const indexedFiles = new Set(entries.map((e) => e?.file).filter(Boolean));
775
+ const diskFiles = (await fs.readdir(learningsDir)).filter(
776
+ (f) => f.endsWith('.md') && f !== '.gitkeep'
777
+ );
778
+ const ghosts = diskFiles.filter((f) => !indexedFiles.has(f));
779
+
780
+ const findings = [];
781
+ for (const f of orphans) {
782
+ findings.push(result(WARN, `Learnings entry: ${f}`, `Entry references missing file '${f}'`));
783
+ }
784
+ for (const f of ghosts) {
785
+ findings.push(
786
+ result(
787
+ WARN,
788
+ `Learnings ghost file: ${f}`,
789
+ `File exists but not referenced by index.json — run /learn to capture or delete manually`
790
+ )
776
791
  );
777
792
  }
793
+ if (findings.length > 0) return findings;
778
794
  return [result(PASS, `Learnings: ${entries.length} entries captured`, null)];
779
795
  }
780
796
 
@@ -3,6 +3,7 @@ import inquirer from 'inquirer';
3
3
  import ora from 'ora';
4
4
  import {
5
5
  scaffoldFile,
6
+ scaffoldAgentsMd,
6
7
  updateGitignore,
7
8
  scaffoldHooks,
8
9
  scaffoldPluginJson,
@@ -76,16 +77,24 @@ async function runAgents(selections) {
76
77
  async function runOptionalExtras(selections) {
77
78
  const { generatePluginJson, scaffoldGtdMemory } = await inquirer.prompt([
78
79
  {
79
- type: 'confirm',
80
+ type: 'list',
80
81
  name: 'generatePluginJson',
81
82
  message: 'Generate .claude-plugin/plugin.json for marketplace compatibility?',
82
- default: selections.generatePluginJson || false,
83
+ choices: [
84
+ { name: 'Yes', value: true },
85
+ { name: 'No', value: false },
86
+ ],
87
+ default: selections.generatePluginJson === true ? 0 : 1,
83
88
  },
84
89
  {
85
- type: 'confirm',
90
+ type: 'list',
86
91
  name: 'scaffoldGtdMemory',
87
92
  message: 'Scaffold structured memory files (decisions.md, preferences.md)?',
88
- default: selections.scaffoldGtdMemory || false,
93
+ choices: [
94
+ { name: 'Yes', value: true },
95
+ { name: 'No', value: false },
96
+ ],
97
+ default: selections.scaffoldGtdMemory === true ? 0 : 1,
89
98
  },
90
99
  ]);
91
100
  return { ...selections, generatePluginJson, scaffoldGtdMemory };
@@ -297,8 +306,13 @@ async function scaffoldFresh(projectRoot, selections, variables, settingsStr, ve
297
306
  await scaffoldFile('core/claude-md.md', 'CLAUDE.md', variables, projectRoot);
298
307
  spinner.text = 'Created CLAUDE.md';
299
308
 
300
- await scaffoldFile('core/agents-md.md', 'AGENTS.md', variables, projectRoot);
301
- spinner.text = 'Created AGENTS.md';
309
+ // "Fresh" means no .claude/workflow-meta.json, but the user may still have
310
+ // an AGENTS.md from Cursor/Codex/another tool. The helper preserves it.
311
+ const agentsMdResult = await scaffoldAgentsMd(projectRoot, variables);
312
+ spinner.text =
313
+ agentsMdResult === 'preserved-with-ref'
314
+ ? 'Preserved existing AGENTS.md (template saved to workflow-ref)'
315
+ : 'Created AGENTS.md';
302
316
 
303
317
  await writeFile(path.join(projectRoot, '.claude', 'settings.json'), settingsStr);
304
318
  spinner.text = 'Created .claude/settings.json';
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { text } from 'node:stream/consumers';
3
4
  import {
4
5
  loadSetupState,
@@ -42,27 +43,47 @@ async function showSubcommand(projectRoot) {
42
43
  }
43
44
 
44
45
  async function saveSubcommand(projectRoot, options) {
45
- if (!options.stdin) {
46
- console.error('Error: save requires --stdin');
46
+ const hasStdin = Boolean(options.stdin);
47
+ const hasFromFile = Boolean(options.fromFile);
48
+
49
+ if (hasStdin && hasFromFile) {
50
+ console.error('Error: --stdin and --from-file are mutually exclusive');
51
+ process.exitCode = 2;
52
+ return;
53
+ }
54
+ if (!hasStdin && !hasFromFile) {
55
+ console.error('Error: save requires --stdin or --from-file <path>');
47
56
  process.exitCode = 2;
48
57
  return;
49
58
  }
50
59
 
51
- const inputStream = options.inputStream || process.stdin;
52
60
  let raw;
53
- try {
54
- raw = await text(inputStream);
55
- } catch (err) {
56
- console.error(`Error reading stdin: ${err.message}`);
57
- process.exitCode = 1;
58
- return;
61
+ if (hasFromFile) {
62
+ const filePath = path.resolve(options.fromFile);
63
+ try {
64
+ raw = await readFile(filePath, 'utf-8');
65
+ } catch (err) {
66
+ console.error(`Error reading ${filePath}: ${err.message}`);
67
+ process.exitCode = 1;
68
+ return;
69
+ }
70
+ } else {
71
+ const inputStream = options.inputStream || process.stdin;
72
+ try {
73
+ raw = await text(inputStream);
74
+ } catch (err) {
75
+ console.error(`Error reading stdin: ${err.message}`);
76
+ process.exitCode = 1;
77
+ return;
78
+ }
59
79
  }
60
80
 
61
81
  let parsed;
62
82
  try {
63
83
  parsed = JSON.parse(raw);
64
84
  } catch (err) {
65
- console.error(`Error: invalid JSON on stdin: ${err.message}`);
85
+ const source = hasFromFile ? options.fromFile : 'stdin';
86
+ console.error(`Error: invalid JSON on ${source}: ${err.message}`);
66
87
  process.exitCode = 1;
67
88
  return;
68
89
  }
@@ -21,6 +21,7 @@ import { getLatestNpmVersion } from '../utils/npm.js';
21
21
  import * as display from '../utils/display.js';
22
22
  import {
23
23
  semverLessThan,
24
+ semverGreaterThan,
24
25
  migrateSkillFormat,
25
26
  patchAgentDescriptions,
26
27
  migrateWorkflowRefLocation,
@@ -161,6 +162,13 @@ function renderTemplatePreview(categories, plan) {
161
162
  }
162
163
  display.newline();
163
164
  }
165
+ if (categories.missingUntracked.length > 0) {
166
+ display.barLine(`${display.red('−')} Deleted (removed in current version):`);
167
+ for (const { key } of categories.missingUntracked) {
168
+ display.barLine(` ${display.red('−')} ${key}`);
169
+ }
170
+ display.newline();
171
+ }
164
172
  if (categories.unchanged.length > 0) {
165
173
  display.barLine(
166
174
  `${display.dimColor('=')} Unchanged: ${display.dimColor(`${categories.unchanged.length} files`)}`
@@ -235,11 +243,25 @@ async function promptProceed(message) {
235
243
  return proceed;
236
244
  }
237
245
 
238
- async function runRepairOnlyFlow({ projectRoot, meta, plan, variables, dryRun, yes }) {
246
+ async function runRepairOnlyFlow({
247
+ projectRoot,
248
+ meta,
249
+ plan,
250
+ variables,
251
+ dryRun,
252
+ yes,
253
+ refRelocationReport = { moved: 0, names: [], skipped: [] },
254
+ }) {
239
255
  display.sectionHeader(`WORCLAUDE REPAIR (v${meta.version})`);
240
256
  display.newline();
241
257
  display.barLine('Drift detected:');
242
258
  renderRepairPreview(plan);
259
+ if (refRelocationReport.moved > 0) {
260
+ display.barLine(
261
+ `${display.green('→')} Relocated ${refRelocationReport.moved} legacy ref file(s) ${display.dimColor('→ .claude/workflow-ref/')}`
262
+ );
263
+ display.newline();
264
+ }
243
265
 
244
266
  if (dryRun) {
245
267
  display.info('Dry run — no changes written.');
@@ -290,6 +312,11 @@ async function runRepairOnlyFlow({ projectRoot, meta, plan, variables, dryRun, y
290
312
  if (result.sidecars.length > 0) {
291
313
  display.barLine(`Sidecar: ${result.sidecars.length} suggestion files`);
292
314
  }
315
+ if (refRelocationReport.moved > 0) {
316
+ display.barLine(
317
+ `Relocated: ${refRelocationReport.moved} legacy ref file(s) ${display.dimColor('→ .claude/workflow-ref/')}`
318
+ );
319
+ }
293
320
  display.barLine(display.dimColor(`Backup: ${path.basename(backupDir)}/`));
294
321
 
295
322
  if (result.migrationConflicts.length > 0 || result.sidecars.length > 0) {
@@ -352,16 +379,29 @@ export async function upgradeCommand(options = {}) {
352
379
  const installedVersion = meta.version;
353
380
  const versionMatch = installedVersion === currentVersion;
354
381
 
382
+ if (semverGreaterThan(installedVersion, currentVersion)) {
383
+ display.error(
384
+ `Refusing to downgrade: installed v${installedVersion} is newer than CLI v${currentVersion}.`
385
+ );
386
+ display.info('Upgrade the CLI with `npm install -g worclaude@latest`.');
387
+ return;
388
+ }
389
+
390
+ // v2.5.1 migration: relocate legacy ref files. Runs before any early-exit
391
+ // so version-match projects with leftover legacy siblings still self-heal.
392
+ // Idempotent (skips already-migrated files).
393
+ const refRelocationReport = await migrateWorkflowRefLocation(projectRoot);
394
+
355
395
  const categories = await categorizeFiles(projectRoot, meta);
356
396
  const claudeMdContent = await readClaudeMd(projectRoot);
357
397
  const plan = await buildRepairPlan(projectRoot, categories, claudeMdContent);
358
398
 
359
399
  const repairWork = hasRepairWork(plan);
360
400
  const templateWork = hasTemplateWork(categories, plan);
401
+ const refWork = refRelocationReport.moved > 0;
361
402
 
362
- // Version match + no repair + no template work → up to date.
363
- // Early return keeps the clean-install fast path free of package.json I/O.
364
- if (versionMatch && !repairWork && !templateWork) {
403
+ // Version match + no repair + no template work + no ref relocation → up to date.
404
+ if (versionMatch && !repairWork && !templateWork && !refWork) {
365
405
  display.success(`Already up to date (v${currentVersion}).`);
366
406
  return;
367
407
  }
@@ -370,7 +410,15 @@ export async function upgradeCommand(options = {}) {
370
410
 
371
411
  // Version match + repair only OR explicit --repair-only → repair-only flow
372
412
  if ((versionMatch && !templateWork) || repairOnly) {
373
- await runRepairOnlyFlow({ projectRoot, meta, plan, variables, dryRun, yes });
413
+ await runRepairOnlyFlow({
414
+ projectRoot,
415
+ meta,
416
+ plan,
417
+ variables,
418
+ dryRun,
419
+ yes,
420
+ refRelocationReport,
421
+ });
374
422
  return;
375
423
  }
376
424
 
@@ -439,10 +487,7 @@ export async function upgradeCommand(options = {}) {
439
487
  spinner.start('Applying updates...');
440
488
  }
441
489
 
442
- // v2.5.1 migration: relocate legacy ref files out of Claude-Code-scanned
443
- // dirs. Runs unconditionally since it's idempotent and the fix applies
444
- // to any project with legacy refs, regardless of source version.
445
- const refRelocationReport = await migrateWorkflowRefLocation(projectRoot);
490
+ // v2.5.1 migration already ran before the early-exit check (above).
446
491
  if (refRelocationReport.moved > 0) {
447
492
  spinner.text = `Relocated ${refRelocationReport.moved} legacy ref file(s)...`;
448
493
  }
@@ -474,7 +519,9 @@ export async function upgradeCommand(options = {}) {
474
519
  const useDocker = meta.useDocker || false;
475
520
  const { settingsObject: workflowSettings } = await buildSettingsJson(techStack, useDocker);
476
521
  const settingsReport = { added: { permissions: 0, hooks: 0 }, hookConflicts: [] };
477
- await mergeSettingsPermissionsAndHooks(projectRoot, workflowSettings, settingsReport);
522
+ await mergeSettingsPermissionsAndHooks(projectRoot, workflowSettings, settingsReport, {
523
+ yes,
524
+ });
478
525
  spinner.text = 'Settings merged...';
479
526
  }
480
527
 
@@ -5,6 +5,7 @@ import {
5
5
  readTemplate,
6
6
  substituteVariables,
7
7
  scaffoldFile,
8
+ scaffoldAgentsMd,
8
9
  mergeSettings,
9
10
  scaffoldHooks,
10
11
  scaffoldPluginJson,
@@ -230,7 +231,12 @@ async function mergeCommands(projectRoot, existingScan, report) {
230
231
  }
231
232
  }
232
233
 
233
- export async function mergeSettingsPermissionsAndHooks(projectRoot, workflowSettings, report) {
234
+ export async function mergeSettingsPermissionsAndHooks(
235
+ projectRoot,
236
+ workflowSettings,
237
+ report,
238
+ options = {}
239
+ ) {
234
240
  const existingRaw = await readFile(path.join(projectRoot, '.claude', 'settings.json'));
235
241
  const existing = parseUserJson(existingRaw, '.claude/settings.json');
236
242
 
@@ -277,8 +283,10 @@ export async function mergeSettingsPermissionsAndHooks(projectRoot, workflowSett
277
283
  if (conflictCandidate) {
278
284
  matched.add(conflictCandidate);
279
285
 
280
- // Tier 3: conflict — ask user
281
- const resolution = await promptHookConflict(category, conflictCandidate, workflowEntry);
286
+ // Tier 3: conflict — ask user (or auto-keep if --yes)
287
+ const resolution = await promptHookConflict(category, conflictCandidate, workflowEntry, {
288
+ yes: options.yes,
289
+ });
282
290
 
283
291
  if (resolution === 'replace') {
284
292
  const idx = existingEntries.indexOf(conflictCandidate);
@@ -428,19 +436,10 @@ async function handleClaudeMd(projectRoot, existingScan, variables, selections,
428
436
  }
429
437
  }
430
438
 
431
- async function mergeAgentsMd(projectRoot, existingScan, variables, report) {
432
- if (!existingScan.hasAgentsMd) {
433
- await scaffoldFile('core/agents-md.md', 'AGENTS.md', variables, projectRoot);
434
- report.agentsMdHandling = 'created';
435
- } else {
436
- await scaffoldFile(
437
- 'core/agents-md.md',
438
- workflowRefRelPath('root/AGENTS.md'),
439
- variables,
440
- projectRoot
441
- );
442
- report.agentsMdHandling = 'saved-alongside';
443
- }
439
+ async function mergeAgentsMd(projectRoot, variables, report) {
440
+ const result = await scaffoldAgentsMd(projectRoot, variables);
441
+ // Preserve the existing report-field vocabulary for downstream observability.
442
+ report.agentsMdHandling = result === 'created' ? 'created' : 'saved-alongside';
444
443
  }
445
444
 
446
445
  // --- Main merge function ---
@@ -498,7 +497,7 @@ export async function performMerge(
498
497
  // Stop spinner before CLAUDE.md merge — interactive prompts for section selection
499
498
  if (spinner) spinner.stop();
500
499
  await handleClaudeMd(projectRoot, existingScan, variables, selections, report);
501
- await mergeAgentsMd(projectRoot, existingScan, variables, report);
500
+ await mergeAgentsMd(projectRoot, variables, report);
502
501
  if (spinner) spinner.start();
503
502
 
504
503
  return report;
@@ -24,6 +24,10 @@ export function semverLessThan(a, b) {
24
24
  return false;
25
25
  }
26
26
 
27
+ export function semverGreaterThan(a, b) {
28
+ return semverLessThan(b, a);
29
+ }
30
+
27
31
  // --- Agent description lookup ---
28
32
 
29
33
  const UNIVERSAL_AGENT_DESCRIPTIONS = {
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import fs from 'fs-extra';
4
4
  import { fileExists, readFile, writeFile } from '../utils/file.js';
5
+ import { workflowRefRelPath } from './file-categorizer.js';
5
6
  import { UNIVERSAL_AGENTS } from '../data/agents.js';
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -29,6 +30,25 @@ export async function scaffoldFile(templateRelativePath, destRelativePath, varia
29
30
  await writeFile(destPath, content);
30
31
  }
31
32
 
33
+ // Scaffold AGENTS.md at the project root, preserving any pre-existing file.
34
+ // When AGENTS.md exists (user-authored, Cursor/Codex/etc.), the template lands
35
+ // under .claude/workflow-ref/AGENTS.md so the user can diff + merge.
36
+ // Returns 'created' on fresh write or 'preserved-with-ref' when skipping.
37
+ export async function scaffoldAgentsMd(projectRoot, variables) {
38
+ const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
39
+ if (await fileExists(agentsMdPath)) {
40
+ await scaffoldFile(
41
+ 'core/agents-md.md',
42
+ workflowRefRelPath('root/AGENTS.md'),
43
+ variables,
44
+ projectRoot
45
+ );
46
+ return 'preserved-with-ref';
47
+ }
48
+ await scaffoldFile('core/agents-md.md', 'AGENTS.md', variables, projectRoot);
49
+ return 'created';
50
+ }
51
+
32
52
  export async function scaffoldDirectory(templateDir, destDir, variables, projectRoot) {
33
53
  const root = projectRoot || process.cwd();
34
54
  const fs = await import('fs-extra');
@@ -98,10 +118,21 @@ export async function scaffoldHooks(projectRoot) {
98
118
  const entries = await fs.readdir(hooksTemplateDir);
99
119
  for (const entry of entries) {
100
120
  if (!entry.endsWith('.cjs') && !entry.endsWith('.js')) continue;
101
- await fs.copy(path.join(hooksTemplateDir, entry), path.join(destDir, entry), {
121
+ const destPath = path.join(destDir, entry);
122
+ await fs.copy(path.join(hooksTemplateDir, entry), destPath, {
102
123
  overwrite: false,
103
124
  errorOnExist: false,
104
125
  });
126
+ // Ensure consistent exec bits on POSIX. Windows has no chmod semantics
127
+ // worth enforcing; node+fs-extra no-ops there anyway, but guard to keep
128
+ // the intent obvious.
129
+ if (process.platform !== 'win32') {
130
+ try {
131
+ await fs.chmod(destPath, 0o755);
132
+ } catch (err) {
133
+ if (err.code !== 'ENOENT') throw err;
134
+ }
135
+ }
105
136
  }
106
137
  }
107
138
 
@@ -114,7 +114,10 @@ function validateState(state) {
114
114
  }
115
115
  for (const [k, v] of Object.entries(state.mediumResolved)) {
116
116
  if (typeof v !== 'string') {
117
- throw new Error(`state.mediumResolved.${k} must be a string`);
117
+ throw new Error(
118
+ `state.mediumResolved.${k} must be a string (got ${typeof v}; ` +
119
+ `stringify the rendered value per the CONFIRM_MEDIUM Storage rule)`
120
+ );
118
121
  }
119
122
  }
120
123
  if (!state.interviewAnswers || typeof state.interviewAnswers !== 'object') {
package/src/index.js CHANGED
@@ -84,8 +84,9 @@ setupState
84
84
 
85
85
  setupState
86
86
  .command('save')
87
- .description('Read a JSON state from stdin, validate, and persist')
88
- .option('--stdin', 'Read JSON from stdin (required)')
87
+ .description('Read a JSON state from stdin or a file, validate, and persist')
88
+ .option('--stdin', 'Read JSON from stdin')
89
+ .option('--from-file <path>', 'Read JSON from a file path')
89
90
  .option('--path <dir>', 'Project root', process.cwd())
90
91
  .action((options) => setupStateCommand('save', options));
91
92
 
@@ -101,4 +102,14 @@ setupState
101
102
  .option('--path <dir>', 'Project root', process.cwd())
102
103
  .action((options) => setupStateCommand('resume-info', options));
103
104
 
105
+ // Catch unknown setup-state subcommands with the spec-matching exit code 2.
106
+ // Commander's default would exit 1, but setup-state's own arg-error contract
107
+ // (see src/commands/setup-state.js) is exit 2 for bad inputs.
108
+ setupState.on('command:*', (operands) => {
109
+ console.error(
110
+ `Error: unknown setup-state subcommand: ${operands[0]} (expected one of show, save, reset, resume-info)`
111
+ );
112
+ process.exitCode = 2;
113
+ });
114
+
104
115
  program.parse();
@@ -1,12 +1,19 @@
1
1
  import inquirer from 'inquirer';
2
2
  import * as display from '../utils/display.js';
3
3
 
4
- export async function promptHookConflict(hookCategory, existingHook, workflowHook) {
4
+ export async function promptHookConflict(hookCategory, existingHook, workflowHook, options = {}) {
5
5
  display.newline();
6
6
  display.warn(`Hook conflict: ${hookCategory} matcher "${existingHook.matcher}"`);
7
7
  display.dim(` Existing: ${existingHook.hooks[0].command}`);
8
8
  display.dim(` Workflow: ${workflowHook.hooks[0].command}`);
9
9
 
10
+ // Non-interactive: preserve the user's existing hook. Safer than "replace"
11
+ // during scripted upgrades — never silently clobbers customizations.
12
+ if (options.yes) {
13
+ display.dim(' --yes: kept existing hook (non-interactive default).');
14
+ return 'keep';
15
+ }
16
+
10
17
  const { resolution } = await inquirer.prompt([
11
18
  {
12
19
  type: 'list',
@@ -54,12 +54,27 @@ normally apply.
54
54
 
55
55
  - Shell: `worclaude scan --path .` (SCAN only)
56
56
  - Shell: `worclaude setup-state show --path .`
57
- - Shell: `worclaude setup-state save --stdin --path .` (pipe the
58
- updated state JSON via stdin — the ONLY way state is persisted)
57
+ - Shell: `worclaude setup-state save --from-file .claude/cache/setup-state.draft.json --path .`
58
+ — the ONLY way state is persisted. Use `Write` to produce the
59
+ draft JSON first, then invoke the CLI against that path. This
60
+ avoids Claude Code's shell-interpolation safety layer that
61
+ triggers on heredoc-with-variable-expansion patterns.
59
62
  - Shell: `worclaude setup-state reset --path .`
60
63
  - Shell: `worclaude setup-state resume-info --path .`
61
64
  - Read: `.claude/cache/detection-report.json`
62
65
  - Read: `.claude/cache/setup-state.json`
66
+ - Write: `.claude/cache/setup-state.draft.json` (state-save staging
67
+ only — overwritten each save).
68
+ - Tool: `AskUserQuestion` permitted at:
69
+ - INTERVIEW states, per the Interaction mode contract
70
+ (`selectable` / `multi-selectable` / `hybrid` questions).
71
+ - CONFIRM_MEDIUM when the per-item option count (candidates + 1
72
+ for "Other") is ≤ 4 — AskUserQuestion's own `maxItems: 4`
73
+ schema cap. When the count exceeds 4, fall back to the verbatim
74
+ text-parse rendering defined in State 3 below.
75
+ Not permitted at CONFIRM_HIGH: the detection list routinely has
76
+ 12+ items in real projects, which exceeds the 4-option schema cap.
77
+ CONFIRM_HIGH renders VERBATIM per rule #7.
63
78
 
64
79
  At WRITE state the whitelist RELAXES to additionally permit:
65
80
 
@@ -82,15 +97,23 @@ normally apply.
82
97
  project only. Only use information the user provides during THIS
83
98
  interview and the detection report for THIS project.
84
99
 
85
- 7. **RENDER PROMPTS VERBATIM.** Where a state specifies a prompt format
86
- with `[x] 1. ...` syntax or other structured output, render it
87
- EXACTLY as specified AND wrap it in a triple-backtick fenced code
88
- block so Markdown rendering does not reformat checkboxes or
89
- renumber lines. The format is part of the contract with the
100
+ 7. **RENDER PROMPTS VERBATIM.** Where a state specifies a text-parse
101
+ prompt format with `[x] 1. ...` syntax or other structured output,
102
+ render it EXACTLY as specified AND wrap it in a triple-backtick
103
+ fenced code block so Markdown rendering does not reformat checkboxes
104
+ or renumber lines. The format is part of the contract with the
90
105
  user-response parser — paraphrasing or reformatting breaks parsing.
91
106
  You MAY add a brief conversational sentence before or after a
92
107
  verbatim prompt, but NOT within it.
93
108
 
109
+ EXCEPTION — CONFIRM_MEDIUM via AskUserQuestion: when the per-item
110
+ option count is ≤ 4 (candidates + "Other"), State 3 uses the
111
+ `AskUserQuestion` tool path instead of the verbatim text prompt
112
+ (see rule #5 and State 3). The verbatim-rendering requirement does
113
+ not apply to tool invocations. When the count exceeds 4, the text
114
+ fallback kicks in and this rule applies as written. CONFIRM_HIGH
115
+ never uses AskUserQuestion (detection lists routinely exceed 4).
116
+
94
117
  KNOWN FAILURE MODES: reformatting `[x] 1. X: Y` as `- [x] X: Y`
95
118
  (loses numbering); paraphrasing labels; collapsing items onto one
96
119
  line; rendering outside a fenced block (Markdown may convert `[x]`
@@ -183,10 +206,27 @@ ENTRY:
183
206
  - ...
184
207
  ```
185
208
 
186
- - State file mutation: write a fresh state with `currentState: "SCAN"`,
187
- `startedAt` and `updatedAt` set to now, `detectionReportPath:
188
- ".claude/cache/detection-report.json"`, empty arrays/objects for the
189
- remaining fields. Persist via `worclaude setup-state save --stdin`.
209
+ - State file mutation: write a fresh state with EXACTLY these fields.
210
+ `schemaVersion: 1` is REQUIRED the validator rejects anything else
211
+ with `Unsupported schemaVersion: undefined`.
212
+
213
+ ```json
214
+ {
215
+ "schemaVersion": 1,
216
+ "currentState": "SCAN",
217
+ "startedAt": "<ISO timestamp, now>",
218
+ "updatedAt": "<ISO timestamp, now>",
219
+ "detectionReportPath": ".claude/cache/detection-report.json",
220
+ "highConfirmedAccepted": [],
221
+ "highConfirmedRejected": [],
222
+ "mediumResolved": {},
223
+ "interviewAnswers": {}
224
+ }
225
+ ```
226
+
227
+ Persist via `Write` → `.claude/cache/setup-state.draft.json` →
228
+ `worclaude setup-state save --from-file .claude/cache/setup-state.draft.json --path .`
229
+ (see rule #5).
190
230
 
191
231
  EXIT: advance to CONFIRM_HIGH.
192
232
 
@@ -201,21 +241,26 @@ ENTRY:
201
241
  `highConfirmedRejected: []`, and transition to CONFIRM_MEDIUM
202
242
  (trivial exit per rule #1).
203
243
  - Otherwise render VERBATIM (wrapped in a triple-backtick fenced code
204
- block):
244
+ block). Below every item add a dim "→ Will be saved as: `<target>`"
245
+ line pulled from the **Field-help table** so the user knows what
246
+ accepting means:
205
247
 
206
248
  ```
207
249
  I scanned your project. Please confirm the high-confidence
208
250
  detections below. Reply with the numbers of any items that are
209
- WRONG (e.g., "2, 5"), or reply "ok" to accept all.
251
+ WRONG (e.g., "2, 5"), or reply "ok" to accept all, or type "help".
210
252
 
211
253
  [x] 1. <formatField(item1.field)>: <renderValue(item1)> (from <item1.source>)
254
+ → Will be saved as: <target1>
212
255
  [x] 2. ...
256
+ → Will be saved as: <target2>
213
257
 
214
258
  Your response:
215
259
  ```
216
260
 
217
261
  `<formatField>` and `<renderValue>` are defined in the **Field
218
- rendering table** below.
262
+ rendering table** below. `<target>` is pulled from the **Field-help
263
+ table** (also below) and must be included verbatim per the table.
219
264
 
220
265
  Response parsing (case-insensitive, whitespace trimmed):
221
266
 
@@ -225,12 +270,20 @@ Response parsing (case-insensitive, whitespace trimmed):
225
270
  - One or more integers (comma or space separated) in range 1..N →
226
271
  those items are rejected; split fields into accepted/rejected
227
272
  accordingly (in rendered order).
273
+ - `help` → render the **Field-help** block for EACH displayed field
274
+ (description + target + example) without advancing state. Then
275
+ restate the prompt above (same text, same items). Do NOT persist a
276
+ mutation for the help render. (`?` is intentionally NOT a trigger —
277
+ Claude Code binds it to a keyboard-shortcut overlay that intercepts
278
+ the keystroke before /setup sees it.)
228
279
  - Anything else (including integers out of range) → rule #3 fires:
229
- restate with "I need either 'ok' or numbers from 1 to `<N>` matching
230
- the items above (e.g., '2, 5'). To cancel, type `cancel setup`."
280
+ restate with "I need either 'ok', numbers from 1 to `<N>` matching
281
+ the items above (e.g., '2, 5'), or type `help`. To cancel, type
282
+ `cancel setup`."
231
283
 
232
284
  State file mutation: persist the updated arrays via
233
- `worclaude setup-state save --stdin`.
285
+ `worclaude setup-state save --from-file .claude/cache/setup-state.draft.json --path .`
286
+ (see rule #5).
234
287
 
235
288
  EXIT: advance to CONFIRM_MEDIUM.
236
289
 
@@ -242,48 +295,116 @@ ENTRY:
242
295
  report.
243
296
  - If there are zero medium-confidence items: persist
244
297
  `mediumResolved: {}`, transition to INTERVIEW_STORY.
245
- - Otherwise iterate in report order. For each medium item, render ONE
246
- prompt VERBATIM in a fenced code block. The prompt shape depends on
247
- `item.candidates`:
248
-
249
- **Shape A `candidates === null`** (emitted by `readme`):
250
-
251
- ```
252
- <formatField(field)> (detected from <source>):
253
-
254
- 1. <renderValue(item)>
255
- 2. Other (I'll type my own)
256
-
257
- Reply with the number of your choice (default: 1):
258
- ```
259
-
260
- **Shape B — `candidates` is a non-empty array** (emitted by
261
- `package-manager` when multiple lockfile groups disagree):
262
-
263
- ```
264
- <formatField(field)> (detected from <source>):
265
-
266
- 1. <candidates[0]>
267
- 2. <candidates[1]>
268
- ...
269
- N. <candidates[N-1]>
270
- N+1. Other (I'll type my own)
271
-
272
- Reply with the number of your choice (default: 1):
273
- ```
274
-
275
- `candidates[0]` equals `item.value` — default-1 accepts the detected
276
- value.
277
-
278
- Response parsing (per item):
279
-
280
- - `""` | `1` | `default`accept item 1 (the detected value).
298
+ - Otherwise iterate in report order. For each medium item, compute the
299
+ total option count:
300
+ - Shape A (`candidates === null`, emitted by `readme`):
301
+ `1 + 1` = 2 (detected value + "Other").
302
+ - Shape B (`candidates` is a non-empty array): `candidates.length + 1`.
303
+ If the total is ≤ 4, use the **AskUserQuestion path** (Path 1).
304
+ Otherwise fall back to the **verbatim text-parse path** (Path 2).
305
+ The threshold is the `maxItems: 4` schema cap of AskUserQuestion.
306
+
307
+ #### Path 1 — AskUserQuestion (option count ≤ 4)
308
+
309
+ Invoke the `AskUserQuestion` tool once per medium item with exactly
310
+ this shape:
311
+
312
+ - `question`: `<formatField(field)> — detected from <source>. Which
313
+ should I use?`
314
+ - `header`: short label for the sidebar (≤ 12 chars) — typically
315
+ `formatField(field)` truncated.
316
+ - `multiSelect`: `false`
317
+ - `options`: built from the Shape above:
318
+ - Shape A: `[{ label: <renderValue(item)>, description: "Will be
319
+ saved as <target>. Accept the detected value." },
320
+ { label: "Other (I'll type my own)", description: "Supply a
321
+ custom value via free-text follow-up." }]`
322
+ - Shape B: one option per `candidates[k]` with
323
+ `description: "Will be saved as <target>."`; append
324
+ `{ label: "Other (I'll type my own)", description: "Supply a
325
+ custom value via free-text follow-up." }` as the final option.
326
+
327
+ `<target>` comes from the **Field-help table** below. Render it
328
+ verbatim per the table.
329
+
330
+ On response:
331
+ - User selects a candidate label → store that label string in
332
+ `mediumResolved[field]` (Storage rule applies).
333
+ - User selects "Other (I'll type my own)"follow up with a free-text
334
+ prompt: "Go ahead — what's the value you'd like to use?". Store the
335
+ trimmed reply.
336
+
337
+ `AskUserQuestion` does not expose a `help` trigger; the per-option
338
+ `description` text carries the equivalent content inline. The text
339
+ fallback's `help` keyword is scoped to Path 2 only.
340
+
341
+ #### Path 2 — Verbatim text prompt (option count > 4)
342
+
343
+ Render ONE prompt VERBATIM in a fenced code block. The prompt shape
344
+ depends on `item.candidates`:
345
+
346
+ **Shape A — `candidates === null`** (rare in this path — only 2
347
+ options, typically handled by Path 1 above; included here for
348
+ completeness in case AskUserQuestion is unavailable):
349
+
350
+ ```
351
+ <formatField(field)> (detected from <source>):
352
+
353
+ 1. <renderValue(item)>
354
+ → Will be saved as: <target>
355
+ 2. Other (I'll type my own)
356
+
357
+ Reply with the number of your choice (default: 1), or type `help`:
358
+ ```
359
+
360
+ **Shape B — `candidates` is a non-empty array** (e.g.,
361
+ `package-manager` when multiple lockfile groups disagree and produce
362
+ 4+ candidates):
363
+
364
+ ```
365
+ <formatField(field)> (detected from <source>):
366
+ → Will be saved as: <target>
367
+
368
+ 1. <candidates[0]>
369
+ 2. <candidates[1]>
370
+ ...
371
+ N. <candidates[N-1]>
372
+ N+1. Other (I'll type my own)
373
+
374
+ Reply with the number of your choice (default: 1), or type `help`:
375
+ ```
376
+
377
+ `<target>` comes from the **Field-help table** below. Render it
378
+ verbatim per the table. `candidates[0]` equals `item.value` —
379
+ default-1 accepts the detected value.
380
+
381
+ Response parsing (Path 2 only):
382
+
383
+ - `""` | `1` | `default` → accept item 1; store `renderValue(item)`
384
+ as a string per the Storage rule.
281
385
  - The final "Other" number (`2` in shape A, `N+1` in shape B) →
282
386
  follow-up free-text prompt: "Go ahead — what's the value you'd like
283
- to use?". Record the trimmed reply as the answer.
284
- - Integer in range `2..N` (shape B only) → accept `candidates[k-1]`.
285
- - Anything else restate with "I need a number from 1 to `<max>`, or
286
- empty for the default. To cancel, type `cancel setup`."
387
+ to use?". Store the trimmed reply.
388
+ - Integer in range `2..N` (shape B only) → store `candidates[k-1]`.
389
+ - `help`render the **Field-help** block for this field
390
+ (description + target + example) without advancing state. Then
391
+ restate the prompt above. Do NOT persist a mutation for the help
392
+ render. (`?` is intentionally NOT a trigger — Claude Code binds it
393
+ to a keyboard-shortcut overlay that intercepts the keystroke.)
394
+ - Anything else → restate with "I need a number from 1 to `<max>`,
395
+ empty for the default, or type `help`. To cancel, type
396
+ `cancel setup`."
397
+
398
+ #### Storage rule (both paths)
399
+
400
+ `mediumResolved[field]` MUST be a string. Store the exact label that
401
+ was shown to the user (Path 1: `AskUserQuestion` `label`; Path 2:
402
+ `renderValue(item)` or `candidates[k-1]`), or the user's trimmed
403
+ free-text on "Other". **NEVER store the raw `item.value` object** —
404
+ for fields like `readme` whose detected value is an object
405
+ (`{projectDescription, setupInstructions, fullPath}`) the validator
406
+ will reject the mutation with `state.mediumResolved.<field> must be a
407
+ string`.
287
408
 
288
409
  State file mutation: after EACH item is resolved (not batched),
289
410
  append to `mediumResolved` and persist.
@@ -298,14 +419,49 @@ Shared ENTRY protocol for each INTERVIEW state:
298
419
  **QuestionId enumeration** below plus any rejected fields routed to
299
420
  this state from CONFIRM_HIGH (per the **Rejected-field re-ask
300
421
  routing** table).
301
- - Skip any `questionId` already present in `interviewAnswers`.
422
+ - Apply the **Detection-skip matrix** (below the enumeration): for
423
+ each `questionId` whose skip-field is present in
424
+ `highConfirmedAccepted`, record
425
+ `interviewAnswers[<questionId>] = "[auto-filled from <field>]"` and
426
+ persist (same Storage rule applies — the value is always a string)
427
+ BEFORE evaluating the already-answered skip-list.
428
+ - Skip any `questionId` already present in `interviewAnswers`
429
+ (including auto-filled ones).
302
430
  - Resume preamble (only if ANY questionId is already answered AND at
303
431
  least one remains): "Resuming `<STATE_NAME>`. Already have:
304
432
  `<comma-list>`. Next: `<next questionId>`."
305
433
  - If ALL `questionId`s for this state are present, trivially-exit:
306
434
  persist a state update (only `currentState` and `updatedAt` change)
307
435
  and advance.
308
- - Ask remaining questions conversationally, in enumeration order.
436
+ - Ask remaining questions in enumeration order. For each question,
437
+ look up its `interactionMode` in the **Per-question interaction
438
+ table** below and use the matching tool:
439
+ - `selectable` / `multi-selectable` → `AskUserQuestion`
440
+ - `hybrid` → pre-fill from the listed detection source, then offer
441
+ accept / edit / replace
442
+ - `free-text` → ordinary text prompt
443
+ The Storage rule (`interviewAnswers[<questionId>]` must be a string)
444
+ applies uniformly — no object shapes regardless of mode.
445
+ - **Reply classification** — before recording a reply as
446
+ `interviewAnswers[<questionId>]`, classify it:
447
+ - **Answer**: a response that plausibly fits the semantic scope of
448
+ `<questionId>` (e.g., `arch.classification` expects one of the
449
+ enum values or a short phrase about system shape; `story.audience`
450
+ expects a description of people/roles). Record and advance.
451
+ - **Skip trigger**: `skip` or `skip all` — rules below apply.
452
+ - **Cancel trigger**: matches rule #4's regex — rule #4 applies.
453
+ - **Back trigger**: starts with `back` — rule below applies.
454
+ - **Everything else → OFF-TOPIC.** Apply rule #3: restate the
455
+ pending question with the off-topic prefix. You MUST NOT record
456
+ the reply in `interviewAnswers`. You MUST NOT advance
457
+ `currentState` or the question pointer. Do NOT persist a mutation
458
+ for this exchange. A topic-mismatched reply is not an answer just
459
+ because it is a sentence — if the reply would land in a different
460
+ section of SPEC.md than the one tied to this `<questionId>`, it is
461
+ off-topic.
462
+
463
+ Prefer off-topic when uncertain. An unnecessary restate costs one
464
+ turn; a mis-filed answer corrupts the state file.
309
465
  - `skip` on a question → record `interviewAnswers[<questionId>] =
310
466
  "[skipped]"`, advance to the next question in this state.
311
467
  - `skip all` → record every remaining `questionId` as `[skipped]`,
@@ -313,13 +469,11 @@ Shared ENTRY protocol for each INTERVIEW state:
313
469
  - `back` → restate the current question with the prefix "I can't go
314
470
  back within a single setup run. Finish this run and edit the output
315
471
  files afterward." (rule #2).
316
- - Rule #3 applies within interview states: off-topic replies trigger
317
- a restatement of the pending question, not an answer to the
318
- off-topic question.
319
472
 
320
473
  State file mutation: after EACH question is answered or skipped,
321
- persist via `worclaude setup-state save --stdin` BEFORE rendering the
322
- next prompt. Resume granularity is per-question.
474
+ persist via `worclaude setup-state save --from-file .claude/cache/setup-state.draft.json --path .`
475
+ (see rule #5) BEFORE rendering the next prompt. Resume granularity is
476
+ per-question.
323
477
 
324
478
  EXIT: advance to the next state; INTERVIEW_VERIFICATION exits to
325
479
  WRITE.
@@ -430,6 +584,78 @@ detection):
430
584
  - `verification.staging` — staging/preview environment.
431
585
  - `verification.required_checks` — CI required checks.
432
586
 
587
+ ### Interaction mode
588
+
589
+ Each interview question is asked in ONE of four modes. The mode lives
590
+ next to the question in the table below. This controls which tool you
591
+ use to collect the answer — text input, menu, checklist, or hybrid.
592
+
593
+ - `selectable` — invoke the `AskUserQuestion` tool with the listed
594
+ choices, `multiSelect: false`. The user gets arrow-key navigation.
595
+ If the user picks "Other (I'll type my own)", follow up with a
596
+ free-text prompt: "Go ahead — what's the value you'd like to use?".
597
+ - `multi-selectable` — invoke `AskUserQuestion` with `multiSelect:
598
+ true` and the listed choices. Always prepend `None` as the first
599
+ choice and append "Other (I'll type my own)" as the last. On
600
+ "Other", follow up with free-text. The stored value joins selections
601
+ with `, ` (e.g. `"REST, GraphQL"`). Selecting `None` alone stores
602
+ `"none"`.
603
+ - `hybrid` — pre-fill a bullet list from detection (see the question
604
+ row for which detection field feeds it) and ask: "I pre-filled this
605
+ from your README/scan. Accept, edit, or replace?". `accept` stores
606
+ the pre-filled text verbatim; `edit` offers free-text with the
607
+ pre-fill visible; `replace` starts from empty.
608
+ - `free-text` — ordinary text prompt (default). No `AskUserQuestion`.
609
+
610
+ Regardless of mode, `interviewAnswers[<questionId>]` MUST be a string
611
+ per the Storage rule. For `multi-selectable`, join with `, `. For
612
+ `hybrid`, store the final accepted or edited text.
613
+
614
+ **Fallback.** If `AskUserQuestion` is unavailable in the current
615
+ Claude Code version (or the tool call fails), degrade to a
616
+ numbered-list free-text prompt using CONFIRM_HIGH-style parsing:
617
+
618
+ ```
619
+ <question-label>:
620
+
621
+ 1. <choice 1>
622
+ 2. <choice 2>
623
+ ...
624
+ N+1. Other (I'll type my own)
625
+
626
+ Reply with the number of your choice, or type your own answer:
627
+ ```
628
+
629
+ ### Per-question interaction table
630
+
631
+ | questionId | Mode | Choices (or pre-fill source) |
632
+ | ------------------------------ | ---------------- | ------------------------------------------------------------------------- |
633
+ | `story.audience` | free-text | — |
634
+ | `story.problem` | free-text | — |
635
+ | `story.analogs` | free-text | — |
636
+ | `arch.classification` | selectable | monolith, modular monolith, microservices, serverless, library, CLI, other |
637
+ | `arch.modules` | free-text | — |
638
+ | `arch.entities` | free-text | — |
639
+ | `arch.external_apis` | multi-selectable | `<detected externalApis>` + None + Other |
640
+ | `arch.stack_rationale` | free-text | — |
641
+ | `features.core` | hybrid | pre-fill from readme bullets (projectDescription / headings) |
642
+ | `features.nice_to_have` | hybrid | pre-fill from readme bullets |
643
+ | `features.non_goals` | hybrid | empty pre-fill (always edit from scratch unless user picks `accept`) |
644
+ | `workflow.new_dev_steps` | free-text | — |
645
+ | `workflow.env_values` | free-text | — |
646
+ | `conventions.patterns` | free-text | — |
647
+ | `conventions.errors` | selectable | throw, Result<T,E>, null, silent catch, mixed, other |
648
+ | `conventions.logging` | selectable | console, structured JSON, dedicated logger, none, other |
649
+ | `conventions.api_format` | selectable | REST, GraphQL, gRPC, none, other |
650
+ | `conventions.naming` | free-text | — |
651
+ | `conventions.rules` | free-text | — |
652
+ | `verification.manual` | free-text | — |
653
+ | `verification.staging` | selectable | yes, no |
654
+ | `verification.required_checks` | multi-selectable | `<detected ci.workflows>` + None + Other |
655
+
656
+ 10 non-default entries: 5 `selectable`, 2 `multi-selectable`, 3
657
+ `hybrid`. The other 12 questions stay `free-text`.
658
+
433
659
  ### Rejected-field re-ask routing
434
660
 
435
661
  Fields in `highConfirmedRejected` are re-asked as one sub-question
@@ -450,6 +676,94 @@ enumeration that `saveSetupState` accepts, matched by prefix. The
450
676
  `<field>` segment must exactly match a known detector field name AND
451
677
  the routing table must map that field to that state prefix.
452
678
 
679
+ ### Detection-skip matrix
680
+
681
+ A question in this table is **auto-skipped** when the listed detection
682
+ field is in `highConfirmedAccepted` (i.e., accepted at CONFIRM_HIGH
683
+ and not rejected). Record the skipped answer in `interviewAnswers` as
684
+ `"[auto-filled from <field>]"` BEFORE evaluating the already-answered
685
+ skip-list. This prevents the interview from re-asking questions the
686
+ scanner already answered.
687
+
688
+ | questionId | Skip when in `highConfirmedAccepted` | Notes |
689
+ | ----------------------- | ---------------------------------------------- | ---------------------------------------- |
690
+ | `story.problem` | `readme` (medium) with non-empty description | README already describes the problem. |
691
+ | `arch.classification` | `monorepo` (high) | Pre-fill `"monorepo"`. |
692
+ | `arch.external_apis` | `externalApis` (high) | Direct mapping. |
693
+ | `workflow.new_dev_steps`| `scripts` (high) AND `readme` (medium) | Both must be present. |
694
+
695
+ All OTHER `questionId`s are always asked — the scanner cannot infer
696
+ them (audience, rationale, conventions, features, etc. are user
697
+ knowledge the detector has no access to).
698
+
699
+ ### Field-help table
700
+
701
+ Drives the `help` command (CONFIRM_HIGH, CONFIRM_MEDIUM, and
702
+ INTERVIEW states) AND the "→ Will be saved as: `<target>`" line on
703
+ every CONFIRM prompt. Keep the `<target>` column stable — parsers
704
+ downstream don't match on it, but users read it for orientation.
705
+
706
+ Detection fields (used at CONFIRM_HIGH and CONFIRM_MEDIUM):
707
+
708
+ | Field | Plain-English description | `<target>` (where accepting lands) | Example answer |
709
+ | ---------------- | ------------------------------------------------------------- | ----------------------------------------------------------- | ----------------------------------------- |
710
+ | `ci` | Which CI provider + workflow files run on push/PR | `## Verification` section of `docs/spec/SPEC.md` | `GitHub Actions, 2 workflows` |
711
+ | `deployment` | Where the app gets deployed | `## Architecture` section of `docs/spec/SPEC.md` | `Vercel` |
712
+ | `envVariables` | Names of env variables the project reads | `## Workflow` section of `docs/spec/SPEC.md` | `DATABASE_URL, STRIPE_SECRET_KEY` |
713
+ | `externalApis` | Third-party APIs / SDKs the project integrates with | `backend-conventions/SKILL.md` External APIs section | `Stripe, Sentry` |
714
+ | `frameworks` | Application frameworks the project uses | `## Tech Stack` section of `CLAUDE.md` | `Next.js 14.2.3, React 18.2.0` |
715
+ | `language` | Primary source language | `## Tech Stack` section of `CLAUDE.md` | `TypeScript` |
716
+ | `linting` | Linters + formatters the project runs | `## Tech Stack` section of `CLAUDE.md` | `ESLint, Prettier` |
717
+ | `monorepo` | Whether the repo is a monorepo + which tool | `## Architecture` section of `docs/spec/SPEC.md` | `pnpm (3 packages)` |
718
+ | `orm` | Database ORM / schema driver | `## Tech Stack` of `CLAUDE.md` + backend-conventions/SKILL | `Prisma` |
719
+ | `packageManager` | How you install dependencies | `## Tech Stack` of `CLAUDE.md` + `## Commands` | `pnpm` |
720
+ | `readme` | Project description pulled from `README.md` | `## Overview` section of `docs/spec/SPEC.md` | a paragraph of text |
721
+ | `scripts` | dev / test / build / lint script names | `## Commands` section of `CLAUDE.md` | `dev=dev test=test build=build lint=lint` |
722
+ | `specDocs` | Spec / design docs already present in the repo | `## Overview` of `docs/spec/SPEC.md` (referenced, not copied) | `2 docs` |
723
+ | `testing` | Test framework + config file | `## Verification` of `docs/spec/SPEC.md` | `vitest (vitest.config.ts)` |
724
+
725
+ Interview questions (used at INTERVIEW states):
726
+
727
+ | `questionId` | Plain-English description | `<target>` | Example answer |
728
+ | ------------------------------ | --------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------- |
729
+ | `story.audience` | Who is this project for? | `## Overview` of `docs/spec/SPEC.md` | `internal developers maintaining our CLI` |
730
+ | `story.problem` | What problem does it solve? | `## Overview` of `docs/spec/SPEC.md` | `scaffolds a worclaude workflow into any repo` |
731
+ | `story.analogs` | Similar products / projects you're modeling after | `## Overview` of `docs/spec/SPEC.md` | `create-react-app, but for Claude workflows` |
732
+ | `arch.classification` | System shape: monolith / services / library / CLI / etc. | `## Architecture` of `docs/spec/SPEC.md` | `modular monolith` |
733
+ | `arch.modules` | Directory purposes and in-house packages | `## Architecture` of `docs/spec/SPEC.md` | `src/commands — CLI entry points` |
734
+ | `arch.entities` | Core database entities (if applicable) | `## Architecture` of `docs/spec/SPEC.md` | `User, Project, Session` |
735
+ | `arch.external_apis` | External APIs beyond SDK detection | `## Architecture` of `docs/spec/SPEC.md` | `Stripe, Sentry` |
736
+ | `arch.stack_rationale` | Why these framework/stack choices | `## Architecture` of `docs/spec/SPEC.md` | `pnpm for workspace perf` |
737
+ | `features.core` | Must-have features | `## Features` of `docs/spec/SPEC.md` | `init / upgrade / doctor / scan` |
738
+ | `features.nice_to_have` | Nice-to-have features | `## Features` of `docs/spec/SPEC.md` | `plugin.json generation` |
739
+ | `features.non_goals` | Explicit non-goals | `## Features` of `docs/spec/SPEC.md` | `no Windows-specific path handling` |
740
+ | `workflow.new_dev_steps` | Dev setup steps beyond README | `## Workflow` of `docs/spec/SPEC.md` | `copy .env.example → .env then fill secrets` |
741
+ | `workflow.env_values` | Guidance for env var values | `## Workflow` of `docs/spec/SPEC.md` | `DATABASE_URL: local Postgres on :5432` |
742
+ | `conventions.patterns` | Code patterns the project uses | `project-patterns/SKILL.md` | `Result<T, E> for fallible ops` |
743
+ | `conventions.errors` | Error-handling approach | `backend-conventions/SKILL.md` | `throw / Result<T,E> / silent catch` |
744
+ | `conventions.logging` | Logging approach | `backend-conventions/SKILL.md` | `pino structured logger` |
745
+ | `conventions.api_format` | API response shape | `backend-conventions/SKILL.md` | `REST JSON: {data, error}` |
746
+ | `conventions.naming` | Naming conventions (vars, files, branches) | `project-patterns/SKILL.md` | `camelCase TS / snake_case Python / kebab branches` |
747
+ | `conventions.rules` | Never / always project rules | `project-patterns/SKILL.md` | `never push to main` |
748
+ | `verification.manual` | Manual verification steps | `## Verification` of `docs/spec/SPEC.md` | `run /init against tmp/ and eyeball output` |
749
+ | `verification.staging` | Whether there's a staging / preview env | `## Verification` of `docs/spec/SPEC.md` | `yes — Vercel preview per PR` |
750
+ | `verification.required_checks` | CI required checks gating merge | `## Verification` of `docs/spec/SPEC.md` | `tests, lint, type-check` |
751
+
752
+ Help-render format when the user types `help` at a CONFIRM or INTERVIEW
753
+ prompt — render in a fenced code block:
754
+
755
+ ```
756
+ Help — <formatField(field) or questionId>
757
+
758
+ What it is: <description>
759
+ Will be saved as: <target>
760
+ Example answer: <example>
761
+ ```
762
+
763
+ For CONFIRM_HIGH (multiple fields on one prompt), render one Help
764
+ block per item. After rendering help, restate the original prompt
765
+ verbatim without advancing.
766
+
453
767
  ---
454
768
 
455
769
  ## Field rendering table
@@ -470,7 +784,7 @@ CONFIRM_HIGH and CONFIRM_MEDIUM to render `<renderValue(item)>`.
470
784
  | `scripts` | `{dev, test, build, lint, ...}` | `dev=<dev.key> test=<test.key> build=<build.key> lint=<lint.key>` — omit null slots; all null → `(no standard scripts)` |
471
785
  | `envVariables` | `{names: string[], inferredServices: [...]}` | `<N> variable(s)` (singular when `N === 1`; 0 → omit) |
472
786
  | `externalApis` | `string[]` | joined by `, ` (empty → omit) |
473
- | `readme` | `{projectDescription, ...}` | `<projectDescription>` truncated to 80 chars with `…` suffix |
787
+ | `readme` | `{projectDescription, ...}` | `<projectDescription>` rendered verbatim; soft-wrap at 100 chars per line for display only (no `…` truncation — user needs the full text to decide whether to accept) |
474
788
  | `specDocs` | `[{path, firstHeading}, ...]` | `<N> doc(s)` (empty → omit) |
475
789
  | `monorepo` | `{tool, packagePaths, ...}` | `<tool> (<N> packages)` |
476
790
  | fallback scalar | string / number / boolean | `String(value)` |
@@ -14,6 +14,11 @@
14
14
  "Bash(git:*)",
15
15
  "Bash(gh:*)",
16
16
 
17
+ "// -- Worclaude Self --",
18
+ "Bash(worclaude:*)",
19
+ "Bash(worclaude scan:*)",
20
+ "Bash(worclaude setup-state:*)",
21
+
17
22
  "// -- Common Dev Tools --",
18
23
  "Bash(echo:*)", "Bash(mkdir:*)", "Bash(touch:*)",
19
24
  "Bash(cp:*)", "Bash(mv:*)",