@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +676 -190
  3. package/dist/commands/db.js +3 -0
  4. package/dist/commands/doctor.d.ts +2 -1
  5. package/dist/commands/doctor.js +4 -0
  6. package/dist/commands/init.js +1 -0
  7. package/dist/commands/learn.d.ts +19 -0
  8. package/dist/commands/learn.js +121 -0
  9. package/dist/commands/session.d.ts +22 -0
  10. package/dist/commands/session.js +106 -0
  11. package/dist/commands/task.js +34 -7
  12. package/dist/commands/verify.js +6 -0
  13. package/dist/core/code-shape.js +4 -2
  14. package/dist/core/enforcement.d.ts +1 -0
  15. package/dist/core/enforcement.js +22 -4
  16. package/dist/core/harness-integrity.d.ts +15 -0
  17. package/dist/core/harness-integrity.js +198 -0
  18. package/dist/core/human-gate.d.ts +7 -0
  19. package/dist/core/human-gate.js +16 -0
  20. package/dist/core/learnings.d.ts +63 -0
  21. package/dist/core/learnings.js +227 -0
  22. package/dist/core/root-resolver.d.ts +5 -0
  23. package/dist/core/root-resolver.js +46 -0
  24. package/dist/core/session-memory.d.ts +42 -0
  25. package/dist/core/session-memory.js +121 -0
  26. package/dist/core/setup-assets.d.ts +1 -1
  27. package/dist/core/setup-assets.js +5 -2
  28. package/dist/core/spec-slices.js +119 -7
  29. package/dist/core/verifier.d.ts +36 -0
  30. package/dist/core/verifier.js +92 -4
  31. package/dist/db/introspector.js +59 -59
  32. package/dist/governor/store.d.ts +18 -0
  33. package/dist/governor/store.js +53 -0
  34. package/dist/index.js +41 -0
  35. package/dist/mcp/server.js +12 -0
  36. package/dist/runner/agent-supervisor.d.ts +16 -0
  37. package/dist/runner/agent-supervisor.js +63 -8
  38. package/dist/scanner/types.d.ts +1 -0
  39. package/dist/server/studio-ui/shell/model.js +3 -1
  40. package/package.json +2 -2
  41. package/plugin/.claude-plugin/plugin.json +2 -2
  42. package/plugin/agents/architect.md +3 -3
  43. package/plugin/agents/explorer.md +49 -0
  44. package/plugin/agents/worker.md +2 -1
  45. package/plugin/skills/nativ/SKILL.md +15 -0
  46. package/templates/AGENTS.md +15 -0
  47. package/templates/CLAUDE.md +32 -32
  48. package/templates/GEMINI.md +2 -2
  49. package/templates/claude-agents/architect.md +3 -3
  50. package/templates/claude-agents/explorer.md +49 -0
  51. package/templates/claude-agents/worker.md +2 -1
  52. package/templates/dot-ai/api_contracts.json +66 -66
  53. package/templates/dot-ai/context.md +38 -38
  54. package/templates/dot-ai/db_schema.json +39 -39
  55. package/templates/dot-ai/subagents/frontend.md +33 -0
  56. 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
  };
@@ -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 markdownSection(content, anchor) {
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 base = slugifyHeading(m[2]);
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 === wanted) {
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 === '' || fragment.startsWith('/'))
207
+ if (fragment === '')
119
208
  return jsonPointerValue(doc, fragment);
120
- return endpointValue(doc, fragment);
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",
@@ -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
  */
@@ -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 raw = await executeVerification(foundTask.verificationCommand, targetDir, options.timeout);
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 raw = await executeVerification(task.verificationCommand, targetDir, options.timeout);
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++;