@njeybe/nativ 2.5.1 → 2.7.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 +67 -0
- package/README.md +100 -5
- package/dist/commands/design-check.d.ts +4 -0
- package/dist/commands/design-check.js +76 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +55 -11
- package/dist/commands/notify.d.ts +2 -0
- package/dist/commands/notify.js +25 -0
- package/dist/commands/session.d.ts +22 -0
- package/dist/commands/session.js +106 -0
- package/dist/commands/task/task-add.js +1 -1
- package/dist/commands/task/task-common.js +6 -6
- package/dist/commands/task/task-escalate.js +8 -10
- package/dist/commands/task/task-lifecycle.d.ts +7 -1
- package/dist/commands/task/task-lifecycle.js +30 -5
- package/dist/commands/task/task-query.d.ts +2 -0
- package/dist/commands/task/task-query.js +27 -12
- package/dist/commands/task/task-reclaim.d.ts +20 -0
- package/dist/commands/task/task-reclaim.js +87 -0
- package/dist/commands/task.d.ts +2 -0
- package/dist/commands/task.js +1 -0
- package/dist/commands/triage.js +3 -3
- package/dist/commands/update.js +46 -0
- package/dist/commands/validate.js +1 -0
- package/dist/commands/verify.js +21 -5
- package/dist/core/design-rules.d.ts +23 -0
- package/dist/core/design-rules.js +115 -0
- package/dist/core/enforcement.js +18 -17
- package/dist/core/harness-integrity.js +26 -7
- package/dist/core/lock-manager.d.ts +6 -0
- package/dist/core/lock-manager.js +50 -41
- package/dist/core/notify.d.ts +38 -0
- package/dist/core/notify.js +133 -0
- package/dist/core/profiles.js +7 -16
- package/dist/core/session-memory.d.ts +42 -0
- package/dist/core/session-memory.js +121 -0
- package/dist/core/setup/pm-playbook.d.ts +5 -0
- package/dist/core/setup/pm-playbook.js +26 -0
- package/dist/core/setup/session-context.js +35 -2
- package/dist/core/setup/setup-planner.js +2 -2
- package/dist/core/setup/types.d.ts +2 -0
- package/dist/core/setup/types.js +5 -1
- package/dist/core/task-claims.d.ts +15 -0
- package/dist/core/task-claims.js +32 -0
- package/dist/core/tier1-liaison.d.ts +1 -0
- package/dist/core/tier1-liaison.js +25 -27
- package/dist/core/verifier/output-digest.d.ts +19 -0
- package/dist/core/verifier/output-digest.js +48 -0
- package/dist/db/data-engine.js +33 -32
- package/dist/db/introspector.js +61 -60
- package/dist/db/sqlite.d.ts +3 -0
- package/dist/db/sqlite.js +10 -0
- package/dist/governor/circuit-breaker.js +12 -0
- package/dist/governor/patch-applier.d.ts +1 -0
- package/dist/governor/patch-applier.js +3 -7
- package/dist/index.js +40 -6
- package/dist/mcp/server.d.ts +1 -1
- package/dist/mcp/server.js +53 -14
- package/dist/providers/claude-api.js +3 -0
- package/dist/providers/gemini.js +4 -1
- package/dist/providers/registry.d.ts +6 -2
- package/dist/providers/registry.js +28 -16
- package/dist/providers/types.d.ts +4 -1
- package/dist/runner/agent-supervisor.js +2 -2
- package/dist/runner/cli-config.js +1 -1
- package/dist/scanner/types.d.ts +5 -1
- package/dist/scanner/types.js +7 -0
- package/dist/server/routes/task-routes.d.ts +2 -0
- package/dist/server/routes/triage-routes.js +3 -18
- package/dist/server/studio-ui/pages/benchmarks/styles.js +8 -5
- package/dist/server/studio-ui/pages/database/browser.js +1 -1
- package/dist/server/studio-ui/pages/database/connection.js +1 -1
- package/dist/server/studio-ui/pages/database/data-shell.js +5 -5
- package/dist/server/studio-ui/pages/database/drift.js +1 -1
- package/dist/server/studio-ui/pages/database/explorer.js +2 -2
- package/dist/server/studio-ui/pages/database/styles.js +5 -5
- package/dist/server/studio-ui/pages/flow/styles.js +3 -3
- package/dist/server/studio-ui/pages/home/script.js +110 -91
- package/dist/server/studio-ui/pages/home/styles.js +33 -45
- package/dist/server/studio-ui/pages/tasks/script.js +150 -3
- package/dist/server/studio-ui/pages/tasks/styles.js +63 -6
- package/dist/server/studio-ui/pages/team/script.js +49 -52
- package/dist/server/studio-ui/pages/team/styles.js +21 -20
- package/dist/server/studio-ui/pages/worktrees/script.js +47 -35
- package/dist/server/studio-ui/pages/worktrees/styles.js +12 -10
- package/dist/server/studio-ui/shell/brand.d.ts +4 -0
- package/dist/server/studio-ui/shell/brand.js +16 -0
- package/dist/server/studio-ui/shell/console.js +61 -2
- package/dist/server/studio-ui/shell/core.js +8 -8
- package/dist/server/studio-ui/shell/detail-view.js +1 -1
- package/dist/server/studio-ui/shell/dispatch.js +18 -0
- package/dist/server/studio-ui/shell/events-tools.js +5 -1
- package/dist/server/studio-ui/shell/format.js +5 -6
- package/dist/server/studio-ui/shell/icons.js +23 -15
- package/dist/server/studio-ui/shell/markup-head.js +11 -6
- package/dist/server/studio-ui/shell/markup-tools.js +19 -10
- package/dist/server/studio-ui/shell/paint.js +3 -0
- package/dist/server/studio-ui/shell/pipeline-state.js +1 -1
- package/dist/server/studio-ui/shell/proposals.js +6 -2
- package/dist/server/studio-ui/shell/pulse.d.ts +1 -0
- package/dist/server/studio-ui/shell/pulse.js +67 -0
- package/dist/server/studio-ui/shell/router.js +25 -0
- package/dist/server/studio-ui/shell/sidebar-view.js +6 -3
- package/dist/server/studio-ui/shell/toasts-format.js +4 -4
- package/dist/server/studio-ui/styles/console.js +33 -13
- package/dist/server/studio-ui/styles/dialogs.js +4 -4
- package/dist/server/studio-ui/styles/responsive.js +8 -0
- package/dist/server/studio-ui/styles/shell.js +24 -5
- package/dist/server/studio-ui/styles/strategist.js +10 -10
- package/dist/server/studio-ui/styles/tokens-base.js +23 -23
- package/dist/server/studio-ui.js +2 -0
- package/package.json +2 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/agents/architect.md +11 -12
- package/plugin/agents/explorer.md +1 -1
- package/plugin/agents/verifier.md +2 -2
- package/plugin/agents/worker.md +5 -7
- package/plugin/skills/nativ/SKILL.md +36 -49
- package/templates/AGENTS.md +36 -49
- package/templates/CLAUDE.md +1 -41
- package/templates/GEMINI.md +3 -2
- package/templates/claude-agents/architect.md +11 -12
- package/templates/claude-agents/explorer.md +1 -1
- package/templates/claude-agents/verifier.md +2 -2
- package/templates/claude-agents/worker.md +5 -7
- package/templates/design/taste/LICENSE +21 -0
- package/templates/design/taste/SOURCE.md +28 -0
- package/templates/design/taste/v1/DIGEST.md +57 -0
- package/templates/design/taste/v1/SKILL.md +226 -0
- package/templates/design/taste/v2/DIGEST.md +62 -0
- package/templates/design/taste/v2/SKILL.md +1206 -0
- 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/architect.md +6 -0
- package/templates/dot-ai/subagents/flutter-developer.md +2 -11
- package/templates/dot-ai/subagents/frontend.md +47 -44
- package/templates/dot-ai/ui_specs.md +15 -36
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export const DESIGN_PROFILES = ['taste-v1', 'taste-v2'];
|
|
2
|
+
const TASTE = ['taste-v1', 'taste-v2'];
|
|
3
|
+
export const DESIGN_RULES = [
|
|
4
|
+
{
|
|
5
|
+
id: 'no-emoji', profiles: DESIGN_PROFILES, severity: 'error',
|
|
6
|
+
message: 'Emoji found; use an icon from the catalog',
|
|
7
|
+
pattern: /\p{Extended_Pictographic}/gu,
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: 'no-em-dash', profiles: ['taste-v2'], severity: 'error',
|
|
11
|
+
message: 'Em or en dash found; use a comma, colon or hyphen',
|
|
12
|
+
pattern: /[–—]/g,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'no-h-screen', profiles: TASTE, severity: 'error',
|
|
16
|
+
message: 'h-screen found; use min-h-[100dvh]',
|
|
17
|
+
pattern: /(?<![\w-])h-screen(?![\w-])/g,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'no-scroll-listener', profiles: TASTE, severity: 'error',
|
|
21
|
+
message: 'Scroll listener found; use IntersectionObserver or CSS',
|
|
22
|
+
pattern: /addEventListener\(\s*['"`]scroll['"`]/g,
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
id: 'no-pure-black', profiles: TASTE, severity: 'warning',
|
|
26
|
+
message: 'Pure black found; use an off-black token',
|
|
27
|
+
pattern: /#000(?:000)?(?![0-9a-fA-F])|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*[,)]/g,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'no-google-fonts-link', profiles: ['taste-v2'], severity: 'warning',
|
|
31
|
+
message: 'Google Fonts link found; self-host the font',
|
|
32
|
+
pattern: /fonts\.googleapis\.com/g,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'no-custom-cursor', profiles: TASTE, severity: 'warning',
|
|
36
|
+
message: 'Custom cursor found',
|
|
37
|
+
pattern: /cursor:\s*url\(|cursor-\[url/g,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'no-hand-rolled-icon', profiles: TASTE, severity: 'warning',
|
|
41
|
+
message: 'Inline svg with a path; use an icon from the catalog',
|
|
42
|
+
exts: ['.tsx', '.jsx'],
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
export const SCAN_EXTENSIONS = [
|
|
46
|
+
'.tsx', '.jsx', '.ts', '.js', '.html', '.vue', '.svelte', '.astro', '.mdx', '.css',
|
|
47
|
+
];
|
|
48
|
+
const PROFILE_LINE = /^- \*\*Profile:\*\*\s*(taste-v1|taste-v2)\s*$/m;
|
|
49
|
+
export function readProfile(uiSpecsText) {
|
|
50
|
+
const m = PROFILE_LINE.exec(uiSpecsText);
|
|
51
|
+
return m ? m[1] : null;
|
|
52
|
+
}
|
|
53
|
+
export function isCommentLine(line) {
|
|
54
|
+
const t = line.trim();
|
|
55
|
+
return ['//', '/*', '*', '{/*', '<!--'].some((p) => t.startsWith(p));
|
|
56
|
+
}
|
|
57
|
+
function active(profile, ext) {
|
|
58
|
+
return DESIGN_RULES.filter((r) => r.profiles.includes(profile) && (!r.exts || r.exts.includes(ext)));
|
|
59
|
+
}
|
|
60
|
+
function lineStarts(text) {
|
|
61
|
+
const starts = [0];
|
|
62
|
+
for (let i = 0; i < text.length; i++)
|
|
63
|
+
if (text[i] === '\n')
|
|
64
|
+
starts.push(i + 1);
|
|
65
|
+
return starts;
|
|
66
|
+
}
|
|
67
|
+
function locate(starts, index) {
|
|
68
|
+
let lo = 0;
|
|
69
|
+
let hi = starts.length - 1;
|
|
70
|
+
while (lo < hi) {
|
|
71
|
+
const mid = (lo + hi + 1) >> 1;
|
|
72
|
+
if (starts[mid] <= index)
|
|
73
|
+
lo = mid;
|
|
74
|
+
else
|
|
75
|
+
hi = mid - 1;
|
|
76
|
+
}
|
|
77
|
+
return { line: lo + 1, column: index - starts[lo] + 1 };
|
|
78
|
+
}
|
|
79
|
+
function svgFindings(text, rule, starts, lines) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const re = /<svg\b/g;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(text))) {
|
|
84
|
+
const end = text.indexOf('</svg>', m.index);
|
|
85
|
+
const body = text.slice(m.index, end === -1 ? text.length : end);
|
|
86
|
+
const pos = locate(starts, m.index);
|
|
87
|
+
if (/<path\b/.test(body) && !isCommentLine(lines[pos.line - 1])) {
|
|
88
|
+
out.push({ ...pos, rule: rule.id, severity: rule.severity, message: rule.message });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
export function checkText(text, ext, profile) {
|
|
94
|
+
const rules = active(profile, ext);
|
|
95
|
+
const lines = text.split('\n');
|
|
96
|
+
const starts = lineStarts(text);
|
|
97
|
+
const found = [];
|
|
98
|
+
for (const rule of rules) {
|
|
99
|
+
if (!rule.pattern) {
|
|
100
|
+
found.push(...svgFindings(text, rule, starts, lines));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
lines.forEach((line, i) => {
|
|
104
|
+
if (isCommentLine(line))
|
|
105
|
+
return;
|
|
106
|
+
for (const m of line.matchAll(rule.pattern)) {
|
|
107
|
+
found.push({
|
|
108
|
+
line: i + 1, column: (m.index ?? 0) + 1,
|
|
109
|
+
rule: rule.id, severity: rule.severity, message: rule.message,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return found.sort((a, b) => a.line - b.line || a.column - b.column);
|
|
115
|
+
}
|
package/dist/core/enforcement.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from 'node:fs';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { EXAMPLE_ENV_FILES } from '../db/env-parser.js';
|
|
15
15
|
import { resolveMainRoot, resolveProjectRoot } from './root-resolver.js';
|
|
16
|
+
import { mutateJsonFileSync } from './lock-manager.js';
|
|
16
17
|
export const DEFAULT_ENFORCEMENT_MODE = 'warn';
|
|
17
18
|
/** Roles other than these are treated as ordinary workers. The architect is the only role that may edit contracts. */
|
|
18
19
|
export const ARCHITECT_ROLE = 'architect';
|
|
@@ -68,31 +69,31 @@ function readUnlocks(root) {
|
|
|
68
69
|
}
|
|
69
70
|
return { unlocks: [] };
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
/** Applies `change` to the unlock list under its lock; returns whether anything changed. */
|
|
73
|
+
function mutateUnlocks(root, change) {
|
|
74
|
+
let changed = false;
|
|
75
|
+
mutateJsonFileSync(path.join(root, UNLOCK_FILE), (file) => {
|
|
76
|
+
const current = Array.isArray(file.unlocks) ? file.unlocks : [];
|
|
77
|
+
const next = change(current);
|
|
78
|
+
if (next) {
|
|
79
|
+
file.unlocks = next;
|
|
80
|
+
changed = true;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
file.unlocks = current;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return changed;
|
|
77
87
|
}
|
|
78
88
|
export function isTaskUnlocked(root, taskId) {
|
|
79
89
|
return readUnlocks(root).unlocks.some((u) => u.taskId === taskId);
|
|
80
90
|
}
|
|
81
91
|
/** Lifts scope enforcement for one task until it is revoked or the task stops being in progress. */
|
|
82
92
|
export function addTaskUnlock(root, taskId, reason) {
|
|
83
|
-
|
|
84
|
-
if (data.unlocks.some((u) => u.taskId === taskId))
|
|
85
|
-
return;
|
|
86
|
-
data.unlocks.push({ taskId, at: new Date().toISOString(), ...(reason ? { reason } : {}) });
|
|
87
|
-
writeUnlocks(root, data);
|
|
93
|
+
mutateUnlocks(root, (unlocks) => unlocks.some((u) => u.taskId === taskId) ? null : [...unlocks, { taskId, at: new Date().toISOString(), ...(reason ? { reason } : {}) }]);
|
|
88
94
|
}
|
|
89
95
|
export function removeTaskUnlock(root, taskId) {
|
|
90
|
-
|
|
91
|
-
const next = data.unlocks.filter((u) => u.taskId !== taskId);
|
|
92
|
-
if (next.length === data.unlocks.length)
|
|
93
|
-
return false;
|
|
94
|
-
writeUnlocks(root, { unlocks: next });
|
|
95
|
-
return true;
|
|
96
|
+
return mutateUnlocks(root, (unlocks) => (unlocks.some((u) => u.taskId === taskId) ? unlocks.filter((u) => u.taskId !== taskId) : null));
|
|
96
97
|
}
|
|
97
98
|
// ─── Path handling ─────────────────────────────────────────────────────────────────────────────────
|
|
98
99
|
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin';
|
|
@@ -169,18 +169,37 @@ function checkLearnings(root) {
|
|
|
169
169
|
}
|
|
170
170
|
return findings;
|
|
171
171
|
}
|
|
172
|
-
function
|
|
173
|
-
|
|
174
|
-
if (specs === null || /Marketing pages/i.test(specs))
|
|
172
|
+
function checkDesignProfile(specs) {
|
|
173
|
+
if (specs === null || /^##\s+Design Profile\b/m.test(specs))
|
|
175
174
|
return [];
|
|
176
175
|
return [{
|
|
177
|
-
id: 'specs:
|
|
176
|
+
id: 'specs:design-profile',
|
|
178
177
|
status: 'info',
|
|
179
178
|
category: 'specs',
|
|
180
|
-
message: '.ai/ui_specs.md
|
|
181
|
-
+ '
|
|
179
|
+
message: '.ai/ui_specs.md has no Design Profile section. Ask the architect to add it from the '
|
|
180
|
+
+ 'current nativ template (templates/dot-ai/ui_specs.md).',
|
|
182
181
|
}];
|
|
183
182
|
}
|
|
183
|
+
function checkTasteBundle(root, templatesDir) {
|
|
184
|
+
const mk = (message) => [{ id: 'design:taste-bundle', status: 'info', category: 'specs', message }];
|
|
185
|
+
const dir = path.join(root, '.ai', 'design', 'taste');
|
|
186
|
+
if (!fs.existsSync(dir)) {
|
|
187
|
+
return mk('The design bundle .ai/design/taste/ is missing. Run `nativ update` to install it.');
|
|
188
|
+
}
|
|
189
|
+
const differs = ['LICENSE', 'v1/SKILL.md', 'v2/SKILL.md'].filter((rel) => {
|
|
190
|
+
const want = readText(path.join(templatesDir, 'design', 'taste', rel));
|
|
191
|
+
const have = readText(path.join(dir, rel));
|
|
192
|
+
return want !== null && (have === null || have.replace(/\r/g, '') !== want.replace(/\r/g, ''));
|
|
193
|
+
});
|
|
194
|
+
if (!differs.length)
|
|
195
|
+
return [];
|
|
196
|
+
return mk(`Design bundle files differ from the nativ package: ${differs.join(', ')}. `
|
|
197
|
+
+ 'Run `nativ update` (or `nativ update --force` to replace edited ones).');
|
|
198
|
+
}
|
|
199
|
+
function checkUiSpecs(root, templatesDir) {
|
|
200
|
+
const specs = readText(path.join(root, '.ai', 'ui_specs.md'));
|
|
201
|
+
return [...checkDesignProfile(specs), ...checkTasteBundle(root, templatesDir)];
|
|
202
|
+
}
|
|
184
203
|
export function checkHarnessIntegrity(root, options = {}) {
|
|
185
204
|
const findings = [
|
|
186
205
|
...checkGuardrailOverrides(root),
|
|
@@ -188,7 +207,7 @@ export function checkHarnessIntegrity(root, options = {}) {
|
|
|
188
207
|
...checkMissingRoleGuides(root),
|
|
189
208
|
...checkEditedRoleGuides(root, options.templatesDir ?? getTemplatesDir()),
|
|
190
209
|
...checkLearnings(root),
|
|
191
|
-
...checkUiSpecs(root),
|
|
210
|
+
...checkUiSpecs(root, options.templatesDir ?? getTemplatesDir()),
|
|
192
211
|
];
|
|
193
212
|
if (!findings.some((f) => f.category === 'guardrail' || f.category === 'secret')) {
|
|
194
213
|
const message = 'No guardrail overrides and no secrets in files agents read';
|
|
@@ -48,6 +48,12 @@ export declare function withFileLockSync<T>(filePath: string, fn: () => T, optio
|
|
|
48
48
|
* file. Retries the rename on Windows sharing violations, like savePlan().
|
|
49
49
|
*/
|
|
50
50
|
export declare function writeJsonAtomicSync(filePath: string, data: unknown): void;
|
|
51
|
+
/**
|
|
52
|
+
* Read-modify-write of a JSON object file under its lock, written atomically, so concurrent writers never drop
|
|
53
|
+
* each other's keys. A missing or empty file starts from `{}`; a file that does not parse is left alone and the
|
|
54
|
+
* write throws, because overwriting it would discard whatever a person wrote there.
|
|
55
|
+
*/
|
|
56
|
+
export declare function mutateJsonFileSync<T extends Record<string, unknown>>(filePath: string, mutator: (current: T) => void): void;
|
|
51
57
|
/**
|
|
52
58
|
* Synchronous variant of withPlanLock using proper-lockfile lockSync.
|
|
53
59
|
*/
|
|
@@ -18,14 +18,7 @@ export const DEFAULT_LOCK_OPTIONS = {
|
|
|
18
18
|
*/
|
|
19
19
|
export async function withFileLock(filePath, fn, options = {}) {
|
|
20
20
|
const mergedOptions = { ...DEFAULT_LOCK_OPTIONS, ...options };
|
|
21
|
-
|
|
22
|
-
if (!fs.existsSync(filePath)) {
|
|
23
|
-
const dir = path.dirname(filePath);
|
|
24
|
-
if (!fs.existsSync(dir)) {
|
|
25
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
26
|
-
}
|
|
27
|
-
fs.writeFileSync(filePath, '', 'utf8');
|
|
28
|
-
}
|
|
21
|
+
ensureLockable(filePath, mergedOptions);
|
|
29
22
|
const release = await lockfile.lock(filePath, mergedOptions);
|
|
30
23
|
try {
|
|
31
24
|
return await fn();
|
|
@@ -151,24 +144,8 @@ export async function withPlanLock(planPath, mutator, options = {}) {
|
|
|
151
144
|
*/
|
|
152
145
|
export function withFileLockSync(filePath, fn, options = {}) {
|
|
153
146
|
const { retries: _ignored, ...syncOptions } = { ...DEFAULT_LOCK_OPTIONS, ...options };
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
fs.writeFileSync(filePath, '', 'utf8');
|
|
157
|
-
}
|
|
158
|
-
let release = null;
|
|
159
|
-
// ~5s of contention before giving up: parallel agents trip the breaker at the same moment.
|
|
160
|
-
const maxAttempts = 200;
|
|
161
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
162
|
-
try {
|
|
163
|
-
release = lockfile.lockSync(filePath, syncOptions);
|
|
164
|
-
break;
|
|
165
|
-
}
|
|
166
|
-
catch (err) {
|
|
167
|
-
if (attempt === maxAttempts - 1 || err.code !== 'ELOCKED')
|
|
168
|
-
throw err;
|
|
169
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
147
|
+
ensureLockable(filePath, syncOptions);
|
|
148
|
+
const release = acquireLockSync(filePath, syncOptions);
|
|
172
149
|
try {
|
|
173
150
|
return fn();
|
|
174
151
|
}
|
|
@@ -208,30 +185,62 @@ export function writeJsonAtomicSync(filePath, data) {
|
|
|
208
185
|
}
|
|
209
186
|
}
|
|
210
187
|
/**
|
|
211
|
-
*
|
|
188
|
+
* With `realpath: false` (the default) the lock is a sibling `<file>.lock` directory, so the file itself need not
|
|
189
|
+
* exist; creating an empty one would leave JSON readers a file they cannot parse. Only the parent must exist.
|
|
212
190
|
*/
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
191
|
+
function ensureLockable(filePath, options) {
|
|
192
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
193
|
+
if (options.realpath && !fs.existsSync(filePath))
|
|
194
|
+
fs.writeFileSync(filePath, '', 'utf8');
|
|
195
|
+
}
|
|
196
|
+
/** ~5s of contention before giving up: parallel agents finish, fail and trip the breaker at the same moments. */
|
|
197
|
+
const SYNC_LOCK_ATTEMPTS = 200;
|
|
198
|
+
function acquireLockSync(filePath, options) {
|
|
199
|
+
for (let attempt = 0;; attempt++) {
|
|
221
200
|
try {
|
|
222
|
-
|
|
223
|
-
break;
|
|
201
|
+
return lockfile.lockSync(filePath, options);
|
|
224
202
|
}
|
|
225
203
|
catch (err) {
|
|
226
|
-
if (attempt
|
|
204
|
+
if (attempt >= SYNC_LOCK_ATTEMPTS - 1 || err.code !== 'ELOCKED')
|
|
227
205
|
throw err;
|
|
228
|
-
}
|
|
229
|
-
// Brief synchronous pause
|
|
230
206
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
231
207
|
}
|
|
232
208
|
}
|
|
233
|
-
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Read-modify-write of a JSON object file under its lock, written atomically, so concurrent writers never drop
|
|
212
|
+
* each other's keys. A missing or empty file starts from `{}`; a file that does not parse is left alone and the
|
|
213
|
+
* write throws, because overwriting it would discard whatever a person wrote there.
|
|
214
|
+
*/
|
|
215
|
+
export function mutateJsonFileSync(filePath, mutator) {
|
|
216
|
+
withFileLockSync(filePath, () => {
|
|
217
|
+
let current = {};
|
|
218
|
+
const text = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '') : '';
|
|
219
|
+
if (text.trim()) {
|
|
220
|
+
let parsed;
|
|
221
|
+
try {
|
|
222
|
+
parsed = JSON.parse(text);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
throw new Error(`${filePath} is not valid JSON (${err.message}); fix or delete it, then retry.`);
|
|
226
|
+
}
|
|
227
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
228
|
+
throw new Error(`${filePath} must hold a JSON object.`);
|
|
229
|
+
current = parsed;
|
|
230
|
+
}
|
|
231
|
+
mutator(current);
|
|
232
|
+
writeJsonAtomicSync(filePath, current);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Synchronous variant of withPlanLock using proper-lockfile lockSync.
|
|
237
|
+
*/
|
|
238
|
+
export function withPlanLockSync(planPath, mutator, options = {}) {
|
|
239
|
+
if (!fs.existsSync(planPath)) {
|
|
234
240
|
return null;
|
|
241
|
+
}
|
|
242
|
+
const { retries: _ignored, ...syncOptions } = { ...DEFAULT_LOCK_OPTIONS, ...options };
|
|
243
|
+
const release = acquireLockSync(planPath, syncOptions);
|
|
235
244
|
try {
|
|
236
245
|
const plan = loadPlan(planPath, { silent: true });
|
|
237
246
|
if (!plan)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nativ · Tells a person when the workflow needs them, through a webhook (Slack, Discord, ntfy, any JSON
|
|
3
|
+
* endpoint) or a local command. Configured in `.nativ/config.json` (git-ignored, so a webhook secret stays
|
|
4
|
+
* local) as `"notify": { "webhook": "...", "command": "...", "events": [...] }`, or NATIV_NOTIFY_WEBHOOK /
|
|
5
|
+
* NATIV_NOTIFY_COMMAND. Delivery is best-effort and never fails the command that triggered it.
|
|
6
|
+
*/
|
|
7
|
+
export type NotifyEvent = 'human_decision' | 'circuit_breaker_tripped';
|
|
8
|
+
export declare const NOTIFY_EVENTS: readonly NotifyEvent[];
|
|
9
|
+
export interface NotifyConfig {
|
|
10
|
+
webhook?: string;
|
|
11
|
+
command?: string;
|
|
12
|
+
/** Set when a configured webhook was refused (not https, or plain http off this machine). */
|
|
13
|
+
rejectedWebhook?: string;
|
|
14
|
+
events: NotifyEvent[];
|
|
15
|
+
}
|
|
16
|
+
export interface NotifyInput {
|
|
17
|
+
event: NotifyEvent;
|
|
18
|
+
taskId: string;
|
|
19
|
+
escalationId?: string;
|
|
20
|
+
summary: string;
|
|
21
|
+
question?: string;
|
|
22
|
+
options?: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface NotifyResult {
|
|
25
|
+
sent: boolean;
|
|
26
|
+
skipped?: 'not_configured' | 'event_disabled' | 'already_sent';
|
|
27
|
+
errors: string[];
|
|
28
|
+
}
|
|
29
|
+
export interface NotifyDeps {
|
|
30
|
+
fetch?: typeof fetch;
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
/** Resend even when this event was already delivered (used by `nativ notify test`). */
|
|
33
|
+
force?: boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Plain http would send the payload, and often a token in the URL, in the clear; it is allowed only to this machine. */
|
|
36
|
+
export declare function isAllowedWebhook(url: string): boolean;
|
|
37
|
+
export declare function loadNotifyConfig(root: string, env?: NodeJS.ProcessEnv): NotifyConfig | null;
|
|
38
|
+
export declare function notifyHuman(root: string, input: NotifyInput, deps?: NotifyDeps): Promise<NotifyResult>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { writeJsonAtomicSync } from './lock-manager.js';
|
|
5
|
+
export const NOTIFY_EVENTS = ['human_decision', 'circuit_breaker_tripped'];
|
|
6
|
+
const WEBHOOK_TIMEOUT_MS = 5_000;
|
|
7
|
+
const COMMAND_TIMEOUT_MS = 10_000;
|
|
8
|
+
const stateFile = (root) => path.join(root, '.nativ', 'notify-state.json');
|
|
9
|
+
/** Plain http would send the payload, and often a token in the URL, in the clear; it is allowed only to this machine. */
|
|
10
|
+
export function isAllowedWebhook(url) {
|
|
11
|
+
try {
|
|
12
|
+
const u = new URL(url);
|
|
13
|
+
return u.protocol === 'https:' || (u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function loadNotifyConfig(root, env = process.env) {
|
|
20
|
+
let file = {};
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, '.nativ', 'config.json'), 'utf8'));
|
|
23
|
+
if (parsed && typeof parsed.notify === 'object' && parsed.notify)
|
|
24
|
+
file = parsed.notify;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// No config file, or an unreadable one: notifications stay off unless the environment sets them.
|
|
28
|
+
}
|
|
29
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
30
|
+
const webhook = str(env.NATIV_NOTIFY_WEBHOOK) ?? str(file.webhook);
|
|
31
|
+
const command = str(env.NATIV_NOTIFY_COMMAND) ?? str(file.command);
|
|
32
|
+
const listed = Array.isArray(file.events) ? file.events.filter((e) => NOTIFY_EVENTS.includes(e)) : [];
|
|
33
|
+
if (!webhook && !command)
|
|
34
|
+
return null;
|
|
35
|
+
return {
|
|
36
|
+
...(webhook && isAllowedWebhook(webhook) ? { webhook } : {}),
|
|
37
|
+
...(webhook && !isAllowedWebhook(webhook) ? { rejectedWebhook: webhook } : {}),
|
|
38
|
+
...(command ? { command } : {}),
|
|
39
|
+
events: listed.length ? listed : [...NOTIFY_EVENTS],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function buildPayload(root, input) {
|
|
43
|
+
const project = path.basename(root);
|
|
44
|
+
const nextStep = input.escalationId ? `nativ triage ${input.escalationId}` : `nativ status`;
|
|
45
|
+
const lines = [`nativ [${project}] needs you: ${input.summary}`, `Task ${input.taskId}${input.escalationId ? `, ${input.escalationId}` : ''}.`];
|
|
46
|
+
if (input.question)
|
|
47
|
+
lines.push(input.question);
|
|
48
|
+
if (input.options?.length)
|
|
49
|
+
lines.push(`Options: ${input.options.join(' | ')}`);
|
|
50
|
+
lines.push(`Next: ${nextStep}`);
|
|
51
|
+
const text = lines.join('\n');
|
|
52
|
+
// `text` suits Slack and ntfy, `content` suits Discord; the structured fields suit everything else.
|
|
53
|
+
return { ...input, project, nextStep, at: new Date().toISOString(), text, content: text };
|
|
54
|
+
}
|
|
55
|
+
function readSent(root) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(fs.readFileSync(stateFile(root), 'utf8'));
|
|
58
|
+
return parsed && typeof parsed.sent === 'object' ? parsed.sent : {};
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function runCommand(command, payload) {
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const body = JSON.stringify(payload);
|
|
67
|
+
const child = spawn(command, {
|
|
68
|
+
shell: true,
|
|
69
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
70
|
+
env: { ...process.env, NATIV_NOTIFY_JSON: body },
|
|
71
|
+
});
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
child.kill();
|
|
74
|
+
resolve('command timed out');
|
|
75
|
+
}, COMMAND_TIMEOUT_MS);
|
|
76
|
+
child.on('error', (err) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
resolve(`command failed: ${err.message}`);
|
|
79
|
+
});
|
|
80
|
+
child.on('exit', (code) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
resolve(code === 0 ? null : `command exited with ${code}`);
|
|
83
|
+
});
|
|
84
|
+
child.stdin?.on('error', () => { });
|
|
85
|
+
child.stdin?.end(body);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
export async function notifyHuman(root, input, deps = {}) {
|
|
89
|
+
const config = loadNotifyConfig(root, deps.env);
|
|
90
|
+
if (!config)
|
|
91
|
+
return { sent: false, skipped: 'not_configured', errors: [] };
|
|
92
|
+
if (!config.events.includes(input.event))
|
|
93
|
+
return { sent: false, skipped: 'event_disabled', errors: [] };
|
|
94
|
+
const key = `${input.event}:${input.escalationId ?? input.taskId}`;
|
|
95
|
+
const sent = readSent(root);
|
|
96
|
+
if (sent[key] && !deps.force)
|
|
97
|
+
return { sent: false, skipped: 'already_sent', errors: [] };
|
|
98
|
+
const payload = buildPayload(root, input);
|
|
99
|
+
const errors = config.rejectedWebhook ? ['webhook must be an https:// URL (plain http only to localhost)'] : [];
|
|
100
|
+
let delivered = false;
|
|
101
|
+
if (config.webhook) {
|
|
102
|
+
try {
|
|
103
|
+
const res = await (deps.fetch ?? fetch)(config.webhook, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify(payload),
|
|
107
|
+
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
|
|
108
|
+
});
|
|
109
|
+
if (res.ok)
|
|
110
|
+
delivered = true;
|
|
111
|
+
else
|
|
112
|
+
errors.push(`webhook answered ${res.status}`);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
errors.push(`webhook failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (config.command) {
|
|
119
|
+
const error = await runCommand(config.command, payload);
|
|
120
|
+
if (error)
|
|
121
|
+
errors.push(error);
|
|
122
|
+
else
|
|
123
|
+
delivered = true;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const next = { ...readSent(root), ...(delivered ? { [key]: payload.at } : {}) };
|
|
127
|
+
writeJsonAtomicSync(stateFile(root), { sent: next, ...(errors.length ? { lastError: { at: payload.at, errors } } : {}) });
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// The state file only prevents repeats; failing to write it must not fail the caller.
|
|
131
|
+
}
|
|
132
|
+
return { sent: delivered, errors };
|
|
133
|
+
}
|
package/dist/core/profiles.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseJsonLoose } from './enforcement.js';
|
|
4
|
+
import { mutateJsonFileSync } from './lock-manager.js';
|
|
4
5
|
export const PROFILE_DEFAULTS = {
|
|
5
6
|
prototype: {
|
|
6
7
|
profile: 'prototype',
|
|
@@ -61,22 +62,12 @@ export function resolveActiveProfileSettings(root) {
|
|
|
61
62
|
return base;
|
|
62
63
|
}
|
|
63
64
|
export function setProfile(root, profile) {
|
|
64
|
-
const configPath = path.join(root, CONFIG_FILE);
|
|
65
|
-
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
66
|
-
let existing = {};
|
|
67
|
-
try {
|
|
68
|
-
existing = parseJsonLoose(fs.readFileSync(configPath, 'utf8')) || {};
|
|
69
|
-
}
|
|
70
|
-
catch {
|
|
71
|
-
existing = {};
|
|
72
|
-
}
|
|
73
65
|
const profileSettings = PROFILE_DEFAULTS[profile];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
fs.renameSync(tmp, configPath);
|
|
66
|
+
mutateJsonFileSync(path.join(root, CONFIG_FILE), (config) => {
|
|
67
|
+
config.profile = profile;
|
|
68
|
+
config.enforcement = profileSettings.enforcement;
|
|
69
|
+
config.governorMode = profileSettings.governorMode;
|
|
70
|
+
config.testIntegrity = profileSettings.testIntegrity;
|
|
71
|
+
});
|
|
81
72
|
return profileSettings;
|
|
82
73
|
}
|
|
@@ -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[];
|