simt-dev 0.1.1 → 0.2.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 (55) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +31 -4
  3. package/docs/decisions/ADR-001-push-requires-approval.md +5 -5
  4. package/docs/decisions/ADR-002-refactor-blast-radius.md +1 -1
  5. package/docs/decisions/ADR-003-test-breadth.md +4 -4
  6. package/docs/decisions/ADR-004-hook-owns-format.md +5 -5
  7. package/docs/decisions/ADR-005-file-length.md +5 -5
  8. package/docs/decisions/ADR-006-coverage-bar.md +3 -3
  9. package/docs/decisions/ADR-007-section-skeleton.md +5 -5
  10. package/docs/decisions/ADR-008-verdict-vocabulary.md +1 -1
  11. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  12. package/docs/decisions/ADR-010-runner-invocation.md +1 -1
  13. package/docs/decisions/ADR-012-drop-windows-support.md +2 -2
  14. package/docs/decisions/ADR-015-a-source-is-content-not-a-location.md +2 -2
  15. package/docs/decisions/ADR-016-a-divergent-rule-is-retired-not-merged.md +13 -13
  16. package/docs/decisions/ADR-017-a-declared-gate-must-be-runnable.md +2 -2
  17. package/docs/decisions/ADR-018-an-unset-hooks-path-is-not-an-absent-hook.md +9 -9
  18. package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +7 -7
  19. package/docs/decisions/ADR-023-the-gate-ships-as-a-file-the-repo-owns.md +2 -2
  20. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +5 -5
  21. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +2 -2
  22. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  23. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +2 -2
  24. package/docs/decisions/ADR-031-the-suite-stages-the-environment-it-needs.md +3 -3
  25. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +4 -4
  26. package/docs/decisions/ADR-041-the-suite-installs-the-package-in-order-to-test-it.md +2 -2
  27. package/docs/decisions/ADR-045-a-dashboard-that-counts-a-defect.md +3 -3
  28. package/docs/decisions/ADR-046-one-rule-one-copy.md +5 -5
  29. package/docs/decisions/ADR-047-a-profile-selects-on-what-a-repository-runs-on.md +4 -4
  30. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +5 -5
  31. package/docs/decisions/ADR-057-an-identity-is-per-developer-and-a-repository-does-not-own-it.md +115 -0
  32. package/docs/decisions/ADR-058-one-tool-three-channels-and-the-order-is-forced.md +148 -0
  33. package/docs/decisions/README.md +3 -1
  34. package/package.json +1 -1
  35. package/src/commands/hooks.mjs +10 -10
  36. package/src/commands/init.mjs +67 -1
  37. package/src/commands/skillsinstall.mjs +1 -1
  38. package/src/compare/discover.mjs +1 -1
  39. package/src/compare/row.mjs +2 -2
  40. package/src/decisions/index.mjs +30 -6
  41. package/src/graph/load.mjs +1 -1
  42. package/src/hooks/capability.mjs +1 -1
  43. package/src/hooks/dispatcher.mjs +1 -1
  44. package/src/hooks/gates.mjs +1 -1
  45. package/src/hooks/where.mjs +1 -1
  46. package/src/identity.mjs +218 -0
  47. package/src/projection/baselines.mjs +1 -1
  48. package/src/projection/conflicts.mjs +1 -1
  49. package/src/projection/manifest.mjs +1 -1
  50. package/src/projection/markers.mjs +3 -3
  51. package/src/projection/mdc.mjs +2 -2
  52. package/src/projection/surfaces.mjs +2 -2
  53. package/src/resolve/fingerprint.mjs +3 -3
  54. package/src/resolve/source.mjs +1 -1
  55. package/src/tsconfig.mjs +1 -1
@@ -78,8 +78,8 @@ export async function buildRow(path, context) {
78
78
  // Adoption is settled BEFORE any problem is collected. Every gate below asks
79
79
  // `row.adoption`, and this block used to run after them - so the stale-rule-file gate
80
80
  // below compared against the initialiser's `'unmanaged'` and could never fire. In the
81
- // estate that made `simt-app`, with 0 of 3 rule files current, report a problems list
82
- // that mentioned neither the rule files nor anything the exit code could see. A gate
81
+ // estate, with 0 of 3 rule files current, report a problems list that mentioned
82
+ // neither the rule files nor anything the exit code could see. A gate
83
83
  // that reads a field the line above has not set yet is not a gate.
84
84
  //
85
85
  // An absent manifest is not a defect; it is the pre-adoption state, which is the normal
@@ -445,8 +445,29 @@ const section = (name) => {
445
445
  *
446
446
  * Not global: callers reuse it with `exec`, and a `/g` flag would remember `lastIndex`.
447
447
  */
448
+ /**
449
+ * Identifiers that belong to the repositories this tool was built against, rather than
450
+ * to anything a reader has.
451
+ *
452
+ * This used to be a list of names, and that was the defect: the guard that exists to
453
+ * keep those names out of shipped text was itself shipped, carrying them. Every
454
+ * adopting repository could read the portfolio out of `src/`.
455
+ *
456
+ * So it matches shapes instead:
457
+ *
458
+ * - `simt-<something>` that is not `simt-dev`, which is most of the estate
459
+ * - `repo-NN`, the pseudonyms the decision records now use in place of a name. These
460
+ * must not leak either: a label that identifies nothing publicly is still a label
461
+ * that joins one record to another, and shipping it into a consumer's repository
462
+ * would carry that join with it.
463
+ *
464
+ * What this no longer catches, stated plainly: three estate repositories whose names
465
+ * do not begin `simt-`, which a shape-based rule cannot recognise without enumerating
466
+ * them - which is what this file used to do. Detection of those now depends on a name
467
+ * never being written into shipped text, rather than on a check.
468
+ */
448
469
  export const ESTATE_NAME =
449
- /\b(?:simt-(?!dev\b)[\w-]+|bunnyland(?:-server)?|wakt-app|app-ui|garmin|(?:the )?Social repos?)\b/;
470
+ /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/;
450
471
 
451
472
  /** Replace estate repository names with a phrase a reader who does not have them can use. */
452
473
  export function redactEstate(text) {
@@ -477,12 +498,15 @@ export function redactCommand(text) {
477
498
  }
478
499
 
479
500
  /**
480
- * Repositories the records were written about. Kept on the summary so a maintainer can
481
- * see which decisions cite which repos. `rules why` redacts them before printing:
482
- * the names are not actionable in a repository that does not contain those files.
501
+ * Repositories the records were written about, so a maintainer can see which decisions
502
+ * cite which. `rules why` redacts them before printing: the names are not actionable in
503
+ * a repository that does not contain those files.
504
+ *
505
+ * Same shape rule as {@link ESTATE_NAME} and for the same reason - this shipped too, so
506
+ * enumerating the names here put the portfolio in every adopting repository. The records
507
+ * themselves now carry `repo-NN` labels, so that is what this matches in practice.
483
508
  */
484
- const ESTATE =
485
- /\b(?:app-ui|bunnyland(?:-server)?|wakt-app|simt-[\w-]+|(?:the )?(?:Social|garmin) repos?)\b/gi;
509
+ const ESTATE = /\b(?:simt-(?!dev\b)[\w-]+|repo-\d{2})\b/gi;
486
510
 
487
511
  /** Trim to whole sentences within a budget, never leaving a stub fragment. */
488
512
  function sentence(text, limit = 260) {
@@ -478,7 +478,7 @@ async function lintSourcesOnDisk(registry, workspaceRoot) {
478
478
  /**
479
479
  * SKILL.md plus every file beneath the skill directory.
480
480
  * Handles both layouts: a directory containing SKILL.md, and the flat
481
- * `skills/<name>.md` form bunnyland-server still uses (ADR-009 says that will be
481
+ * `skills/<name>.md` form one repository still uses (ADR-009 says that will be
482
482
  * wrapped, so lint reports it rather than accepting it silently).
483
483
  */
484
484
  export async function measureSkill(full) {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // This exists because the first real adoption declared five gates and could run two.
4
4
  // The stack table proposes a formatter, a linter and an i18n check for any Node repo;
5
- // simt-social-media has no prettier installed, no `lint` script and no declared command
5
+ // a repository with no prettier installed, no `lint` script and no declared command
6
6
  // for the i18n check. Every one of those gates fails closed by design, so installing the
7
7
  // dispatcher on that manifest blocks every commit and teaches the team to reach for
8
8
  // `--no-verify`. The dispatcher was right; the declaration was wrong.
@@ -3,7 +3,7 @@
3
3
  // Design commitments, all of them load-bearing:
4
4
  //
5
5
  // Fail closed, never fail open. A gate that cannot run is a failure, not a
6
- // skip. simt-space removed tests from pre-commit because they were slow, and
6
+ // skip. A repository removed tests from pre-commit because they were slow, and
7
7
  // the documented consequence was that authors reached for --no-verify. A
8
8
  // dispatcher that skips what it cannot do reproduces exactly that.
9
9
  //
@@ -100,7 +100,7 @@ export function getGate(name) {
100
100
  * This is the load-bearing decision of the whole dispatcher. A gate that
101
101
  * silently does nothing is indistinguishable from a gate that passed, and the
102
102
  * documented consequence in this estate is an agent reaching for
103
- * `--no-verify` - simt-space removed its tests from pre-commit precisely
103
+ * `--no-verify` - a repository removed its tests from pre-commit precisely
104
104
  * because they were slow, and slow-plus-silent is what teaches everyone that
105
105
  * hooks are advisory.
106
106
  */
@@ -55,7 +55,7 @@ export async function resolveHooksDir(root) {
55
55
  }
56
56
 
57
57
  // Rule 1: core.hooksPath wins. A relative value resolves against the top level
58
- // of the working tree, which is what makes simt-app's `hooks` work.
58
+ // of the working tree, which is what makes a `hooks` directory work.
59
59
  if (configured !== null) {
60
60
  return {
61
61
  dir: isAbsolute(configured) ? configured : resolve(top, configured),
@@ -0,0 +1,218 @@
1
+ // The per-developer git identity, as a file the repository does not track.
2
+ //
3
+ // A repository has more than one person in it, and each of them may already have an
4
+ // identity this tool has no business choosing. So the rules here are conservative in
5
+ // that order:
6
+ //
7
+ // 1. If `git.config` already exists, it is left exactly as it is. Not updated, not
8
+ // merged, not reformatted. It may have been written by a person, by an older
9
+ // version of this tool, or by hand years ago, and none of those are this tool's
10
+ // to second-guess.
11
+ // 2. Otherwise the identity comes from the repository's own last commit author.
12
+ // That is the person who has been working here, which is the best available
13
+ // answer to "who commits here" and requires no guessing: it is a fact, not a
14
+ // default.
15
+ // 3. Otherwise from the git identity configured for this machine or this user,
16
+ // which is what would have been used anyway had this file never existed.
17
+ // 4. Otherwise nothing is written. An identity nobody configured is not one to
18
+ // invent, and a file with a placeholder in it is worse than no file: git would
19
+ // use the placeholder.
20
+ //
21
+ // The file is deliberately NOT written into the repository's tracked files. It holds
22
+ // whoever is committing, and committing it publishes one developer's name and email
23
+ // to everybody else in the repository. `init` adds it to .gitignore when it creates
24
+ // one; a repository that already tracks it is left that way, because removing a
25
+ // tracked file is not something an adopting command should do silently.
26
+
27
+ import { execFile } from 'node:child_process';
28
+ import { readFile, stat } from 'node:fs/promises';
29
+ import { join } from 'node:path';
30
+ import { promisify } from 'node:util';
31
+
32
+ const exec = promisify(execFile);
33
+
34
+ /** The file, at the repository root. Named for what it is: a git config fragment. */
35
+ export const IDENTITY_FILE = 'git.config';
36
+
37
+ /** The git config key that pulls the file in. */
38
+ export const INCLUDE_KEY = 'include.path';
39
+
40
+ /**
41
+ * Run a git command and return trimmed stdout, or null if it fails.
42
+ *
43
+ * Null rather than an exception for every case: a repository that is not a git
44
+ * repository, a version of git that does not know a flag, a machine with no
45
+ * configured identity. None of those is an error worth refusing an adoption over.
46
+ *
47
+ * @param {string} cwd
48
+ * @param {string[]} args
49
+ * @returns {Promise<string|null>}
50
+ */
51
+ async function git(cwd, args) {
52
+ try {
53
+ const { stdout } = await exec('git', args, { cwd });
54
+ return stdout.trim();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Who last committed here.
62
+ *
63
+ * `%an` and `%ae` rather than the configured identity on purpose: this is what
64
+ * actually happened, which is the answer to the question being asked. Reading
65
+ * `git config user.email` here would return the value this file is about to be
66
+ * derived from, and the step would be circular.
67
+ *
68
+ * @param {string} root
69
+ * @returns {Promise<{name: string|null, email: string|null}>}
70
+ */
71
+ export async function lastCommitAuthor(root) {
72
+ const line = await git(root, ['log', '-1', '--format=%an <%ae>']);
73
+ if (!line) return { name: null, email: null };
74
+ // The name may itself contain angle brackets, so the address is split off the
75
+ // LAST one rather than the first.
76
+ const at = line.lastIndexOf('<');
77
+ const close = line.lastIndexOf('>');
78
+ if (at === -1 || close < at) return { name: line || null, email: null };
79
+ return {
80
+ name: line.slice(0, at).trim() || null,
81
+ email: line.slice(at + 1, close).trim() || null,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * The identity this machine would use anyway.
87
+ *
88
+ * `--get` rather than `--global`: it resolves through system, global and local in
89
+ * git's own order, so what it returns is what a commit would have used had this file
90
+ * never been created. That is the fallback's whole point - it must not be a second
91
+ * opinion about what identity means.
92
+ *
93
+ * @param {string} root
94
+ * @returns {Promise<{name: string|null, email: string|null}>}
95
+ */
96
+ export async function configuredIdentity(root) {
97
+ const read = async (key) => {
98
+ const value = await git(root, ['config', '--get', key]);
99
+ return value && value !== '' ? value : null;
100
+ };
101
+ return { name: await read('user.name'), email: await read('user.email') };
102
+ }
103
+
104
+ /**
105
+ * The identity to write, and where it came from.
106
+ *
107
+ * @param {string} root
108
+ * @returns {Promise<{name: string|null, email: string|null, from: 'last-commit'|'configured'|null,
109
+ * exists: boolean, bytes: number}>}
110
+ */
111
+ export async function resolveIdentity(root) {
112
+ const path = join(root, IDENTITY_FILE);
113
+ try {
114
+ const info = await stat(path);
115
+ if (info.isFile()) {
116
+ return { name: null, email: null, from: null, exists: true, bytes: info.size };
117
+ }
118
+ } catch {
119
+ // Absent is the case this whole module exists for.
120
+ }
121
+
122
+ const committed = await lastCommitAuthor(root);
123
+ if (committed.name && committed.email) {
124
+ return { ...committed, from: 'last-commit', exists: false, bytes: 0 };
125
+ }
126
+ const configured = await configuredIdentity(root);
127
+ if (configured.name || configured.email) {
128
+ return { ...configured, from: 'configured', exists: false, bytes: 0 };
129
+ }
130
+ return { name: null, email: null, from: null, exists: false, bytes: 0 };
131
+ }
132
+
133
+ /**
134
+ * The file's bytes.
135
+ *
136
+ * Only the keys that were found are written. A partial identity is written as a
137
+ * partial identity rather than padded with a placeholder, because a placeholder is
138
+ * an identity git would use.
139
+ *
140
+ * @param {{name: string|null, email: string|null}} identity
141
+ * @returns {string} LF-terminated
142
+ */
143
+ export function renderIdentityFile({ name, email }) {
144
+ const lines = ['[user]'];
145
+ if (name) lines.push(`\tname = ${name}`);
146
+ if (email) lines.push(`\temail = ${email}`);
147
+ return `${lines.join('\n')}\n`;
148
+ }
149
+
150
+ /**
151
+ * Whether `include.path` already names this repository's own file.
152
+ *
153
+ * @param {string} root
154
+ * @returns {Promise<boolean>}
155
+ */
156
+ export async function includesOwnIdentity(root) {
157
+ const values = (await git(root, ['config', '--local', '--get-all', INCLUDE_KEY])) ?? '';
158
+ return values.split('\n').map((v) => v.trim()).filter(Boolean).includes(`../${IDENTITY_FILE}`);
159
+ }
160
+
161
+ /**
162
+ * Point `core.hooksPath`-style local config at this repository's own identity file.
163
+ *
164
+ * `--replace-all` rather than `--add`, and that is the whole point: a value naming
165
+ * ANOTHER repository's identity file is not an addition to keep, it is a link to
166
+ * break. Two of the repositories here had exactly that, and both worked only because
167
+ * the sibling checkout happened to exist at the expected relative path.
168
+ *
169
+ * @param {string} root
170
+ * @returns {Promise<void>}
171
+ */
172
+ export async function includeOwnIdentity(root) {
173
+ await git(root, ['config', '--local', '--replace-all', INCLUDE_KEY, `../${IDENTITY_FILE}`]);
174
+ }
175
+
176
+ /**
177
+ * Whether the repository tracks the identity file, which it should not.
178
+ *
179
+ * @param {string} root
180
+ * @returns {Promise<boolean>}
181
+ */
182
+ export async function tracksIdentity(root) {
183
+ const listed = await git(root, ['ls-files', '--error-unmatch', IDENTITY_FILE]);
184
+ return listed === IDENTITY_FILE;
185
+ }
186
+
187
+ /**
188
+ * Add the identity file to .gitignore, if it is not already there.
189
+ *
190
+ * Matching on the exact line rather than a substring, because a `.gitignore`
191
+ * entry of `/git.config` or `git.config` both mean it while a comment mentioning
192
+ * it means nothing, and appending a duplicate of an entry that is already present
193
+ * is how a file ends up listed twice.
194
+ *
195
+ * @param {string} root
196
+ * @returns {Promise<boolean>} whether the file was changed
197
+ */
198
+ export async function ignoreIdentity(root) {
199
+ const path = join(root, '.gitignore');
200
+ let text;
201
+ try {
202
+ text = await readFile(path, 'utf8');
203
+ } catch {
204
+ text = '';
205
+ }
206
+ const already = text.split('\n').map((l) => l.trim()).includes(IDENTITY_FILE);
207
+ if (already) return false;
208
+ const prefix = text === '' || text.endsWith('\n') ? '' : '\n';
209
+ const block =
210
+ `${prefix}\n` +
211
+ '# Per-developer git identity, written by `simt init`. Not a file to share:\n' +
212
+ '# it carries whoever is committing into the tree, and a repository has more than\n' +
213
+ '# one of those.\n' +
214
+ `${IDENTITY_FILE}\n`;
215
+ const { writeFile } = await import('node:fs/promises');
216
+ await writeFile(path, text + block);
217
+ return true;
218
+ }
@@ -93,7 +93,7 @@ indent_style = tab`;
93
93
  * every path as text and is the single most common way to corrupt a binary in a
94
94
  * repository, because nothing tells git it is wrong. `auto` makes git decide per path
95
95
  * from its own content sniffing, so a `.tif`, a `.zip` and a `.mc` file are left exactly
96
- * as they are and only files that genuinely are text get normalised. simt-garmin
96
+ * as they are and only files that genuinely are text get normalised. One
97
97
  * already worked this out on its own, which is the strongest evidence available that
98
98
  * the conservative form is the one the estate would have picked.
99
99
  *
@@ -6,7 +6,7 @@
6
6
  // the same rule restated in prose outside a generated region - prohibited by the
7
7
  // anti-duplication doctrine, but not a contradiction.
8
8
  //
9
- // The distinction is not academic. bunnyland's AGENTS.md says "Do not use `uv run
9
+ // The distinction is not academic. One AGENTS.md says "Do not use `uv run
10
10
  // pytest`" - which contains the forbidden string `uv run pytest` verbatim. A naive
11
11
  // substring scan reports that file as violating the very rule it is correctly
12
12
  // restating. So every match is checked for prohibition context first.
@@ -397,7 +397,7 @@ function defaultId(root) {
397
397
  * 1. A STALE GENERATED FIELD. `buildManifest` emits `$comment` and
398
398
  * `projection.simtDevSha`; the repo owns everything else. `upgrade` spreads the
399
399
  * existing manifest and refreshes only the SHA, so a field added to the writer
400
- * after a repo was adopted never appears — simt-space had been projecting for
400
+ * after a repo was adopted never appears — one repository had been projecting for
401
401
  * several versions with no `surfaces` key, and nothing said so. Every parse-based
402
402
  * comparison passes, because the file is valid and the values in it are all true.
403
403
  *
@@ -217,7 +217,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
217
217
  );
218
218
  const trimmed = body.replace(/\n+$/, '');
219
219
  // Two lines here exist only because a real repo proved they are needed. A
220
- // formatter that runs on commit - simt-space has one, and three of the estate's
220
+ // formatter that runs on commit - several repositories have one, and three of the
221
221
  // other repos do too - rewrote the region on the very first commit: it inserted a
222
222
  // blank line after the header comment and added the trailing newline. Both edits
223
223
  // are things prettier considers correct, and either one alone makes `check`
@@ -226,7 +226,7 @@ export function compose(body, { sha, sources = [] } = {}, style) {
226
226
  // projection emits the formatter's shape in the first place. The blank line before
227
227
  // END is the same class: prettier treats an HTML comment after a paragraph as a
228
228
  // new block and inserts the separating blank line itself, which made
229
- // simt-blog's `format:check` fail on a file whose generated region was correct.
229
+ // a `format:check` fail on a file whose generated region was correct.
230
230
  const out =
231
231
  trimmed === ''
232
232
  ? [s.begin, ...header, s.end]
@@ -293,7 +293,7 @@ export function inject(text, body, meta, opts = {}) {
293
293
  // terminates the region with one; adding a second left every adopted file
294
294
  // ending in a blank line. Nothing in this tool noticed, because `check`
295
295
  // compares region bodies and the byte after END is not part of the region —
296
- // but bunnyland-server's own `git diff --check` gate did, and it is a gate
296
+ // but a repository's own `git diff --check` gate did, and it is a gate
297
297
  // that repo runs before every commit. The `updated` branch below has always
298
298
  // been correct for exactly this reason.
299
299
  //
@@ -201,7 +201,7 @@ function stripFrontmatter(text) {
201
201
  *
202
202
  * An empty-string default is a deliberate "this repo has nothing to add here"
203
203
  * and the line it sits on is DROPPED. That is how `preview-postflight` reaches
204
- * `simt-social-media` without printing an empty bullet, and how a repo with no
204
+ * a repository without printing an empty bullet, and how a repo with no
205
205
  * production-domain note gets a clean document instead of a blank one.
206
206
  *
207
207
  * A placeholder with neither a declared nor a default value is a defect: the
@@ -218,7 +218,7 @@ export function renderMdc(rule, declared = {}) {
218
218
 
219
219
  if (has && provided === '') {
220
220
  // A repo declared this key and declared it empty: the line it sits on is
221
- // removed. This is how preview-postflight reaches simt-social-media without
221
+ // removed. This is how preview-postflight reaches a repository without
222
222
  // printing a blank bullet.
223
223
  body = dropOrReplace(body, sub.key, '');
224
224
  continue;
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // 1. A POINTER IS NOT A COPY. `CLAUDE.md` and `GEMINI.md` are symlinks to
19
19
  // `AGENTS.md`. A copy is the corpus's one true content duplicate
20
- // (bunnyland-server's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
20
+ // (one repository's `CLAUDE.md` is byte-identical to its `AGENTS.md`), and
21
21
  // the DeepSeek harness explicitly rejects loading both in one directory
22
22
  // because "repositories in transition commonly duplicate guidance" — and
23
23
  // *its* dedup is content-based, so a symlink collapses to one while a
@@ -32,7 +32,7 @@
32
32
  // mode bit. So those get machine-written copies — and because every byte is
33
33
  // derived from `rules/mdc/*.mdc` and `simt-dev check` compares each against
34
34
  // that same derivation, they are not duplication, they are a build output.
35
- // The difference from bunnyland's duplicate is that nobody can hand-edit one
35
+ // The difference from that duplicate is that nobody can hand-edit one
36
36
  // and have it survive a sync.
37
37
  //
38
38
  // 3. AN ALWAYS-ON RULE EMITS NO SCOPING FRONTMATTER. Claude Code's `paths:` key
@@ -41,7 +41,7 @@ const MARKERS = [
41
41
  /**
42
42
  * Extensions are weaker evidence than marker files: a stray script must not
43
43
  * define a repo's stack. `python` is deliberately absent here because
44
- * simt-app carries .py tooling scripts inside a Gradle project - a marker file
44
+ * a repository carries .py tooling scripts inside a Gradle project - a marker file
45
45
  * is required for that tag. `kotlin` has no marker file, so it stays here.
46
46
  */
47
47
  const EXTENSION_TAGS = new Map([
@@ -83,7 +83,7 @@ const DEP_TAGS = new Map([
83
83
  *
84
84
  * Three, and the reason is a convention rather than a preference: the Android Gradle
85
85
  * Plugin puts a module's manifest at `<module>/src/main/AndroidManifest.xml`, and
86
- * simt-app's sits at `app/src/main/AndroidManifest.xml`. A bound shallower than the
86
+ * one common layout sits at `app/src/main/AndroidManifest.xml`. A bound shallower than the
87
87
  * convention makes `has-android-manifest` the shape S07 found - a fact no repository
88
88
  * could ever have. Deeper would be a scan rather than a probe, and ADR-013's promise
89
89
  * is that adding an ecosystem is data, so a fact must not cost a search.
@@ -300,7 +300,7 @@ export async function fingerprint(root) {
300
300
  }
301
301
  }
302
302
 
303
- // simt-app and wakt-app both carry a wear/ module; wakt-app is the wearable.
303
+ // two repositories both carry a wear/ module; only one of them is the wearable.
304
304
  if (names.has('wear') && (await isDir(join(root, 'wear')))) {
305
305
  stack.add('wear-os');
306
306
  evidence.push('wear/');
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // The second location is the one nobody wrote down: **the repository being operated
10
10
  // on is itself a location for a declared source**. Someone who cloned only
11
- // `simt-social` has the `simt-social` body on disk at `.agents/skills/slice-commit`
11
+ // one repository has its body on disk at `.agents/skills/slice-commit`
12
12
  // with no workspace anywhere. The registry entry is satisfied by that file.
13
13
  //
14
14
  // Bytes are what make this safe rather than hopeful. Every source declares its size,
package/src/tsconfig.mjs CHANGED
@@ -32,7 +32,7 @@ export const TSCONFIG_FILE = 'tsconfig.json';
32
32
  * The strictness floor.
33
33
  *
34
34
  * Every key here is already set, and set the same way, by every `tsconfig.json` in the
35
- * estate that could afford it. `simt-goto` is the outlier and it is not one on purpose: it
35
+ * estate that could afford it. One repository is the outlier and it is not one on purpose: it
36
36
  * is missing the seven flags below that the three social repos share, which is the gap
37
37
  * plan section 3.1 row 5 named. Nothing in this object is a value anybody has argued about;
38
38
  * it is the intersection of what four repositories independently wrote.