driftseal 0.2.0 → 0.3.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/README.md CHANGED
@@ -12,7 +12,7 @@ Before an agent touches the code, DriftSeal records what this round will accompl
12
12
  seal intent → do the work → prove the result → close the round
13
13
  ```
14
14
 
15
- **One open intent. One declared proof. One durable trail.** No service, no database, no runtime dependencies—just a Node.js CLI and plain files that travel with the repo.
15
+ **One open intent. One declared proof. One durable trail.** No service and no database—just local Node.js tools and plain files that travel with the repo.
16
16
 
17
17
  ## The problem is not speed. It is drift.
18
18
 
@@ -46,6 +46,44 @@ npm link
46
46
 
47
47
  The package includes `skills/use-driftseal`, an agent-agnostic companion skill that drives repository work through the complete DriftSeal loop while keeping decision records selective. Install or link it using your agent runtime’s skill discovery convention, then invoke `use-driftseal` by name.
48
48
 
49
+ ## Use DriftSeal through MCP
50
+
51
+ The same package includes `driftseal-mcp`, a local stdio MCP server. It exposes
52
+ structured tools for the complete intent and decision workflow while reusing the
53
+ same locking, WAL, atomic-write, schema, and recovery implementation as the CLI.
54
+ The server never shells out to `driftseal` and does not parse CLI output.
55
+
56
+ Fix the server to one repository when starting it:
57
+
58
+ ```sh
59
+ driftseal-mcp --root /absolute/path/to/repository
60
+ ```
61
+
62
+ For Codex, add the installed command as a stdio MCP server:
63
+
64
+ ```sh
65
+ codex mcp add driftseal -- driftseal-mcp --root /absolute/path/to/repository
66
+ ```
67
+
68
+ The root is startup configuration, not a tool input. In MCP mode DriftSeal also
69
+ ignores inherited `DRIFTSEAL_HOME` and `DRIFTSEAL_DECISION_HOME` overrides, so a
70
+ tool call cannot redirect writes outside the selected repository.
71
+
72
+ The v1 server provides:
73
+
74
+ | MCP capability | Purpose |
75
+ | --- | --- |
76
+ | `driftseal_status`, `driftseal_log` | Read the current intent and intent history. |
77
+ | `driftseal_begin`, `driftseal_end` | Open and honestly close a work round. |
78
+ | `driftseal_decision_list`, `driftseal_decision_show` | Find and read MADR records. |
79
+ | `driftseal_decision_add`, `driftseal_decision_update` | Add selective decisions and reconcile linked ones. |
80
+ | `driftseal://intent/current` | Read the current intent as a JSON resource. |
81
+ | `driftseal://intents/recent` | Read the ten most recent intents as a JSON resource. |
82
+ | `driftseal://decisions` | Read the decision catalog as a JSON resource. |
83
+
84
+ The companion skill remains important: MCP supplies controlled, structured
85
+ operations; the skill teaches the agent when to use them and how to avoid drift.
86
+
49
87
  ## A work round
50
88
 
51
89
  Declare the round before changing files:
package/README.zh-CN.md CHANGED
@@ -12,7 +12,7 @@ Agentic coding 可以很快。**DriftSeal 让这种快不以失控为代价。**
12
12
  封存 intent → 执行工作 → 证明结果 → 关闭本轮
13
13
  ```
14
14
 
15
- **一个 open intent,一份预先声明的验证标准,一条可靠留存的工作轨迹。** 不需要 service,不需要 database,也没有 runtime dependencies;只有一个 Node.js CLI,以及跟着 repo 一起走的普通文件。
15
+ **一个 open intent,一份预先声明的验证标准,一条可靠留存的工作轨迹。** 不需要 service,也不需要 database;只有本地 Node.js tools,以及跟着 repo 一起走的普通文件。
16
16
 
17
17
  ## 真正麻烦的不是慢,而是偏航
18
18
 
@@ -46,6 +46,44 @@ npm link
46
46
 
47
47
  npm package 内含 `skills/use-driftseal`。这是一个不绑定特定 agent runtime 的配套 skill,会按完整 DriftSeal 闭环执行仓库任务,同时克制地使用 decision record。按照所用 agent runtime 的 skill discovery 约定安装或 link,之后通过名称 `use-driftseal` 调用即可。
48
48
 
49
+ ## 通过 MCP 使用 DriftSeal
50
+
51
+ 同一个 package 还提供本地 stdio MCP server:`driftseal-mcp`。它为完整的
52
+ intent 与 decision 工作流提供结构化 tools,并与 CLI 复用同一套锁、WAL、
53
+ atomic write、schema 和 recovery 实现。server 不会启动 `driftseal` 子进程,
54
+ 也不需要解析 CLI 输出。
55
+
56
+ 启动时把 server 固定到一个 repository:
57
+
58
+ ```sh
59
+ driftseal-mcp --root /absolute/path/to/repository
60
+ ```
61
+
62
+ 在 Codex 中,可以把安装后的命令添加为 stdio MCP server:
63
+
64
+ ```sh
65
+ codex mcp add driftseal -- driftseal-mcp --root /absolute/path/to/repository
66
+ ```
67
+
68
+ root 只能在启动时配置,不是 tool input。MCP 模式也会忽略继承到进程中的
69
+ `DRIFTSEAL_HOME` 和 `DRIFTSEAL_DECISION_HOME` override,因此 tool call 不能把
70
+ 写入重定向到所选 repository 之外。
71
+
72
+ v1 server 提供:
73
+
74
+ | MCP capability | 用途 |
75
+ | --- | --- |
76
+ | `driftseal_status`, `driftseal_log` | 读取当前 intent 和 intent 历史。 |
77
+ | `driftseal_begin`, `driftseal_end` | 开启并诚实关闭一轮工作。 |
78
+ | `driftseal_decision_list`, `driftseal_decision_show` | 查找并读取 MADR record。 |
79
+ | `driftseal_decision_add`, `driftseal_decision_update` | 克制地增加 decision,并 reconcile 已关联的 decision。 |
80
+ | `driftseal://intent/current` | 以 JSON resource 读取当前 intent。 |
81
+ | `driftseal://intents/recent` | 以 JSON resource 读取最近十条 intent。 |
82
+ | `driftseal://decisions` | 以 JSON resource 读取 decision catalog。 |
83
+
84
+ 配套 skill 仍然不可替代:MCP 提供受控、结构化的操作,skill 则告诉 agent
85
+ 何时使用这些操作,以及怎样避免 drift。
86
+
49
87
  ## 一轮标准工作流
50
88
 
51
89
  修改文件前,先声明这轮工作的目标:
@@ -0,0 +1,335 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const { createApi, DECISION_STATUSES, END_STATUSES } = require('./driftseal.js');
6
+
7
+ const SERVER_NAME = 'driftseal';
8
+ const SERVER_VERSION = require('../package.json').version;
9
+
10
+ function parseArguments(argv) {
11
+ let root = process.cwd();
12
+ for (let index = 0; index < argv.length; index++) {
13
+ const argument = argv[index];
14
+ if (argument === '--help' || argument === '-h') return { help: true, root };
15
+ if (argument === '--root') {
16
+ const value = argv[++index];
17
+ if (!value || value.startsWith('-')) throw new Error('--root requires a directory');
18
+ root = value;
19
+ continue;
20
+ }
21
+ if (argument.startsWith('--root=')) {
22
+ const value = argument.slice('--root='.length);
23
+ if (!value) throw new Error('--root requires a directory');
24
+ root = value;
25
+ continue;
26
+ }
27
+ throw new Error(`unknown argument: ${argument}`);
28
+ }
29
+ return { help: false, root };
30
+ }
31
+
32
+ function helpText() {
33
+ return `DriftSeal MCP server
34
+
35
+ usage:
36
+ driftseal-mcp [--root <repository>]
37
+
38
+ The server uses stdio transport and fixes all DriftSeal state to the selected
39
+ repository. Tool calls cannot select another root.`;
40
+ }
41
+
42
+ function jsonText(value) {
43
+ return JSON.stringify(value, null, 2);
44
+ }
45
+
46
+ function success(structuredContent, summary) {
47
+ return {
48
+ structuredContent,
49
+ content: [{ type: 'text', text: summary || jsonText(structuredContent) }],
50
+ };
51
+ }
52
+
53
+ function failure(error) {
54
+ const message = error && error.message ? error.message : String(error);
55
+ return {
56
+ isError: true,
57
+ content: [{ type: 'text', text: `DriftSeal rejected the operation: ${message}` }],
58
+ };
59
+ }
60
+
61
+ function guarded(action) {
62
+ try {
63
+ return action();
64
+ } catch (error) {
65
+ return failure(error);
66
+ }
67
+ }
68
+
69
+ function registerTools(server, api, z) {
70
+ const intentRecord = z.object({
71
+ id: z.string(),
72
+ intent: z.string(),
73
+ verify: z.string().nullable(),
74
+ decisions: z.array(z.string()),
75
+ status: z.enum(END_STATUSES).or(z.literal('in_progress')),
76
+ note: z.string().nullable(),
77
+ verifyResult: z.string().nullable(),
78
+ beganAt: z.string(),
79
+ endedAt: z.string().nullable(),
80
+ });
81
+ const decisionRecord = z.object({
82
+ id: z.string(),
83
+ title: z.string(),
84
+ status: z.enum(DECISION_STATUSES),
85
+ file: z.string(),
86
+ });
87
+ const decisionWithContent = decisionRecord.extend({ content: z.string() });
88
+ const closedStatus = z.enum(END_STATUSES);
89
+ const decisionStatus = z.enum(DECISION_STATUSES);
90
+ const decisionId = z.string().regex(/^\d+$/, 'decision id must contain only digits');
91
+ const nonEmpty = z.string().trim().min(1);
92
+ const readOnly = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
93
+ const localWrite = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
94
+
95
+ server.registerTool(
96
+ 'driftseal_status',
97
+ {
98
+ title: 'Get current DriftSeal intent',
99
+ description:
100
+ 'Inspect the one intent currently in progress before repository work or after context loss. Returns null when no intent is open.',
101
+ inputSchema: {},
102
+ outputSchema: { root: z.string(), intent: intentRecord.nullable() },
103
+ annotations: readOnly,
104
+ },
105
+ async () =>
106
+ guarded(() => {
107
+ const intent = api.status();
108
+ return success(
109
+ { root: api.root, intent },
110
+ intent ? `Intent ${intent.id} is ${intent.status}.` : 'No DriftSeal intent is in progress.'
111
+ );
112
+ })
113
+ );
114
+
115
+ server.registerTool(
116
+ 'driftseal_begin',
117
+ {
118
+ title: 'Begin a DriftSeal intent',
119
+ description:
120
+ 'Open one focused work-round intent before making repository changes. Fails if another intent is already open; close it explicitly first.',
121
+ inputSchema: {
122
+ intent: nonEmpty.describe('Outcome this work round will accomplish.'),
123
+ verify: nonEmpty.optional().describe('Exact command or outcome check that will prove completion.'),
124
+ decisions: z
125
+ .array(decisionId)
126
+ .default([])
127
+ .describe('Existing decision IDs this round may change or explicitly confirm.'),
128
+ },
129
+ outputSchema: { root: z.string(), intent: intentRecord },
130
+ annotations: localWrite,
131
+ },
132
+ async (input) =>
133
+ guarded(() => {
134
+ const intent = api.begin(input);
135
+ return success({ root: api.root, intent }, `Opened DriftSeal intent ${intent.id}.`);
136
+ })
137
+ );
138
+
139
+ server.registerTool(
140
+ 'driftseal_end',
141
+ {
142
+ title: 'Close a DriftSeal intent',
143
+ description:
144
+ 'Close the current work-round intent with an honest terminal status, note, and verification result. Linked decisions must be reconciled before completed or partial closure.',
145
+ inputSchema: {
146
+ id: z.string().optional().describe('Intent ID; omit to close the current open intent.'),
147
+ status: closedStatus.default('completed'),
148
+ note: nonEmpty.optional().describe('What actually happened in the round.'),
149
+ verifyResult: nonEmpty.optional().describe('Concise, honest result of the declared verification.'),
150
+ },
151
+ outputSchema: { root: z.string(), intent: intentRecord },
152
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
153
+ },
154
+ async (input) =>
155
+ guarded(() => {
156
+ const intent = api.end(input);
157
+ return success({ root: api.root, intent }, `Closed DriftSeal intent ${intent.id} as ${intent.status}.`);
158
+ })
159
+ );
160
+
161
+ server.registerTool(
162
+ 'driftseal_log',
163
+ {
164
+ title: 'List DriftSeal intent history',
165
+ description:
166
+ 'Review recent or complete DriftSeal intent history to re-anchor work and understand prior outcomes.',
167
+ inputSchema: { last: z.number().int().positive().max(100).optional() },
168
+ outputSchema: { root: z.string(), intents: z.array(intentRecord) },
169
+ annotations: readOnly,
170
+ },
171
+ async (input) =>
172
+ guarded(() => {
173
+ const intents = api.log(input);
174
+ return success({ root: api.root, intents }, `Found ${intents.length} DriftSeal intent records.`);
175
+ })
176
+ );
177
+
178
+ server.registerTool(
179
+ 'driftseal_decision_list',
180
+ {
181
+ title: 'List DriftSeal decisions',
182
+ description:
183
+ 'Find MADR decision records, optionally filtered by current status. Use this before showing or linking a decision.',
184
+ inputSchema: {
185
+ status: decisionStatus.optional(),
186
+ last: z.number().int().positive().max(100).optional(),
187
+ },
188
+ outputSchema: { root: z.string(), decisions: z.array(decisionRecord) },
189
+ annotations: readOnly,
190
+ },
191
+ async (input) =>
192
+ guarded(() => {
193
+ const decisions = api.decisionList(input);
194
+ return success({ root: api.root, decisions }, `Found ${decisions.length} DriftSeal decisions.`);
195
+ })
196
+ );
197
+
198
+ server.registerTool(
199
+ 'driftseal_decision_show',
200
+ {
201
+ title: 'Show a DriftSeal decision',
202
+ description: 'Read one complete MADR decision record by stable numeric ID.',
203
+ inputSchema: { id: decisionId },
204
+ outputSchema: { root: z.string(), decision: decisionWithContent },
205
+ annotations: readOnly,
206
+ },
207
+ async ({ id }) =>
208
+ guarded(() => {
209
+ const decision = api.decisionShow({ id });
210
+ return success({ root: api.root, decision }, `Loaded DriftSeal decision ${decision.id}.`);
211
+ })
212
+ );
213
+
214
+ server.registerTool(
215
+ 'driftseal_decision_add',
216
+ {
217
+ title: 'Add a DriftSeal decision',
218
+ description:
219
+ 'Create a MADR record only for durable rationale, rejected paths, deferred choices, or costly-to-reverse decisions that Git and the intent log cannot recover.',
220
+ inputSchema: {
221
+ title: nonEmpty,
222
+ context: nonEmpty,
223
+ outcome: nonEmpty,
224
+ status: decisionStatus.default('accepted'),
225
+ drivers: z.array(nonEmpty).default([]),
226
+ options: z.array(nonEmpty).default([]),
227
+ consequences: z.array(nonEmpty).default([]),
228
+ },
229
+ outputSchema: { root: z.string(), decision: decisionWithContent },
230
+ annotations: localWrite,
231
+ },
232
+ async (input) =>
233
+ guarded(() => {
234
+ const decision = api.decisionAdd(input);
235
+ return success({ root: api.root, decision }, `Created DriftSeal decision ${decision.id}.`);
236
+ })
237
+ );
238
+
239
+ server.registerTool(
240
+ 'driftseal_decision_update',
241
+ {
242
+ title: 'Reconcile a DriftSeal decision',
243
+ description:
244
+ 'Reconcile one decision linked to the current open intent, updating or explicitly confirming its status with a history note.',
245
+ inputSchema: {
246
+ id: decisionId,
247
+ status: decisionStatus.optional(),
248
+ note: nonEmpty,
249
+ },
250
+ outputSchema: { root: z.string(), decision: decisionWithContent },
251
+ annotations: localWrite,
252
+ },
253
+ async (input) =>
254
+ guarded(() => {
255
+ const decision = api.decisionUpdate(input);
256
+ return success({ root: api.root, decision }, `Reconciled DriftSeal decision ${decision.id}.`);
257
+ })
258
+ );
259
+ }
260
+
261
+ function registerResources(server, api) {
262
+ const registerJson = (name, uri, title, description, read) => {
263
+ server.registerResource(
264
+ name,
265
+ uri,
266
+ { title, description, mimeType: 'application/json' },
267
+ async () => {
268
+ const value = read();
269
+ return { contents: [{ uri, mimeType: 'application/json', text: jsonText(value) }] };
270
+ }
271
+ );
272
+ };
273
+
274
+ registerJson(
275
+ 'current-intent',
276
+ 'driftseal://intent/current',
277
+ 'Current DriftSeal intent',
278
+ 'The work-round intent currently in progress for the fixed repository.',
279
+ () => ({ root: api.root, intent: api.status() })
280
+ );
281
+ registerJson(
282
+ 'recent-intents',
283
+ 'driftseal://intents/recent',
284
+ 'Recent DriftSeal intents',
285
+ 'The ten most recent work-round intent records for the fixed repository.',
286
+ () => ({ root: api.root, intents: api.log({ last: 10 }) })
287
+ );
288
+ registerJson(
289
+ 'decision-catalog',
290
+ 'driftseal://decisions',
291
+ 'DriftSeal decision catalog',
292
+ 'All MADR decision summaries for the fixed repository.',
293
+ () => ({ root: api.root, decisions: api.decisionList() })
294
+ );
295
+ }
296
+
297
+ async function createServer({ root }) {
298
+ const [{ McpServer }, { StdioServerTransport }, zod] = await Promise.all([
299
+ import('@modelcontextprotocol/sdk/server/mcp.js'),
300
+ import('@modelcontextprotocol/sdk/server/stdio.js'),
301
+ import('zod'),
302
+ ]);
303
+ const z = zod.z || zod.default || zod;
304
+ const api = createApi({ root: path.resolve(root), isolateStorage: true });
305
+ const server = new McpServer(
306
+ { name: SERVER_NAME, version: SERVER_VERSION },
307
+ {
308
+ instructions:
309
+ 'Use driftseal_status before repository changes or after context loss. Open one focused intent with driftseal_begin before changes, then run the declared verification and close it honestly with driftseal_end. Reconcile every linked decision before completed or partial closure.',
310
+ }
311
+ );
312
+ registerTools(server, api, z);
313
+ registerResources(server, api);
314
+ return { server, transport: new StdioServerTransport(), root: api.root };
315
+ }
316
+
317
+ async function main() {
318
+ const options = parseArguments(process.argv.slice(2));
319
+ if (options.help) {
320
+ process.stdout.write(helpText() + '\n');
321
+ return;
322
+ }
323
+ const { server, transport } = await createServer(options);
324
+ await server.connect(transport);
325
+ }
326
+
327
+ module.exports = { createServer, helpText, parseArguments, registerResources, registerTools };
328
+
329
+ if (require.main === module) {
330
+ main().catch((error) => {
331
+ const message = error && error.message ? error.message : String(error);
332
+ console.error(`driftseal-mcp: error: ${message}`);
333
+ process.exitCode = 1;
334
+ });
335
+ }
package/bin/driftseal.js CHANGED
@@ -40,6 +40,42 @@ const LOCK_STALE_MS = 30 * 60 * 1000;
40
40
  const LOCK_INIT_STALE_MS = 5 * 1000;
41
41
  const MAX_DECISION_SLUG_LENGTH = 180;
42
42
 
43
+ class DriftSealError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'DriftSealError';
47
+ }
48
+ }
49
+
50
+ let activeOutput = null;
51
+
52
+ function printLine(value = '') {
53
+ const text = String(value);
54
+ if (activeOutput) {
55
+ activeOutput.stdout += text + '\n';
56
+ return;
57
+ }
58
+ console.log(text);
59
+ }
60
+
61
+ function printError(value = '') {
62
+ const text = String(value);
63
+ if (activeOutput) {
64
+ activeOutput.stderr += text + '\n';
65
+ return;
66
+ }
67
+ console.error(text);
68
+ }
69
+
70
+ function writeOutput(value) {
71
+ const text = String(value);
72
+ if (activeOutput) {
73
+ activeOutput.stdout += text;
74
+ return;
75
+ }
76
+ process.stdout.write(text);
77
+ }
78
+
43
79
  if (process.env._DRIFTSEAL_TEST_UMASK) {
44
80
  process.umask(Number.parseInt(process.env._DRIFTSEAL_TEST_UMASK, 8));
45
81
  }
@@ -176,7 +212,8 @@ function readEvents({ repairTail = false } = {}) {
176
212
  .map((line, i) => {
177
213
  try {
178
214
  return normalizeEvent(JSON.parse(line), i + 1);
179
- } catch {
215
+ } catch (err) {
216
+ if (err instanceof DriftSealError) throw err;
180
217
  fail(`corrupt log line ${i + 1} in ${file}`);
181
218
  }
182
219
  });
@@ -928,8 +965,7 @@ function closeIntentAsEscape(events, record, requestedStatus, note, verifyResult
928
965
  }
929
966
 
930
967
  function fail(msg) {
931
- console.error(`driftseal: error: ${msg}`);
932
- process.exit(1);
968
+ throw new DriftSealError(msg);
933
969
  }
934
970
 
935
971
  function positiveInteger(value, flag) {
@@ -1005,6 +1041,32 @@ function render(rec) {
1005
1041
  return lines.join('\n');
1006
1042
  }
1007
1043
 
1044
+ function publicIntent(rec) {
1045
+ if (!rec) return null;
1046
+ return {
1047
+ id: rec.id,
1048
+ intent: rec.intent,
1049
+ verify: rec.verify,
1050
+ decisions: [...rec.decisions],
1051
+ status: rec.status,
1052
+ note: rec.note,
1053
+ verifyResult: rec.verifyResult,
1054
+ beganAt: rec.tsBegin,
1055
+ endedAt: rec.tsEnd,
1056
+ };
1057
+ }
1058
+
1059
+ function publicDecision(decision, { includeContent = false } = {}) {
1060
+ const record = {
1061
+ id: decision.id,
1062
+ title: decision.title,
1063
+ status: decision.status,
1064
+ file: decision.file,
1065
+ };
1066
+ if (includeContent) record.content = decision.content;
1067
+ return record;
1068
+ }
1069
+
1008
1070
  const INTENT_PROTOCOL_MARKER = '<!-- driftseal -->';
1009
1071
  const INTENT_PROTOCOL_END = '<!-- /driftseal -->';
1010
1072
  const DECISION_PROTOCOL_MARKER = '<!-- driftseal-decisions -->';
@@ -1232,19 +1294,21 @@ const commands = {
1232
1294
  'superseded by --force',
1233
1295
  null
1234
1296
  );
1235
- console.error(`driftseal: ${status} ${open.id}`);
1297
+ printError(`driftseal: ${status} ${open.id}`);
1236
1298
  }
1237
1299
 
1238
1300
  const id = nextId(events);
1239
- appendEvent({
1301
+ events.push(appendEvent({
1240
1302
  type: 'begin',
1241
1303
  id,
1242
1304
  ts: new Date().toISOString(),
1243
1305
  intent,
1244
1306
  verify: flags.verify || null,
1245
1307
  decisions,
1246
- });
1247
- console.log(id);
1308
+ }));
1309
+ const record = fold(events).find((candidate) => candidate.id === id);
1310
+ printLine(id);
1311
+ return publicIntent(record);
1248
1312
  },
1249
1313
 
1250
1314
  end(argv) {
@@ -1279,8 +1343,9 @@ const commands = {
1279
1343
  flags.note,
1280
1344
  flags['verify-result']
1281
1345
  );
1282
- console.log(`${target.id} ${terminalStatus}`);
1283
- return;
1346
+ const record = fold(events).find((candidate) => candidate.id === target.id);
1347
+ printLine(`${target.id} ${terminalStatus}`);
1348
+ return publicIntent(record);
1284
1349
  }
1285
1350
 
1286
1351
  if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
@@ -1317,15 +1382,17 @@ const commands = {
1317
1382
  }
1318
1383
  }
1319
1384
 
1320
- appendEvent({
1385
+ events.push(appendEvent({
1321
1386
  type: 'end',
1322
1387
  id: target.id,
1323
1388
  ts: new Date().toISOString(),
1324
1389
  status,
1325
1390
  note: flags.note || null,
1326
1391
  verifyResult: flags['verify-result'] || null,
1327
- });
1328
- console.log(`${target.id} ${status}`);
1392
+ }));
1393
+ const record = fold(events).find((candidate) => candidate.id === target.id);
1394
+ printLine(`${target.id} ${status}`);
1395
+ return publicIntent(record);
1329
1396
  },
1330
1397
 
1331
1398
  status(argv) {
@@ -1333,10 +1400,11 @@ const commands = {
1333
1400
  if (positionals.length > 0) fail('usage: driftseal status');
1334
1401
  const open = openIntent(fold(readEvents({ repairTail: true })));
1335
1402
  if (!open) {
1336
- console.log('no intent in progress');
1337
- return;
1403
+ printLine('no intent in progress');
1404
+ return null;
1338
1405
  }
1339
- console.log(render(open));
1406
+ printLine(render(open));
1407
+ return publicIntent(open);
1340
1408
  },
1341
1409
 
1342
1410
  log(argv) {
@@ -1348,10 +1416,11 @@ const commands = {
1348
1416
  records = records.slice(-n);
1349
1417
  }
1350
1418
  if (records.length === 0) {
1351
- console.log('log is empty');
1352
- return;
1419
+ printLine('log is empty');
1420
+ return [];
1353
1421
  }
1354
- console.log(records.map(render).join('\n\n'));
1422
+ printLine(records.map(render).join('\n\n'));
1423
+ return records.map(publicIntent);
1355
1424
  },
1356
1425
 
1357
1426
  decision(argv) {
@@ -1392,8 +1461,9 @@ const commands = {
1392
1461
  });
1393
1462
  ensureDirectoryDurable(decisionDir());
1394
1463
  atomicCreateFile(path.join(decisionDir(), file), content);
1395
- console.log(path.join(decisionDir(), file));
1396
- return;
1464
+ const decision = findDecision(String(id));
1465
+ printLine(path.join(decisionDir(), file));
1466
+ return publicDecision(decision, { includeContent: true });
1397
1467
  }
1398
1468
 
1399
1469
  if (subcommand === 'update') {
@@ -1431,8 +1501,9 @@ const commands = {
1431
1501
  fail('simulated interruption after decision write');
1432
1502
  }
1433
1503
  appendEvent(reconciliationEvent('decision_reconcile_commit', update));
1434
- console.log(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1435
- return;
1504
+ const reconciled = findDecision(decision.id);
1505
+ printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1506
+ return publicDecision(reconciled, { includeContent: true });
1436
1507
  }
1437
1508
 
1438
1509
  if (subcommand === 'list') {
@@ -1448,8 +1519,8 @@ const commands = {
1448
1519
  }
1449
1520
  const index = decisionIndex();
1450
1521
  if (flags.count && !status) {
1451
- console.log(index.length);
1452
- return;
1522
+ printLine(index.length);
1523
+ return { count: index.length };
1453
1524
  }
1454
1525
  let records = decisionCatalog(!status && last ? index.slice(-last) : index);
1455
1526
  if (status) {
@@ -1457,19 +1528,19 @@ const commands = {
1457
1528
  }
1458
1529
  if (status && last) records = records.slice(-last);
1459
1530
  if (flags.count) {
1460
- console.log(records.length);
1461
- return;
1531
+ printLine(records.length);
1532
+ return { count: records.length };
1462
1533
  }
1463
1534
  if (records.length === 0) {
1464
- console.log(status ? `no decision records with status ${status}` : 'decision log is empty');
1465
- return;
1535
+ printLine(status ? `no decision records with status ${status}` : 'decision log is empty');
1536
+ return [];
1466
1537
  }
1467
- console.log(
1538
+ printLine(
1468
1539
  records
1469
1540
  .map((record) => `[${record.id}] ${titleCase(record.status)} — ${record.title}\n ${record.file}`)
1470
1541
  .join('\n')
1471
1542
  );
1472
- return;
1543
+ return records.map(publicDecision);
1473
1544
  }
1474
1545
 
1475
1546
  if (subcommand === 'show') {
@@ -1478,8 +1549,8 @@ const commands = {
1478
1549
  fail('usage: driftseal decision show <id>');
1479
1550
  }
1480
1551
  const decision = findDecision(positionals[0]);
1481
- process.stdout.write(decision.content);
1482
- return;
1552
+ writeOutput(decision.content);
1553
+ return publicDecision(decision, { includeContent: true });
1483
1554
  }
1484
1555
 
1485
1556
  fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
@@ -1539,15 +1610,16 @@ const commands = {
1539
1610
  }
1540
1611
 
1541
1612
  if (updated === current) {
1542
- console.log('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1543
- return;
1613
+ printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1614
+ return { changed: false, target };
1544
1615
  }
1545
1616
  atomicWriteFile(target, updated);
1546
- console.log(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
1617
+ printLine(`DriftSeal protocol ${existed ? 'updated in' : 'written to'} ${target}`);
1618
+ return { changed: true, target };
1547
1619
  },
1548
1620
 
1549
1621
  help() {
1550
- console.log(`DriftSeal — Seal the intent. Stop the drift.
1622
+ printLine(`DriftSeal — Seal the intent. Stop the drift.
1551
1623
 
1552
1624
  Intent-level write-ahead log for agent sessions.
1553
1625
 
@@ -1573,6 +1645,7 @@ decision add options:
1573
1645
 
1574
1646
  intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
1575
1647
  decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
1648
+ return null;
1576
1649
  },
1577
1650
  };
1578
1651
 
@@ -1595,11 +1668,10 @@ function mutationResources(cmd, argv) {
1595
1668
  return [logDir(), decisionDir()];
1596
1669
  }
1597
1670
 
1598
- function main() {
1599
- const [cmd, ...rest] = process.argv.slice(2);
1671
+ function dispatch(argv) {
1672
+ const [cmd, ...rest] = argv;
1600
1673
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1601
- commands.help();
1602
- process.exit(cmd ? 0 : 1);
1674
+ return { data: commands.help(), exitCode: cmd ? 0 : 1 };
1603
1675
  }
1604
1676
  const fn = commands[cmd];
1605
1677
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
@@ -1609,10 +1681,144 @@ function main() {
1609
1681
  const readsIntentLog = ['status', 'log'].includes(cmd);
1610
1682
  if (mutates || readsIntentLog) {
1611
1683
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
1612
- withMutationLocks(resources, () => fn(rest));
1613
- } else {
1614
- fn(rest);
1684
+ return { data: withMutationLocks(resources, () => fn(rest)), exitCode: 0 };
1615
1685
  }
1686
+ return { data: fn(rest), exitCode: 0 };
1616
1687
  }
1617
1688
 
1618
- main();
1689
+ function repositoryRoot(root) {
1690
+ if (typeof root !== 'string' || root.trim().length === 0) {
1691
+ fail('repository root must be a non-empty path');
1692
+ }
1693
+ const resolved = path.resolve(root);
1694
+ let stat;
1695
+ try {
1696
+ stat = fs.statSync(resolved);
1697
+ } catch {
1698
+ fail(`repository root does not exist: ${resolved}`);
1699
+ }
1700
+ if (!stat.isDirectory()) fail(`repository root is not a directory: ${resolved}`);
1701
+ return fs.realpathSync(resolved);
1702
+ }
1703
+
1704
+ function runCommand(argv, { root = process.cwd(), isolateStorage = false, capture = true } = {}) {
1705
+ if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== 'string')) {
1706
+ fail('command arguments must be an array of strings');
1707
+ }
1708
+ if (capture && activeOutput) fail('nested captured DriftSeal commands are not supported');
1709
+
1710
+ const fixedRoot = repositoryRoot(root);
1711
+ const previousCwd = process.cwd();
1712
+ const previousIntentHome = process.env.DRIFTSEAL_HOME;
1713
+ const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
1714
+ const output = { stdout: '', stderr: '', data: null, exitCode: 0 };
1715
+ const previousOutput = activeOutput;
1716
+
1717
+ try {
1718
+ process.chdir(fixedRoot);
1719
+ if (isolateStorage) {
1720
+ delete process.env.DRIFTSEAL_HOME;
1721
+ delete process.env.DRIFTSEAL_DECISION_HOME;
1722
+ }
1723
+ if (capture) activeOutput = output;
1724
+ const result = dispatch(argv);
1725
+ output.data = result.data;
1726
+ output.exitCode = result.exitCode;
1727
+ return output;
1728
+ } catch (err) {
1729
+ if (capture) {
1730
+ err.stdout = output.stdout;
1731
+ err.stderr = output.stderr;
1732
+ }
1733
+ throw err;
1734
+ } finally {
1735
+ activeOutput = previousOutput;
1736
+ process.chdir(previousCwd);
1737
+ if (previousIntentHome === undefined) delete process.env.DRIFTSEAL_HOME;
1738
+ else process.env.DRIFTSEAL_HOME = previousIntentHome;
1739
+ if (previousDecisionHome === undefined) delete process.env.DRIFTSEAL_DECISION_HOME;
1740
+ else process.env.DRIFTSEAL_DECISION_HOME = previousDecisionHome;
1741
+ }
1742
+ }
1743
+
1744
+ function appendFlag(argv, flag, value) {
1745
+ if (value !== undefined && value !== null && value !== '') argv.push(flag, String(value));
1746
+ }
1747
+
1748
+ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
1749
+ const fixedRoot = repositoryRoot(root);
1750
+ const call = (argv) => runCommand(argv, { root: fixedRoot, isolateStorage, capture: true }).data;
1751
+ return Object.freeze({
1752
+ root: fixedRoot,
1753
+ status() {
1754
+ return call(['status']);
1755
+ },
1756
+ begin({ intent, verify, decisions = [], force = false }) {
1757
+ const argv = ['begin', intent];
1758
+ appendFlag(argv, '--verify', verify);
1759
+ for (const decision of decisions) appendFlag(argv, '--decision', decision);
1760
+ if (force) argv.push('--force');
1761
+ return call(argv);
1762
+ },
1763
+ end({ id, status, note, verifyResult } = {}) {
1764
+ const argv = ['end'];
1765
+ if (id) argv.push(String(id));
1766
+ appendFlag(argv, '--status', status);
1767
+ appendFlag(argv, '--note', note);
1768
+ appendFlag(argv, '--verify-result', verifyResult);
1769
+ return call(argv);
1770
+ },
1771
+ log({ last } = {}) {
1772
+ const argv = ['log'];
1773
+ appendFlag(argv, '--last', last);
1774
+ return call(argv);
1775
+ },
1776
+ decisionAdd({ title, context, outcome, status, drivers = [], options = [], consequences = [] }) {
1777
+ const argv = ['decision', 'add', title, '--context', context, '--outcome', outcome];
1778
+ appendFlag(argv, '--status', status);
1779
+ for (const driver of drivers) appendFlag(argv, '--driver', driver);
1780
+ for (const option of options) appendFlag(argv, '--option', option);
1781
+ for (const consequence of consequences) appendFlag(argv, '--consequence', consequence);
1782
+ return call(argv);
1783
+ },
1784
+ decisionUpdate({ id, status, note }) {
1785
+ const argv = ['decision', 'update', String(id), '--note', note];
1786
+ appendFlag(argv, '--status', status);
1787
+ return call(argv);
1788
+ },
1789
+ decisionList({ status, last, count = false } = {}) {
1790
+ const argv = ['decision', 'list'];
1791
+ appendFlag(argv, '--status', status);
1792
+ appendFlag(argv, '--last', last);
1793
+ if (count) argv.push('--count');
1794
+ return call(argv);
1795
+ },
1796
+ decisionShow({ id }) {
1797
+ return call(['decision', 'show', String(id)]);
1798
+ },
1799
+ init() {
1800
+ return call(['init']);
1801
+ },
1802
+ });
1803
+ }
1804
+
1805
+ function main() {
1806
+ try {
1807
+ const result = dispatch(process.argv.slice(2));
1808
+ process.exitCode = result.exitCode;
1809
+ } catch (err) {
1810
+ const message = err && err.message ? err.message : String(err);
1811
+ console.error(`driftseal: error: ${message}`);
1812
+ process.exitCode = 1;
1813
+ }
1814
+ }
1815
+
1816
+ module.exports = {
1817
+ DECISION_STATUSES,
1818
+ END_STATUSES,
1819
+ DriftSealError,
1820
+ createApi,
1821
+ runCommand,
1822
+ };
1823
+
1824
+ if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",
@@ -25,7 +25,8 @@
25
25
  "type": "commonjs",
26
26
  "main": "index.js",
27
27
  "bin": {
28
- "driftseal": "bin/driftseal.js"
28
+ "driftseal": "bin/driftseal.js",
29
+ "driftseal-mcp": "bin/driftseal-mcp.js"
29
30
  },
30
31
  "directories": {
31
32
  "test": "test"
@@ -42,5 +43,9 @@
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=18"
46
+ },
47
+ "dependencies": {
48
+ "@modelcontextprotocol/sdk": "^1.30.0",
49
+ "zod": "^4.4.3"
45
50
  }
46
51
  }
@@ -11,15 +11,23 @@ activity streams.
11
11
 
12
12
  ## Locate DriftSeal
13
13
 
14
+ - Prefer the `driftseal_*` MCP tools when the DriftSeal MCP server is available
15
+ for the target repository. Use `driftseal_status`, `driftseal_begin`,
16
+ `driftseal_end`, and the corresponding decision tools instead of invoking the
17
+ CLI through a shell.
14
18
  - Work from the repository root unless its instructions specify another scope.
15
- - Prefer `driftseal` from `PATH`. In a DriftSeal source checkout, fall back to
16
- `node bin/driftseal.js` when the global command is unavailable.
17
- - Follow the repository's `AGENTS.md` and storage overrides such as `DRIFTSEAL_HOME`.
19
+ - When MCP is unavailable, prefer `driftseal` from `PATH`. In a DriftSeal source
20
+ checkout, fall back to `node bin/driftseal.js` when the global command is
21
+ unavailable.
22
+ - Follow the repository's `AGENTS.md`. Storage overrides such as
23
+ `DRIFTSEAL_HOME` apply to CLI use; the MCP server deliberately keeps state in
24
+ its fixed repository root.
18
25
  - If DriftSeal is unavailable, limit activity to read-only discovery and report the
19
26
  blocker. Do not mutate the repository without the required log.
20
27
 
21
- Use one command form consistently within a round. The examples below use
22
- `driftseal`; substitute the local source command when necessary.
28
+ Use one interface consistently within a round. The examples below use
29
+ `driftseal`; map them directly to the same-named MCP workflow when MCP is
30
+ available, or substitute the local source command when necessary.
23
31
 
24
32
  ## Re-anchor Before Acting
25
33