autonomous-sdlc-harness 0.1.0

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.
Files changed (171) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +7 -0
  3. package/README.md +24 -0
  4. package/dist/cli.js +194 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/config.js +561 -0
  7. package/dist/commands/config.js.map +1 -0
  8. package/dist/commands/daemon.js +791 -0
  9. package/dist/commands/daemon.js.map +1 -0
  10. package/dist/commands/doctor.js +336 -0
  11. package/dist/commands/doctor.js.map +1 -0
  12. package/dist/commands/init.js +2023 -0
  13. package/dist/commands/init.js.map +1 -0
  14. package/dist/commands/registry.js +42 -0
  15. package/dist/commands/registry.js.map +1 -0
  16. package/dist/config/check.js +505 -0
  17. package/dist/config/check.js.map +1 -0
  18. package/dist/config/io.js +177 -0
  19. package/dist/config/io.js.map +1 -0
  20. package/dist/config/model.js +406 -0
  21. package/dist/config/model.js.map +1 -0
  22. package/dist/core/errors.js +71 -0
  23. package/dist/core/errors.js.map +1 -0
  24. package/dist/core/git.js +537 -0
  25. package/dist/core/git.js.map +1 -0
  26. package/dist/core/json.js +125 -0
  27. package/dist/core/json.js.map +1 -0
  28. package/dist/core/layerCoverage.js +141 -0
  29. package/dist/core/layerCoverage.js.map +1 -0
  30. package/dist/core/layerGapRemedy.js +62 -0
  31. package/dist/core/layerGapRemedy.js.map +1 -0
  32. package/dist/core/nameList.js +23 -0
  33. package/dist/core/nameList.js.map +1 -0
  34. package/dist/core/paths.js +153 -0
  35. package/dist/core/paths.js.map +1 -0
  36. package/dist/core/prompt.js +206 -0
  37. package/dist/core/prompt.js.map +1 -0
  38. package/dist/core/repoPaths.js +55 -0
  39. package/dist/core/repoPaths.js.map +1 -0
  40. package/dist/core/report.js +150 -0
  41. package/dist/core/report.js.map +1 -0
  42. package/dist/core/templating.js +88 -0
  43. package/dist/core/templating.js.map +1 -0
  44. package/dist/core/writer.js +479 -0
  45. package/dist/core/writer.js.map +1 -0
  46. package/dist/daemon/backend.js +180 -0
  47. package/dist/daemon/backend.js.map +1 -0
  48. package/dist/daemon/units.js +380 -0
  49. package/dist/daemon/units.js.map +1 -0
  50. package/dist/detect/nestedApplication.js +79 -0
  51. package/dist/detect/nestedApplication.js.map +1 -0
  52. package/dist/detect/presets.js +2033 -0
  53. package/dist/detect/presets.js.map +1 -0
  54. package/dist/detect/signals.js +1368 -0
  55. package/dist/detect/signals.js.map +1 -0
  56. package/dist/doctor/checks.js +3530 -0
  57. package/dist/doctor/checks.js.map +1 -0
  58. package/dist/generators/claudeContext.js +588 -0
  59. package/dist/generators/claudeContext.js.map +1 -0
  60. package/dist/generators/githooks.js +446 -0
  61. package/dist/generators/githooks.js.map +1 -0
  62. package/dist/generators/harnessConfig.js +632 -0
  63. package/dist/generators/harnessConfig.js.map +1 -0
  64. package/dist/generators/notifications.js +191 -0
  65. package/dist/generators/notifications.js.map +1 -0
  66. package/dist/generators/outerLoopScripts.js +165 -0
  67. package/dist/generators/outerLoopScripts.js.map +1 -0
  68. package/dist/generators/permissionProfile.js +1172 -0
  69. package/dist/generators/permissionProfile.js.map +1 -0
  70. package/dist/generators/projectSettings.js +322 -0
  71. package/dist/generators/projectSettings.js.map +1 -0
  72. package/dist/generators/repoRoot.js +417 -0
  73. package/dist/generators/repoRoot.js.map +1 -0
  74. package/dist/generators/scripts.js +557 -0
  75. package/dist/generators/scripts.js.map +1 -0
  76. package/dist/generators/stateDir.js +221 -0
  77. package/dist/generators/stateDir.js.map +1 -0
  78. package/dist/machine/paths.js +111 -0
  79. package/dist/machine/paths.js.map +1 -0
  80. package/dist/machine/plugins.js +224 -0
  81. package/dist/machine/plugins.js.map +1 -0
  82. package/dist/machine/registry.js +330 -0
  83. package/dist/machine/registry.js.map +1 -0
  84. package/package.json +23 -0
  85. package/scripts/README.md +13 -0
  86. package/scripts/daemon/launchd.plist.template +59 -0
  87. package/scripts/daemon/systemd.service.template +58 -0
  88. package/templates/README.md +15 -0
  89. package/templates/claude/CLAUDE.md +54 -0
  90. package/templates/claude/README.md +5 -0
  91. package/templates/claude/context/api.md +29 -0
  92. package/templates/claude/context/conventions.md +23 -0
  93. package/templates/claude/context/data-layer.md +28 -0
  94. package/templates/claude/context/data-storage.md +29 -0
  95. package/templates/claude/context/docs-catalog.md +29 -0
  96. package/templates/claude/context/domain.md +28 -0
  97. package/templates/claude/context/layer.md +20 -0
  98. package/templates/claude/context/module.md +30 -0
  99. package/templates/claude/context/package.md +29 -0
  100. package/templates/claude/context/presentation.md +32 -0
  101. package/templates/claude/context/state-slices.md +28 -0
  102. package/templates/claude/context/tests.md +28 -0
  103. package/templates/claude/harness-task-offer.md +58 -0
  104. package/templates/claude/push-notify.env.example +21 -0
  105. package/templates/claude/qa-accounts.env.example +38 -0
  106. package/templates/claude/qa_test_scenarios.md +110 -0
  107. package/templates/claude/settings.autonomous.json +93 -0
  108. package/templates/claude/settings.autonomous.qa.json +36 -0
  109. package/templates/githooks/README.md +3 -0
  110. package/templates/githooks/pre-push +72 -0
  111. package/templates/repo/README.md +3 -0
  112. package/templates/repo/gitattributes +16 -0
  113. package/templates/repo/gitignore +61 -0
  114. package/templates/repo/gitignore.qa +25 -0
  115. package/templates/repo/mcp.json +17 -0
  116. package/templates/scripts/README.md +5 -0
  117. package/templates/scripts/autonomous-format-stream.sh +95 -0
  118. package/templates/scripts/autonomous-notify.sh +337 -0
  119. package/templates/scripts/autonomous-watcher.sh +3087 -0
  120. package/templates/scripts/cleanup-merged-worktrees.sh +327 -0
  121. package/templates/scripts/commit-on-branch.sh +288 -0
  122. package/templates/scripts/create-worktree.sh +360 -0
  123. package/templates/scripts/deploy.sh +47 -0
  124. package/templates/scripts/lib/harness-run-lib.sh +1481 -0
  125. package/templates/scripts/push-branch.sh +140 -0
  126. package/templates/scripts/refresh-branch.sh +244 -0
  127. package/templates/scripts/restart-watcher.sh +401 -0
  128. package/templates/scripts/scratch-run.sh +302 -0
  129. package/templates/scripts/setup-worktree.sh +262 -0
  130. package/templates/scripts/start-dev-server.sh +99 -0
  131. package/templates/scripts/test.sh +50 -0
  132. package/templates/scripts/typecheck.sh +50 -0
  133. package/templates/state-dir/README-root.md +13 -0
  134. package/templates/state-dir/README.md +9 -0
  135. package/templates/state-dir/architecture_branch_review_point_reviews/README.md +9 -0
  136. package/templates/state-dir/architecture_branch_reviews/README.md +9 -0
  137. package/templates/state-dir/architecture_reviews/README.md +9 -0
  138. package/templates/state-dir/architecture_user_review_reviews/README.md +9 -0
  139. package/templates/state-dir/autonomous_inbox/README.md +9 -0
  140. package/templates/state-dir/autonomous_logs/README.md +9 -0
  141. package/templates/state-dir/branch_statistics/README.md +9 -0
  142. package/templates/state-dir/business_parity_branch_review_point_reviews/README.md +9 -0
  143. package/templates/state-dir/business_parity_branch_reviews/README.md +9 -0
  144. package/templates/state-dir/business_parity_reviews/README.md +9 -0
  145. package/templates/state-dir/business_parity_user_review_reviews/README.md +9 -0
  146. package/templates/state-dir/clarification_digests/README.md +9 -0
  147. package/templates/state-dir/clarifications/README.md +9 -0
  148. package/templates/state-dir/code_reviews/README.md +9 -0
  149. package/templates/state-dir/dispatch_additions/README.md +19 -0
  150. package/templates/state-dir/docs_catalog/README.md +9 -0
  151. package/templates/state-dir/flow_progress/README.md +9 -0
  152. package/templates/state-dir/improvement_observations/README.md +19 -0
  153. package/templates/state-dir/improvement_suggestions.md +29 -0
  154. package/templates/state-dir/lessons.md +23 -0
  155. package/templates/state-dir/qa_review_point_reviews/README.md +9 -0
  156. package/templates/state-dir/qa_reviews/README.md +9 -0
  157. package/templates/state-dir/review_plan_point_reviews/README.md +9 -0
  158. package/templates/state-dir/review_plan_reviews/README.md +9 -0
  159. package/templates/state-dir/scratch/README.md +11 -0
  160. package/templates/state-dir/skeptic_review_plan_reviews/README.md +9 -0
  161. package/templates/state-dir/skeptic_review_point_reviews/README.md +9 -0
  162. package/templates/state-dir/skeptic_reviews/README.md +9 -0
  163. package/templates/state-dir/story_plans/README.md +9 -0
  164. package/templates/state-dir/task_plan_point_reviews/README.md +9 -0
  165. package/templates/state-dir/task_plan_reviews/README.md +9 -0
  166. package/templates/state-dir/task_plans/README.md +9 -0
  167. package/templates/state-dir/task_prompts/README.md +9 -0
  168. package/templates/state-dir/ui_test_plan_reviews/README.md +9 -0
  169. package/templates/state-dir/ui_test_plans/README.md +9 -0
  170. package/templates/state-dir/user_review_fix_plan_point_reviews/README.md +9 -0
  171. package/templates/state-dir/user_reviews/README.md +9 -0
@@ -0,0 +1,2023 @@
1
+ /**
2
+ * Command: `init` — wire an adopting repository, once, deterministically.
3
+ *
4
+ * **The rule this module exists to enforce: `init` orders and reports; it decides nothing.** Every
5
+ * value written here comes from stack detection or from a generator, and every byte reaches the
6
+ * filesystem through one {@link WritePlan} applied once at the end. That is what makes the command
7
+ * reproducible: a second `init` over the same repository produces the same result, and an adopter can
8
+ * re-derive the recorded preset from the detection table by hand. It is also why judgement-dependent
9
+ * work — reading real code to refine a layer profile, filling a conventions stub — belongs to
10
+ * `/harness-analyze` and is deliberately absent from this file. The offer to run it
11
+ * ({@link resolveAnalyzeOffer}) adds no model call and no external process here: the answer's whole
12
+ * material effect is which banner wording the generated always-loaded file carries and which closing
13
+ * pointer this run prints.
14
+ *
15
+ * ## Three non-obvious choices, and where each comes from
16
+ *
17
+ * 1. **The whole run is one plan, applied after every generator has spoken.** A generator enqueues
18
+ * and returns; no generator touches the filesystem. So `--dry-run` cannot leak a write no matter
19
+ * what a generator enqueued, and a refusal raised against the last request cannot leave the first
20
+ * ten on disk — the write engine checks the whole plan before committing any of it
21
+ * (`core/writer.ts`). **Three mutations sit outside that plan**, and each is confined by a
22
+ * condition of its own rather than by a shared one: `initRepository` before the plan is built
23
+ * runs only where there is no repository yet ({@link resolveTargetRepository}); `pointHooksPath`
24
+ * after it has been applied writes `core.hooksPath` only where that setting is unset
25
+ * (`generators/githooks.ts`); and `commitAll`, after it as well, runs only in a repository with
26
+ * no commit ({@link commitGeneratedFiles}) — so none of them can reach an established history or
27
+ * an adopter's own hooks directory. So `--dry-run`'s "cannot leak a write" guarantee no longer
28
+ * rests on the plan's structure alone — it rests on that structure **plus one explicit `dryRun`
29
+ * guard at each of those three calls**, which is three guards to check rather than a structural
30
+ * argument that covers everything. The commit's position is load-bearing for a second reason: the
31
+ * managed `.gitignore` block arrives with the plan, and it is what keeps the commit's `git add -A`
32
+ * from staging the run-control artifacts — so a commit made before `plan.apply` would commit
33
+ * exactly the files that must never be committed (`generators/repoRoot.ts`).
34
+ * 2. **The config generator runs first and hands back the config *in effect*, and every later
35
+ * generator reads that object rather than the flags.** Whenever a config file is there it is
36
+ * kept and read, on every run and `--force` included, so the config in effect is the adopter's
37
+ * own — which is the only way `init` writes the state tree, the profile and the hook against the
38
+ * values the adopter edited rather than against the ones `init` would have chosen again. Only
39
+ * `--reset-config` rebuilds it, and it says so (`generators/harnessConfig.ts`).
40
+ * 3. **The scripts are written immediately after the config and before the profile** — the wrappers
41
+ * and then the outer-loop set, both into the configured `scriptsDir`. The permission profile
42
+ * allow-lists their literal paths, so a run that wrote the profile without them would allow-list
43
+ * files that do not exist, leaving the configured verification commands matching neither `allow`
44
+ * nor `deny` — which in an unattended run is a stall rather than a refusal
45
+ * (`generators/scripts.ts`, `generators/outerLoopScripts.ts`, `generators/permissionProfile.ts`).
46
+ *
47
+ * ## The refusals, and why they come before the plan is built
48
+ *
49
+ * `init` is a writer aimed at a repository, so a mis-scoped run is destructive rather than merely
50
+ * wrong. All three preconditions are **settled** before a single generator is called — two by a
51
+ * check the flags alone answer, the third by an answer (the flag, a prompt on a terminal, or the
52
+ * non-interactive default) — and that is the property the ordering exists for: a plan that is never
53
+ * built cannot be half-applied.
54
+ *
55
+ * They are ordered by **what each one needs**, and the git gate is what makes that ordering
56
+ * load-bearing rather than cosmetic: its accept path creates a repository, which is one of the three
57
+ * mutations outside the plan. So every refusal answerable from the parsed flags is raised ahead of
58
+ * it, and a refused run leaves the adopter's directory exactly as it found it. The same reasoning
59
+ * puts {@link parseQaDriver} — and the refusal of a command line giving both spellings of the
60
+ * analyze offer — inside the parser, one step earlier still.
61
+ *
62
+ * - **`--state-dir` names a dot-directory** — an unattended run may not be permitted to write
63
+ * beneath one, so the tree would be created and then silently lose every artifact produced under
64
+ * it (`docs/config.md` §3).
65
+ * Answerable from the flag, so it is raised before the git gate — as is `--preset`'s closed-set
66
+ * check (`detect/signals.ts`), the flag surface's other pure-argument refusal;
67
+ * - **not inside a git repository, and the run was not told to create one** — the root every write
68
+ * is confined to cannot be resolved (`core/git.ts`), and the worktree-based flow this wires cannot
69
+ * prepare a checkout without one. Git stays a hard gate and nothing is ever created silently:
70
+ * `--git-init` creates the repository, a terminal without the flag is asked, and a run that cannot
71
+ * be asked refuses — which is the behaviour every earlier release had (`core/prompt.ts`,
72
+ * `docs/cli.md` §2);
73
+ * - **the resolved root is the harness's own repository** — `init` at this repository's root would
74
+ * wire the harness itself, which is the trap that makes `docs/development.md` §5's gate 2 unsafe
75
+ * the moment this command works. Last of the three because it is the one check that needs the root
76
+ * the gate above resolved.
77
+ *
78
+ * ## What this command deliberately does not do
79
+ *
80
+ * - **It reads no template and formats no generated content.** Every artifact's shape, re-run policy
81
+ * and warning text belongs to the generator that owns it; duplicating any of it here would be a
82
+ * second source for a decision that already has one.
83
+ * - **It writes no guard hooks into the adopter's settings**, and takes no browser wiring decision:
84
+ * the plugin's own hooks compose with the adopter's, and `.mcp.json` is the repo-root generator's
85
+ * to gate on `browserWiringApplies` (`config/model.ts`) — the one predicate that generator and the
86
+ * permission profile both read, so the condition is not restated here to go stale. This command
87
+ * *calls* that predicate, once, to decide whether the closing report owes the registry sentence
88
+ * ({@link reportNextSteps}); calling it is not restating it.
89
+ */
90
+ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
91
+ import { join, relative, resolve } from 'node:path';
92
+ import { formatProblem } from '../config/check.js';
93
+ import { configExists, loadConfig } from '../config/io.js';
94
+ import { answersNone, asQaDriver, browserWiringApplies, CONFIG_FILENAME, DEFAULTS, isPlaceholder, qaDriverChoices, STATE_DIR_DOT_PATTERN, } from '../config/model.js';
95
+ import { EXIT, HarnessError } from '../core/errors.js';
96
+ import { commitAll, hasCommits, initRepository, probeRepoRoot } from '../core/git.js';
97
+ import { readJsonFile } from '../core/json.js';
98
+ import { layerCoverage } from '../core/layerCoverage.js';
99
+ import { layerGapRemedy, recordedVerdictClause } from '../core/layerGapRemedy.js';
100
+ import { nameList } from '../core/nameList.js';
101
+ import { insideRepo, packageRoot } from '../core/paths.js';
102
+ import { askLine, askYesNo, canPrompt } from '../core/prompt.js';
103
+ import { normalizeRepoDir, normalizeRepoPathStrict } from '../core/repoPaths.js';
104
+ import { WritePlan } from '../core/writer.js';
105
+ import { findNestedApplicationDir } from '../detect/nestedApplication.js';
106
+ import { buildPreset, commandSourceClaim, searchedRoots, SHARED_CONVENTIONS_PATH, } from '../detect/presets.js';
107
+ import { detectPreset, parsePresetName, FLAT_FALLBACK_SIGNAL_ID, FORCED_SIGNAL_ID, } from '../detect/signals.js';
108
+ import { writeClaudeContext, CLAUDE_MD_PATH, RESERVED_ANALYZE_TARGETS, SETUP_PENDING_OPEN, isUntouchedSkeletonText, } from '../generators/claudeContext.js';
109
+ import { pointHooksPath, writeGitHooks } from '../generators/githooks.js';
110
+ import { writeHarnessConfig } from '../generators/harnessConfig.js';
111
+ import { writeNotifications, GUIDED_ENDPOINT_EXAMPLE } from '../generators/notifications.js';
112
+ import { writeOuterLoopScripts } from '../generators/outerLoopScripts.js';
113
+ import { writePermissionProfile } from '../generators/permissionProfile.js';
114
+ import { writeProjectSettings, MARKETPLACE_FLAG, MARKETPLACE_NAME, MARKETPLACES_KEY, PLUGIN_NAME, SETTINGS_PATH, SLUG_SHAPE, } from '../generators/projectSettings.js';
115
+ import { writeRepoRootFiles, MCP_PATH } from '../generators/repoRoot.js';
116
+ import { configKeyPath, configuredCommand, wrapperCommandLine, wrapperPath, writeWrapperScripts, WRAPPER_SCRIPTS, } from '../generators/scripts.js';
117
+ import { writeStateDir } from '../generators/stateDir.js';
118
+ /** The command's one-line summary, in the usage block and at the head of its own `--help`. */
119
+ const SUMMARY = 'Wire a repository: config, scripts, state tree, permission profile, project settings, conventions stubs';
120
+ /** The roadmap item this command belongs to, as the registry reports it. */
121
+ const ROADMAP_ITEM = 13;
122
+ /** The analyze command's own name, without the leading `/` and without the plugin prefix. */
123
+ const ANALYZE_COMMAND_NAME = 'harness-analyze';
124
+ /** The analyze command as a line addressing the adopter names it — step C of the install story. */
125
+ const ANALYZE_COMMAND = `/${ANALYZE_COMMAND_NAME}`;
126
+ /**
127
+ * The same command, spelled as something **executed** rather than read.
128
+ *
129
+ * A session's `/` picker lists every command of this plugin under the plugin's own prefix and
130
+ * fuzzy-matches a bare name onto it, so {@link ANALYZE_COMMAND} reaches the command wherever a
131
+ * person types it into a session. A command passed as a session's **first message** — which is
132
+ * what {@link ANALYZE_INVOCATION} does — meets no picker and is matched exactly, so the bare
133
+ * form fails there with `Unknown command`. That the **prefixed** form succeeds where the bare
134
+ * one fails is not established: the only measurement in this tree is headless and negative on
135
+ * both spellings (root `README.md`, `### Measured while building that evidence, and not fixed
136
+ * here`), and the interactive first-message form waits on the hand-run gate — if that comes back
137
+ * negative the printed line is dropped rather than respelled (`docs/analyze.md` §9). The prefix
138
+ * is taken from {@link PLUGIN_NAME}, which mirrors the plugin manifest, rather than written out
139
+ * here.
140
+ */
141
+ const ANALYZE_COMMAND_QUALIFIED = `/${PLUGIN_NAME}:${ANALYZE_COMMAND_NAME}`;
142
+ /**
143
+ * How this CLI is typed, for every line that tells an adopter to run something — stated here once
144
+ * for the whole command surface.
145
+ *
146
+ * **The rule, one line: an occurrence an adopter is told to *run* carries the `npx` prefix; an
147
+ * occurrence that is the tool's *name* does not.** The bare `autonomous-sdlc-harness …` form
148
+ * resolves only where something installed a global binary, and no documented adoption path does:
149
+ * root `README.md`'s quick start is `npx autonomous-sdlc-harness …` throughout and
150
+ * `docs/development.md`'s contributor loop is `node cli/dist/cli.js …`. `npx` is the safe superset
151
+ * — it runs an already-installed global binary as well as fetching a published one — so it is the
152
+ * one adopter-facing spelling rather than a second special case. Under the name half of the rule
153
+ * the prefix is wrong and is not added: the `autonomous-sdlc-harness: <message>` error prefix and
154
+ * the `--version` banner in `cli.ts`, {@link MARKETPLACE_NAME}, `MACHINE_DIR_NAME`,
155
+ * `LAUNCHD_LABEL_PREFIX`, and the managed `.gitignore` block header in `generators/repoRoot.ts`.
156
+ * There is no third category.
157
+ */
158
+ const CLI = 'npx autonomous-sdlc-harness';
159
+ /**
160
+ * This verb, written in full wherever a line addresses the adopter.
161
+ *
162
+ * An adopter about to be offered {@link ANALYZE_COMMAND} meets two initializers — this one and the
163
+ * host tool's own, which also writes an always-loaded project file — so a bare `init` in an
164
+ * adopter-facing line names neither of them unambiguously (`docs/analyze.md` §9).
165
+ */
166
+ const INIT_VERB = `${CLI} init`;
167
+ /** The command that verifies what this one wired, named in the closing pointer. */
168
+ const DOCTOR_COMMAND = `${CLI} doctor`;
169
+ /**
170
+ * The command that arms the inbox, named in the closing pointer's last step while `phases.qa` is on.
171
+ *
172
+ * Spelled here rather than left to prose because it is one of the two operator steps that step names,
173
+ * and the other one — the `permissions.allow` entries — deliberately has no constant: its lines carry
174
+ * a machine-local path this command cannot see, and `doctor`'s `plugin-permissions` check prints them
175
+ * (`docs/cli.md` §7).
176
+ */
177
+ const DAEMON_INSTALL_COMMAND = `${CLI} daemon install`;
178
+ /**
179
+ * The command that answers, before the phase ever runs, whether the pinned MCP server packages can be
180
+ * fetched from this machine's registry — named in the registry sentence step 4 adds while
181
+ * `browserWiringApplies` holds (`doctor/checks.ts`'s browser-wiring check, `commands/doctor.ts`).
182
+ */
183
+ const DOCTOR_CHECK_REGISTRY_COMMAND = `${DOCTOR_COMMAND} --check-registry`;
184
+ /**
185
+ * How `.mcp.json` launches those servers, as the sentence names it.
186
+ *
187
+ * The launcher, not the specs: the two package names and their pinned versions live in
188
+ * `templates/repo/mcp.json` and are enumerated by the `doctor` check above, so a third copy here
189
+ * would go stale on the next pin bump.
190
+ */
191
+ const MCP_LAUNCHER = 'npx -y';
192
+ /**
193
+ * The agent runner as it is typed at a shell, and the two lines of the closing report that name it.
194
+ *
195
+ * Both are strings a person retypes: this CLI executes neither, and {@link INIT_VERB} starts no
196
+ * process and opens no window. {@link MARKETPLACE_ADD_COMMAND} is spelled as `docs/development.md`
197
+ * §1 attests the host tool's own verb — the adopter form of `claude plugin marketplace add`, over
198
+ * the slug shape the flag beside it takes — and no other host-tool verb is invented here.
199
+ */
200
+ const AGENT_CLI = 'claude';
201
+ const MARKETPLACE_ADD_COMMAND = `${AGENT_CLI} plugin marketplace add ${SLUG_SHAPE}`;
202
+ const ANALYZE_INVOCATION = `${AGENT_CLI} "${ANALYZE_COMMAND_QUALIFIED}"`;
203
+ /** The verb that applies a layer-profile revision — the one writer of `layers[]` (`docs/analyze.md` §3). */
204
+ const CONFIG_SET_LAYERS_COMMAND = `${CLI} config set layers`;
205
+ /** The one-key form of the same verb, named in step 3 for the two run settings (`commands/config.ts`). */
206
+ const CONFIG_SET_COMMAND = `${CLI} config set <key> <value>`;
207
+ /**
208
+ * The marketplace manifest at a repository root, and the CLI package inside it.
209
+ *
210
+ * Together with the package **name** below they are the whole of how `init` recognises the harness's
211
+ * own repository. Two signals rather than one because either alone is ordinary: a repository may
212
+ * publish its own marketplace, and a repository may have a `cli/` package — only a repository with
213
+ * both, whose CLI package is *this* package, is the one `init` must not be aimed at.
214
+ */
215
+ const MARKETPLACE_MANIFEST = join('.claude-plugin', 'marketplace.json');
216
+ const CLI_MANIFEST = join('cli', 'package.json');
217
+ /** The flag whose value the dot-directory refusal is raised against. */
218
+ const STATE_DIR_FLAG = '--state-dir';
219
+ /** The flag that answers the git gate — named in the prompt, in the refusal, and in the note. */
220
+ const GIT_INIT_FLAG = '--git-init';
221
+ /**
222
+ * The flag that overrides the configured application directory — named in the rung-2 note and in the
223
+ * warning a configured value that could not be used raises ({@link resolveDetectionAppDir}).
224
+ */
225
+ const APP_DIR_FLAG = '--app-dir';
226
+ /** The flag that answers the QA-driver question — named in the prompt, the refusal and the note. */
227
+ const QA_DRIVER_FLAG = '--qa-driver';
228
+ /**
229
+ * The two spellings that answer the analyze offer — one accept, one decline, and neither given is
230
+ * the state {@link resolveAnalyzeOffer} answers with the documented default.
231
+ *
232
+ * The one `--no-*` pair in this flag surface, and the precedent it sets: **two flags, two keys, one
233
+ * tri-state**. A single key cannot tell an absent flag from an explicit decline, and the flag table's
234
+ * discriminated union admits no negated-switch kind — a row pairs a switch with a boolean key or it
235
+ * is not a switch row at all.
236
+ */
237
+ const ANALYZE_FLAG = '--analyze';
238
+ const NO_ANALYZE_FLAG = '--no-analyze';
239
+ /** The flag that answers the notification opt-in — named in the prompt and in the no-terminal note. */
240
+ const NOTIFICATIONS_FLAG = '--notifications';
241
+ /** The flag that supplies the endpoint the opt-in needs, and without which nothing is written. */
242
+ const PUSH_URL_FLAG = '--push-url';
243
+ /**
244
+ * The subject of the first commit, which `init` makes in any repository it wires that has no commit
245
+ * yet — the one this run created, and the one it found in that state.
246
+ *
247
+ * A fixed sentence with nothing of this machine in it: no absolute path, no branch name, no
248
+ * operator's label. The commit lands in a repository that is about to be shared, and everything a
249
+ * reader of its history needs is what happened, not where it was run.
250
+ */
251
+ const FIRST_COMMIT_MESSAGE = 'chore: adopt the autonomous SDLC harness';
252
+ /**
253
+ * Freeze the flag table, and check it covers the whole flag surface.
254
+ *
255
+ * `rows` infers its literal key union; the second parameter is empty — and the call therefore
256
+ * complete — only when that union covers every {@link InitFlags} key. A key left without a row makes
257
+ * this call a missing-argument error whose expected type is that key, which is what turns "extends
258
+ * the generator flag interfaces" into an enforced table rather than a convention.
259
+ */
260
+ function initOptions(rows, ..._rowsMissingFor) {
261
+ return Object.freeze(rows);
262
+ }
263
+ /**
264
+ * The flag surface, in the order `--help` prints it: the gate that decides whether there is a
265
+ * repository to wire at all, then the one that decides whether the config in it is read or rebuilt,
266
+ * then the two that decide what is detected, then the values written into the config, then the three
267
+ * phase toggles with their own inputs beside them, then the onboarding slug, then the pair that
268
+ * answers the offer to analyze this repository — which decides the wording the generated
269
+ * always-loaded file carries — and last the pair that decides whether this account gets told when an
270
+ * unattended run finishes, which is the one pair that writes nothing into the repository at all.
271
+ *
272
+ * The first two sit together, and ahead of everything else, because they are the rows whose subject
273
+ * is the **shape of the run** rather than a value in the generated file: one settles what `init` is
274
+ * aimed at, the other settles whether it generates that file at all or reads the one already there.
275
+ * `--reset-config` would otherwise be filed among the config values it re-derives, which is the one
276
+ * thing it is not — it writes none of them itself.
277
+ *
278
+ * Every row is optional and every one has a documented default — for the config values, in the
279
+ * generator that reads it; for {@link GIT_INIT_FLAG}, in {@link resolveTargetRepository}, whose
280
+ * default is the refusal every earlier release raised; and for `--reset-config`, in
281
+ * `generators/harnessConfig.ts`, whose default is to read the file that is already there. `init`
282
+ * supplies no default of its own beyond that, so an absent flag and a flag never added are the same
283
+ * thing to everything downstream.
284
+ */
285
+ const INIT_OPTIONS = initOptions([
286
+ {
287
+ key: 'gitInit',
288
+ flag: GIT_INIT_FLAG,
289
+ kind: 'switch',
290
+ summary: 'Create a git repository when the target directory is not in one (otherwise init refuses)',
291
+ },
292
+ {
293
+ key: 'resetConfig',
294
+ flag: '--reset-config',
295
+ kind: 'switch',
296
+ summary: `Rebuild ${CONFIG_FILENAME} from detection and the flags, after a .bak (init otherwise reads it)`,
297
+ },
298
+ {
299
+ key: 'preset',
300
+ flag: '--preset',
301
+ kind: 'value',
302
+ placeholder: '<name>',
303
+ summary: 'Force a layer preset instead of detecting one',
304
+ configValue: 'detection.preset',
305
+ steersDetection: true,
306
+ },
307
+ {
308
+ key: 'appDir',
309
+ flag: APP_DIR_FLAG,
310
+ kind: 'value',
311
+ placeholder: '<dir>',
312
+ summary: 'Repo-relative directory of the application, when the repository nests it',
313
+ configValue: 'appDir',
314
+ steersDetection: true,
315
+ },
316
+ {
317
+ key: 'projectName',
318
+ flag: '--project-name',
319
+ kind: 'value',
320
+ placeholder: '<name>',
321
+ summary: 'Short project identifier (default: the repository directory name)',
322
+ configValue: 'projectName',
323
+ },
324
+ {
325
+ key: 'defaultBranch',
326
+ flag: '--default-branch',
327
+ kind: 'value',
328
+ placeholder: '<branch>',
329
+ summary: 'The branch work merges back into, and the one a run may never push to',
330
+ configValue: 'defaultBranch',
331
+ },
332
+ {
333
+ key: 'stateDir',
334
+ flag: STATE_DIR_FLAG,
335
+ kind: 'value',
336
+ placeholder: '<dir>',
337
+ summary: 'Repo-relative run-artifact directory; must not be dot-named',
338
+ configValue: 'stateDir',
339
+ },
340
+ { key: 'qa', flag: '--qa', kind: 'switch', summary: 'Turn the interactive test phase on', configValue: 'phases.qa' },
341
+ {
342
+ key: 'qaDriver',
343
+ flag: QA_DRIVER_FLAG,
344
+ kind: 'value',
345
+ placeholder: '<driver>',
346
+ summary: 'Which interactive-test driver the QA phase runs (with --qa)',
347
+ configValue: 'qa.driver',
348
+ },
349
+ {
350
+ key: 'docs',
351
+ flag: '--docs',
352
+ kind: 'switch',
353
+ summary: 'Turn the documentation phase on',
354
+ configValue: 'phases.docs',
355
+ },
356
+ {
357
+ key: 'docsRoot',
358
+ flag: '--docs-root',
359
+ kind: 'value',
360
+ placeholder: '<dir>',
361
+ summary: 'Documentation root the docs phase keeps current (with --docs)',
362
+ configValue: 'docs.root',
363
+ },
364
+ {
365
+ key: 'parity',
366
+ flag: '--parity',
367
+ kind: 'switch',
368
+ summary: 'Turn the reference-parity phase on',
369
+ configValue: 'phases.parity',
370
+ },
371
+ {
372
+ key: 'referenceImpl',
373
+ flag: '--reference-impl',
374
+ kind: 'value',
375
+ placeholder: '<path>',
376
+ summary: 'Checkout of the reference implementation to compare against (with --parity)',
377
+ configValue: 'parity.referenceImplPath',
378
+ },
379
+ {
380
+ key: 'referenceToolchainPath',
381
+ flag: '--reference-toolchain-path',
382
+ kind: 'value',
383
+ placeholder: '<abs>',
384
+ summary: "Absolute directory of the reference implementation's own toolchain (with --parity)",
385
+ },
386
+ {
387
+ key: 'marketplace',
388
+ flag: '--marketplace',
389
+ kind: 'value',
390
+ placeholder: '<owner>/<repo>',
391
+ summary: 'Marketplace repository written into the committed .claude/settings.json',
392
+ },
393
+ // Two rows, two keys, one tri-state — the reasoning is at {@link ANALYZE_FLAG}, and
394
+ // {@link resolveAnalyzeOffer} is what folds the pair into a single answer.
395
+ {
396
+ key: 'analyze',
397
+ flag: ANALYZE_FLAG,
398
+ kind: 'switch',
399
+ summary: `Offer to run ${ANALYZE_COMMAND} after wiring (the default; ${NO_ANALYZE_FLAG} declines)`,
400
+ },
401
+ {
402
+ key: 'noAnalyze',
403
+ flag: NO_ANALYZE_FLAG,
404
+ kind: 'switch',
405
+ summary: 'Decline the analyze offer, and record the declined wording',
406
+ },
407
+ {
408
+ key: 'notifications',
409
+ flag: NOTIFICATIONS_FLAG,
410
+ kind: 'switch',
411
+ summary: 'Set up push notifications for unattended runs (default: off)',
412
+ },
413
+ {
414
+ key: 'pushUrl',
415
+ flag: PUSH_URL_FLAG,
416
+ kind: 'value',
417
+ placeholder: '<url>',
418
+ summary: 'Endpoint unattended-run notifications are posted to (with --notifications)',
419
+ },
420
+ ]);
421
+ /**
422
+ * A flag value as a reader can retype it: quoted only where a shell would otherwise split or expand
423
+ * it, so the remedy below stays copy-pasteable for an ordinary branch name and stays correct for a
424
+ * path with a space in it.
425
+ */
426
+ function retypable(value) {
427
+ return /^[A-Za-z0-9._/@-]+$/.test(value) ? value : JSON.stringify(value);
428
+ }
429
+ /**
430
+ * The warning a kept-config run owes for the flags whose values it resolved and threw away, or
431
+ * `undefined` when this command line carried none.
432
+ *
433
+ * Derived from {@link INIT_OPTIONS} and the parsed flags rather than from a list of its own — a row
434
+ * is in the discarded class exactly when it carries {@link InitOption.configValue} (the membership
435
+ * test is in that type's header), so a flag added to the table is classified there and here at once.
436
+ * *Given* is `true` for a switch rather than merely present, because {@link parseInitFlags} names
437
+ * every switch key on every run and presence alone would name flags nobody passed.
438
+ *
439
+ * A **warning** rather than a note: it survives `--quiet` (`core/report.ts`), and an unattended run
440
+ * that passed a flag which did nothing is exactly the reader who needs to have been told. It names
441
+ * both routes because they are not the same repair — `config set` changes the one key it is given
442
+ * and re-derives nothing else, while `--reset-config` rebuilds the whole file from detection and
443
+ * this command line, which is the only route that also re-derives what a value implies.
444
+ *
445
+ * **It claims the unwritten value, never that the flag did nothing.** A dropped row carrying
446
+ * {@link InitOption.steersDetection} did steer this run's stack detection, and a kept run with
447
+ * `--force` re-renders the wrapper bodies from that detection (`generators/scripts.ts`,
448
+ * `resolveBody` precedence 2) — so the clause naming that is added when, and only when, such a row
449
+ * is among the dropped. A reader told a flag reached nothing reaches for `--reset-config`, which
450
+ * rebuilds the whole file and discards every hand-set value, to get an effect a plain re-run had
451
+ * already had half of.
452
+ */
453
+ function discardedConfigFlagsWarning(flags) {
454
+ const dropped = [];
455
+ for (const option of INIT_OPTIONS) {
456
+ const configKey = option.configValue;
457
+ if (configKey === undefined)
458
+ continue;
459
+ const steersDetection = option.steersDetection === true;
460
+ if (option.kind === 'switch') {
461
+ if (flags[option.key] === true) {
462
+ dropped.push({ flag: option.flag, configKey, invocation: option.flag, steersDetection });
463
+ }
464
+ continue;
465
+ }
466
+ const value = flags[option.key];
467
+ if (value === undefined)
468
+ continue;
469
+ dropped.push({ flag: option.flag, configKey, invocation: `${option.flag} ${retypable(value)}`, steersDetection });
470
+ }
471
+ if (dropped.length === 0)
472
+ return undefined;
473
+ const one = dropped.length === 1;
474
+ const keyRemedies = dropped.map((entry) => `\`${CLI} config set ${entry.configKey} <value>\``).join(', ');
475
+ const steering = dropped.filter((entry) => entry.steersDetection).map((entry) => entry.flag);
476
+ const steeringClause = steering.length === 0
477
+ ? ''
478
+ : ` ${steering.join(', ')} also steer${steering.length === 1 ? 's' : ''} stack detection, and still did on this run: what \`--force\` then re-renders from that detection is the wrapper scripts, not this file.`;
479
+ return `${dropped.map((entry) => entry.flag).join(', ')} ${one ? 'was' : 'were'} given, and ${CONFIG_FILENAME} was read rather than written on this run: ${one ? 'that flag supplies a value' : 'each of those flags supplies a value'} for a key of that file, so no such value was written and the keys in the file are the ones every generator after it read.${steeringClause} Apply ${one ? 'it' : 'them'} by rebuilding the file from detection and this command line with \`${INIT_VERB} --reset-config ${dropped.map((entry) => entry.invocation).join(' ')}\`, which copies the file that is there to a .bak first and is the only route that also re-derives what a value implies — or change ${one ? 'the key' : 'the keys'} alone with ${keyRemedies}`;
480
+ }
481
+ /** The command's own `Options:` rows, invocation-aligned, derived from {@link INIT_OPTIONS}. */
482
+ function initOptionLines() {
483
+ const invocations = INIT_OPTIONS.map((option) => `${option.flag}${option.kind === 'switch' ? '' : ` ${option.placeholder}`}`);
484
+ const width = Math.max(...invocations.map((invocation) => invocation.length));
485
+ return INIT_OPTIONS.map((option, index) => ` ${invocations[index].padEnd(width)} ${option.summary}`);
486
+ }
487
+ /**
488
+ * The lines the registry renders under this command's synopsis. The entry point appends the global
489
+ * options after them, so `init --help` prints both sets and exits 0 without reaching {@link run} —
490
+ * which is what keeps `--help` safe to invoke against a command that writes files.
491
+ */
492
+ const INIT_USAGE = Object.freeze([
493
+ 'Init options (every one optional):',
494
+ ...initOptionLines(),
495
+ ]);
496
+ /**
497
+ * Parse the arguments left after the entry point consumed the global flags.
498
+ *
499
+ * **An unrecognised flag is a refusal, never a silent ignore.** A mistyped flag that is quietly
500
+ * dropped produces a repository wired differently from what was asked for, and the difference is
501
+ * only visible later, in a generated file nobody re-reads. A positional argument is refused for the
502
+ * same reason: `init` takes none, so one is a mistyped flag or a command that does not exist.
503
+ */
504
+ /**
505
+ * The {@link QA_DRIVER_FLAG} value, checked against the schema's own enum — the flag surface's twin
506
+ * of `parsePresetName`, and the only value flag whose legal values are a closed set.
507
+ *
508
+ * It is settled at the flag rather than left to the config check for two reasons. The generated
509
+ * config would be refused by {@link writeHarnessConfig}'s guard as {@link EXIT.INTERNAL}, which
510
+ * blames this CLI for a value the adopter typed; and the two mobile values name agent variants
511
+ * closely enough (`mobile-maestro`, `mobile-mcp`) that reaching for the wrong one is a plausible
512
+ * mistake rather than a contrived one, so the refusal lists all three — from `qaDriverChoices`, the
513
+ * same renderer {@link askQaDriver} puts the values through, so the two surfaces cannot disagree
514
+ * about which of them this release implements. This is also the only one of the two a subprocess can
515
+ * reach, and so the one `test/init.test.mjs` asserts the marking on behaviourally.
516
+ */
517
+ function parseQaDriver(value) {
518
+ const driver = asQaDriver(value);
519
+ if (driver === undefined) {
520
+ throw new HarnessError(`init: unknown ${QA_DRIVER_FLAG} ${JSON.stringify(value)}: expected one of ${qaDriverChoices()}`);
521
+ }
522
+ return driver;
523
+ }
524
+ function parseInitFlags(argv) {
525
+ const values = new Map();
526
+ const switches = new Set();
527
+ for (let index = 0; index < argv.length; index += 1) {
528
+ const token = argv[index];
529
+ const separator = token.startsWith('--') ? token.indexOf('=') : -1;
530
+ const name = separator > 0 ? token.slice(0, separator) : token;
531
+ const inlineValue = separator > 0 ? token.slice(separator + 1) : undefined;
532
+ const option = INIT_OPTIONS.find((candidate) => candidate.flag === name);
533
+ if (option === undefined) {
534
+ throw new HarnessError(token.startsWith('-')
535
+ ? `init: unknown option ${JSON.stringify(name)} — run \`${INIT_VERB} --help\` for the options it takes`
536
+ : `init: unexpected argument ${JSON.stringify(token)} — ${INIT_VERB} takes options only, and writes into the repository containing the current directory (or --cwd)`);
537
+ }
538
+ if (option.kind === 'switch') {
539
+ if (inlineValue !== undefined)
540
+ throw new HarnessError(`init: ${name} does not take a value`);
541
+ switches.add(option.key);
542
+ continue;
543
+ }
544
+ let value = inlineValue;
545
+ if (value === undefined) {
546
+ index += 1;
547
+ value = argv[index];
548
+ }
549
+ if (value === undefined || value === '')
550
+ throw new HarnessError(`init: ${name} requires ${option.placeholder}`);
551
+ values.set(option.key, value);
552
+ }
553
+ // Sound by construction rather than by inspection: {@link InitOption} admits a string only under a
554
+ // `ValueFlagKey` and a switch only under a `SwitchFlagKey`, so every entry assembled here holds
555
+ // the type the interface declares for its key. The switches are named one by one rather than
556
+ // collected because the list below is meant to be the whole of {@link SwitchFlagKey} — that is
557
+ // what makes each of them `false` when absent rather than missing, and a key added to that type
558
+ // and not here is a flag the parser accepts and every generator then reads as `undefined`.
559
+ //
560
+ // `qaDriver` is the one exception and is re-set from {@link parseQaDriver} below, because its
561
+ // declared type is the enum rather than `string`: the collected raw value would satisfy the cast
562
+ // and not the type. Checking it here — inside the parser, before the git gate and every other
563
+ // precondition — is also what keeps a mistyped driver from costing an adopter a repository this
564
+ // run created and then refused to wire.
565
+ const qaDriver = values.get('qaDriver');
566
+ // The analyze pair is refused here for that same reason and at that same point: a contradictory
567
+ // command line must not cost an adopter a repository this run created and then declined to wire.
568
+ if (switches.has('analyze') && switches.has('noAnalyze')) {
569
+ throw new HarnessError(`init: ${ANALYZE_FLAG} and ${NO_ANALYZE_FLAG} answer the same question opposite ways and both were given: pass one, or neither — with neither, the documented default accepts the offer`);
570
+ }
571
+ return {
572
+ ...Object.fromEntries([...values]),
573
+ ...(qaDriver === undefined ? {} : { qaDriver: parseQaDriver(qaDriver) }),
574
+ gitInit: switches.has('gitInit'),
575
+ resetConfig: switches.has('resetConfig'),
576
+ analyze: switches.has('analyze'),
577
+ noAnalyze: switches.has('noAnalyze'),
578
+ notifications: switches.has('notifications'),
579
+ qa: switches.has('qa'),
580
+ docs: switches.has('docs'),
581
+ parity: switches.has('parity'),
582
+ };
583
+ }
584
+ /** The `name` of a JSON manifest, or `undefined` when there is no readable one with a name. */
585
+ function packageNameAt(manifestPath) {
586
+ let parsed;
587
+ try {
588
+ parsed = readJsonFile(manifestPath);
589
+ }
590
+ catch {
591
+ // A manifest that does not parse belongs to a repository this command has no opinion about:
592
+ // the question here is only "is this the harness's own repository", and an unreadable answer
593
+ // is a no.
594
+ return undefined;
595
+ }
596
+ if (parsed === undefined || parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
597
+ return undefined;
598
+ const name = parsed['name'];
599
+ return typeof name === 'string' && name !== '' ? name : undefined;
600
+ }
601
+ /** This package's own `name`, read from the manifest beside the compiled tree. */
602
+ function ownPackageName() {
603
+ const manifestPath = join(packageRoot(), 'package.json');
604
+ const name = packageNameAt(manifestPath);
605
+ if (name === undefined) {
606
+ throw new HarnessError(`this CLI's own manifest could not be read at ${manifestPath}, so init could not check whether it was aimed at the harness's own repository: this is a fault in this CLI rather than in the repository it was run against`, EXIT.INTERNAL);
607
+ }
608
+ return name;
609
+ }
610
+ /**
611
+ * Refuse a run aimed at the repository this CLI itself lives in.
612
+ *
613
+ * Wiring the harness's own repository is never what was meant, and it is a plausible accident:
614
+ * `docs/development.md` §5's gate 2 documents running the built CLI from this root, which was a
615
+ * refusal check for as long as `init` refused everything. Once it works, that invocation would
616
+ * generate a config, a state tree and a permission profile into the harness's own tree.
617
+ */
618
+ function assertNotHarnessOwnRepository(repoRoot) {
619
+ if (!existsSync(join(repoRoot, MARKETPLACE_MANIFEST)))
620
+ return;
621
+ if (packageNameAt(join(repoRoot, CLI_MANIFEST)) !== ownPackageName())
622
+ return;
623
+ throw new HarnessError(`refusing to wire the harness's own repository; run \`${INIT_VERB}\` in the repository you want to adopt it. ${repoRoot} carries ${MARKETPLACE_MANIFEST} and a ${CLI_MANIFEST} naming this package, so it is the harness itself rather than an adopting project: wiring it would generate a ${CONFIG_FILENAME}, a run-artifact tree and a permission profile into the tree that ships them. Run ${INIT_VERB} from the adopting repository, or point it at one with --cwd <path>`);
624
+ }
625
+ /**
626
+ * Refuse a `--state-dir` that names, or reaches through, a dot-directory — **before** anything is
627
+ * assembled from it.
628
+ *
629
+ * The reason is the one `docs/config.md` §3 gives and the one the tree generator repeats: the
630
+ * run-artifact tree has to be writable by an unattended run, and a dot-path is where a host reserves
631
+ * directories an unattended run may not write to. The failure is therefore silent — the tree is
632
+ * created, the run reports success, and every artifact it produced is gone. Refusing at the flag is what makes it an adopter-fixable usage error with the ordinary exit
633
+ * code, rather than the internal-fault exit the config generator's backstop would raise on a config
634
+ * this CLI had already assembled.
635
+ */
636
+ function assertUsableStateDir(value) {
637
+ if (value === undefined)
638
+ return;
639
+ if (!STATE_DIR_DOT_PATTERN.test(value.trim()))
640
+ return;
641
+ throw new HarnessError(`${STATE_DIR_FLAG} ${JSON.stringify(value)} has a path segment starting with '.', and the run-artifact tree must not be dot-named or reach through a dot segment: it has to be writable by an unattended run, and a dot-path is where a host reserves directories an unattended run may not write to — measured for .claude/**, where such a run completes with exit 0 having written nothing. Pass a name without a leading dot; do not "fix" it back to a dot-name`);
642
+ }
643
+ /**
644
+ * What the run says about a directory that was **already** inside a repository — what
645
+ * {@link GIT_INIT_FLAG} meant there, and what this run does about that repository's own history. The
646
+ * case was silent until Task 8, and the silence is what let a run leave the exact state
647
+ * {@link commitGeneratedFiles} exists to prevent without anything saying so.
648
+ *
649
+ * **Notes, never warnings.** A provisioning script that passes the flag whether or not the directory
650
+ * is fresh is a *correct* run, and a warning that fires on every correct run is how a warning list
651
+ * stops being read (`generators/harnessConfig.ts`).
652
+ *
653
+ * **Two gates, because the two facts are about different things.** What the flag did here is a
654
+ * statement *about the flag*, so a run that never asked for a repository is told none of it. Whether
655
+ * the first commit happens is a statement about the repository's **state**:
656
+ * {@link commitGeneratedFiles} stopped consulting the flag once the commit became a fix for an unborn
657
+ * `HEAD` rather than a courtesy to a repository this run created — so a preview gated on the flag
658
+ * would leave the ordinary no-flag adoption (an adopter's own `git init`, never committed into)
659
+ * silent about the one mutation outside the plan a real run makes there. That is `docs/cli.md` §1's
660
+ * "a dry run cannot report a success a real run would not have" contract read in its other
661
+ * direction: a dry run may not omit a mutation a real run makes either.
662
+ *
663
+ * Four texts — three gated on the flag, one on {@link hasCommits} alone:
664
+ *
665
+ * - **Flag given, the repository has history.** Nothing was created and nothing is committed —
666
+ * {@link commitGeneratedFiles} short-circuits on {@link hasCommits}, so the *commit* half reads the
667
+ * same in both modes: it is the repository's state that decides it, not the run's. The *wiring*
668
+ * half is the run's own work, so it splits — "this run only wires it" on a real run, "a real run
669
+ * would only wire it" under `--dry-run`, which wires nothing.
670
+ * - **Flag given, no commit yet.** Nothing was created, said on its own: the commit half of this
671
+ * state is one of the two lines below, neither of which is about the flag.
672
+ * - **No commit yet, real run — flag gated.** This run *does* make the first commit, for the reason
673
+ * that guard exists: `git worktree add` cannot prepare a checkout from a repository with no commit.
674
+ * A real run's account of the commit it actually made is {@link commitGeneratedFiles}' `ok` line,
675
+ * which every commit-less real run already gets; this line is the promise made before the plan is
676
+ * applied, and it stays flag-gated so a no-flag run is not told the same thing twice.
677
+ * - **No commit yet, `--dry-run` — gated on the state alone.** The same two facts in the conditional,
678
+ * printed whether or not the flag was given, because that is the gate the commit itself is under
679
+ * and a dry run is the only run with nothing after the fact to say it.
680
+ *
681
+ * Asking {@link hasCommits} here costs a second probe and is safe: nothing commits between this call
682
+ * and {@link commitGeneratedFiles}, so the note and the commit cannot disagree.
683
+ */
684
+ function alreadyARepositoryNotes(ctx, flags, root) {
685
+ const commits = hasCommits(root);
686
+ const notes = [];
687
+ if (flags.gitInit === true) {
688
+ const nothingCreated = `${GIT_INIT_FLAG} was given and nothing was created: this directory is already inside a git repository at ${root}`;
689
+ notes.push(commits
690
+ ? `${nothingCreated}, and that repository has history — init never commits into an established history, so ${ctx.flags.dryRun ? 'a real run would only wire it' : 'this run only wires it'}`
691
+ : nothingCreated);
692
+ }
693
+ const worktreeReason = 'because `git worktree add` cannot prepare a checkout from a repository with no commit';
694
+ if (!commits && ctx.flags.dryRun) {
695
+ notes.push(`this repository has no commit yet, so a real run would make the first commit here — \`git add -A\` would stage everything in the working tree, what init generated and whatever was already here, with the managed .gitignore block deciding what is left out — ${worktreeReason}. This was a dry run, so nothing was written and nothing was committed`);
696
+ }
697
+ else if (!commits && flags.gitInit === true) {
698
+ notes.push(`this repository has no commit yet, so init makes the first commit from what is in the working tree once the plan has been applied, ${worktreeReason}`);
699
+ }
700
+ return notes;
701
+ }
702
+ /**
703
+ * Settle the one precondition that has an *answer* rather than only a verdict: is there a repository
704
+ * to wire, and — when there is not — may this run create one.
705
+ *
706
+ * **Git stays a hard gate; nothing is ever created silently.** The repository appears only because
707
+ * {@link GIT_INIT_FLAG} said so, or because someone at a terminal answered yes. A run that cannot be
708
+ * asked takes the documented default and refuses, which is exactly what every release before the
709
+ * offer did — so an unattended run's outcome is unchanged (`core/prompt.ts`).
710
+ *
711
+ * **And every outcome of the flag is reported, including the one where it did nothing.** Passing
712
+ * {@link GIT_INIT_FLAG} into a directory that is already a repository creates nothing, which is
713
+ * correct and used to be silent; it now carries a note saying so and saying what this run does about
714
+ * that repository's own history ({@link alreadyARepositoryNotes}).
715
+ *
716
+ * It is called where `resolveRepoRoot` used to be — before the harness-own-repository check and
717
+ * before the first generator, so the accept path still runs before a single write is enqueued and
718
+ * the refusal path still leaves a plan that was never built. **After** the two refusals the flags
719
+ * alone answer, though, and that half of the ordering is this function's doing: its accept path
720
+ * creates a repository, so a refusal raised later would leave one behind in a directory the run then
721
+ * declined to wire — a repository the adopter never asked for and was never told about. The
722
+ * corrected re-run would then find it simply *there*, take it as the root every write is confined to
723
+ * without putting the question this gate exists to put, and — since it has no commit —
724
+ * {@link commitGeneratedFiles} would commit the whole of that directory's working tree into it.
725
+ *
726
+ * **Only git's own "not a git repository" is an offer.** A `git` that is not on PATH, and a `git`
727
+ * that refused to answer (`detected dubious ownership`, an unreadable object store), each throw the
728
+ * probe's own message unchanged: the first is a missing prerequisite whose accept path would fail
729
+ * for the same reason, and the second may well be a refusal *inside* an existing repository, where
730
+ * creating one is the wrong move and git's own sentence is the remedy.
731
+ */
732
+ function resolveTargetRepository(ctx, flags) {
733
+ const probe = probeRepoRoot(ctx.cwd);
734
+ if (probe.kind === 'repository') {
735
+ // Nothing to decide — but a run that passed the flag anyway gets told what that meant here, and
736
+ // a dry run over a commit-less repository gets the first commit previewed whether or not it did,
737
+ // rather than the silence that let a commit-less repository be wired and left uncommitted with
738
+ // nothing said ({@link alreadyARepositoryNotes}).
739
+ return { root: probe.root, created: false, notes: alreadyARepositoryNotes(ctx, flags, probe.root) };
740
+ }
741
+ if (probe.kind !== 'not-a-repository')
742
+ throw new HarnessError(probe.message);
743
+ const accepted = flags.gitInit === true ||
744
+ askYesNo({
745
+ question: `No git repository at ${ctx.cwd}. Create one here and wire it?`,
746
+ defaultAnswer: false,
747
+ flag: GIT_INIT_FLAG,
748
+ flagHint: 'to create one without being asked',
749
+ }, { flags: ctx.flags, report: ctx.report });
750
+ if (!accepted) {
751
+ throw new HarnessError(`${probe.message}. Every path init writes is confined to a repository root, and the worktree-based flow it wires cannot prepare a checkout without one, so this is a gate rather than something init works around. Pass ${GIT_INIT_FLAG} to create a repository here, run ${INIT_VERB} from inside an existing one, or point it at one with --cwd <path>`);
752
+ }
753
+ if (ctx.flags.dryRun) {
754
+ // The preview reports the decision without making it, and the rest of the plan is previewed
755
+ // against `cwd` as the root. Resolved through `realpathSync` rather than taken as spelled, for
756
+ // the reason the real path below re-probes: a caller's directory may reach its target through a
757
+ // symlink (`/tmp` on macOS is one), and a preview whose absolute paths differ from the ones a
758
+ // real run writes is not the faithful preview `docs/cli.md` §1 promises. This is an explicit
759
+ // `dryRun` guard rather than a consequence of the plan's structure, because `initRepository`
760
+ // writes outside the plan: a dry run may not report a success a real run would not have, and it
761
+ // may not create a repository in order to say so.
762
+ const root = realpathSync(ctx.cwd);
763
+ ctx.report.info(`would create a git repository at ${root}`);
764
+ return {
765
+ root,
766
+ created: false,
767
+ notes: [
768
+ `no git repository was created — this was a dry run; with ${GIT_INIT_FLAG} a real run creates one at ${root}, wires it, and makes the first commit from everything in this directory's working tree — what it generated and whatever was already here — with the managed .gitignore block deciding what is left out`,
769
+ ],
770
+ };
771
+ }
772
+ initRepository(ctx.cwd);
773
+ // Re-probed rather than assumed: `rev-parse --show-toplevel` answers with the physical path, and
774
+ // the caller's directory may reach it through a symlink (`/tmp` on macOS is one). Taking `cwd`
775
+ // here would spell every absolute path the generators write differently from the root git reports,
776
+ // and nothing downstream would notice.
777
+ const created = probeRepoRoot(ctx.cwd);
778
+ if (created.kind !== 'repository')
779
+ throw new HarnessError(created.message);
780
+ ctx.report.ok(`created a git repository at ${created.root}`);
781
+ return {
782
+ root: created.root,
783
+ created: true,
784
+ notes: [
785
+ `the git repository at ${created.root} was created by this run — init makes the first commit from everything in this directory's working tree, what it generated and whatever was already here, with the managed .gitignore block deciding what is left out, because \`git worktree add\` cannot prepare a checkout from a repository with no commit`,
786
+ ],
787
+ };
788
+ }
789
+ /**
790
+ * Make the first commit — in any repository being wired that has no commit yet, and only from what
791
+ * is on disk once the whole plan has been applied.
792
+ *
793
+ * **Why it exists at all:** a repository with an unborn `HEAD` is one `git worktree add` away from
794
+ * failing, and that is a fact about the repository's **state** rather than about who created it. So
795
+ * an adopter who accepted the offer — or who ran `init` inside a repository they had made and not
796
+ * yet committed into — would have a correctly wired repository from which the worktree-based flow
797
+ * this command exists to enable cannot prepare a single checkout: one non-obvious step away from
798
+ * working, with nothing in the run saying so. `doctor` reports it, but `doctor` cannot be relied on
799
+ * to have run.
800
+ *
801
+ * Two conditions, each answering a different way this could be wrong:
802
+ *
803
+ * - **`dryRun`** — the third of the three explicit guards named in choice 1 of the module header. A
804
+ * dry run has applied no plan, so there would be nothing of its own to commit anyway; the guard
805
+ * is written all the same, because the guarantee it protects is "a dry run mutates nothing" and
806
+ * that must not rest on what some other function did elsewhere in the file.
807
+ * - **{@link hasCommits}** — a repository with history is never committed into. This one check is
808
+ * the whole of that property and it holds by construction rather than by inspection: it
809
+ * short-circuits before `commitAll` is reached, so no path from here reaches a commit on top of
810
+ * somebody's work.
811
+ *
812
+ * **Two consequences, both said out loud rather than left implicit.** First, an established
813
+ * repository is untouched, whoever wired it and whether or not this run created anything — the
814
+ * check above is the whole reason, and there is no second one to keep in step with it. Second,
815
+ * `git add -A` stages the **whole working tree** — what `init` generated and whatever was already in
816
+ * the directory — in either arm, with the managed `.gitignore` block the plan just wrote deciding
817
+ * what is left out (`generators/repoRoot.ts`). A repository `--git-init` created is not an
818
+ * exception: detection read that directory's manifests before the plan was built, so there is
819
+ * content there to stage. That single-commit shape is deliberate — one shape, one message — so the
820
+ * run **reports which arm it was in**, because *who* left the repository without a commit is what
821
+ * differs, not what got staged.
822
+ *
823
+ * **A refusal is a warning, not a failure.** Everything the run was asked to wire is on disk and
824
+ * correct by the time this runs, so exiting non-zero would report a failed adoption because of a
825
+ * step the adopter can finish with two commands. The overwhelmingly common cause is a machine with
826
+ * no commit identity — which `init` deliberately does not set, because an identity is the
827
+ * operator's and inventing one authors commits as somebody who does not exist — so the warning
828
+ * carries the exact commands rather than only git's account of the refusal.
829
+ */
830
+ function commitGeneratedFiles(ctx, target) {
831
+ if (ctx.flags.dryRun || hasCommits(target.root))
832
+ return [];
833
+ const result = commitAll(target.root, FIRST_COMMIT_MESSAGE);
834
+ if (result.committed) {
835
+ ctx.report.ok(target.created
836
+ ? `committed this directory as the repository's first commit: ${FIRST_COMMIT_MESSAGE}. \`git add -A\` staged everything in the working tree — what init generated and whatever was already here — with the managed .gitignore block deciding what was left out`
837
+ : `this repository was already here and had no commit yet, so init made its first one: ${FIRST_COMMIT_MESSAGE}. \`git add -A\` staged everything in the working tree — what init generated and whatever was already here — with the managed .gitignore block deciding what was left out, because \`git worktree add\` cannot prepare a checkout from a repository with no commit`);
838
+ return [];
839
+ }
840
+ return [
841
+ `this repository has no commit yet: git declined to make one — ${result.reason}. Everything init generated is on disk and correct; only the commit is missing, and \`git worktree add\` fails against a repository with no commit, so the worktree-based flow cannot prepare a checkout until there is one. Most often the machine has no commit identity, which init does not set for you: run \`git config user.email "you@example.com"\` and \`git config user.name "Your Name"\`, then \`git add -A && git commit -m "${FIRST_COMMIT_MESSAGE}"\``,
842
+ ];
843
+ }
844
+ /**
845
+ * Ask which driver the interactive test phase should run — or answer `undefined` on every run that
846
+ * cannot be asked, which is what tells the config generator to take the documented default and to
847
+ * say so rather than pretending a choice was made.
848
+ *
849
+ * **`undefined` rather than the default itself** is the whole reason this wrapper exists: `askLine`
850
+ * answers a no-terminal run with `defaultValue`, so a caller taking its return alone cannot tell an
851
+ * adopter who chose `web-playwright` from a CI run that was never asked — and the two want opposite
852
+ * notes. {@link canPrompt} is the same predicate `askLine` itself uses, so `--non-interactive` and
853
+ * `--quiet` are honoured here on exactly the terms they are honoured everywhere.
854
+ *
855
+ * The question **back-references** the detected preset instead of naming it. It used to open on
856
+ * `` detected the `<preset>` layer preset ``, which is verbatim the opening clause of the detection
857
+ * line printed immediately above it: the two read as one narration line printed twice, so the eye
858
+ * skips the second and the question with it. What the naming was for still holds — the detection
859
+ * table is file-existence only and has no mobile row (`docs/cli.md` §4), so what `init` found says
860
+ * nothing about how the application is reached — and the back-reference carries it without
861
+ * re-printing the line the adopter has just read. The preset is therefore no longer an input, and
862
+ * the call site's ordering (after the detection line) is what the back-reference depends on.
863
+ *
864
+ * The values come from `qaDriverChoices`, so the two that ship declared-not-implemented are marked
865
+ * **where the choice is made** rather than only in documents a reader opens after choosing.
866
+ */
867
+ function askQaDriver(ctx) {
868
+ const promptCtx = { flags: ctx.flags, report: ctx.report };
869
+ if (!canPrompt(promptCtx))
870
+ return undefined;
871
+ return askLine({
872
+ question: `the detected preset says nothing about how this project's application is reached: which driver should the interactive test phase run? (${qaDriverChoices()})`,
873
+ flag: QA_DRIVER_FLAG,
874
+ defaultValue: DEFAULTS.qa.driver,
875
+ }, promptCtx);
876
+ }
877
+ /**
878
+ * The conventions documents this repository has: every distinct `layers[].conventions` value, plus
879
+ * the shared cross-layer document whether or not a layer points at it.
880
+ *
881
+ * **The walk is deliberately duplicated and bounded to the walk.** `generators/claudeContext.ts`
882
+ * applies the same rule when it collects the stubs, and it is module-private on this branch — so
883
+ * each consumer of the rule walks `layers[]` itself, while all of them take the shared document's
884
+ * path from {@link SHARED_CONVENTIONS_PATH}, the one exported constant, rather than re-spelling a
885
+ * context path. `doctor`'s `setup-analysis` check states the same derivation in its own comment.
886
+ */
887
+ function conventionsDocuments(config) {
888
+ return [
889
+ ...new Set([SHARED_CONVENTIONS_PATH, ...config.layers.map((layer) => layer.conventions)].map(normalizeRepoPathStrict)),
890
+ ];
891
+ }
892
+ /**
893
+ * The **untouched-skeleton test** applied to one document — {@link isUntouchedSkeletonText}, which
894
+ * is the predicate itself and lives beside the two markers it is built from. `doctor` and the
895
+ * analyze command apply the same test, so the three cannot disagree about what "unfilled" means,
896
+ * and an adopter who wrote a document by hand is never treated as having left a skeleton.
897
+ *
898
+ * What this adds is the reading, and the answer for a document that cannot be read: **untouched**,
899
+ * because here that means a document `init` is about to create for the first time — which is what
900
+ * keeps a fresh adoption from taking the skip branch below.
901
+ */
902
+ function isUntouchedSkeleton(repoRoot, repoRelative) {
903
+ let content;
904
+ try {
905
+ content = readFileSync(join(repoRoot, repoRelative), 'utf8');
906
+ }
907
+ catch {
908
+ return true;
909
+ }
910
+ return isUntouchedSkeletonText(content);
911
+ }
912
+ /** True when the always-loaded file is there and still carries an opened setup-pending banner. */
913
+ function hasOpenSetupBanner(repoRoot) {
914
+ try {
915
+ return readFileSync(join(repoRoot, CLAUDE_MD_PATH), 'utf8').includes(SETUP_PENDING_OPEN);
916
+ }
917
+ catch {
918
+ return false;
919
+ }
920
+ }
921
+ /**
922
+ * Settle the offer to run {@link ANALYZE_COMMAND} in this repository's first session.
923
+ *
924
+ * **The third of the four questions this command puts**, and — like all four — settled before
925
+ * `plan.apply`, so no answer is taken after anything has been written: the repository itself
926
+ * ({@link resolveTargetRepository}), which driver the interactive test phase runs
927
+ * ({@link askQaDriver}, asked lazily inside the config generator), this offer, and push
928
+ * notifications ({@link resolveNotifications}). Moving the call site breaks that order and this
929
+ * sentence with it.
930
+ *
931
+ * **The documented default is yes, and the interaction rule's continuity clause holds in two halves
932
+ * rather than one** (`docs/cli.md` §2). The *pointer* half is exactly the behaviour this release
933
+ * already had: every earlier release ended by telling every adopter to run this command
934
+ * ({@link reportNextSteps}), so taking the default leaves the run saying what it has always said and
935
+ * *declining* is the new option — which is why the default is yes. The *recorded-intent* half is
936
+ * **new in this release**: no earlier release wrote any banner into the generated always-loaded file,
937
+ * and the declined answer writes one too.
938
+ *
939
+ * There is deliberately **no** size threshold, duration estimate or any other condition that
940
+ * silently declines — the adopter consented to this specific work — and the answer costs this
941
+ * command no model call and no external process.
942
+ */
943
+ function resolveAnalyzeOffer(ctx, flags, repoRoot, config) {
944
+ // Read from the filesystem **before** the plan is built, because the generator enqueues the banner
945
+ // while the plan is being assembled: by the time it has spoken, "was this file already there" and
946
+ // "was this document still a skeleton" are no longer answerable, and the answer is an *input* to
947
+ // that generator rather than a reading of what it produced.
948
+ const documents = conventionsDocuments(config);
949
+ const filledConventions = documents.filter((path) => !isUntouchedSkeleton(repoRoot, path));
950
+ const facts = { claudeMdExisted: existsSync(join(repoRoot, CLAUDE_MD_PATH)), filledConventions };
951
+ // (1) The flag pair folded into the tri-state, with no prompt either way. Both-given was refused in
952
+ // the parser, so at most one is true here — and neither being true is the "not answered" state the
953
+ // rest of this function handles.
954
+ if (flags.noAnalyze === true)
955
+ return { offer: 'declined', nothingToFill: false, notes: [], ...facts };
956
+ if (flags.analyze === true)
957
+ return { offer: 'accepted', nothingToFill: false, notes: [], ...facts };
958
+ // (2) The skip branch: nothing is left to fill, and nothing this run does will unfill it. All
959
+ // three conditions have to hold, and each is load-bearing:
960
+ //
961
+ // (2a) **the run is not forced.** `--force` upgrades every `create-if-absent` request without its
962
+ // own `forceOverride` to overwrite-after-backup (`core/writer.ts`), and the conventions stubs
963
+ // are enqueued that way for every document that is not already a skeleton
964
+ // (`generators/claudeContext.ts` fixes `forceOverride: 'never'` on that one case, where the
965
+ // `.bak` holds the only surviving analysis) — so a forced run over an analyzed repository
966
+ // rewrites every filled document back to its skeleton and regenerates the always-loaded file
967
+ // from the template. "Nothing is left to fill" is then true of the tree this run *found* and
968
+ // false of the tree it is *about to produce*, and this branch reads the found one.
969
+ // (2b) **no document is still an untouched skeleton** ({@link isUntouchedSkeleton}, both halves).
970
+ // (2c) **the always-loaded file exists and carries no open banner.** It exists, so an unforced run
971
+ // keeps it and no banner can be written into it whatever the answer; and no open banner,
972
+ // because one still there is a pass that never completed — precisely the case that must not go
973
+ // unasked. Deliberately not "does not exist or has no open banner": a repository whose
974
+ // documents are filled but whose always-loaded file was deleted is one this run *creates* that
975
+ // file for, so a banner will be written and its wording has to be chosen by an answer.
976
+ if (!ctx.flags.force &&
977
+ filledConventions.length === documents.length &&
978
+ facts.claudeMdExisted &&
979
+ !hasOpenSetupBanner(repoRoot)) {
980
+ // `offer` is a placeholder on this path — no answer was taken. (2a) + (2c) keep it out of the
981
+ // generator: the file is kept, so no banner is written whatever it says. It is `nothingToFill`
982
+ // that keeps the closing wording from reading it as a declined answer.
983
+ return {
984
+ offer: 'declined',
985
+ nothingToFill: true,
986
+ notes: [
987
+ `the conventions documents are already filled, so ${ANALYZE_COMMAND} was not offered again — run it at any time to revise them, and \`${DOCTOR_COMMAND}\` for what is still unfilled`,
988
+ ],
989
+ ...facts,
990
+ };
991
+ }
992
+ const promptCtx = { flags: ctx.flags, report: ctx.report };
993
+ // (3) A run that cannot be asked takes the documented default and says so **here**, rather than
994
+ // leaving the note to {@link askYesNo}: that function prints only on its `'no-terminal'` path and
995
+ // returns the default silently under `--non-interactive` and `--quiet` (`core/prompt.ts`), so the
996
+ // note would be missing from exactly the unattended run that most needs it. One line, one
997
+ // producer, both reasons — and through the reporter's `info` like every other narration, so
998
+ // `--quiet` still suppresses it with the rest.
999
+ if (!canPrompt(promptCtx)) {
1000
+ return {
1001
+ offer: 'accepted',
1002
+ nothingToFill: false,
1003
+ notes: [
1004
+ `the analyze offer was not put — no terminal, --non-interactive or --quiet — so it took its documented default and was accepted: pass ${NO_ANALYZE_FLAG} to decline it without being asked`,
1005
+ ],
1006
+ ...facts,
1007
+ };
1008
+ }
1009
+ // (4) A terminal, and neither flag given: the one question this offer puts. The bracketed default
1010
+ // is yes, and the question says what is about to happen and where it happens.
1011
+ const accepted = askYesNo({
1012
+ question: `Run ${ANALYZE_COMMAND} in this repository's first session? It reads this repository's code and writes its conventions documents from what it finds — it runs in a session rather than here, so ${INIT_VERB} analyzes nothing itself.`,
1013
+ defaultAnswer: true,
1014
+ flag: NO_ANALYZE_FLAG,
1015
+ flagHint: 'to decline it without being asked',
1016
+ }, promptCtx);
1017
+ return { offer: accepted ? 'accepted' : 'declined', nothingToFill: false, notes: [], ...facts };
1018
+ }
1019
+ /**
1020
+ * Settle whether this account gets told when an unattended run finishes, and where.
1021
+ *
1022
+ * **The documented default is off**, which is exactly what every release before this one did:
1023
+ * delivery has shipped since the outer-loop scripts did, and nobody was ever asked, so an operator
1024
+ * who never read `docs/watcher.md` §6 got a desktop banner where one was available and nothing else.
1025
+ * The default therefore changes no existing behaviour — it is the *asking* that is new.
1026
+ *
1027
+ * The two questions are asked in order and the second only inside the first's yes, because an
1028
+ * endpoint is meaningless without the opt-in and the opt-in writes nothing without an endpoint. The
1029
+ * endpoint question carries no default: `askLine` answers `undefined` with none, and `undefined` is
1030
+ * what routes the run to the generator's guided-setup note rather than to a file — writing an empty
1031
+ * machine-local file would shadow a repository-side one that already has values
1032
+ * (`generators/notifications.ts`, choice 1).
1033
+ *
1034
+ * A `--push-url` passed **without** the opt-in is left in place rather than dropped here: the
1035
+ * generator owns what that means and warns about it, as it owns every other line about the artifact
1036
+ * it writes.
1037
+ */
1038
+ function resolveNotifications(ctx, flags) {
1039
+ const promptCtx = { flags: ctx.flags, report: ctx.report };
1040
+ const enabled = flags.notifications === true ||
1041
+ askYesNo({
1042
+ question: 'Set up push notifications for unattended runs?',
1043
+ defaultAnswer: false,
1044
+ flag: NOTIFICATIONS_FLAG,
1045
+ flagHint: 'to set them up without being asked',
1046
+ }, promptCtx);
1047
+ const pushUrl = flags.pushUrl ??
1048
+ (enabled
1049
+ ? askLine({
1050
+ question: `Where should notifications be posted? (any endpoint that accepts a POST, e.g. ${GUIDED_ENDPOINT_EXAMPLE})`,
1051
+ flag: PUSH_URL_FLAG,
1052
+ }, promptCtx)
1053
+ : undefined);
1054
+ return pushUrl === undefined ? { enabled } : { enabled, pushUrl };
1055
+ }
1056
+ /** What detection concluded, as one line the summary can carry. */
1057
+ function detectionLine(detection) {
1058
+ if (detection.matchedSignal === FORCED_SIGNAL_ID) {
1059
+ return `--preset selected the \`${detection.preset}\` layer preset, so no detection signal was evaluated`;
1060
+ }
1061
+ const evidence = detection.evidence === undefined ? '' : ` — ${detection.evidence}`;
1062
+ return `detected the \`${detection.preset}\` layer preset (signal ${detection.matchedSignal}${evidence})`;
1063
+ }
1064
+ /**
1065
+ * What answered for the **commands**, as the second line of the same step — the half
1066
+ * {@link detectionLine} says nothing about, because that line is a verdict on the layer preset alone.
1067
+ *
1068
+ * The two halves are decided independently (`detect/presets.ts`, `COMMAND_FAMILIES`), so a run whose
1069
+ * layer table fell to `flat:fallback` while a family answered off the root manifest reported only the
1070
+ * pessimistic half: a grep of a whole init capture for any manifest or family name returned nothing.
1071
+ *
1072
+ * **It reads {@link PresetProfile}'s recorded fields and derives none of them.** The pair naming the
1073
+ * source is {@link PresetProfile.commandManifest}'s, and which required keys that source actually
1074
+ * supplied is {@link PresetProfile.resolvedRequired}'s — both composed by `commandSourceClaim`, the
1075
+ * same composer the two detection notes use, so one run's output cannot name that source two ways
1076
+ * nor claim a key the same run warns is a placeholder. Never an entry picked out of
1077
+ * {@link PresetProfile.readManifests} by position: those two lists are ordered by different tables.
1078
+ * `readManifests` is read here for a **count** and nothing else.
1079
+ *
1080
+ * It states no rule about the search and claims nothing about whether the other manifests were
1081
+ * consulted: the collision's detail is the tiebreak note's, and the clause pointing at it is gated on
1082
+ * that note being **published** as well as raised. Raising it is `buildPreset`'s gate
1083
+ * (`readManifests.length > 1`); publishing it is the `config.kept` gate at the call site, which drops
1084
+ * `profile.notes` wholesale on a kept re-run (`generatedConfigNotes`). Reproducing the raise gate
1085
+ * alone is what let this clause point at a note the run never printed, so `kept` suppresses it.
1086
+ *
1087
+ * `kept` also decides the **verb**. Every other line of this shape asserts what a key *was written
1088
+ * as*, and a kept re-run wrote no key — the standing local rule `generators/harnessConfig.ts` gates
1089
+ * `notesIfWritten` on. So on a kept re-run this line reports what **detection resolved**, never what
1090
+ * is in effect; what is in effect is the kept file's, and `writeHarnessConfig`'s replacement note
1091
+ * says so.
1092
+ *
1093
+ * @param options.kept whether this run keeps an existing config rather than writing one — the same
1094
+ * fact `writeHarnessConfig` computes as `kept`, derived at the call site because this line prints
1095
+ * before that generator runs.
1096
+ */
1097
+ function commandSourceLine(profile, detection, options) {
1098
+ const resolvedNoLine = options.kept
1099
+ ? 'this run resolved no line for commands.typecheck or commands.test'
1100
+ : 'commands.typecheck and commands.test carry placeholders';
1101
+ if (profile.commandFamily !== undefined) {
1102
+ const claim = commandSourceClaim({
1103
+ family: profile.commandFamily,
1104
+ manifest: profile.commandManifest,
1105
+ resolvedRequired: profile.resolvedRequired,
1106
+ }, options.kept ? 'detection-resolved' : 'came-from');
1107
+ const others = !options.kept && profile.readManifests.length > 1
1108
+ ? profile.readManifests.filter((entry) => entry !== profile.commandManifest).length
1109
+ : 0;
1110
+ const collision = others === 0
1111
+ ? ''
1112
+ : ` — ${others} other manifest${others === 1 ? '' : 's'} init reads ${others === 1 ? 'is' : 'are'} present too, and the tiebreak note names ${others === 1 ? 'it' : 'them'} and what became of ${others === 1 ? 'it' : 'them'}`;
1113
+ return `${claim}${collision}`;
1114
+ }
1115
+ // The one positional read of this list, and it is in the arm where **no family answered**: there is
1116
+ // nothing to pair a manifest with here, only a present file to name so the placeholder pair is not
1117
+ // reported against a repository that looks empty. The prohibition is on pairing an entry with a
1118
+ // family by position ({@link PresetProfile.commandManifest}), and no arm above does that.
1119
+ const [first] = profile.readManifests;
1120
+ if (first === undefined) {
1121
+ return `no command family answered: no manifest init reads is present in ${searchedRoots(detection.context)}, so ${resolvedNoLine}`;
1122
+ }
1123
+ const rest = profile.readManifests.length - 1;
1124
+ const alongside = rest === 0 ? '' : ` (with ${rest} other manifest${rest === 1 ? '' : 's'} init reads)`;
1125
+ return `no command family answered, so ${resolvedNoLine} — \`${first}\` is present${alongside} and supplied neither line`;
1126
+ }
1127
+ /**
1128
+ * The command keys the `commands` step walks, in the fixed order it prints them: the two verifiers
1129
+ * first, then the dev server, then the two unwrapped lines, then the deploy command on its own
1130
+ * object.
1131
+ *
1132
+ * Order is stated here rather than taken from `WRAPPER_SCRIPTS`, because the report covers **more**
1133
+ * than the wrapper set — `build` and `depInstall` are deliberately never wrapped
1134
+ * (`generators/scripts.ts`) and are still commands this run put into effect.
1135
+ */
1136
+ const COMMAND_REPORT_ORDER = Object.freeze([
1137
+ 'typecheck',
1138
+ 'test',
1139
+ 'devServer',
1140
+ 'build',
1141
+ 'depInstall',
1142
+ 'deploy',
1143
+ ]);
1144
+ /** The same key as a {@link WrapperKey}, or `undefined` for one of the two unwrapped keys. */
1145
+ function asWrapperKey(key) {
1146
+ return WRAPPER_SCRIPTS.find((script) => script.key === key)?.key;
1147
+ }
1148
+ /** What a wrapped row prints in place of a command line for {@link WrapperBody}'s `unresolved` body. */
1149
+ const RUNS_NO_COMMAND = 'runs no command; the wrapper reports what to fix';
1150
+ /**
1151
+ * The body the wrapper **on disk** carries, or `undefined` when this run is the one that writes it.
1152
+ *
1153
+ * The path is composed from the generator's own `scriptsDir` and `file`, never parsed back out of a
1154
+ * `commands.*` value — `configuredWrapperFile`'s standing prohibition (`generators/scripts.ts`).
1155
+ * This runs while the plan is still being built, so `existsSync` answers about the repository as this
1156
+ * run found it. `--force` upgrades `create-if-absent` to overwrite-after-`.bak` (`core/writer.ts`), so
1157
+ * a forced run writes its own body and the file on disk has nothing to say.
1158
+ *
1159
+ * A read that throws is answered `unrecognised`, the same state an adopter's unreadable edit reports:
1160
+ * both mean *this command cannot say what that file runs*, and neither is a licence to guess.
1161
+ */
1162
+ function keptWrapperBody(options) {
1163
+ const path = join(options.repoRoot, options.scriptsDir, options.file);
1164
+ if (options.force || !existsSync(path))
1165
+ return undefined;
1166
+ try {
1167
+ return wrapperCommandLine(readFileSync(path, 'utf8'));
1168
+ }
1169
+ catch {
1170
+ return { kind: 'unrecognised' };
1171
+ }
1172
+ }
1173
+ /**
1174
+ * One wrapped key's row: what that wrapper will run, and where that line came from.
1175
+ *
1176
+ * **The kept line and the resolved line stay two facts rather than one.** Where they differ the row
1177
+ * leads with the kept line, because that is what executes, and names the resolved one after it — an
1178
+ * adopter who edited a wrapper to correct its command and re-ran `init` is exactly the reader who
1179
+ * needs to see both, and that difference is the state `--force` exists for.
1180
+ */
1181
+ function wrappedRowLine(options) {
1182
+ const { keyPath, wrapper, kept, dryRun } = options;
1183
+ const head = `${keyPath} -> ${wrapper.invocation} ->`;
1184
+ const resolved = wrapper.unresolved ? RUNS_NO_COMMAND : wrapper.command;
1185
+ if (kept === undefined) {
1186
+ const writes = dryRun ? 'a real run would write it' : 'this run writes it';
1187
+ return `${head} ${resolved} — the line this run resolved for ${wrapper.file}; ${writes}`;
1188
+ }
1189
+ if (kept.kind === 'unrecognised') {
1190
+ return `${head} ${resolved} — the line this run resolved for ${wrapper.file}; the wrapper on disk could not be read, so what that file runs is not reported here`;
1191
+ }
1192
+ const runs = kept.kind === 'unresolved' ? RUNS_NO_COMMAND : kept.command;
1193
+ const difference = runs === resolved ? '' : `; this run resolved ${resolved}, and --force is what replaces the file with it`;
1194
+ return `${head} ${runs} — the line ${wrapper.file} already carries${difference}`;
1195
+ }
1196
+ /**
1197
+ * The residue an answered key leaves behind, as a clause, or `''` when there is none.
1198
+ *
1199
+ * The answered row says what **this run** did; this says what an earlier one left. Wrappers are
1200
+ * `create-if-absent` and no generator deletes, so a repository that had a real command — whose `init`
1201
+ * wrote the wrapper and its allow entries — and later answers the key keeps that file. The clause is
1202
+ * printed only when the file is actually there, so the row never names a residue this repository does
1203
+ * not have.
1204
+ *
1205
+ * The permission half is split on `force` because that is the flag that regenerates the profile: an
1206
+ * unforced run leaves the entries naming the file exactly where they are, and a forced one is itself
1207
+ * the run that drops them. `existsSync` answers about the repository as this run found it, so a dry
1208
+ * run reports the same residue a real one would.
1209
+ */
1210
+ function answeredNoneResidue(options) {
1211
+ const { repoRoot, scriptsDir, key, force, dryRun } = options;
1212
+ if (key === undefined)
1213
+ return '';
1214
+ const file = WRAPPER_SCRIPTS.find((script) => script.key === key)?.file;
1215
+ if (file === undefined || !existsSync(join(repoRoot, scriptsDir, file)))
1216
+ return '';
1217
+ const entries = force
1218
+ ? `this run ${dryRun ? 'would regenerate' : 'regenerated'} the profile without its permission entries`
1219
+ : 'its permission entries are still in the profile, and init --force is what clears them';
1220
+ return `; ${wrapperPath(scriptsDir, file)} an earlier run wrote is still on disk — nothing here deletes it — and ${entries}`;
1221
+ }
1222
+ /**
1223
+ * Every command line this run put in effect, one line per key — what the `commands` step prints,
1224
+ * identically in a real run and under `--dry-run`, save for the write provenance a dry run states in
1225
+ * the conditional.
1226
+ *
1227
+ * **A wrapped row's third field is the line that will *run*, which is not always the line this run
1228
+ * resolved.** Wrappers are `create-if-absent` (`generators/scripts.ts`), so over a wrapper already on
1229
+ * disk the body resolved here is discarded and the kept file keeps running its own line —
1230
+ * {@link WrittenWrapper.command} carries that caveat, and {@link keptWrapperBody} is what reads the
1231
+ * file so the row does not inherit it. Each row therefore says which of the two it printed.
1232
+ *
1233
+ * Composed from the config **in effect** plus the generator's own answer, never from detection: on a
1234
+ * kept re-run the adopter's file is what every later generator read, and it is what an unattended run
1235
+ * will execute.
1236
+ */
1237
+ function commandReportLines(options) {
1238
+ const { repoRoot, config, wrappers, force, dryRun } = options;
1239
+ const lines = [];
1240
+ for (const key of COMMAND_REPORT_ORDER) {
1241
+ const wrapperKey = asWrapperKey(key);
1242
+ const keyPath = wrapperKey === undefined ? `commands.${key}` : configKeyPath(wrapperKey);
1243
+ const configured = configuredCommand(config, key);
1244
+ const wrapper = wrappers.written.find((entry) => entry.key === key);
1245
+ // Both of the next two arms are read before the wrapper lookup, for the same reason by two
1246
+ // different routes: `selectWrapper` writes no file for either value, so there is no line to
1247
+ // print behind it.
1248
+ // The placeholder first, because it is the one value that is neither a command nor a wrapper
1249
+ // invocation: `selectWrapper` writes no file for it, so there is no line to print behind it.
1250
+ if (configured !== undefined && isPlaceholder(configured)) {
1251
+ lines.push(`${keyPath} -> ${configured} — the placeholder init writes for a command it could not detect${wrapperKey === undefined ? '' : ', so no wrapper was written for it'}; nothing runs until it is set`);
1252
+ continue;
1253
+ }
1254
+ // The sentinel next, gated on the key and not on the value's shape: `answersNone` answers for
1255
+ // `typecheck` alone, so none of the other five keys prints this row for a line this run did in
1256
+ // fact wrap and allow-list. `init` never writes the sentinel, so this row is reachable only on
1257
+ // a re-run over a config an adopter has already answered.
1258
+ if (answersNone(key, configured)) {
1259
+ lines.push(`${keyPath} -> ${configured} — this repository states it has no such command${wrapperKey === undefined ? '' : ', so no wrapper was written for it'}; this run allow-listed none, and nothing runs for this key${answeredNoneResidue({
1260
+ repoRoot,
1261
+ scriptsDir: wrappers.scriptsDir,
1262
+ key: wrapperKey,
1263
+ force,
1264
+ dryRun,
1265
+ })}`);
1266
+ continue;
1267
+ }
1268
+ if (wrapper !== undefined) {
1269
+ const kept = keptWrapperBody({ repoRoot, scriptsDir: wrappers.scriptsDir, file: wrapper.file, force });
1270
+ if (wrapper.unresolved || configured === wrapper.invocation || configured === undefined) {
1271
+ lines.push(wrappedRowLine({ keyPath, wrapper, kept, dryRun }));
1272
+ }
1273
+ else {
1274
+ // Precedence 1 of `resolveBody`: the key holds a raw line, and this run inlines it into the
1275
+ // wrapper the permission profile allow-lists — but only where that file is not already there,
1276
+ // which is `create-if-absent`'s whole contract.
1277
+ const inlined = kept === undefined
1278
+ ? `, which this run ${dryRun ? 'would inline' : 'inlined'} into ${wrapper.file}: the wrapper is the form the permission profile allow-lists`
1279
+ : kept.kind === 'unrecognised'
1280
+ ? `; ${wrapper.file} is already on disk and could not be read, so what that file runs is not reported here`
1281
+ : `; ${wrapper.file} is already on disk and carries ${kept.kind === 'unresolved' ? RUNS_NO_COMMAND : kept.command}, and --force is what replaces the file with this line`;
1282
+ lines.push(`${keyPath} -> ${configured} — a raw command line${inlined}`);
1283
+ }
1284
+ continue;
1285
+ }
1286
+ // No wrapper: either one of the two keys that are never wrapped, or an optional wrapped key this
1287
+ // repository set no line for — which prints nothing at all.
1288
+ if (configured === undefined)
1289
+ continue;
1290
+ lines.push(`${keyPath} -> ${configured} — run as configured; this key is not wrapped and is not allow-listed`);
1291
+ }
1292
+ return lines;
1293
+ }
1294
+ /** True when `path` names a directory that is there — the second half of the rung-2 screen. */
1295
+ function isDirectory(path) {
1296
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
1297
+ }
1298
+ /**
1299
+ * The `appDir` **stack detection probes**, in one settled order:
1300
+ *
1301
+ * 1. {@link APP_DIR_FLAG}, this command line's own statement;
1302
+ * 2. {@link CONFIG_FILENAME}'s `appDir` — the tree the repository already declares;
1303
+ * 3. `.`, the repository as its own application.
1304
+ *
1305
+ * Rung 2 is what this function exists for. The sole `detectPreset` call read the flag or `.` and
1306
+ * never the file, which both JSDoc blocks on the receiving end already documented it as reading
1307
+ * (`detect/signals.ts`, `DetectContext`'s constructor and `detectPreset`). Without it a plain re-run on a
1308
+ * nested-app repository detects the *root's* layout and reports it beside layers read from the kept
1309
+ * config, and `init --reset-config` — the path this CLI prescribes for rebuilding a configuration —
1310
+ * re-derives every layer scope under `.`, forgetting an application directory that was `init`'s own
1311
+ * output, with no line of output naming what was lost.
1312
+ *
1313
+ * **The two bad-value arms are deliberately asymmetric, and that asymmetry is the point.** A
1314
+ * flag-sourced value outside the repository keeps `DetectContext`'s hard refusal — it is a typo
1315
+ * or a mis-scoped invocation, and this function does not screen it. Anything **config**-sourced —
1316
+ * a value that cannot be used, and equally a file that cannot be read at all — falls back to `.`
1317
+ * with a warning instead: `--reset-config` is the documented route to repair a broken configuration,
1318
+ * so a refusal sourced from the very file being rebuilt would make a repository with a bad `appDir`
1319
+ * unrepairable by the one command documented to repair it. Silence is the arm that is not available
1320
+ * on any of them: `.` re-derives every layer scope under the repository root.
1321
+ *
1322
+ * Modelled on `resolveDefaultBranch` (`generators/harnessConfig.ts`), the shipped precedent for a
1323
+ * per-rung resolver that reports **which rung answered** rather than only the value.
1324
+ */
1325
+ function resolveDetectionAppDir(repoRoot, flags) {
1326
+ // Rung 1, and silent: the flag is on the adopter's own command line, so a note restating it tells
1327
+ // them nothing they cannot already see.
1328
+ if (flags.appDir !== undefined)
1329
+ return { appDir: flags.appDir, source: 'flag' };
1330
+ // Rung 2. `loadConfig` never throws — an absent or malformed file is reported as a problem and
1331
+ // answers `config: undefined` (`config/io.ts`). The type check is not redundant with that: the
1332
+ // returned type is an assertion about the parsed value rather than a guarantee.
1333
+ const loaded = loadConfig(repoRoot);
1334
+ const configured = loaded.config?.appDir;
1335
+ if (typeof configured !== 'string' || configured === '') {
1336
+ // A file that is there but did not parse is not the same answer as no file at all, and the two
1337
+ // are told apart here because `--reset-config` — the documented repair for exactly that file —
1338
+ // is the run that reaches this branch: a silent `.` re-derives every layer scope under the root
1339
+ // while the rebuild note says the application directory survived. Third arm of the same screen
1340
+ // the two bad-value rejections below carry, and it warns for the same reason they do.
1341
+ if (loaded.config === undefined && configExists(repoRoot)) {
1342
+ return {
1343
+ appDir: '.',
1344
+ source: 'default',
1345
+ warning: `${CONFIG_FILENAME} is present at ${loaded.path} but could not be read, so no configured appDir answered and stack detection probed the repository root instead: ${loaded.problems.map(formatProblem).join('; ')}. Every layer scope this run derives is under the repository root: re-run with ${APP_DIR_FLAG} <dir> to name the application directory this repository actually has`,
1346
+ };
1347
+ }
1348
+ return { appDir: '.', source: 'default' };
1349
+ }
1350
+ const absolute = resolve(repoRoot, configured);
1351
+ const rejection = !insideRepo(repoRoot, absolute)
1352
+ ? 'resolves outside the repository'
1353
+ : isDirectory(absolute)
1354
+ ? undefined
1355
+ : 'names no directory that is there';
1356
+ if (rejection !== undefined) {
1357
+ // `source` is `'default'` rather than `'config'`: the file was consulted, and `.` is what
1358
+ // answered.
1359
+ return {
1360
+ appDir: '.',
1361
+ source: 'default',
1362
+ warning: `${CONFIG_FILENAME} sets appDir to ${JSON.stringify(configured)}, which ${rejection}, so stack detection probed the repository root instead — refusing here would leave a repository with a bad appDir unrepairable by \`${INIT_VERB} --reset-config\`, which is the documented way to rebuild that file. Every layer scope this run derives is under the repository root: re-run with ${APP_DIR_FLAG} <dir> to name the application directory this repository actually has`,
1363
+ };
1364
+ }
1365
+ return {
1366
+ appDir: configured,
1367
+ source: 'config',
1368
+ // Worded to stay true of the `.` an already-wired flat repository carries: it says which rung
1369
+ // the value came from, not that the value differs from the root.
1370
+ note: `stack detection probed ${JSON.stringify(configured)} — the appDir ${CONFIG_FILENAME} already declares — rather than a directory given on this command line; ${APP_DIR_FLAG} <dir> overrides it, and \`${APP_DIR_FLAG} .\` points detection at the repository root`,
1371
+ };
1372
+ }
1373
+ /**
1374
+ * The directory this run was **invoked in**, repo-relative — and `undefined` when that is the
1375
+ * repository root itself, which is the ordinary case and has nothing to report.
1376
+ *
1377
+ * `realpathSync` rather than `ctx.cwd` as it was spelled, for the reason
1378
+ * {@link resolveTargetRepository}'s `--dry-run` arm resolves the same value: git answers with the
1379
+ * physical path, and a caller's directory may reach it through a symlink (`os.tmpdir()` and `/var` on
1380
+ * macOS are two). A lexical comparison against an unresolved `cwd` would call an ordinary root-level
1381
+ * run nested and print the lines below on every one of them.
1382
+ *
1383
+ * Outside the root is `undefined` too, and unreachable rather than handled: the root came from a
1384
+ * probe of this very directory ({@link resolveTargetRepository}), so a `cwd` outside it is a state no
1385
+ * invocation produces.
1386
+ */
1387
+ function invocationBelowRoot(ctx, repoRoot) {
1388
+ const invokedIn = realpathSync(ctx.cwd);
1389
+ if (!insideRepo(repoRoot, invokedIn))
1390
+ return undefined;
1391
+ const rel = normalizeRepoDir(relative(repoRoot, invokedIn));
1392
+ return rel === '.' ? undefined : rel;
1393
+ }
1394
+ /**
1395
+ * The source directories the config **in effect** covers with no layer, as one note — or `undefined`
1396
+ * where there are none to name.
1397
+ *
1398
+ * **The set and the remedy are both somebody else's**: `core/layerCoverage.ts` derives the
1399
+ * directories, `core/nameList.ts` renders them and `core/layerGapRemedy.ts` composes what to do
1400
+ * about them, all three shared with `doctor`'s `layer-drift` check. This function decides only that
1401
+ * `init` says it, and the two commands are then one statement rather than two findings — which is
1402
+ * the whole point: before this line, a gap in a detected profile appeared in `doctor` alone, so an
1403
+ * adopter who read `init` and never ran `doctor` learned nothing about it.
1404
+ *
1405
+ * **A note, never a warning.** A freshly detected repository routing one directory to the catch-all
1406
+ * is an ordinary adoption outcome, and this command's warnings are reserved for what needs fixing.
1407
+ *
1408
+ * **Nothing is said where `layerCoverage` does not grade**: that state is the catch-all-only profile,
1409
+ * which `buildPreset` already warns about and `LAYER_PROFILE_CHECK` reports as itself, and a second
1410
+ * line here would report it twice.
1411
+ */
1412
+ function layerGapNote(repoRoot, config) {
1413
+ const { graded, appDir, candidates, uncovered } = layerCoverage({ repoRoot, config });
1414
+ if (!graded || uncovered.length === 0)
1415
+ return undefined;
1416
+ const one = uncovered.length === 1;
1417
+ const clause = recordedVerdictClause(config.detection?.review);
1418
+ const remedy = layerGapRemedy({ review: config.detection?.review, analyzeCommand: ANALYZE_COMMAND, cli: CLI });
1419
+ return (`${uncovered.length} of the ${candidates.length} source directories under \`${appDir}\` ${one ? 'is' : 'are'} covered by no layer and ${one ? 'routes' : 'route'} to the catch-all: ${nameList(uncovered)} — an implementer and a reviewer working there are handed the shared cross-layer document rather than that layer's own rules.` +
1420
+ `${clause === undefined ? '' : ` ${clause}.`} ${remedy}. \`${DOCTOR_COMMAND}\`'s \`layer-drift\` check reports this same set, so the two commands are one statement rather than two findings`);
1421
+ }
1422
+ /**
1423
+ * Wire one repository: detect, build the whole write plan bottom-up, apply it once, report what
1424
+ * happened, and end by pointing at the analyze command.
1425
+ *
1426
+ * The generator order below is load-bearing and is stated in one place, here: the config first
1427
+ * because everything after it reads the config **in effect**; the wrapper scripts and then the
1428
+ * outer-loop scripts, because the profile allow-lists their literal paths and both land in the
1429
+ * configured `scriptsDir`; the state tree and the conventions stubs; the profile;
1430
+ * the committable project settings; the repository-root files; the account's own push-notification
1431
+ * settings, which are the one target outside the repository and are ordered here only because their
1432
+ * question is the last one the run puts; and the git hook last, with
1433
+ * `core.hooksPath` pointed at it only **after** the plan has been applied and the hook it enables
1434
+ * therefore exists — and after that, in a repository that has no commit yet, the first commit
1435
+ * ({@link commitGeneratedFiles}).
1436
+ */
1437
+ async function run(ctx) {
1438
+ const flags = parseInitFlags(ctx.argv);
1439
+ // Settled from the flags alone, and therefore ahead of the git gate: `resolveTargetRepository` may
1440
+ // create a repository, and a refusal raised after it would leave one behind in a directory this run
1441
+ // then declined to wire — the harm {@link parseQaDriver} is checked inside the parser to avoid.
1442
+ assertUsableStateDir(flags.stateDir);
1443
+ const forcedPreset = flags.preset === undefined ? undefined : parsePresetName(flags.preset);
1444
+ // Every precondition that needs the repository is settled here, before a generator is called and
1445
+ // therefore before anything is enqueued: a plan that is never built cannot be half-applied. The
1446
+ // first is settled by an answer — the flag, a prompt, or the non-interactive default — the second
1447
+ // by a check against the root that answer resolved.
1448
+ const target = resolveTargetRepository(ctx, flags);
1449
+ const repoRoot = target.root;
1450
+ assertNotHarnessOwnRepository(repoRoot);
1451
+ // The one fact the rest of this run is relative to, named before any of it: every path below is
1452
+ // under this root, and until this line an adopter standing in a subdirectory had ninety lines of
1453
+ // output and nothing saying where they landed. A **step** rather than a note, and the same shape
1454
+ // `doctor` opens with (`commands/doctor.ts`'s `checks (<root>)`), so the two commands name a
1455
+ // repository the same way — a note would print at the end, after the reader has already read the
1456
+ // run relative to an unnamed root. `--quiet` suppresses it with the rest of the narration, which is
1457
+ // correct: a quiet run has no reader to orient.
1458
+ ctx.report.step(`wiring (${repoRoot})`);
1459
+ // Settled before anything is reported, and after the refusals above so nothing is enqueued if this
1460
+ // one refuses too — a flag-sourced value outside the repository still throws out of
1461
+ // `DetectContext` ({@link resolveDetectionAppDir}). The whole answer stays in scope rather than
1462
+ // only its value: `appDir.source` is what a later reader gates on, and it is the only carrier of
1463
+ // which rung produced the directory detection probed.
1464
+ const appDir = resolveDetectionAppDir(repoRoot, flags);
1465
+ const detection = detectPreset(repoRoot, appDir.appDir, forcedPreset);
1466
+ const profile = buildPreset(detection);
1467
+ // Collected rather than printed as they arrive, so the run reports its actions first and its
1468
+ // caveats after them — a warning raised by the first generator is otherwise scrolled off by the
1469
+ // hundred action lines that follow it.
1470
+ const warnings = [];
1471
+ // `target.notes` leads: it is a repository fact, true whichever config this run ends up with, and
1472
+ // it stays unconditional.
1473
+ const notes = [...target.notes];
1474
+ // The four detection lists, held back rather than seeded: every line in them describes the config
1475
+ // this run **generated**, which `writeHarnessConfig` discards on a kept re-run — a placeholder
1476
+ // warning naming a key the kept file resolved, a note naming the manifest a `commands.*` value the
1477
+ // kept file does not hold was derived from. Warnings and notes move behind one `config.kept` gate
1478
+ // below for that one reason, and neither may be pinned here as unconditional.
1479
+ const generatedConfigWarnings = [...detection.warnings, ...profile.warnings];
1480
+ const generatedConfigNotes = [...detection.notes, ...profile.notes];
1481
+ // The resolver's own two lines are statements about **this invocation** — which rung supplied the
1482
+ // directory detection probed, and what a configured value that could not be used cost — so they are
1483
+ // unconditional, like `target.notes` and unlike the four lists above them: both stay true of a
1484
+ // re-run that keeps the very file rung 2 read. Same rule as the sink split in
1485
+ // `generators/harnessConfig.ts`'s `buildConfig`, one level up.
1486
+ if (appDir.warning !== undefined)
1487
+ warnings.push(appDir.warning);
1488
+ if (appDir.note !== undefined)
1489
+ notes.push(appDir.note);
1490
+ // Two more statements about **this invocation**, and unconditional for the same reason those two
1491
+ // are. Both are gated on `appDir.source` — the field, never the absence of the two message
1492
+ // fields ({@link DetectionAppDir}) — so an adopter who has already answered the application-directory
1493
+ // question, on this command line or in the file, is not told to answer it again.
1494
+ //
1495
+ // The first is a **note**: wiring the root is what `init` is supposed to do from anywhere inside the
1496
+ // repository, and a warning that fires on every correct run is how a warning list stops being read
1497
+ // (`generators/harnessConfig.ts`). The second is a warning, and only where detection recognised
1498
+ // nothing — in a repository that nests its application that is the one case where the silent `.`
1499
+ // is the likeliest *cause* rather than an incidental fact. It is pushed as a second line rather than
1500
+ // folded into `FLAT_FALLBACK_WARNING`, which is detection's and knows nothing about the directory
1501
+ // this command was invoked in (`detect/signals.ts`).
1502
+ const invokedIn = invocationBelowRoot(ctx, repoRoot);
1503
+ if (invokedIn !== undefined && appDir.source === 'default') {
1504
+ notes.push(`init was run in ${invokedIn} and wired the repository at ${repoRoot}, which is where every file it writes lands; appDir is ${JSON.stringify(appDir.appDir)}, because no application directory this run could use was named. If ${invokedIn} is the application, re-run with ${APP_DIR_FLAG} ${invokedIn}`);
1505
+ if (detection.matchedSignal === FLAT_FALLBACK_SIGNAL_ID) {
1506
+ warnings.push(`no detection signal matched, so the \`flat\` preset caught this run — and stack detection probed the repository root while init was run in ${invokedIn}. Where a repository nests its application, an application that was never named is the likeliest reason nothing is recognised: re-run with ${APP_DIR_FLAG} ${invokedIn} to detect that tree instead, before reaching for ${ANALYZE_COMMAND}`);
1507
+ }
1508
+ }
1509
+ // The third statement about **this invocation**, and the one that reaches the repository the
1510
+ // finding is actually about: an application that sits entirely in one subdirectory, adopted from
1511
+ // the root with a `flat` fallback and a placeholder for both required commands, where nothing in
1512
+ // the output named the subdirectory or the flag that would detect it.
1513
+ //
1514
+ // Gated on the same `appDir.source === 'default'` the two lines above are — an adopter who has
1515
+ // already named an application directory is not asked again — plus the fallback, because a
1516
+ // recognised layout is not a repository that failed to see its application. The third condition is
1517
+ // the block above: where the run was started **at or below** the directory this probe would name,
1518
+ // that block already points into that tree, and two `--app-dir` remedies for one condition is worse
1519
+ // than the duplication this condition exists to prevent.
1520
+ //
1521
+ // A **note**, never a warning: a repository is not at fault for nesting its application, and this
1522
+ // run is otherwise correct. Printed on `--dry-run` as on a real run, like the two lines above it,
1523
+ // because it is a statement about what this invocation detected rather than about what it wrote.
1524
+ if (detection.matchedSignal === FLAT_FALLBACK_SIGNAL_ID && appDir.source === 'default') {
1525
+ const nested = findNestedApplicationDir(repoRoot);
1526
+ // `findNestedApplicationDir` only ever names a direct child of the root, so the equality leg
1527
+ // covers every directory it can return; the `startsWith` leg is for the *invocation* side — a
1528
+ // run started deeper than that child (`service/api` under a probed `service`).
1529
+ const startedInside = invokedIn !== undefined &&
1530
+ nested !== undefined &&
1531
+ (invokedIn === nested.dir || invokedIn.startsWith(`${nested.dir}/`));
1532
+ if (nested !== undefined && !startedInside) {
1533
+ notes.push(`no detection signal matched at the repository root, and \`${nested.dir}\` holds \`${nested.manifest}\` — a manifest init reads — so the application may be that directory rather than the repository. Nothing here adopts it: which directory is the application is your statement, not a detection result. Re-run with \`${APP_DIR_FLAG} ${nested.dir}\` to point detection at that tree instead`);
1534
+ }
1535
+ }
1536
+ // Both halves of detection, in one step: the layer preset, then what answered for the commands.
1537
+ // The second line is a statement about what **this invocation** detected rather than about what it
1538
+ // wrote, so it is printed on `--dry-run` exactly as on a real run — and `--quiet` suppresses it with
1539
+ // the rest of the narration, which needs no code here (`core/report.ts`).
1540
+ //
1541
+ // `writeHarnessConfig` computes the same fact as `kept` (`generators/harnessConfig.ts`), and it is
1542
+ // derived a second time here rather than read off `config` because this step prints **before** that
1543
+ // generator runs. Moving the line below the generator would separate it from its own step heading.
1544
+ // It governs both of the second line's claims: what the two command keys were resolved *as*, and
1545
+ // whether the tiebreak note it points at is published at all.
1546
+ const configKept = !flags.resetConfig && configExists(repoRoot);
1547
+ ctx.report.step('stack detection');
1548
+ ctx.report.info(detectionLine(detection));
1549
+ ctx.report.info(commandSourceLine(profile, detection, { kept: configKept }));
1550
+ const plan = new WritePlan();
1551
+ const config = writeHarnessConfig({
1552
+ repoRoot,
1553
+ detection,
1554
+ preset: profile,
1555
+ flags,
1556
+ plan,
1557
+ // No `force:` — the run's `--force` is not an input to this generator (`harnessConfig.ts`).
1558
+ // `plan.apply` below still passes it, so it governs every other create-if-absent artifact.
1559
+ resetConfig: flags.resetConfig,
1560
+ // Wording only — the decision above is mode-independent (`harnessConfig.ts`).
1561
+ dryRun: ctx.flags.dryRun,
1562
+ // Wording only too, and the rung rather than the value: the start-over note may claim `appDir`
1563
+ // was re-read from the file being rebuilt only where rung 2 answered.
1564
+ appDirSource: appDir.source,
1565
+ // Lazy on purpose: the generator calls it only while the interactive test phase is on and only
1566
+ // when `--qa-driver` did not already answer, so no run that did not need the question is asked
1567
+ // it. It is put here, after the detection line has been printed, because the question
1568
+ // back-references it ({@link askQaDriver}) — "the detected preset" names nothing an adopter who
1569
+ // has not read that line can resolve.
1570
+ askDriver: () => askQaDriver(ctx),
1571
+ });
1572
+ // `kept` is known only now, so this is where the four held-back lists are published or dropped —
1573
+ // ahead of `config.warnings`, which is the order they printed in before they were gated. A kept
1574
+ // re-run gets one note in their place instead of silence, and the kept file's own warnings come
1575
+ // from `writeHarnessConfig`, which reports them from the config check.
1576
+ //
1577
+ // This note is about **this run's detection**: the preset and command lines it produced went
1578
+ // unwritten. What the kept file puts in force is `writeHarnessConfig`'s own replacement note to
1579
+ // say, in the same block, and the two must stay distinct rather than converge on one sentence.
1580
+ //
1581
+ // The warning beside it is about **this command line**: which flags of it were dropped. It does
1582
+ // not repeat the note — one is about a detection nobody asked for, the other about values somebody
1583
+ // typed — and it is a warning rather than a third note for the reason its own header gives.
1584
+ if (config.kept) {
1585
+ notes.push(`stack detection ran to answer what ${CONFIG_FILENAME} already answers, so the preset and command lines it detected were not written and the values in that file are the ones in effect; re-run with --reset-config to rebuild the file from this run's detection`);
1586
+ const discarded = discardedConfigFlagsWarning(flags);
1587
+ if (discarded !== undefined)
1588
+ warnings.push(discarded);
1589
+ }
1590
+ else {
1591
+ warnings.push(...generatedConfigWarnings);
1592
+ notes.push(...generatedConfigNotes);
1593
+ }
1594
+ warnings.push(...config.warnings);
1595
+ notes.push(...config.notes);
1596
+ // The config **in effect** from here on: freshly built, or the adopter's own when a re-run kept
1597
+ // the file that was already there.
1598
+ const effective = config.config;
1599
+ // Unconditional on `config.kept`, unlike the four detection lists above: this is derived from the
1600
+ // config **in effect**, so it stays true of a re-run that kept the adopter's own file — and that
1601
+ // population is the one that can carry a recorded review, which the note's remedy is fitted to.
1602
+ // Printed on `--dry-run` as on a real run, because it describes what this invocation detected.
1603
+ const layerGap = layerGapNote(repoRoot, effective);
1604
+ if (layerGap !== undefined)
1605
+ notes.push(layerGap);
1606
+ const wrappers = writeWrapperScripts({ repoRoot, config: effective, rawCommands: profile.rawCommands, plan });
1607
+ warnings.push(...wrappers.warnings);
1608
+ // What this run put in effect, before the file log rather than after it, so the reader has the
1609
+ // command lines in hand while reading which wrappers were written. Narration — `step` and `info`,
1610
+ // suppressed by `--quiet` like the rest of the action log (`core/report.ts`) — and no line of it
1611
+ // reaches `notes` or `warnings`, which stay the caveat channels. It is composed while the plan is
1612
+ // being built and writes nothing, so `--dry-run` prints the same rows over the same repository
1613
+ // state; the one thing it says differently is the write provenance, in the conditional.
1614
+ const commandLines = commandReportLines({
1615
+ repoRoot,
1616
+ config: effective,
1617
+ wrappers,
1618
+ force: ctx.flags.force,
1619
+ dryRun: ctx.flags.dryRun,
1620
+ });
1621
+ if (commandLines.length > 0) {
1622
+ ctx.report.step('commands');
1623
+ for (const line of commandLines)
1624
+ ctx.report.info(line);
1625
+ }
1626
+ // Immediately after the wrappers, into the same directory: the outer-loop scripts and the library
1627
+ // they source. Unlike the wrappers these are **not** configurable command lines — they carry no
1628
+ // token at all and read `harness.config.json` at run time — so a re-run leaves an adopter's edited
1629
+ // script byte-identical and `--force` regenerates it only after a `.bak`. They are written before
1630
+ // the permission profile for the same reason the wrappers are: the profile allow-lists the literal
1631
+ // paths of the agent-invocable ones, and a profile written first would name files that do not
1632
+ // exist (`generators/outerLoopScripts.ts`).
1633
+ const outerLoop = writeOuterLoopScripts({ repoRoot, config: effective, plan });
1634
+ notes.push(...outerLoop.notes);
1635
+ const state = writeStateDir({ repoRoot, config: effective, plan });
1636
+ notes.push(...state.notes);
1637
+ // The third of the four questions this run puts, and settled here because its answer is an
1638
+ // *input* to the generator below: that generator enqueues the banner while the plan is being
1639
+ // built, so a resolution taken afterwards would be reading a decision it was supposed to make.
1640
+ const analyze = resolveAnalyzeOffer(ctx, flags, repoRoot, effective);
1641
+ notes.push(...analyze.notes);
1642
+ const context = writeClaudeContext({
1643
+ repoRoot,
1644
+ config: effective,
1645
+ plan,
1646
+ // The offer's whole material effect on what is written: which of the two banner wordings the
1647
+ // generated always-loaded file carries, each ending in its own disposal sentence.
1648
+ analyzeOffer: analyze.offer,
1649
+ // The run's `--force`, because it is half of what decides whether the setup-pending banner
1650
+ // reached an always-loaded project file that was already there: an unforced run keeps that file
1651
+ // and the generator notes that no banner was written into it, a forced one regenerates it from
1652
+ // the template after a `.bak`.
1653
+ force: ctx.flags.force,
1654
+ // The run's `--dry-run`, which changes that note's tense and nothing else, the same contract
1655
+ // `analyzeRecordSentence` keeps for the closing pointer: both sentences print on one run, so a
1656
+ // dry run that reports a keep it did not make contradicts the line under it.
1657
+ dryRun: ctx.flags.dryRun,
1658
+ });
1659
+ warnings.push(...context.warnings);
1660
+ notes.push(...context.notes);
1661
+ const permissions = writePermissionProfile({
1662
+ repoRoot,
1663
+ config: effective,
1664
+ plan,
1665
+ // Authoritative: the profile may allow-list only what these two generators actually wrote — the
1666
+ // wrappers in full, and the outer-loop rows their own `agentInvocable` flag says an agent runs.
1667
+ written: wrappers.written,
1668
+ writtenOuterLoop: outerLoop.written,
1669
+ ...(flags.referenceToolchainPath === undefined
1670
+ ? {}
1671
+ : { referenceToolchainPath: flags.referenceToolchainPath }),
1672
+ // The run's `--force`, because it is the only run that replaces the profile: the generator reads
1673
+ // the file it is about to overwrite and carries forward the plugin-root entries `doctor` told the
1674
+ // adopter to paste in by hand, which nothing here can regenerate.
1675
+ force: ctx.flags.force,
1676
+ // The run's `--dry-run`, which changes that report's tense and nothing else — the same contract
1677
+ // the project-file generator above keeps.
1678
+ dryRun: ctx.flags.dryRun,
1679
+ });
1680
+ warnings.push(...permissions.warnings);
1681
+ notes.push(...permissions.notes);
1682
+ const settings = writeProjectSettings({ repoRoot, plan, flags });
1683
+ warnings.push(...settings.warnings);
1684
+ notes.push(...settings.notes);
1685
+ const repoFiles = writeRepoRootFiles({ repoRoot, config: effective, plan });
1686
+ notes.push(...repoFiles.notes);
1687
+ // The one generator whose target is outside the repository — the account's own push-notification
1688
+ // settings — and therefore the one place `init` asks a question whose answer nothing in the
1689
+ // repository records. It is enqueued into the same plan as everything else, so `--dry-run` skips
1690
+ // it for the same structural reason it skips the rest.
1691
+ const notifications = writeNotifications({
1692
+ repoRoot,
1693
+ config: effective,
1694
+ plan,
1695
+ ...resolveNotifications(ctx, flags),
1696
+ });
1697
+ notes.push(...notifications.notes);
1698
+ warnings.push(...notifications.warnings);
1699
+ const hooks = writeGitHooks({
1700
+ repoRoot,
1701
+ config: effective,
1702
+ plan,
1703
+ // The one input to the generator's re-render arm: this run rebuilt the config the hook is
1704
+ // rendered from, so a hook whose `case` label the rebuild made wrong is re-rendered on the same
1705
+ // run instead of waiting for a separate --force. The run's `--force` is not passed and is not
1706
+ // an input — `plan.apply` below still carries it, so it governs this artifact as it always did.
1707
+ configRebuilt: flags.resetConfig,
1708
+ // Wording only — the decision above is mode-independent (`generators/githooks.ts`).
1709
+ dryRun: ctx.flags.dryRun,
1710
+ });
1711
+ warnings.push(...hooks.warnings);
1712
+ notes.push(...hooks.notes);
1713
+ ctx.report.step(ctx.flags.dryRun ? 'files (dry run — nothing is written)' : 'files');
1714
+ plan.apply({ repoRoot, report: ctx.report, dryRun: ctx.flags.dryRun, force: ctx.flags.force });
1715
+ // After the plan, deliberately: this is the one git-configuration write, and pointing
1716
+ // `core.hooksPath` at a directory whose hook has not landed yet would enable nothing.
1717
+ const hooksPath = pointHooksPath({ repoRoot, githooksDir: hooks.githooksDir, dryRun: ctx.flags.dryRun });
1718
+ warnings.push(...hooksPath.warnings);
1719
+ notes.push(...hooksPath.notes);
1720
+ // The second post-plan step, and after the plan for a reason of its own: the managed `.gitignore`
1721
+ // block that decides what `git add -A` may stage arrived with the plan. Before the summary, so the
1722
+ // commit is reported inside the run's action log rather than after its closing pointer.
1723
+ warnings.push(...commitGeneratedFiles(ctx, target));
1724
+ ctx.report.summary();
1725
+ reportLines(ctx, notes, warnings);
1726
+ // `settings` carries the two facts step 1 states — the composite key and the resolved slug — so the
1727
+ // report is worded against what this run's plan actually held rather than against a second
1728
+ // resolution taken here.
1729
+ reportNextSteps(ctx, effective.layers.map((layer) => layer.name), analyze, ctx.flags.force, ctx.flags.dryRun,
1730
+ // The generator's fact, not a second reading: by the time this prints, the plan has been applied
1731
+ // and "did the bytes on disk differ from the ones this run rendered" is no longer answerable.
1732
+ context.claudeMdBackupWouldCarryContent,
1733
+ // The second fact only the generator can supply, and for the same reason: once the plan has been
1734
+ // applied, "was a .bak there before this run" is no longer answerable either.
1735
+ context.claudeMdBackupExisted, settings, effective.phases?.qa === true,
1736
+ // The second gate, and a different question from the one above it: whether this run declared any
1737
+ // browser wiring at all. Taken from the one predicate the two generators that write that wiring
1738
+ // read, never re-spelled here (`config/model.ts`).
1739
+ browserWiringApplies(effective));
1740
+ return EXIT.OK;
1741
+ }
1742
+ /**
1743
+ * The caveats, after the action log: the informational lines first, then the ones needing attention.
1744
+ *
1745
+ * Warnings go to stderr and print even under `--quiet`, which is the reporter's contract and the
1746
+ * whole of what makes a quiet run silent unless something wants a human (`core/report.ts`).
1747
+ *
1748
+ * **One blank line between entries, and only between them.** Each note is a paragraph of several
1749
+ * sentences, and a dozen of them run together as one block otherwise. The heading keeps its first
1750
+ * note and nothing trails the block, so the separator marks where an entry ends rather than padding
1751
+ * the report.
1752
+ */
1753
+ function reportLines(ctx, notes, warnings) {
1754
+ if (notes.length > 0) {
1755
+ ctx.report.step('notes');
1756
+ notes.forEach((note, index) => {
1757
+ if (index > 0)
1758
+ ctx.report.info('');
1759
+ ctx.report.info(`- ${note}`);
1760
+ });
1761
+ }
1762
+ for (const warning of warnings)
1763
+ ctx.report.warn(warning);
1764
+ }
1765
+ /**
1766
+ * Where the answer was recorded — keyed on **what the write engine actually did** to the
1767
+ * always-loaded file rather than on the answer alone, so no wording asserts a record the tree does
1768
+ * not carry.
1769
+ *
1770
+ * That file is `create-if-absent`, and `--force` upgrades exactly that policy to
1771
+ * overwrite-after-backup (`core/writer.ts`), so there are **three** outcomes and not two: *created*,
1772
+ * so the banner is in the file; *overwritten under `--force`*, so it is re-written and the previous
1773
+ * file survives only as its single `.bak`; and *kept*, so nothing was written into it at all. Nothing
1774
+ * here words anything as if `--force` could not reach this file.
1775
+ *
1776
+ * **The forced outcome has two wordings, and `claudeMdBackupWouldCarryContent` is which.** The
1777
+ * generator fixes `forceOverride: 'never'` on a file whose bytes already are the text it would write
1778
+ * (`generators/claudeContext.ts`), because that write would put nothing in the `.bak` and destroy
1779
+ * whatever is in the one already there. So the promise "anything a previous pass had filled into it is
1780
+ * in that `.bak` alone" is true of the run that overwrote the file and false of the run that kept it —
1781
+ * on which the `.bak` was never touched and still holds what the previous forced pass rescued. Only
1782
+ * the generator holds the rendered text, so the fact is taken from its result rather than recomputed.
1783
+ *
1784
+ * **The keep splits again on `claudeMdBackupExisted`.** A plain `init` followed by `init --force`
1785
+ * renders the same bytes twice, so the keep fires with no `.bak` ever written; that arm says no
1786
+ * backup was made instead of promising a surviving copy of content that was never displaced.
1787
+ *
1788
+ * **The conventions documents are a fourth fact, not a fourth outcome.** `--force` puts every filled
1789
+ * one back to a skeleton after its own `.bak` whatever happened to the always-loaded file, so that
1790
+ * clause is keyed on `filledConventions` alone and appended to whichever wording is true — this run's
1791
+ * report is the only carrier of it, `doctor` never names the `.bak` files.
1792
+ *
1793
+ * On the kept outcome the run itself is the carrier of the answer, and `doctor`'s `setup-analysis`
1794
+ * check is the standing one — it reads the unfilled markers rather than the banner. Nothing here
1795
+ * writes, patches or re-inserts anything.
1796
+ *
1797
+ * **`--dry-run` changes the tense and nothing else.** The outcome is computed from the same real
1798
+ * facts, so a dry run reports which wording it *would* have recorded and whether it would have been
1799
+ * recorded at all — `docs/cli.md` §1's contract read in its other direction, where a dry run may no
1800
+ * more claim a record it did not make than omit one a real run would. Two verb tokens rather than a
1801
+ * second set of wordings, which would drift from this one.
1802
+ */
1803
+ function analyzeRecordSentence(analyze, force, dryRun, backupWouldCarryContent, backupExisted) {
1804
+ const records = dryRun ? 'would record' : 'records';
1805
+ const recorded = analyze.offer === 'accepted'
1806
+ ? `The setup-pending banner in ${CLAUDE_MD_PATH} ${records} that answer, so running it is the next session's first action.`
1807
+ : `The banner in ${CLAUDE_MD_PATH} ${records} that the analysis was declined — the conventions documents are yours to write by hand, ${ANALYZE_COMMAND} is still there if you change your mind, and the banner's own last sentence says how to be rid of the block.`;
1808
+ // What the forced run did to the **conventions documents**, computed from `filledConventions`
1809
+ // alone and never gated on the project file: the two are independent facts, and a first forced
1810
+ // `init` over hand-written rules — no project file yet — is the run that most needs to be told.
1811
+ // Said in one clause and given its subject at each of the two call sites below.
1812
+ const putBack = !force || analyze.filledConventions.length === 0
1813
+ ? ''
1814
+ : `${dryRun ? 'would also put' : 'also put'} ${analyze.filledConventions.join(', ')} back to skeletons after their own .bak siblings, so the analysis has to be run again — or those .bak files restored.`;
1815
+ if (!analyze.claudeMdExisted)
1816
+ return putBack === '' ? recorded : `${recorded} --force ${putBack}`;
1817
+ if (force) {
1818
+ // The forced re-run says what it did to the tree rather than what it found — which is the
1819
+ // counterpart of the skip branch's condition (2a): no run can both regenerate these documents and
1820
+ // report them as already filled.
1821
+ //
1822
+ // The keep is not a third answer about the *banner*: the file was kept because its bytes already
1823
+ // are the ones this run renders, banner included, so `recorded` is true of it by identity — an
1824
+ // adopter answering the offer differently between two runs renders different text, the file is
1825
+ // then not identical, and the arm above is the one that fires.
1826
+ if (!backupWouldCarryContent) {
1827
+ // And the keep itself has two wordings, because the `.bak` it leaves alone may not exist: a
1828
+ // plain `init` followed by `init --force` renders the same bytes both times, so the keep fires
1829
+ // in a tree where nothing was ever backed up. Naming a surviving copy there sends an adopter
1830
+ // after a file that is not on disk — the same defect class as the sentence this arm replaced.
1831
+ const backup = backupExisted
1832
+ ? ` and ${dryRun ? 'would leave' : 'left'} ${CLAUDE_MD_PATH}.bak alone — whatever a previous pass had filled into this file is still in that .bak`
1833
+ : `, and ${dryRun ? 'would write' : 'wrote'} no ${CLAUDE_MD_PATH}.bak, because there was nothing to back up`;
1834
+ return `${recorded} ${CLAUDE_MD_PATH} ${dryRun ? 'is' : 'was'} already byte-identical to the file this run generates, so --force ${dryRun ? 'would keep' : 'kept'} it${backup}, and ${ANALYZE_COMMAND} is what fills this one again.${putBack === '' ? '' : ` It ${putBack}`}`;
1835
+ }
1836
+ return `${recorded} --force ${dryRun ? 'would regenerate' : 'regenerated'} that whole file from the template after a single ${CLAUDE_MD_PATH}.bak, so anything a previous pass had filled into it ${dryRun ? 'would be' : 'is'} in that .bak alone.${putBack === '' ? '' : ` It ${putBack}`}`;
1837
+ }
1838
+ const kept = `${CLAUDE_MD_PATH} was already there and this run was not forced, so it ${dryRun ? 'would be kept' : 'was kept'} as it stands and nothing ${dryRun ? 'would be' : 'was'} recorded in it`;
1839
+ // The skip branch is read **before** `offer`, which is a placeholder there and not an answer: it
1840
+ // only ever reaches this outcome (unforced, and the file was already there), and the declined
1841
+ // wording below would tell an adopter whose documents are all written to write them by hand.
1842
+ if (analyze.nothingToFill) {
1843
+ return `${kept}: every conventions document is already written, so there was nothing to offer — \`${DOCTOR_COMMAND}\` names what is still unfilled at any time, and ${ANALYZE_COMMAND} revises a document whenever you want one revised.`;
1844
+ }
1845
+ return analyze.offer === 'accepted'
1846
+ ? `${kept}: tell the next session to run ${ANALYZE_COMMAND}, and \`${DOCTOR_COMMAND}\` names what is still unfilled at any time.`
1847
+ : `${kept}: the conventions documents are yours to write by hand, ${ANALYZE_COMMAND} is still there if you change your mind, and \`${DOCTOR_COMMAND}\` names what is still unfilled at any time.`;
1848
+ }
1849
+ /**
1850
+ * The closing report — step C of the install story — printed last and on every run that got here,
1851
+ * whatever was written and whatever the phases are. Four numbered steps in the order an adopter
1852
+ * performs them, and one pasteable line under the second. The step count does not move with the
1853
+ * phases (`docs/cli.md` §2 states it): the one phase-gated part of this report is a clause *inside*
1854
+ * step 4, never a fifth step.
1855
+ *
1856
+ * {@link INIT_VERB} deliberately stops short of the judgement calls: it detected a layout from file
1857
+ * existence alone and wrote skeletons for the conventions documents, and turning those into this
1858
+ * project's actual rules is the analyze command's. Saying so here is what keeps a freshly wired
1859
+ * repository from being mistaken for a finished one. The generated config is named because it is the
1860
+ * file the adopter edits to correct anything above.
1861
+ *
1862
+ * **Step 1 is the plugin wiring, and it is first because the ordering was the defect.** The report
1863
+ * used to open by telling every adopter to run a command that exists only in a session which resolves
1864
+ * this package's plugin, with the precondition for it stated nowhere above the instruction. An
1865
+ * ordered sequence is the fix rather than an annotation: the step that makes the command exist cannot
1866
+ * come after the step that runs it.
1867
+ *
1868
+ * **It states what *this run wrote*, never what this machine already has.** `init` cannot see the host
1869
+ * tool's install state — that lives in a user-level registry this command neither reads nor writes —
1870
+ * and probing for it would make the output a function of the machine rather than of the flags and the
1871
+ * repository — the determinism that lets a second `init` over the same repository produce the same
1872
+ * result and an adopter re-derive the recorded preset by hand (`docs/cli.md` §2). So the line
1873
+ * reports the keys the plan carried and stops: the enablement key always, the
1874
+ * `extraKnownMarketplaces` entry only where the owner slug resolved
1875
+ * (`generators/projectSettings.ts`). The unresolved arm names the flag, the slug shape and the
1876
+ * marketplace from that generator's own constants, so this line and the warning an adopter reads a
1877
+ * few lines earlier cannot drift apart. Under `--dry-run` it takes the conditional the rest of the
1878
+ * report keeps (`docs/cli.md` §1) — the `/reload-plugins` pointer included, which is why that clause
1879
+ * belongs to each arm rather than to the printed line: a dry run merged nothing to reload, and the
1880
+ * unresolved arm can offer it only after one of the two routes it names is taken.
1881
+ *
1882
+ * **Step 2 carries the settled vocabulary** (`docs/analyze.md` §1): the no-argument form, and
1883
+ * `<target>` over the configured layer names *plus* {@link RESERVED_ANALYZE_TARGETS} — the three
1884
+ * targets that name no layer. It states what the command does in the settled terms, including the
1885
+ * one thing it does not do: a layer-profile revision is proposed and reaches
1886
+ * `harness.config.json` only through {@link CONFIG_SET_LAYERS_COMMAND} after an explicit yes
1887
+ * (`docs/analyze.md` §3). And it ends with {@link analyzeRecordSentence} — given the generator's
1888
+ * `claudeMdBackupWouldCarryContent` and `claudeMdBackupExisted`, the two inputs to that sentence no
1889
+ * reading taken here could supply — so what the step claims about the tree is what the write engine actually did to it — in
1890
+ * the conditional under
1891
+ * `--dry-run`, which did nothing to it at all. Step 2 is where {@link ANALYZE_COMMAND} is named and
1892
+ * step 1 deliberately refers to it rather than spelling it, so "the numbered step that names the
1893
+ * command" identifies exactly one line of this report — which is how the tests find it.
1894
+ *
1895
+ * **{@link ANALYZE_INVOCATION} is printed under it, on its own line and on the accepted arm alone.**
1896
+ * A line naming a command to type still leaves the adopter to open a session first; a line to paste
1897
+ * does not, and it is indented under step 2 rather than numbered because it is that step's *how*
1898
+ * rather than a fifth thing to do. It names {@link AGENT_CLI} directly where the generated watcher
1899
+ * reaches the same binary through `${HARNESS_AGENT_CLI:-claude}`
1900
+ * (`templates/scripts/autonomous-watcher.sh`) — an override that belongs to a script's own
1901
+ * environment and would mean nothing on a line printed for a person to paste. It names
1902
+ * {@link ANALYZE_COMMAND_QUALIFIED} where step 2 above it names {@link ANALYZE_COMMAND}, and the
1903
+ * difference is the two paths rather than an inconsistency: step 2 is a name to type into a session,
1904
+ * whose picker resolves the bare form, while this line is a first message, which is matched exactly —
1905
+ * the introducing sentence says so, because a report that showed both spellings and explained neither
1906
+ * would read as a typo. It is withheld wherever
1907
+ * it would contradict the report around it: on the declined arm, whose answer the same step just
1908
+ * quoted; on the skip branch, where there is nothing to fill; and under `--dry-run`, which wired
1909
+ * nothing to run it against. On the unresolved-slug arm it is **printed but qualified** rather than
1910
+ * withheld — withholding would make it dead on every run of this package before its repository is
1911
+ * published — because step 1 has just said the session resolves the plugin only once one of the two
1912
+ * routes it names is taken, and handing the line over without that precondition would contradict it.
1913
+ * **The outcome is stated as intended on both arms**: whether the prefixed first-message form
1914
+ * succeeds is not established ({@link ANALYZE_COMMAND_QUALIFIED}), so the line an adopter acts on
1915
+ * immediately names the typed route as the measured one instead of asserting its own.
1916
+ *
1917
+ * **One blank line between the numbered steps, and none before that paste line.** Each step is a
1918
+ * paragraph of several sentences; the paste line is step 2's *how*, so it stays attached to the step
1919
+ * it belongs to rather than reading as a fifth entry.
1920
+ *
1921
+ * **Step 3 names the two run settings — as values that file *may carry*, never as values this run
1922
+ * wrote, since nothing here writes an `agentEffort` line — rather than {@link INIT_VERB} asking about
1923
+ * either**: `agentModel`'s default is a working value for every adopter and an absent `agentEffort` is
1924
+ * already resolved, no effort flag reaching the launch line at all, so a prompt would put a question
1925
+ * whose answer is already correct where the bar for asking is `resolveQaDriver`'s — a default that
1926
+ * cannot reach the application at all (`generators/harnessConfig.ts`) — and the failure actually left
1927
+ * open is the other one: a key that exists only in a file nobody is told to open.
1928
+ *
1929
+ * **Step 4 names the two operator steps `doctor` reports on, and it names them only while
1930
+ * `phases.qa` is on** — the parallel to step 3 above: that step names values instead of asking about
1931
+ * them, and this one names steps instead of performing them, because neither is `init`'s to perform.
1932
+ * The `permissions.allow` entries belong to the interactive-test phase's helper scripts and are
1933
+ * unreachable with the phase off, and a step every adopter is told about but only some owe is how a
1934
+ * closing list stops being read; the daemon half rides the same gate because it is already reported
1935
+ * to everyone by `doctor`'s `repo-registry` check. **Each half names its consequence, not just its
1936
+ * verb**, because the two fail differently: an uninstalled daemon is inert and legible — the file
1937
+ * dropped into the inbox sits there — while a missing permission entry is a silent stall on the first
1938
+ * unattended run, which is the one an adopter has to act on *before* that run rather than after it. The entry
1939
+ * form is not restated: those lines carry a machine-local root this command cannot see, which is why
1940
+ * only the daemon half has a constant here ({@link DAEMON_INSTALL_COMMAND}), and
1941
+ * `plugin/scripts/README.md` and `doctor`'s `plugin-permissions` check own it — so this step points
1942
+ * at {@link DOCTOR_COMMAND} for which of the two is still outstanding rather than carrying a list
1943
+ * that would drift from the one that prints the lines to paste.
1944
+ *
1945
+ * **Step 4 carries a second, separately gated sentence: the browser MCP servers are fetched on first
1946
+ * use.** `.mcp.json` launches them with {@link MCP_LAUNCHER}, so `init` installs nothing and an
1947
+ * adopter offline, behind a proxy, or on a mirror without those pins finds out at the first
1948
+ * interactive-test dispatch — after planning, implementation and both branch reviews have been paid
1949
+ * for. The sentence names {@link DOCTOR_CHECK_REGISTRY_COMMAND}, which answers it beforehand, and it
1950
+ * names no package or version: those live in `templates/repo/mcp.json` and that check enumerates
1951
+ * them. Its gate is `browserWiring` — `config/model.ts`'s `browserWiringApplies`, computed at the
1952
+ * call site — and **not** `qaPhase`, because a mobile-driver run has the phase on, owes the operator
1953
+ * steps above, and has no `.mcp.json` to fetch anything for. Two gates, two parameters, two
1954
+ * sentences; do not merge them.
1955
+ */
1956
+ function reportNextSteps(ctx, layerNames, analyze, force, dryRun, backupWouldCarryContent, backupExisted, wiring, qaPhase, browserWiring) {
1957
+ const targets = [...layerNames, ...RESERVED_ANALYZE_TARGETS].join(', ');
1958
+ const merged = dryRun ? 'would merge' : 'merged';
1959
+ const resolves = dryRun ? 'would resolve' : 'resolves';
1960
+ // The reload clause is inside each arm rather than appended to the line: it claims there is
1961
+ // something to pick up, which is false under `--dry-run`, and the unresolved arm owes its own
1962
+ // wording because the sentence before it withholds what the resolved arm's promises.
1963
+ const reloadResolved = dryRun ? '' : ' `/reload-plugins` picks it up in a session that was already open.';
1964
+ const reloadUnresolved = dryRun
1965
+ ? ''
1966
+ : ' Once either route is taken, `/reload-plugins` picks the change up in a session that was already open.';
1967
+ const wired = wiring.marketplaceSlug === undefined
1968
+ ? `This run ${merged} \`${wiring.pluginKey}\` into ${SETTINGS_PATH}, which enables the plugin the next step's command belongs to — but no \`${MARKETPLACES_KEY}\` entry ${dryRun ? 'would be' : 'was'} written, so a session here has never been told where that plugin comes from and ${resolves} it only if this machine already added the \`${MARKETPLACE_NAME}\` marketplace: run \`${MARKETPLACE_ADD_COMMAND}\` for this machine, or re-run ${INIT_VERB} with \`${MARKETPLACE_FLAG} ${SLUG_SHAPE}\` to write the entry into that committed file for everyone who clones it.${reloadUnresolved}`
1969
+ : `Nothing to install by hand: this run ${merged} \`${wiring.pluginKey}\` into ${SETTINGS_PATH} together with the \`${MARKETPLACES_KEY}\` entry naming ${wiring.marketplaceSlug}, and that file is committed — so a session opened in this repository ${resolves} the plugin the next step's command belongs to.${reloadResolved}`;
1970
+ // The paste line is gated on the answer **and** on this run having wired anything to run it
1971
+ // against. `nothingToFill` is read before `offer` for the reason {@link analyzeRecordSentence}
1972
+ // reads it first: `offer` is a placeholder on that path rather than an answer.
1973
+ const pasteable = !analyze.nothingToFill && analyze.offer === 'accepted' && !dryRun;
1974
+ // The sentence introducing the line carries the precondition step 1 just stated. On the unresolved
1975
+ // arm, handing the line over without that precondition would contradict the step above it, which
1976
+ // has said the session resolves the plugin only once one of the two routes it names is taken.
1977
+ const pasteReason = " It is spelled with the plugin prefix because a first message is matched exactly, where a session's command picker matches the name above.";
1978
+ // Whether the prefixed first message succeeds is unmeasured, so the outcome is named as intended
1979
+ // and the measured route is pointed at ({@link ANALYZE_COMMAND_QUALIFIED}, `docs/analyze.md` §9).
1980
+ const pasteUnconfirmed = ' That first-message form is not confirmed on the version measured here; the name above, typed into an open session, is the route that is.';
1981
+ const pasteIntro = wiring.marketplaceSlug === undefined
1982
+ ? ` Once the plugin resolves — step 1 names both routes — paste the line below at this repository's root: it is meant to start a session with that command already running.${pasteReason}${pasteUnconfirmed}`
1983
+ : ` Paste the line below at this repository's root: it is meant to start a session with that command already running.${pasteReason}${pasteUnconfirmed}`;
1984
+ ctx.report.step('next');
1985
+ ctx.report.info(`1. ${wired}`);
1986
+ ctx.report.info('');
1987
+ ctx.report.info(`2. Run ${ANALYZE_COMMAND} (or ${ANALYZE_COMMAND} <target> — targets: ${targets}) in this repository: it fills the conventions documents from this repository's real code, and proposes a layer-profile revision it applies only through \`${CONFIG_SET_LAYERS_COMMAND}\` after an explicit yes. ${INIT_VERB} detected this layout from file existence alone and deliberately judges none of it. ${analyzeRecordSentence(analyze, force, dryRun, backupWouldCarryContent, backupExisted)}${pasteable ? pasteIntro : ''}`);
1988
+ // No separator before the paste line: it is step 2's continuation, which is what the indent says.
1989
+ if (pasteable)
1990
+ ctx.report.info(` ${ANALYZE_INVOCATION}`);
1991
+ ctx.report.info('');
1992
+ ctx.report.info(`3. Read ${CONFIG_FILENAME} and correct anything above — it is the file everything here was generated from, it is committed, and after ${INIT_VERB} the copy in this repository is the only copy. Two of the values it may carry are the run settings nothing here could detect: \`agentModel\` chooses which model an unattended run talks to, and \`agentEffort\` pins the reasoning-effort level it runs at — leave it out and the runtime applies its own per-model default. Either one is changed with \`${CONFIG_SET_COMMAND}\`.`);
1993
+ // The clause is the phase's, not the driver's: every interactive-test driver reaches the same
1994
+ // helper scripts under the plugin's root.
1995
+ const operatorSteps = qaPhase
1996
+ ? ` Two of the things it reports are an operator's and neither is done from here: \`${DAEMON_INSTALL_COMMAND}\`, without which nothing polls this repository's inbox and a file dropped into it simply sits there; and hand-adding the \`permissions.allow\` entries this machine's plugin roots need, without which an unattended run with the interactive-test phase on parks at the first helper script it reaches, with no error. Running it is how you find out which of the two is still outstanding, and it prints the entries to paste.`
1997
+ : '';
1998
+ // A second gate, deliberately not folded into the one above: that clause is the phase's, this one
1999
+ // is the browser wiring's. A mobile-driver run turns the phase on, owes the operator steps, and
2000
+ // declares no server and fetches nothing — so merging them would print this to an adopter with no
2001
+ // `.mcp.json` at all. The predicate is `browserWiringApplies`, passed in rather than re-spelled.
2002
+ const declared = dryRun ? 'would declare' : 'declared';
2003
+ const registryStep = browserWiring
2004
+ ? ` This run ${declared} the two browser MCP servers in ${MCP_PATH}, and they are launched with \`${MCP_LAUNCHER}\`: each pinned package is fetched from the registry the first time the interactive-test phase runs rather than installed now. Offline, behind a proxy, or on a mirror that does not carry those exact versions, that fails at the first interactive-test dispatch — after planning, implementation and both branch reviews have been paid for — and \`${DOCTOR_CHECK_REGISTRY_COMMAND}\` is what answers it before then.`
2005
+ : '';
2006
+ ctx.report.info('');
2007
+ ctx.report.info(`4. Run \`${DOCTOR_COMMAND}\` to verify the wiring.${operatorSteps}${registryStep}`);
2008
+ }
2009
+ /**
2010
+ * The registry row for this command.
2011
+ *
2012
+ * Exported as the row itself rather than as a `run` the table wraps, so the summary, the usage
2013
+ * lines and the behaviour stay in the file that owns them. The import back to `commands/registry.ts`
2014
+ * is type-only and therefore erased, so the table can list this row without a runtime cycle.
2015
+ */
2016
+ export const INIT_COMMAND = {
2017
+ name: 'init',
2018
+ summary: SUMMARY,
2019
+ roadmapItem: ROADMAP_ITEM,
2020
+ usage: INIT_USAGE,
2021
+ run,
2022
+ };
2023
+ //# sourceMappingURL=init.js.map