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.
- package/LICENSE +21 -0
- package/README.md +142 -0
- package/dist/cli.js +105 -0
- package/dist/commands/build.js +35 -0
- package/dist/commands/detect.js +91 -0
- package/dist/commands/doctor.js +110 -0
- package/dist/commands/graph.js +106 -0
- package/dist/commands/install.js +76 -0
- package/dist/commands/lint.js +87 -0
- package/dist/commands/rubric.js +104 -0
- package/dist/commands/spec.js +300 -0
- package/dist/compile.js +235 -0
- package/dist/detect/rules.js +208 -0
- package/dist/detect/types.js +36 -0
- package/dist/emit.js +77 -0
- package/dist/graph.js +288 -0
- package/dist/heuristics.js +185 -0
- package/dist/lock.js +18 -0
- package/dist/mdx.js +84 -0
- package/dist/paths.js +62 -0
- package/dist/skill.js +69 -0
- package/package.json +51 -0
- package/src/skills/SKILL.md +125 -0
- package/src/skills/flow/build.md +57 -0
- package/src/skills/flow/firebase.md +102 -0
- package/src/skills/flow/init.md +116 -0
- package/src/skills/flow/review.md +186 -0
- package/src/skills/flow/spec.md +149 -0
- package/src/skills/heuristics/accessibility.md +124 -0
- package/src/skills/heuristics/ads.md +140 -0
- package/src/skills/heuristics/auth.md +130 -0
- package/src/skills/heuristics/background-work.md +129 -0
- package/src/skills/heuristics/buttons.md +99 -0
- package/src/skills/heuristics/camera.md +127 -0
- package/src/skills/heuristics/chat.md +125 -0
- package/src/skills/heuristics/colors.md +129 -0
- package/src/skills/heuristics/copy.md +157 -0
- package/src/skills/heuristics/data-display.md +124 -0
- package/src/skills/heuristics/feedback.md +122 -0
- package/src/skills/heuristics/forms.md +124 -0
- package/src/skills/heuristics/icons-and-imagery.md +135 -0
- package/src/skills/heuristics/layout.md +125 -0
- package/src/skills/heuristics/lists.md +129 -0
- package/src/skills/heuristics/localization.md +128 -0
- package/src/skills/heuristics/maps.md +129 -0
- package/src/skills/heuristics/media.md +130 -0
- package/src/skills/heuristics/motion.md +113 -0
- package/src/skills/heuristics/navigation.md +116 -0
- package/src/skills/heuristics/network.md +118 -0
- package/src/skills/heuristics/notifications.md +121 -0
- package/src/skills/heuristics/offline.md +124 -0
- package/src/skills/heuristics/onboarding.md +103 -0
- package/src/skills/heuristics/payments.md +138 -0
- package/src/skills/heuristics/performance.md +111 -0
- package/src/skills/heuristics/permissions.md +125 -0
- package/src/skills/heuristics/privacy-ui.md +112 -0
- package/src/skills/heuristics/scrolling.md +114 -0
- package/src/skills/heuristics/search.md +127 -0
- package/src/skills/heuristics/sense.md +128 -0
- package/src/skills/heuristics/settings.md +129 -0
- package/src/skills/heuristics/sharing.md +102 -0
- package/src/skills/heuristics/sound.md +95 -0
- package/src/skills/heuristics/splashscreen.md +111 -0
- package/src/skills/heuristics/states.md +120 -0
- package/src/skills/heuristics/touch.md +95 -0
- package/src/skills/heuristics/typography.md +99 -0
- package/src/skills/heuristics/updates.md +129 -0
- package/src/skills/heuristics/webviews.md +114 -0
- package/src/skills/heuristics/widgets.md +128 -0
- package/src/skills/references/capability-checks.md +59 -0
- package/src/skills/references/fonts.json +339 -0
- package/src/skills/references/icon-and-image-assets.md +103 -0
- package/src/skills/references/input-fields.md +82 -0
- package/src/skills/references/launch-surface.md +94 -0
- package/src/skills/references/motion-tokens.md +89 -0
- package/src/skills/references/navigation-containers.md +51 -0
- package/src/skills/references/search-controls.md +49 -0
- package/src/skills/references/type-scales.md +60 -0
- package/src/skills/references/wireframe-frame.md +209 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tiago Danin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Trunative
|
|
2
|
+
|
|
3
|
+
A mobile-only design skill for AI coding agents.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
Trunative is a [skill](https://www.skills.sh/docs) that makes coding agents treat mobile as its own platform rather than a narrow browser window. A phone is not a small desktop: input is imprecise and one-handed, the session gets interrupted by calls and notifications, the network drops mid-request, the OS owns gestures and insets and permissions, text scales to whatever the user set, the battery and the thermal budget are finite, and the whole thing is often used outdoors while walking. Those constraints, not the viewport width, are what a mobile design has to answer for.
|
|
8
|
+
|
|
9
|
+
It is stack-agnostic: the rules apply to React Native, Expo, Flutter, SwiftUI, Jetpack Compose, or mobile web, and the agent detects the framework and translates.
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Coding agents are trained mostly on desktop web. Left alone, they produce mobile screens that are technically correct and physically unusable:
|
|
14
|
+
|
|
15
|
+
- A tap target that measures 32 points because that is what the icon measures, and misses under a thumb that is not looking at it (`touch-floor`).
|
|
16
|
+
- The primary action parked at the top of the screen, in the part of it a hand cannot reach without regripping the phone (`touch-reach`).
|
|
17
|
+
- Insets taken from a constant instead of from the framework, so the last row of the list sits under the home indicator on the one device nobody opened (`layout-insets`).
|
|
18
|
+
- One design for the case where the data arrived: no shape to load into, no sentence for the empty list, and a failure that does not say what failed (`state-set`).
|
|
19
|
+
- A layout that holds at the default text size and falls apart two steps up the accessibility scale, which is where a lot of people already are (`type-scaling`).
|
|
20
|
+
- A dark theme that is the light one with its colours inverted, so what was a shadow is now a glow (`color-dark-composed`).
|
|
21
|
+
- Seed data chosen to flatter the layout: three short names, no zero, no null, no long string, no empty list (`copy-sample-data`).
|
|
22
|
+
- A hero image filling a third of the screen and depicting nothing anybody chose (`icon-depicts`).
|
|
23
|
+
|
|
24
|
+
None of this shows up in a code review or a passing test suite. It shows up when someone holds the phone.
|
|
25
|
+
|
|
26
|
+
Trunative encodes the constraints that mobile actually imposes, such as reach, touch precision, interruption, latency, sunlight and one hand, as rules the agent applies before writing the first component, and as a checklist it verifies against afterwards. Desktop is explicitly out of scope: there is no responsive breakpoint to hide behind, so no decision gets deferred to "it'll be fine on a big screen".
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
npx trunative install
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This copies the skill into every agent directory the project already has (`.claude`, `.agents`, `.antigravity`, `.codex`, `.opencode`), and writes `.trunative/skill.lock` so the agent can tell when its copy is out of date.
|
|
35
|
+
|
|
36
|
+
Then, before the agent starts:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx trunative doctor
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
It checks that the project has its three briefs, in `.trunative/` or at the root, and that the installed skill matches the current version:
|
|
43
|
+
|
|
44
|
+
- `PRODUCT.md`, who uses the app and what it is for.
|
|
45
|
+
- `DESIGN.md`, the visual identity, in the [design.md format](https://github.com/google-labs-code/design.md) from Google Labs.
|
|
46
|
+
- `STACK.md`, the codebase itself: UI primitives, navigation, components, accepted exceptions.
|
|
47
|
+
|
|
48
|
+
The doctor reports and exits 1, it never writes the briefs. Writing them is the agent's job in the init step.
|
|
49
|
+
|
|
50
|
+
A fourth brief is per screen instead of per project. The agent writes one to `.trunative/screens/<name>.md` whenever a task changes hierarchy, actions, states or navigation, and this checks it:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx trunative spec
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The agent also has a generator for the review checklist:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
npx trunative rubric --only touch --only forms
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
It prints one row per rule id, with that rule's own checklist line as the criterion, so the checklist comes out of the heuristics instead of being written by hand. A rule added today shows up in the next review with no second edit.
|
|
63
|
+
|
|
64
|
+
The skills.sh CLI works too, without the lockfile and the version check:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npx skills add TiagoDanin/Trunative --skill trunative
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The repository carries seven copies: `trunative` covers every stack, and `trunative-flutter`, `trunative-expo`, `trunative-react-native`, `trunative-swiftui`, `trunative-compose` and `trunative-web` are the same skill with the other frameworks' instructions removed. Pick one with `--skill`, or list them with `-l`. `npx trunative install` needs none of this: it reads the stack from `STACK.md` and resolves the copy itself.
|
|
71
|
+
|
|
72
|
+
To install manually, copy one of those folders into your agent's skills directory:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
git clone https://github.com/TiagoDanin/Trunative.git
|
|
76
|
+
cp -r Trunative/skills/trunative .claude/skills/trunative
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
The skill loads on its own when the task is mobile UI work. Building a screen, a component, a navigation flow, a form, or reviewing an existing one. No command needed:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
Build the checkout screen for the app.
|
|
85
|
+
Add a filter sheet to the product list.
|
|
86
|
+
Review this screen does it work one-handed?
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
To force it, name it:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
Use the trunative skill on src/screens/Checkout.tsx
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The agent then:
|
|
96
|
+
|
|
97
|
+
1. **Detects the stack** and maps the rules to that framework's primitives (`SafeAreaView`, `SafeArea`, `WindowInsets`, `env(safe-area-inset-*)`, …).
|
|
98
|
+
2. **Settles the structure first**, when the task moves hierarchy, actions, states or navigation. It writes a screen brief to `.trunative/screens/`, draws it as a greyscale wireframe, and asks once before any code exists. Approving a picture costs a fraction of rewriting a screen, and a cosmetic change skips this entirely.
|
|
99
|
+
3. **Applies the design principles** below while writing the code.
|
|
100
|
+
4. **Audits the result** against the checklist and reports what it could not satisfy, instead of silently shipping it.
|
|
101
|
+
|
|
102
|
+
That audit scores every rule that applies from 1 to 5, and a 1 or a 2 sends the agent back to fix it before the work is handed over.
|
|
103
|
+
|
|
104
|
+
The same review runs heavier on a screen that already exists, asked for by name:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
Review the checkout screen
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
It drives the app instead of reading the diff, separates what was read in the source from what was seen on a device, writes the result into `.trunative/review/`, and prints the score against the last run's.
|
|
111
|
+
|
|
112
|
+
## Design Principles
|
|
113
|
+
|
|
114
|
+
The rules the skill enforces. Each is a hard constraint, not a preference, and each carries a stable id the agent reports against. `src/skills/heuristics/` is the source of truth; this is the summary.
|
|
115
|
+
|
|
116
|
+
Ten files open on every screen, because every screen has colour, text, targets, a layout, states, at least one action, words, something that moves and something drawn.
|
|
117
|
+
|
|
118
|
+
**Colour** is reached through a role, never as a hex at a call site (`color-roles`). One accent means touchable and nothing else (`color-one-accent`). Dark is a second design rather than an inverted switch (`color-dark-composed`), contrast is calculated rather than eyeballed (`color-contrast`), and nothing is said by colour alone (`color-not-alone`).
|
|
119
|
+
|
|
120
|
+
**Type** comes from the platform ramp, so no component carries a literal size (`type-scale`), and the screen is rendered at the largest accessibility step before anyone calls it done (`type-scaling`). Body copy stays at a readable measure (`type-measure`) and the longest translation still fits (`type-strings`).
|
|
121
|
+
|
|
122
|
+
**Touch** is the one every desktop habit breaks. The target is the hit area and not the drawing, at 44pt or 48dp (`touch-floor`), with 8dp of dead space between neighbours (`touch-spacing`). The bottom third is the only easy part of the screen (`touch-reach`), destructive actions sit away from it (`touch-destructive`), every press answers within about 100ms outside the area the finger covers (`touch-feedback`), and the screen edges belong to the operating system (`touch-gestures`).
|
|
123
|
+
|
|
124
|
+
**Buttons** allow exactly one primary action per screen (`button-one-primary`), with emphasis taken from the platform's own ladder (`button-ladder`) and labels that name what will happen (`button-label`).
|
|
125
|
+
|
|
126
|
+
**Layout** takes its insets from the framework at runtime rather than from a constant (`layout-insets`), keeps one column and one scrolling axis (`layout-column`), and survives the narrow device (`layout-width`). The first screenful answers what the screen is and what to do (`layout-fold`).
|
|
127
|
+
|
|
128
|
+
**States** are named before the happy path is written, all six of them (`state-set`). Loading is a placeholder shaped like the content (`state-loading`), the three empties get three different sentences (`state-empty`), errors say what failed without inventing why (`state-error`), offline is four states rather than a boolean (`state-offline`), and work in progress survives the system killing the process (`state-interrupt`).
|
|
129
|
+
|
|
130
|
+
**Motion** earns its place by answering continuity, latency or acknowledgement (`motion-job`), leaves the platform's own transitions alone (`motion-platform`), never holds the user still (`motion-blocks`), and reads the reduced-motion setting on every platform it ships to (`motion-reduced`).
|
|
131
|
+
|
|
132
|
+
**Accessibility** gives every control a name, a role and a value (`a11y-name`), hides decoration rather than describing it (`a11y-hidden`), and never makes a gesture the only route to anything (`a11y-gesture`). One whole flow is driven with the screen reader on before it ships (`a11y-test`).
|
|
133
|
+
|
|
134
|
+
**Copy** decides its word budget before the sentence is written (`copy-budget`), spends the first two words on meaning (`copy-first-word`), names the failure and ends on the fix (`copy-error`), and uses one term per thing throughout (`copy-terms`). Sample content is chosen to break the layout rather than to flatter it (`copy-sample-data`).
|
|
135
|
+
|
|
136
|
+
**Icons and imagery** come from one set, at one weight, and an emoji is not a substitute for a missing icon (`icon-one-set`, `icon-no-emoji`). Artwork depicts something somebody chose, and decoration that means nothing loses its space to content (`icon-depicts`). The space an image will occupy is reserved before it lands, so nothing jumps under a thumb already moving (`icon-reserve`), and anything drawn light on dark needs its own variant rather than a filter (`icon-dark`).
|
|
137
|
+
|
|
138
|
+
The rest of the rules open when the screen touches them: navigation and back (`nav-`), lists (`list-`), forms (`form-`), chat (`chat-`), permissions (`perm-`), onboarding (`onboard-`), localization (`l10n-`), notifications (`notify-`), widgets and live surfaces (`widget-`), device capabilities (`sense-`), camera (`cam-`), network (`net-`), offline (`off-`), the launch surface (`splash-`), performance (`perf-`), feedback (`fb-`), search (`search-`), auth (`auth-`), settings (`set-`), media (`media-`), background work (`bg-`), privacy (`priv-`), sharing (`share-`), updates and migrations (`upd-`), scrolling (`scroll-`), data and charts (`data-`), sound (`sound-`), payments (`pay-`), ads (`ads-`), maps (`map-`) and web views (`webview-`).
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
[MIT](LICENSE) © Tiago Danin
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { build } from './commands/build.js';
|
|
6
|
+
import { detect } from './commands/detect.js';
|
|
7
|
+
import { doctor } from './commands/doctor.js';
|
|
8
|
+
import { graph } from './commands/graph.js';
|
|
9
|
+
import { lint } from './commands/lint.js';
|
|
10
|
+
import { install } from './commands/install.js';
|
|
11
|
+
import { rubric } from './commands/rubric.js';
|
|
12
|
+
import { spec } from './commands/spec.js';
|
|
13
|
+
import { packageRoot } from './paths.js';
|
|
14
|
+
const USAGE = `trunative, a mobile-only design skill for AI coding agents
|
|
15
|
+
|
|
16
|
+
Usage
|
|
17
|
+
npx trunative doctor check the project before the agent starts
|
|
18
|
+
npx trunative install [--dir] copy the skill into the agent directories
|
|
19
|
+
npx trunative spec [paths] check the screen briefs, exits 1 on a finding
|
|
20
|
+
npx trunative rubric [--only] print the review checklist, one row per rule
|
|
21
|
+
npx trunative build resolve the skill once per agent and stack
|
|
22
|
+
npx trunative lint check the written skill, exits 1 on a finding
|
|
23
|
+
npx trunative graph [--only] render the skill's structure
|
|
24
|
+
npx trunative detect [paths] answer the Check lines a file can settle
|
|
25
|
+
|
|
26
|
+
Options
|
|
27
|
+
--dir <path> install into this directory instead of the detected ones
|
|
28
|
+
(repeatable)
|
|
29
|
+
--cwd <path> run against this project instead of the current directory
|
|
30
|
+
--stack <name> install the copy resolved for this stack, instead of the one
|
|
31
|
+
the STACK.md "Stack:" line names
|
|
32
|
+
--only <name> limit the rubric to a heuristics file ("touch"), a rule
|
|
33
|
+
prefix ("touch-") or one rule id (repeatable)
|
|
34
|
+
--format <f> rubric output: markdown (default), json, ids
|
|
35
|
+
--check accepted on "spec", which only ever checks
|
|
36
|
+
-v, --version print the version
|
|
37
|
+
-h, --help print this help
|
|
38
|
+
|
|
39
|
+
The repository layout stays compatible with skills.sh, so
|
|
40
|
+
"npx skills add TiagoDanin/Trunative" keeps working as an alternative.
|
|
41
|
+
`;
|
|
42
|
+
async function readVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const source = await readFile(join(packageRoot, 'package.json'), 'utf8');
|
|
45
|
+
return JSON.parse(source).version ?? '0.0.0';
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '0.0.0';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function main() {
|
|
52
|
+
const { values, positionals } = parseArgs({
|
|
53
|
+
args: process.argv.slice(2),
|
|
54
|
+
allowPositionals: true,
|
|
55
|
+
options: {
|
|
56
|
+
dir: { type: 'string', multiple: true },
|
|
57
|
+
only: { type: 'string', multiple: true },
|
|
58
|
+
format: { type: 'string' },
|
|
59
|
+
stack: { type: 'string' },
|
|
60
|
+
cwd: { type: 'string' },
|
|
61
|
+
check: { type: 'boolean' },
|
|
62
|
+
version: { type: 'boolean', short: 'v' },
|
|
63
|
+
help: { type: 'boolean', short: 'h' },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const version = await readVersion();
|
|
67
|
+
if (values.version) {
|
|
68
|
+
console.log(version);
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
const command = positionals[0];
|
|
72
|
+
if (values.help || !command || command === 'help') {
|
|
73
|
+
console.log(USAGE);
|
|
74
|
+
return command || values.help ? 0 : 1;
|
|
75
|
+
}
|
|
76
|
+
const cwd = values.cwd ? join(process.cwd(), values.cwd) : process.cwd();
|
|
77
|
+
switch (command) {
|
|
78
|
+
case 'doctor':
|
|
79
|
+
return doctor({ cwd, version });
|
|
80
|
+
case 'install':
|
|
81
|
+
return install({ cwd, version, dirs: values.dir, stack: values.stack });
|
|
82
|
+
case 'build':
|
|
83
|
+
return build({ version });
|
|
84
|
+
case 'lint':
|
|
85
|
+
return lint({ cwd });
|
|
86
|
+
case 'spec':
|
|
87
|
+
return spec({ cwd, paths: positionals.slice(1) });
|
|
88
|
+
case 'detect':
|
|
89
|
+
return detect({
|
|
90
|
+
cwd,
|
|
91
|
+
paths: positionals.slice(1),
|
|
92
|
+
only: values.only,
|
|
93
|
+
format: values.format,
|
|
94
|
+
});
|
|
95
|
+
case 'graph':
|
|
96
|
+
return graph({ only: values.only, format: values.format });
|
|
97
|
+
case 'rubric':
|
|
98
|
+
return rubric({ version, only: values.only, format: values.format });
|
|
99
|
+
default:
|
|
100
|
+
console.error(`unknown command "${command}"\n`);
|
|
101
|
+
console.error(USAGE);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
process.exitCode = await main();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
|
+
import { AGENTS, STACKS } from '../compile.js';
|
|
4
|
+
import { readSources, resolve, write } from '../emit.js';
|
|
5
|
+
import { AGENT_DIR, compiledSkillsDir, PUBLISHED_AGENT, publishedSkillsDir, variantName, } from '../paths.js';
|
|
6
|
+
export async function build(options) {
|
|
7
|
+
const sources = await readSources();
|
|
8
|
+
const variants = [undefined, ...STACKS];
|
|
9
|
+
let written = 0;
|
|
10
|
+
for (const agent of AGENTS) {
|
|
11
|
+
for (const stack of variants) {
|
|
12
|
+
const target = stack ? { agent, stack } : { agent };
|
|
13
|
+
const destination = join(compiledSkillsDir, AGENT_DIR[agent] ?? `.${agent}`, 'skills', variantName('trunative', stack));
|
|
14
|
+
written += await write(resolve(sources, target), destination);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// The published copies. A stale variant would keep being served, so the
|
|
18
|
+
// directory is emptied rather than written over.
|
|
19
|
+
await rm(publishedSkillsDir, { recursive: true, force: true });
|
|
20
|
+
let published = 0;
|
|
21
|
+
for (const stack of variants) {
|
|
22
|
+
const target = stack ? { agent: PUBLISHED_AGENT, stack } : { agent: PUBLISHED_AGENT };
|
|
23
|
+
const destination = join(publishedSkillsDir, variantName('trunative', stack));
|
|
24
|
+
published += await write(resolve(sources, target), destination);
|
|
25
|
+
}
|
|
26
|
+
const names = variants.map((stack) => variantName('trunative', stack)).join(', ');
|
|
27
|
+
const dirs = AGENTS.map((agent) => AGENT_DIR[agent] ?? `.${agent}`).join(', ');
|
|
28
|
+
const at = (path) => relative(process.cwd(), path).split(sep).join('/');
|
|
29
|
+
console.log(`built ${written} files for trunative ${options.version}`);
|
|
30
|
+
console.log(`directories: ${dirs}`);
|
|
31
|
+
console.log(`skills in each: ${names}`);
|
|
32
|
+
console.log(`into ${at(compiledSkillsDir)}`);
|
|
33
|
+
console.log(`published ${published} files into ${at(publishedSkillsDir)} (${names}, resolved for ${PUBLISHED_AGENT})`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
3
|
+
import { DETECTORS } from '../detect/rules.js';
|
|
4
|
+
/** Directories a scan never enters, because nothing in them is the app. */
|
|
5
|
+
const SKIPPED = new Set([
|
|
6
|
+
'node_modules',
|
|
7
|
+
'build',
|
|
8
|
+
'dist',
|
|
9
|
+
'.dart_tool',
|
|
10
|
+
'Pods',
|
|
11
|
+
'.git',
|
|
12
|
+
'.gradle',
|
|
13
|
+
'DerivedData',
|
|
14
|
+
'ios/Flutter',
|
|
15
|
+
]);
|
|
16
|
+
const EXTENSIONS = new Set(DETECTORS.flatMap((detector) => detector.extensions));
|
|
17
|
+
async function walk(root, dir, found) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (SKIPPED.has(entry.name)) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const full = join(dir, entry.name);
|
|
30
|
+
if (entry.isDirectory()) {
|
|
31
|
+
await walk(root, full, found);
|
|
32
|
+
}
|
|
33
|
+
else if (entry.isFile() && EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
34
|
+
found.push(relative(root, full).split(sep).join('/'));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Files the detectors can read, under the paths asked for. */
|
|
39
|
+
async function scannable(cwd, paths) {
|
|
40
|
+
const found = [];
|
|
41
|
+
for (const path of paths.length > 0 ? paths : ['.']) {
|
|
42
|
+
const full = join(cwd, path);
|
|
43
|
+
const info = await stat(full).catch(() => null);
|
|
44
|
+
if (!info) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (info.isDirectory()) {
|
|
48
|
+
await walk(cwd, full, found);
|
|
49
|
+
}
|
|
50
|
+
else if (EXTENSIONS.has(extname(full).toLowerCase())) {
|
|
51
|
+
found.push(relative(cwd, full).split(sep).join('/'));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
55
|
+
}
|
|
56
|
+
export async function detect(options) {
|
|
57
|
+
const { cwd } = options;
|
|
58
|
+
const only = options.only ?? [];
|
|
59
|
+
const detectors = DETECTORS.filter((detector) => only.length === 0 || only.some((term) => detector.rule === term || detector.rule.startsWith(term)));
|
|
60
|
+
if (detectors.length === 0) {
|
|
61
|
+
console.error(`no detector matches --only ${only.join(', ')}`);
|
|
62
|
+
console.error(`detectors: ${DETECTORS.map((detector) => detector.rule).join(', ')}`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
const files = await scannable(cwd, options.paths ?? []);
|
|
66
|
+
const findings = [];
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
const source = await readFile(join(cwd, ...file.split('/')), 'utf8');
|
|
69
|
+
const extension = extname(file).toLowerCase();
|
|
70
|
+
for (const detector of detectors) {
|
|
71
|
+
if (detector.extensions.includes(extension)) {
|
|
72
|
+
findings.push(...detector.run(file, source));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
findings.sort((a, b) => (a.file === b.file ? a.line - b.line : a.file < b.file ? -1 : 1));
|
|
77
|
+
if (options.format === 'json') {
|
|
78
|
+
console.log(JSON.stringify({
|
|
79
|
+
scanned: files.length,
|
|
80
|
+
rules: detectors.map((detector) => detector.rule),
|
|
81
|
+
findings,
|
|
82
|
+
}, undefined, '\t'));
|
|
83
|
+
return findings.length > 0 ? 2 : 0;
|
|
84
|
+
}
|
|
85
|
+
for (const found of findings) {
|
|
86
|
+
console.log(`${found.file}:${found.line} ${found.rule} ${found.message}`);
|
|
87
|
+
}
|
|
88
|
+
console.log(`\n${findings.length} finding(s) in ${files.length} file(s), from ${detectors.length} detector(s).`);
|
|
89
|
+
console.log('Source evidence only: a finding is a place to look, and silence is not a pass.');
|
|
90
|
+
return findings.length > 0 ? 2 : 0;
|
|
91
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { readSources, resolve } from '../emit.js';
|
|
4
|
+
import { readLock } from '../lock.js';
|
|
5
|
+
import { briefCandidates, packagedSkillDir } from '../paths.js';
|
|
6
|
+
import { hashFiles, hashSkill, readSkillName } from '../skill.js';
|
|
7
|
+
async function exists(path) {
|
|
8
|
+
try {
|
|
9
|
+
await stat(path);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function checkBrief(cwd, file, label) {
|
|
17
|
+
for (const candidate of briefCandidates(cwd, file)) {
|
|
18
|
+
if (await exists(candidate)) {
|
|
19
|
+
return { name: label, ok: true, detail: relative(cwd, candidate).replace(/\\/g, '/') };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
name: label,
|
|
24
|
+
ok: false,
|
|
25
|
+
detail: `not found, expected .trunative/${file} or ${file}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Compares each installed copy against the same variant resolved from the
|
|
30
|
+
* skill shipping in this package. Every target has its own hash, because a copy
|
|
31
|
+
* resolved for one agent and one stack is not the same bytes as another.
|
|
32
|
+
*/
|
|
33
|
+
async function checkSkill(cwd, version) {
|
|
34
|
+
const source = await hashSkill(packagedSkillDir);
|
|
35
|
+
const name = await readSkillName(packagedSkillDir);
|
|
36
|
+
const lock = await readLock(cwd);
|
|
37
|
+
if (!lock) {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
name: 'skill installed',
|
|
41
|
+
ok: false,
|
|
42
|
+
detail: 'no .trunative/skill.lock, run "npx trunative install"',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
const checks = [
|
|
47
|
+
{
|
|
48
|
+
name: 'skill installed',
|
|
49
|
+
ok: true,
|
|
50
|
+
detail: `${lock.skill} in ${lock.targets.map((target) => target.dir).join(', ')}`,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
const sources = await readSources();
|
|
54
|
+
const missing = [];
|
|
55
|
+
const stale = [];
|
|
56
|
+
for (const target of lock.targets) {
|
|
57
|
+
const destination = join(cwd, ...target.dir.split('/'));
|
|
58
|
+
if (!(await exists(destination))) {
|
|
59
|
+
missing.push(target.dir);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const shape = target.stack
|
|
63
|
+
? { agent: target.agent, stack: target.stack }
|
|
64
|
+
: { agent: target.agent };
|
|
65
|
+
if ((await hashSkill(destination)) !== hashFiles(resolve(sources, shape))) {
|
|
66
|
+
stale.push(target.dir);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (missing.length > 0) {
|
|
70
|
+
checks.push({
|
|
71
|
+
name: 'skill present',
|
|
72
|
+
ok: false,
|
|
73
|
+
detail: `missing in ${missing.join(', ')}, run "npx trunative install"`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (stale.length > 0 || lock.hash !== source) {
|
|
77
|
+
const reason = stale.length > 0
|
|
78
|
+
? `content differs in ${stale.join(', ')}`
|
|
79
|
+
: `installed with trunative@${lock.version}, this is ${version}`;
|
|
80
|
+
checks.push({
|
|
81
|
+
name: 'skill up to date',
|
|
82
|
+
ok: false,
|
|
83
|
+
detail: `${reason}, run "npx trunative install"`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
checks.push({ name: 'skill up to date', ok: true, detail: `${name} ${source.slice(0, 19)}` });
|
|
88
|
+
}
|
|
89
|
+
return checks;
|
|
90
|
+
}
|
|
91
|
+
export async function doctor(options) {
|
|
92
|
+
const { cwd, version } = options;
|
|
93
|
+
const checks = [
|
|
94
|
+
await checkBrief(cwd, 'PRODUCT.md', 'product brief'),
|
|
95
|
+
await checkBrief(cwd, 'DESIGN.md', 'design brief'),
|
|
96
|
+
await checkBrief(cwd, 'STACK.md', 'stack brief'),
|
|
97
|
+
...(await checkSkill(cwd, version)),
|
|
98
|
+
];
|
|
99
|
+
const width = Math.max(...checks.map((check) => check.name.length));
|
|
100
|
+
for (const check of checks) {
|
|
101
|
+
console.log(`${check.ok ? 'PASS' : 'FAIL'} ${check.name.padEnd(width)} ${check.detail}`);
|
|
102
|
+
}
|
|
103
|
+
const failed = checks.filter((check) => !check.ok);
|
|
104
|
+
if (failed.length === 0) {
|
|
105
|
+
console.log('\nReady. Start the build step.');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
console.log(`\n${failed.length} check(s) failed. Fix them before the build step.`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { readGraph } from '../graph.js';
|
|
3
|
+
const FORMATS = ['mermaid', 'json', 'text'];
|
|
4
|
+
function id(path) {
|
|
5
|
+
return path.replace(/[^a-zA-Z0-9]/g, '_');
|
|
6
|
+
}
|
|
7
|
+
/** Heuristic to heuristic, counted, so two files claiming one concern show up. */
|
|
8
|
+
function crossReferences(graph) {
|
|
9
|
+
const owner = new Map(graph.rules.map((rule) => [rule.id, rule.file]));
|
|
10
|
+
const edges = new Map();
|
|
11
|
+
for (const mention of graph.mentions) {
|
|
12
|
+
if (!mention.file.startsWith('heuristics/')) {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const from = basename(mention.file, '.md');
|
|
16
|
+
const to = owner.get(mention.id);
|
|
17
|
+
if (!to || to === from) {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const key = `${from} ${to}`;
|
|
21
|
+
edges.set(key, (edges.get(key) ?? 0) + 1);
|
|
22
|
+
}
|
|
23
|
+
return edges;
|
|
24
|
+
}
|
|
25
|
+
function mermaid(graph, only) {
|
|
26
|
+
const base = graph.index.base.map((path) => basename(path, '.md'));
|
|
27
|
+
const lines = ['flowchart LR', ' SKILL["SKILL.md"]'];
|
|
28
|
+
for (const file of graph.index.flow) {
|
|
29
|
+
lines.push(` SKILL --> ${id(file)}["${file}"]`);
|
|
30
|
+
}
|
|
31
|
+
for (const heuristic of graph.heuristics) {
|
|
32
|
+
const tier = base.includes(heuristic.file) ? 'base' : 'extra';
|
|
33
|
+
const node = id(heuristic.file);
|
|
34
|
+
if (only.includes(heuristic.file)) {
|
|
35
|
+
lines.push(` subgraph ${node}_g["${heuristic.file} (${tier})"]`);
|
|
36
|
+
for (const rule of heuristic.rules) {
|
|
37
|
+
const mark = rule.evidence === 'device' ? ' [device]' : '';
|
|
38
|
+
lines.push(` ${id(rule.id)}["${rule.id}${mark}"]`);
|
|
39
|
+
}
|
|
40
|
+
lines.push(' end');
|
|
41
|
+
lines.push(` SKILL --> ${node}_g`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
lines.push(` SKILL --> ${node}["${heuristic.file} (${tier}, ${heuristic.rules.length})"]`);
|
|
45
|
+
}
|
|
46
|
+
for (const [key, count] of crossReferences(graph)) {
|
|
47
|
+
const [from, to] = key.split(' ');
|
|
48
|
+
lines.push(` ${id(from)} -.->|${count}| ${id(to)}`);
|
|
49
|
+
}
|
|
50
|
+
for (const file of graph.index.references) {
|
|
51
|
+
const linked = new Set(graph.links
|
|
52
|
+
.filter((link) => link.target === file && link.file.startsWith('heuristics/'))
|
|
53
|
+
.map((link) => basename(link.file, '.md')));
|
|
54
|
+
lines.push(` ${id(file)}(["${file}"])`);
|
|
55
|
+
for (const from of linked) {
|
|
56
|
+
lines.push(` ${id(from)} --> ${id(file)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return lines.join('\n');
|
|
60
|
+
}
|
|
61
|
+
function text(graph, only) {
|
|
62
|
+
const base = graph.index.base.map((path) => basename(path, '.md'));
|
|
63
|
+
const lines = ['SKILL.md'];
|
|
64
|
+
lines.push(' flow');
|
|
65
|
+
for (const file of graph.index.flow) {
|
|
66
|
+
lines.push(` ${file}`);
|
|
67
|
+
}
|
|
68
|
+
for (const tier of ['base', 'extra']) {
|
|
69
|
+
lines.push(` ${tier}`);
|
|
70
|
+
for (const heuristic of graph.heuristics) {
|
|
71
|
+
const isBase = base.includes(heuristic.file);
|
|
72
|
+
if ((tier === 'base') !== isBase) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
lines.push(` ${heuristic.file} (${heuristic.rules.length} rules)`);
|
|
76
|
+
if (!only.includes(heuristic.file)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const rule of heuristic.rules) {
|
|
80
|
+
const mark = rule.evidence === 'device' ? ' [device]' : '';
|
|
81
|
+
lines.push(` ${rule.id}${mark} line ${rule.line}, check ${rule.checkLine}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
lines.push(' references');
|
|
86
|
+
for (const file of graph.index.references) {
|
|
87
|
+
const count = graph.links.filter((link) => link.target === file && link.file !== 'SKILL.md').length;
|
|
88
|
+
lines.push(` ${file} (${count} link${count === 1 ? '' : 's'})`);
|
|
89
|
+
}
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
}
|
|
92
|
+
export async function graph(options) {
|
|
93
|
+
const format = options.format ?? 'mermaid';
|
|
94
|
+
if (!FORMATS.includes(format)) {
|
|
95
|
+
console.error(`unknown --format "${format}", expected ${FORMATS.join(', ')}`);
|
|
96
|
+
return 1;
|
|
97
|
+
}
|
|
98
|
+
const model = await readGraph();
|
|
99
|
+
const only = options.only ?? [];
|
|
100
|
+
if (format === 'json') {
|
|
101
|
+
console.log(JSON.stringify(model, undefined, '\t'));
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
console.log(format === 'text' ? text(model, only) : mermaid(model, only));
|
|
105
|
+
return 0;
|
|
106
|
+
}
|