@njeybe/nativ 2.3.1 → 2.4.1
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 +55 -1
- package/README.md +676 -190
- package/dist/commands/db.js +3 -0
- package/dist/commands/doctor.d.ts +2 -1
- package/dist/commands/doctor.js +4 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/learn.d.ts +19 -0
- package/dist/commands/learn.js +121 -0
- package/dist/commands/session.d.ts +22 -0
- package/dist/commands/session.js +106 -0
- package/dist/commands/task.js +34 -7
- package/dist/commands/verify.js +6 -0
- package/dist/core/code-shape.js +4 -2
- package/dist/core/enforcement.d.ts +1 -0
- package/dist/core/enforcement.js +22 -4
- package/dist/core/harness-integrity.d.ts +15 -0
- package/dist/core/harness-integrity.js +198 -0
- package/dist/core/human-gate.d.ts +7 -0
- package/dist/core/human-gate.js +16 -0
- package/dist/core/learnings.d.ts +63 -0
- package/dist/core/learnings.js +227 -0
- package/dist/core/root-resolver.d.ts +5 -0
- package/dist/core/root-resolver.js +46 -0
- package/dist/core/session-memory.d.ts +42 -0
- package/dist/core/session-memory.js +121 -0
- package/dist/core/setup-assets.d.ts +1 -1
- package/dist/core/setup-assets.js +5 -2
- package/dist/core/spec-slices.js +119 -7
- package/dist/core/verifier.d.ts +36 -0
- package/dist/core/verifier.js +92 -4
- package/dist/db/introspector.js +59 -59
- package/dist/governor/store.d.ts +18 -0
- package/dist/governor/store.js +53 -0
- package/dist/index.js +41 -0
- package/dist/mcp/server.js +12 -0
- package/dist/runner/agent-supervisor.d.ts +16 -0
- package/dist/runner/agent-supervisor.js +63 -8
- package/dist/scanner/types.d.ts +1 -0
- package/dist/server/studio-ui/shell/model.js +3 -1
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/agents/architect.md +3 -3
- package/plugin/agents/explorer.md +49 -0
- package/plugin/agents/worker.md +2 -1
- package/plugin/skills/nativ/SKILL.md +15 -0
- package/templates/AGENTS.md +15 -0
- package/templates/CLAUDE.md +32 -32
- package/templates/GEMINI.md +2 -2
- package/templates/claude-agents/architect.md +3 -3
- package/templates/claude-agents/explorer.md +49 -0
- package/templates/claude-agents/worker.md +2 -1
- package/templates/dot-ai/api_contracts.json +66 -66
- package/templates/dot-ai/context.md +38 -38
- package/templates/dot-ai/db_schema.json +39 -39
- package/templates/dot-ai/subagents/frontend.md +33 -0
- package/templates/dot-ai/ui_specs.md +17 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export function getSessionMemoryDir(targetDir) {
|
|
4
|
+
return path.join(targetDir, '.ai', 'memory', 'sessions');
|
|
5
|
+
}
|
|
6
|
+
export function getSessionIndexPath(targetDir) {
|
|
7
|
+
return path.join(getSessionMemoryDir(targetDir), 'index.json');
|
|
8
|
+
}
|
|
9
|
+
export function getLatestSessionPath(targetDir) {
|
|
10
|
+
return path.join(getSessionMemoryDir(targetDir), 'latest.json');
|
|
11
|
+
}
|
|
12
|
+
export function loadSessionIndex(targetDir) {
|
|
13
|
+
const indexPath = getSessionIndexPath(targetDir);
|
|
14
|
+
if (!fs.existsSync(indexPath)) {
|
|
15
|
+
return {
|
|
16
|
+
version: '1.0.0',
|
|
17
|
+
updatedAt: new Date().toISOString(),
|
|
18
|
+
sessions: [],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = fs.readFileSync(indexPath, 'utf8');
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (!Array.isArray(parsed.sessions)) {
|
|
25
|
+
return {
|
|
26
|
+
version: '1.0.0',
|
|
27
|
+
updatedAt: new Date().toISOString(),
|
|
28
|
+
sessions: [],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {
|
|
35
|
+
version: '1.0.0',
|
|
36
|
+
updatedAt: new Date().toISOString(),
|
|
37
|
+
sessions: [],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function saveSessionIndex(targetDir, index) {
|
|
42
|
+
const dir = getSessionMemoryDir(targetDir);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
index.updatedAt = new Date().toISOString();
|
|
47
|
+
fs.writeFileSync(getSessionIndexPath(targetDir), JSON.stringify(index, null, 2), 'utf8');
|
|
48
|
+
}
|
|
49
|
+
export function saveSessionCheckpoint(targetDir, input) {
|
|
50
|
+
const dir = getSessionMemoryDir(targetDir);
|
|
51
|
+
if (!fs.existsSync(dir)) {
|
|
52
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const dateStr = now.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
56
|
+
const sessionId = input.sessionId || `sess-${dateStr}`;
|
|
57
|
+
const checkpoint = {
|
|
58
|
+
sessionId,
|
|
59
|
+
timestamp: now.toISOString(),
|
|
60
|
+
...(input.role ? { role: input.role.trim() } : {}),
|
|
61
|
+
...(input.taskId ? { taskId: input.taskId.trim() } : {}),
|
|
62
|
+
summary: input.summary.trim(),
|
|
63
|
+
...(input.decisions && input.decisions.length > 0 ? { decisions: input.decisions.map((d) => d.trim()) } : {}),
|
|
64
|
+
...(input.obstacles && input.obstacles.length > 0 ? { obstacles: input.obstacles.map((o) => o.trim()) } : {}),
|
|
65
|
+
...(input.nextSteps && input.nextSteps.length > 0 ? { nextSteps: input.nextSteps.map((n) => n.trim()) } : {}),
|
|
66
|
+
...(input.activeFiles && input.activeFiles.length > 0 ? { activeFiles: input.activeFiles.map((f) => f.trim()) } : {}),
|
|
67
|
+
};
|
|
68
|
+
// Write individual session file
|
|
69
|
+
const sessionPath = path.join(dir, `${sessionId}.json`);
|
|
70
|
+
fs.writeFileSync(sessionPath, JSON.stringify(checkpoint, null, 2), 'utf8');
|
|
71
|
+
// Write latest.json
|
|
72
|
+
fs.writeFileSync(getLatestSessionPath(targetDir), JSON.stringify(checkpoint, null, 2), 'utf8');
|
|
73
|
+
// Update index
|
|
74
|
+
const index = loadSessionIndex(targetDir);
|
|
75
|
+
index.latestSessionId = sessionId;
|
|
76
|
+
// Remove existing entry with same sessionId if updating
|
|
77
|
+
index.sessions = index.sessions.filter((s) => s.sessionId !== sessionId);
|
|
78
|
+
index.sessions.unshift({
|
|
79
|
+
sessionId,
|
|
80
|
+
timestamp: checkpoint.timestamp,
|
|
81
|
+
role: checkpoint.role,
|
|
82
|
+
taskId: checkpoint.taskId,
|
|
83
|
+
summary: checkpoint.summary,
|
|
84
|
+
});
|
|
85
|
+
// Keep index trimmed to 50 most recent sessions
|
|
86
|
+
if (index.sessions.length > 50) {
|
|
87
|
+
index.sessions = index.sessions.slice(0, 50);
|
|
88
|
+
}
|
|
89
|
+
saveSessionIndex(targetDir, index);
|
|
90
|
+
return checkpoint;
|
|
91
|
+
}
|
|
92
|
+
export function getLatestSession(targetDir) {
|
|
93
|
+
const latestPath = getLatestSessionPath(targetDir);
|
|
94
|
+
if (!fs.existsSync(latestPath)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
const raw = fs.readFileSync(latestPath, 'utf8');
|
|
99
|
+
return JSON.parse(raw);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function getSession(targetDir, sessionId) {
|
|
106
|
+
const sessionPath = path.join(getSessionMemoryDir(targetDir), `${sessionId}.json`);
|
|
107
|
+
if (!fs.existsSync(sessionPath)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const raw = fs.readFileSync(sessionPath, 'utf8');
|
|
112
|
+
return JSON.parse(raw);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function listSessions(targetDir, limit = 10) {
|
|
119
|
+
const index = loadSessionIndex(targetDir);
|
|
120
|
+
return index.sessions.slice(0, limit);
|
|
121
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { type EnforcementMode } from './enforcement.js';
|
|
9
9
|
export declare const MCP_SERVER_NAME = "nativ";
|
|
10
|
-
export declare const AGENT_FILES: readonly ["architect.md", "worker.md", "verifier.md"];
|
|
10
|
+
export declare const AGENT_FILES: readonly ["architect.md", "worker.md", "verifier.md", "explorer.md"];
|
|
11
11
|
export type AssetAction = 'created' | 'updated' | 'unchanged' | 'skipped';
|
|
12
12
|
export interface AssetChange {
|
|
13
13
|
/** Path relative to the project root, forward slashes. */
|
|
@@ -15,7 +15,7 @@ import { resolveProjectRoot } from './root-resolver.js';
|
|
|
15
15
|
import { isLegacyTemplate, normalizeForHash } from './legacy-templates.js';
|
|
16
16
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
export const MCP_SERVER_NAME = 'nativ';
|
|
18
|
-
export const AGENT_FILES = ['architect.md', 'worker.md', 'verifier.md'];
|
|
18
|
+
export const AGENT_FILES = ['architect.md', 'worker.md', 'verifier.md', 'explorer.md'];
|
|
19
19
|
const MANAGED_PREFIX = '<!-- nativ:managed sha256=';
|
|
20
20
|
const MANAGED_PATTERN = /\n?<!-- nativ:managed sha256=([0-9a-f]{12}) -->\s*$/;
|
|
21
21
|
const PRE_TOOL_USE_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit';
|
|
@@ -60,6 +60,7 @@ const SECRET_READ_DENY = [
|
|
|
60
60
|
'Read(./.env.production.local)',
|
|
61
61
|
'Read(./**/*.pem)',
|
|
62
62
|
'Read(./**/*.key)',
|
|
63
|
+
'Read(./.nativ/*.local.json)',
|
|
63
64
|
];
|
|
64
65
|
export function buildDesiredConfig(invocation) {
|
|
65
66
|
const cli = cliString(invocation);
|
|
@@ -72,6 +73,8 @@ export function buildDesiredConfig(invocation) {
|
|
|
72
73
|
deny: [
|
|
73
74
|
...prefixes.flatMap((p) => [
|
|
74
75
|
`Bash(${p} task unlock *)`,
|
|
76
|
+
`Bash(${p} learn approve *)`,
|
|
77
|
+
`Bash(${p} learn reject *)`,
|
|
75
78
|
`Bash(${p} db sync *)`,
|
|
76
79
|
`Bash(${p} task complete * --no-verify)`,
|
|
77
80
|
`Bash(${p} task complete * --no-verify *)`,
|
|
@@ -79,7 +82,7 @@ export function buildDesiredConfig(invocation) {
|
|
|
79
82
|
...SECRET_READ_DENY,
|
|
80
83
|
],
|
|
81
84
|
// Contract edits always need a human's yes, whichever agent asks.
|
|
82
|
-
ask: ['Edit(./.ai/**)', 'Write(./.ai/**)'],
|
|
85
|
+
ask: ['Edit(./.ai/**)', 'Write(./.ai/**)', 'Edit(./.nativ/**)', 'Write(./.nativ/**)'],
|
|
83
86
|
preToolUse: { matcher: PRE_TOOL_USE_MATCHER, command: `${cli} hook check`, timeout: HOOK_TIMEOUT_SECONDS },
|
|
84
87
|
sessionStart: { matcher: SESSION_START_MATCHER, command: `${cli} hook context`, timeout: HOOK_TIMEOUT_SECONDS },
|
|
85
88
|
};
|
package/dist/core/spec-slices.js
CHANGED
|
@@ -16,9 +16,7 @@ function cap(text, max) {
|
|
|
16
16
|
return { text, truncated: false };
|
|
17
17
|
return { text: text.slice(0, Math.max(0, max)) + TRUNCATION_MARKER, truncated: true };
|
|
18
18
|
}
|
|
19
|
-
function
|
|
20
|
-
const wanted = decodeURIComponent(anchor).toLowerCase();
|
|
21
|
-
const lines = content.split(/\r?\n/);
|
|
19
|
+
function findSection(lines, predicate) {
|
|
22
20
|
const seen = new Map();
|
|
23
21
|
let inFence = false;
|
|
24
22
|
let start = -1;
|
|
@@ -40,17 +38,41 @@ function markdownSection(content, anchor) {
|
|
|
40
38
|
return lines.slice(start, i).join('\n').trimEnd();
|
|
41
39
|
continue;
|
|
42
40
|
}
|
|
43
|
-
const
|
|
41
|
+
const headingText = m[2];
|
|
42
|
+
const base = slugifyHeading(headingText);
|
|
44
43
|
const count = seen.get(base) ?? 0;
|
|
45
44
|
seen.set(base, count + 1);
|
|
46
45
|
const slug = count === 0 ? base : `${base}-${count}`;
|
|
47
|
-
if (slug
|
|
46
|
+
if (predicate(slug, headingText)) {
|
|
48
47
|
start = i;
|
|
49
48
|
level = hLevel;
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
return start >= 0 ? lines.slice(start).join('\n').trimEnd() : null;
|
|
53
52
|
}
|
|
53
|
+
function markdownSection(content, anchor) {
|
|
54
|
+
const wanted = decodeURIComponent(anchor).trim().toLowerCase();
|
|
55
|
+
const lines = content.split(/\r?\n/);
|
|
56
|
+
// 1. Exact slug match (standard markdown anchor)
|
|
57
|
+
const exact = findSection(lines, (slug) => slug === wanted);
|
|
58
|
+
if (exact !== null)
|
|
59
|
+
return exact;
|
|
60
|
+
// 2. Prefix / section number match (e.g. "6" or "15.1" matching "## 6. Coach..." or "### 15.1 Shared Pieces")
|
|
61
|
+
const prefixMatch = findSection(lines, (_slug, headingText) => {
|
|
62
|
+
const raw = headingText.trim().toLowerCase();
|
|
63
|
+
if (raw === wanted)
|
|
64
|
+
return true;
|
|
65
|
+
if (raw.startsWith(`${wanted}.`) || raw.startsWith(`${wanted} `) || raw.startsWith(`${wanted}:`))
|
|
66
|
+
return true;
|
|
67
|
+
const base = slugifyHeading(headingText);
|
|
68
|
+
if (base.startsWith(`${wanted}-`) || base === wanted)
|
|
69
|
+
return true;
|
|
70
|
+
return false;
|
|
71
|
+
});
|
|
72
|
+
if (prefixMatch !== null)
|
|
73
|
+
return prefixMatch;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
54
76
|
function jsonPointerValue(doc, pointer) {
|
|
55
77
|
if (pointer === '')
|
|
56
78
|
return { found: true, value: doc };
|
|
@@ -80,6 +102,73 @@ function jsonPointerValue(doc, pointer) {
|
|
|
80
102
|
}
|
|
81
103
|
return { found: true, value: current };
|
|
82
104
|
}
|
|
105
|
+
function getByDotPath(obj, pathStr) {
|
|
106
|
+
const parts = pathStr.split('.');
|
|
107
|
+
let cur = obj;
|
|
108
|
+
for (const p of parts) {
|
|
109
|
+
if (cur && typeof cur === 'object' && Object.prototype.hasOwnProperty.call(cur, p)) {
|
|
110
|
+
cur = cur[p];
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
return { found: false };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { found: true, value: cur };
|
|
117
|
+
}
|
|
118
|
+
function findEntityInDoc(doc, ref) {
|
|
119
|
+
if (!doc || typeof doc !== 'object')
|
|
120
|
+
return { found: false };
|
|
121
|
+
// 1. Direct table match in relevantTables or tables
|
|
122
|
+
const tables = Array.isArray(doc.relevantTables) ? doc.relevantTables : (Array.isArray(doc.tables) ? doc.tables : null);
|
|
123
|
+
if (tables) {
|
|
124
|
+
const table = tables.find((t) => t && (t.name === ref || t.tableName === ref || t.id === ref));
|
|
125
|
+
if (table)
|
|
126
|
+
return { found: true, value: table };
|
|
127
|
+
}
|
|
128
|
+
if (doc.tables && typeof doc.tables === 'object' && !Array.isArray(doc.tables) && Object.prototype.hasOwnProperty.call(doc.tables, ref)) {
|
|
129
|
+
return { found: true, value: doc.tables[ref] };
|
|
130
|
+
}
|
|
131
|
+
// 2. Mail events trigger match
|
|
132
|
+
if (Array.isArray(doc.mailEvents)) {
|
|
133
|
+
const event = doc.mailEvents.find((m) => m && (m.trigger === ref || m.endpoint === ref || m.id === ref));
|
|
134
|
+
if (event)
|
|
135
|
+
return { found: true, value: event };
|
|
136
|
+
}
|
|
137
|
+
// 3. Routes match in routingPolicy or routes
|
|
138
|
+
const routeLists = [];
|
|
139
|
+
if (doc.routingPolicy && typeof doc.routingPolicy === 'object') {
|
|
140
|
+
if (Array.isArray(doc.routingPolicy.playerRoutes))
|
|
141
|
+
routeLists.push(doc.routingPolicy.playerRoutes);
|
|
142
|
+
if (Array.isArray(doc.routingPolicy.venueScopedRoutes))
|
|
143
|
+
routeLists.push(doc.routingPolicy.venueScopedRoutes);
|
|
144
|
+
if (Array.isArray(doc.routingPolicy.adminRoutes))
|
|
145
|
+
routeLists.push(doc.routingPolicy.adminRoutes);
|
|
146
|
+
if (Array.isArray(doc.routingPolicy.routes))
|
|
147
|
+
routeLists.push(doc.routingPolicy.routes);
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(doc.routes))
|
|
150
|
+
routeLists.push(doc.routes);
|
|
151
|
+
for (const list of routeLists) {
|
|
152
|
+
const route = list.find((r) => r && (r.name === ref ||
|
|
153
|
+
r.id === ref ||
|
|
154
|
+
(Array.isArray(r.routeNames) && r.routeNames.includes(ref))));
|
|
155
|
+
if (route)
|
|
156
|
+
return { found: true, value: route };
|
|
157
|
+
}
|
|
158
|
+
// 4. Contract list items (e.g. confirmationPageContracts array)
|
|
159
|
+
for (const key of Object.keys(doc)) {
|
|
160
|
+
const val = doc[key];
|
|
161
|
+
if (Array.isArray(val)) {
|
|
162
|
+
const match = val.find((item) => item && typeof item === 'object' && (item.name === ref ||
|
|
163
|
+
item.id === ref ||
|
|
164
|
+
item.trigger === ref ||
|
|
165
|
+
(Array.isArray(item.routeNames) && item.routeNames.includes(ref))));
|
|
166
|
+
if (match)
|
|
167
|
+
return { found: true, value: match };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { found: false };
|
|
171
|
+
}
|
|
83
172
|
function joinUrl(base, p) {
|
|
84
173
|
return typeof base === 'string' ? base.replace(/\/+$/, '') + p : p;
|
|
85
174
|
}
|
|
@@ -115,9 +204,32 @@ function endpointValue(doc, fragment) {
|
|
|
115
204
|
return byId ? { found: true, value: byId } : { found: false };
|
|
116
205
|
}
|
|
117
206
|
function jsonRefValue(doc, fragment) {
|
|
118
|
-
if (fragment === ''
|
|
207
|
+
if (fragment === '')
|
|
119
208
|
return jsonPointerValue(doc, fragment);
|
|
120
|
-
|
|
209
|
+
// 1. Direct JSON pointer starting with /
|
|
210
|
+
if (fragment.startsWith('/')) {
|
|
211
|
+
const ptr = jsonPointerValue(doc, fragment);
|
|
212
|
+
if (ptr.found)
|
|
213
|
+
return ptr;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
// 2. JSON pointer or top-level property without leading slash (e.g. #confirmationPageContracts)
|
|
217
|
+
const ptr = jsonPointerValue(doc, `/${fragment}`);
|
|
218
|
+
if (ptr.found)
|
|
219
|
+
return ptr;
|
|
220
|
+
// 3. Dot-path property navigation (e.g. #contract.section)
|
|
221
|
+
if (fragment.includes('.')) {
|
|
222
|
+
const dot = getByDotPath(doc, fragment);
|
|
223
|
+
if (dot.found)
|
|
224
|
+
return dot;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// 4. Endpoint lookup by method + path or id
|
|
228
|
+
const endpoint = endpointValue(doc, fragment);
|
|
229
|
+
if (endpoint.found)
|
|
230
|
+
return endpoint;
|
|
231
|
+
// 5. Named entity lookup: route names, mail event triggers, DB schema tables
|
|
232
|
+
return findEntityInDoc(doc, fragment);
|
|
121
233
|
}
|
|
122
234
|
/**
|
|
123
235
|
* Resolves specRefs ("ui_specs.md#anchor", "api_contracts.json#/paths/~1x",
|
package/dist/core/verifier.d.ts
CHANGED
|
@@ -10,6 +10,14 @@ export interface VerificationResult {
|
|
|
10
10
|
error?: string;
|
|
11
11
|
skipped?: boolean;
|
|
12
12
|
codeShape?: CodeShapeReport;
|
|
13
|
+
/** Results of the configured `verifyPhases`, when any ran. */
|
|
14
|
+
phases?: PhaseResult[];
|
|
15
|
+
}
|
|
16
|
+
export interface PhaseResult {
|
|
17
|
+
name: string;
|
|
18
|
+
command: string;
|
|
19
|
+
success: boolean;
|
|
20
|
+
durationMs: number;
|
|
13
21
|
}
|
|
14
22
|
export interface TaskVerificationResult {
|
|
15
23
|
taskId: string;
|
|
@@ -42,6 +50,34 @@ export declare function executeVerification(command: string, cwd: string, timeou
|
|
|
42
50
|
* In 'warn' mode (default) the result is untouched; in 'block' mode issues fail it with a reason.
|
|
43
51
|
*/
|
|
44
52
|
export declare function applyCodeShape(result: VerificationResult, targetDir: string, task: MasterPlanTask): VerificationResult;
|
|
53
|
+
export interface VerifyPhase {
|
|
54
|
+
name: string;
|
|
55
|
+
run: string;
|
|
56
|
+
}
|
|
57
|
+
/** Configured phases, or an error when the config exists but cannot be trusted to say what they are. */
|
|
58
|
+
export interface VerifyPhasesConfig {
|
|
59
|
+
phases: VerifyPhase[];
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
/** `verifyPhases` from the main checkout's `.nativ/config.json`, never from a worktree's own copy. */
|
|
63
|
+
export declare function loadVerifyPhases(configDir: string): VerifyPhasesConfig;
|
|
64
|
+
/** Outcome of running the configured phases once; shared by every task in a batch. */
|
|
65
|
+
export interface PhasesOutcome {
|
|
66
|
+
reports: PhaseResult[];
|
|
67
|
+
failure?: VerificationResult;
|
|
68
|
+
}
|
|
69
|
+
/** Runs the configured phases in order and stops at the first failure. */
|
|
70
|
+
export declare function runVerifyPhases(cwd: string, configDir: string, timeout?: number): Promise<PhasesOutcome>;
|
|
71
|
+
/**
|
|
72
|
+
* The one verification every path shares: the configured phases in order, stopping at the
|
|
73
|
+
* first failure, then the task's own command, then the code-shape check.
|
|
74
|
+
*/
|
|
75
|
+
export declare function runTaskVerification(task: MasterPlanTask, options: {
|
|
76
|
+
cwd: string;
|
|
77
|
+
configDir?: string;
|
|
78
|
+
timeout?: number;
|
|
79
|
+
phases?: PhasesOutcome;
|
|
80
|
+
}): Promise<VerificationResult>;
|
|
45
81
|
/**
|
|
46
82
|
* Run verification for a single specific task in the plan.
|
|
47
83
|
*/
|
package/dist/core/verifier.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { exec } from 'node:child_process';
|
|
4
4
|
import { checkCodeShape, formatCodeShapeReport } from './code-shape.js';
|
|
5
|
+
import { parseJsonLoose } from './enforcement.js';
|
|
6
|
+
import { resolveMainRoot } from './root-resolver.js';
|
|
5
7
|
export function loadMasterPlan(targetDir) {
|
|
6
8
|
const planPath = path.join(targetDir, '.ai', 'master_plan.json');
|
|
7
9
|
if (!fs.existsSync(planPath)) {
|
|
@@ -98,6 +100,91 @@ export function applyCodeShape(result, targetDir, task) {
|
|
|
98
100
|
}
|
|
99
101
|
return result;
|
|
100
102
|
}
|
|
103
|
+
/** Reads the file, retrying once: antivirus or an editor saving can briefly lock it on Windows. */
|
|
104
|
+
function readConfigText(file) {
|
|
105
|
+
for (let attempt = 0;; attempt++) {
|
|
106
|
+
try {
|
|
107
|
+
return fs.readFileSync(file, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (err?.code === 'ENOENT')
|
|
111
|
+
return null;
|
|
112
|
+
if (attempt >= 1)
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** `verifyPhases` from the main checkout's `.nativ/config.json`, never from a worktree's own copy. */
|
|
118
|
+
export function loadVerifyPhases(configDir) {
|
|
119
|
+
let raw;
|
|
120
|
+
try {
|
|
121
|
+
raw = readConfigText(path.join(resolveMainRoot(configDir), '.nativ', 'config.json'));
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
return { phases: [], error: `.nativ/config.json cannot be read (${err?.message})` };
|
|
125
|
+
}
|
|
126
|
+
if (raw === null)
|
|
127
|
+
return { phases: [] };
|
|
128
|
+
if (!raw.trim())
|
|
129
|
+
return { phases: [], error: '.nativ/config.json is empty' };
|
|
130
|
+
let phases;
|
|
131
|
+
try {
|
|
132
|
+
phases = parseJsonLoose(raw)?.verifyPhases;
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
return { phases: [], error: `.nativ/config.json is not valid JSON (${err?.message})` };
|
|
136
|
+
}
|
|
137
|
+
if (phases === undefined || phases === null)
|
|
138
|
+
return { phases: [] };
|
|
139
|
+
// An empty or null "run" switches a phase off; a missing or mistyped field is a mistake.
|
|
140
|
+
const isEntry = (p) => typeof p?.name === 'string' && p.name.trim()
|
|
141
|
+
&& (p.run === null || typeof p.run === 'string');
|
|
142
|
+
if (!Array.isArray(phases) || !phases.every(isEntry)) {
|
|
143
|
+
return { phases: [], error: 'verifyPhases must be a list of { "name": "...", "run": "..." } entries' };
|
|
144
|
+
}
|
|
145
|
+
const active = phases.filter((p) => p.run && p.run.trim());
|
|
146
|
+
return { phases: active.map((p) => ({ name: p.name.trim(), run: p.run.trim() })) };
|
|
147
|
+
}
|
|
148
|
+
function configFailure(message) {
|
|
149
|
+
const error = `${message}, so the configured checks could not run. Fix it and verify again.`;
|
|
150
|
+
const command = '.nativ/config.json';
|
|
151
|
+
return { success: false, exitCode: 1, command, stdout: '', stderr: error, durationMs: 0, error };
|
|
152
|
+
}
|
|
153
|
+
/** Runs the configured phases in order and stops at the first failure. */
|
|
154
|
+
export async function runVerifyPhases(cwd, configDir, timeout) {
|
|
155
|
+
const config = loadVerifyPhases(configDir);
|
|
156
|
+
if (config.error)
|
|
157
|
+
return { reports: [], failure: configFailure(config.error) };
|
|
158
|
+
const reports = [];
|
|
159
|
+
for (const phase of config.phases) {
|
|
160
|
+
const res = await executeVerification(phase.run, cwd, timeout);
|
|
161
|
+
reports.push({ name: phase.name, command: res.command, success: res.success, durationMs: res.durationMs });
|
|
162
|
+
if (!res.success) {
|
|
163
|
+
const cause = res.error ? `: ${res.error.trim()}` : '';
|
|
164
|
+
return { reports, failure: { ...res, phases: reports, error: `Phase "${phase.name}" failed${cause}` } };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { reports };
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* The one verification every path shares: the configured phases in order, stopping at the
|
|
171
|
+
* first failure, then the task's own command, then the code-shape check.
|
|
172
|
+
*/
|
|
173
|
+
export async function runTaskVerification(task, options) {
|
|
174
|
+
const configDir = options.configDir ?? options.cwd;
|
|
175
|
+
const outcome = options.phases ?? (await runVerifyPhases(options.cwd, configDir, options.timeout));
|
|
176
|
+
// In a batch the shared phase time is counted once, by the batch, not again for every task.
|
|
177
|
+
if (outcome.failure) {
|
|
178
|
+
return { ...outcome.failure, phases: outcome.reports, ...(options.phases ? { durationMs: 0 } : {}) };
|
|
179
|
+
}
|
|
180
|
+
const raw = await executeVerification(task.verificationCommand, options.cwd, options.timeout);
|
|
181
|
+
if (outcome.reports.length) {
|
|
182
|
+
raw.phases = outcome.reports;
|
|
183
|
+
raw.durationMs += options.phases ? 0 : outcome.reports.reduce((sum, r) => sum + r.durationMs, 0);
|
|
184
|
+
raw.skipped = false;
|
|
185
|
+
}
|
|
186
|
+
return applyCodeShape(raw, options.cwd, task);
|
|
187
|
+
}
|
|
101
188
|
/**
|
|
102
189
|
* Run verification for a single specific task in the plan.
|
|
103
190
|
*/
|
|
@@ -150,8 +237,7 @@ export async function verifyTask(taskId, targetDirArg, options = {}) {
|
|
|
150
237
|
},
|
|
151
238
|
};
|
|
152
239
|
}
|
|
153
|
-
const
|
|
154
|
-
const result = applyCodeShape(raw, targetDir, foundTask);
|
|
240
|
+
const result = await runTaskVerification(foundTask, { cwd: targetDir, timeout: options.timeout });
|
|
155
241
|
return {
|
|
156
242
|
taskId: foundTask.id,
|
|
157
243
|
title: foundTask.title,
|
|
@@ -239,9 +325,11 @@ export async function verifyBatch(targetDirArg, options = {}) {
|
|
|
239
325
|
let failed = 0;
|
|
240
326
|
let skipped = 0;
|
|
241
327
|
let totalDuration = 0;
|
|
328
|
+
// The phases check the whole project, so one run serves every task in the batch.
|
|
329
|
+
const phases = candidates.length ? await runVerifyPhases(targetDir, targetDir, options.timeout) : { reports: [] };
|
|
330
|
+
totalDuration += phases.reports.reduce((sum, r) => sum + r.durationMs, 0);
|
|
242
331
|
for (const { task, milestone } of candidates) {
|
|
243
|
-
const
|
|
244
|
-
const vResult = applyCodeShape(raw, targetDir, task);
|
|
332
|
+
const vResult = await runTaskVerification(task, { cwd: targetDir, timeout: options.timeout, phases });
|
|
245
333
|
totalDuration += vResult.durationMs;
|
|
246
334
|
if (vResult.skipped) {
|
|
247
335
|
skipped++;
|