@njeybe/nativ 2.3.0 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +53 -0
  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/verifier.d.ts +36 -0
  29. package/dist/core/verifier.js +92 -4
  30. package/dist/db/introspector.js +59 -59
  31. package/dist/governor/store.d.ts +18 -0
  32. package/dist/governor/store.js +53 -0
  33. package/dist/index.js +41 -0
  34. package/dist/mcp/server.js +12 -0
  35. package/dist/runner/agent-supervisor.d.ts +21 -3
  36. package/dist/runner/agent-supervisor.js +80 -20
  37. package/dist/scanner/types.d.ts +1 -0
  38. package/dist/server/studio-server.js +1 -130
  39. package/dist/server/studio-ui/shell/dispatch.js +3 -9
  40. package/dist/server/studio-ui/shell/model.js +3 -1
  41. package/package.json +2 -2
  42. package/plugin/.claude-plugin/plugin.json +2 -2
  43. package/plugin/agents/architect.md +3 -3
  44. package/plugin/agents/explorer.md +49 -0
  45. package/plugin/agents/worker.md +2 -1
  46. package/plugin/skills/nativ/SKILL.md +15 -0
  47. package/templates/AGENTS.md +15 -0
  48. package/templates/CLAUDE.md +32 -32
  49. package/templates/GEMINI.md +2 -2
  50. package/templates/claude-agents/architect.md +3 -3
  51. package/templates/claude-agents/explorer.md +49 -0
  52. package/templates/claude-agents/worker.md +2 -1
  53. package/templates/dot-ai/api_contracts.json +66 -66
  54. package/templates/dot-ai/context.md +38 -38
  55. package/templates/dot-ai/db_schema.json +39 -39
  56. package/templates/dot-ai/subagents/frontend.md +33 -0
  57. 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
  };
@@ -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++;
@@ -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,
@@ -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[];
@@ -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');