docket-agent 0.1.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/LICENSE +21 -0
- package/README.md +268 -0
- package/bin/docket.js +10 -0
- package/eval/REPORT.md +67 -0
- package/eval/run.js +114 -0
- package/eval/scenarios.js +111 -0
- package/package.json +45 -0
- package/spec/SPEC.md +259 -0
- package/src/cli.js +79 -0
- package/src/commands/check.js +41 -0
- package/src/commands/compile.js +45 -0
- package/src/commands/init.js +54 -0
- package/src/commands/list.js +53 -0
- package/src/commands/mcp.js +187 -0
- package/src/commands/new.js +229 -0
- package/src/commands/record.js +116 -0
- package/src/lib/args.js +36 -0
- package/src/lib/compile.js +140 -0
- package/src/lib/loop.js +198 -0
- package/src/lib/pkg.js +5 -0
- package/src/lib/record.js +177 -0
- package/src/lib/ui.js +20 -0
- package/src/lib/warrant.js +142 -0
- package/src/lib/yaml.js +132 -0
- package/templates/client-follow-up.loop.md +59 -0
- package/templates/cross-tool-memory.loop.md +55 -0
- package/templates/insurance-appeal.loop.md +59 -0
- package/templates/marketing-brain.loop.md +62 -0
- package/templates/ticket-handoff.loop.md +59 -0
- package/templates/travel-morning.loop.md +54 -0
- package/templates/weekly-planning.loop.md +55 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Compile loops into the context file your agent already reads.
|
|
2
|
+
// The point of owning the context: a model switch is a recompile, not a re-teach.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { ACTIONS } from './loop.js';
|
|
7
|
+
|
|
8
|
+
export const TARGETS = {
|
|
9
|
+
claude: { file: 'CLAUDE.md', label: 'Claude Code' },
|
|
10
|
+
agents: { file: 'AGENTS.md', label: 'AGENTS.md (ChatGPT/Codex, Zed, …)' },
|
|
11
|
+
gemini: { file: 'GEMINI.md', label: 'Gemini CLI' },
|
|
12
|
+
cursor: { file: path.join('.cursor', 'rules', 'docket.mdc'), label: 'Cursor rules' },
|
|
13
|
+
raw: { file: null, label: 'stdout' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const BEGIN = '<!-- docket:begin — generated by `docket compile`, do not edit by hand -->';
|
|
17
|
+
const END = '<!-- docket:end -->';
|
|
18
|
+
|
|
19
|
+
// Loop prose is rendered verbatim into a file we later re-parse by marker,
|
|
20
|
+
// so the markers must never appear in rendered content. A zero-width space
|
|
21
|
+
// after "docket" keeps quoted markers readable but inert.
|
|
22
|
+
function neutralizeMarkers(text) {
|
|
23
|
+
return text.replace(/<!--\s*docket:/g, '<!-- docket:');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function bulletList(items, indent = '') {
|
|
27
|
+
return items.map((i) => `${indent}- ${i}`).join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One source of truth for how a warrant reads, shared by `docket show`,
|
|
31
|
+
// the compiler, and the MCP context tool — the human must review the same
|
|
32
|
+
// rules the agent operates under.
|
|
33
|
+
export function warrantLines(loop) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
for (const action of ACTIONS) {
|
|
36
|
+
const list = loop.warrant[action];
|
|
37
|
+
lines.push({
|
|
38
|
+
label: action,
|
|
39
|
+
text: list.length ? list.join('; ') : '(nothing — ask first)',
|
|
40
|
+
kind: 'action',
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (loop.warrant.ask.length) {
|
|
44
|
+
lines.push({ label: 'ask', text: loop.warrant.ask.join('; '), kind: 'ask' });
|
|
45
|
+
}
|
|
46
|
+
if (loop.warrant.never.length) {
|
|
47
|
+
lines.push({ label: 'never', text: loop.warrant.never.join('; '), kind: 'never' });
|
|
48
|
+
}
|
|
49
|
+
return lines;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderLoop(loop) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push(`### Loop: ${loop.name}`);
|
|
55
|
+
if (loop.description) lines.push('', `*${loop.description}*`);
|
|
56
|
+
|
|
57
|
+
if (loop.brief) {
|
|
58
|
+
lines.push('', '**Brief — know this before you start:**', '', loop.brief);
|
|
59
|
+
}
|
|
60
|
+
if (loop.procedure) {
|
|
61
|
+
lines.push('', '**Procedure — how this work is done:**', '', loop.procedure);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push('', '**Warrant — what you may do on your own:**', '');
|
|
65
|
+
for (const row of warrantLines(loop)) {
|
|
66
|
+
const label =
|
|
67
|
+
row.kind === 'ask' ? 'always ask before' : row.kind === 'never' ? 'never, even with approval' : row.label;
|
|
68
|
+
lines.push(`- ${label}: ${row.text}`);
|
|
69
|
+
}
|
|
70
|
+
lines.push(
|
|
71
|
+
'',
|
|
72
|
+
'Anything not listed above requires asking a human first. Silence is never permission.'
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
if (loop.reserved.length) {
|
|
76
|
+
lines.push('', '**Reserved — stays human, do not touch:**', '', bulletList(loop.reserved));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (loop.record.length) {
|
|
80
|
+
lines.push(
|
|
81
|
+
'',
|
|
82
|
+
'**Record — when you finish (or stop), report:**',
|
|
83
|
+
'',
|
|
84
|
+
bulletList(loop.record)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return neutralizeMarkers(lines.join('\n'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function renderBlock(loops) {
|
|
91
|
+
const body = loops.map(renderLoop).join('\n\n---\n\n');
|
|
92
|
+
const header = [
|
|
93
|
+
'## Docket loops',
|
|
94
|
+
'',
|
|
95
|
+
'These loops define what you should remember, how the work happens, what you',
|
|
96
|
+
'may do on your own, where you must stop, and what evidence you owe. Boundaries',
|
|
97
|
+
'are not suggestions. When the warrant says ask, stop and ask.',
|
|
98
|
+
].join('\n');
|
|
99
|
+
return `${BEGIN}\n${header}\n\n${body}\n${END}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Locate the managed block: first BEGIN at a line start, LAST END at a line
|
|
103
|
+
// start. Content is marker-neutralized at render time, so a matching END is
|
|
104
|
+
// always a real one.
|
|
105
|
+
function findBlock(text) {
|
|
106
|
+
const beginRe = /^<!-- docket:begin[^\n]*-->/m;
|
|
107
|
+
const beginMatch = beginRe.exec(text);
|
|
108
|
+
if (!beginMatch) return null;
|
|
109
|
+
const endIdx = text.lastIndexOf(`\n${END}`);
|
|
110
|
+
if (endIdx === -1 || endIdx < beginMatch.index) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`found a docket:begin marker without a matching docket:end — the file was ` +
|
|
113
|
+
`edited by hand inside the managed block. Fix or remove the block, then recompile.`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { start: beginMatch.index, end: endIdx + 1 + END.length };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function compileToFile(rootDir, target, loops) {
|
|
120
|
+
const spec = TARGETS[target];
|
|
121
|
+
if (!spec || !spec.file) throw new Error(`target "${target}" cannot be written to a file`);
|
|
122
|
+
const filePath = path.join(rootDir, spec.file);
|
|
123
|
+
const block = renderBlock(loops);
|
|
124
|
+
let existing = '';
|
|
125
|
+
if (fs.existsSync(filePath)) existing = fs.readFileSync(filePath, 'utf8');
|
|
126
|
+
|
|
127
|
+
let next;
|
|
128
|
+
const found = findBlock(existing);
|
|
129
|
+
if (found) {
|
|
130
|
+
next = existing.slice(0, found.start) + block + existing.slice(found.end);
|
|
131
|
+
} else if (existing.trim()) {
|
|
132
|
+
next = existing.replace(/\s*$/, '\n\n') + block + '\n';
|
|
133
|
+
} else {
|
|
134
|
+
next = block + '\n';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
138
|
+
fs.writeFileSync(filePath, next);
|
|
139
|
+
return filePath;
|
|
140
|
+
}
|
package/src/lib/loop.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Loop file parsing and validation.
|
|
2
|
+
//
|
|
3
|
+
// A loop is one recurring task wrapped in five layers:
|
|
4
|
+
// brief — what the agent must know before it starts (markdown body)
|
|
5
|
+
// procedure — how this job is done properly (markdown body)
|
|
6
|
+
// warrant — what it may read / draft / change / send (frontmatter)
|
|
7
|
+
// record — the evidence the agent owes when it stops (frontmatter)
|
|
8
|
+
// reserved — what stays with the human, always (frontmatter)
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { parseYaml } from './yaml.js';
|
|
13
|
+
|
|
14
|
+
export const ACTIONS = ['read', 'draft', 'change', 'send'];
|
|
15
|
+
export const VERDICTS = ['allow', 'ask', 'deny'];
|
|
16
|
+
export const LOOP_NAME_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
17
|
+
export const LOOP_EXT = '.loop.md';
|
|
18
|
+
export const SPEC_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
export class LoopError extends Error {}
|
|
21
|
+
|
|
22
|
+
export function splitFrontmatter(text) {
|
|
23
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
24
|
+
if (!m) {
|
|
25
|
+
throw new LoopError(
|
|
26
|
+
'loop file must start with a `---` YAML frontmatter block (see spec/SPEC.md)'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
return { frontmatter: m[1], body: m[2] };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only headings literally named Brief or Procedure delimit sections; every
|
|
33
|
+
// other line — subheadings, other sections, comments inside fenced code
|
|
34
|
+
// blocks — is content and stays with the section it appears in. Prose the
|
|
35
|
+
// human wrote must never be silently dropped from the compiled context.
|
|
36
|
+
export function extractSections(body) {
|
|
37
|
+
const sections = {};
|
|
38
|
+
let current = null;
|
|
39
|
+
let inFence = false;
|
|
40
|
+
for (const line of body.split(/\r?\n/)) {
|
|
41
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence;
|
|
42
|
+
const h = inFence ? null : line.match(/^#{1,3}\s+(brief|procedure)\s*$/i);
|
|
43
|
+
if (h) {
|
|
44
|
+
current = h[1].toLowerCase();
|
|
45
|
+
sections[current] ??= [];
|
|
46
|
+
} else if (current) {
|
|
47
|
+
sections[current].push(line);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const out = {};
|
|
51
|
+
for (const [k, v] of Object.entries(sections)) {
|
|
52
|
+
out[k] = v.join('\n').trim();
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asStringList(value, where) {
|
|
58
|
+
if (value === null || value === undefined) return [];
|
|
59
|
+
if (!Array.isArray(value)) {
|
|
60
|
+
throw new LoopError(`\`${where}\` must be a list`);
|
|
61
|
+
}
|
|
62
|
+
return value.map((v) => {
|
|
63
|
+
if (typeof v !== 'string' || !v.trim()) {
|
|
64
|
+
throw new LoopError(`\`${where}\` entries must be non-empty strings`);
|
|
65
|
+
}
|
|
66
|
+
return v.trim();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseLoop(text, { file } = {}) {
|
|
71
|
+
const { frontmatter, body } = splitFrontmatter(text);
|
|
72
|
+
let meta;
|
|
73
|
+
try {
|
|
74
|
+
meta = parseYaml(frontmatter);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new LoopError(`${file ? file + ': ' : ''}${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
79
|
+
throw new LoopError('frontmatter must be a YAML map');
|
|
80
|
+
}
|
|
81
|
+
if (typeof meta.name !== 'string' || !LOOP_NAME_RE.test(meta.name)) {
|
|
82
|
+
throw new LoopError('`name` is required and must be lowercase letters, digits, and dashes');
|
|
83
|
+
}
|
|
84
|
+
if (file) {
|
|
85
|
+
const base = path.basename(file);
|
|
86
|
+
if (base.endsWith(LOOP_EXT) && base !== `${meta.name}${LOOP_EXT}`) {
|
|
87
|
+
throw new LoopError(
|
|
88
|
+
`${base}: frontmatter says \`name: ${meta.name}\` but the file is named ${base} — ` +
|
|
89
|
+
`they must match, or record entries get attributed to a loop that cannot be loaded`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const version = meta.version ?? SPEC_VERSION;
|
|
94
|
+
if (version !== SPEC_VERSION) {
|
|
95
|
+
throw new LoopError(
|
|
96
|
+
`loop declares version ${version}, but this docket only understands version ${SPEC_VERSION} — upgrade docket`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const warrantSrc = meta.warrant ?? {};
|
|
101
|
+
if (typeof warrantSrc !== 'object' || Array.isArray(warrantSrc)) {
|
|
102
|
+
throw new LoopError('`warrant` must be a map of action lists');
|
|
103
|
+
}
|
|
104
|
+
const warrant = {};
|
|
105
|
+
for (const action of ACTIONS) {
|
|
106
|
+
warrant[action] = asStringList(warrantSrc[action], `warrant.${action}`);
|
|
107
|
+
}
|
|
108
|
+
warrant.ask = asStringList(warrantSrc.ask, 'warrant.ask');
|
|
109
|
+
warrant.never = asStringList(warrantSrc.never, 'warrant.never');
|
|
110
|
+
|
|
111
|
+
for (const key of Object.keys(warrantSrc)) {
|
|
112
|
+
if (![...ACTIONS, 'ask', 'never'].includes(key)) {
|
|
113
|
+
throw new LoopError(
|
|
114
|
+
`warrant.${key} is not a thing — actions are read/draft/change/send, plus ask/never lists`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sections = extractSections(body);
|
|
120
|
+
const loop = {
|
|
121
|
+
name: meta.name,
|
|
122
|
+
description: typeof meta.description === 'string' ? meta.description : '',
|
|
123
|
+
version,
|
|
124
|
+
warrant,
|
|
125
|
+
reserved: asStringList(meta.reserved, 'reserved'),
|
|
126
|
+
record: asStringList(meta.record, 'record'),
|
|
127
|
+
brief: sections.brief ?? '',
|
|
128
|
+
procedure: sections.procedure ?? '',
|
|
129
|
+
file: file ?? null,
|
|
130
|
+
};
|
|
131
|
+
return loop;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function findDocketDir(startDir = process.cwd()) {
|
|
135
|
+
let dir = path.resolve(startDir);
|
|
136
|
+
for (;;) {
|
|
137
|
+
const candidate = path.join(dir, '.docket');
|
|
138
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
|
|
139
|
+
const parent = path.dirname(dir);
|
|
140
|
+
if (parent === dir) return null;
|
|
141
|
+
dir = parent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function requireDocketDir(startDir = process.cwd()) {
|
|
146
|
+
const dir = findDocketDir(startDir);
|
|
147
|
+
if (!dir) {
|
|
148
|
+
throw new LoopError('no .docket directory found — run `docket init` first');
|
|
149
|
+
}
|
|
150
|
+
return dir;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function loopsDir(docketDir) {
|
|
154
|
+
return path.join(docketDir, 'loops');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loopFile(docketDir, name) {
|
|
158
|
+
return path.join(loopsDir(docketDir), `${name}${LOOP_EXT}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function loopNames(docketDir) {
|
|
162
|
+
const dir = loopsDir(docketDir);
|
|
163
|
+
if (!fs.existsSync(dir)) return [];
|
|
164
|
+
return fs
|
|
165
|
+
.readdirSync(dir)
|
|
166
|
+
.filter((f) => f.endsWith(LOOP_EXT))
|
|
167
|
+
.map((f) => f.slice(0, -LOOP_EXT.length))
|
|
168
|
+
.sort();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function loopExists(docketDir, name) {
|
|
172
|
+
return LOOP_NAME_RE.test(name) && fs.existsSync(loopFile(docketDir, name));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function listLoops(docketDir) {
|
|
176
|
+
const dir = loopsDir(docketDir);
|
|
177
|
+
if (!fs.existsSync(dir)) return [];
|
|
178
|
+
const loops = [];
|
|
179
|
+
for (const entry of fs.readdirSync(dir).sort()) {
|
|
180
|
+
if (!entry.endsWith(LOOP_EXT)) continue;
|
|
181
|
+
const file = path.join(dir, entry);
|
|
182
|
+
loops.push(parseLoop(fs.readFileSync(file, 'utf8'), { file }));
|
|
183
|
+
}
|
|
184
|
+
return loops;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function loadLoop(docketDir, name) {
|
|
188
|
+
const file = loopFile(docketDir, name);
|
|
189
|
+
if (!fs.existsSync(file)) {
|
|
190
|
+
// Names come from filenames, not parses, so one broken sibling loop
|
|
191
|
+
// can't mask the real "no such loop" message.
|
|
192
|
+
const available = loopNames(docketDir);
|
|
193
|
+
throw new LoopError(
|
|
194
|
+
`no loop named "${name}"${available.length ? ` — have: ${available.join(', ')}` : ' — create one with \`docket new\`'}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return parseLoop(fs.readFileSync(file, 'utf8'), { file });
|
|
198
|
+
}
|
package/src/lib/pkg.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// The record: an append-only, hash-chained log of what the agent saw, did,
|
|
2
|
+
// left alone, and where it stopped. Each entry commits to the previous one,
|
|
3
|
+
// so `docket record verify` detects any edit, deletion, or reordering.
|
|
4
|
+
//
|
|
5
|
+
// Storage is a plain JSONL file — human-readable, diff-able, yours.
|
|
6
|
+
//
|
|
7
|
+
// Known limitation (by construction, documented in the spec): truncating the
|
|
8
|
+
// TAIL of the file leaves a valid shorter chain. Pin the head hash somewhere
|
|
9
|
+
// the log can't reach (a password manager, a commit, another machine) and
|
|
10
|
+
// check it with `docket record verify --head <hash>`.
|
|
11
|
+
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
export const GENESIS = 'GENESIS';
|
|
17
|
+
|
|
18
|
+
// The evidentiary fields a note entry may carry. Every writer (CLI, MCP)
|
|
19
|
+
// filters through collectRecordFields so the audit schema has one owner.
|
|
20
|
+
export const RECORD_FIELDS = ['saw', 'did', 'skipped', 'stopped', 'note'];
|
|
21
|
+
|
|
22
|
+
export function collectRecordFields(source) {
|
|
23
|
+
const fields = {};
|
|
24
|
+
const dropped = [];
|
|
25
|
+
for (const key of RECORD_FIELDS) {
|
|
26
|
+
if (!(key in source) || source[key] === undefined) continue;
|
|
27
|
+
if (typeof source[key] === 'string' && source[key].trim()) {
|
|
28
|
+
fields[key] = source[key].trim();
|
|
29
|
+
} else {
|
|
30
|
+
dropped.push(key); // present but empty/non-string — surface, never silently drop
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { fields, dropped };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function recordFile(docketDir) {
|
|
37
|
+
return path.join(docketDir, 'record.jsonl');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function canonicalize(value) {
|
|
41
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]`;
|
|
42
|
+
if (value && typeof value === 'object') {
|
|
43
|
+
const keys = Object.keys(value).sort();
|
|
44
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalize(value[k])}`).join(',')}}`;
|
|
45
|
+
}
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function hashEntry(entry, prev) {
|
|
50
|
+
const { hash, ...rest } = entry;
|
|
51
|
+
return (
|
|
52
|
+
'sha256:' +
|
|
53
|
+
crypto.createHash('sha256').update(`${prev}\n${canonicalize(rest)}`).digest('hex')
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function readRecords(docketDir) {
|
|
58
|
+
const file = recordFile(docketDir);
|
|
59
|
+
if (!fs.existsSync(file)) return [];
|
|
60
|
+
const entries = [];
|
|
61
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
entries.push(JSON.parse(line));
|
|
64
|
+
}
|
|
65
|
+
return entries;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Read only the final entry, without parsing the whole file — appends are
|
|
69
|
+
// the hot path (every warrant check writes one) and the log grows
|
|
70
|
+
// unbounded over months of agent use.
|
|
71
|
+
export function readLastRecord(docketDir) {
|
|
72
|
+
const file = recordFile(docketDir);
|
|
73
|
+
if (!fs.existsSync(file)) return null;
|
|
74
|
+
const size = fs.statSync(file).size;
|
|
75
|
+
if (size === 0) return null;
|
|
76
|
+
const fd = fs.openSync(file, 'r');
|
|
77
|
+
try {
|
|
78
|
+
let chunkSize = 64 * 1024;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const start = Math.max(0, size - chunkSize);
|
|
81
|
+
const buf = Buffer.alloc(size - start);
|
|
82
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
83
|
+
const text = buf.toString('utf8');
|
|
84
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
85
|
+
if (lines.length === 0) return null;
|
|
86
|
+
// Walk backward to the last parseable line: the tail line may be a
|
|
87
|
+
// partial interrupted write, and the chunk-head line may be cut off.
|
|
88
|
+
for (let j = lines.length - 1; j >= 0; j--) {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(lines[j]);
|
|
91
|
+
} catch {
|
|
92
|
+
// not a complete entry — try the line before it
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (start === 0) return null;
|
|
96
|
+
chunkSize *= 4;
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
fs.closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function appendRecord(docketDir, fields) {
|
|
104
|
+
const last = readLastRecord(docketDir);
|
|
105
|
+
const prev = last ? last.hash : GENESIS;
|
|
106
|
+
const entry = {
|
|
107
|
+
seq: last ? last.seq + 1 : 1,
|
|
108
|
+
ts: new Date().toISOString(),
|
|
109
|
+
...fields,
|
|
110
|
+
prev,
|
|
111
|
+
};
|
|
112
|
+
entry.hash = hashEntry(entry, prev);
|
|
113
|
+
fs.appendFileSync(recordFile(docketDir), JSON.stringify(entry) + '\n');
|
|
114
|
+
return entry;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// One writer for warrant-check evidence, whoever asked (CLI or MCP) —
|
|
118
|
+
// the audit schema for checks is defined here and nowhere else.
|
|
119
|
+
export function recordCheck(docketDir, loopName, action, target, result, extra = {}) {
|
|
120
|
+
return appendRecord(docketDir, {
|
|
121
|
+
loop: loopName,
|
|
122
|
+
kind: 'check',
|
|
123
|
+
action,
|
|
124
|
+
target,
|
|
125
|
+
verdict: result.verdict,
|
|
126
|
+
rule: result.rule,
|
|
127
|
+
...extra,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Returns { ok, count, head, brokenAt, problem }.
|
|
132
|
+
export function verifyRecord(docketDir, { expectHead } = {}) {
|
|
133
|
+
const entries = readRecords(docketDir);
|
|
134
|
+
let prev = GENESIS;
|
|
135
|
+
for (let i = 0; i < entries.length; i++) {
|
|
136
|
+
const entry = entries[i];
|
|
137
|
+
if (entry.seq !== i + 1) {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
count: entries.length,
|
|
141
|
+
head: null,
|
|
142
|
+
brokenAt: i + 1,
|
|
143
|
+
problem: `entry ${i + 1} has seq ${entry.seq} — an entry was removed, added, or reordered`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (entry.prev !== prev) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
count: entries.length,
|
|
150
|
+
head: null,
|
|
151
|
+
brokenAt: entry.seq,
|
|
152
|
+
problem: `entry ${entry.seq} does not chain to the previous entry`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
if (hashEntry(entry, prev) !== entry.hash) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
count: entries.length,
|
|
159
|
+
head: null,
|
|
160
|
+
brokenAt: entry.seq,
|
|
161
|
+
problem: `entry ${entry.seq} was modified after it was written`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
prev = entry.hash;
|
|
165
|
+
}
|
|
166
|
+
const head = entries.length ? prev : GENESIS;
|
|
167
|
+
if (expectHead && head !== expectHead && !head.startsWith(expectHead)) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
count: entries.length,
|
|
171
|
+
head,
|
|
172
|
+
brokenAt: entries.length,
|
|
173
|
+
problem: `chain is internally consistent but its head is ${head.slice(0, 23)}…, not the pinned ${expectHead.slice(0, 23)}… — entries were likely truncated from the tail`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, count: entries.length, head, brokenAt: null, problem: null };
|
|
177
|
+
}
|
package/src/lib/ui.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Tiny ANSI helpers. Colors only when writing to a TTY (or FORCE_COLOR).
|
|
2
|
+
|
|
3
|
+
const on =
|
|
4
|
+
process.env.FORCE_COLOR === '1' ||
|
|
5
|
+
(process.stdout.isTTY && process.env.NO_COLOR === undefined && process.env.FORCE_COLOR !== '0');
|
|
6
|
+
|
|
7
|
+
const wrap = (open, close) => (s) => (on ? `\x1b[${open}m${s}\x1b[${close}m` : String(s));
|
|
8
|
+
|
|
9
|
+
export const bold = wrap(1, 22);
|
|
10
|
+
export const dim = wrap(2, 22);
|
|
11
|
+
export const red = wrap(31, 39);
|
|
12
|
+
export const green = wrap(32, 39);
|
|
13
|
+
export const yellow = wrap(33, 39);
|
|
14
|
+
export const cyan = wrap(36, 39);
|
|
15
|
+
|
|
16
|
+
export const VERDICT_STYLE = {
|
|
17
|
+
allow: { color: green, badge: 'ALLOW' },
|
|
18
|
+
ask: { color: yellow, badge: 'ASK' },
|
|
19
|
+
deny: { color: red, badge: 'DENY' },
|
|
20
|
+
};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The warrant engine: answers "what exactly is the agent allowed to do here?"
|
|
2
|
+
// BEFORE the action happens, deterministically, from a file the human wrote.
|
|
3
|
+
//
|
|
4
|
+
// Verdict order (first match wins):
|
|
5
|
+
// 1. `never` → deny (hard stop, no override)
|
|
6
|
+
// 2. `ask` → ask (human approval required)
|
|
7
|
+
// 3. action's allow list → allow
|
|
8
|
+
// 4. anything unlisted → ask (silence is never permission)
|
|
9
|
+
//
|
|
10
|
+
// Matching is ASYMMETRIC by design. ask/never patterns match fuzzily in both
|
|
11
|
+
// directions — an ambiguous target escalates. Allow patterns match strictly:
|
|
12
|
+
// the target must cover everything the pattern names, so a vague target
|
|
13
|
+
// ("email") can never inherit permission from a specific allow entry
|
|
14
|
+
// ("status email to the team"). A phrasing difference may cause an
|
|
15
|
+
// unnecessary ask; it must never cause an accidental allow.
|
|
16
|
+
|
|
17
|
+
import { ACTIONS } from './loop.js';
|
|
18
|
+
|
|
19
|
+
function escapeRe(s) {
|
|
20
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Filler words carry no permission semantics; matching keys on content words.
|
|
24
|
+
const STOPWORDS = new Set([
|
|
25
|
+
'a', 'an', 'the', 'any', 'anything', 'anyone', 'this', 'that', 'these', 'those',
|
|
26
|
+
'to', 'of', 'for', 'with', 'without', 'on', 'in', 'at', 'by', 'about', 'into',
|
|
27
|
+
'it', 'its', 'is', 'are', 'be', 'will', 'even', 'my', 'your', 'our', 'their',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Light stemming, candidate-set style: two words match when any of their
|
|
31
|
+
// suffix-stripped forms coincide ("quotes"→{quote,quot} meets "quote"→{quote},
|
|
32
|
+
// "contacting" meets "contact"). No dictionary — just enough to keep
|
|
33
|
+
// phrasing from deciding permission.
|
|
34
|
+
function stemCandidates(word) {
|
|
35
|
+
const c = new Set([word]);
|
|
36
|
+
const base = word.replace(/'s$/, '');
|
|
37
|
+
c.add(base);
|
|
38
|
+
for (const suffix of ['ing', 'ed', 'es', 's']) {
|
|
39
|
+
if (base.endsWith(suffix) && base.length - suffix.length >= 3) {
|
|
40
|
+
c.add(base.slice(0, -suffix.length));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return c;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sameWord(a, b) {
|
|
47
|
+
for (const cand of stemCandidates(a)) {
|
|
48
|
+
if (stemCandidates(b).has(cand)) return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function contentWords(s) {
|
|
54
|
+
return s.split(/[^a-z0-9']+/).filter((w) => w && !STOPWORDS.has(w));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function subset(inner, outer) {
|
|
58
|
+
return inner.every((iw) => outer.some((ow) => sameWord(iw, ow)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A pattern splits into alternatives on commas, " or ", and " and " —
|
|
62
|
+
// natural-language lists ("secrets, tokens, or passwords") are lists.
|
|
63
|
+
function alternatives(pattern) {
|
|
64
|
+
return pattern
|
|
65
|
+
.split(/\s*,\s*|\s+or\s+|\s+and\s+/)
|
|
66
|
+
.map((s) => s.trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Pattern semantics (case-insensitive throughout):
|
|
71
|
+
// - contains `*` → glob over the whole target (author-explicit, both modes)
|
|
72
|
+
// - all stopwords ("anything") → matches every target (its plain meaning)
|
|
73
|
+
// - otherwise, per alternative:
|
|
74
|
+
// cautious mode (ask/never): substring either direction, or content-word
|
|
75
|
+
// subset either direction — ambiguity escalates to the human.
|
|
76
|
+
// strict mode (allow): the target must contain the pattern — as a
|
|
77
|
+
// substring or as a content-word superset. The reverse never allows.
|
|
78
|
+
export function matchPattern(pattern, target, { strict = false } = {}) {
|
|
79
|
+
const p = pattern.trim().toLowerCase();
|
|
80
|
+
const t = target.trim().toLowerCase();
|
|
81
|
+
if (!p || !t) return false;
|
|
82
|
+
if (p.includes('*')) {
|
|
83
|
+
const re = new RegExp(`^${p.split('*').map(escapeRe).join('.*')}$`);
|
|
84
|
+
return re.test(t);
|
|
85
|
+
}
|
|
86
|
+
const tWords = contentWords(t);
|
|
87
|
+
return alternatives(p).some((alt) => {
|
|
88
|
+
const pWords = contentWords(alt);
|
|
89
|
+
if (!pWords.length) return true; // "anything" means anything
|
|
90
|
+
if (t.includes(alt)) return true;
|
|
91
|
+
if (subset(pWords, tWords)) return true;
|
|
92
|
+
if (strict) return false;
|
|
93
|
+
return alt.includes(t) || (tWords.length > 0 && subset(tWords, pWords));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstMatch(patterns, target, opts) {
|
|
98
|
+
for (const pattern of patterns) {
|
|
99
|
+
if (matchPattern(pattern, target, opts)) return pattern;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function checkWarrant(loop, action, target) {
|
|
105
|
+
if (!ACTIONS.includes(action)) {
|
|
106
|
+
throw new Error(`unknown action "${action}" — actions are: ${ACTIONS.join(', ')}`);
|
|
107
|
+
}
|
|
108
|
+
const b = loop.warrant;
|
|
109
|
+
|
|
110
|
+
const never = firstMatch(b.never, target);
|
|
111
|
+
if (never) {
|
|
112
|
+
return {
|
|
113
|
+
verdict: 'deny',
|
|
114
|
+
rule: `never: ${never}`,
|
|
115
|
+
reason: `"${target}" matches a hard stop. The loop says this never happens, with or without approval.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const ask = firstMatch(b.ask, target);
|
|
120
|
+
if (ask) {
|
|
121
|
+
return {
|
|
122
|
+
verdict: 'ask',
|
|
123
|
+
rule: `ask: ${ask}`,
|
|
124
|
+
reason: `"${target}" always needs human approval in this loop.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const allow = firstMatch(b[action], target, { strict: true });
|
|
129
|
+
if (allow) {
|
|
130
|
+
return {
|
|
131
|
+
verdict: 'allow',
|
|
132
|
+
rule: `${action}: ${allow}`,
|
|
133
|
+
reason: `"${target}" is within the ${action} warrant.`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
verdict: 'ask',
|
|
139
|
+
rule: 'default',
|
|
140
|
+
reason: `"${target}" is not listed under \`${action}\`. Unlisted means ask — silence is never permission.`,
|
|
141
|
+
};
|
|
142
|
+
}
|