simt-dev 0.2.0__py3-none-any.whl

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 (138) hide show
  1. python/simt_dev/__init__.py +32 -0
  2. python/simt_dev/cli.py +225 -0
  3. simt_dev/__init__.py +32 -0
  4. simt_dev/cli.py +225 -0
  5. simt_dev/vendored/CHANGELOG.md +166 -0
  6. simt_dev/vendored/bin/simt-dev.mjs +28 -0
  7. simt_dev/vendored/docs/decisions/ADR-001-push-requires-approval.md +42 -0
  8. simt_dev/vendored/docs/decisions/ADR-002-refactor-blast-radius.md +45 -0
  9. simt_dev/vendored/docs/decisions/ADR-003-test-breadth.md +46 -0
  10. simt_dev/vendored/docs/decisions/ADR-004-hook-owns-format.md +45 -0
  11. simt_dev/vendored/docs/decisions/ADR-005-file-length.md +42 -0
  12. simt_dev/vendored/docs/decisions/ADR-006-coverage-bar.md +59 -0
  13. simt_dev/vendored/docs/decisions/ADR-007-section-skeleton.md +54 -0
  14. simt_dev/vendored/docs/decisions/ADR-008-verdict-vocabulary.md +59 -0
  15. simt_dev/vendored/docs/decisions/ADR-009-skill-layout.md +48 -0
  16. simt_dev/vendored/docs/decisions/ADR-010-runner-invocation.md +56 -0
  17. simt_dev/vendored/docs/decisions/ADR-011-windows-support.md +52 -0
  18. simt_dev/vendored/docs/decisions/ADR-012-drop-windows-support.md +59 -0
  19. simt_dev/vendored/docs/decisions/ADR-013-genericity-by-facts-and-local-profiles.md +114 -0
  20. simt_dev/vendored/docs/decisions/ADR-014-workspace-is-configuration.md +211 -0
  21. simt_dev/vendored/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +117 -0
  22. simt_dev/vendored/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +180 -0
  23. simt_dev/vendored/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +89 -0
  24. simt_dev/vendored/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +183 -0
  25. simt_dev/vendored/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +110 -0
  26. simt_dev/vendored/docs/decisions/ADR-020-a-rule-is-written-per-surface-and-pointed-not-copied.md +100 -0
  27. simt_dev/vendored/docs/decisions/ADR-021-one-surface-per-repo.md +94 -0
  28. simt_dev/vendored/docs/decisions/ADR-022-a-key-simt-dev-reads-is-prefixed.md +98 -0
  29. simt_dev/vendored/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +106 -0
  30. simt_dev/vendored/docs/decisions/ADR-024-simt-dev-overwrites-its-own-gate-file.md +111 -0
  31. simt_dev/vendored/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +153 -0
  32. simt_dev/vendored/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +117 -0
  33. simt_dev/vendored/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +130 -0
  34. simt_dev/vendored/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +131 -0
  35. simt_dev/vendored/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +120 -0
  36. simt_dev/vendored/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +114 -0
  37. simt_dev/vendored/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +129 -0
  38. simt_dev/vendored/docs/decisions/ADR-032-provenance-is-a-content-digest-not-a-commit.md +141 -0
  39. simt_dev/vendored/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +82 -0
  40. simt_dev/vendored/docs/decisions/ADR-034-a-staged-tool-refuses-a-partially-staged-file.md +91 -0
  41. simt_dev/vendored/docs/decisions/ADR-035-one-write-decision-and-one-json-document.md +119 -0
  42. simt_dev/vendored/docs/decisions/ADR-036-a-writer-that-writes-says-so-and-the-instructions-naming-it-move-with-it.md +103 -0
  43. simt_dev/vendored/docs/decisions/ADR-037-unchanged-means-writing-would-not-change-a-byte.md +100 -0
  44. simt_dev/vendored/docs/decisions/ADR-038-the-gate-filter-belongs-to-every-manifest-writer.md +95 -0
  45. simt_dev/vendored/docs/decisions/ADR-039-a-ci-step-runs-a-form-that-can-fail.md +101 -0
  46. simt_dev/vendored/docs/decisions/ADR-040-the-last-writer-that-wrote-by-default-was-not-the-one-named.md +158 -0
  47. simt_dev/vendored/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +92 -0
  48. simt_dev/vendored/docs/decisions/ADR-042-a-declared-glob-must-match-a-path-built-from-it.md +94 -0
  49. simt_dev/vendored/docs/decisions/ADR-043-a-keyword-is-a-phrase-and-two-letters-is-a-word.md +90 -0
  50. simt_dev/vendored/docs/decisions/ADR-044-compatibility-is-not-evidence.md +116 -0
  51. simt_dev/vendored/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +119 -0
  52. simt_dev/vendored/docs/decisions/ADR-046-one-rule-one-copy.md +140 -0
  53. simt_dev/vendored/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +141 -0
  54. simt_dev/vendored/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +150 -0
  55. simt_dev/vendored/docs/decisions/ADR-049-a-ruling-that-a-later-record-moved-says-so-where-it-is-printed.md +153 -0
  56. simt_dev/vendored/docs/decisions/ADR-050-a-byte-count-is-not-a-region-and-updated-is-not-a-change-of-rules.md +102 -0
  57. simt_dev/vendored/docs/decisions/ADR-051-a-pointer-is-the-readers-only-route-so-it-is-not-a-convenience.md +134 -0
  58. simt_dev/vendored/docs/decisions/ADR-052-a-dependency-is-counted-against-the-budget-and-there-is-one-budget.md +101 -0
  59. simt_dev/vendored/docs/decisions/ADR-053-the-node-floor-is-22-20-0.md +49 -0
  60. simt_dev/vendored/docs/decisions/ADR-054-the-digest-covers-every-byte-that-leaves-the-package.md +100 -0
  61. simt_dev/vendored/docs/decisions/ADR-055-a-status-line-travels-so-it-may-not-name-a-location.md +98 -0
  62. simt_dev/vendored/docs/decisions/ADR-056-three-names-one-of-which-is-cheap-to-move.md +160 -0
  63. simt_dev/vendored/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  64. simt_dev/vendored/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  65. simt_dev/vendored/docs/decisions/README.md +530 -0
  66. simt_dev/vendored/graph/skills.graph.json +1622 -0
  67. simt_dev/vendored/package.json +43 -0
  68. simt_dev/vendored/rules/core.md +76 -0
  69. simt_dev/vendored/rules/mdc/intake-questions.mdc +50 -0
  70. simt_dev/vendored/rules/mdc/preview-postflight.mdc +61 -0
  71. simt_dev/vendored/rules/mdc/slice-commit.mdc +52 -0
  72. simt_dev/vendored/rules/mdc/test-gate.mdc +54 -0
  73. simt_dev/vendored/rules/stacks/android.md +63 -0
  74. simt_dev/vendored/rules/stacks/cloudflare-workers.md +65 -0
  75. simt_dev/vendored/rules/stacks/connectiq.md +62 -0
  76. simt_dev/vendored/rules/stacks/container.md +31 -0
  77. simt_dev/vendored/rules/stacks/jvm.md +54 -0
  78. simt_dev/vendored/rules/stacks/node.md +45 -0
  79. simt_dev/vendored/rules/stacks/python.md +48 -0
  80. simt_dev/vendored/rules/stacks/react-native.md +58 -0
  81. simt_dev/vendored/rules/stacks/react-web.md +61 -0
  82. simt_dev/vendored/rules/stacks/typescript.md +21 -0
  83. simt_dev/vendored/rules/stacks/uv.md +32 -0
  84. simt_dev/vendored/src/argv.mjs +116 -0
  85. simt_dev/vendored/src/ci/dependabot.mjs +268 -0
  86. simt_dev/vendored/src/ci/workflow.mjs +360 -0
  87. simt_dev/vendored/src/commands/ci.mjs +285 -0
  88. simt_dev/vendored/src/commands/compare.mjs +195 -0
  89. simt_dev/vendored/src/commands/graph.mjs +73 -0
  90. simt_dev/vendored/src/commands/help.mjs +108 -0
  91. simt_dev/vendored/src/commands/hooks.mjs +471 -0
  92. simt_dev/vendored/src/commands/index.mjs +241 -0
  93. simt_dev/vendored/src/commands/init.mjs +632 -0
  94. simt_dev/vendored/src/commands/mcp.mjs +63 -0
  95. simt_dev/vendored/src/commands/rules.mjs +374 -0
  96. simt_dev/vendored/src/commands/skills.mjs +647 -0
  97. simt_dev/vendored/src/commands/skillsinstall.mjs +515 -0
  98. simt_dev/vendored/src/commands/sync.mjs +331 -0
  99. simt_dev/vendored/src/commands/upgrade.mjs +202 -0
  100. simt_dev/vendored/src/compare/discover.mjs +212 -0
  101. simt_dev/vendored/src/compare/hooksstate.mjs +159 -0
  102. simt_dev/vendored/src/compare/row.mjs +299 -0
  103. simt_dev/vendored/src/decisions/index.mjs +536 -0
  104. simt_dev/vendored/src/graph/load.mjs +541 -0
  105. simt_dev/vendored/src/hooks/capability.mjs +129 -0
  106. simt_dev/vendored/src/hooks/dispatcher.mjs +268 -0
  107. simt_dev/vendored/src/hooks/gates.mjs +144 -0
  108. simt_dev/vendored/src/hooks/resolve.mjs +194 -0
  109. simt_dev/vendored/src/hooks/shim.mjs +140 -0
  110. simt_dev/vendored/src/hooks/where.mjs +118 -0
  111. simt_dev/vendored/src/identity.mjs +218 -0
  112. simt_dev/vendored/src/locate.mjs +75 -0
  113. simt_dev/vendored/src/mcp/server.mjs +167 -0
  114. simt_dev/vendored/src/mcp/tools.mjs +410 -0
  115. simt_dev/vendored/src/name.mjs +95 -0
  116. simt_dev/vendored/src/output/index.mjs +96 -0
  117. simt_dev/vendored/src/projection/baselines.mjs +209 -0
  118. simt_dev/vendored/src/projection/conflicts.mjs +205 -0
  119. simt_dev/vendored/src/projection/manifest.mjs +478 -0
  120. simt_dev/vendored/src/projection/markers.mjs +349 -0
  121. simt_dev/vendored/src/projection/mdc.mjs +327 -0
  122. simt_dev/vendored/src/projection/project.mjs +504 -0
  123. simt_dev/vendored/src/projection/rules.mjs +640 -0
  124. simt_dev/vendored/src/projection/surfaces.mjs +398 -0
  125. simt_dev/vendored/src/projection/targets.mjs +221 -0
  126. simt_dev/vendored/src/resolve/fingerprint.mjs +553 -0
  127. simt_dev/vendored/src/resolve/near.mjs +61 -0
  128. simt_dev/vendored/src/resolve/resolve.mjs +457 -0
  129. simt_dev/vendored/src/resolve/source.mjs +105 -0
  130. simt_dev/vendored/src/toolchain.mjs +202 -0
  131. simt_dev/vendored/src/tsconfig.mjs +314 -0
  132. simt_dev/vendored/src/upgrade/plan.mjs +203 -0
  133. simt_dev/vendored/src/workspace.mjs +289 -0
  134. simt_dev-0.2.0.dist-info/METADATA +150 -0
  135. simt_dev-0.2.0.dist-info/RECORD +138 -0
  136. simt_dev-0.2.0.dist-info/WHEEL +4 -0
  137. simt_dev-0.2.0.dist-info/entry_points.txt +2 -0
  138. simt_dev-0.2.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,632 @@
1
+ // simt-dev init - adopt a repo into the ecosystem, or scaffold a new one.
2
+ //
3
+ // The governing idea: init never destroys hand-written work. It writes a manifest
4
+ // and a generated region, reports every conflict it found BEFORE writing anything,
5
+ // and requires an explicit --yes to change a file at all. An agent that ran init
6
+ // without --yes learns exactly what would happen and nothing has changed - which
7
+ // is the only safe default for a tool whose first action is to touch someone's repo.
8
+ //
9
+ // Ordering is deliberate and is the whole safety story:
10
+ // 1. fingerprint what is this repo
11
+ // 2. scan for conflicts does it contradict a ruling
12
+ // 3. block on conflicts unless --accept-conflicts
13
+ // 4. plan everything manifest + region, dry
14
+ // 5. branch and tag a named escape hatch, before the first write
15
+ // 6. write only with --yes
16
+ // Never commits, never pushes (ADR-001).
17
+
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ import { EXIT, emit, fail, isJson, note } from '../output/index.mjs';
24
+ import { fingerprint } from '../resolve/fingerprint.mjs';
25
+ import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
26
+ import { MANIFEST, buildManifest, manifestPath, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
27
+ import { readCapability } from '../hooks/capability.mjs';
28
+ import { readHooksState } from '../compare/hooksstate.mjs';
29
+ import { planWorkflow, renderWorkflow, WORKFLOWS_DIR } from '../ci/workflow.mjs';
30
+ import { planDependabot, renderDependabot } from '../ci/dependabot.mjs';
31
+ import { planTsconfigBase, renderTsconfigBase, TSCONFIG_BASE_FILE } from '../tsconfig.mjs';
32
+ import {
33
+ IDENTITY_FILE,
34
+ INCLUDE_KEY,
35
+ ignoreIdentity,
36
+ includeOwnIdentity,
37
+ includesOwnIdentity,
38
+ renderIdentityFile,
39
+ resolveIdentity,
40
+ } from '../identity.mjs';
41
+ import { pointerState, pointerTargets } from '../projection/project.mjs';
42
+ import { surfacesLine } from '../projection/surfaces.mjs';
43
+ import { plan, provenanceDigest, PRIMARY_TARGET, LOCAL_PROFILE_DIR } from '../projection/project.mjs';
44
+ import { parse as parseRegion } from '../projection/markers.mjs';
45
+ import { CLI } from '../name.mjs';
46
+ import { MANIFEST_FILE } from '../name.mjs';
47
+
48
+ const run = promisify(execFile);
49
+
50
+ const BACKUP_TAG = 'backup-before-simt-dev';
51
+ const ADOPT_BRANCH = 'simt-dev/adopt';
52
+
53
+ export async function initCommand(positional, flags) {
54
+ const root = flags.root ?? positional[0] ?? process.cwd();
55
+
56
+ const detected = await fingerprint(root);
57
+ if (detected.error) {
58
+ return fail(`${root}: ${detected.error}`, EXIT.CONFIG, `run: ${CLI} init <path>`);
59
+ }
60
+
61
+ const sha = await provenanceDigest();
62
+ const existing = await readManifest(root);
63
+ const { manifest: current, exists, error } = existing;
64
+ if (error) {
65
+ return fail(`${await manifestPath(root)}: ${error}`, EXIT.CONFIG, `fix the JSON in ${MANIFEST_FILE}`);
66
+ }
67
+
68
+ // What this repo can demonstrably run, not what its stack suggests. Without this the
69
+ // stack table proposes gates the repo has no way to execute — the adoption of
70
+ // a repository declared five and could run two, which would have blocked every
71
+ // commit once hooks were installed.
72
+ const capability = await readCapability(root);
73
+ const { dropped } = planGates(detected.stack, capability);
74
+ const manifest = buildManifest({ root, stack: detected.stack, sha, existing: current, capability });
75
+ const findings = await scanRepo(root);
76
+ const summary = summarise(findings);
77
+ const projected = await plan(root, flags);
78
+ const region = projected.targets[0];
79
+ const mdcProblems = projected.mdcProblems ?? [];
80
+
81
+ // Which editors this repo's rules reach. `--surfaces` wins for this run and is
82
+ // recorded in the manifest so the choice survives; with nothing declared the
83
+ // global standard applies, and the choice is printed because an invisible
84
+ // configuration is one nobody can debug (the same ruling as ADR-014).
85
+ const pointerPlan = [];
86
+ const pointerBlocked = [];
87
+ for (const p of pointerTargets(root, projected.surfaces)) {
88
+ const st = await pointerState(root, p);
89
+ if (st.state === 'foreign') pointerBlocked.push(st);
90
+ // An already-current pointer is still reported. It is the file the reader will
91
+ // open in a Claude Code session, so "already right" is an answer they are owed,
92
+ // not a line to save.
93
+ else pointerPlan.push({ ...p, state: st.state });
94
+ }
95
+ manifest.surfaces = projected.surfaces;
96
+
97
+ const adoption = exists || Boolean(region?.exists);
98
+ const dryRun = flags.dryRun === true;
99
+ const approved = flags.yes === true;
100
+ const apply = approved && !dryRun;
101
+
102
+ const manifestAction = exists ? 'update' : 'create';
103
+ const manifestFile = await manifestPath(root);
104
+ // Planned here, written below with everything else. A file the reader was not shown
105
+ // in the plan is a file they approve by reading the diff afterwards, which is the
106
+ // one review step this tool exists to make unnecessary.
107
+ const ci = await planWorkflow(root);
108
+ const dependabot = await planDependabot(root);
109
+ const tsconfigBase = await planTsconfigBase(root);
110
+ // Per-developer, and planned with the rest so the operator sees it in the same
111
+ // preview that lists everything else. `exists` short-circuits the write: a
112
+ // repository that already has one keeps it exactly as it is.
113
+ const identity = await resolveIdentity(root);
114
+ const identityPlan = {
115
+ file: IDENTITY_FILE,
116
+ action: identity.exists ? 'unchanged' : identity.from === null ? 'skip' : 'created',
117
+ from: identity.from,
118
+ name: identity.name,
119
+ email: identity.email,
120
+ reason: identity.exists
121
+ ? `${IDENTITY_FILE} already exists and is left exactly as it is`
122
+ : identity.from === null
123
+ ? 'no identity is configured anywhere, and this tool does not invent one'
124
+ : null,
125
+ // Only worth fixing when this repository is the one being routed: an include
126
+ // pointing at another repository's identity file works here and breaks for the
127
+ // next person who clones without that sibling checkout.
128
+ include: await includesOwnIdentity(root),
129
+ };
130
+
131
+ const payload = {
132
+ ok: summary.conflicts === 0 || flags.acceptConflicts === true,
133
+ command: 'init',
134
+ root,
135
+ stack: detected.stack,
136
+ facts: detected.facts,
137
+ profiles: projected.profiles,
138
+ fallback: projected.fallback,
139
+ notes: projected.notes,
140
+ // Gates the stack suggested and this repo cannot run. Only meaningful on a fresh
141
+ // adoption: an existing manifest keeps its gates on purpose, so its drops would
142
+ // describe proposals that were never made.
143
+ droppedGates: exists ? [] : dropped,
144
+ surfaces: projected.surfaces,
145
+ surfaceSource: projected.surfaceSource,
146
+ pointers: pointerPlan.map((p) => ({ file: p.file, to: p.to, readers: p.readers, state: p.state })),
147
+ pointersBlocked: pointerBlocked,
148
+ ci: { file: ci.file, action: ci.action, bytes: ci.bytes, own: ci.own, reason: ci.reason },
149
+ identity: identityPlan,
150
+ dependabot: {
151
+ file: dependabot.file,
152
+ action: dependabot.action,
153
+ bytes: dependabot.bytes,
154
+ own: dependabot.own,
155
+ reason: dependabot.reason,
156
+ ecosystems: dependabot.ecosystems,
157
+ facts: dependabot.facts,
158
+ stack: dependabot.stack,
159
+ },
160
+ tsconfigBase: {
161
+ file: tsconfigBase.file,
162
+ action: tsconfigBase.action,
163
+ bytes: tsconfigBase.bytes,
164
+ reason: tsconfigBase.reason,
165
+ },
166
+ adoption,
167
+ manifest: { file: MANIFEST, action: manifestAction, path: manifestFile },
168
+ region: region && {
169
+ file: region.file,
170
+ action: region.action,
171
+ previousBytes: region.previousBytes,
172
+ bytes: region.bytes,
173
+ malformed: region.action === 'malformed',
174
+ },
175
+ // Every planned target, not just AGENTS.md. The plan is rendered before anything is
176
+ // written, so a dry run and a real run describe the same set of files.
177
+ targets: projected.targets.map((t) => ({
178
+ file: t.file,
179
+ // `kind` and `surface` are carried so the plan can say what edit each file is
180
+ // about to receive. A reader approving "frontmatter kept, body replaced" for a
181
+ // file that keeps everything outside a region has been told the wrong thing,
182
+ // and this is the one place they would learn it.
183
+ kind: t.kind,
184
+ surface: t.surface,
185
+ action: t.action,
186
+ previousBytes: t.previousBytes,
187
+ bytes: t.bytes,
188
+ })),
189
+ findings,
190
+ summary,
191
+ dryRun,
192
+ written: false,
193
+ git: null,
194
+ };
195
+
196
+ // A `.mdc` rule that cannot render is a defect in this build. Adopting a repo
197
+ // without it would leave the family half-retired, and the user would have no way
198
+ // to tell which half from the diff.
199
+ if (mdcProblems.length > 0) {
200
+ payload.ok = false;
201
+ payload.blockedBy = 'mdc-problem';
202
+ for (const p of mdcProblems) note(`PROBLEM ${p}`);
203
+ if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
204
+ return fail(
205
+ `${mdcProblems.length} canonical .mdc rule(s) cannot be projected (${mdcProblems[0]})`,
206
+ EXIT.FAIL,
207
+ `fix the reported substitution keys in ${MANIFEST_FILE}`,
208
+ );
209
+ }
210
+
211
+ // Step 3: block on a real conflict before doing anything else. A duplicate is
212
+ // reported but never blocks - restating a ruling is a hygiene problem, while
213
+ // instructing an agent to do the forbidden thing is a correctness problem.
214
+ if (summary.conflicts > 0 && flags.acceptConflicts !== true) {
215
+ payload.ok = false;
216
+ payload.blockedBy = 'conflict';
217
+ if (isJson(flags)) {
218
+ emit(`${JSON.stringify(payload, null, 2)}\n`);
219
+ } else {
220
+ emit(render(payload, manifest));
221
+ note('');
222
+ note(
223
+ `blocked ${summary.conflicts} conflict${summary.conflicts === 1 ? '' : 's'}; nothing written`,
224
+ );
225
+ }
226
+ return fail(
227
+ `${root} contradicts ${summary.byAdr.join(', ')}`,
228
+ EXIT.FAIL,
229
+ `edit the quoted lines, then rerun: ${CLI} init --yes | or accept: ${CLI} init --accept-conflicts --yes`,
230
+ );
231
+ }
232
+
233
+ // One malformed target refuses the whole adoption, not just that file. Writing the
234
+ // others first would leave the repo half-adopted with the half that failed having
235
+ // produced no change at all — indistinguishable, in the diff, from never running.
236
+ const malformed = projected.targets.filter((t) => t.action === 'malformed');
237
+ if (malformed.length > 0) {
238
+ payload.ok = false;
239
+ payload.blockedBy = 'malformed-region';
240
+ if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
241
+ else emit(render(payload, manifest));
242
+ return fail(
243
+ `${malformed.length} target(s) have a malformed generated region (${malformed[0].file}: ${malformed[0].reason})`,
244
+ EXIT.FAIL,
245
+ `fix the markers at ${malformed[0].file}:${malformed[0].line}, then rerun: ${CLI} init --yes`,
246
+ );
247
+ }
248
+
249
+ // Step 4/5: report everything, then make changes only when told to.
250
+ if (isJson(flags)) {
251
+ if (!apply) emit(`${JSON.stringify(payload, null, 2)}\n`);
252
+ } else {
253
+ emit(render(payload, manifest));
254
+ }
255
+ if (!apply) {
256
+ note('');
257
+ note(
258
+ dryRun
259
+ ? 'dry-run nothing written'
260
+ : `dry-run nothing written; apply with: ${CLI} init --yes${adoption ? ' (add --accept-conflicts to proceed despite conflicts)' : ''}`,
261
+ );
262
+ return EXIT.OK;
263
+ }
264
+
265
+ const git = await establishEscapeHatch(root);
266
+ payload.git = git;
267
+ for (const message of git.notes) note(`note ${message}`);
268
+
269
+ await writeManifest(root, manifest);
270
+ note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
271
+
272
+ // Identity last, and for the same reason the gate is written after the manifest:
273
+ // the things that change what this repository does come first, and the one that
274
+ // only records who is doing them comes once there is something to be committing.
275
+ //
276
+ // Three separate refusals, each of which is the correct answer rather than a
277
+ // failure to try harder. An existing file is not this tool's to rewrite. An
278
+ // unconfigured identity is not one to invent - a placeholder is an identity git
279
+ // would use. And the include is only repointed when it is missing or wrong,
280
+ // because replacing it when it is already right would be a no-op that reads as
281
+ // a change.
282
+ if (payload.identity.action === 'created') {
283
+ await writeFile(join(root, IDENTITY_FILE), renderIdentityFile(identity));
284
+ await ignoreIdentity(root);
285
+ note(`wrote ${IDENTITY_FILE} (${identity.from === 'last-commit' ? 'from the last commit in this repository' : 'from the identity configured on this machine'}); gitignored, because it is yours and not the repository's`);
286
+ } else if (payload.identity.action === 'unchanged') {
287
+ note(`kept ${IDENTITY_FILE} (${payload.identity.reason})`);
288
+ } else {
289
+ note(`skipped ${IDENTITY_FILE} ${payload.identity.reason}`);
290
+ }
291
+ if (!payload.identity.include) {
292
+ await includeOwnIdentity(root);
293
+ note(`set ${INCLUDE_KEY} = ../${IDENTITY_FILE} this repository's own identity file`);
294
+ }
295
+
296
+ // Written after the manifest so the gate exists in the same adoption that declares
297
+ // what the gates are. Skipped with its reason printed rather than silently: a repo
298
+ // with no `.github/workflows` has no CI surface to attach to, and inventing one
299
+ // starts running jobs on pull requests nobody asked for.
300
+ //
301
+ // `own` is passed through because the two kinds of repository get different bytes:
302
+ // simt-dev's own copy says so in its own header, and writing the adopting repo's
303
+ // text into the template's own repository would be the one place a reader is
304
+ // guaranteed to be misled.
305
+ if (payload.ci.action === 'create') {
306
+ await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
307
+ await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own }));
308
+ note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
309
+ }
310
+ if (payload.dependabot.action === 'create') {
311
+ await mkdir(dirname(join(root, payload.dependabot.file)), { recursive: true });
312
+ // Rendered from the plan's facts, not from a fresh detection: the bytes printed
313
+ // in the plan below are the bytes written here, and re-detecting would make the
314
+ // printed entry list and the written one disagree the moment a manifest appears
315
+ // between the two calls.
316
+ await writeFile(
317
+ join(root, payload.dependabot.file),
318
+ renderDependabot(payload.dependabot.facts, {
319
+ stack: payload.dependabot.stack,
320
+ own: payload.dependabot.own,
321
+ }),
322
+ );
323
+ note(
324
+ `wrote ${payload.dependabot.file} (create; watches ${payload.dependabot.ecosystems.join(', ') || 'github actions'} — yours to edit, simt-dev will not rewrite it)`,
325
+ );
326
+ }
327
+ if (payload.tsconfigBase.action === 'create') {
328
+ await writeFile(join(root, payload.tsconfigBase.file), renderTsconfigBase());
329
+ note(
330
+ `wrote ${payload.tsconfigBase.file} (create; ${TSCONFIG_BASE_FILE} is JSON, so it cannot say this itself — add "extends": "./${TSCONFIG_BASE_FILE}" to your tsconfig.json or nothing reads it)`,
331
+ );
332
+ }
333
+
334
+ // Every planned target is written, not just the AGENTS.md region. An adoption that
335
+ // scaffolds the manifest and one region but leaves the `.mdc` rules for a later
336
+ // `sync` produces a repo whose `check` fails on files the user was never told
337
+ // about — which reads as a broken adoption rather than an unfinished one.
338
+ for (const target of projected.targets) {
339
+ if (target.action === 'unchanged') {
340
+ note(`current ${target.file} (already up to date)`);
341
+ continue;
342
+ }
343
+ if (target.action === 'malformed') {
344
+ // Checked before any write above; reaching here means the guard moved.
345
+ return fail(
346
+ `${target.file}: ${target.reason}`,
347
+ EXIT.FAIL,
348
+ `fix the markers at ${target.file}:${target.line}, then rerun: ${CLI} init --yes`,
349
+ );
350
+ }
351
+ // A per-rule target lives in `.cursor/rules/`, `.claude/rules/`,
352
+ // `.agents/rules/` or `.github/instructions/` — four directories a repo that had
353
+ // no such configuration at all will not have. Creating them is part of adopting,
354
+ // not a detail of writing the file.
355
+ await mkdir(dirname(target.path), { recursive: true });
356
+ await writeFile(target.path, target.text);
357
+ note(`wrote ${target.file} (${target.action})`);
358
+ }
359
+
360
+ // Pointers, after the file they point at exists. A regular file already at one of
361
+ // these paths is reported and left alone: `CLAUDE.md` is exactly the path the
362
+ // estate's one byte-identical duplicate lives at, and replacing it silently during
363
+ // an adoption would destroy hand-written prose in a file the user was not shown.
364
+ for (const p of pointerPlan) {
365
+ // `pointerPlan` carries current pointers too, for the report. Only the ones that
366
+ // are actually missing get a link written; calling `symlink` on a link that
367
+ // already exists is EEXIST, which is what a re-init hit the first time the report
368
+ // and the writer were reading the same list.
369
+ if (p.state === 'current') {
370
+ note(`pointer ${p.file} -> ${p.to} already correct`);
371
+ continue;
372
+ }
373
+ await symlink(p.to, p.path);
374
+ note(`linked ${p.file} -> ${p.to}`);
375
+ }
376
+ for (const p of pointerBlocked) {
377
+ note(`BLOCKED ${p.file}: ${p.detail} (${p.bytes} bytes); left as it is`);
378
+ }
379
+ payload.written = true;
380
+
381
+ if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`);
382
+
383
+ note('');
384
+ note(`next ${CLI} sync --yes, then ${CLI} check in CI. Commit when you are happy with the diff.`);
385
+ // The manifest declares what the repo promises to enforce. Nothing enforces it until
386
+ // the dispatcher is installed, and `init` used to finish without saying so — so a
387
+ // fully adopted, `check`-green repo could enforce nothing at all, which is the state
388
+ // the first adoption reached.
389
+ const gates = [...(manifest.gates.preCommit ?? []), ...(manifest.gates.prePush ?? [])];
390
+ if (gates.length === 0) {
391
+ note('hooks this repo declares no gate it can run, so there is nothing to install. Run it in CI instead.');
392
+ } else {
393
+ const state = await readHooksState(root);
394
+ // Singular and plural, because "test are declared" is the kind of small wrongness
395
+ // that makes a reader stop trusting the rest of the line.
396
+ const verb = gates.length === 1 ? 'is declared' : 'are declared';
397
+ note(
398
+ state.complete
399
+ ? `hooks installed and current: ${gates.join(', ')}`
400
+ : `hooks NOT ENFORCED yet - ${gates.join(', ')} ${verb} but no hook runs them. Install with: ${CLI} hooks install --root ${root} --yes`,
401
+ );
402
+ }
403
+ return EXIT.OK;
404
+ }
405
+
406
+ /** Scan whichever rule files exist, named or globbed. A missing file is not a
407
+ * finding. Generated regions are skipped: quoting a ruling is not a conflict with
408
+ * it, and the region is rewritten by `sync` anyway. */
409
+ async function scanRepo(root) {
410
+ const findings = [];
411
+ for (const path of await rulePaths(root)) {
412
+ let text;
413
+ try {
414
+ text = await readFile(path, 'utf8');
415
+ } catch {
416
+ continue;
417
+ }
418
+ const region = parseRegion(text);
419
+ const handWritten = region.status === 'ok' ? `${region.before}\n${region.after}` : text;
420
+ findings.push(...scanDocument(handWritten, path));
421
+ }
422
+ return findings;
423
+ }
424
+
425
+ /**
426
+ * A named escape hatch before the first write, per plan 0001. Both operations are
427
+ * best-effort: a repo that is not a git repo, or has no commits yet, must still be
428
+ * adoptable. Failure is reported as a note rather than blocking, because refusing
429
+ * to initialise an unborn repo would be worse than proceeding without a backup.
430
+ */
431
+ async function establishEscapeHatch(root) {
432
+ const result = { branch: null, tag: null, notes: [] };
433
+ const isRepo = await git(root, ['rev-parse', '--is-inside-work-tree']);
434
+ if (!isRepo.ok) {
435
+ result.notes.push('not a git work tree; no backup branch or tag created');
436
+ return result;
437
+ }
438
+
439
+ const dirty = await git(root, ['status', '--porcelain']);
440
+ if (dirty.ok && dirty.stdout.trim() !== '') {
441
+ result.notes.push('worktree already dirty; left untouched, existing changes are not staged');
442
+ }
443
+
444
+ const head = await git(root, ['rev-parse', '--verify', 'HEAD']);
445
+ if (!head.ok) {
446
+ result.notes.push('no commits yet; nothing to tag');
447
+ } else {
448
+ const hasTag = await git(root, ['rev-parse', '--verify', `refs/tags/${BACKUP_TAG}`]);
449
+ if (hasTag.ok) {
450
+ result.tag = BACKUP_TAG;
451
+ } else {
452
+ const tag = await git(root, ['tag', BACKUP_TAG]);
453
+ if (tag.ok) result.tag = BACKUP_TAG;
454
+ else result.notes.push(`could not create tag ${BACKUP_TAG}: ${tag.stderr.trim()}`);
455
+ }
456
+ }
457
+
458
+ const current = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
459
+ if (current.ok && current.stdout.trim() !== ADOPT_BRANCH) {
460
+ const branch = await git(root, ['checkout', '-b', ADOPT_BRANCH]);
461
+ if (branch.ok) result.branch = ADOPT_BRANCH;
462
+ else result.notes.push(`could not create branch ${ADOPT_BRANCH}: ${branch.stderr.trim()}`);
463
+ } else if (current.ok) {
464
+ result.branch = ADOPT_BRANCH;
465
+ }
466
+
467
+ return result;
468
+ }
469
+
470
+ async function git(cwd, args) {
471
+ try {
472
+ const { stdout, stderr } = await run('git', args, { cwd });
473
+ return { ok: true, stdout, stderr };
474
+ } catch (error) {
475
+ return { ok: false, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
476
+ }
477
+ }
478
+
479
+ /** Every line is a decision with a reason. Nothing is silent. */
480
+ function render(payload, manifest) {
481
+ const lines = [
482
+ `repo ${payload.root}`,
483
+ `stack ${payload.stack.length > 0 ? payload.stack.join(', ') : '(undetected)'}`,
484
+ `facts ${payload.facts.length > 0 ? payload.facts.join(' ') : '(none)'}`,
485
+ `profiles ${payload.profiles.join(', ')}${payload.fallback ? ' (fallback)' : ''}`,
486
+ `mode ${payload.adoption ? 'adopt existing repo' : 'scaffold new repo'}`,
487
+ surfacesLine({ ids: payload.surfaces, source: payload.surfaceSource }),
488
+ `sha ${manifest.projection.simtDevSha}`,
489
+ ];
490
+ for (const n of payload.notes) lines.push(`note ${n}`);
491
+
492
+ lines.push('', 'PLAN');
493
+ lines.push(` ${payload.manifest.action.padEnd(9)} ${MANIFEST_FILE}`);
494
+ lines.push(
495
+ ` id=${manifest.id} stack=${manifest.stack.join(',') || '(none)'} ` +
496
+ `preCommit=${manifest.gates.preCommit.join(',') || '(none)'} ` +
497
+ `prePush=${manifest.gates.prePush.join(',') || '(none)'} ` +
498
+ `coverage=${manifest.limits.coverage ?? 'undeclared (ADR-006)'} ` +
499
+ `fileLines=${manifest.limits.fileLines.warn}/${manifest.limits.fileLines.error} (ADR-005)`,
500
+ );
501
+ // The identity line, with its source named. "Created" is a statement about somebody's
502
+ // name landing in a file, so where the name came from is not a detail of the line.
503
+ const id = payload.identity;
504
+ if (id) {
505
+ const verb = id.action === 'skip' ? 'skip' : id.action;
506
+ const detail = id.action === 'unchanged'
507
+ ? id.reason
508
+ : id.action === 'skip'
509
+ ? id.reason
510
+ : `${id.name} <${id.email}> from ${id.from === 'last-commit' ? "this repository's last commit" : 'the identity configured on this machine'}`;
511
+ lines.push(` ${verb.padEnd(9)} ${id.file}${detail ? `\n ${detail}` : ''}`);
512
+ }
513
+ // Every gate the stack suggested and this repo cannot run is named, with the reason.
514
+ // A silently shorter gate list reads as "this repo needs no formatting check", when
515
+ // the truth is "declaring one would block every commit". The remedy is stated because
516
+ // the fix is a real choice: install the tool, add the script, or declare the command.
517
+ for (const d of payload.droppedGates ?? []) {
518
+ lines.push(` ${'dropped'.padEnd(9)} gate ${d.gate}: ${d.why}`);
519
+ lines.push(` remedy: ${d.remedy}`);
520
+ }
521
+ lines.push(
522
+ ` ${'profiles'.padEnd(9)} ${LOCAL_PROFILE_DIR}/*.md ` +
523
+ `repo-local profiles are selected by the same matcher (ADR-013)`,
524
+ );
525
+ // Every planned target is listed, not just AGENTS.md. A plan that omits the files it
526
+ // will write is the same defect as writing only the first target: the reader is told
527
+ // about one file and four change.
528
+ for (const target of payload.targets) {
529
+ // The containment guarantee is a property of a region inside a file the repo owns,
530
+ // and NOT of a managed `.mdc`. A managed rule file is a generated file: its
531
+ // frontmatter survives, its body is replaced, and printing the containment caveat
532
+ // on it would understate the edit the reader is approving. A baseline is the
533
+ // opposite case to AGENTS.md in the one way that matters here — it is placed FIRST,
534
+ // so the repo's own settings below it keep winning (ADR-025).
535
+ const caption =
536
+ target.file === PRIMARY_TARGET
537
+ ? 'hand-written content outside the markers is never modified'
538
+ : target.kind === 'baseline'
539
+ ? 'a region placed first, so your own settings below it still win (ADR-025)'
540
+ : 'generated file: frontmatter kept, body replaced (ADR-016)';
541
+ lines.push(
542
+ ` ${target.action.padEnd(9)} ${target.file} ${target.previousBytes} -> ${target.bytes} bytes ${caption}`,
543
+ );
544
+ }
545
+
546
+ // Pointers are printed as their own kind because they are the one target that
547
+ // carries no content, and a reader who sees a file listed with no byte count
548
+ // cannot tell whether it is a copy, a stub or a link.
549
+ for (const p of payload.pointers ?? []) {
550
+ lines.push(` ${'link'.padEnd(9)} ${p.file} -> ${p.to} ${p.state ?? 'absent'}`);
551
+ lines.push(` readers: ${p.readers}; carries no content of its own (ADR-020)`);
552
+ }
553
+ for (const p of payload.pointersBlocked ?? []) {
554
+ lines.push(` ${'blocked'.padEnd(9)} ${p.file} ${p.detail} (${p.bytes} bytes), left as it is`);
555
+ lines.push(` remedy: move it aside, or delete it and rerun init`);
556
+ }
557
+
558
+ // The CI gate is listed as its own kind because it is the one plan entry whose
559
+ // absence is a decision rather than a default. Skipped states its reason inline so
560
+ // a reader can tell "this repo does not use GitHub Actions" from "this repo was
561
+ // not offered the gate".
562
+ if (payload.ci.action === 'create') {
563
+ lines.push(` ${'create'.padEnd(9)} ${payload.ci.file} ${renderWorkflow({ own: payload.ci.own }).length} bytes`);
564
+ lines.push(
565
+ payload.ci.own
566
+ ? ` this repository is simt-dev's own; ${CLI} ci --yes refreshes this copy when the gate changes`
567
+ : ` runs ${CLI} check where a pull request is merged; yours to edit after this`,
568
+ );
569
+ } else if (payload.ci.action === 'refresh') {
570
+ // init does not write it. `simt-dev ci` owns the template's own copy, and a
571
+ // second writer would be a second answer to who owns it.
572
+ lines.push(` ${'refresh'.padEnd(9)} ${payload.ci.file} ${payload.ci.reason}`);
573
+ lines.push(` remedy: ${CLI} ci --yes (init does not write simt-dev's own copy)`);
574
+ } else if (payload.ci.action === 'skip') {
575
+ lines.push(` ${'skip'.padEnd(9)} ${payload.ci.file} ${payload.ci.reason}`);
576
+ lines.push(` create ${WORKFLOWS_DIR}/ first if this repo is on GitHub Actions, then rerun init`);
577
+ }
578
+
579
+ // Same three states as the gate, and deliberately the same shape rather than a
580
+ // second formatting: both files exist for the same reason and a reader comparing
581
+ // the two plan rows is checking for a difference that means something, not one
582
+ // produced by two hand-written renderers.
583
+ const db = payload.dependabot;
584
+ if (db && db.action === 'create') {
585
+ lines.push(` ${'create'.padEnd(9)} ${db.file} ${renderDependabot(db.facts, { stack: db.stack, own: db.own }).length} bytes`);
586
+ lines.push(
587
+ db.own
588
+ ? ` watches ${db.ecosystems.join(', ')}; ${CLI} ci --yes refreshes this copy when the template changes`
589
+ : ` watches ${db.ecosystems.join(', ') || 'github actions'} - one per manifest this repo has; yours to edit after this`,
590
+ );
591
+ } else if (db && db.action === 'refresh') {
592
+ lines.push(` ${'refresh'.padEnd(9)} ${db.file} ${db.reason}`);
593
+ lines.push(` remedy: ${CLI} ci --yes (init does not write simt-dev's own copy)`);
594
+ } else if (db && db.action === 'skip') {
595
+ lines.push(` ${'skip'.padEnd(9)} ${db.file} ${db.reason}`);
596
+ lines.push(` create ${WORKFLOWS_DIR}/ first if this repo is on GitHub Actions, then rerun init`);
597
+ }
598
+
599
+ // Two states rather than three: there is no refresh, because `init` writes this file once
600
+ // and there is no repository in this estate that owns the template. A third state here
601
+ // would be a mechanism for keeping a copy current that nobody has asked for.
602
+ const tb = payload.tsconfigBase;
603
+ if (tb && tb.action === 'create') {
604
+ lines.push(` ${'create'.padEnd(9)} ${tb.file} ${renderTsconfigBase().length} bytes`);
605
+ lines.push(
606
+ ` the strictness flags these repositories already agree on; add "extends": "./${tb.file}" to your tsconfig.json to use it`,
607
+ );
608
+ } else if (tb && tb.action === 'skip') {
609
+ lines.push(` ${'skip'.padEnd(9)} ${tb.file} ${tb.reason}`);
610
+ }
611
+
612
+ const { findings, summary } = payload;
613
+ if (summary.conflicts > 0) {
614
+ lines.push('', `CONFLICTS ${summary.conflicts} - blocking without --accept-conflicts`);
615
+ for (const f of findings.filter((x) => x.severity === 'conflict')) {
616
+ lines.push(` ${f.file}:${f.line} ${f.adr} ${f.id}`);
617
+ lines.push(` found ${f.quote}`);
618
+ lines.push(` ruling ${f.canonical}`);
619
+ lines.push(` action edit the line above, then rerun init`);
620
+ }
621
+ }
622
+ if (summary.duplicates > 0) {
623
+ lines.push('', `DUPLICATES ${summary.duplicates} - reported, not blocking`);
624
+ lines.push(' these restate a ruling outside the generated region (anti-duplication doctrine)');
625
+ for (const f of findings.filter((x) => x.severity === 'duplicate')) {
626
+ lines.push(` ${f.file}:${f.line} ${f.adr} ${f.id}`);
627
+ lines.push(` ruling ${f.canonical}`);
628
+ }
629
+ }
630
+ if (summary.total === 0) lines.push('', 'CONFLICTS none detected');
631
+ return lines.filter((l) => l !== undefined).join('\n');
632
+ }