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,398 @@
1
+ // Which editor a repo's rules are projected into, and why.
2
+ //
3
+ // simt-dev used to write exactly two things into a consumer repo: a region in
4
+ // `AGENTS.md`, and `.cursor/rules/*.mdc`. That is support for two IDEs, one of
5
+ // which is the only reader of the `.mdc` extension. A Claude Code user, a Copilot
6
+ // user and an Antigravity user in an adopted repo received `AGENTS.md` — the core
7
+ // and stack rules — and **none** of the three always-on process rules, because
8
+ // those lived only in a directory only Cursor reads. "Supported" was true of the
9
+ // file format and false of the estate.
10
+ //
11
+ // This module is the named surface: each row is one editor, the path its rules
12
+ // take, the frontmatter keys it understands, and the document the row was checked
13
+ // against. Nothing here is inferred from the ecosystem; a surface is a fact about
14
+ // a vendor, and it was read, not guessed.
15
+ //
16
+ // FOUR DECISIONS, each with a cost.
17
+ //
18
+ // 1. A POINTER IS NOT A COPY. `CLAUDE.md` and `GEMINI.md` are symlinks to
19
+ // `AGENTS.md`. A copy is the corpus's one true content duplicate
20
+ // (one repository's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
21
+ // the DeepSeek harness explicitly rejects loading both in one directory
22
+ // because "repositories in transition commonly duplicate guidance" — and
23
+ // *its* dedup is content-based, so a symlink collapses to one while a
24
+ // drifted copy loads in full beside its twin. Both vendors document the
25
+ // symlink. POSIX-only symlinks are already in scope under ADR-012.
26
+ //
27
+ // 2. THE PER-RULE SURFACES ARE GENERATED FILES, NOT SYMLINKS, because symlink
28
+ // support is documented for only one of them. Claude Code states that
29
+ // `.claude/rules/` supports symlinks. Nothing states that for
30
+ // `.agents/rules/` or `.github/instructions/`, and a symlink into a
31
+ // directory whose reader's symlink behaviour is unverified is a guess with a
32
+ // mode bit. So those get machine-written copies — and because every byte is
33
+ // derived from `rules/mdc/*.mdc` and `simt-dev check` compares each against
34
+ // that same derivation, they are not duplication, they are a build output.
35
+ // The difference from that duplicate is that nobody can hand-edit one
36
+ // and have it survive a sync.
37
+ //
38
+ // 3. AN ALWAYS-ON RULE EMITS NO SCOPING FRONTMATTER. Claude Code's `paths:` key
39
+ // has a documented parser defect — quoted values and YAML sequences
40
+ // silently fail to lazy-load (anthropics/claude-code#13905, #17204,
41
+ // #19377), and the documented examples are not valid YAML. Every rule
42
+ // shipped here is always-on, so the key would buy nothing and cost a
43
+ // silent-failure surface. Absence of frontmatter is documented to load
44
+ // eagerly, which is exactly what is wanted.
45
+ //
46
+ // 4. `verified` IS THE POINT OF THE TABLE. A row whose `verified` is null was
47
+ // not checked against a vendor document, and the row says so where a reader
48
+ // can see it. A surface list that silently grew a guessed row would put an
49
+ // unverified assumption into every repo that adopted it, which is the exact
50
+ // failure this table exists to prevent.
51
+
52
+ /**
53
+ * Surfaces, in the order the writer emits them.
54
+ *
55
+ * `kind`:
56
+ * - `region` a generated region inside a file the repo may also write by hand
57
+ * - `file` a wholly generated file; hand-written body text is retired
58
+ * - `pointer` a symlink to the primary target; carries no content of its own
59
+ *
60
+ * `ruleFrontmatter` returns the keys that editor reads BEFORE any rule text. A
61
+ * surface that needs none returns '' and the projected file starts at the body.
62
+ */
63
+ export const SURFACES = [
64
+ {
65
+ // THE STANDARD SURFACE. One repo, one editor, one file — unless the agent or the
66
+ // repo's owner names another. This row exists because "write a file for every IDE
67
+ // we know" is not support: it gives a repo that lives in opencode three rule
68
+ // directories and two symlinks it will never open, and it means every rule change
69
+ // lands in four places instead of one. ADR-020 wrote all four; ADR-021 writes one
70
+ // and asks.
71
+ id: 'opencode',
72
+ label: 'opencode (AGENTS.md + .agents/skills)',
73
+ readers:
74
+ 'opencode, and AGENTS.md is also read natively by Codex, Cursor, Windsurf, Zed, Amp, Jules, Copilot CLI and the DeepSeek harness; Claude Code falls back to it, Antigravity and Gemini CLI accept GEMINI.md as an equal name',
75
+ primary: 'AGENTS.md',
76
+ kind: 'region',
77
+ // opencode documents NO per-rule directory: it reads AGENTS.md (walking up to the
78
+ // git worktree), CLAUDE.md only as a fallback, and .opencode/AGENTS.md in
79
+ // addition. So `foldRules` — this surface carries every rule inside the one
80
+ // generated region, and the other rows are how a rule reaches an editor that
81
+ // cannot read AGENTS.md. Writing .opencode/rules/*.md would be a guess, and a
82
+ // guessed path is the failure this table exists to prevent.
83
+ rule: null,
84
+ foldRules: true,
85
+ skills: '.agents/skills',
86
+ documentedSkillsPaths: ['.opencode/skills', '.claude/skills', '.agents/skills'],
87
+ // Codex concatenates AGENTS.md from the root down and stops at
88
+ // project_doc_max_bytes. The default is 32 KiB. A region that ends past it is
89
+ // never read, and nothing else in check notices. Other readers of this file
90
+ // publish no such limit.
91
+ readLimits: [
92
+ {
93
+ reader: 'Codex',
94
+ bytes: 32768,
95
+ verified: 'Codex project_doc_max_bytes defaults to 32 KiB',
96
+ },
97
+ ],
98
+ verified:
99
+ 'opencode docs, "Rules" and "Agent Skills" (read 2026-10-07) — AGENTS.md walked up from cwd to the git worktree; CLAUDE.md used only if no AGENTS.md exists; .opencode/AGENTS.md additionally; skills at .opencode/skills/, .claude/skills/ and .agents/skills/<name>/SKILL.md; skill frontmatter recognises only name, description, license, compatibility, metadata',
100
+ note:
101
+ '.agents/skills is one of the three skill locations opencode documents, so the same directory serves the standard surface without a symlink.',
102
+ },
103
+ {
104
+ id: 'cursor',
105
+ label: 'Cursor rules',
106
+ readers: 'Cursor',
107
+ primary: null,
108
+ kind: 'file',
109
+ rule: '.cursor/rules/{name}.mdc',
110
+ // `documentedKeys` is the set the vendor's own docs name for this surface, and
111
+ // `ruleFrontmatter` must emit nothing outside it. A key invented here would be
112
+ // inert at best; on Antigravity an unrecognised value silently discards the
113
+ // rule. The guard lives in test/shipped.test.mjs, and this row is its evidence.
114
+ documentedKeys: ['description', 'alwaysApply', 'globs'],
115
+ // ADR-021 ruled that "a Cursor reader getting .mdc files does not serve them",
116
+ // because Cursor had no documented channel for AGENTS.md. It does, and has:
117
+ // cursor.com/docs/context/rules calls AGENTS.md "a simple alternative to
118
+ // .cursor/rules" and documents support in the project root and subdirectories.
119
+ // So a folded rule reaches a Cursor reader twice - once inside the region, once
120
+ // in the .mdc - and the second copy is 28 KB of pure repetition across the five
121
+ // repos that select both surfaces.
122
+ //
123
+ // The caveat belongs here rather than in the ADR body, because it is a fact about
124
+ // a product and products move: a Cursor bug report records AGENTS.md NOT being
125
+ // read in worktree agents by default, fixed in 3.9. A reader below that version
126
+ // gets the .mdc copy and a reader above it does not need it.
127
+ readsPrimary: true,
128
+ // Filled into a substitution only when every selected surface names the same
129
+ // value. One body is written for all of them, so a Cursor name must not land
130
+ // in a file Claude Code also reads.
131
+ substitutions: { askTool: 'Use the interactive `AskQuestion` tool' },
132
+ ruleFrontmatter: (rule) =>
133
+ [
134
+ `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
135
+ `alwaysApply: ${rule.alwaysApply ? 'true' : 'false'}`,
136
+ ],
137
+ verified: 'Cursor project rules (.cursor/rules/*.mdc, description + alwaysApply + globs)',
138
+ note: 'The only surface that reads `.mdc`. Every other row here is a plain `.md` or a symlink.',
139
+ },
140
+ {
141
+ id: 'claude',
142
+ label: 'Claude Code rules',
143
+ readers: 'Claude Code',
144
+ primary: null,
145
+ kind: 'file',
146
+ rule: '.claude/rules/{name}.md',
147
+ // Intentionally no `paths:`. See decision 3 in the header.
148
+ documentedKeys: ['paths', 'description'],
149
+ ruleFrontmatter: () => [],
150
+ // The claude-md pointer makes CLAUDE.md a symlink to AGENTS.md (ADR-021 ruling 1),
151
+ // and Claude reads CLAUDE.md in preference to AGENTS.md. So the folded region
152
+ // arrives through the pointer and a rule file alongside it is the same text twice.
153
+ readsPrimary: true,
154
+ substitutions: { askTool: 'Use the interactive `AskUserQuestion` tool' },
155
+ verified: 'Claude Code, "Organize rules with .claude/rules/" — recursive discovery, symlinks supported, no `paths` loads at session start',
156
+ note:
157
+ 'CLAUDE.md is a separate pointer surface (see POINTERS). A rule file without frontmatter loads eagerly, which is what an always-on rule wants.',
158
+ },
159
+ {
160
+ id: 'antigravity',
161
+ label: 'Antigravity workspace rules',
162
+ readers: 'Antigravity IDE, Antigravity CLI',
163
+ primary: null,
164
+ kind: 'file',
165
+ rule: '.agents/rules/{name}.md',
166
+ // `trigger` is REQUIRED here and an unrecognised value discards the rule with no
167
+ // message, which is why the value is mapped rather than passed through.
168
+ documentedKeys: ['trigger', 'description', 'globs', 'glob'],
169
+ // The gemini-md pointer's own `verified` cites the same Antigravity docs: "workspace
170
+ // rules are AGENTS.md or GEMINI.md in the root or any subdirectory". Either name
171
+ // reaches the reader, so a .agents/rules file repeats text the region already
172
+ // carries. Both channels are documented as alternatives to each other, which is
173
+ // what makes them duplicates rather than a fallback.
174
+ readsPrimary: true,
175
+ ruleFrontmatter: (rule) => [
176
+ `trigger: ${rule.alwaysApply ? 'always_on' : 'model_decision'}`,
177
+ `description: ${rule.description ?? `Canonical ${rule.id} rule managed by simt-dev`}`,
178
+ ],
179
+ verified: 'Google Antigravity docs, "Rules" — .agents/rules/*.md, YAML frontmatter with trigger: always_on | model_decision | glob | manual; missing or invalid frontmatter silently discards the rule',
180
+ note:
181
+ 'This is why frontmatter is mandatory here and why the trigger value is not guessed: an unrecognised trigger means the rule is dropped with no message.',
182
+ },
183
+ {
184
+ id: 'copilot',
185
+ label: 'GitHub Copilot instructions',
186
+ readers: 'GitHub Copilot chat, Copilot CLI, Copilot code review, Copilot cloud agent',
187
+ primary: null,
188
+ kind: 'file',
189
+ rule: '.github/instructions/{name}.instructions.md',
190
+ documentedKeys: ['applyTo', 'excludeAgent'],
191
+ ruleFrontmatter: () => ['applyTo: "**"'],
192
+ verified:
193
+ 'GitHub docs, "Adding repository custom instructions" — .github/instructions/NAME.instructions.md with applyTo globs; `applyTo: "**"` is the documented example',
194
+ // NOT marked readsPrimary, and this row is the reason the flag is per-surface
195
+ // rather than global. The reader is split: Copilot CLI reads AGENTS.md, the
196
+ // editor and code review do not. A per-rule file here is not duplication for two
197
+ // of the three, and dropping it would remove the process rules from a chat that
198
+ // had no other channel to them.
199
+ note:
200
+ 'Copilot reads AGENTS.md itself through Copilot CLI, but the editor and code review do not; this is the channel they use.',
201
+ },
202
+ ];
203
+
204
+ /**
205
+ * Pointers: files that must exist and must carry no content.
206
+ *
207
+ * A pointer is the cheapest possible answer to an editor that wants its own
208
+ * filename, and the only one that cannot rot: a stale pointer is a broken symlink
209
+ * that fails loudly, whereas a stale copy is a file full of confidently wrong
210
+ * rules that nobody notices for a release.
211
+ */
212
+ export const POINTERS = [
213
+ {
214
+ id: 'claude-md',
215
+ // A pointer belongs to the surface that needs it, never to every repo.
216
+ // ADR-020 wrote both of these unconditionally, which meant that narrowing to
217
+ // one surface under ADR-021 still created a CLAUDE.md nobody asked for — the
218
+ // same over-writing that ruling set out to stop, one file down.
219
+ surface: 'claude',
220
+ path: 'CLAUDE.md',
221
+ to: 'AGENTS.md',
222
+ readers: 'Claude Code',
223
+ verified:
224
+ 'Claude Code memory docs: "A symlink also works" — `ln -s AGENTS.md CLAUDE.md`; Claude reads CLAUDE.md by default and AGENTS.md only as a fallback',
225
+ note:
226
+ 'Claude Code reads CLAUDE.md and ignores AGENTS.md when both exist. A pointer is what makes one file answer for both without a second copy.',
227
+ },
228
+ {
229
+ id: 'gemini-md',
230
+ surface: 'antigravity',
231
+ path: 'GEMINI.md',
232
+ to: 'AGENTS.md',
233
+ readers: 'Gemini CLI, Antigravity',
234
+ verified:
235
+ 'Google Antigravity docs, "Rules": workspace rules are AGENTS.md or GEMINI.md in the root or any subdirectory; Copilot CLI also discovers GEMINI.md',
236
+ note: 'Covers Gemini CLI and gives Antigravity a second, equally-native name for the same file.',
237
+ },
238
+ ];
239
+
240
+ /**
241
+ * The global standard: what a repo gets when it has not chosen.
242
+ *
243
+ * ONE surface, not all of them. ADR-020 made the default the union of every
244
+ * verified surface, on the reasoning that writing nothing leaves editors
245
+ * unsupported. It over-corrected: a repo that lives in one editor acquired four
246
+ * rule directories and two symlinks, and every rule change landed four times.
247
+ * "Support every known editor" is satisfied by *knowing how to* write each one and
248
+ * writing the one that was asked for.
249
+ *
250
+ * The standard is opencode, because it is the agent simt-dev is developed against
251
+ * and its paths (`AGENTS.md`, `.agents/skills/`) are the ones this workspace already
252
+ * uses. `AGENTS.md` is the widest-reaching filename in the table: seven of the eight
253
+ * readers listed on that row read it natively, so the default is not a private
254
+ * choice — it is the file most editors already read.
255
+ *
256
+ * A repo narrows or changes it by declaring `"surfaces"` in simt-dev.json, and the
257
+ * agent can be asked for a specific one with `--surfaces`. Declaring an empty list is
258
+ * a real choice and means AGENTS.md with no per-rule files and no pointers.
259
+ */
260
+ export const DEFAULT_SURFACES = ['opencode'];
261
+
262
+ /**
263
+ * True when every selected surface carries its rules inside the primary region
264
+ * rather than in per-rule files.
265
+ *
266
+ * ANY selected surface that folds is enough. Asking "does the whole set fold?" would
267
+ * be wrong the moment a repo adds a second surface: opencode's only channel is
268
+ * AGENTS.md, so a reader in opencode still needs the rules in the region even when a
269
+ * Cursor reader gets them as `.mdc`. Folding and per-rule files therefore coexist,
270
+ * and `ruleTargets` already skips a surface that has no rule path.
271
+ *
272
+ * Asked in exactly two places that must agree: `desiredRegion` folds the bodies in,
273
+ * and `mdcTargets` writes the files. Asking it twice is how ADR-020's fold decision
274
+ * could drift between the writer and the gate, which is the step-6 failure.
275
+ */
276
+ export function foldsRules(resolved) {
277
+ return resolved.surfaces.some((s) => s.foldRules === true);
278
+ }
279
+
280
+ /**
281
+ * Surfaces whose reader already loads the primary document, so a per-rule file for
282
+ * them repeats text the region already carries.
283
+ *
284
+ * Only meaningful when folding is on: with no folding there is no region to duplicate.
285
+ * That condition is why this is not a property of the surface alone, and why the
286
+ * callers ask both questions rather than this function answering both.
287
+ *
288
+ * Per-surface rather than global, because Copilot is the counter-example: its CLI reads
289
+ * AGENTS.md and its editor and code review do not, so a per-rule file there is the only
290
+ * channel two of its three readers have.
291
+ */
292
+ export function readsPrimary(resolved) {
293
+ return foldsRules(resolved) && resolved.surfaces.some((s) => s.readsPrimary === true);
294
+ }
295
+
296
+ export const SURFACE_IDS = SURFACES.map((s) => s.id);
297
+
298
+ /**
299
+ * Frontmatter keys a surface emits that its own vendor does not document.
300
+ *
301
+ * Returns one message per offender. Lives here rather than in a test so that the
302
+ * shipped-content gate and the surface table cannot drift apart: the gate asks this
303
+ * question, and so can anyone who wants to add a row.
304
+ *
305
+ * Two failure modes it must catch, both silent at the far end. A key an editor does
306
+ * not read is inert — the rule loads with its scoping ignored. A *value* an editor
307
+ * does not recognise is worse: Antigravity's documented behaviour for an
308
+ * unrecognised `trigger` is to discard the rule with no message, so the repo looks
309
+ * configured and is not.
310
+ */
311
+ export function undocumentedKeys(surfaceRows = SURFACES) {
312
+ const bad = [];
313
+ for (const s of surfaceRows) {
314
+ if (!s.ruleFrontmatter) continue;
315
+ const documented = Array.isArray(s.documentedKeys) ? s.documentedKeys : [];
316
+ for (const line of s.ruleFrontmatter({ id: 'probe', description: 'probe', alwaysApply: true })) {
317
+ const key = String(line).split(':')[0].trim();
318
+ if (!documented.includes(key)) {
319
+ bad.push(`${s.id} emits frontmatter key "${key}"; its vendor documents ${documented.join(', ') || '(nothing)'}`);
320
+ }
321
+ }
322
+ }
323
+ return bad;
324
+ }
325
+
326
+ /** Surface `id` or null. */
327
+ export function surface(id) {
328
+ return SURFACES.find((s) => s.id === id) ?? null;
329
+ }
330
+
331
+ /** The absolute-ish path a rule takes on a surface, given the rule's bare name. */
332
+ /** Assemble YAML frontmatter lines into a block. Lives here because this is the
333
+ * lowest module that owns frontmatter: surfaces.mjs imports nothing, so both
334
+ * the `.mdc` renderer and the per-surface projector can share one implementation
335
+ * without either importing the other. Two copies of these four lines is the
336
+ * "third copy of the same logic is a defect" case with a third-copy count of two. */
337
+ export function frontmatterBlock(lines) {
338
+ if (lines.length === 0) return '';
339
+ return ['---', ...lines, '---'].join('\n');
340
+ }
341
+
342
+ export function rulePath(surfaceId, name) {
343
+ const s = surface(surfaceId);
344
+ if (!s || !s.rule) return null;
345
+ return s.rule.replace('{name}', name);
346
+ }
347
+
348
+ /**
349
+ * Which surfaces this repo projects to, and why it chose them.
350
+ *
351
+ * Precedence is flag, then manifest, then the global standard. Reported as
352
+ * `source` because an invisible configuration is a configuration nobody can
353
+ * debug — the same ruling as ADR-014 for the workspace.
354
+ *
355
+ * An unknown id is reported rather than skipped: a typo in `"surfaces"` would
356
+ * otherwise silently reduce the estate to AGENTS.md, and a repo that believes it
357
+ * is covered by Copilot instructions would not be.
358
+ */
359
+ export function resolveSurfaces(manifest, flags = {}) {
360
+ const problems = [];
361
+ let ids = null;
362
+ let source = 'default';
363
+
364
+ if (typeof flags.surfaces === 'string') {
365
+ ids = flags.surfaces.split(',').map((s) => s.trim()).filter(Boolean);
366
+ source = 'flag';
367
+ } else if (manifest && Array.isArray(manifest.surfaces)) {
368
+ ids = manifest.surfaces.filter((s) => typeof s === 'string');
369
+ source = 'manifest';
370
+ } else {
371
+ ids = [...DEFAULT_SURFACES];
372
+ }
373
+
374
+ const seen = new Set();
375
+ const chosen = [];
376
+ for (const id of ids) {
377
+ if (!surface(id)) {
378
+ problems.push(`unknown surface "${id}"; known: ${SURFACE_IDS.join(', ')}`);
379
+ continue;
380
+ }
381
+ if (seen.has(id)) continue;
382
+ seen.add(id);
383
+ chosen.push(surface(id));
384
+ }
385
+ return { surfaces: chosen, ids: chosen.map((s) => s.id), source, problems };
386
+ }
387
+
388
+ /** One line a reader can check: what this repo gets, and why.
389
+ *
390
+ * `width` exists because the two renderers disagree — `init` pads its header to 12
391
+ * columns and `sync` to 8 — and a value column that does not line up with the
392
+ * lines above it reads as a different kind of thing, which is exactly the kind of
393
+ * small wrongness that makes people stop reading output carefully. */
394
+ export function surfacesLine(result, width = 12) {
395
+ const where = result.source === 'default' ? 'global standard' : result.source;
396
+ const ids = result.ids.length === 0 ? '(AGENTS.md only)' : result.ids.join(', ');
397
+ return `${'surfaces'.padEnd(width)}${ids} (${where})`;
398
+ }
@@ -0,0 +1,221 @@
1
+ // Where a rule actually lands: one body, many surfaces, plus the pointer files.
2
+ //
3
+ // Split out of project.mjs, which had grown past the 400-line review trigger in
4
+ // ADR-005 because this concern arrived with surface support (ADR-020) and had
5
+ // nowhere else to live. The split is by question, not by convenience: project.mjs
6
+ // answers *which rules apply*, this module answers *what files that produces*.
7
+ // Neither imports the other's decision, so neither can quietly change it.
8
+ //
9
+ // The load-bearing rule in here is that this is the SINGLE place a rule file is
10
+ // turned into bytes. `plan` writes what these functions return and `checkTargets`
11
+ // compares against the same construction. The step-6 defect was two places
12
+ // computing the desired region and the gate then reporting drift on a clean tree,
13
+ // which is the failure mode a PR cannot review because both places look right.
14
+
15
+ import { readdir, readFile, readlink, stat } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+
18
+ import { diff, parse, inject } from './markers.mjs';
19
+ import { ruleName } from './mdc.mjs';
20
+ import { foldsRules, frontmatterBlock, POINTERS, rulePath, SURFACES } from './surfaces.mjs';
21
+
22
+ /**
23
+ * A managed rule file is a GENERATED FILE, unlike AGENTS.md.
24
+ *
25
+ * The body after the frontmatter is the region, full stop: there is no hand-owned
26
+ * prose to preserve, because any prose there is exactly the divergent copy this
27
+ * mechanism retires. Appending instead would leave two `## Requirements` sections
28
+ * in one file — the duplication the estate's own review agent is instructed to
29
+ * block, produced by the tool meant to remove it. So the existing frontmatter is
30
+ * kept (the editor reads it before any rule text) and everything below it is
31
+ * replaced.
32
+ */
33
+ export async function ruleTargets(root, rule, body, sha, resolved, notes) {
34
+ const targets = [];
35
+ const name = ruleName(rule);
36
+
37
+ const folding = foldsRules(resolved);
38
+
39
+ for (const s of resolved.surfaces) {
40
+ if (!s.rule) continue;
41
+ // A reader that already loads AGENTS.md has the rule from the region. Writing the
42
+ // file as well is the duplication S12 exists to remove: measured across the five
43
+ // estate repos that select `opencode,cursor`, 17 rule files carrying 28 KB of text
44
+ // an agent reads twice per session. ADR-021 ruled the opposite on the belief that
45
+ // Cursor had no channel for AGENTS.md; cursor.com/docs/context/rules documents one.
46
+ //
47
+ // Skipped, never deleted. ADR-021 ruling 6: `sync` does not remove a file it once
48
+ // generated, so `compare` reports the leftover with a remedy and a human removes it.
49
+ if (folding && s.readsPrimary === true) {
50
+ const rel = rulePath(s.id, name);
51
+ if (rel) notes.push(`${rel}: not written; ${s.label} reads AGENTS.md, where the rule is already folded`);
52
+ continue;
53
+ }
54
+ const rel = rulePath(s.id, name);
55
+ if (!rel) continue;
56
+ const path = join(root, rel);
57
+
58
+ let existing = '';
59
+ let exists = true;
60
+ try {
61
+ existing = await readFile(path, 'utf8');
62
+ } catch {
63
+ exists = false;
64
+ }
65
+
66
+ const mine = s.ruleFrontmatter(rule);
67
+ const frontmatter = extractFrontmatter(existing) ?? frontmatterBlock(mine);
68
+ const state = exists ? parse(existing) : { status: 'absent' };
69
+ // A surface that wants no frontmatter must not get a blank line where the
70
+ // frontmatter would have been: Claude's rule files opened with two empty lines
71
+ // before the marker, which is untidy in a file the reader will open, and a
72
+ // difference between surfaces that no check reported.
73
+ const composed =
74
+ state.status === 'ok' ? existing : frontmatter === '' ? '' : `${frontmatter}\n`;
75
+ const result = inject(composed, body, { sha, sources: [rule.source] });
76
+
77
+ // Report the prose being retired rather than letting it vanish into a diff the
78
+ // reader has to notice. Silent deletion is the one thing a reviewer cannot audit.
79
+ const retired = exists && state.status !== 'ok' ? bodyAfterFrontmatter(existing).trim() : '';
80
+ if (retired !== '') {
81
+ notes.push(
82
+ `${rel}: retiring ${Buffer.byteLength(retired)} bytes of divergent hand-written rule text`,
83
+ );
84
+ }
85
+
86
+ targets.push({
87
+ file: rel,
88
+ surface: s.id,
89
+ path,
90
+ exists,
91
+ action: result.action,
92
+ // As on every other target: the state the file was in, not the action taken on it.
93
+ region: result.region,
94
+ previousBody: result.previousBody,
95
+ reason: result.reason,
96
+ line: result.line,
97
+ bytes: Buffer.byteLength(result.text),
98
+ previousBytes: Buffer.byteLength(existing),
99
+ sha,
100
+ body,
101
+ text: result.text,
102
+ previous: existing,
103
+ });
104
+ }
105
+ return { targets };
106
+ }
107
+
108
+
109
+ /**
110
+ * Pointer targets: `CLAUDE.md` and `GEMINI.md` as symlinks to `AGENTS.md`.
111
+ *
112
+ * A pointer target carries `kind: 'pointer'` and no `text`. The writer makes the
113
+ * link; the checker compares the link's target. Nothing else may write these two
114
+ * paths, and an existing regular file at either path is reported as a conflict
115
+ * rather than replaced, because both are files people wrote by hand — one of them
116
+ * is the corpus's only byte-identical duplicate, and overwriting it silently is
117
+ * the one outcome nobody would forgive.
118
+ */
119
+ export function pointerTargets(root, resolved = null) {
120
+ // Only the pointers whose surface was selected. `resolved` accepts the surface
121
+ // objects `plan` returns or a bare id list; `null` means every surface, which is
122
+ // what an unadopted caller means and what the unit tests pass.
123
+ const ids =
124
+ resolved === null
125
+ ? null
126
+ : new Set(resolved.map((s) => (typeof s === 'string' ? s : s.id)));
127
+ return POINTERS.filter((p) => ids === null || ids.has(p.surface)).map((p) => ({
128
+ file: p.path,
129
+ surface: p.id,
130
+ owner: p.surface,
131
+ kind: 'pointer',
132
+ path: join(root, p.path),
133
+ to: p.to,
134
+ readers: p.readers,
135
+ verified: p.verified,
136
+ }));
137
+ }
138
+
139
+ /**
140
+ * Read the current state of one pointer, for both the writer and the gate.
141
+ *
142
+ * Three answers, not two: absent, already pointing where it should, or something
143
+ * else entirely. "Something else" carries the byte count of a regular file so the
144
+ * refusal names what it would have destroyed.
145
+ */
146
+ export async function pointerState(root, pointer) {
147
+ // `pointer.path` is already root-joined by `pointerTargets`. Re-joining it here
148
+ // would look in `<root><root>/CLAUDE.md` and report every live pointer as absent
149
+ // — which is what happened the first time this ran, and which a unit test that
150
+ // passes the root twice would never catch.
151
+ const path = pointer.path;
152
+ let link = null;
153
+ try {
154
+ link = await readlink(path);
155
+ } catch (e) {
156
+ // EINVAL, not ENOENT, is what readlink returns for a path that exists and is not
157
+ // a symlink. Treating only ENOENT as "not a symlink" made every hand-written
158
+ // CLAUDE.md report `unreadable` instead of `foreign` — which is precisely the
159
+ // file this function exists to protect, and the one the corpus actually
160
+ // contains. Both codes mean "no link here"; anything else is a real fault.
161
+ if (e.code !== 'ENOENT' && e.code !== 'EINVAL') {
162
+ return { file: pointer.file, state: 'unreadable', detail: e.code };
163
+ }
164
+ // Not a symlink. Either nothing is there, or a regular file is — or a DIRECTORY,
165
+ // which is neither. A directory at `CLAUDE.md` is a real thing a repository has: it
166
+ // is where per-project Claude instructions live, and the corpus contains one. Read
167
+ // as "absent" it is invisible: `check` reported nothing at all, and `sync` crashed
168
+ // with `EEXIST` on the `symlink()` call *after* writing every other target, so the
169
+ // run left a half-migrated repo and named `simt-dev doctor` as the remedy, which
170
+ // reports nothing about it either. `stat` is what tells a directory from a file, and
171
+ // it is asked before the read because reading a directory as utf8 throws EISDIR.
172
+ try {
173
+ const st = await stat(path);
174
+ if (st.isDirectory()) {
175
+ let entries = 0;
176
+ try {
177
+ entries = (await readdir(path)).length;
178
+ } catch {
179
+ entries = 0;
180
+ }
181
+ return {
182
+ file: pointer.file,
183
+ state: 'foreign',
184
+ bytes: null,
185
+ entries,
186
+ detail: 'a directory, not a pointer',
187
+ };
188
+ }
189
+ const text = await readFile(path, 'utf8');
190
+ return {
191
+ file: pointer.file,
192
+ state: 'foreign',
193
+ bytes: Buffer.byteLength(text),
194
+ detail: 'a file, not a pointer',
195
+ };
196
+ } catch {
197
+ return { file: pointer.file, state: 'absent' };
198
+ }
199
+ }
200
+ if (link === pointer.to) return { file: pointer.file, state: 'current', link };
201
+ return { file: pointer.file, state: 'mispointed', link, detail: `points at ${link}` };
202
+ }
203
+
204
+ export function surfaceFrontmatter(surfaceId, rule) {
205
+ const s = SURFACES.find((x) => x.id === surfaceId);
206
+ return s && s.ruleFrontmatter ? s.ruleFrontmatter(rule) : [];
207
+ }
208
+
209
+ // The per-surface path lives in surfaces.mjs next to the row that justifies it;
210
+ // re-exported so a caller building a target never has to know which module owns
211
+ // path construction as opposed to frontmatter construction.
212
+ export { rulePath };
213
+
214
+ export function extractFrontmatter(text) {
215
+ const m = /^---\n[\s\S]*?\n---\n?/.exec(text);
216
+ return m ? m[0].trimEnd() : null;
217
+ }
218
+
219
+ function bodyAfterFrontmatter(text) {
220
+ return text.replace(/^---\n[\s\S]*?\n---\n*/, '');
221
+ }