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,515 @@
1
+ // `simt-dev skills install` - pin a skill into a repo as a local copy.
2
+ //
3
+ // Plan 0002 section 8.3: installed skills are recorded in `simt-dev.json`
4
+ // `localSkills` and are excluded from central-drift accounting. The point is a
5
+ // deliberate pin - a repo-specific procedure the central registry should not
6
+ // carry, or a version pinned ahead of a rollout - not a bulk copy. So there is
7
+ // no `--all`: installing 928 KB of skills into a repo should never be something
8
+ // an agent does by accident.
9
+ //
10
+ // Three things here are not obvious:
11
+ //
12
+ // 1. A skill with `variants[]` must not install its canonical body by
13
+ // default. `slice-commit` exists in two forms; installing the web body into
14
+ // the API repo is precisely the drift the registry exists to prevent. So the
15
+ // repo's signals pick the variant, and an ambiguous or unmatched stack is an
16
+ // error rather than a guess.
17
+ // 2. A flat `<name>.md` skill is wrapped into `<name>/SKILL.md`, because ADR-009
18
+ // requires that layout. Wrapping restructures a file, so it is reported,
19
+ // never done silently.
20
+ // 3. References come with the skill. A skill whose reference files are missing
21
+ // is worse than one that was never installed, because it looks installed.
22
+
23
+ import { cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
24
+ import { existsSync } from 'node:fs';
25
+ import { dirname, join } from 'node:path';
26
+
27
+ import { EXIT, emit, fail, isJson, note, writeMode } from '../output/index.mjs';
28
+ import { loadRegistry, REGISTRY_PATH, measureSkill } from '../graph/load.mjs';
29
+ import { fingerprint } from '../resolve/fingerprint.mjs';
30
+ import { readCapability } from '../hooks/capability.mjs';
31
+ import { MANIFEST, buildManifest, readManifest, writeManifest } from '../projection/manifest.mjs';
32
+ import { provenanceDigest } from '../projection/project.mjs';
33
+ import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
34
+ import { nearMatches } from '../resolve/near.mjs';
35
+ import { resolveWorkspace, WORKSPACE_REMEDY } from '../workspace.mjs';
36
+ import { CLI } from '../name.mjs';
37
+ import { MANIFEST_FILE } from '../name.mjs';
38
+
39
+ /**
40
+ * Where skills live in this repo. The estate already has a convention - a
41
+ * canonical directory plus symlink fan-out - and it is not the same directory in
42
+ * every repo (`.agents/skills` in some, `.cursor/skills` in others). So the
43
+ * existing layout is respected and only invented when there is none.
44
+ */
45
+ export function skillsDir(root, entries) {
46
+ if (entries.includes('.agents')) return '.agents/skills';
47
+ if (entries.includes('.cursor')) return '.cursor/skills';
48
+ if (entries.includes('.claude')) return '.claude/skills';
49
+ return '.agents/skills';
50
+ }
51
+
52
+ export async function installCommand(args, flags) {
53
+ const name = args[0];
54
+ if (!name) {
55
+ return fail(
56
+ 'install needs a skill name',
57
+ EXIT.USAGE,
58
+ `usage: ${CLI} skills install <name> [--variant <id>] [--from <repo/path>]`,
59
+ );
60
+ }
61
+
62
+ const { registry, errors, ok } = await loadRegistry(REGISTRY_PATH, flags);
63
+ if (!ok) return fail(errors[0], EXIT.REGISTRY);
64
+
65
+ const skill = registry.skills.find((s) => s.name === name);
66
+ if (!skill) {
67
+ const near = nearMatches(name, registry.skills.map((s) => s.name));
68
+ return fail(
69
+ `no skill named "${name}"${near.length > 0 ? ` (did you mean ${near.join(', ')}?)` : ''}`,
70
+ EXIT.USAGE,
71
+ `run: ${CLI} skills find <query>`,
72
+ );
73
+ }
74
+
75
+ // A workspace is where the canonical bodies live. It is not required, because the
76
+ // repository being installed into is itself a location for a declared source: a
77
+ // clone of one repo holds that repo's own bodies with no workspace anywhere. See
78
+ // resolve/source.mjs - bytes, not location, decide identity.
79
+ const ws = await resolveWorkspace(flags);
80
+ if (ws.error) return fail(`workspace config: ${ws.error}`, EXIT.CONFIG, 'fix the JSON, or remove the file');
81
+
82
+ const root = flags.root ?? process.cwd();
83
+ const repo = await fingerprint(root);
84
+ const pick = await chooseSource(skill, repo.signals, flags, { root, workspaceRoot: ws.root });
85
+ if (pick.error) return fail(pick.error, EXIT.USAGE, pick.remedy);
86
+
87
+ const found = await locateSource(pick.source, { workspaceRoot: ws.root, root });
88
+ if (!found.path) {
89
+ // Two reasons land here and they need different wording: no workspace at all,
90
+ // or a workspace that should hold this body and does not. An install that
91
+ // cannot read a body has failed, so this is never a degraded mode - the
92
+ // opposite of `skills show`, which stays useful with no workspace.
93
+ const because = ws.root
94
+ ? `the workspace at ${ws.root} should hold it and does not`
95
+ : 'no workspace is configured and this repo does not hold it';
96
+ return fail(
97
+ `${skill.name}: ${label(pick.source)} - ${found.reason} (${because})`,
98
+ EXIT.CONFIG,
99
+ ws.root ? `check the clone at ${ws.root}, or: ${WORKSPACE_REMEDY}` : WORKSPACE_REMEDY,
100
+ );
101
+ }
102
+ const src = found.path;
103
+ let measured;
104
+ try {
105
+ measured = await measureSkill(src);
106
+ } catch (error) {
107
+ return fail(
108
+ `${pick.source.repo}/${pick.source.path}: ${error.code ?? error.message}`,
109
+ EXIT.CONFIG,
110
+ `the registry points at a body that cannot be read; run: ${CLI} graph lint`,
111
+ );
112
+ }
113
+
114
+ const dir = skillsDir(root, await dirEntries(root));
115
+ const dest = join(root, dir, name);
116
+ const inplace = await samePath(src, dest);
117
+ const action = inplace ? 'in-place' : await compare(dest, src, measured.flat, name);
118
+
119
+ const record = {
120
+ name,
121
+ from: `${pick.source.repo}/${pick.source.path}`,
122
+ variant: pick.variant,
123
+ bytes: measured.bytes,
124
+ sha: await fileSha(src),
125
+ };
126
+
127
+ const payload = {
128
+ ok: true,
129
+ command: 'skills install',
130
+ root,
131
+ skill: name,
132
+ variant: pick.variant,
133
+ source: record.from,
134
+ locatedBy: found.how,
135
+ dest: `${dir}/${name}`,
136
+ action,
137
+ bytes: measured.bytes,
138
+ files: measured.files,
139
+ wrapped: measured.flat,
140
+ record,
141
+ note: pick.note ?? null,
142
+ written: false,
143
+ };
144
+
145
+ // `--json` used to return here, before anything was written, which made
146
+ // `--yes --json` a command that reported a plan and changed nothing - the one
147
+ // combination a script writes. The payload is now emitted once, at the end,
148
+ // after the write it describes.
149
+ const report = () => {
150
+ if (isJson(flags)) emit(`${JSON.stringify(payload, null, 2)}\n`, { maxBytes: flags.maxBytes ?? 0 });
151
+ };
152
+ const mode = writeMode(flags);
153
+
154
+ if (!isJson(flags)) {
155
+ emit(
156
+ [
157
+ `install ${name}${pick.variant ? ` (variant ${pick.variant})` : ''}`,
158
+ `repo ${root.split('/').pop()} (${repo.stack.join(', ') || 'undetected'})`,
159
+ `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
160
+ `source ${record.from}${pick.reason ? ` - ${pick.reason}` : ''}`,
161
+ `read ${found.how}${found.how === 'repo' ? ` (${src})` : ''}`,
162
+ `dest ${payload.dest}`,
163
+ `size ${measured.bytes} bytes in ${measured.files} file${measured.files === 1 ? '' : 's'}`,
164
+ `status ${action}`,
165
+ measured.flat ? 'wrapped flat .md installed as <name>/SKILL.md (ADR-009)' : null,
166
+ '',
167
+ 'PROVENANCE',
168
+ ` name ${record.name}`,
169
+ ` from ${record.from}`,
170
+ ` variant ${record.variant ?? '(none - single body)'}`,
171
+ ` sha ${record.sha}`,
172
+ ` bytes ${record.bytes}`,
173
+ ]
174
+ .filter((l) => l !== null)
175
+ .join('\n'),
176
+ { maxBytes: flags.maxBytes ?? 0 },
177
+ );
178
+ }
179
+
180
+ if (pick.note) note(pick.note);
181
+
182
+ if (action === 'in-place') {
183
+ // The repo already holds the canonical source: the symlink fan-out means the
184
+ // "installed copy" and the registry source are the same bytes on disk.
185
+ // `cp` of a directory onto itself fails, and the correct action is nothing.
186
+ note(`note ${payload.dest} is the registry source itself; nothing to install`);
187
+ report();
188
+ return EXIT.OK;
189
+ }
190
+
191
+ if (action === 'unchanged' && !mode.apply) {
192
+ note(`note ${payload.dest} already matches the source; nothing to write`);
193
+ report();
194
+ return EXIT.OK;
195
+ }
196
+
197
+ // The note was printed unconditionally, so a run that was about to write said
198
+ // "nothing written" and then wrote. It belongs on the path that does not write.
199
+ if (!mode.apply) {
200
+ note(`note dry-run nothing written; apply with: ${CLI} skills install ${name} --yes`);
201
+ report();
202
+ return EXIT.OK;
203
+ }
204
+
205
+ await mkdir(dirname(join(dest, 'SKILL.md')), { recursive: true });
206
+ try {
207
+ if (measured.flat) {
208
+ await writeFile(join(dest, 'SKILL.md'), await wrapFlat(src, name), 'utf8');
209
+ } else {
210
+ // References come with the skill: a skill whose reference files are missing
211
+ // is worse than one that was never installed, because it looks installed.
212
+ await cp(src, dest, { recursive: true });
213
+ }
214
+ } catch (error) {
215
+ return fail(
216
+ `${pick.source.repo}/${pick.source.path}: ${error.code ?? error.message}`,
217
+ EXIT.CONFIG,
218
+ `run: ${CLI} graph lint (the registry must match the file on disk)`,
219
+ );
220
+ }
221
+ payload.written = true;
222
+ note(`wrote ${payload.dest}`);
223
+
224
+ const recorded = await recordLocal(root, record, skill.name, repo);
225
+ note(`${recorded.created ? 'created' : 'updated'} ${MANIFEST_FILE} localSkills`);
226
+ if (recorded.created) {
227
+ note(`note this repo had no manifest; ${CLI} init still needs to run to project the rules`);
228
+ }
229
+ note(`note ${name} is now repo-local and is excluded from central-drift accounting`);
230
+ report();
231
+ return EXIT.OK;
232
+ }
233
+
234
+ /** The variant a source carries, identified by its declared bytes. A single-body
235
+ * skill has none, so this returns null rather than inventing an id. */
236
+ function variantForBytes(skill, source) {
237
+ const match = (skill.variants ?? []).find((v) => v.bytes === source.bytes);
238
+ return match ? match.id : null;
239
+ }
240
+
241
+ /** The one declared source carrying a variant's bytes. Lint has already checked
242
+ * that every declared size matches disk, so size identifies the body. */
243
+ function sourceForVariant(skill, variantId) {
244
+ const sources = skill.sources ?? [];
245
+ const v = (skill.variants ?? []).find((x) => x.id === variantId);
246
+ if (!v) return null;
247
+ const match = sources.filter((s) => s.bytes === v.bytes);
248
+ return match.length === 1 ? match[0] : { ambiguous: match.length };
249
+ }
250
+
251
+ const label = (s) => `${s.repo}/${s.path}`;
252
+
253
+ /**
254
+ * Pick the source to install.
255
+ *
256
+ * Precedence, and why each step exists:
257
+ *
258
+ * 1. `--from` - an explicit instruction. It is obeyed even when it disagrees
259
+ * with what the repo holds, because the author is answering a question the
260
+ * tool cannot; the disagreement is reported as a note, not a block.
261
+ * 2. `--variant` - also explicit, same treatment.
262
+ * 3. The repo's signals pick exactly one variant. This is the normal path.
263
+ * 4. **The repo already holds exactly one declared source.** This is the step
264
+ * that makes a single-repo clone work: such a clone's `package.json` usually
265
+ * cannot tell `web` from `api`, but its own 5048 bytes of `slice-commit` are
266
+ * an exact answer. Content beats taxonomy.
267
+ * 5. Refuse. Ambiguity and absence are both reported, never guessed.
268
+ *
269
+ * Step 3 can also *contradict* step 4 - signals say `api`, the bytes on disk are
270
+ * `web`. That is a real disagreement worth surfacing, because one of the two
271
+ * inputs is wrong and the reader needs to know which.
272
+ */
273
+ async function chooseSource(skill, signals, flags, { root = null, workspaceRoot = null } = {}) {
274
+ const sources = skill.sources ?? [];
275
+ const held = await sourceHeldLocally(skill, root, { workspaceRoot });
276
+
277
+ if (held?.ambiguous) {
278
+ return {
279
+ error: `${skill.name}: this repo matches ${held.held.length} declared sources at once (${held.held
280
+ .map((h) => label(h.source))
281
+ .join(', ')})`,
282
+ remedy:
283
+ 'the registry and this repo disagree about what it holds; name the one you mean with --from <repo/path>',
284
+ };
285
+ }
286
+
287
+ const holdNote = held
288
+ ? ` note this repo already holds the ${variantForBytes(skill, held.source) ?? 'single'} body at ${label(held.source)}`
289
+ : null;
290
+
291
+ // --- explicit choices ------------------------------------------------------
292
+ if (flags.from) {
293
+ const found = sources.find((s) => label(s) === flags.from);
294
+ if (!found) {
295
+ return {
296
+ error: `--from ${flags.from} is not a declared source of ${skill.name}`,
297
+ remedy: `declared: ${sources.map(label).join(', ')}`,
298
+ };
299
+ }
300
+ return {
301
+ source: found,
302
+ variant: flags.variant ?? variantForBytes(skill, found),
303
+ reason: 'chosen with --from',
304
+ note: holdNote,
305
+ };
306
+ }
307
+
308
+ const canonical = sources.find((s) => s.role === 'canonical') ?? sources[0];
309
+
310
+ if (!skill.variants?.length) {
311
+ return { source: canonical, variant: null, reason: 'single body', note: holdNote };
312
+ }
313
+
314
+ if (flags.variant) {
315
+ const resolved = sourceForVariant(skill, flags.variant);
316
+ if (!resolved) {
317
+ return {
318
+ error: `${skill.name} has no variant "${flags.variant}"`,
319
+ remedy: `variants: ${skill.variants.map((x) => x.id).join(', ')}`,
320
+ };
321
+ }
322
+ if (resolved.ambiguous !== undefined) {
323
+ return {
324
+ error: `variant ${flags.variant} (${skill.variants.find((x) => x.id === flags.variant).bytes} bytes) does not identify exactly one source (${resolved.ambiguous} matched)`,
325
+ remedy: `choose explicitly: ${CLI} skills install ${skill.name} --from <repo/path>`,
326
+ };
327
+ }
328
+ return { source: resolved, variant: flags.variant, reason: 'chosen with --variant', note: holdNote };
329
+ }
330
+
331
+ // --- the repo's signals ----------------------------------------------------
332
+ const tags = new Set(signals);
333
+ const matched = skill.variants.filter((v) => v.stack.some((t) => tags.has(t)));
334
+
335
+ if (matched.length === 1) {
336
+ const chosen = sourceForVariant(skill, matched[0].id);
337
+ if (!chosen || chosen.ambiguous !== undefined) {
338
+ return {
339
+ error: `variant ${matched[0].id} does not identify exactly one source (${chosen?.ambiguous ?? 0} matched)`,
340
+ remedy: `choose explicitly: ${CLI} skills install ${skill.name} --from <repo/path>`,
341
+ };
342
+ }
343
+ if (held && held.source !== chosen) {
344
+ // The two signals disagree. Installing either one silently would pick a side.
345
+ return {
346
+ error: `${skill.name}: the repo's signals select the ${matched[0].id} variant (${label(
347
+ chosen,
348
+ )}) but this repo already holds the ${variantForBytes(skill, held.source)} variant (${label(
349
+ held.source,
350
+ )})`,
351
+ remedy: `decide which is right: ${CLI} skills install ${skill.name} --variant ${variantForBytes(
352
+ skill,
353
+ held.source,
354
+ )}, or --from ${label(chosen)}`,
355
+ };
356
+ }
357
+ return {
358
+ source: chosen,
359
+ variant: matched[0].id,
360
+ reason: `repo signals select ${matched[0].stack.join('/')}`,
361
+ note: holdNote,
362
+ };
363
+ }
364
+
365
+ if (matched.length > 1) {
366
+ // Several variants claim this repo. The repo's own bytes are the tie-breaker
367
+ // that is not a guess - if it holds one of them, that one is the answer.
368
+ if (held) {
369
+ const heldVariant = variantForBytes(skill, held.source);
370
+ return {
371
+ source: held.source,
372
+ variant: heldVariant,
373
+ reason: `signals are ambiguous (${matched.map((v) => v.id).join(', ')}); the repo's own copy decides`,
374
+ note: holdNote,
375
+ };
376
+ }
377
+ return {
378
+ error: `${skill.name}: variants ${matched.map((v) => v.id).join(', ')} all match this repo`,
379
+ remedy: `choose explicitly: ${CLI} skills install ${skill.name} --variant <id>`,
380
+ };
381
+ }
382
+
383
+ // --- nothing matched: the repo's own contents are the last real signal -----
384
+ if (held) {
385
+ const heldVariant = variantForBytes(skill, held.source);
386
+ return {
387
+ source: held.source,
388
+ variant: heldVariant,
389
+ reason: heldVariant
390
+ ? `no signal matched (${signals.join(', ') || 'none'}), but the repo already holds the ${heldVariant} variant`
391
+ : `no signal matched (${signals.join(', ') || 'none'}), but the repo already holds this body`,
392
+ note: holdNote,
393
+ };
394
+ }
395
+
396
+ // A directory that is the RIGHT shape but the WRONG body is a different failure
397
+ // from having nothing, and saying "none matches this repo" would send the reader
398
+ // looking at their stack tags instead of at the file that is actually there.
399
+ const impostor = sources.find((s) => existsSync(join(root, s.path)));
400
+ if (impostor) {
401
+ return {
402
+ error: `${skill.name}: ${root}/${impostor.path} exists but is not the declared body (${impostor.bytes} bytes declared)`,
403
+ remedy: `run: ${CLI} graph lint --workspace <path> to see what the registry expects, or delete the local copy`,
404
+ };
405
+ }
406
+
407
+ return {
408
+ error: `${skill.name} has ${skill.variants.length} variants and none matches this repo (${signals.join(', ') || 'no signals'})`,
409
+ remedy: `choose explicitly: ${CLI} skills install ${skill.name} --variant <${skill.variants.map((v) => v.id).join('|')}>`,
410
+ };
411
+ }
412
+
413
+ /** `added` | `updated` | `unchanged`, decided by comparing what is on disk.
414
+ * A wrapped flat skill is compared against the WRAPPED form, because that is
415
+ * what install writes - comparing it against the raw source made every rerun
416
+ * report `updated` and re-write a file that was already correct. */
417
+ async function compare(dest, src, flat, name) {
418
+ if (!existsSync(dest)) return 'added';
419
+ if (!existsSync(join(dest, 'SKILL.md'))) return 'updated';
420
+ const installed = await readFile(join(dest, 'SKILL.md'), 'utf8');
421
+ const wanted = flat ? await wrapFlat(src, name) : await readFile(join(src, 'SKILL.md'), 'utf8');
422
+ // Compare normalised so a trailing-newline difference is not reported as a
423
+ // change that requires rewriting a file the author may have since edited.
424
+ return installed.trimEnd() === wanted.trimEnd() ? 'unchanged' : 'updated';
425
+ }
426
+
427
+ /** Realpath comparison: the canonical source can BE the destination, because the
428
+ * estate resolves `.agents/skills` through symlinks into the source repo. */
429
+ async function samePath(a, b) {
430
+ const { realpath } = await import('node:fs/promises');
431
+ try {
432
+ const [ra, rb] = await Promise.all([realpath(a), realpath(b)]);
433
+ return ra === rb;
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+
439
+ async function dirEntries(root) {
440
+ const { readdir } = await import('node:fs/promises');
441
+ try {
442
+ return await readdir(root);
443
+ } catch {
444
+ return [];
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Flat `.md` skills become `<name>/SKILL.md`, and gain the `name:` ADR-009 requires.
450
+ *
451
+ * Exported because the registry no longer holds a flat skill to exercise this
452
+ * through, and an untested rule mechanism is a rule that silently stops
453
+ * working. The path is still live for any repo that adopts with a flat entry.
454
+ */
455
+ export async function wrapFlat(src, name) {
456
+ const text = await readFile(src, 'utf8');
457
+ const fm = /^---\n([\s\S]*?)\n---\n?/.exec(text);
458
+ if (!fm) return `---\nname: ${name}\n---\n\n${text.trim()}\n`;
459
+ const keys = fm[1]
460
+ .split('\n')
461
+ .filter((l) => !/^name:/.test(l))
462
+ .join('\n');
463
+ return `---\nname: ${name}\n${keys}\n---\n\n${text.slice(fm[0].length).trim()}\n`;
464
+ }
465
+
466
+ async function fileSha(path) {
467
+ const { createHash } = await import('node:crypto');
468
+ const entry = (await stat(path)).isDirectory() ? join(path, 'SKILL.md') : path;
469
+ const digest = createHash('sha256');
470
+ digest.update(await readFile(entry));
471
+ return digest.digest('hex').slice(0, 12);
472
+ }
473
+
474
+ /**
475
+ * Record the pin in the manifest. A hand-written string entry is kept as-is
476
+ * rather than normalised away: the author's declaration is not the tool's to
477
+ * rewrite, and the record is additive.
478
+ *
479
+ * When the repo has no manifest at all, one is written in the FULL canonical
480
+ * shape via `buildManifest` rather than a `{localSkills}` stub. A partial manifest
481
+ * would read as authoritative while omitting `gates`, which is the field the hook
482
+ * dispatcher runs from - so a repo whose manifest came from `skills install`
483
+ * would install hooks with nothing to enforce.
484
+ *
485
+ * "Full canonical shape" is not the same as "full set of gates", and the difference is
486
+ * this function's own reasoning rather than a default's. A canonical shape with the
487
+ * default gates in it is a promise the repo cannot keep, and a command that installs one
488
+ * skill is not entitled to make it.
489
+ */
490
+ async function recordLocal(root, record, name, repo) {
491
+ const { manifest, error } = await readManifest(root);
492
+ if (error) return { written: false, reason: error };
493
+
494
+ // A repo with no manifest gets one here, so this function writes a *promise* on that
495
+ // repo's behalf: it decides which gates the repo claims to run. Without a capability
496
+ // probe it wrote `format`, `lint`, `test` and `typecheck` into a repository with no
497
+ // scripts and no formatter, and `check` then reported four UNRUNNABLE gates. The moment
498
+ // hooks route through the manifest - which is the entire point of a gate - every commit
499
+ // in that repo is blocked, over gates nobody declared. ADR-017 exists to prevent exactly
500
+ // that, and a manifest written by a command that installs one skill was the way round it.
501
+ // `init` probes for the same reason; a second writer that does not is a defect waiting
502
+ // for the next command that needs a manifest.
503
+ const base =
504
+ manifest ??
505
+ (await buildManifest({
506
+ root,
507
+ stack: repo.stack,
508
+ sha: await provenanceDigest(),
509
+ capability: await readCapability(root),
510
+ }));
511
+ const existing = Array.isArray(base.localSkills) ? base.localSkills : [];
512
+ const kept = existing.filter((e) => (typeof e === 'string' ? e : e?.name) !== name);
513
+ await writeManifest(root, { ...base, localSkills: [...kept, record] });
514
+ return { written: true, created: manifest === null };
515
+ }