driftseal 0.1.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:
@@ -66,7 +104,7 @@ driftseal end \
66
104
 
67
105
  If the scope changes, close the current intent as `partial` or `abandoned`, then start a new one. After context loss, use `driftseal status` and `driftseal log --last 3` to re-anchor.
68
106
 
69
- If the user has authorized a Git commit, staging and committing only the verified changes and the just-closed intent log finalizes that round; it does not need a commit-only intent. Any content change made while preparing the commit starts a new round.
107
+ Single-step commands that only build, check, or record work already done — compiling, running tests, `git add`/`git commit` — need no intent of their own. When a commit is authorized, staging and committing only the verified changes and the just-closed intent log finalizes that round. Any content change made while preparing the commit starts a new round.
70
108
 
71
109
  ## Commands
72
110
 
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
  修改文件前,先声明这轮工作的目标:
@@ -66,7 +104,7 @@ driftseal end \
66
104
 
67
105
  如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
68
106
 
69
- 如果用户已经授权创建 Git commit,只把已验证的改动和刚关闭的 intent log 进行 stage 和 commit,就属于这一轮的持久化收尾,不需要额外开启一个只用于 commit 的 intent。准备 commit 时一旦需要修改内容,就必须开启新一轮。
107
+ 只负责构建、检查或记录已完成工作的单步命令(编译、跑测试、`git add`/`git commit`)不需要单独开启 intent。如果用户已经授权创建 Git commit,只把已验证的改动和刚关闭的 intent log 进行 stage 和 commit,就属于这一轮的持久化收尾。准备 commit 时一旦需要修改内容,就必须开启新一轮。
70
108
 
71
109
  ## 命令速览
72
110
 
@@ -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
@@ -7,7 +7,7 @@
7
7
  * Intent-level write-ahead log and MADR decision log for agentic coding sessions.
8
8
  *
9
9
  * Protocol per work round:
10
- * 1. driftseal begin "<intent>" [--verify "<how to verify>"] (before touching anything)
10
+ * 1. driftseal begin "<intent>" [--verify "<how to verify>"] (before changes that may need a rollback)
11
11
  * 2. execute the intent
12
12
  * 3. driftseal end [--status ...] [--note ...] [--verify-result ...] (reconcile against intent)
13
13
  *
@@ -35,11 +35,47 @@ const DECISION_STATUSES = [
35
35
  'superseded',
36
36
  ];
37
37
  const EVENT_SCHEMA_VERSION = 2;
38
- const PROTOCOL_VERSION = 4;
38
+ const PROTOCOL_VERSION = 5;
39
39
  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 -->';
@@ -1018,9 +1080,12 @@ function intentProtocolBlock(version = PROTOCOL_VERSION) {
1018
1080
 
1019
1081
  This repo uses DriftSeal (\`driftseal\`) to prevent agent drift. Every work round:
1020
1082
 
1021
- 1. **Write intent first**, before modifying a file or running a mutating command:
1083
+ 1. **Write intent first**, before modifying, creating, or deleting files, or
1084
+ making any other change that may need a rollback:
1022
1085
  \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1023
1086
  Add one \`--decision <id>\` for each existing decision this round may change.
1087
+ Single-step commands that only build, check, or record work already done
1088
+ (compiling, running tests, \`git add\`/\`git commit\`) need no intent.
1024
1089
  2. **Execute only the intent.** Scope change? Close the current intent
1025
1090
  (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1026
1091
  3. **Verify, then close**: run the declared verification, then
@@ -1044,7 +1109,19 @@ ${INTENT_PROTOCOL_END}`;
1044
1109
  }
1045
1110
 
1046
1111
  function previousIntentProtocolBlock(version) {
1047
- return intentProtocolBlock(version).replace(
1112
+ const v4 = intentProtocolBlock(version).replace(
1113
+ '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
1114
+ ' making any other change that may need a rollback:\n' +
1115
+ ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
1116
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
1117
+ ' Single-step commands that only build, check, or record work already done\n' +
1118
+ ' (compiling, running tests, `git add`/`git commit`) need no intent.',
1119
+ '1. **Write intent first**, before modifying a file or running a mutating command:\n' +
1120
+ ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.\n' +
1121
+ ' Add one `--decision <id>` for each existing decision this round may change.'
1122
+ );
1123
+ if (version >= 4) return v4;
1124
+ return v4.replace(
1048
1125
  ' by the next linked `decision update` or successful `end`. Closing as\n' +
1049
1126
  ' `failed` or `abandoned` cancels pending recovery for that intent.',
1050
1127
  ' by the next `decision update` or `end`.'
@@ -1217,19 +1294,21 @@ const commands = {
1217
1294
  'superseded by --force',
1218
1295
  null
1219
1296
  );
1220
- console.error(`driftseal: ${status} ${open.id}`);
1297
+ printError(`driftseal: ${status} ${open.id}`);
1221
1298
  }
1222
1299
 
1223
1300
  const id = nextId(events);
1224
- appendEvent({
1301
+ events.push(appendEvent({
1225
1302
  type: 'begin',
1226
1303
  id,
1227
1304
  ts: new Date().toISOString(),
1228
1305
  intent,
1229
1306
  verify: flags.verify || null,
1230
1307
  decisions,
1231
- });
1232
- console.log(id);
1308
+ }));
1309
+ const record = fold(events).find((candidate) => candidate.id === id);
1310
+ printLine(id);
1311
+ return publicIntent(record);
1233
1312
  },
1234
1313
 
1235
1314
  end(argv) {
@@ -1264,8 +1343,9 @@ const commands = {
1264
1343
  flags.note,
1265
1344
  flags['verify-result']
1266
1345
  );
1267
- console.log(`${target.id} ${terminalStatus}`);
1268
- return;
1346
+ const record = fold(events).find((candidate) => candidate.id === target.id);
1347
+ printLine(`${target.id} ${terminalStatus}`);
1348
+ return publicIntent(record);
1269
1349
  }
1270
1350
 
1271
1351
  if (['completed', 'partial'].includes(status) && target.decisions.length > 0) {
@@ -1302,15 +1382,17 @@ const commands = {
1302
1382
  }
1303
1383
  }
1304
1384
 
1305
- appendEvent({
1385
+ events.push(appendEvent({
1306
1386
  type: 'end',
1307
1387
  id: target.id,
1308
1388
  ts: new Date().toISOString(),
1309
1389
  status,
1310
1390
  note: flags.note || null,
1311
1391
  verifyResult: flags['verify-result'] || null,
1312
- });
1313
- 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);
1314
1396
  },
1315
1397
 
1316
1398
  status(argv) {
@@ -1318,10 +1400,11 @@ const commands = {
1318
1400
  if (positionals.length > 0) fail('usage: driftseal status');
1319
1401
  const open = openIntent(fold(readEvents({ repairTail: true })));
1320
1402
  if (!open) {
1321
- console.log('no intent in progress');
1322
- return;
1403
+ printLine('no intent in progress');
1404
+ return null;
1323
1405
  }
1324
- console.log(render(open));
1406
+ printLine(render(open));
1407
+ return publicIntent(open);
1325
1408
  },
1326
1409
 
1327
1410
  log(argv) {
@@ -1333,10 +1416,11 @@ const commands = {
1333
1416
  records = records.slice(-n);
1334
1417
  }
1335
1418
  if (records.length === 0) {
1336
- console.log('log is empty');
1337
- return;
1419
+ printLine('log is empty');
1420
+ return [];
1338
1421
  }
1339
- console.log(records.map(render).join('\n\n'));
1422
+ printLine(records.map(render).join('\n\n'));
1423
+ return records.map(publicIntent);
1340
1424
  },
1341
1425
 
1342
1426
  decision(argv) {
@@ -1377,8 +1461,9 @@ const commands = {
1377
1461
  });
1378
1462
  ensureDirectoryDurable(decisionDir());
1379
1463
  atomicCreateFile(path.join(decisionDir(), file), content);
1380
- console.log(path.join(decisionDir(), file));
1381
- return;
1464
+ const decision = findDecision(String(id));
1465
+ printLine(path.join(decisionDir(), file));
1466
+ return publicDecision(decision, { includeContent: true });
1382
1467
  }
1383
1468
 
1384
1469
  if (subcommand === 'update') {
@@ -1416,8 +1501,9 @@ const commands = {
1416
1501
  fail('simulated interruption after decision write');
1417
1502
  }
1418
1503
  appendEvent(reconciliationEvent('decision_reconcile_commit', update));
1419
- console.log(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1420
- return;
1504
+ const reconciled = findDecision(decision.id);
1505
+ printLine(`${decision.id} ${update.fromStatus} -> ${update.toStatus} (${intent.id})`);
1506
+ return publicDecision(reconciled, { includeContent: true });
1421
1507
  }
1422
1508
 
1423
1509
  if (subcommand === 'list') {
@@ -1433,8 +1519,8 @@ const commands = {
1433
1519
  }
1434
1520
  const index = decisionIndex();
1435
1521
  if (flags.count && !status) {
1436
- console.log(index.length);
1437
- return;
1522
+ printLine(index.length);
1523
+ return { count: index.length };
1438
1524
  }
1439
1525
  let records = decisionCatalog(!status && last ? index.slice(-last) : index);
1440
1526
  if (status) {
@@ -1442,19 +1528,19 @@ const commands = {
1442
1528
  }
1443
1529
  if (status && last) records = records.slice(-last);
1444
1530
  if (flags.count) {
1445
- console.log(records.length);
1446
- return;
1531
+ printLine(records.length);
1532
+ return { count: records.length };
1447
1533
  }
1448
1534
  if (records.length === 0) {
1449
- console.log(status ? `no decision records with status ${status}` : 'decision log is empty');
1450
- return;
1535
+ printLine(status ? `no decision records with status ${status}` : 'decision log is empty');
1536
+ return [];
1451
1537
  }
1452
- console.log(
1538
+ printLine(
1453
1539
  records
1454
1540
  .map((record) => `[${record.id}] ${titleCase(record.status)} — ${record.title}\n ${record.file}`)
1455
1541
  .join('\n')
1456
1542
  );
1457
- return;
1543
+ return records.map(publicDecision);
1458
1544
  }
1459
1545
 
1460
1546
  if (subcommand === 'show') {
@@ -1463,8 +1549,8 @@ const commands = {
1463
1549
  fail('usage: driftseal decision show <id>');
1464
1550
  }
1465
1551
  const decision = findDecision(positionals[0]);
1466
- process.stdout.write(decision.content);
1467
- return;
1552
+ writeOutput(decision.content);
1553
+ return publicDecision(decision, { includeContent: true });
1468
1554
  }
1469
1555
 
1470
1556
  fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
@@ -1489,6 +1575,7 @@ const commands = {
1489
1575
  knownManagedBlocks: [
1490
1576
  protocolEol(previousIntentProtocolBlock(2), eol),
1491
1577
  protocolEol(previousIntentProtocolBlock(3), eol),
1578
+ protocolEol(previousIntentProtocolBlock(4), eol),
1492
1579
  ],
1493
1580
  knownLegacyBlocks: [protocolEol(legacyIntentProtocolBlock(), eol)],
1494
1581
  });
@@ -1502,6 +1589,7 @@ const commands = {
1502
1589
  knownManagedBlocks: [
1503
1590
  protocolEol(decisionProtocolBlock(2), eol),
1504
1591
  protocolEol(decisionProtocolBlock(3), eol),
1592
+ protocolEol(decisionProtocolBlock(4), eol),
1505
1593
  ],
1506
1594
  knownLegacyBlocks: [protocolEol(legacyDecisionProtocolBlock(), eol)],
1507
1595
  });
@@ -1522,15 +1610,16 @@ const commands = {
1522
1610
  }
1523
1611
 
1524
1612
  if (updated === current) {
1525
- console.log('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1526
- return;
1613
+ printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
1614
+ return { changed: false, target };
1527
1615
  }
1528
1616
  atomicWriteFile(target, updated);
1529
- 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 };
1530
1619
  },
1531
1620
 
1532
1621
  help() {
1533
- console.log(`DriftSeal — Seal the intent. Stop the drift.
1622
+ printLine(`DriftSeal — Seal the intent. Stop the drift.
1534
1623
 
1535
1624
  Intent-level write-ahead log for agent sessions.
1536
1625
 
@@ -1556,6 +1645,7 @@ decision add options:
1556
1645
 
1557
1646
  intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl
1558
1647
  decision log: $DRIFTSEAL_DECISION_HOME, or .decision-log/ in the current directory`);
1648
+ return null;
1559
1649
  },
1560
1650
  };
1561
1651
 
@@ -1578,11 +1668,10 @@ function mutationResources(cmd, argv) {
1578
1668
  return [logDir(), decisionDir()];
1579
1669
  }
1580
1670
 
1581
- function main() {
1582
- const [cmd, ...rest] = process.argv.slice(2);
1671
+ function dispatch(argv) {
1672
+ const [cmd, ...rest] = argv;
1583
1673
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
1584
- commands.help();
1585
- process.exit(cmd ? 0 : 1);
1674
+ return { data: commands.help(), exitCode: cmd ? 0 : 1 };
1586
1675
  }
1587
1676
  const fn = commands[cmd];
1588
1677
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
@@ -1592,10 +1681,144 @@ function main() {
1592
1681
  const readsIntentLog = ['status', 'log'].includes(cmd);
1593
1682
  if (mutates || readsIntentLog) {
1594
1683
  const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
1595
- withMutationLocks(resources, () => fn(rest));
1596
- } else {
1597
- fn(rest);
1684
+ return { data: withMutationLocks(resources, () => fn(rest)), exitCode: 0 };
1685
+ }
1686
+ return { data: fn(rest), exitCode: 0 };
1687
+ }
1688
+
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;
1598
1741
  }
1599
1742
  }
1600
1743
 
1601
- main();
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.1.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
 
@@ -35,8 +43,8 @@ open intent, stop mutating and coordinate instead of abandoning its work.
35
43
 
36
44
  ## Begin the Round
37
45
 
38
- Before modifying a file or running any mutating command, declare one objective
39
- and its proof:
46
+ Before modifying, creating, or deleting files — or making any other change that
47
+ may need a rollback — declare one objective and its proof:
40
48
 
41
49
  ```sh
42
50
  driftseal begin "<small objective for this round>" \
@@ -45,7 +53,9 @@ driftseal begin "<small objective for this round>" \
45
53
 
46
54
  Make the intent small enough to finish and verify in one round. Prefer an
47
55
  outcome-focused check over a vague activity such as "inspect the result."
48
- Starting the intent is the first permitted mutation.
56
+ Starting the intent is the first permitted mutation. Single-step commands that
57
+ only build, check, or record work already done — compiling, running tests,
58
+ `git add`/`git commit` — need no intent of their own.
49
59
 
50
60
  When the round may change or confirm an existing decision, declare each one at
51
61
  the boundary with `--decision <id>`. Do not add decision links speculatively.