trunative 1.0.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +142 -0
  3. package/dist/cli.js +105 -0
  4. package/dist/commands/build.js +35 -0
  5. package/dist/commands/detect.js +91 -0
  6. package/dist/commands/doctor.js +110 -0
  7. package/dist/commands/graph.js +106 -0
  8. package/dist/commands/install.js +76 -0
  9. package/dist/commands/lint.js +87 -0
  10. package/dist/commands/rubric.js +104 -0
  11. package/dist/commands/spec.js +300 -0
  12. package/dist/compile.js +235 -0
  13. package/dist/detect/rules.js +208 -0
  14. package/dist/detect/types.js +36 -0
  15. package/dist/emit.js +77 -0
  16. package/dist/graph.js +288 -0
  17. package/dist/heuristics.js +185 -0
  18. package/dist/lock.js +18 -0
  19. package/dist/mdx.js +84 -0
  20. package/dist/paths.js +62 -0
  21. package/dist/skill.js +69 -0
  22. package/package.json +51 -0
  23. package/src/skills/SKILL.md +125 -0
  24. package/src/skills/flow/build.md +57 -0
  25. package/src/skills/flow/firebase.md +102 -0
  26. package/src/skills/flow/init.md +116 -0
  27. package/src/skills/flow/review.md +186 -0
  28. package/src/skills/flow/spec.md +149 -0
  29. package/src/skills/heuristics/accessibility.md +124 -0
  30. package/src/skills/heuristics/ads.md +140 -0
  31. package/src/skills/heuristics/auth.md +130 -0
  32. package/src/skills/heuristics/background-work.md +129 -0
  33. package/src/skills/heuristics/buttons.md +99 -0
  34. package/src/skills/heuristics/camera.md +127 -0
  35. package/src/skills/heuristics/chat.md +125 -0
  36. package/src/skills/heuristics/colors.md +129 -0
  37. package/src/skills/heuristics/copy.md +157 -0
  38. package/src/skills/heuristics/data-display.md +124 -0
  39. package/src/skills/heuristics/feedback.md +122 -0
  40. package/src/skills/heuristics/forms.md +124 -0
  41. package/src/skills/heuristics/icons-and-imagery.md +135 -0
  42. package/src/skills/heuristics/layout.md +125 -0
  43. package/src/skills/heuristics/lists.md +129 -0
  44. package/src/skills/heuristics/localization.md +128 -0
  45. package/src/skills/heuristics/maps.md +129 -0
  46. package/src/skills/heuristics/media.md +130 -0
  47. package/src/skills/heuristics/motion.md +113 -0
  48. package/src/skills/heuristics/navigation.md +116 -0
  49. package/src/skills/heuristics/network.md +118 -0
  50. package/src/skills/heuristics/notifications.md +121 -0
  51. package/src/skills/heuristics/offline.md +124 -0
  52. package/src/skills/heuristics/onboarding.md +103 -0
  53. package/src/skills/heuristics/payments.md +138 -0
  54. package/src/skills/heuristics/performance.md +111 -0
  55. package/src/skills/heuristics/permissions.md +125 -0
  56. package/src/skills/heuristics/privacy-ui.md +112 -0
  57. package/src/skills/heuristics/scrolling.md +114 -0
  58. package/src/skills/heuristics/search.md +127 -0
  59. package/src/skills/heuristics/sense.md +128 -0
  60. package/src/skills/heuristics/settings.md +129 -0
  61. package/src/skills/heuristics/sharing.md +102 -0
  62. package/src/skills/heuristics/sound.md +95 -0
  63. package/src/skills/heuristics/splashscreen.md +111 -0
  64. package/src/skills/heuristics/states.md +120 -0
  65. package/src/skills/heuristics/touch.md +95 -0
  66. package/src/skills/heuristics/typography.md +99 -0
  67. package/src/skills/heuristics/updates.md +129 -0
  68. package/src/skills/heuristics/webviews.md +114 -0
  69. package/src/skills/heuristics/widgets.md +128 -0
  70. package/src/skills/references/capability-checks.md +59 -0
  71. package/src/skills/references/fonts.json +339 -0
  72. package/src/skills/references/icon-and-image-assets.md +103 -0
  73. package/src/skills/references/input-fields.md +82 -0
  74. package/src/skills/references/launch-surface.md +94 -0
  75. package/src/skills/references/motion-tokens.md +89 -0
  76. package/src/skills/references/navigation-containers.md +51 -0
  77. package/src/skills/references/search-controls.md +49 -0
  78. package/src/skills/references/type-scales.md +60 -0
  79. package/src/skills/references/wireframe-frame.md +209 -0
package/dist/graph.js ADDED
@@ -0,0 +1,288 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { AGENTS, STACKS } from './compile.js';
4
+ import { parseHeuristic, readIndex } from './heuristics.js';
5
+ import { tokenize, values } from './mdx.js';
6
+ import { packagedSkillDir } from './paths.js';
7
+ const BACKTICKED = /`([a-z0-9][a-z0-9-]*)`/g;
8
+ const REFERENCE = /`(references\/[a-z0-9-]+\.(?:md|json))`/g;
9
+ const PREFIX_ROW = /\|\s*`heuristics\/([a-z0-9-]+)\.md`\s*\|\s*`([a-z0-9-]+)`\s*\|/;
10
+ async function listDir(skillDir, dir) {
11
+ try {
12
+ return (await readdir(join(skillDir, dir)))
13
+ .filter((name) => !name.startsWith('.'))
14
+ .map((name) => `${dir}/${name}`)
15
+ .sort();
16
+ }
17
+ catch {
18
+ return [];
19
+ }
20
+ }
21
+ /** Paths a SKILL.md index names, such as "flow/init.md". */
22
+ function indexed(lines, dir) {
23
+ const found = [];
24
+ const pattern = new RegExp('`(' + dir + '/[a-z0-9-]+\\.(?:md|json))`', 'g');
25
+ for (const line of lines) {
26
+ for (const match of line.matchAll(pattern)) {
27
+ if (!found.includes(match[1])) {
28
+ found.push(match[1]);
29
+ }
30
+ }
31
+ }
32
+ return found;
33
+ }
34
+ /** Declared prefix per heuristics file, read from the base and extra tables. */
35
+ function prefixes(lines) {
36
+ const found = new Map();
37
+ for (const line of lines) {
38
+ const row = PREFIX_ROW.exec(line);
39
+ if (row) {
40
+ found.set(row[1], row[2]);
41
+ }
42
+ }
43
+ return found;
44
+ }
45
+ export async function readGraph(skillDir = packagedSkillDir) {
46
+ const files = {
47
+ flow: await listDir(skillDir, 'flow'),
48
+ heuristics: await listDir(skillDir, 'heuristics'),
49
+ references: await listDir(skillDir, 'references'),
50
+ };
51
+ const baseLines = await readIndex(skillDir, 'base');
52
+ const extraLines = await readIndex(skillDir, 'extra');
53
+ const index = {
54
+ flow: indexed(await readIndex(skillDir, 'flow'), 'flow'),
55
+ base: indexed(baseLines, 'heuristics'),
56
+ extra: indexed(extraLines, 'heuristics'),
57
+ references: indexed(await readIndex(skillDir, 'references'), 'references'),
58
+ };
59
+ const alwaysLines = await readIndex(skillDir, 'always');
60
+ const always = [];
61
+ for (const line of alwaysLines) {
62
+ for (const match of line.matchAll(BACKTICKED)) {
63
+ if (match[1].includes('-') && !always.includes(match[1])) {
64
+ always.push(match[1]);
65
+ }
66
+ }
67
+ }
68
+ const declared = prefixes([...baseLines, ...extraLines]);
69
+ const heuristics = [];
70
+ for (const path of files.heuristics.filter((file) => file.endsWith('.md'))) {
71
+ const source = await readFile(join(skillDir, path), 'utf8');
72
+ heuristics.push(parseHeuristic(basename(path, '.md'), path, source, always));
73
+ }
74
+ const rules = heuristics.flatMap((heuristic) => heuristic.rules);
75
+ const ids = new Set(rules.map((rule) => rule.id));
76
+ /**
77
+ * A backticked token is a citation when it sits in the prefix namespace the
78
+ * index declares. Everything else in backticks is a platform constant, and
79
+ * `email-address` is not a broken link to a rule nobody wrote.
80
+ */
81
+ const namespace = [...declared.values()];
82
+ const cites = (token) => !namespace.includes(token) && namespace.some((prefix) => token.startsWith(prefix));
83
+ const everyFile = ['SKILL.md', ...files.flow, ...files.heuristics, ...files.references];
84
+ const mentions = [];
85
+ const links = [];
86
+ const tags = [];
87
+ for (const path of everyFile.filter((file) => file.endsWith('.md'))) {
88
+ const source = await readFile(join(skillDir, path), 'utf8');
89
+ let section;
90
+ for (const token of tokenize(source)) {
91
+ if (token.type === 'tag') {
92
+ tags.push({ tag: token.tag, file: path });
93
+ if (token.tag.name === 'Rule') {
94
+ section = token.tag.attributes['id'];
95
+ }
96
+ }
97
+ const text = token.type === 'text' ? token.text : token.tag.content;
98
+ const line = token.type === 'text' ? token.line : token.tag.line;
99
+ for (const match of text.matchAll(BACKTICKED)) {
100
+ const id = match[1];
101
+ if (ids.has(id) || cites(id)) {
102
+ mentions.push({ id, file: path, line: line + 1, ...(section ? { from: section } : {}) });
103
+ }
104
+ }
105
+ for (const match of text.matchAll(REFERENCE)) {
106
+ links.push({ target: match[1], file: path, line: line + 1 });
107
+ }
108
+ }
109
+ }
110
+ return {
111
+ heuristics,
112
+ rules,
113
+ mentions,
114
+ links,
115
+ files,
116
+ index,
117
+ always,
118
+ tags,
119
+ findings: check({ heuristics, rules, mentions, links, files, index, always, tags, declared }),
120
+ };
121
+ }
122
+ /** Where each tag is allowed to appear, by directory. */
123
+ const PLACEMENT = {
124
+ Rule: (file) => file.startsWith('heuristics/'),
125
+ Check: (file) => file.startsWith('heuristics/'),
126
+ Verify: (file) => file.startsWith('heuristics/'),
127
+ Device: (file) => file.startsWith('heuristics/'),
128
+ If: (file) => file.startsWith('flow/') || file.startsWith('references/'),
129
+ Ask: (file) => file.startsWith('flow/'),
130
+ Option: (file) => file.startsWith('flow/'),
131
+ Index: (file) => file === 'SKILL.md',
132
+ };
133
+ const ATTRIBUTES = {
134
+ Rule: ['id', 'evidence', 'description'],
135
+ Check: ['against'],
136
+ Verify: ['rule'],
137
+ Device: [],
138
+ If: ['agent', 'stack'],
139
+ Ask: ['header'],
140
+ Option: [],
141
+ Index: ['of'],
142
+ };
143
+ const INDEX_VALUES = ['flow', 'base', 'extra', 'always', 'references'];
144
+ const STACK_VALUES = [...STACKS, 'other', 'undecided'];
145
+ function check(input) {
146
+ const findings = [];
147
+ const add = (kind, detail, file, line) => {
148
+ findings.push({ kind, detail, ...(file ? { file } : {}), ...(line ? { line } : {}) });
149
+ };
150
+ const ids = new Set(input.rules.map((rule) => rule.id));
151
+ for (const rule of input.rules) {
152
+ if (rule.line === 0) {
153
+ add('check without rule', `\`${rule.id}\` is verified but never defined`, `heuristics/${rule.file}.md`, rule.checkLine);
154
+ }
155
+ if (rule.checkLine === 0) {
156
+ add('rule without check', `\`${rule.id}\` has no Verify line`, `heuristics/${rule.file}.md`, rule.line);
157
+ }
158
+ if (!rule.title) {
159
+ add('rule without description', `\`${rule.id}\``, `heuristics/${rule.file}.md`, rule.line);
160
+ }
161
+ const prefix = input.declared.get(rule.file);
162
+ if (prefix && !rule.id.startsWith(prefix)) {
163
+ add('prefix', `\`${rule.id}\` does not start with \`${prefix}\``, `heuristics/${rule.file}.md`, rule.line);
164
+ }
165
+ }
166
+ for (const mention of input.mentions) {
167
+ if (!ids.has(mention.id)) {
168
+ add('dangling id', `\`${mention.id}\` is defined nowhere`, mention.file, mention.line);
169
+ }
170
+ }
171
+ for (const id of input.always) {
172
+ if (!ids.has(id)) {
173
+ add('always in scope', `\`${id}\` is listed in SKILL.md and defined nowhere`, 'SKILL.md');
174
+ }
175
+ }
176
+ const base = input.index.base.map((path) => basename(path, '.md'));
177
+ for (const id of input.always) {
178
+ const owner = input.rules.find((rule) => rule.id === id);
179
+ if (owner && !base.includes(owner.file)) {
180
+ add('always in scope', `\`${id}\` lives in ${owner.file}, which is not a base file`, 'SKILL.md');
181
+ }
182
+ }
183
+ const listed = new Set([...input.index.flow, ...input.index.base, ...input.index.extra, ...input.index.references]);
184
+ for (const group of ['flow', 'heuristics', 'references']) {
185
+ for (const file of input.files[group]) {
186
+ if (!listed.has(file)) {
187
+ add('not indexed', `${file} exists and no index in SKILL.md lists it`, 'SKILL.md');
188
+ }
189
+ }
190
+ for (const file of group === 'heuristics' ? [...input.index.base, ...input.index.extra] : input.index[group]) {
191
+ if (!input.files[group].includes(file)) {
192
+ add('indexed and missing', `SKILL.md lists ${file} and it is not on disk`, 'SKILL.md');
193
+ }
194
+ }
195
+ }
196
+ for (const file of input.index.references) {
197
+ if (!input.links.some((link) => link.target === file && link.file !== 'SKILL.md')) {
198
+ add('unlinked reference', `${file} is listed and nothing points at it`, 'SKILL.md');
199
+ }
200
+ }
201
+ for (const link of input.links) {
202
+ if (!input.files.references.includes(link.target)) {
203
+ add('dangling link', `${link.target} does not exist`, link.file, link.line);
204
+ }
205
+ }
206
+ const open = [];
207
+ for (const { tag, file } of input.tags) {
208
+ const allowed = PLACEMENT[tag.name];
209
+ if (!allowed) {
210
+ add('unknown tag', `<${tag.name}>`, file, tag.line + 1);
211
+ continue;
212
+ }
213
+ if (!allowed(file)) {
214
+ add('tag in the wrong place', `<${tag.name}> in ${file}`, file, tag.line + 1);
215
+ }
216
+ for (const name of Object.keys(tag.attributes)) {
217
+ if (!ATTRIBUTES[tag.name].includes(name)) {
218
+ add('unknown attribute', `${name} on <${tag.name}>`, file, tag.line + 1);
219
+ }
220
+ }
221
+ if (tag.name === 'If' && tag.kind === 'open') {
222
+ const agent = values(tag.attributes['agent']);
223
+ const stack = values(tag.attributes['stack']);
224
+ if (agent.length > 0 && stack.length > 0) {
225
+ add('If', 'carries both agent and stack', file, tag.line + 1);
226
+ }
227
+ if (agent.length === 0 && stack.length === 0) {
228
+ add('If', 'carries neither agent nor stack', file, tag.line + 1);
229
+ }
230
+ for (const value of agent) {
231
+ if (!AGENTS.includes(value)) {
232
+ add('unknown agent', value, file, tag.line + 1);
233
+ }
234
+ }
235
+ for (const value of stack) {
236
+ if (!STACK_VALUES.includes(value)) {
237
+ add('unknown stack', value, file, tag.line + 1);
238
+ }
239
+ }
240
+ }
241
+ if (tag.name === 'Index') {
242
+ const of = tag.attributes['of'] ?? '';
243
+ if (!INDEX_VALUES.includes(of)) {
244
+ add('unknown index', of, file, tag.line + 1);
245
+ }
246
+ }
247
+ if (tag.name === 'Ask' && tag.kind === 'open') {
248
+ const header = tag.attributes['header'] ?? '';
249
+ if (!header) {
250
+ add('Ask', 'has no header', file, tag.line + 1);
251
+ }
252
+ else if (header.length > 12) {
253
+ add('Ask', `header "${header}" is longer than 12 characters`, file, tag.line + 1);
254
+ }
255
+ }
256
+ if (tag.name === 'Rule' && tag.attributes['evidence'] && tag.attributes['evidence'] !== 'device') {
257
+ add('Rule', `evidence="${tag.attributes['evidence']}" is not a value`, file, tag.line + 1);
258
+ }
259
+ if (tag.kind === 'open') {
260
+ open.push({ tag, file });
261
+ }
262
+ else if (tag.kind === 'close') {
263
+ const last = open.pop();
264
+ if (!last || last.tag.name !== tag.name) {
265
+ add('unbalanced tag', `</${tag.name}> closes ${last ? `<${last.tag.name}>` : 'nothing'}`, file, tag.line + 1);
266
+ }
267
+ }
268
+ }
269
+ for (const { tag, file } of open) {
270
+ add('unbalanced tag', `<${tag.name}> is never closed`, file, tag.line + 1);
271
+ }
272
+ for (const of of INDEX_VALUES) {
273
+ const count = input.tags.filter((entry) => entry.tag.name === 'Index' && entry.tag.attributes['of'] === of).length;
274
+ if (count !== 1) {
275
+ add('index marker', `<Index of="${of}" /> appears ${count} times, expected once`, 'SKILL.md');
276
+ }
277
+ }
278
+ const options = input.tags.filter((entry) => entry.tag.name === 'Option' && entry.tag.kind === 'open');
279
+ const recommended = options.filter((entry) => entry.tag.flags.includes('recommended'));
280
+ if (options.length > 0 && recommended.length > 1) {
281
+ add('Ask', `${recommended.length} options are marked recommended`, recommended[1].file);
282
+ }
283
+ return findings;
284
+ }
285
+ /** Every id the skill defines, in file order. Used by the detector registry. */
286
+ export function ruleIds(graph) {
287
+ return graph.rules.map((rule) => rule.id);
288
+ }
@@ -0,0 +1,185 @@
1
+ import { readdir, readFile } from 'node:fs/promises';
2
+ import { basename, join } from 'node:path';
3
+ import { tokenize } from './mdx.js';
4
+ /** A rule heading: `## <Rule id="touch-floor" evidence="device" description="..." />`. */
5
+ const HEADING = /^## <Rule\s+([^>]*?)\s*\/>\s*$/;
6
+ const ATTRIBUTE = /([a-z]+)="([^"]*)"/g;
7
+ const CHECK_LINE = /^- (.*?)\s*`([a-z0-9-]+)`\s*$/;
8
+ function attributes(source) {
9
+ const found = {};
10
+ for (const match of source.matchAll(ATTRIBUTE)) {
11
+ found[match[1]] = match[2];
12
+ }
13
+ return found;
14
+ }
15
+ /**
16
+ * The lines of the list an <Index of="..." /> marker in SKILL.md announces,
17
+ * up to the next heading or the next marker. Empty when the marker is absent.
18
+ */
19
+ export async function readIndex(skillDir, of) {
20
+ let source;
21
+ try {
22
+ source = await readFile(join(skillDir, 'SKILL.md'), 'utf8');
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ const lines = source.split(/\r?\n/);
28
+ const start = lines.findIndex((line) => line.trim() === `<Index of="${of}" />`);
29
+ if (start === -1) {
30
+ return [];
31
+ }
32
+ const found = [];
33
+ for (const line of lines.slice(start + 1)) {
34
+ if (line.startsWith('#') || line.trim().startsWith('<Index ')) {
35
+ break;
36
+ }
37
+ found.push(line);
38
+ }
39
+ return found;
40
+ }
41
+ /** Ids listed under the "always" index in SKILL.md. */
42
+ export async function readAlwaysInScope(skillDir) {
43
+ const ids = [];
44
+ for (const line of await readIndex(skillDir, 'always')) {
45
+ if (!line.startsWith('- ')) {
46
+ continue;
47
+ }
48
+ for (const match of line.matchAll(/`([a-z0-9-]+)`/g)) {
49
+ const id = match[1];
50
+ if (id.includes('-') && !ids.includes(id)) {
51
+ ids.push(id);
52
+ }
53
+ }
54
+ }
55
+ return ids;
56
+ }
57
+ /** File stems listed under one of the file indexes in SKILL.md, such as "base". */
58
+ export async function readIndexFiles(skillDir, of) {
59
+ const stems = [];
60
+ for (const line of await readIndex(skillDir, of)) {
61
+ for (const match of line.matchAll(/`heuristics\/([a-z0-9-]+)\.md`/g)) {
62
+ if (!stems.includes(match[1])) {
63
+ stems.push(match[1]);
64
+ }
65
+ }
66
+ }
67
+ return stems;
68
+ }
69
+ export function parseHeuristic(file, path, source, always) {
70
+ const lines = source.split(/\r?\n/);
71
+ const rules = [];
72
+ const order = new Map();
73
+ const title = lines.find((line) => line.startsWith('# '))?.slice(2).trim() ?? file;
74
+ lines.forEach((line, index) => {
75
+ const heading = HEADING.exec(line);
76
+ if (!heading) {
77
+ return;
78
+ }
79
+ const attrs = attributes(heading[1]);
80
+ const id = attrs['id'];
81
+ if (!id) {
82
+ return;
83
+ }
84
+ const rule = {
85
+ id,
86
+ title: attrs['description'] ?? '',
87
+ check: '',
88
+ file,
89
+ always: always.includes(id),
90
+ line: index + 1,
91
+ checkLine: 0,
92
+ evidence: attrs['evidence'] === 'device' ? 'device' : 'source',
93
+ };
94
+ rules.push(rule);
95
+ order.set(rule.id, rule);
96
+ });
97
+ const device = [];
98
+ for (const token of tokenize(source)) {
99
+ if (token.type !== 'tag' || token.tag.kind !== 'inline') {
100
+ continue;
101
+ }
102
+ if (token.tag.name === 'Device') {
103
+ device.push(token.tag.content);
104
+ continue;
105
+ }
106
+ if (token.tag.name !== 'Verify') {
107
+ continue;
108
+ }
109
+ const id = token.tag.attributes['rule'] ?? '';
110
+ const known = order.get(id);
111
+ if (known) {
112
+ known.check = token.tag.content;
113
+ known.checkLine = token.tag.line + 1;
114
+ continue;
115
+ }
116
+ const rule = {
117
+ id,
118
+ title: '',
119
+ check: token.tag.content,
120
+ file,
121
+ always: always.includes(id),
122
+ line: 0,
123
+ checkLine: token.tag.line + 1,
124
+ evidence: 'source',
125
+ };
126
+ rules.push(rule);
127
+ order.set(id, rule);
128
+ }
129
+ const start = rules.some((rule) => rule.checkLine > 0)
130
+ ? -1
131
+ : lines.findIndex((line) => line.trim() === '## Check');
132
+ if (start !== -1) {
133
+ let seenBullet = false;
134
+ for (let index = start + 1; index < lines.length; index += 1) {
135
+ const line = lines[index];
136
+ if (line.startsWith('## ')) {
137
+ break;
138
+ }
139
+ const check = CHECK_LINE.exec(line);
140
+ if (check) {
141
+ seenBullet = true;
142
+ const id = check[2];
143
+ const known = order.get(id);
144
+ if (known) {
145
+ known.check = check[1];
146
+ known.checkLine = index + 1;
147
+ }
148
+ else {
149
+ const rule = {
150
+ id,
151
+ title: '',
152
+ check: check[1],
153
+ file,
154
+ always: always.includes(id),
155
+ line: 0,
156
+ checkLine: index + 1,
157
+ evidence: 'source',
158
+ };
159
+ rules.push(rule);
160
+ order.set(id, rule);
161
+ }
162
+ continue;
163
+ }
164
+ if (seenBullet && line.trim() !== '') {
165
+ device.push(line.trim());
166
+ }
167
+ }
168
+ }
169
+ return { file, path, title, rules, device: device.join(' ') };
170
+ }
171
+ /** Every heuristics file in the skill, in filename order. */
172
+ export async function readHeuristics(skillDir) {
173
+ const dir = join(skillDir, 'heuristics');
174
+ const names = (await readdir(dir))
175
+ .filter((name) => name.endsWith('.md'))
176
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
177
+ const always = await readAlwaysInScope(skillDir);
178
+ const heuristics = [];
179
+ for (const name of names) {
180
+ const source = await readFile(join(dir, name), 'utf8');
181
+ const file = basename(name, '.md');
182
+ heuristics.push(parseHeuristic(file, `heuristics/${name}`, source, always));
183
+ }
184
+ return heuristics;
185
+ }
package/dist/lock.js ADDED
@@ -0,0 +1,18 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { CONFIG_DIR, LOCK_FILE } from './paths.js';
4
+ export function lockPath(cwd) {
5
+ return join(cwd, CONFIG_DIR, LOCK_FILE);
6
+ }
7
+ export async function readLock(cwd) {
8
+ try {
9
+ return JSON.parse(await readFile(lockPath(cwd), 'utf8'));
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ export async function writeLock(cwd, lock) {
16
+ await mkdir(join(cwd, CONFIG_DIR), { recursive: true });
17
+ await writeFile(lockPath(cwd), `${JSON.stringify(lock, null, 2)}\n`, 'utf8');
18
+ }
package/dist/mdx.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The tag syntax the skill is written in. Every tag sits alone on its line,
3
+ * optionally behind a heading marker, and nothing inside a fenced code block is
4
+ * a tag. This module is the only place that knows the grammar.
5
+ */
6
+ const TAG_LINE = /^(#{1,6}\s+)?<\s*(\/?)\s*([A-Z][A-Za-z]*)\s*([^>]*?)\s*(\/?)>\s*$/;
7
+ /** One line that opens, fills and closes a tag: <Verify rule="x">text</Verify>. */
8
+ const INLINE_LINE = /^(#{1,6}\s+)?<([A-Z][A-Za-z]*)\s*([^>]*?)>(.+)<\/\2>\s*$/;
9
+ const ATTRIBUTE = /([a-zA-Z-]+)(?:="([^"]*)")?/g;
10
+ const FENCE = /^\s*(```|~~~)/;
11
+ function parseAttributes(source) {
12
+ const attributes = {};
13
+ const flags = [];
14
+ for (const match of source.matchAll(ATTRIBUTE)) {
15
+ if (match[2] === undefined) {
16
+ flags.push(match[1]);
17
+ }
18
+ else {
19
+ attributes[match[1]] = match[2];
20
+ }
21
+ }
22
+ return { attributes, flags };
23
+ }
24
+ /** Every line of the file, with the tag lines parsed. Code fences pass as text. */
25
+ export function tokenize(source) {
26
+ const tokens = [];
27
+ let fence = '';
28
+ source.split(/\r?\n/).forEach((text, line) => {
29
+ const opener = FENCE.exec(text);
30
+ if (fence) {
31
+ if (opener && text.trim().startsWith(fence)) {
32
+ fence = '';
33
+ }
34
+ tokens.push({ type: 'text', line, text });
35
+ return;
36
+ }
37
+ if (opener) {
38
+ fence = opener[1];
39
+ tokens.push({ type: 'text', line, text });
40
+ return;
41
+ }
42
+ const inline = INLINE_LINE.exec(text);
43
+ if (inline) {
44
+ tokens.push({
45
+ type: 'tag',
46
+ tag: {
47
+ line,
48
+ name: inline[2],
49
+ kind: 'inline',
50
+ prefix: inline[1] ?? '',
51
+ content: inline[4].trim(),
52
+ ...parseAttributes(inline[3] ?? ''),
53
+ },
54
+ });
55
+ return;
56
+ }
57
+ const match = TAG_LINE.exec(text);
58
+ if (!match) {
59
+ tokens.push({ type: 'text', line, text });
60
+ return;
61
+ }
62
+ const closing = match[2] === '/';
63
+ const self = match[5] === '/';
64
+ tokens.push({
65
+ type: 'tag',
66
+ tag: {
67
+ line,
68
+ name: match[3],
69
+ kind: closing ? 'close' : self ? 'self' : 'open',
70
+ prefix: match[1] ?? '',
71
+ content: '',
72
+ ...parseAttributes(match[4] ?? ''),
73
+ },
74
+ });
75
+ });
76
+ return tokens;
77
+ }
78
+ /** Comma separated attribute values, such as agent="codex, antigravity". */
79
+ export function values(attribute) {
80
+ return (attribute ?? '')
81
+ .split(',')
82
+ .map((value) => value.trim())
83
+ .filter(Boolean);
84
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,62 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ /** Root of the installed trunative package, resolved from this module. */
4
+ export const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
5
+ /**
6
+ * The skill as it is written, with its tags still in it. It sits under "src"
7
+ * because a skill index walks the repository root one level deep and the
8
+ * "skills" directory three: a source tree named "skills" is served as a skill,
9
+ * tags and all. Nested under "src" it is out of both walks.
10
+ */
11
+ export const packagedSkillDir = join(packageRoot, 'src', 'skills');
12
+ /**
13
+ * Where "trunative build" writes the resolved copies. Inside it the layout is
14
+ * the one a project has, so ".claude/skills/trunative-flutter" here is the same
15
+ * path it lands on there.
16
+ */
17
+ export const compiledSkillsDir = join(packageRoot, 'dist', 'agents');
18
+ /**
19
+ * The published copies, in the flat layout a skill index reads. One directory
20
+ * per stack variant, resolved for the "other" agent, because an index hands
21
+ * every harness the same bytes and "other" is the branch that names no harness.
22
+ */
23
+ export const publishedSkillsDir = join(packageRoot, 'skills');
24
+ /** The agent a published copy resolves. See publishedSkillsDir. */
25
+ export const PUBLISHED_AGENT = 'other';
26
+ /**
27
+ * The directory each agent reads in a project. Zed and anything else reading
28
+ * the shared root gets the "other" build, which resolves no harness branch.
29
+ */
30
+ export const AGENT_DIR = {
31
+ claude: '.claude',
32
+ codex: '.codex',
33
+ antigravity: '.antigravity',
34
+ opencode: '.opencode',
35
+ other: '.agents',
36
+ };
37
+ /** "trunative" for the generic copy, "trunative-flutter" for a stack variant. */
38
+ export function variantName(name, stack) {
39
+ return stack ? `${name}-${stack}` : name;
40
+ }
41
+ /** Where trunative keeps its own files inside a consumer project. */
42
+ export const CONFIG_DIR = '.trunative';
43
+ export const LOCK_FILE = 'skill.lock';
44
+ /**
45
+ * Agent roots we install into. Only the ones that already exist in the project
46
+ * are used, so we never create a directory for an agent the user does not have.
47
+ */
48
+ export const AGENT_ROOTS = ['.claude', '.agents', '.antigravity', '.codex', '.opencode'];
49
+ /** Used when the project has no agent directory at all. */
50
+ export const DEFAULT_AGENT_ROOT = '.claude';
51
+ /** The agent whose branches a directory's copy resolves. */
52
+ export function agentFor(root) {
53
+ const found = Object.entries(AGENT_DIR).find(([, dir]) => dir === root);
54
+ return found ? found[0] : 'other';
55
+ }
56
+ /**
57
+ * Both places a brief may live, in priority order. The bare filename is
58
+ * supported so a project can keep the briefs next to its README.
59
+ */
60
+ export function briefCandidates(cwd, file) {
61
+ return [join(cwd, CONFIG_DIR, file), join(cwd, file)];
62
+ }