@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.
- package/CHANGELOG.md +53 -0
- 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 +21 -3
- package/dist/runner/agent-supervisor.js +80 -20
- package/dist/scanner/types.d.ts +1 -0
- package/dist/server/studio-server.js +1 -130
- package/dist/server/studio-ui/shell/dispatch.js +3 -9
- 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,198 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseJsonLoose } from './enforcement.js';
|
|
4
|
+
import { getTemplatesDir, planTemplateFile } from './setup-assets.js';
|
|
5
|
+
import { canonicalRole, listLearnings, loadLearnings } from './learnings.js';
|
|
6
|
+
/** Well-known credential shapes only; loose `password=` matches would flag every doc that mentions one. */
|
|
7
|
+
const SECRET_SHAPES = [
|
|
8
|
+
{ kind: 'private key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/, severity: 'fail' },
|
|
9
|
+
{ kind: 'Anthropic or OpenAI key', pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{20,}/, severity: 'fail' },
|
|
10
|
+
{ kind: 'GitHub token', pattern: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/, severity: 'fail' },
|
|
11
|
+
{ kind: 'AWS access key', pattern: /\bAKIA[0-9A-Z]{16}\b/, severity: 'fail' },
|
|
12
|
+
{ kind: 'Google API key', pattern: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: 'fail' },
|
|
13
|
+
{ kind: 'JSON web token', pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, severity: 'fail' },
|
|
14
|
+
// A placeholder such as user:password@localhost also matches, so this one only warns.
|
|
15
|
+
{
|
|
16
|
+
kind: 'connection string with a password',
|
|
17
|
+
pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@"']*:[^\s@"'$<{/]+@/i,
|
|
18
|
+
severity: 'warn',
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
const SETTINGS_FILES = ['.claude/settings.json', '.claude/settings.local.json'];
|
|
22
|
+
const AGENT_READ_FILES = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.mcp.json', '.nativ/config.json', ...SETTINGS_FILES];
|
|
23
|
+
const AGENT_READ_DIRS = ['.claude/agents', '.ai'];
|
|
24
|
+
const SCANNED_EXT = new Set(['.md', '.json', '.html', '.txt', '.yaml', '.yml']);
|
|
25
|
+
const MAX_SCAN_BYTES = 1_000_000;
|
|
26
|
+
function readText(file) {
|
|
27
|
+
try {
|
|
28
|
+
return fs.readFileSync(file, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function listFiles(dir, out = []) {
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const full = path.join(dir, e.name);
|
|
44
|
+
if (e.isDirectory())
|
|
45
|
+
listFiles(full, out);
|
|
46
|
+
else if (e.isFile() && SCANNED_EXT.has(path.extname(e.name).toLowerCase()))
|
|
47
|
+
out.push(full);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
function checkGuardrailOverrides(root) {
|
|
52
|
+
const findings = [];
|
|
53
|
+
const add = (id, status, message) => findings.push({ id, status, message, category: 'guardrail' });
|
|
54
|
+
for (const rel of SETTINGS_FILES) {
|
|
55
|
+
const text = readText(path.join(root, rel));
|
|
56
|
+
if (!text?.trim())
|
|
57
|
+
continue;
|
|
58
|
+
let settings;
|
|
59
|
+
try {
|
|
60
|
+
settings = parseJsonLoose(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
const message = `${rel} is not valid JSON, so its guardrail settings could not be checked.`;
|
|
64
|
+
add(`guardrail:${rel}:unreadable`, 'warn', message);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (settings?.disableAllHooks === true) {
|
|
68
|
+
const message = `${rel} sets disableAllHooks, so the enforcement hook never runs. Remove it.`;
|
|
69
|
+
add(`guardrail:${rel}:hooks`, 'fail', message);
|
|
70
|
+
}
|
|
71
|
+
if (settings?.permissions?.defaultMode === 'bypassPermissions') {
|
|
72
|
+
const message = `${rel} sets bypassPermissions, so writes under .ai/ and .nativ/ no longer ask a human first.`;
|
|
73
|
+
add(`guardrail:${rel}:bypass`, 'warn', message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return findings;
|
|
77
|
+
}
|
|
78
|
+
function scanFile(root, file) {
|
|
79
|
+
try {
|
|
80
|
+
if (fs.statSync(file).size > MAX_SCAN_BYTES)
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const lines = (readText(file) ?? '').split(/\r?\n/);
|
|
87
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
88
|
+
const findings = [];
|
|
89
|
+
for (const shape of SECRET_SHAPES) {
|
|
90
|
+
const line = lines.findIndex((l) => shape.pattern.test(l));
|
|
91
|
+
if (line < 0)
|
|
92
|
+
continue;
|
|
93
|
+
findings.push({
|
|
94
|
+
id: `secret:${rel}:${shape.kind}`,
|
|
95
|
+
status: shape.severity,
|
|
96
|
+
category: 'secret',
|
|
97
|
+
message: `${rel}:${line + 1} looks like a ${shape.kind}. Agents read this file; `
|
|
98
|
+
+ 'move the value to .env and reference it.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
function checkSecrets(root) {
|
|
104
|
+
const files = [
|
|
105
|
+
...AGENT_READ_FILES.map((rel) => path.join(root, rel)),
|
|
106
|
+
...AGENT_READ_DIRS.flatMap((rel) => listFiles(path.join(root, rel))),
|
|
107
|
+
];
|
|
108
|
+
return files.flatMap((file) => scanFile(root, file));
|
|
109
|
+
}
|
|
110
|
+
function plannedRoles(root) {
|
|
111
|
+
try {
|
|
112
|
+
const plan = parseJsonLoose(readText(path.join(root, '.ai', 'master_plan.json')) ?? '{}');
|
|
113
|
+
const roles = (plan.milestones ?? []).flatMap((m) => (Array.isArray(m?.tasks) ? m.tasks : []))
|
|
114
|
+
.map((t) => t?.assignedSubagent)
|
|
115
|
+
.filter((r) => typeof r === 'string');
|
|
116
|
+
return [...new Set(roles)];
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function checkMissingRoleGuides(root) {
|
|
123
|
+
const guidesDir = path.join(root, '.ai', 'subagents');
|
|
124
|
+
const missing = plannedRoles(root).filter((role) => {
|
|
125
|
+
const guide = canonicalRole(role) ?? role;
|
|
126
|
+
return !/^[\w-]+$/.test(guide) || !fs.existsSync(path.join(guidesDir, `${guide}.md`));
|
|
127
|
+
});
|
|
128
|
+
if (!missing.length)
|
|
129
|
+
return [];
|
|
130
|
+
return [{
|
|
131
|
+
id: 'role-guides:missing',
|
|
132
|
+
status: 'warn',
|
|
133
|
+
category: 'roles',
|
|
134
|
+
message: `Tasks name roles with no guide in .ai/subagents/: ${missing.sort().join(', ')}. `
|
|
135
|
+
+ 'Their workers start without one.',
|
|
136
|
+
}];
|
|
137
|
+
}
|
|
138
|
+
function checkEditedRoleGuides(root, templatesDir) {
|
|
139
|
+
const edited = [];
|
|
140
|
+
for (const file of listFiles(path.join(templatesDir, 'dot-ai', 'subagents'))) {
|
|
141
|
+
const name = path.basename(file);
|
|
142
|
+
const existing = readText(path.join(root, '.ai', 'subagents', name));
|
|
143
|
+
if (existing === null)
|
|
144
|
+
continue;
|
|
145
|
+
if (planTemplateFile(existing, readText(file) ?? '', `subagents/${name}`).action === 'skipped')
|
|
146
|
+
edited.push(name);
|
|
147
|
+
}
|
|
148
|
+
if (!edited.length)
|
|
149
|
+
return [];
|
|
150
|
+
const message = `Role guides edited by hand (nativ update keeps them as they are): ${edited.sort().join(', ')}`;
|
|
151
|
+
return [{ id: 'role-guides:edited', status: 'info', category: 'roles', message }];
|
|
152
|
+
}
|
|
153
|
+
function checkLearnings(root) {
|
|
154
|
+
const findings = [];
|
|
155
|
+
const waiting = listLearnings(root, { status: 'proposed' }).length;
|
|
156
|
+
if (waiting) {
|
|
157
|
+
const noun = waiting === 1 ? 'lesson waits' : 'lessons wait';
|
|
158
|
+
const message = `${waiting} proposed ${noun} for approval: nativ learn list --status proposed`;
|
|
159
|
+
findings.push({ id: 'learnings', status: 'info', category: 'learnings', message });
|
|
160
|
+
}
|
|
161
|
+
if (loadLearnings(root).unreadable) {
|
|
162
|
+
findings.push({
|
|
163
|
+
id: 'learnings:unreadable',
|
|
164
|
+
status: 'warn',
|
|
165
|
+
category: 'learnings',
|
|
166
|
+
message: '.ai/learnings.json cannot be read, so workers receive no lessons and new ones cannot be '
|
|
167
|
+
+ 'proposed. Fix the JSON or move the file.',
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
function checkUiSpecs(root) {
|
|
173
|
+
const specs = readText(path.join(root, '.ai', 'ui_specs.md'));
|
|
174
|
+
if (specs === null || /Marketing pages/i.test(specs))
|
|
175
|
+
return [];
|
|
176
|
+
return [{
|
|
177
|
+
id: 'specs:marketing',
|
|
178
|
+
status: 'info',
|
|
179
|
+
category: 'specs',
|
|
180
|
+
message: '.ai/ui_specs.md predates the Marketing pages checks. Ask the architect to add that part '
|
|
181
|
+
+ 'of the Anti-Generic Checklist from the current nativ template.',
|
|
182
|
+
}];
|
|
183
|
+
}
|
|
184
|
+
export function checkHarnessIntegrity(root, options = {}) {
|
|
185
|
+
const findings = [
|
|
186
|
+
...checkGuardrailOverrides(root),
|
|
187
|
+
...checkSecrets(root),
|
|
188
|
+
...checkMissingRoleGuides(root),
|
|
189
|
+
...checkEditedRoleGuides(root, options.templatesDir ?? getTemplatesDir()),
|
|
190
|
+
...checkLearnings(root),
|
|
191
|
+
...checkUiSpecs(root),
|
|
192
|
+
];
|
|
193
|
+
if (!findings.some((f) => f.category === 'guardrail' || f.category === 'secret')) {
|
|
194
|
+
const message = 'No guardrail overrides and no secrets in files agents read';
|
|
195
|
+
findings.unshift({ id: 'integrity', status: 'ok', category: 'guardrail', message });
|
|
196
|
+
}
|
|
197
|
+
return findings;
|
|
198
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Commands only a person may run. Agents run headless, so a missing terminal stops them even
|
|
3
|
+
* where no permission rule is installed. It is one layer, not a sandbox: the hook and ask rules are others.
|
|
4
|
+
*/
|
|
5
|
+
export declare function isInteractive(): boolean;
|
|
6
|
+
/** Prints why and sets a failing exit code when there is no terminal. Returns true when refused. */
|
|
7
|
+
export declare function refuseHeadless(command: string): boolean;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
/**
|
|
3
|
+
* nativ · Commands only a person may run. Agents run headless, so a missing terminal stops them even
|
|
4
|
+
* where no permission rule is installed. It is one layer, not a sandbox: the hook and ask rules are others.
|
|
5
|
+
*/
|
|
6
|
+
export function isInteractive() {
|
|
7
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8
|
+
}
|
|
9
|
+
/** Prints why and sets a failing exit code when there is no terminal. Returns true when refused. */
|
|
10
|
+
export function refuseHeadless(command) {
|
|
11
|
+
if (isInteractive())
|
|
12
|
+
return false;
|
|
13
|
+
console.error(pc.red(`✖ ${command} needs an interactive terminal; agents cannot run it.`));
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Project learnings in `.ai/learnings.json`. Agents propose; only a human approves, from an
|
|
3
|
+
* interactive terminal. The file is protected like the contracts, by the hook and the ask rules.
|
|
4
|
+
*/
|
|
5
|
+
export declare const LEARNING_STATUSES: readonly ["proposed", "approved", "rejected"];
|
|
6
|
+
export type LearningStatus = (typeof LEARNING_STATUSES)[number];
|
|
7
|
+
export type LearningDecision = Exclude<LearningStatus, 'proposed'>;
|
|
8
|
+
export interface Learning {
|
|
9
|
+
id: string;
|
|
10
|
+
status: LearningStatus;
|
|
11
|
+
insight: string;
|
|
12
|
+
details?: string;
|
|
13
|
+
/** Applies to this role only; absent means every role. */
|
|
14
|
+
role?: string;
|
|
15
|
+
/** Applies when a task targets a matching file (exact, `dir/` or glob); absent means any file. */
|
|
16
|
+
files?: string[];
|
|
17
|
+
taskId?: string;
|
|
18
|
+
proposedBy?: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
decidedAt?: string;
|
|
21
|
+
decisionNote?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface LearningsFile {
|
|
24
|
+
version: '1.0.0';
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
learnings: Learning[];
|
|
27
|
+
}
|
|
28
|
+
/** The most lessons one task carries, so a long list never crowds out the contracts. */
|
|
29
|
+
export declare const MAX_TASK_LEARNINGS = 5;
|
|
30
|
+
export declare function learningsPath(root: string): string;
|
|
31
|
+
export declare function loadLearnings(root: string): LearningsFile & {
|
|
32
|
+
unreadable: boolean;
|
|
33
|
+
};
|
|
34
|
+
/** Maps role aliases (`backend-agent`, `qa`) to the role names tasks use, or null when unknown. */
|
|
35
|
+
export declare function canonicalRole(role: string | null | undefined): string | null;
|
|
36
|
+
export interface ProposeInput {
|
|
37
|
+
insight: string;
|
|
38
|
+
details?: string;
|
|
39
|
+
role?: string;
|
|
40
|
+
files?: string[];
|
|
41
|
+
taskId?: string;
|
|
42
|
+
proposedBy?: string;
|
|
43
|
+
}
|
|
44
|
+
export declare function proposeLearning(root: string, input: ProposeInput): Learning;
|
|
45
|
+
/** What a worker receives from a lesson; a decision applies only if this is what the human saw. */
|
|
46
|
+
export declare function lessonContent(l: Pick<Learning, 'insight' | 'details' | 'role' | 'files'>): string;
|
|
47
|
+
/**
|
|
48
|
+
* Approves or rejects a lesson. Pass `seen` (from lessonContent) to refuse when the lesson changed
|
|
49
|
+
* after the human looked at it. Returns an error message, or the updated lesson.
|
|
50
|
+
*/
|
|
51
|
+
export declare function decideLearning(root: string, id: string, decision: LearningDecision, note?: string, seen?: string): Learning | string;
|
|
52
|
+
export declare function listLearnings(root: string, filter?: {
|
|
53
|
+
status?: LearningStatus;
|
|
54
|
+
role?: string;
|
|
55
|
+
}): Learning[];
|
|
56
|
+
/**
|
|
57
|
+
* Approved lessons for one task: those scoped to its files first, then to its role, then
|
|
58
|
+
* project-wide ones, newest first within each, capped at MAX_TASK_LEARNINGS.
|
|
59
|
+
*/
|
|
60
|
+
export declare function learningsForTask(root: string, task: {
|
|
61
|
+
assignedSubagent?: string;
|
|
62
|
+
targetFiles?: string[];
|
|
63
|
+
}): Learning[];
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { withFileLockSync, writeJsonAtomicSync } from './lock-manager.js';
|
|
4
|
+
import { matchesTarget, parseJsonLoose } from './enforcement.js';
|
|
5
|
+
import { SUBAGENT_TYPES } from '../scanner/types.js';
|
|
6
|
+
/**
|
|
7
|
+
* nativ · Project learnings in `.ai/learnings.json`. Agents propose; only a human approves, from an
|
|
8
|
+
* interactive terminal. The file is protected like the contracts, by the hook and the ask rules.
|
|
9
|
+
*/
|
|
10
|
+
export const LEARNING_STATUSES = ['proposed', 'approved', 'rejected'];
|
|
11
|
+
/** The most lessons one task carries, so a long list never crowds out the contracts. */
|
|
12
|
+
export const MAX_TASK_LEARNINGS = 5;
|
|
13
|
+
const MAX_INSIGHT = 300;
|
|
14
|
+
const MAX_DETAILS = 500;
|
|
15
|
+
const MAX_FILES = 10;
|
|
16
|
+
const MAX_FILE_PATTERN = 200;
|
|
17
|
+
const MAX_PENDING = 100;
|
|
18
|
+
export function learningsPath(root) {
|
|
19
|
+
return path.join(root, '.ai', 'learnings.json');
|
|
20
|
+
}
|
|
21
|
+
function isLearning(l) {
|
|
22
|
+
if (!l || typeof l !== 'object')
|
|
23
|
+
return false;
|
|
24
|
+
const item = l;
|
|
25
|
+
if (typeof item.id !== 'string' || typeof item.insight !== 'string')
|
|
26
|
+
return false;
|
|
27
|
+
if (!LEARNING_STATUSES.includes(item.status))
|
|
28
|
+
return false;
|
|
29
|
+
if (item.files !== undefined && !(Array.isArray(item.files) && item.files.every((f) => typeof f === 'string'))) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return item.role === undefined || item.role === null || typeof item.role === 'string';
|
|
33
|
+
}
|
|
34
|
+
function readRaw(root) {
|
|
35
|
+
const fresh = () => ({ doc: { version: '1.0.0' }, records: [], corrupt: false });
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(learningsPath(root), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return fresh();
|
|
42
|
+
}
|
|
43
|
+
if (!raw.trim())
|
|
44
|
+
return fresh();
|
|
45
|
+
try {
|
|
46
|
+
const doc = parseJsonLoose(raw);
|
|
47
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.learnings))
|
|
48
|
+
return { ...fresh(), corrupt: true };
|
|
49
|
+
return { doc, records: doc.learnings, corrupt: false };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { ...fresh(), corrupt: true };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function loadLearnings(root) {
|
|
56
|
+
const { doc, records, corrupt } = readRaw(root);
|
|
57
|
+
return {
|
|
58
|
+
version: '1.0.0',
|
|
59
|
+
updatedAt: String(doc.updatedAt ?? ''),
|
|
60
|
+
learnings: records.filter(isLearning),
|
|
61
|
+
unreadable: corrupt,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function mutateLearnings(root, mutate) {
|
|
65
|
+
const file = learningsPath(root);
|
|
66
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
67
|
+
return withFileLockSync(file, () => {
|
|
68
|
+
const { doc, records, corrupt } = readRaw(root);
|
|
69
|
+
if (corrupt) {
|
|
70
|
+
const rel = path.relative(root, file);
|
|
71
|
+
throw new Error(`${rel} cannot be read, so nothing was changed. Fix or move it, then retry.`);
|
|
72
|
+
}
|
|
73
|
+
const result = mutate(records);
|
|
74
|
+
writeJsonAtomicSync(file, { ...doc, version: '1.0.0', updatedAt: new Date().toISOString(), learnings: records });
|
|
75
|
+
return result;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Ids already used by any record, valid or not, so a new lesson never takes an old one's id. */
|
|
79
|
+
function nextId(records) {
|
|
80
|
+
const max = records.reduce((m, r) => {
|
|
81
|
+
const id = r && typeof r === 'object' ? r.id : undefined;
|
|
82
|
+
return Math.max(m, Number(typeof id === 'string' ? /^learn-(\d+)$/.exec(id)?.[1] ?? 0 : 0));
|
|
83
|
+
}, 0);
|
|
84
|
+
return `learn-${String(max + 1).padStart(2, '0')}`;
|
|
85
|
+
}
|
|
86
|
+
// Control characters, bidi overrides, zero-width marks and line/paragraph separators can make the
|
|
87
|
+
// text a human approves read differently from what a worker receives.
|
|
88
|
+
const HIDDEN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g;
|
|
89
|
+
const HIDDEN_AND_NEWLINE = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u2069\ufeff]/g;
|
|
90
|
+
function clean(text, max, keepNewlines = false) {
|
|
91
|
+
const t = text?.replace(keepNewlines ? HIDDEN : HIDDEN_AND_NEWLINE, keepNewlines ? '' : ' ').trim();
|
|
92
|
+
if (!t)
|
|
93
|
+
return undefined;
|
|
94
|
+
return t.length > max ? t.slice(0, max) : t;
|
|
95
|
+
}
|
|
96
|
+
/** Maps role aliases (`backend-agent`, `qa`) to the role names tasks use, or null when unknown. */
|
|
97
|
+
export function canonicalRole(role) {
|
|
98
|
+
const r = (role ?? '').trim().toLowerCase();
|
|
99
|
+
if (!r)
|
|
100
|
+
return null;
|
|
101
|
+
const known = SUBAGENT_TYPES;
|
|
102
|
+
const candidates = [r, r.replace(/-agent$/, ''), r === 'qa' || r === 'qa-agent' ? 'qa-tester' : '', `${r}-agent`];
|
|
103
|
+
const hit = candidates.find((c) => c && known.includes(c));
|
|
104
|
+
return hit ? hit.replace(/^(backend|frontend|database)-agent$/, '$1').replace(/^qa-agent$/, 'qa-tester') : null;
|
|
105
|
+
}
|
|
106
|
+
/** A lesson's role in canonical form, also for records saved before roles were normalised. */
|
|
107
|
+
const roleOf = (l) => (l.role ? canonicalRole(l.role) ?? l.role.toLowerCase() : null);
|
|
108
|
+
function normalizePattern(p) {
|
|
109
|
+
return p.trim().replace(/\\/g, '/').replace(/^\.\//, '');
|
|
110
|
+
}
|
|
111
|
+
function cleanFiles(files) {
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const f of files ?? []) {
|
|
114
|
+
const p = normalizePattern(f);
|
|
115
|
+
if (!p)
|
|
116
|
+
continue;
|
|
117
|
+
if (p.length > MAX_FILE_PATTERN) {
|
|
118
|
+
throw new Error(`File pattern is longer than ${MAX_FILE_PATTERN} characters: ${p.slice(0, 40)}...`);
|
|
119
|
+
}
|
|
120
|
+
if (p.startsWith('/') || /^[A-Za-z]:/.test(p) || /(^|\/)\.\.(\/|$)/.test(p)) {
|
|
121
|
+
throw new Error(`File patterns must be relative to the project: ${p}`);
|
|
122
|
+
}
|
|
123
|
+
out.push(p);
|
|
124
|
+
}
|
|
125
|
+
if (out.length > MAX_FILES)
|
|
126
|
+
throw new Error(`A lesson can name at most ${MAX_FILES} file patterns.`);
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
export function proposeLearning(root, input) {
|
|
130
|
+
const insight = clean(input.insight, MAX_INSIGHT);
|
|
131
|
+
if (!insight)
|
|
132
|
+
throw new Error('A learning needs a one-line insight.');
|
|
133
|
+
const details = clean(input.details, MAX_DETAILS, true);
|
|
134
|
+
const role = input.role?.trim() ? canonicalRole(input.role) : undefined;
|
|
135
|
+
if (role === null)
|
|
136
|
+
throw new Error(`Unknown role "${input.role}". Use one of: ${SUBAGENT_TYPES.join(', ')}.`);
|
|
137
|
+
const files = cleanFiles(input.files);
|
|
138
|
+
const taskId = clean(input.taskId, 64);
|
|
139
|
+
const proposedBy = clean(input.proposedBy, 64);
|
|
140
|
+
return mutateLearnings(root, (records) => {
|
|
141
|
+
const pending = records.filter((r) => isLearning(r) && r.status === 'proposed').length;
|
|
142
|
+
if (pending >= MAX_PENDING) {
|
|
143
|
+
throw new Error(`${pending} lessons already wait for approval. Approve or reject some before proposing more.`);
|
|
144
|
+
}
|
|
145
|
+
const item = {
|
|
146
|
+
id: nextId(records),
|
|
147
|
+
status: 'proposed',
|
|
148
|
+
insight,
|
|
149
|
+
...(details ? { details } : {}),
|
|
150
|
+
...(role ? { role } : {}),
|
|
151
|
+
...(files.length ? { files } : {}),
|
|
152
|
+
...(taskId ? { taskId } : {}),
|
|
153
|
+
...(proposedBy ? { proposedBy } : {}),
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
records.push(item);
|
|
157
|
+
return item;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** What a worker receives from a lesson; a decision applies only if this is what the human saw. */
|
|
161
|
+
export function lessonContent(l) {
|
|
162
|
+
return JSON.stringify([l.insight, l.details ?? '', l.role ?? '', l.files ?? []]);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Approves or rejects a lesson. Pass `seen` (from lessonContent) to refuse when the lesson changed
|
|
166
|
+
* after the human looked at it. Returns an error message, or the updated lesson.
|
|
167
|
+
*/
|
|
168
|
+
export function decideLearning(root, id, decision, note, seen) {
|
|
169
|
+
return mutateLearnings(root, (records) => {
|
|
170
|
+
const item = records.find((r) => isLearning(r) && r.id === id);
|
|
171
|
+
if (!item)
|
|
172
|
+
return `No learning ${id}.`;
|
|
173
|
+
if (seen !== undefined && lessonContent(item) !== seen) {
|
|
174
|
+
return `${id} changed while you were reviewing it. Nothing was decided; run the command again.`;
|
|
175
|
+
}
|
|
176
|
+
if (item.status === decision)
|
|
177
|
+
return `${id} is already ${decision}.`;
|
|
178
|
+
item.status = decision;
|
|
179
|
+
item.decidedAt = new Date().toISOString();
|
|
180
|
+
const cleanNote = clean(note, MAX_DETAILS);
|
|
181
|
+
if (cleanNote)
|
|
182
|
+
item.decisionNote = cleanNote;
|
|
183
|
+
else
|
|
184
|
+
delete item.decisionNote;
|
|
185
|
+
return item;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
export function listLearnings(root, filter = {}) {
|
|
189
|
+
const role = filter.role ? canonicalRole(filter.role) ?? filter.role.toLowerCase() : undefined;
|
|
190
|
+
return loadLearnings(root).learnings.filter((l) => (!filter.status || l.status === filter.status) && (!role || roleOf(l) === role));
|
|
191
|
+
}
|
|
192
|
+
const globBase = (p) => p.split(/[*?[{]/)[0];
|
|
193
|
+
/** True when a task target and a lesson pattern overlap, whichever of them is the wider one. */
|
|
194
|
+
function overlaps(target, pattern) {
|
|
195
|
+
const t = normalizePattern(target);
|
|
196
|
+
if (matchesTarget(t, pattern) || matchesTarget(pattern, t))
|
|
197
|
+
return true;
|
|
198
|
+
const [tb, pb] = [globBase(t).toLowerCase(), globBase(pattern).toLowerCase()];
|
|
199
|
+
const targetIsWide = t.endsWith('/') || t.length !== tb.length;
|
|
200
|
+
const patternIsWide = pattern.endsWith('/') || pattern.length !== pb.length;
|
|
201
|
+
return (targetIsWide && tb !== '' && pattern.toLowerCase().startsWith(tb))
|
|
202
|
+
|| (patternIsWide && pb !== '' && t.toLowerCase().startsWith(pb));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Approved lessons for one task: those scoped to its files first, then to its role, then
|
|
206
|
+
* project-wide ones, newest first within each, capped at MAX_TASK_LEARNINGS.
|
|
207
|
+
*/
|
|
208
|
+
export function learningsForTask(root, task) {
|
|
209
|
+
const role = canonicalRole(task.assignedSubagent);
|
|
210
|
+
const targets = Array.isArray(task.targetFiles) ? task.targetFiles.filter((t) => typeof t === 'string') : [];
|
|
211
|
+
const scored = [];
|
|
212
|
+
loadLearnings(root).learnings.forEach((item, order) => {
|
|
213
|
+
if (item.status !== 'approved')
|
|
214
|
+
return;
|
|
215
|
+
const itemRole = roleOf(item);
|
|
216
|
+
if (itemRole && itemRole !== role)
|
|
217
|
+
return;
|
|
218
|
+
const fileMatch = item.files?.length ? targets.some((t) => item.files.some((p) => overlaps(t, p))) : null;
|
|
219
|
+
if (fileMatch === false)
|
|
220
|
+
return;
|
|
221
|
+
scored.push({ item, score: (fileMatch ? 2 : 0) + (itemRole ? 1 : 0), order });
|
|
222
|
+
});
|
|
223
|
+
return scored
|
|
224
|
+
.sort((a, b) => b.score - a.score || b.order - a.order)
|
|
225
|
+
.slice(0, MAX_TASK_LEARNINGS)
|
|
226
|
+
.map((s) => s.item);
|
|
227
|
+
}
|
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* schemas remain accessible to sub-agents.
|
|
6
6
|
*/
|
|
7
7
|
export declare function resolveProjectRoot(startDirArg?: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The main checkout, also from inside a task worktree. Worktrees link `.ai/` but not `.nativ/`,
|
|
10
|
+
* so settings and keys must always come from here, never from the agent's own copy.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveMainRoot(startDirArg?: string): string;
|
|
8
13
|
/**
|
|
9
14
|
* Creates a directory junction (Windows) or symlink (Unix) linking .ai/
|
|
10
15
|
* from the main project root into the newly created worktree so path-relative
|
|
@@ -57,6 +57,52 @@ export function resolveProjectRoot(startDirArg) {
|
|
|
57
57
|
}
|
|
58
58
|
return path.resolve(startDirArg || process.cwd());
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The main checkout, also from inside a task worktree. Worktrees link `.ai/` but not `.nativ/`,
|
|
62
|
+
* so settings and keys must always come from here, never from the agent's own copy.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveMainRoot(startDirArg) {
|
|
65
|
+
const start = path.resolve(startDirArg || process.cwd());
|
|
66
|
+
const marker = `${path.sep}.worktrees${path.sep}`;
|
|
67
|
+
const idx = `${start}${path.sep}`.indexOf(marker);
|
|
68
|
+
if (idx !== -1 && fs.existsSync(path.join(start.slice(0, idx), '.ai')))
|
|
69
|
+
return start.slice(0, idx);
|
|
70
|
+
const linked = linkedWorktreeMain(start);
|
|
71
|
+
return linked ?? resolveProjectRoot(start);
|
|
72
|
+
}
|
|
73
|
+
const isInside = (parent, child) => {
|
|
74
|
+
const rel = path.relative(parent, child);
|
|
75
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* For a git linked worktree, the main checkout from git's own `commondir` record. Only a checkout
|
|
79
|
+
* that contains the worktree counts, so an edited `.git` file cannot point somewhere else.
|
|
80
|
+
*/
|
|
81
|
+
function linkedWorktreeMain(start) {
|
|
82
|
+
let top = start;
|
|
83
|
+
while (!fs.existsSync(path.join(top, '.git'))) {
|
|
84
|
+
const up = path.dirname(top);
|
|
85
|
+
if (up === top)
|
|
86
|
+
return null;
|
|
87
|
+
top = up;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const gitFile = path.join(top, '.git');
|
|
91
|
+
if (!fs.lstatSync(gitFile).isFile())
|
|
92
|
+
return null;
|
|
93
|
+
const gitDirRaw = /^gitdir:\s*(.+)$/im.exec(fs.readFileSync(gitFile, 'utf8'))?.[1]?.trim();
|
|
94
|
+
if (!gitDirRaw)
|
|
95
|
+
return null;
|
|
96
|
+
const gitDir = path.resolve(top, gitDirRaw);
|
|
97
|
+
const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8').trim();
|
|
98
|
+
const mainRepo = path.dirname(path.resolve(gitDir, common));
|
|
99
|
+
return isInside(mainRepo, top) && fs.existsSync(path.join(mainRepo, '.ai')) ? mainRepo : null;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// No commondir (a submodule, or not a worktree): not a linked worktree.
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
60
106
|
/**
|
|
61
107
|
* Creates a directory junction (Windows) or symlink (Unix) linking .ai/
|
|
62
108
|
* from the main project root into the newly created worktree so path-relative
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface SessionCheckpoint {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
timestamp: string;
|
|
4
|
+
role?: string;
|
|
5
|
+
taskId?: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
decisions?: string[];
|
|
8
|
+
obstacles?: string[];
|
|
9
|
+
nextSteps?: string[];
|
|
10
|
+
activeFiles?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface SessionSummaryEntry {
|
|
13
|
+
sessionId: string;
|
|
14
|
+
timestamp: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
taskId?: string;
|
|
17
|
+
summary: string;
|
|
18
|
+
}
|
|
19
|
+
export interface SessionIndex {
|
|
20
|
+
version: '1.0.0';
|
|
21
|
+
updatedAt: string;
|
|
22
|
+
latestSessionId?: string;
|
|
23
|
+
sessions: SessionSummaryEntry[];
|
|
24
|
+
}
|
|
25
|
+
export declare function getSessionMemoryDir(targetDir: string): string;
|
|
26
|
+
export declare function getSessionIndexPath(targetDir: string): string;
|
|
27
|
+
export declare function getLatestSessionPath(targetDir: string): string;
|
|
28
|
+
export declare function loadSessionIndex(targetDir: string): SessionIndex;
|
|
29
|
+
export declare function saveSessionIndex(targetDir: string, index: SessionIndex): void;
|
|
30
|
+
export declare function saveSessionCheckpoint(targetDir: string, input: {
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
role?: string;
|
|
33
|
+
taskId?: string;
|
|
34
|
+
summary: string;
|
|
35
|
+
decisions?: string[];
|
|
36
|
+
obstacles?: string[];
|
|
37
|
+
nextSteps?: string[];
|
|
38
|
+
activeFiles?: string[];
|
|
39
|
+
}): SessionCheckpoint;
|
|
40
|
+
export declare function getLatestSession(targetDir: string): SessionCheckpoint | null;
|
|
41
|
+
export declare function getSession(targetDir: string, sessionId: string): SessionCheckpoint | null;
|
|
42
|
+
export declare function listSessions(targetDir: string, limit?: number): SessionSummaryEntry[];
|