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,187 @@
|
|
|
1
|
+
// MCP server (stdio, newline-delimited JSON-RPC) so agents use docket natively:
|
|
2
|
+
// look up a loop's context, check the warrant before acting, leave record entries.
|
|
3
|
+
// Zero dependencies — the protocol surface we need is small.
|
|
4
|
+
|
|
5
|
+
import readline from 'node:readline';
|
|
6
|
+
import { parseArgs } from '../lib/args.js';
|
|
7
|
+
import { requireDocketDir, listLoops, loadLoop, loopExists, loopNames, ACTIONS } from '../lib/loop.js';
|
|
8
|
+
import { checkWarrant } from '../lib/warrant.js';
|
|
9
|
+
import { appendRecord, collectRecordFields, recordCheck } from '../lib/record.js';
|
|
10
|
+
import { renderLoop } from '../lib/compile.js';
|
|
11
|
+
import { VERSION } from '../lib/pkg.js';
|
|
12
|
+
|
|
13
|
+
const TOOLS = [
|
|
14
|
+
{
|
|
15
|
+
name: 'docket_list_loops',
|
|
16
|
+
description:
|
|
17
|
+
'List the loops the human has defined. Each loop is one recurring task with brief, procedure, warrant, record, and reserved layers.',
|
|
18
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'docket_loop_context',
|
|
22
|
+
description:
|
|
23
|
+
'Get the full context for a loop before starting work on it: what you must know, how the work is done, what you may do on your own, where you must stop, and what evidence you owe. Call this FIRST, before doing the task.',
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: { loop: { type: 'string', description: 'loop name' } },
|
|
27
|
+
required: ['loop'],
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: 'docket_warrant_check',
|
|
33
|
+
description:
|
|
34
|
+
'Check whether an action is covered by the warrant of a loop BEFORE doing it. Returns allow, ask, or deny. "ask" means stop and get human approval; "deny" means never. The check itself is written to the record. Call this before any read/draft/change/send that could matter.',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
loop: { type: 'string', description: 'loop name' },
|
|
39
|
+
action: { type: 'string', enum: ACTIONS, description: 'what kind of act this is' },
|
|
40
|
+
target: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'what the action touches, in plain words (e.g. "appeal email to the insurer")',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['loop', 'action', 'target'],
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'docket_record',
|
|
51
|
+
description:
|
|
52
|
+
'Add to the record: what you saw, what you did, what you skipped or left alone, and where you stopped. The record is hash-chained and verifiable. Write to it whenever you finish or stop work on a loop.',
|
|
53
|
+
inputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
loop: { type: 'string', description: 'loop name' },
|
|
57
|
+
saw: { type: 'string', description: 'sources and state you consulted' },
|
|
58
|
+
did: { type: 'string', description: 'what you actually did' },
|
|
59
|
+
skipped: { type: 'string', description: 'what you deliberately left alone' },
|
|
60
|
+
stopped: { type: 'string', description: 'where and why you stopped' },
|
|
61
|
+
note: { type: 'string', description: 'anything else a human should be able to trust' },
|
|
62
|
+
},
|
|
63
|
+
required: ['loop'],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function textResult(text, isError = false) {
|
|
70
|
+
return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function handleToolCall(docketDir, name, args = {}) {
|
|
74
|
+
switch (name) {
|
|
75
|
+
case 'docket_list_loops': {
|
|
76
|
+
const loops = listLoops(docketDir);
|
|
77
|
+
if (!loops.length) return textResult('No loops defined yet.');
|
|
78
|
+
return textResult(loops.map((l) => `${l.name}: ${l.description}`).join('\n'));
|
|
79
|
+
}
|
|
80
|
+
case 'docket_loop_context': {
|
|
81
|
+
const loop = loadLoop(docketDir, args.loop);
|
|
82
|
+
return textResult(renderLoop(loop));
|
|
83
|
+
}
|
|
84
|
+
case 'docket_warrant_check': {
|
|
85
|
+
const loop = loadLoop(docketDir, args.loop);
|
|
86
|
+
const result = checkWarrant(loop, args.action, args.target);
|
|
87
|
+
recordCheck(docketDir, loop.name, args.action, args.target, result, { via: 'mcp' });
|
|
88
|
+
const instruction = {
|
|
89
|
+
allow: 'Proceed.',
|
|
90
|
+
ask: 'STOP. Do not do this yet — tell the human what you want to do and why, and wait for approval.',
|
|
91
|
+
deny: 'Do NOT do this, even if asked again in this session. It is outside the loop entirely.',
|
|
92
|
+
}[result.verdict];
|
|
93
|
+
return textResult(
|
|
94
|
+
`verdict: ${result.verdict}\nrule: ${result.rule}\n${result.reason}\n${instruction}`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
case 'docket_record': {
|
|
98
|
+
if (!loopExists(docketDir, args.loop)) {
|
|
99
|
+
return textResult(
|
|
100
|
+
`no loop named "${args.loop}" — have: ${loopNames(docketDir).join(', ') || '(none)'}`,
|
|
101
|
+
true
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
const { fields, dropped } = collectRecordFields(args);
|
|
105
|
+
if (dropped.length) {
|
|
106
|
+
return textResult(`these fields were empty, refusing to lose evidence: ${dropped.join(', ')}`, true);
|
|
107
|
+
}
|
|
108
|
+
if (!Object.keys(fields).length) {
|
|
109
|
+
return textResult('a record entry needs at least one of: saw, did, skipped, stopped, note', true);
|
|
110
|
+
}
|
|
111
|
+
const entry = appendRecord(docketDir, { loop: args.loop, kind: 'note', via: 'mcp', ...fields });
|
|
112
|
+
return textResult(`record #${entry.seq} appended (${entry.hash.slice(0, 23)}…)`);
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return textResult(`unknown tool: ${name}`, true);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function cmdMcp(argv = []) {
|
|
120
|
+
// MCP hosts often spawn servers with cwd '/' — resolve the project from
|
|
121
|
+
// --dir (or DOCKET_DIR) so the config can say where the loops live, and
|
|
122
|
+
// never die before initialize: a server that crashes on startup surfaces
|
|
123
|
+
// as an opaque "disconnected" in the client.
|
|
124
|
+
const { flags } = parseArgs(argv);
|
|
125
|
+
const startDir = flags.dir ?? process.env.DOCKET_DIR ?? process.cwd();
|
|
126
|
+
let docketDir = null;
|
|
127
|
+
let startupError = null;
|
|
128
|
+
try {
|
|
129
|
+
docketDir = requireDocketDir(startDir);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
startupError = `${err.message} (searched upward from ${startDir}; pass --dir <project> or set DOCKET_DIR)`;
|
|
132
|
+
}
|
|
133
|
+
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
134
|
+
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
135
|
+
|
|
136
|
+
rl.on('line', (line) => {
|
|
137
|
+
if (!line.trim()) return;
|
|
138
|
+
let msg;
|
|
139
|
+
try {
|
|
140
|
+
msg = JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const { id, method, params } = msg;
|
|
146
|
+
const reply = (result) => id !== undefined && send({ jsonrpc: '2.0', id, result });
|
|
147
|
+
const fail = (code, message) =>
|
|
148
|
+
id !== undefined && send({ jsonrpc: '2.0', id, error: { code, message } });
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
switch (method) {
|
|
152
|
+
case 'initialize':
|
|
153
|
+
reply({
|
|
154
|
+
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
155
|
+
capabilities: { tools: {} },
|
|
156
|
+
serverInfo: { name: 'docket', version: VERSION },
|
|
157
|
+
});
|
|
158
|
+
break;
|
|
159
|
+
case 'notifications/initialized':
|
|
160
|
+
case 'notifications/cancelled':
|
|
161
|
+
break; // notifications: no response
|
|
162
|
+
case 'ping':
|
|
163
|
+
reply({});
|
|
164
|
+
break;
|
|
165
|
+
case 'tools/list':
|
|
166
|
+
reply({ tools: TOOLS });
|
|
167
|
+
break;
|
|
168
|
+
case 'tools/call':
|
|
169
|
+
if (!docketDir) {
|
|
170
|
+
reply(textResult(startupError, true));
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
reply(handleToolCall(docketDir, params?.name, params?.arguments ?? {}));
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
176
|
+
fail(-32601, `method not found: ${method}`);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (id !== undefined) {
|
|
180
|
+
reply(textResult(String(err && err.message ? err.message : err), true));
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Keep the process alive until stdin closes.
|
|
186
|
+
return new Promise((resolve) => rl.on('close', () => resolve(0)));
|
|
187
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import readline from 'node:readline';
|
|
5
|
+
import { parseArgs } from '../lib/args.js';
|
|
6
|
+
import {
|
|
7
|
+
requireDocketDir,
|
|
8
|
+
loopFile,
|
|
9
|
+
parseLoop,
|
|
10
|
+
splitFrontmatter,
|
|
11
|
+
LoopError,
|
|
12
|
+
LOOP_NAME_RE,
|
|
13
|
+
} from '../lib/loop.js';
|
|
14
|
+
import { dumpYaml } from '../lib/yaml.js';
|
|
15
|
+
import { bold, cyan, dim, green } from '../lib/ui.js';
|
|
16
|
+
|
|
17
|
+
const TEMPLATES_DIR = path.join(fileURLToPath(new URL('.', import.meta.url)), '../../templates');
|
|
18
|
+
|
|
19
|
+
export function listTemplates() {
|
|
20
|
+
return fs
|
|
21
|
+
.readdirSync(TEMPLATES_DIR)
|
|
22
|
+
.filter((f) => f.endsWith('.loop.md'))
|
|
23
|
+
.sort()
|
|
24
|
+
.map((f) => {
|
|
25
|
+
const text = fs.readFileSync(path.join(TEMPLATES_DIR, f), 'utf8');
|
|
26
|
+
const loop = parseLoop(text, { file: f });
|
|
27
|
+
return { name: loop.name, description: loop.description, file: path.join(TEMPLATES_DIR, f) };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function cmdTemplates() {
|
|
32
|
+
console.log(bold('Starter loops') + dim(' — each is one recurring task, five layers, ready to edit\n'));
|
|
33
|
+
for (const t of listTemplates()) {
|
|
34
|
+
console.log(` ${cyan(t.name.padEnd(20))} ${t.description}`);
|
|
35
|
+
}
|
|
36
|
+
console.log(dim('\nUse one: docket new <name> --template <template>'));
|
|
37
|
+
return 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function scaffold({ name, description, brief, procedure, warrant, reserved, record }) {
|
|
41
|
+
const frontmatter = dumpYaml({
|
|
42
|
+
name,
|
|
43
|
+
description,
|
|
44
|
+
version: 1,
|
|
45
|
+
warrant,
|
|
46
|
+
reserved,
|
|
47
|
+
record,
|
|
48
|
+
});
|
|
49
|
+
return `---\n${frontmatter}---\n\n# Brief\n\n${brief}\n\n# Procedure\n\n${procedure}\n`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PLACEHOLDER = {
|
|
53
|
+
brief: `<!-- The context that changes the answer: the people involved, the history
|
|
54
|
+
so far, hard constraints, standards, and decisions already made. Without
|
|
55
|
+
this, the agent guesses. -->\n\n- TODO`,
|
|
56
|
+
procedure: `<!-- How this job is done properly. Which sources count, what finished
|
|
57
|
+
looks like, and the known ways it goes wrong. -->\n\n1. TODO`,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function splitList(answer) {
|
|
61
|
+
return answer
|
|
62
|
+
.split(',')
|
|
63
|
+
.map((s) => s.trim())
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function interview(name) {
|
|
68
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
69
|
+
// Buffer lines that arrive while no question is pending — pasted blocks of
|
|
70
|
+
// prepared answers must feed successive questions, not vanish.
|
|
71
|
+
const buffered = [];
|
|
72
|
+
let waiter = null;
|
|
73
|
+
let closed = false;
|
|
74
|
+
rl.on('line', (line) => {
|
|
75
|
+
if (waiter) {
|
|
76
|
+
const w = waiter;
|
|
77
|
+
waiter = null;
|
|
78
|
+
w.resolve(line);
|
|
79
|
+
} else {
|
|
80
|
+
buffered.push(line);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
rl.on('close', () => {
|
|
84
|
+
closed = true;
|
|
85
|
+
if (waiter) {
|
|
86
|
+
const err = new Error('interview aborted');
|
|
87
|
+
err.code = 'ABORT_ERR';
|
|
88
|
+
waiter.reject(err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
const readAnswer = () => {
|
|
92
|
+
if (buffered.length) return Promise.resolve(buffered.shift());
|
|
93
|
+
if (closed) {
|
|
94
|
+
const err = new Error('interview aborted');
|
|
95
|
+
err.code = 'ABORT_ERR';
|
|
96
|
+
return Promise.reject(err);
|
|
97
|
+
}
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
waiter = { resolve, reject };
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const q = async (prompt, hint) => {
|
|
103
|
+
if (hint) console.log(dim(` ${hint}`));
|
|
104
|
+
rl.setPrompt(`${prompt} `);
|
|
105
|
+
rl.prompt();
|
|
106
|
+
const a = await readAnswer();
|
|
107
|
+
console.log();
|
|
108
|
+
return a.trim();
|
|
109
|
+
};
|
|
110
|
+
console.log(
|
|
111
|
+
`\n${bold(`Five questions build the loop "${name}".`)}\n${dim(
|
|
112
|
+
'Unwritten answers get guessed at. Written answers get enforced.'
|
|
113
|
+
)}\n`
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const brief = await q(
|
|
117
|
+
bold('1. What must the agent know before it starts?'),
|
|
118
|
+
'the context that changes the answer: people, history, constraints, standards, past decisions'
|
|
119
|
+
);
|
|
120
|
+
const procedure = await q(
|
|
121
|
+
bold('2. How is this work supposed to be done?'),
|
|
122
|
+
'sources that count, what done looks like, the known ways it goes wrong'
|
|
123
|
+
);
|
|
124
|
+
console.log(bold('3. What may it do without asking?') + dim(' (comma-separated; empty = ask first)'));
|
|
125
|
+
const read = splitList(await q(' read:'));
|
|
126
|
+
const draft = splitList(await q(' draft:'));
|
|
127
|
+
const change = splitList(await q(' change:'));
|
|
128
|
+
const send = splitList(await q(' send:'));
|
|
129
|
+
console.log(bold('4. Where does it have to stop?'));
|
|
130
|
+
const ask = splitList(await q(' always ask before:', 'comma-separated'));
|
|
131
|
+
const never = splitList(await q(' never, even with approval:', 'comma-separated'));
|
|
132
|
+
const reserved = splitList(
|
|
133
|
+
await q(' what stays human:', 'accounts, secrets, permissions, final sign-off')
|
|
134
|
+
);
|
|
135
|
+
const record = splitList(
|
|
136
|
+
await q(
|
|
137
|
+
bold('5. What evidence must it leave behind?'),
|
|
138
|
+
'e.g. sources consulted, what was drafted, what was skipped, where it stopped'
|
|
139
|
+
)
|
|
140
|
+
);
|
|
141
|
+
const description = await q(bold('One-line description of this loop:'));
|
|
142
|
+
rl.close();
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name,
|
|
146
|
+
description: description || `The ${name} loop.`,
|
|
147
|
+
brief: brief ? `- ${brief}` : PLACEHOLDER.brief,
|
|
148
|
+
procedure: procedure ? `1. ${procedure}` : PLACEHOLDER.procedure,
|
|
149
|
+
warrant: { read, draft, change, send, ask, never },
|
|
150
|
+
reserved,
|
|
151
|
+
record,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Rename a template through the frontmatter layer, not blind text
|
|
156
|
+
// substitution — and parseLoop's name-vs-filename check backstops it.
|
|
157
|
+
function withName(templateText, name) {
|
|
158
|
+
const { frontmatter, body } = splitFrontmatter(templateText);
|
|
159
|
+
const renamed = frontmatter.replace(/^\s*name\s*:.*$/m, `name: ${name}`);
|
|
160
|
+
return `---\n${renamed}\n---\n${body}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function cmdNew(argv) {
|
|
164
|
+
const { flags, positional } = parseArgs(argv, { booleans: ['blank'] });
|
|
165
|
+
const name = positional[0];
|
|
166
|
+
if (!name) {
|
|
167
|
+
console.error('usage: docket new <name> [--template <template>] [--blank]');
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
if (!LOOP_NAME_RE.test(name)) {
|
|
171
|
+
console.error('docket: loop names are lowercase letters, digits, and dashes');
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
const docketDir = requireDocketDir();
|
|
175
|
+
const dest = loopFile(docketDir, name);
|
|
176
|
+
if (fs.existsSync(dest)) {
|
|
177
|
+
console.error(`docket: loop "${name}" already exists at ${dest}`);
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let content;
|
|
182
|
+
if (flags.template) {
|
|
183
|
+
const tpl = listTemplates().find((t) => t.name === flags.template);
|
|
184
|
+
if (!tpl) {
|
|
185
|
+
console.error(
|
|
186
|
+
`docket: no template "${flags.template}" — run \`docket templates\` to see them`
|
|
187
|
+
);
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
content = withName(fs.readFileSync(tpl.file, 'utf8'), name);
|
|
191
|
+
} else if (!flags.blank && process.stdin.isTTY && process.stdout.isTTY) {
|
|
192
|
+
try {
|
|
193
|
+
content = scaffold(await interview(name));
|
|
194
|
+
} catch (err) {
|
|
195
|
+
if (err && (err.code === 'ABORT_ERR' || /abort/i.test(err.message ?? ''))) {
|
|
196
|
+
console.error('\ndocket: interview cancelled — nothing written');
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
content = scaffold({
|
|
203
|
+
name,
|
|
204
|
+
description: `TODO: one line on what the ${name} loop does.`,
|
|
205
|
+
brief: PLACEHOLDER.brief,
|
|
206
|
+
procedure: PLACEHOLDER.procedure,
|
|
207
|
+
warrant: { read: [], draft: [], change: [], send: [], ask: [], never: [] },
|
|
208
|
+
reserved: ['final approval'],
|
|
209
|
+
record: ['what it saw', 'what it did', 'what it left alone', 'where it stopped'],
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Validate before writing — a loop that can't be parsed can't protect anyone.
|
|
214
|
+
try {
|
|
215
|
+
parseLoop(content, { file: dest });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
if (err instanceof LoopError) {
|
|
218
|
+
console.error(`docket: refusing to write an invalid loop: ${err.message}`);
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
225
|
+
fs.writeFileSync(dest, content);
|
|
226
|
+
console.log(green('✓') + ` wrote ${path.relative(process.cwd(), dest)}`);
|
|
227
|
+
console.log(dim(` edit it, then: docket show ${name} · docket check ${name} send "…" · docket compile --write`));
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { parseArgs } from '../lib/args.js';
|
|
2
|
+
import { requireDocketDir, loopExists, loopNames } from '../lib/loop.js';
|
|
3
|
+
import {
|
|
4
|
+
appendRecord,
|
|
5
|
+
collectRecordFields,
|
|
6
|
+
readRecords,
|
|
7
|
+
verifyRecord,
|
|
8
|
+
recordFile,
|
|
9
|
+
} from '../lib/record.js';
|
|
10
|
+
import { bold, cyan, dim, green, red, VERDICT_STYLE } from '../lib/ui.js';
|
|
11
|
+
|
|
12
|
+
function formatEntry(e) {
|
|
13
|
+
const ts = dim(e.ts.replace('T', ' ').replace(/\.\d+Z$/, 'Z'));
|
|
14
|
+
const head = `${dim(`#${e.seq}`)} ${ts} ${cyan(e.loop)}`;
|
|
15
|
+
if (e.kind === 'check') {
|
|
16
|
+
const style = VERDICT_STYLE[e.verdict] ?? { color: (s) => s, badge: e.verdict };
|
|
17
|
+
return `${head} ${style.color(style.badge.toLowerCase())} ${e.action} → "${e.target}" ${dim(`(${e.rule})`)}`;
|
|
18
|
+
}
|
|
19
|
+
const parts = [];
|
|
20
|
+
if (e.saw) parts.push(`saw: ${e.saw}`);
|
|
21
|
+
if (e.did) parts.push(`did: ${e.did}`);
|
|
22
|
+
if (e.skipped) parts.push(`skipped: ${e.skipped}`);
|
|
23
|
+
if (e.stopped) parts.push(`stopped: ${e.stopped}`);
|
|
24
|
+
if (e.note) parts.push(e.note);
|
|
25
|
+
return `${head} ${parts.join(' · ') || dim('(empty note)')}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cmdRecord(argv) {
|
|
29
|
+
const [sub, ...rest] = argv;
|
|
30
|
+
switch (sub) {
|
|
31
|
+
case 'add':
|
|
32
|
+
return recordAdd(rest);
|
|
33
|
+
case 'log':
|
|
34
|
+
return recordLog(rest);
|
|
35
|
+
case 'verify':
|
|
36
|
+
return recordVerify(rest);
|
|
37
|
+
default:
|
|
38
|
+
console.error('usage: docket record <add|log|verify>');
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function recordAdd(argv) {
|
|
44
|
+
const { flags, positional } = parseArgs(argv);
|
|
45
|
+
const loopName = positional[0];
|
|
46
|
+
if (!loopName) {
|
|
47
|
+
console.error(
|
|
48
|
+
'usage: docket record add <loop> [--saw ..] [--did ..] [--skipped ..] [--stopped ..] [--note ..]'
|
|
49
|
+
);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
const docketDir = requireDocketDir();
|
|
53
|
+
// Existence check by filename, deliberately not a full parse: a loop file
|
|
54
|
+
// with a frontmatter typo must not block the agent from leaving evidence.
|
|
55
|
+
if (!loopExists(docketDir, loopName)) {
|
|
56
|
+
const available = loopNames(docketDir);
|
|
57
|
+
console.error(
|
|
58
|
+
`docket: no loop named "${loopName}"${available.length ? ` — have: ${available.join(', ')}` : ''}`
|
|
59
|
+
);
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
const { fields, dropped } = collectRecordFields(flags);
|
|
63
|
+
if (dropped.length) {
|
|
64
|
+
// A record entry that silently loses evidence is worse than an error.
|
|
65
|
+
console.error(
|
|
66
|
+
`docket: refusing to write — ${dropped.map((d) => `--${d}`).join(', ')} ` +
|
|
67
|
+
`${dropped.length === 1 ? 'has' : 'have'} no text (empty or missing value)`
|
|
68
|
+
);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
if (!Object.keys(fields).length) {
|
|
72
|
+
console.error('docket: a record entry with nothing in it proves nothing — pass --saw/--did/--skipped/--stopped/--note');
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
const entry = appendRecord(docketDir, { loop: loopName, kind: 'note', via: 'cli', ...fields });
|
|
76
|
+
console.log(green('✓') + ` record #${entry.seq} ${dim(entry.hash.slice(0, 23) + '…')}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function recordLog(argv) {
|
|
81
|
+
const { flags, positional } = parseArgs(argv);
|
|
82
|
+
const docketDir = requireDocketDir();
|
|
83
|
+
const loopName = positional[0];
|
|
84
|
+
const n = Number(flags.n ?? 20);
|
|
85
|
+
let entries = readRecords(docketDir);
|
|
86
|
+
if (loopName) entries = entries.filter((e) => e.loop === loopName);
|
|
87
|
+
if (!entries.length) {
|
|
88
|
+
console.log('no record entries yet');
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
for (const e of entries.slice(-n)) console.log(formatEntry(e));
|
|
92
|
+
console.log(dim(`\n${entries.length} total · file: ${recordFile(docketDir)}`));
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function recordVerify(argv) {
|
|
97
|
+
const { flags } = parseArgs(argv);
|
|
98
|
+
const docketDir = requireDocketDir();
|
|
99
|
+
const result = verifyRecord(docketDir, {
|
|
100
|
+
expectHead: typeof flags.head === 'string' ? flags.head : undefined,
|
|
101
|
+
});
|
|
102
|
+
if (result.ok) {
|
|
103
|
+
console.log(
|
|
104
|
+
green('✓ chain intact') +
|
|
105
|
+
` — ${result.count} entr${result.count === 1 ? 'y' : 'ies'}, every entry commits to the one before it`
|
|
106
|
+
);
|
|
107
|
+
console.log(dim(` head: ${result.head}`));
|
|
108
|
+
console.log(
|
|
109
|
+
dim(' pin this head somewhere the log can\'t reach, then: docket record verify --head <hash>')
|
|
110
|
+
);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
console.error(red(bold('✗ chain broken')) + ` at entry ${result.brokenAt}: ${result.problem}`);
|
|
114
|
+
console.error(dim(' a record that can be edited quietly is not a record'));
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
package/src/lib/args.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Dependency-free flag parsing: `--key value`, `--key=value`, boolean flags.
|
|
2
|
+
//
|
|
3
|
+
// Only flags declared in `booleans` are ever treated as boolean — everything
|
|
4
|
+
// else consumes the next token as its value even if that token starts with
|
|
5
|
+
// `--`. Guessing from the token's shape silently drops user data (a --note
|
|
6
|
+
// whose text begins with a dash would vanish from the record).
|
|
7
|
+
|
|
8
|
+
export function parseArgs(argv, { booleans = [] } = {}) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positional = [];
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const arg = argv[i];
|
|
13
|
+
if (arg === '--') {
|
|
14
|
+
positional.push(...argv.slice(i + 1));
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (arg.startsWith('--')) {
|
|
18
|
+
const eq = arg.indexOf('=');
|
|
19
|
+
if (eq !== -1) {
|
|
20
|
+
flags[arg.slice(2, eq)] = arg.slice(eq + 1);
|
|
21
|
+
} else {
|
|
22
|
+
const key = arg.slice(2);
|
|
23
|
+
if (booleans.includes(key)) {
|
|
24
|
+
flags[key] = true;
|
|
25
|
+
} else if (i + 1 < argv.length) {
|
|
26
|
+
flags[key] = argv[++i];
|
|
27
|
+
} else {
|
|
28
|
+
flags[key] = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} else {
|
|
32
|
+
positional.push(arg);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { flags, positional };
|
|
36
|
+
}
|