@njeybe/nativ 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/README.md +30 -1
- package/dist/commands/task.d.ts +6 -0
- package/dist/commands/task.js +20 -2
- package/dist/commands/validate.js +14 -0
- package/dist/core/code-shape.d.ts +26 -0
- package/dist/core/code-shape.js +126 -0
- package/dist/core/model-routing.d.ts +6 -0
- package/dist/core/model-routing.js +49 -0
- package/dist/core/spec-slices.d.ts +27 -0
- package/dist/core/spec-slices.js +194 -0
- package/dist/core/verifier.d.ts +8 -1
- package/dist/core/verifier.js +26 -2
- package/dist/index.js +3 -0
- package/dist/mcp/server.js +16 -4
- package/dist/runner/agent-supervisor.js +39 -20
- package/dist/scanner/types.d.ts +7 -0
- package/dist/scanner/types.js +8 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +38 -1
- package/plugin/agents/verifier.md +1 -0
- package/plugin/agents/worker.md +2 -1
- package/plugin/skills/nativ/SKILL.md +14 -0
- package/templates/AGENTS.md +81 -67
- package/templates/GEMINI.md +21 -6
- package/templates/claude-agents/architect.md +65 -28
- package/templates/claude-agents/verifier.md +29 -28
- package/templates/claude-agents/worker.md +27 -26
- package/templates/dot-ai/subagents/flutter-developer.md +84 -11
- package/templates/dot-ai/subagents/frontend.md +83 -126
- package/templates/dot-ai/ui_specs.md +214 -78
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.2.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Dual-Track Intake (Experience-First vs Data-First).** The `ui_specs.md` template can now start from either user experience (sketches and flows) or data structure (tables and entity relationships). Both paths lead to the same component map and style tile.
|
|
8
|
+
- **Semantic Component Trees.** Components now organize into a hierarchical structure with semantic roles: layout containers, data display, input controls, feedback, and navigation. The component map in `ui_specs.md` reflects these roles.
|
|
9
|
+
- **Stack-Aware Style Tiles.** Style tiles now include platform-specific variants. A web style tile shows how a design token adapts across desktop, tablet and mobile breakpoints. Flutter tiles show platform-native adaptations for iOS, Android and Cupertino.
|
|
10
|
+
- **Contextual Typography.** The `ui_specs.md` template documents font scales in context: scale per platform, role (heading, body, caption, code), and how type tokens map to semantic roles in components. Font sizes, weights and line heights are specified per context, not globally.
|
|
11
|
+
- **Mutation State Specs.** Every input component, button, and interactive element now carries a spec for its mutation states: default, hover, focus, disabled, loading, error and success. States are shown in the wireframe notes and in the component section.
|
|
12
|
+
|
|
13
|
+
## 2.1.0
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Design-first planning.** The `ui_specs.md` template is rewritten around planning levels (Quick, Standard, Full), content samples, user flows, wireframes, a component map and a style tile. The architect, frontend and flutter guides are leaner.
|
|
18
|
+
- **Optional task fields** `specRefs`, `complexity` and `acceptanceCriteria`: `nativ task add --spec-refs --complexity --accept`, and the same fields on the `nativ_task_add` MCP tool.
|
|
19
|
+
- **Spec slices.** `nativ task next --json` returns `specSlices` and `specWarnings` for a task's `specRefs`, the native run engine adds a `<spec_slices>` block to the worker prompt, and `nativ validate` warns about refs that do not resolve.
|
|
20
|
+
- **Complexity-based model routing.** `nativ task next --json` returns `recommendedModel` (simple: haiku, standard: sonnet, complex: opus). Override it with `workerModels` in `.nativ/config.json`. The run engine uses it when no model is given, and the project manager passes it as the Agent `model` parameter. Works with a Claude Pro login; no API key.
|
|
21
|
+
- **Code style rules** for workers in `AGENTS.md`, and a code-shape check in `nativ verify` for long lines and long comments. It warns by default; configure it with `codeStyle` in `.nativ/config.json`. The `nativ task complete` gatekeeper runs it too, warn-only.
|
|
22
|
+
- **API spec links.** `specRefs` can point at an endpoint by method and path (`api_contracts.json#GET /path`) or by endpoint id (`api_contracts.json#<endpoint-id>`).
|
|
23
|
+
- **Design Brief step** in the Architect procedure. It records the primary platform and comes before the design directions.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **`nativ_task_complete` over MCP refuses `skipVerify`.** Only a human at a terminal can skip verification.
|
|
28
|
+
|
|
3
29
|
## 2.0.2
|
|
4
30
|
|
|
5
31
|
- **`nativ doctor` no longer reports a failure on a brand-new project.** Right after `nativ init`, Claude Code lists the project's `nativ` MCP server as "Pending approval" until you approve it, which is the expected first state. Doctor showed it as a red failure and exited 1. It is now a warning that tells you to open `claude` in the folder and approve the server; a server that genuinely fails to connect is still a failure.
|
|
@@ -8,7 +34,7 @@
|
|
|
8
34
|
|
|
9
35
|
Documentation and package metadata only; no behaviour changes.
|
|
10
36
|
|
|
11
|
-
- **npm is the only install path.** The source repository is private, so the instructions for `npm install -g github
|
|
37
|
+
- **npm is the only install path.** The source repository is private, so the instructions for `npm install -g github:…` and for adding the plugin marketplace were removed from the README and the installation guide. `nativ setup` is the complete route: it writes the agents, the enforcement hook, the MCP entry and the permission rules, which a plugin cannot ship.
|
|
12
38
|
- The plugin files still ship inside the package (`plugin/`) and can be tried for one session with `claude --plugin-dir`.
|
|
13
39
|
- `package.json` no longer declares `repository`, `homepage` or `bugs`, so the npm page has no dead links.
|
|
14
40
|
- The 2.0.0 entry below still mentions the plugin marketplace; that route is not available outside the private repository.
|
package/README.md
CHANGED
|
@@ -125,6 +125,34 @@ Start in `warn`, look at the log, then switch to `block`. If a task genuinely ne
|
|
|
125
125
|
|
|
126
126
|
---
|
|
127
127
|
|
|
128
|
+
## Design-first planning, task fields and model routing
|
|
129
|
+
|
|
130
|
+
**Planning levels.** The `.ai/ui_specs.md` template opens with a planning level: Quick (small fix, no design phase), Standard (new screen in an existing design) or Full (new app or redesign). It then walks through the design brief, users and top tasks, real content samples, user flows, design directions with a style tile, wireframes, a component map and one section per component. The architect and frontend/flutter guides were trimmed to match.
|
|
131
|
+
|
|
132
|
+
**UI/UX design enhancements.** The template supports two intake approaches: Experience-First (start with user flows and wireframes) or Data-First (start with entity relationships and table schemas), both leading to the same component map. The component map now organizes components into semantic trees reflecting their roles in the interface (layout, data display, input, feedback, navigation). Style tiles are stack-aware, showing platform-specific token variants for web breakpoints and Flutter platform adaptations. Typography is specified in context with font scales per platform and semantic role, mapped directly to components. Every interactive element and input carries a mutation state spec: default, hover, focus, disabled, loading, error and success states, documented in wireframe notes and component sections.
|
|
133
|
+
|
|
134
|
+
**Optional task fields.** A task can carry `specRefs` (anchors such as `ui_specs.md#appointment-list`), `complexity` (`simple`, `standard` or `complex`) and `acceptanceCriteria` ("done when" lines):
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
nativ task add "Appointment list" -a frontend --complexity standard --spec-refs "ui_specs.md#appointment-list" --accept "Empty state shown|Rows sorted by time"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`--accept` can be repeated or `|`-separated. The `nativ_task_add` MCP tool takes the same three fields.
|
|
141
|
+
|
|
142
|
+
**Spec slices.** For a task with `specRefs`, `nativ task next --json` adds `specSlices` (the referenced heading sections or JSON pointers, size-capped) and `specWarnings` (a ref that is missing or outside `.ai/`). The native run engine puts the same slices in a `<spec_slices>` block of the worker prompt, and `nativ validate` prints the warnings for the whole plan. Workers read a whole contract only when a slice is missing or cut short.
|
|
143
|
+
|
|
144
|
+
**Model routing.** `nativ task next --json` returns `recommendedModel` from the task's `complexity`: `simple` gives `haiku`, `standard` gives `sonnet`, `complex` gives `opus`. A task with no complexity gets no recommendation and nothing changes. Override the mapping with `workerModels` in `.nativ/config.json`:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{ "workerModels": { "simple": "haiku", "standard": "sonnet", "complex": "opus" } }
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The run engine uses the routed model when you did not pass one explicitly, and the project manager passes `recommendedModel` as the `model` parameter of the Agent tool. These are Claude Code model aliases, so a Claude Pro login is enough and no API key is needed.
|
|
151
|
+
|
|
152
|
+
**Code style.** `AGENTS.md` gives workers short style rules: lines of 100 characters or fewer (hard maximum 120), functions of about 40 lines, files of about 300, comments of at most 2 lines, and your own formatter config wins. `nativ verify` also runs a check on a task's target files for over-long lines and long comment blocks. It only warns by default. Tune it with `codeStyle` in `.nativ/config.json` (`mode`: `warn`, `block` or `off`; `maxLineLength`, default 120; `maxCommentLines`, default 2). The `nativ task complete` gatekeeper runs it too, warn-only.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
128
156
|
## Providers
|
|
129
157
|
|
|
130
158
|
nativ's own model calls (currently triage of escalations) go through one adapter, so no single vendor is on the critical path.
|
|
@@ -171,6 +199,7 @@ nativ task next # next executable task and its contract slice
|
|
|
171
199
|
nativ task list --available
|
|
172
200
|
nativ task list --milestone m1 --status pending
|
|
173
201
|
nativ task add "Implement OAuth2 callback" -a backend -v "npm test" -f "src/auth.ts"
|
|
202
|
+
nativ task add "Login form" -a frontend --complexity simple --spec-refs "ui_specs.md#login-form" --accept "Errors shown inline"
|
|
174
203
|
nativ task start task-01
|
|
175
204
|
nativ task complete task-01 # runs the verification command
|
|
176
205
|
nativ task complete task-01 --timeout 600000 # for slow suites
|
|
@@ -271,7 +300,7 @@ Task transitions and role violations are recorded in `.ai/telemetry.json`: durat
|
|
|
271
300
|
| Tool / resource | Description |
|
|
272
301
|
| :--- | :--- |
|
|
273
302
|
| `nativ_task_next`, `nativ_task_list`, `nativ_task_add` | Find and add work |
|
|
274
|
-
| `nativ_task_start`, `nativ_task_complete`, `nativ_task_block` | Move a task through its lifecycle (complete runs the gatekeeper) |
|
|
303
|
+
| `nativ_task_start`, `nativ_task_complete`, `nativ_task_block` | Move a task through its lifecycle (complete runs the gatekeeper and refuses skipVerify) |
|
|
275
304
|
| `nativ_task_escalate`, `nativ_task_propose_patch` | Escalate a contract gap, or propose a governed patch |
|
|
276
305
|
| `nativ_verify`, `nativ_bench`, `nativ_status` | Verification, benchmarks, progress |
|
|
277
306
|
| `nativ_worktree_*` | Create, list, merge and remove task worktrees |
|
package/dist/commands/task.d.ts
CHANGED
|
@@ -55,6 +55,12 @@ export interface TaskAddOptions {
|
|
|
55
55
|
/** Dependency task IDs, comma-separated or as an array. */
|
|
56
56
|
deps?: string | string[];
|
|
57
57
|
notes?: string;
|
|
58
|
+
/** Spec refs, comma-separated or as an array. */
|
|
59
|
+
specRefs?: string | string[];
|
|
60
|
+
/** simple | standard | complex. */
|
|
61
|
+
complexity?: string;
|
|
62
|
+
/** "Done when" lines: an array, or one string with "|" separators. */
|
|
63
|
+
accept?: string | string[];
|
|
58
64
|
json?: boolean;
|
|
59
65
|
}
|
|
60
66
|
export interface TaskAddResult {
|
package/dist/commands/task.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import pc from 'picocolors';
|
|
3
3
|
import { isSubagentType, SUBAGENT_TYPES, validateMasterPlanTask, } from '../scanner/types.js';
|
|
4
|
-
import { executeVerification } from '../core/verifier.js';
|
|
4
|
+
import { applyCodeShape, executeVerification } from '../core/verifier.js';
|
|
5
5
|
import { ContractGovernor, CircuitBreaker, TestIntegrityGuard, resolveGitHead, appendEscalation, } from '../governor/index.js';
|
|
6
6
|
import { loadPlan, savePlan, withPlanLock } from '../core/lock-manager.js';
|
|
7
7
|
import { recordTaskStart, recordTaskComplete } from '../core/telemetry.js';
|
|
8
|
+
import { resolveSpecSlices } from '../core/spec-slices.js';
|
|
9
|
+
import { resolveWorkerModel } from '../core/model-routing.js';
|
|
8
10
|
import { addTaskUnlock, removeTaskUnlock } from '../core/enforcement.js';
|
|
9
11
|
export { loadPlan, savePlan, withPlanLock };
|
|
10
12
|
function getPlanPath(targetDirArg) {
|
|
@@ -256,6 +258,10 @@ export async function runTaskNext(targetDirArg, options = {}) {
|
|
|
256
258
|
const roleGuide = getRoleGuide(targetTask.assignedSubagent);
|
|
257
259
|
const contractSlice = getRecommendedContractSlice(targetTask.assignedSubagent);
|
|
258
260
|
if (options.json) {
|
|
261
|
+
const specResult = targetTask.specRefs?.length
|
|
262
|
+
? resolveSpecSlices(path.dirname(path.dirname(planPath)), targetTask.specRefs)
|
|
263
|
+
: null;
|
|
264
|
+
const recommendedModel = resolveWorkerModel(path.dirname(path.dirname(planPath)), targetTask);
|
|
259
265
|
console.log(JSON.stringify({
|
|
260
266
|
status: 'ready',
|
|
261
267
|
milestoneId: activeMilestone.id,
|
|
@@ -264,6 +270,8 @@ export async function runTaskNext(targetDirArg, options = {}) {
|
|
|
264
270
|
...targetTask,
|
|
265
271
|
roleGuide,
|
|
266
272
|
recommendedContractSlice: contractSlice,
|
|
273
|
+
...(recommendedModel ? { recommendedModel } : {}),
|
|
274
|
+
...(specResult ? { specSlices: specResult.slices, ...(specResult.warnings.length ? { specWarnings: specResult.warnings } : {}) } : {}),
|
|
267
275
|
}
|
|
268
276
|
}, null, 2));
|
|
269
277
|
return;
|
|
@@ -417,7 +425,8 @@ export async function runTaskComplete(taskId, targetDirArg, options = {}) {
|
|
|
417
425
|
// ── Verification Gatekeeper ────────────────────────────────────────────────
|
|
418
426
|
let vResult = null;
|
|
419
427
|
if (!options.skipVerify) {
|
|
420
|
-
|
|
428
|
+
const rawResult = await executeVerification(foundTask.verificationCommand, targetDir, options.timeout);
|
|
429
|
+
vResult = applyCodeShape(rawResult, targetDir, foundTask);
|
|
421
430
|
if (!vResult.success) {
|
|
422
431
|
console.error(pc.red(`\n✖ Task [${pc.bold(taskId)}] verification FAILED with exit code ${vResult.exitCode}:`));
|
|
423
432
|
console.error(pc.yellow(` Command: \`${foundTask.verificationCommand}\``));
|
|
@@ -704,6 +713,12 @@ export async function runTaskAdd(title, targetDirArg, options = {}) {
|
|
|
704
713
|
return failTaskAdd('NO_OPEN_MILESTONE', 'Every milestone is completed. Pass --milestone <id> to reopen one, or --fast-path', options.json);
|
|
705
714
|
}
|
|
706
715
|
}
|
|
716
|
+
const specRefs = [...new Set(splitList(options.specRefs))];
|
|
717
|
+
const acceptanceCriteria = (Array.isArray(options.accept) ? options.accept : options.accept ? [options.accept] : [])
|
|
718
|
+
.flatMap((item) => item.split('|'))
|
|
719
|
+
.map((item) => item.trim())
|
|
720
|
+
.filter(Boolean);
|
|
721
|
+
const complexity = options.complexity?.trim().toLowerCase();
|
|
707
722
|
const task = {
|
|
708
723
|
id: generateNextTaskId(plan),
|
|
709
724
|
title: cleanTitle,
|
|
@@ -715,6 +730,9 @@ export async function runTaskAdd(title, targetDirArg, options = {}) {
|
|
|
715
730
|
verificationCommand: (options.verify ?? '').trim(),
|
|
716
731
|
notes: (options.notes ?? '').trim(),
|
|
717
732
|
...(fastPath ? { fastPath: true } : {}),
|
|
733
|
+
...(specRefs.length ? { specRefs } : {}),
|
|
734
|
+
...(complexity ? { complexity: complexity } : {}),
|
|
735
|
+
...(acceptanceCriteria.length ? { acceptanceCriteria } : {}),
|
|
718
736
|
};
|
|
719
737
|
const problems = validateMasterPlanTask(task);
|
|
720
738
|
if (problems.length) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import pc from 'picocolors';
|
|
4
|
+
import { resolveSpecSlices } from '../core/spec-slices.js';
|
|
4
5
|
export async function runValidate(targetDirArg) {
|
|
5
6
|
const targetDir = path.resolve(targetDirArg || process.cwd());
|
|
6
7
|
console.log(pc.bold(pc.cyan(`\n🔍 Validating AI Agent Workflow Contracts in: ${targetDir}\n`)));
|
|
@@ -52,6 +53,19 @@ export async function runValidate(targetDirArg) {
|
|
|
52
53
|
hasErrors = true;
|
|
53
54
|
}
|
|
54
55
|
}
|
|
56
|
+
if (plan && Array.isArray(plan.milestones)) {
|
|
57
|
+
const specWarnings = [];
|
|
58
|
+
for (const m of plan.milestones) {
|
|
59
|
+
for (const t of Array.isArray(m?.tasks) ? m.tasks : []) {
|
|
60
|
+
if (!Array.isArray(t?.specRefs) || !t.specRefs.length)
|
|
61
|
+
continue;
|
|
62
|
+
for (const w of resolveSpecSlices(targetDir, t.specRefs).warnings)
|
|
63
|
+
specWarnings.push(`${t.id}: ${w}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const w of specWarnings)
|
|
67
|
+
console.log(pc.yellow(` ⚠ ${w}`));
|
|
68
|
+
}
|
|
55
69
|
const escalationPath = path.join(targetDir, '.ai/escalation.json');
|
|
56
70
|
if (fs.existsSync(escalationPath)) {
|
|
57
71
|
const escData = checkFile('.ai/escalation.json', true);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-shape check: flags over-long lines and long comment blocks in a task's target files.
|
|
3
|
+
* Settings live under `codeStyle` in .nativ/config.json. Default mode is 'warn'.
|
|
4
|
+
* Exempt: a license/header comment at the top of a file, and JSDoc directly above an export.
|
|
5
|
+
*/
|
|
6
|
+
export type CodeShapeMode = 'warn' | 'block' | 'off';
|
|
7
|
+
export interface CodeShapeSettings {
|
|
8
|
+
mode: CodeShapeMode;
|
|
9
|
+
maxLineLength: number;
|
|
10
|
+
maxCommentLines: number;
|
|
11
|
+
}
|
|
12
|
+
export interface CodeShapeIssue {
|
|
13
|
+
file: string;
|
|
14
|
+
line: number;
|
|
15
|
+
kind: 'long-line' | 'long-comment';
|
|
16
|
+
message: string;
|
|
17
|
+
}
|
|
18
|
+
export interface CodeShapeReport {
|
|
19
|
+
mode: CodeShapeMode;
|
|
20
|
+
issues: CodeShapeIssue[];
|
|
21
|
+
}
|
|
22
|
+
export declare function loadCodeShapeSettings(targetDir: string): CodeShapeSettings;
|
|
23
|
+
export declare function checkFileShape(file: string, text: string, s: CodeShapeSettings): CodeShapeIssue[];
|
|
24
|
+
export declare function checkCodeShape(targetDir: string, targetFiles?: string[]): CodeShapeReport;
|
|
25
|
+
/** Short plain-language summary; empty string when there is nothing to report. */
|
|
26
|
+
export declare function formatCodeShapeReport(report: CodeShapeReport): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const DEFAULTS = { mode: 'warn', maxLineLength: 120, maxCommentLines: 2 };
|
|
4
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
5
|
+
'.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.kt', '.dart',
|
|
6
|
+
'.c', '.cc', '.cpp', '.h', '.cs', '.rb', '.php', '.swift', '.vue', '.svelte', '.css', '.scss',
|
|
7
|
+
]);
|
|
8
|
+
const SKIPPED_DIRS = ['dist', 'build', 'node_modules', 'generated', '.next', 'coverage'];
|
|
9
|
+
const MAX_SHOWN = 10;
|
|
10
|
+
export function loadCodeShapeSettings(targetDir) {
|
|
11
|
+
const settings = { ...DEFAULTS };
|
|
12
|
+
try {
|
|
13
|
+
const raw = fs.readFileSync(path.join(targetDir, '.nativ', 'config.json'), 'utf8');
|
|
14
|
+
const style = JSON.parse(raw)?.codeStyle;
|
|
15
|
+
if (!style || typeof style !== 'object')
|
|
16
|
+
return settings;
|
|
17
|
+
if (['warn', 'block', 'off'].includes(style.mode))
|
|
18
|
+
settings.mode = style.mode;
|
|
19
|
+
if (Number.isInteger(style.maxLineLength) && style.maxLineLength > 0) {
|
|
20
|
+
settings.maxLineLength = style.maxLineLength;
|
|
21
|
+
}
|
|
22
|
+
if (Number.isInteger(style.maxCommentLines) && style.maxCommentLines > 0) {
|
|
23
|
+
settings.maxCommentLines = style.maxCommentLines;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Missing or unreadable config: use defaults.
|
|
28
|
+
}
|
|
29
|
+
return settings;
|
|
30
|
+
}
|
|
31
|
+
function isCheckable(file) {
|
|
32
|
+
const parts = file.split(/[\\/]/);
|
|
33
|
+
if (parts.some((p) => SKIPPED_DIRS.includes(p)))
|
|
34
|
+
return false;
|
|
35
|
+
const base = parts[parts.length - 1];
|
|
36
|
+
if (/\.(min|generated|g)\./.test(base) || base.endsWith('.d.ts'))
|
|
37
|
+
return false;
|
|
38
|
+
return SOURCE_EXTENSIONS.has(path.extname(base).toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
/** Returns the comment-block runs as { start, length, exempt } (start is a 1-based line number). */
|
|
41
|
+
function findCommentBlocks(lines) {
|
|
42
|
+
const blocks = [];
|
|
43
|
+
let inBlock = false;
|
|
44
|
+
let current = null;
|
|
45
|
+
let seenCode = false;
|
|
46
|
+
const close = (endIndex) => {
|
|
47
|
+
if (!current)
|
|
48
|
+
return;
|
|
49
|
+
const next = lines.slice(endIndex + 1).find((l) => l.trim() !== '') ?? '';
|
|
50
|
+
const header = !seenCode && current.start <= 3;
|
|
51
|
+
const exportedDoc = current.jsdoc && /^\s*export\b/.test(next);
|
|
52
|
+
blocks.push({ start: current.start, length: current.length, exempt: header || exportedDoc });
|
|
53
|
+
current = null;
|
|
54
|
+
};
|
|
55
|
+
lines.forEach((line, i) => {
|
|
56
|
+
const t = line.trim();
|
|
57
|
+
const isComment = inBlock || t.startsWith('//') || t.startsWith('/*');
|
|
58
|
+
if (isComment) {
|
|
59
|
+
current ??= { start: i + 1, length: 0, jsdoc: t.startsWith('/**') };
|
|
60
|
+
current.length++;
|
|
61
|
+
if (t.startsWith('/*') && !t.includes('*/'))
|
|
62
|
+
inBlock = true;
|
|
63
|
+
if (inBlock && t.includes('*/'))
|
|
64
|
+
inBlock = false;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
close(i - 1);
|
|
68
|
+
if (t !== '')
|
|
69
|
+
seenCode = true;
|
|
70
|
+
});
|
|
71
|
+
close(lines.length - 1);
|
|
72
|
+
return blocks;
|
|
73
|
+
}
|
|
74
|
+
export function checkFileShape(file, text, s) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
const lines = text.split(/\r?\n/);
|
|
77
|
+
lines.forEach((line, i) => {
|
|
78
|
+
if (line.length > s.maxLineLength) {
|
|
79
|
+
issues.push({
|
|
80
|
+
file, line: i + 1, kind: 'long-line',
|
|
81
|
+
message: `line is ${line.length} characters (max ${s.maxLineLength})`,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
for (const b of findCommentBlocks(lines)) {
|
|
86
|
+
if (b.length > s.maxCommentLines && !b.exempt) {
|
|
87
|
+
issues.push({
|
|
88
|
+
file, line: b.start, kind: 'long-comment',
|
|
89
|
+
message: `comment block is ${b.length} lines (max ${s.maxCommentLines})`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return issues;
|
|
94
|
+
}
|
|
95
|
+
export function checkCodeShape(targetDir, targetFiles = []) {
|
|
96
|
+
const settings = loadCodeShapeSettings(targetDir);
|
|
97
|
+
const report = { mode: settings.mode, issues: [] };
|
|
98
|
+
if (settings.mode === 'off')
|
|
99
|
+
return report;
|
|
100
|
+
for (const file of targetFiles) {
|
|
101
|
+
if (!isCheckable(file))
|
|
102
|
+
continue;
|
|
103
|
+
const full = path.resolve(targetDir, file);
|
|
104
|
+
try {
|
|
105
|
+
if (!fs.statSync(full).isFile())
|
|
106
|
+
continue;
|
|
107
|
+
report.issues.push(...checkFileShape(file, fs.readFileSync(full, 'utf8'), settings));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// File missing or unreadable: skip.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return report;
|
|
114
|
+
}
|
|
115
|
+
/** Short plain-language summary; empty string when there is nothing to report. */
|
|
116
|
+
export function formatCodeShapeReport(report) {
|
|
117
|
+
if (report.mode === 'off' || report.issues.length === 0)
|
|
118
|
+
return '';
|
|
119
|
+
const total = report.issues.length;
|
|
120
|
+
const head = report.mode === 'block'
|
|
121
|
+
? `Code shape: ${total} issue(s) must be fixed before this task can pass.`
|
|
122
|
+
: `Code shape: ${total} style warning(s). Verification is not affected.`;
|
|
123
|
+
const shown = report.issues.slice(0, MAX_SHOWN).map((i) => ` ${i.file}:${i.line} ${i.message}`);
|
|
124
|
+
const more = total > MAX_SHOWN ? [` ...and ${total - MAX_SHOWN} more`] : [];
|
|
125
|
+
return [head, ...shown, ...more].join('\n');
|
|
126
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type MasterPlanTask, type TaskComplexity } from '../scanner/types.js';
|
|
2
|
+
export declare const DEFAULT_WORKER_MODELS: Record<TaskComplexity, string>;
|
|
3
|
+
/** Model alias for the task, or null when it has no complexity (behaviour stays unchanged). */
|
|
4
|
+
export declare function resolveWorkerModel(root: string, task: Pick<MasterPlanTask, 'complexity'>): string | null;
|
|
5
|
+
/** Turns an alias into a full model ID for the native engine; unknown values pass through. */
|
|
6
|
+
export declare function toNativeModelId(model: string, opusModelId: string): string;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Picks the worker model from a task's complexity.
|
|
3
|
+
* Overrides live under `workerModels` in .nativ/config.json. The separate `models` key is
|
|
4
|
+
* for nativ's own provider calls and is never read here.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { TASK_COMPLEXITIES } from '../scanner/types.js';
|
|
9
|
+
export const DEFAULT_WORKER_MODELS = {
|
|
10
|
+
simple: 'haiku',
|
|
11
|
+
standard: 'sonnet',
|
|
12
|
+
complex: 'opus',
|
|
13
|
+
};
|
|
14
|
+
// Full IDs for the native engine. Opus is passed in by the caller (its default model).
|
|
15
|
+
const NATIVE_MODEL_IDS = {
|
|
16
|
+
haiku: 'claude-haiku-4-5-20251001',
|
|
17
|
+
sonnet: 'claude-sonnet-4-6',
|
|
18
|
+
};
|
|
19
|
+
function loadWorkerModelOverrides(root) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(path.join(root, '.nativ', 'config.json'), 'utf8');
|
|
22
|
+
const overrides = JSON.parse(raw)?.workerModels;
|
|
23
|
+
if (!overrides || typeof overrides !== 'object')
|
|
24
|
+
return {};
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const level of TASK_COMPLEXITIES) {
|
|
27
|
+
const value = overrides[level];
|
|
28
|
+
if (typeof value === 'string' && value.trim())
|
|
29
|
+
result[level] = value.trim();
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Model alias for the task, or null when it has no complexity (behaviour stays unchanged). */
|
|
38
|
+
export function resolveWorkerModel(root, task) {
|
|
39
|
+
const level = task.complexity;
|
|
40
|
+
if (!level || !TASK_COMPLEXITIES.includes(level))
|
|
41
|
+
return null;
|
|
42
|
+
return loadWorkerModelOverrides(root)[level] ?? DEFAULT_WORKER_MODELS[level];
|
|
43
|
+
}
|
|
44
|
+
/** Turns an alias into a full model ID for the native engine; unknown values pass through. */
|
|
45
|
+
export function toNativeModelId(model, opusModelId) {
|
|
46
|
+
if (model === 'opus')
|
|
47
|
+
return opusModelId;
|
|
48
|
+
return NATIVE_MODEL_IDS[model] ?? model;
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface SpecSlice {
|
|
2
|
+
ref: string;
|
|
3
|
+
/** Path relative to the project root, forward slashes (e.g. ".ai/ui_specs.md"). */
|
|
4
|
+
file: string;
|
|
5
|
+
text: string;
|
|
6
|
+
truncated: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface SpecSliceResult {
|
|
9
|
+
slices: SpecSlice[];
|
|
10
|
+
warnings: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface SpecSliceOptions {
|
|
13
|
+
/** Cap per slice (default 4000 characters). */
|
|
14
|
+
maxChars?: number;
|
|
15
|
+
/** Cap across all slices (default 4x maxChars). */
|
|
16
|
+
maxTotalChars?: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const DEFAULT_SLICE_CHARS = 4000;
|
|
19
|
+
export declare const TRUNCATION_MARKER = "\n[... truncated ...]";
|
|
20
|
+
/** GitHub-style heading slug: lowercase, drop punctuation, spaces to hyphens. */
|
|
21
|
+
export declare function slugifyHeading(text: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Resolves specRefs ("ui_specs.md#anchor", "api_contracts.json#/paths/~1x",
|
|
24
|
+
* "api_contracts.json#GET /x" or "api_contracts.json#<endpoint id>") relative to
|
|
25
|
+
* <root>/.ai into size-capped slices. Unresolvable refs produce a warning and no slice.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveSpecSlices(root: string, specRefs: string[] | undefined, options?: SpecSliceOptions): SpecSliceResult;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const DEFAULT_SLICE_CHARS = 4000;
|
|
4
|
+
export const TRUNCATION_MARKER = '\n[... truncated ...]';
|
|
5
|
+
/** GitHub-style heading slug: lowercase, drop punctuation, spaces to hyphens. */
|
|
6
|
+
export function slugifyHeading(text) {
|
|
7
|
+
return text
|
|
8
|
+
.trim()
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[`*_~]/g, '')
|
|
11
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, '')
|
|
12
|
+
.replace(/\s/g, '-');
|
|
13
|
+
}
|
|
14
|
+
function cap(text, max) {
|
|
15
|
+
if (text.length <= max)
|
|
16
|
+
return { text, truncated: false };
|
|
17
|
+
return { text: text.slice(0, Math.max(0, max)) + TRUNCATION_MARKER, truncated: true };
|
|
18
|
+
}
|
|
19
|
+
function markdownSection(content, anchor) {
|
|
20
|
+
const wanted = decodeURIComponent(anchor).toLowerCase();
|
|
21
|
+
const lines = content.split(/\r?\n/);
|
|
22
|
+
const seen = new Map();
|
|
23
|
+
let inFence = false;
|
|
24
|
+
let start = -1;
|
|
25
|
+
let level = 0;
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
29
|
+
inFence = !inFence;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (inFence)
|
|
33
|
+
continue;
|
|
34
|
+
const m = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
|
|
35
|
+
if (!m)
|
|
36
|
+
continue;
|
|
37
|
+
const hLevel = m[1].length;
|
|
38
|
+
if (start >= 0) {
|
|
39
|
+
if (hLevel <= level)
|
|
40
|
+
return lines.slice(start, i).join('\n').trimEnd();
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const base = slugifyHeading(m[2]);
|
|
44
|
+
const count = seen.get(base) ?? 0;
|
|
45
|
+
seen.set(base, count + 1);
|
|
46
|
+
const slug = count === 0 ? base : `${base}-${count}`;
|
|
47
|
+
if (slug === wanted) {
|
|
48
|
+
start = i;
|
|
49
|
+
level = hLevel;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return start >= 0 ? lines.slice(start).join('\n').trimEnd() : null;
|
|
53
|
+
}
|
|
54
|
+
function jsonPointerValue(doc, pointer) {
|
|
55
|
+
if (pointer === '')
|
|
56
|
+
return { found: true, value: doc };
|
|
57
|
+
if (!pointer.startsWith('/'))
|
|
58
|
+
return { found: false };
|
|
59
|
+
let current = doc;
|
|
60
|
+
for (const raw of pointer.slice(1).split('/')) {
|
|
61
|
+
let token;
|
|
62
|
+
try {
|
|
63
|
+
token = decodeURIComponent(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
token = raw;
|
|
67
|
+
}
|
|
68
|
+
token = token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
69
|
+
if (Array.isArray(current)) {
|
|
70
|
+
if (!/^(0|[1-9]\d*)$/.test(token) || Number(token) >= current.length)
|
|
71
|
+
return { found: false };
|
|
72
|
+
current = current[Number(token)];
|
|
73
|
+
}
|
|
74
|
+
else if (current && typeof current === 'object' && Object.prototype.hasOwnProperty.call(current, token)) {
|
|
75
|
+
current = current[token];
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return { found: false };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { found: true, value: current };
|
|
82
|
+
}
|
|
83
|
+
function joinUrl(base, p) {
|
|
84
|
+
return typeof base === 'string' ? base.replace(/\/+$/, '') + p : p;
|
|
85
|
+
}
|
|
86
|
+
/** Finds an endpoint by "GET /path" (with or without baseUrl) or by its id. */
|
|
87
|
+
function endpointValue(doc, fragment) {
|
|
88
|
+
let ref = fragment;
|
|
89
|
+
try {
|
|
90
|
+
ref = decodeURIComponent(fragment);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// keep the raw text when it is not valid percent-encoding
|
|
94
|
+
}
|
|
95
|
+
ref = ref.trim();
|
|
96
|
+
const call = /^([A-Za-z]+)\s+(\/\S*)$/.exec(ref);
|
|
97
|
+
const list = Array.isArray(doc?.endpoints) ? doc.endpoints : [];
|
|
98
|
+
if (call) {
|
|
99
|
+
const method = call[1].toLowerCase();
|
|
100
|
+
const want = call[2];
|
|
101
|
+
const hit = list.find((e) => {
|
|
102
|
+
if (!e || typeof e.path !== 'string' || String(e.method).toLowerCase() !== method)
|
|
103
|
+
return false;
|
|
104
|
+
return e.path === want || joinUrl(doc.baseUrl, e.path) === want;
|
|
105
|
+
});
|
|
106
|
+
if (hit)
|
|
107
|
+
return { found: true, value: hit };
|
|
108
|
+
const item = doc?.paths && typeof doc.paths === 'object' ? doc.paths[want] : undefined;
|
|
109
|
+
if (item && typeof item === 'object' && Object.prototype.hasOwnProperty.call(item, method)) {
|
|
110
|
+
return { found: true, value: item[method] };
|
|
111
|
+
}
|
|
112
|
+
return { found: false };
|
|
113
|
+
}
|
|
114
|
+
const byId = list.find((e) => e && typeof e.id === 'string' && e.id === ref);
|
|
115
|
+
return byId ? { found: true, value: byId } : { found: false };
|
|
116
|
+
}
|
|
117
|
+
function jsonRefValue(doc, fragment) {
|
|
118
|
+
if (fragment === '' || fragment.startsWith('/'))
|
|
119
|
+
return jsonPointerValue(doc, fragment);
|
|
120
|
+
return endpointValue(doc, fragment);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolves specRefs ("ui_specs.md#anchor", "api_contracts.json#/paths/~1x",
|
|
124
|
+
* "api_contracts.json#GET /x" or "api_contracts.json#<endpoint id>") relative to
|
|
125
|
+
* <root>/.ai into size-capped slices. Unresolvable refs produce a warning and no slice.
|
|
126
|
+
*/
|
|
127
|
+
export function resolveSpecSlices(root, specRefs, options = {}) {
|
|
128
|
+
const slices = [];
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const maxChars = options.maxChars ?? DEFAULT_SLICE_CHARS;
|
|
131
|
+
const maxTotal = options.maxTotalChars ?? maxChars * 4;
|
|
132
|
+
const aiDir = path.resolve(root, '.ai');
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const ref of specRefs ?? []) {
|
|
135
|
+
if (typeof ref !== 'string' || !ref.trim())
|
|
136
|
+
continue;
|
|
137
|
+
const hash = ref.indexOf('#');
|
|
138
|
+
const filePart = (hash >= 0 ? ref.slice(0, hash) : ref).trim();
|
|
139
|
+
const fragment = hash >= 0 ? ref.slice(hash + 1) : '';
|
|
140
|
+
const full = path.resolve(aiDir, filePart);
|
|
141
|
+
const rel = path.relative(aiDir, full);
|
|
142
|
+
if (!filePart || rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
143
|
+
warnings.push(`specRef "${ref}" points outside .ai/ and was ignored`);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
let content;
|
|
147
|
+
try {
|
|
148
|
+
content = fs.readFileSync(full, 'utf8');
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
warnings.push(`specRef "${ref}": file .ai/${rel.split(path.sep).join('/')} not found`);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
let body;
|
|
155
|
+
if (/\.json$/i.test(filePart)) {
|
|
156
|
+
try {
|
|
157
|
+
const found = jsonRefValue(JSON.parse(content), fragment);
|
|
158
|
+
body = found.found ? JSON.stringify(found.value, null, 2) ?? 'null' : null;
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
warnings.push(`specRef "${ref}": .ai/${rel.split(path.sep).join('/')} is not valid JSON`);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (body === null) {
|
|
165
|
+
warnings.push(`specRef "${ref}": JSON reference "${fragment}" not found`);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (fragment) {
|
|
170
|
+
try {
|
|
171
|
+
body = markdownSection(content, fragment);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
body = null;
|
|
175
|
+
}
|
|
176
|
+
if (body === null) {
|
|
177
|
+
warnings.push(`specRef "${ref}": heading anchor "#${fragment}" not found`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
body = content;
|
|
183
|
+
}
|
|
184
|
+
const remaining = maxTotal - total;
|
|
185
|
+
if (remaining <= 0) {
|
|
186
|
+
warnings.push(`specRef "${ref}" omitted: total spec slice budget (${maxTotal} characters) exhausted`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const capped = cap(body, Math.min(maxChars, remaining));
|
|
190
|
+
total += capped.text.length;
|
|
191
|
+
slices.push({ ref, file: `.ai/${rel.split(path.sep).join('/')}`, text: capped.text, truncated: capped.truncated });
|
|
192
|
+
}
|
|
193
|
+
return { slices, warnings };
|
|
194
|
+
}
|
package/dist/core/verifier.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { MasterPlan } from '../scanner/types.js';
|
|
1
|
+
import { MasterPlan, MasterPlanTask } from '../scanner/types.js';
|
|
2
|
+
import { type CodeShapeReport } from './code-shape.js';
|
|
2
3
|
export interface VerificationResult {
|
|
3
4
|
success: boolean;
|
|
4
5
|
exitCode: number;
|
|
@@ -8,6 +9,7 @@ export interface VerificationResult {
|
|
|
8
9
|
durationMs: number;
|
|
9
10
|
error?: string;
|
|
10
11
|
skipped?: boolean;
|
|
12
|
+
codeShape?: CodeShapeReport;
|
|
11
13
|
}
|
|
12
14
|
export interface TaskVerificationResult {
|
|
13
15
|
taskId: string;
|
|
@@ -35,6 +37,11 @@ export declare function loadMasterPlan(targetDir: string): {
|
|
|
35
37
|
* Execute a verification command in the workspace directory with timeout and output capture.
|
|
36
38
|
*/
|
|
37
39
|
export declare function executeVerification(command: string, cwd: string, timeoutMs?: number): Promise<VerificationResult>;
|
|
40
|
+
/**
|
|
41
|
+
* Run the code-shape check on a task's target files and print a short summary to stderr.
|
|
42
|
+
* In 'warn' mode (default) the result is untouched; in 'block' mode issues fail it with a reason.
|
|
43
|
+
*/
|
|
44
|
+
export declare function applyCodeShape(result: VerificationResult, targetDir: string, task: MasterPlanTask): VerificationResult;
|
|
38
45
|
/**
|
|
39
46
|
* Run verification for a single specific task in the plan.
|
|
40
47
|
*/
|