@njeybe/nativ 2.3.1 → 2.4.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 +44 -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/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/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++;
|
package/dist/db/introspector.js
CHANGED
|
@@ -81,30 +81,30 @@ async function createPostgresDriver(url) {
|
|
|
81
81
|
},
|
|
82
82
|
async tables() {
|
|
83
83
|
const builder = new TableBuilder();
|
|
84
|
-
const tables = await client.query(`
|
|
85
|
-
SELECT t.table_name, obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass, 'pg_class') AS description
|
|
86
|
-
FROM information_schema.tables t
|
|
87
|
-
WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'
|
|
84
|
+
const tables = await client.query(`
|
|
85
|
+
SELECT t.table_name, obj_description(format('%I.%I', t.table_schema, t.table_name)::regclass, 'pg_class') AS description
|
|
86
|
+
FROM information_schema.tables t
|
|
87
|
+
WHERE t.table_schema = current_schema() AND t.table_type = 'BASE TABLE'
|
|
88
88
|
ORDER BY t.table_name`);
|
|
89
89
|
for (const row of tables.rows) {
|
|
90
90
|
const table = builder.get(row.table_name);
|
|
91
91
|
if (row.description)
|
|
92
92
|
table.description = row.description;
|
|
93
93
|
}
|
|
94
|
-
const pks = await client.query(`
|
|
95
|
-
SELECT kcu.table_name, kcu.column_name
|
|
96
|
-
FROM information_schema.table_constraints tc
|
|
97
|
-
JOIN information_schema.key_column_usage kcu
|
|
98
|
-
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
94
|
+
const pks = await client.query(`
|
|
95
|
+
SELECT kcu.table_name, kcu.column_name
|
|
96
|
+
FROM information_schema.table_constraints tc
|
|
97
|
+
JOIN information_schema.key_column_usage kcu
|
|
98
|
+
ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name
|
|
99
99
|
WHERE tc.table_schema = current_schema() AND tc.constraint_type = 'PRIMARY KEY'`);
|
|
100
100
|
const pkSet = new Set(pks.rows.map((r) => `${r.table_name}.${r.column_name}`));
|
|
101
|
-
const columns = await client.query(`
|
|
102
|
-
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
103
|
-
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default
|
|
104
|
-
FROM information_schema.columns c
|
|
105
|
-
JOIN information_schema.tables t
|
|
106
|
-
ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE'
|
|
107
|
-
WHERE c.table_schema = current_schema()
|
|
101
|
+
const columns = await client.query(`
|
|
102
|
+
SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.character_maximum_length,
|
|
103
|
+
c.numeric_precision, c.numeric_scale, c.is_nullable, c.column_default
|
|
104
|
+
FROM information_schema.columns c
|
|
105
|
+
JOIN information_schema.tables t
|
|
106
|
+
ON t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.table_type = 'BASE TABLE'
|
|
107
|
+
WHERE c.table_schema = current_schema()
|
|
108
108
|
ORDER BY c.table_name, c.ordinal_position`);
|
|
109
109
|
for (const row of columns.rows) {
|
|
110
110
|
builder.get(row.table_name).columns.push({
|
|
@@ -116,31 +116,31 @@ async function createPostgresDriver(url) {
|
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
118
|
// information_schema has no index view — pg_catalog is the only source.
|
|
119
|
-
const indexes = await client.query(`
|
|
120
|
-
SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique,
|
|
121
|
-
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
122
|
-
FROM unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
123
|
-
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS columns
|
|
124
|
-
FROM pg_index ix
|
|
125
|
-
JOIN pg_class t ON t.oid = ix.indrelid
|
|
126
|
-
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
127
|
-
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
128
|
-
WHERE n.nspname = current_schema() AND NOT ix.indisprimary AND t.relkind IN ('r', 'p')
|
|
119
|
+
const indexes = await client.query(`
|
|
120
|
+
SELECT t.relname AS table_name, i.relname AS index_name, ix.indisunique AS is_unique,
|
|
121
|
+
(SELECT array_agg(a.attname::text ORDER BY k.ord)
|
|
122
|
+
FROM unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, ord)
|
|
123
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum) AS columns
|
|
124
|
+
FROM pg_index ix
|
|
125
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
126
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
127
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
128
|
+
WHERE n.nspname = current_schema() AND NOT ix.indisprimary AND t.relkind IN ('r', 'p')
|
|
129
129
|
ORDER BY t.relname, i.relname`);
|
|
130
130
|
for (const row of indexes.rows) {
|
|
131
131
|
builder.get(row.table_name).indexes.push({ name: row.index_name, columns: row.columns ?? [], unique: row.is_unique });
|
|
132
132
|
}
|
|
133
|
-
const fks = await client.query(`
|
|
134
|
-
SELECT con.conname AS constraint_name, cl.relname AS table_name, a.attname AS column_name,
|
|
135
|
-
fcl.relname AS ref_table, fa.attname AS ref_column, con.confdeltype AS on_delete
|
|
136
|
-
FROM pg_constraint con
|
|
137
|
-
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
138
|
-
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
139
|
-
JOIN pg_class fcl ON fcl.oid = con.confrelid
|
|
140
|
-
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(attnum, fattnum)
|
|
141
|
-
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
|
142
|
-
JOIN pg_attribute fa ON fa.attrelid = con.confrelid AND fa.attnum = k.fattnum
|
|
143
|
-
WHERE n.nspname = current_schema() AND con.contype = 'f'
|
|
133
|
+
const fks = await client.query(`
|
|
134
|
+
SELECT con.conname AS constraint_name, cl.relname AS table_name, a.attname AS column_name,
|
|
135
|
+
fcl.relname AS ref_table, fa.attname AS ref_column, con.confdeltype AS on_delete
|
|
136
|
+
FROM pg_constraint con
|
|
137
|
+
JOIN pg_class cl ON cl.oid = con.conrelid
|
|
138
|
+
JOIN pg_namespace n ON n.oid = cl.relnamespace
|
|
139
|
+
JOIN pg_class fcl ON fcl.oid = con.confrelid
|
|
140
|
+
CROSS JOIN LATERAL unnest(con.conkey, con.confkey) AS k(attnum, fattnum)
|
|
141
|
+
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = k.attnum
|
|
142
|
+
JOIN pg_attribute fa ON fa.attrelid = con.confrelid AND fa.attnum = k.fattnum
|
|
143
|
+
WHERE n.nspname = current_schema() AND con.contype = 'f'
|
|
144
144
|
ORDER BY cl.relname, con.conname`);
|
|
145
145
|
for (const row of fks.rows) {
|
|
146
146
|
builder.get(row.table_name).foreignKeys.push({
|
|
@@ -177,22 +177,22 @@ async function createMysqlDriver(url) {
|
|
|
177
177
|
},
|
|
178
178
|
async tables() {
|
|
179
179
|
const builder = new TableBuilder();
|
|
180
|
-
for (const row of await rows(`
|
|
181
|
-
SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment
|
|
182
|
-
FROM information_schema.TABLES
|
|
183
|
-
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
180
|
+
for (const row of await rows(`
|
|
181
|
+
SELECT TABLE_NAME AS table_name, TABLE_COMMENT AS table_comment
|
|
182
|
+
FROM information_schema.TABLES
|
|
183
|
+
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'
|
|
184
184
|
ORDER BY TABLE_NAME`)) {
|
|
185
185
|
const table = builder.get(row.table_name);
|
|
186
186
|
if (row.table_comment)
|
|
187
187
|
table.description = row.table_comment;
|
|
188
188
|
}
|
|
189
|
-
for (const row of await rows(`
|
|
190
|
-
SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_TYPE AS column_type,
|
|
191
|
-
c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY AS column_key
|
|
192
|
-
FROM information_schema.COLUMNS c
|
|
193
|
-
JOIN information_schema.TABLES t
|
|
194
|
-
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
|
|
195
|
-
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
189
|
+
for (const row of await rows(`
|
|
190
|
+
SELECT c.TABLE_NAME AS table_name, c.COLUMN_NAME AS column_name, c.COLUMN_TYPE AS column_type,
|
|
191
|
+
c.IS_NULLABLE AS is_nullable, c.COLUMN_DEFAULT AS column_default, c.COLUMN_KEY AS column_key
|
|
192
|
+
FROM information_schema.COLUMNS c
|
|
193
|
+
JOIN information_schema.TABLES t
|
|
194
|
+
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA AND t.TABLE_NAME = c.TABLE_NAME AND t.TABLE_TYPE = 'BASE TABLE'
|
|
195
|
+
WHERE c.TABLE_SCHEMA = DATABASE()
|
|
196
196
|
ORDER BY c.TABLE_NAME, c.ORDINAL_POSITION`)) {
|
|
197
197
|
builder.get(row.table_name).columns.push({
|
|
198
198
|
name: row.column_name,
|
|
@@ -203,10 +203,10 @@ async function createMysqlDriver(url) {
|
|
|
203
203
|
});
|
|
204
204
|
}
|
|
205
205
|
const indexMap = new Map();
|
|
206
|
-
for (const row of await rows(`
|
|
207
|
-
SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, NON_UNIQUE AS non_unique, COLUMN_NAME AS column_name
|
|
208
|
-
FROM information_schema.STATISTICS
|
|
209
|
-
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME <> 'PRIMARY'
|
|
206
|
+
for (const row of await rows(`
|
|
207
|
+
SELECT TABLE_NAME AS table_name, INDEX_NAME AS index_name, NON_UNIQUE AS non_unique, COLUMN_NAME AS column_name
|
|
208
|
+
FROM information_schema.STATISTICS
|
|
209
|
+
WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME <> 'PRIMARY'
|
|
210
210
|
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX`)) {
|
|
211
211
|
const key = `${row.table_name}.${row.index_name}`;
|
|
212
212
|
let entry = indexMap.get(key);
|
|
@@ -219,13 +219,13 @@ async function createMysqlDriver(url) {
|
|
|
219
219
|
}
|
|
220
220
|
for (const { table, index } of indexMap.values())
|
|
221
221
|
builder.get(table).indexes.push(index);
|
|
222
|
-
for (const row of await rows(`
|
|
223
|
-
SELECT k.CONSTRAINT_NAME AS constraint_name, k.TABLE_NAME AS table_name, k.COLUMN_NAME AS column_name,
|
|
224
|
-
k.REFERENCED_TABLE_NAME AS ref_table, k.REFERENCED_COLUMN_NAME AS ref_column, r.DELETE_RULE AS delete_rule
|
|
225
|
-
FROM information_schema.KEY_COLUMN_USAGE k
|
|
226
|
-
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
227
|
-
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
|
|
228
|
-
WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
222
|
+
for (const row of await rows(`
|
|
223
|
+
SELECT k.CONSTRAINT_NAME AS constraint_name, k.TABLE_NAME AS table_name, k.COLUMN_NAME AS column_name,
|
|
224
|
+
k.REFERENCED_TABLE_NAME AS ref_table, k.REFERENCED_COLUMN_NAME AS ref_column, r.DELETE_RULE AS delete_rule
|
|
225
|
+
FROM information_schema.KEY_COLUMN_USAGE k
|
|
226
|
+
JOIN information_schema.REFERENTIAL_CONSTRAINTS r
|
|
227
|
+
ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME AND r.TABLE_NAME = k.TABLE_NAME
|
|
228
|
+
WHERE k.TABLE_SCHEMA = DATABASE() AND k.REFERENCED_TABLE_NAME IS NOT NULL
|
|
229
229
|
ORDER BY k.TABLE_NAME, k.CONSTRAINT_NAME, k.ORDINAL_POSITION`)) {
|
|
230
230
|
builder.get(row.table_name).foreignKeys.push({
|
|
231
231
|
name: row.constraint_name,
|
package/dist/governor/store.d.ts
CHANGED
|
@@ -31,3 +31,21 @@ export declare function appendEscalation(targetDir: string, build: (id: string)
|
|
|
31
31
|
position?: 'first' | 'last';
|
|
32
32
|
projectName?: string;
|
|
33
33
|
}): string;
|
|
34
|
+
export interface EscalationHistoryEntry {
|
|
35
|
+
id: string;
|
|
36
|
+
type: string;
|
|
37
|
+
status: 'resolved' | 'dismissed';
|
|
38
|
+
summary: string;
|
|
39
|
+
resolutionNotes?: string;
|
|
40
|
+
resolvedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The closed escalations of one task, oldest first, so the worker who picks the task up again
|
|
44
|
+
* learns how each gap was settled instead of raising it a second time.
|
|
45
|
+
*/
|
|
46
|
+
export declare function taskEscalationHistory(targetDir: string, taskId: string, limit?: number): EscalationHistoryEntry[];
|
|
47
|
+
/**
|
|
48
|
+
* Closes the pending escalations of a task that has just completed: the question was settled one way
|
|
49
|
+
* or another, so it should not keep asking for a decision. Returns the ids it closed.
|
|
50
|
+
*/
|
|
51
|
+
export declare function closeEscalationsForTask(targetDir: string, taskId: string, note: string): string[];
|
package/dist/governor/store.js
CHANGED
|
@@ -82,3 +82,56 @@ export function appendEscalation(targetDir, build, options) {
|
|
|
82
82
|
return id;
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
|
+
const HISTORY_TEXT_LIMIT = 400;
|
|
86
|
+
/** Plain single-line text: control characters could hide words in a terminal or a prompt. */
|
|
87
|
+
function clip(value) {
|
|
88
|
+
const text = String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim();
|
|
89
|
+
return text.length > HISTORY_TEXT_LIMIT ? `${text.slice(0, HISTORY_TEXT_LIMIT)}...` : text;
|
|
90
|
+
}
|
|
91
|
+
const sortKey = (e) => String(e.resolvedAt ?? e.timestamp ?? '');
|
|
92
|
+
/**
|
|
93
|
+
* The closed escalations of one task, oldest first, so the worker who picks the task up again
|
|
94
|
+
* learns how each gap was settled instead of raising it a second time.
|
|
95
|
+
*/
|
|
96
|
+
export function taskEscalationHistory(targetDir, taskId, limit = 3) {
|
|
97
|
+
const records = loadEscalationFile(targetDir, path.basename(targetDir)).escalations;
|
|
98
|
+
return records
|
|
99
|
+
.filter((e) => e && typeof e === 'object' && e.taskId === taskId)
|
|
100
|
+
.filter((e) => e.status === 'resolved' || e.status === 'dismissed')
|
|
101
|
+
.sort((a, b) => sortKey(a).localeCompare(sortKey(b)))
|
|
102
|
+
.slice(-limit)
|
|
103
|
+
.map((e) => ({
|
|
104
|
+
id: clip(e.id),
|
|
105
|
+
type: clip(e.type),
|
|
106
|
+
status: e.status,
|
|
107
|
+
summary: clip(e.summary),
|
|
108
|
+
...(e.resolutionNotes ? { resolutionNotes: clip(e.resolutionNotes) } : {}),
|
|
109
|
+
...(e.resolvedAt ? { resolvedAt: clip(e.resolvedAt) } : {}),
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Closes the pending escalations of a task that has just completed: the question was settled one way
|
|
114
|
+
* or another, so it should not keep asking for a decision. Returns the ids it closed.
|
|
115
|
+
*/
|
|
116
|
+
export function closeEscalationsForTask(targetDir, taskId, note) {
|
|
117
|
+
const file = escalationPath(targetDir);
|
|
118
|
+
if (!fs.existsSync(file))
|
|
119
|
+
return [];
|
|
120
|
+
return withFileLockSync(file, () => {
|
|
121
|
+
const data = loadEscalationFile(targetDir, path.basename(targetDir));
|
|
122
|
+
const closed = [];
|
|
123
|
+
for (const e of data.escalations) {
|
|
124
|
+
if (!e || e.taskId !== taskId || (e.status && e.status !== 'pending_review'))
|
|
125
|
+
continue;
|
|
126
|
+
e.status = 'resolved';
|
|
127
|
+
e.resolutionNotes = note;
|
|
128
|
+
e.resolvedAt = new Date().toISOString();
|
|
129
|
+
closed.push(e.id);
|
|
130
|
+
}
|
|
131
|
+
if (closed.length) {
|
|
132
|
+
data.lastUpdated = new Date().toISOString();
|
|
133
|
+
writeJsonAtomicSync(file, data);
|
|
134
|
+
}
|
|
135
|
+
return closed;
|
|
136
|
+
});
|
|
137
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { runVerify } from './commands/verify.js';
|
|
|
15
15
|
import { runTestGen, TEST_GEN_FRAMEWORK_HELP } from './commands/test-gen.js';
|
|
16
16
|
import { runBench } from './commands/bench.js';
|
|
17
17
|
import { runTriage } from './commands/triage.js';
|
|
18
|
+
import { runLearnPropose, runLearnDecide, runLearnList } from './commands/learn.js';
|
|
18
19
|
import { packageVersion } from './core/version.js';
|
|
19
20
|
export function createProgram() {
|
|
20
21
|
const program = new Command();
|
|
@@ -215,6 +216,46 @@ export function createProgram() {
|
|
|
215
216
|
.action(async (taskId, targetDir, options) => {
|
|
216
217
|
await runTaskUnlock(taskId, targetDir, { reason: options.reason, revoke: options.revoke });
|
|
217
218
|
});
|
|
219
|
+
const learn = program
|
|
220
|
+
.command('learn')
|
|
221
|
+
.description('Project lessons the contracts do not capture: agents propose, a human approves');
|
|
222
|
+
learn
|
|
223
|
+
.command('propose <insight> [targetDir]')
|
|
224
|
+
.description('Propose a lesson for later workers (it waits for a human to approve it)')
|
|
225
|
+
.option('--role <role>', 'Only workers with this role see it')
|
|
226
|
+
.option('--files <globs>', 'Comma-separated files, dirs or globs; only tasks touching them see it')
|
|
227
|
+
.option('--task <taskId>', 'The task where it came up')
|
|
228
|
+
.option('-d, --details <details>', 'Longer explanation')
|
|
229
|
+
.option('--by <who>', 'Who proposes it (defaults to NATIV_ROLE)')
|
|
230
|
+
.option('--json', 'Output the stored learning as JSON')
|
|
231
|
+
.action(async (insight, targetDir, options) => {
|
|
232
|
+
await runLearnPropose(insight, targetDir, options);
|
|
233
|
+
});
|
|
234
|
+
learn
|
|
235
|
+
.command('approve <id> [targetDir]')
|
|
236
|
+
.description('Approve a proposed lesson so matching tasks carry it (interactive terminal only)')
|
|
237
|
+
.option('-n, --note <note>', 'Why it was approved')
|
|
238
|
+
.option('-y, --yes', 'Skip the confirmation question (still needs a terminal)')
|
|
239
|
+
.action(async (id, targetDir, options) => {
|
|
240
|
+
await runLearnDecide(id, 'approved', targetDir, options);
|
|
241
|
+
});
|
|
242
|
+
learn
|
|
243
|
+
.command('reject <id> [targetDir]')
|
|
244
|
+
.description('Reject a proposed or approved lesson (interactive terminal only)')
|
|
245
|
+
.option('-n, --note <note>', 'Why it was rejected')
|
|
246
|
+
.option('-y, --yes', 'Skip the confirmation question (still needs a terminal)')
|
|
247
|
+
.action(async (id, targetDir, options) => {
|
|
248
|
+
await runLearnDecide(id, 'rejected', targetDir, options);
|
|
249
|
+
});
|
|
250
|
+
learn
|
|
251
|
+
.command('list [targetDir]')
|
|
252
|
+
.description('List lessons')
|
|
253
|
+
.option('-s, --status <status>', 'proposed, approved or rejected')
|
|
254
|
+
.option('--role <role>', 'Only lessons scoped to this role')
|
|
255
|
+
.option('--json', 'Output as JSON')
|
|
256
|
+
.action(async (targetDir, options) => {
|
|
257
|
+
await runLearnList(targetDir, options);
|
|
258
|
+
});
|
|
218
259
|
const hook = program
|
|
219
260
|
.command('hook')
|
|
220
261
|
.description('Claude Code hook entry points for role enforcement');
|