driftseal 1.2.1 → 1.3.2

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
@@ -137,7 +137,7 @@ The v1 server provides:
137
137
  | MCP capability | Purpose |
138
138
  | --- | --- |
139
139
  | `driftseal_status`, `driftseal_log` | Read the current intent and intent history. |
140
- | `driftseal_begin`, `driftseal_end` | Open and honestly close a work round. |
140
+ | `driftseal_begin`, `driftseal_verify`, `driftseal_end` | Open a work round, capture machine verification evidence, and honestly close it. |
141
141
  | `driftseal_absorb` | Repair merge collisions or absorb another worktree's logs while remapping colliding IDs. |
142
142
  | `driftseal_reclaim`, `driftseal_unreclaim` | Hide meaningless closed records behind append-only markers, or restore them. |
143
143
  | `driftseal_decision_list`, `driftseal_decision_show` | Find and read MADR records. |
@@ -196,10 +196,51 @@ Declare the round before making non-Git changes:
196
196
 
197
197
  ```sh
198
198
  driftseal begin "add rate limiting to /api/login" \
199
+ --accept "the sixth login attempt within one minute receives HTTP 429" \
199
200
  --verify "npm test test/rate-limit.test.js"
200
201
  ```
201
202
 
202
- Do the work, run the declared check, then reconcile the result:
203
+ Do the work, reconcile any linked decisions, inspect the declared command with
204
+ `driftseal status`, and only then let DriftSeal run it:
205
+
206
+ ```sh
207
+ driftseal verify
208
+ ```
209
+
210
+ `driftseal verify` passes the exact stored string to the operating-system shell.
211
+ The command can therefore read or modify files, access the network, or run any
212
+ other program available to the current user. Treat it as executable code, not as
213
+ passive log data. DriftSeal records local provenance when an intent is opened:
214
+ the default Git workflow parks the intent in Git metadata, while non-Git and
215
+ custom `DRIFTSEAL_HOME` workflows keep a small local marker outside the intent
216
+ log. Those locally created intents run normally. If an open intent arrives only
217
+ through an intent log, without matching local provenance, DriftSeal cannot confirm
218
+ who chose its command. It prints the command to stderr and refuses to execute it
219
+ until you inspect it and explicitly run
220
+ `driftseal verify --allow-tracked-command`. The programmatic API and MCP tool
221
+ expose the equivalent `allowTrackedCommand` opt-in. Local provenance state is
222
+ removed when the intent closes. Non-Git markers are bound to the local log
223
+ file's identity, so copying a marker with the log does not transfer trust. If
224
+ local provenance is lost or no longer matches, verification fails safe and
225
+ requires the same explicit opt-in.
226
+
227
+ The verification event records the command's exit status, duration, output
228
+ digest and byte counts, Git HEAD, and a fingerprint of every tracked or
229
+ untracked non-ignored workspace file except the intent event log. A successful
230
+ result becomes stale if those workspace contents change. DriftSeal therefore
231
+ rejects `completed` until the command passes again on the current workspace.
232
+ Command output is spooled to temporary files instead of a fixed in-memory
233
+ buffer, then replayed after the command exits and removed. Output size therefore
234
+ has no DriftSeal-defined limit, though it remains bounded by available disk space.
235
+ Ignored files are deliberately outside this fingerprint. Outside a Git
236
+ worktree the fingerprint is unavailable, so the gate proves only the command's
237
+ recorded exit status and cannot detect later content changes.
238
+
239
+ This proves that the declared command passed on recorded contents; it does not
240
+ prove that the acceptance criterion or test is adequate. Existing intents
241
+ without `--accept` retain the manual verification workflow for compatibility.
242
+ Use protected CI, independent review, or human approval when the verifier was
243
+ written by the same agent, the outcome is subjective, or the change is high risk.
203
244
 
204
245
  ```sh
205
246
  driftseal end \
@@ -224,7 +265,8 @@ also need no intent. Any other non-Git content change starts a new work round.
224
265
 
225
266
  | Command | Purpose |
226
267
  | --- | --- |
227
- | `driftseal begin "<intent>" [-v "<verify>"] [--decision id] [--force]` | Open a work-round intent and optionally link existing decisions. |
268
+ | `driftseal begin "<intent>" [--accept "<outcome>"] [-v "<command>"] [--decision id] [--force]` | Open a work-round intent. Repeat `--accept` for observable completion criteria; acceptance requires a verification command. |
269
+ | `driftseal verify [--allow-tracked-command]` | Execute the acceptance-bound intent's predeclared command and bind machine evidence to the current Git-visible workspace contents. Commands without matching local provenance require the explicit opt-in. |
228
270
  | `driftseal end [id] [-s status] [-n note] [-r verify-result]` | Close an intent honestly. |
229
271
  | `driftseal status` | Show the intent currently in progress. |
230
272
  | `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
@@ -248,7 +290,9 @@ When `begin` declares one or more `--decision <id>` links, every linked
248
290
  decision must be reconciled with `driftseal decision update` before that intent can
249
291
  close as `completed` or `partial`. The update changes the current status when
250
292
  requested and appends a timestamped history entry tied to the intent. Intents
251
- without decision links keep the ordinary workflow.
293
+ without decision links keep the ordinary workflow. For acceptance-bound linked
294
+ intents, perform every decision update before `driftseal verify`, because a decision
295
+ update changes the workspace fingerprint: reconcile, verify, then end.
252
296
 
253
297
  ## Reclaiming noise records
254
298
 
package/README.zh-CN.md CHANGED
@@ -132,7 +132,7 @@ v1 server 提供:
132
132
  | MCP capability | 用途 |
133
133
  | --- | --- |
134
134
  | `driftseal_status`, `driftseal_log` | 读取当前 intent 和 intent 历史。 |
135
- | `driftseal_begin`, `driftseal_end` | 开启并诚实关闭一轮工作。 |
135
+ | `driftseal_begin`, `driftseal_verify`, `driftseal_end` | 开启一轮工作、采集机器验证证据,并诚实关闭。 |
136
136
  | `driftseal_absorb` | 修复 merge 撞号,或吸收另一条 worktree 日志并重编号冲突 ID。 |
137
137
  | `driftseal_reclaim`, `driftseal_unreclaim` | 用 append-only 标记隐藏已无意义的已关闭记录,或将其恢复。 |
138
138
  | `driftseal_decision_list`, `driftseal_decision_show` | 查找并读取 MADR record。 |
@@ -186,10 +186,45 @@ Kimi Code 只在全局 `config.toml` 中记录 hook,因此该 target 必须指
186
186
 
187
187
  ```sh
188
188
  driftseal begin "add rate limiting to /api/login" \
189
+ --accept "the sixth login attempt within one minute receives HTTP 429" \
189
190
  --verify "npm test test/rate-limit.test.js"
190
191
  ```
191
192
 
192
- 完成工作并运行约定的 check 后,记录实际结果:
193
+ 完成工作后,先 reconcile 所有关联 decision,再用 `driftseal status` 检查声明的
194
+ 命令,确认无误后才让 DriftSeal 执行:
195
+
196
+ ```sh
197
+ driftseal verify
198
+ ```
199
+
200
+ `driftseal verify` 会把日志中保存的完整字符串交给操作系统 shell。这个命令可以
201
+ 读写文件、访问网络,也可以运行当前用户有权执行的任何程序;因此它是可执行代码,
202
+ 不是被动的日志数据。创建 intent 时,DriftSeal 会记录本地 provenance:默认 Git
203
+ 流程把 open intent park 在 Git metadata 中;非 Git 环境和自定义
204
+ `DRIFTSEAL_HOME` 则在 intent log 之外保存一个很小的本地标记。这些本地创建的
205
+ intent 可以直接验证。如果 open intent 只有 log 记录、没有匹配的本地 provenance,
206
+ DriftSeal 就无法确认是谁选择了其中的命令。此时它会先把命令输出到 stderr 并拒绝
207
+ 执行;只有检查并信任该命令后,才能显式运行
208
+ `driftseal verify --allow-tracked-command`。Programmatic API 和 MCP tool 中对应的显式
209
+ 开关是 `allowTrackedCommand`。intent 关闭时,本地 provenance 会被清理;如果它提前
210
+ 丢失,DriftSeal 会按安全方向处理,仍要求显式 opt in。非 Git marker 还会绑定本地
211
+ log 文件的 identity,因此把 marker 和 log 一起复制到别处也不会转移信任。
212
+
213
+ 验证事件会记录 exit status、耗时、输出摘要及字节数、Git HEAD,以及当前所有
214
+ tracked 和未被 ignore 的 untracked 文件的内容指纹(intent event log 除外)。
215
+ 验证后只要这些内容发生变化,成功证据就会过期,必须重新运行;否则 DriftSeal
216
+ 会拒绝把 intent 关闭为 `completed`。命令输出会先写入临时 spool 文件,而不是
217
+ 受固定大小的内存 buffer 限制;命令退出后再回放并删除。因此 DriftSeal 不再限制
218
+ 输出大小,但实际容量仍受可用磁盘空间约束。被 ignore 的文件不在指纹范围内。
219
+ 如果当前目录不是 Git worktree,指纹不可用;此时 gate 只能证明记录到的 exit
220
+ status,无法发现之后发生的内容变化。
221
+
222
+ 这只能证明预先声明的命令在记录的内容上通过,不能证明 acceptance criterion
223
+ 或测试本身足够可靠。为兼容旧记录,没有 `--accept` 的 intent 仍沿用手动验证流程。
224
+ 如果验证器也由同一个 agent 编写、结果带有主观判断,或改动风险较高,应再使用
225
+ 受保护的 CI、独立 review 或人工确认。
226
+
227
+ 最后记录实际结果:
193
228
 
194
229
  ```sh
195
230
  driftseal end \
@@ -211,7 +246,8 @@ intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`
211
246
 
212
247
  | Command | 用途 |
213
248
  | --- | --- |
214
- | `driftseal begin "<intent>" [-v "<verify>"] [--decision id] [--force]` | 开启一轮工作,并可关联已有 decision。 |
249
+ | `driftseal begin "<intent>" [--accept "<outcome>"] [-v "<command>"] [--decision id] [--force]` | 开启一轮工作。可重复使用 `--accept` 声明可观察的完成条件;一旦声明,就必须同时提供验证命令。 |
250
+ | `driftseal verify [--allow-tracked-command]` | 执行 acceptance-bound intent 预先声明的命令,并把机器证据绑定到当前 Git 可见的工作区内容;没有匹配本地 provenance 的命令必须显式 opt in。 |
215
251
  | `driftseal end [id] [-s status] [-n note] [-r verify-result]` | 诚实地关闭 intent。 |
216
252
  | `driftseal status` | 查看当前进行中的 intent。 |
217
253
  | `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
@@ -234,7 +270,10 @@ intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`
234
270
  如果 `begin` 通过一个或多个 `--decision <id>` 声明了关联,那么 intent
235
271
  以 `completed` 或 `partial` 关闭前,必须用 `driftseal decision update` reconcile
236
272
  每一条关联 decision。update 可以改变当前 status,并会追加一条包含时间和
237
- intent ID 的 history。没有关联 decision 的 intent 仍沿用普通流程。
273
+ intent ID 的 history。没有关联 decision 的 intent 仍沿用普通流程。对于
274
+ acceptance-bound linked intent,所有 decision update 都必须发生在
275
+ `driftseal verify` 之前,因为 update 会改变 workspace fingerprint;顺序应当是
276
+ reconcile、verify、end。
238
277
 
239
278
  ## 回收已无意义的记录
240
279
 
@@ -69,10 +69,25 @@ function guarded(action) {
69
69
  }
70
70
 
71
71
  function registerTools(server, api, z) {
72
+ const verificationRecord = z.object({
73
+ id: z.string(),
74
+ passed: z.boolean(),
75
+ exitCode: z.number().int().nonnegative().nullable(),
76
+ signal: z.string().nullable(),
77
+ durationMs: z.number().int().nonnegative(),
78
+ outputHash: z.string().regex(/^[a-f0-9]{64}$/),
79
+ stdoutBytes: z.number().int().nonnegative(),
80
+ stderrBytes: z.number().int().nonnegative(),
81
+ workspace: z.string().regex(/^[a-f0-9]{64}$/).nullable(),
82
+ head: z.string().nullable(),
83
+ ranAt: z.string(),
84
+ });
72
85
  const intentRecord = z.object({
73
86
  id: z.string(),
74
87
  intent: z.string(),
88
+ acceptance: z.array(z.string()),
75
89
  verify: z.string().nullable(),
90
+ verification: verificationRecord.nullable(),
76
91
  decisions: z.array(z.string()),
77
92
  status: z.enum(END_STATUSES).or(z.literal('in_progress')),
78
93
  note: z.string().nullable(),
@@ -150,6 +165,10 @@ function registerTools(server, api, z) {
150
165
  'Open one focused work-round intent before making repository changes. Fails if another intent is already open; close it explicitly first.',
151
166
  inputSchema: {
152
167
  intent: nonEmpty.describe('Outcome this work round will accomplish.'),
168
+ acceptance: z
169
+ .array(nonEmpty)
170
+ .default([])
171
+ .describe('Observable outcomes that make machine-verified completion meaningful.'),
153
172
  verify: nonEmpty.optional().describe('Exact command or outcome check that will prove completion.'),
154
173
  decisions: z
155
174
  .array(decisionId)
@@ -166,12 +185,44 @@ function registerTools(server, api, z) {
166
185
  })
167
186
  );
168
187
 
188
+ server.registerTool(
189
+ 'driftseal_verify',
190
+ {
191
+ title: 'Run the declared DriftSeal verification',
192
+ description:
193
+ 'Execute the current acceptance-bound intent\'s predeclared shell command and record machine evidence bound to the resulting Git-visible workspace contents. Inspect the command with driftseal_status first. A command sourced from the repository intent log is untrusted and requires allowTrackedCommand.',
194
+ inputSchema: {
195
+ allowTrackedCommand: z
196
+ .boolean()
197
+ .default(false)
198
+ .describe(
199
+ 'Explicitly allow a command sourced from the repository intent log after inspecting and trusting it.'
200
+ ),
201
+ },
202
+ outputSchema: {
203
+ root: z.string(),
204
+ intent: intentRecord,
205
+ verification: verificationRecord,
206
+ exitCode: z.number().int().nonnegative(),
207
+ },
208
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: true },
209
+ },
210
+ async (input) =>
211
+ guarded(() => {
212
+ const result = api.verify({ allowTrackedCommand: input.allowTrackedCommand });
213
+ return success(
214
+ { root: api.root, ...result },
215
+ `Machine verification ${result.verification.passed ? 'passed' : 'failed'} for intent ${result.intent.id}.`
216
+ );
217
+ })
218
+ );
219
+
169
220
  server.registerTool(
170
221
  'driftseal_end',
171
222
  {
172
223
  title: 'Close a DriftSeal intent',
173
224
  description:
174
- '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.',
225
+ 'Close the current work-round intent with an honest terminal status, note, and verification result. Reconcile linked decisions before running final verification. Acceptance-bound intents require fresh successful machine verification before completed closure.',
175
226
  inputSchema: {
176
227
  id: z.string().optional().describe('Intent ID; omit to close the current open intent.'),
177
228
  status: closedStatus.default('completed'),
@@ -447,7 +498,7 @@ async function createServer({ root }) {
447
498
  { name: SERVER_NAME, version: SERVER_VERSION },
448
499
  {
449
500
  instructions:
450
- '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.',
501
+ 'Use driftseal_status before repository changes or after context loss. Open one focused intent with driftseal_begin before changes. Reconcile every linked decision before verification. When the intent declares acceptance criteria, inspect its command and use driftseal_verify to capture machine evidence before completed closure; a command sourced from the repository log requires explicit allowTrackedCommand after it is trusted. Otherwise run the declared check directly. Close honestly with driftseal_end.',
451
502
  }
452
503
  );
453
504
  registerTools(server, api, z);
package/bin/driftseal.js CHANGED
@@ -7,12 +7,14 @@
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 changes that may need a rollback)
10
+ * 1. driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"]
11
11
  * 2. execute the intent
12
- * 3. driftseal end [--status ...] [--note ...] [--verify-result ...] (reconcile against intent)
12
+ * 3. driftseal verify (for acceptance-bound machine evidence)
13
+ * 4. driftseal end [--status ...] [--note ...] [--verify-result ...] (reconcile against intent)
13
14
  *
14
15
  * Events are appended to an append-only JSONL log (WAL semantics):
15
- * { "type": "begin", "id", "ts", "intent", "verify" }
16
+ * { "type": "begin", "id", "ts", "intent", "acceptance", "verify" }
17
+ * { "type": "verify", "id", "ts", "command", "passed", "workspace" }
16
18
  * { "type": "end", "id", "ts", "status", "note", "verifyResult" }
17
19
  *
18
20
  * Intent log: $DRIFTSEAL_HOME/events.jsonl, or .intent-log/events.jsonl in cwd.
@@ -25,7 +27,8 @@ const path = require('path');
25
27
  const crypto = require('crypto');
26
28
  const os = require('os');
27
29
  const { isDeepStrictEqual } = require('util');
28
- const { execFileSync } = require('child_process');
30
+ const { StringDecoder } = require('string_decoder');
31
+ const { execFileSync, spawnSync } = require('child_process');
29
32
  const { version: PACKAGE_VERSION } = require('../package.json');
30
33
 
31
34
  const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
@@ -37,8 +40,8 @@ const DECISION_STATUSES = [
37
40
  'deprecated',
38
41
  'superseded',
39
42
  ];
40
- const EVENT_SCHEMA_VERSION = 3;
41
- const PROTOCOL_VERSION = 12;
43
+ const EVENT_SCHEMA_VERSION = 4;
44
+ const PROTOCOL_VERSION = 13;
42
45
  const DEFAULT_LOG_LANGUAGE = 'en';
43
46
  const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
44
47
  const LOCK_STALE_MS = 30 * 60 * 1000;
@@ -46,6 +49,10 @@ const LOCK_INIT_STALE_MS = 5 * 1000;
46
49
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
47
50
  const READ_ONLY_LOCK_WAIT_MS = Number(process.env._DRIFTSEAL_TEST_READ_ONLY_LOCK_WAIT_MS) || 1500;
48
51
  const MAX_DECISION_SLUG_LENGTH = 180;
52
+ const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
53
+ const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
54
+ const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
55
+ const LOCAL_INTENT_PROVENANCE_FILE = '.driftseal-local-intent.json';
49
56
 
50
57
  class DriftSealError extends Error {
51
58
  constructor(message) {
@@ -66,7 +73,9 @@ class HelpRequested extends DriftSealError {
66
73
  /** Single source of truth for per-command usage lines. */
67
74
  function usageFor(key) {
68
75
  const lines = {
69
- begin: 'usage: driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]',
76
+ begin:
77
+ 'usage: driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"] [--decision <id>] [--force]',
78
+ verify: 'usage: driftseal verify [--allow-tracked-command]',
70
79
  end: 'usage: driftseal end [id] [options]',
71
80
  status: 'usage: driftseal status',
72
81
  log: 'usage: driftseal log [--last N] [--all]',
@@ -94,33 +103,73 @@ function usageFor(key) {
94
103
 
95
104
  let activeOutput = null;
96
105
 
106
+ function createBoundedOutputCapture() {
107
+ return { head: '', tail: '', length: 0, headComplete: false };
108
+ }
109
+
110
+ function splitsSurrogatePair(text, index) {
111
+ if (index <= 0 || index >= text.length) return false;
112
+ const before = text.charCodeAt(index - 1);
113
+ const after = text.charCodeAt(index);
114
+ return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;
115
+ }
116
+
117
+ function appendBoundedOutput(capture, value) {
118
+ let text = String(value);
119
+ capture.length += text.length;
120
+
121
+ if (!capture.headComplete) {
122
+ let take = Math.min(CAPTURE_OUTPUT_EDGE_CHARACTERS - capture.head.length, text.length);
123
+ if (splitsSurrogatePair(text, take)) take -= 1;
124
+ capture.head += text.slice(0, take);
125
+ text = text.slice(take);
126
+ if (text.length > 0) capture.headComplete = true;
127
+ }
128
+
129
+ if (text.length === 0) return;
130
+ const combined = capture.tail + text;
131
+ let start = Math.max(0, combined.length - CAPTURE_OUTPUT_EDGE_CHARACTERS);
132
+ if (splitsSurrogatePair(combined, start)) start += 1;
133
+ capture.tail = combined.slice(start);
134
+ }
135
+
136
+ function renderBoundedOutput(capture) {
137
+ if (capture.length === capture.head.length + capture.tail.length) {
138
+ return capture.head + capture.tail;
139
+ }
140
+ return capture.head + CAPTURE_OUTPUT_OMISSION + capture.tail;
141
+ }
142
+
143
+ function captureOutput(stream, value) {
144
+ if (!activeOutput) return false;
145
+ appendBoundedOutput(activeOutput[stream], value);
146
+ return true;
147
+ }
148
+
97
149
  function printLine(value = '') {
98
150
  const text = String(value);
99
- if (activeOutput) {
100
- activeOutput.stdout += text + '\n';
101
- return;
102
- }
151
+ if (captureOutput('stdout', text + '\n')) return;
103
152
  console.log(text);
104
153
  }
105
154
 
106
155
  function printError(value = '') {
107
156
  const text = String(value);
108
- if (activeOutput) {
109
- activeOutput.stderr += text + '\n';
110
- return;
111
- }
157
+ if (captureOutput('stderr', text + '\n')) return;
112
158
  console.error(text);
113
159
  }
114
160
 
115
161
  function writeOutput(value) {
116
162
  const text = String(value);
117
- if (activeOutput) {
118
- activeOutput.stdout += text;
119
- return;
120
- }
163
+ if (captureOutput('stdout', text)) return;
121
164
  process.stdout.write(text);
122
165
  }
123
166
 
167
+ function writeErrorOutput(value) {
168
+ const text = String(value);
169
+ if (captureOutput('stderr', text)) return;
170
+ process.stderr.write(text);
171
+ }
172
+
124
173
  if (process.env._DRIFTSEAL_TEST_UMASK) {
125
174
  process.umask(Number.parseInt(process.env._DRIFTSEAL_TEST_UMASK, 8));
126
175
  }
@@ -168,15 +217,65 @@ function normalizeEvent(event, line) {
168
217
  if (!Array.isArray(event.decisions) && event.decisions !== undefined) {
169
218
  fail(`invalid decisions list on log line ${line}`);
170
219
  }
220
+ if (!Array.isArray(event.acceptance) && event.acceptance !== undefined) {
221
+ fail(`invalid acceptance list on log line ${line}`);
222
+ }
223
+ const acceptance = event.acceptance || [];
224
+ if (acceptance.some((criterion) => typeof criterion !== 'string' || criterion.trim().length === 0)) {
225
+ fail(`invalid acceptance criterion on log line ${line}`);
226
+ }
227
+ if (acceptance.length > 0 && (typeof event.verify !== 'string' || event.verify.trim().length === 0)) {
228
+ fail(`acceptance-bound intent has no verification command on log line ${line}`);
229
+ }
171
230
  const decisions = (event.decisions || []).map(normalizeDecisionId);
172
231
  if (new Set(decisions).size !== decisions.length) {
173
232
  fail(`duplicate linked decision on log line ${line}`);
174
233
  }
175
- return { ...event, decisions, head: normalizeHead(event.head) };
234
+ return { ...event, acceptance, decisions, head: normalizeHead(event.head) };
235
+ }
236
+
237
+ if (event.type === 'verify') {
238
+ if (
239
+ typeof event.verificationId !== 'string' ||
240
+ event.verificationId.length === 0 ||
241
+ typeof event.command !== 'string' ||
242
+ event.command.trim().length === 0 ||
243
+ typeof event.passed !== 'boolean' ||
244
+ (event.exitCode !== null && (!Number.isInteger(event.exitCode) || event.exitCode < 0)) ||
245
+ (event.signal !== null && typeof event.signal !== 'string') ||
246
+ !Number.isSafeInteger(event.durationMs) ||
247
+ event.durationMs < 0 ||
248
+ !Number.isSafeInteger(event.stdoutBytes) ||
249
+ event.stdoutBytes < 0 ||
250
+ !Number.isSafeInteger(event.stderrBytes) ||
251
+ event.stderrBytes < 0 ||
252
+ typeof event.outputHash !== 'string' ||
253
+ !/^[a-f0-9]{64}$/.test(event.outputHash) ||
254
+ (event.workspace !== null &&
255
+ (typeof event.workspace !== 'string' || !/^[a-f0-9]{64}$/.test(event.workspace))) ||
256
+ event.passed !== (event.exitCode === 0 && event.signal === null)
257
+ ) {
258
+ fail(`invalid verification event on log line ${line}`);
259
+ }
260
+ return { ...event, head: normalizeHead(event.head) };
176
261
  }
177
262
 
178
263
  if (event.type === 'end') {
179
264
  if (!END_STATUSES.includes(event.status)) fail(`invalid end event on log line ${line}`);
265
+ if (
266
+ event.verificationId !== undefined &&
267
+ event.verificationId !== null &&
268
+ (typeof event.verificationId !== 'string' || event.verificationId.length === 0)
269
+ ) {
270
+ fail(`invalid end verification id on log line ${line}`);
271
+ }
272
+ if (
273
+ event.workspace !== undefined &&
274
+ event.workspace !== null &&
275
+ (typeof event.workspace !== 'string' || !/^[a-f0-9]{64}$/.test(event.workspace))
276
+ ) {
277
+ fail(`invalid end workspace on log line ${line}`);
278
+ }
180
279
  return { ...event, head: normalizeHead(event.head) };
181
280
  }
182
281
 
@@ -506,7 +605,12 @@ function parkedOpenIntent(park) {
506
605
 
507
606
  function appendEvent(event) {
508
607
  const park = inProgressFile();
509
- if (!park) return appendEventTo(logFile(), event);
608
+ if (!park) {
609
+ const stored = appendEventTo(logFile(), event);
610
+ if (event.type === 'begin') writeLocalIntentProvenance(stored);
611
+ if (event.type === 'end') clearLocalIntentProvenance(event.id);
612
+ return stored;
613
+ }
510
614
 
511
615
  const open = parkedOpenIntent(park);
512
616
  // A park with nothing open left in it belongs in the log; an interrupted end retries here.
@@ -528,9 +632,88 @@ function contentHash(content) {
528
632
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
529
633
  }
530
634
 
531
- function atomicWriteFile(target, content) {
635
+ function localIntentProvenanceFile() {
636
+ const root = gitWorktreeRoot();
637
+ const key = contentHash(path.resolve(logFile())).slice(0, 16);
638
+ if (!root) return path.join(logDir(), LOCAL_INTENT_PROVENANCE_FILE);
639
+ const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-intent-${key}.json`]);
640
+ return gitPath ? path.resolve(process.cwd(), gitPath) : null;
641
+ }
642
+
643
+ function localIntentLogIdentity() {
644
+ try {
645
+ const stat = fs.statSync(logFile(), { bigint: true });
646
+ return contentHash(JSON.stringify([String(stat.dev), String(stat.ino), String(stat.birthtimeNs)]));
647
+ } catch {
648
+ return null;
649
+ }
650
+ }
651
+
652
+ function localIntentProvenanceFingerprint({ id, ts, verify }) {
653
+ return contentHash(JSON.stringify([id, ts, verify || null]));
654
+ }
655
+
656
+ function writeLocalIntentProvenance(event) {
657
+ const file = localIntentProvenanceFile();
658
+ if (!file) return;
659
+ ensureDirectoryDurable(path.dirname(file));
660
+ atomicWriteFile(
661
+ file,
662
+ JSON.stringify({
663
+ version: 1,
664
+ id: event.id,
665
+ fingerprint: localIntentProvenanceFingerprint(event),
666
+ logIdentity: localIntentLogIdentity(),
667
+ }) + '\n',
668
+ 0o600
669
+ );
670
+ }
671
+
672
+ function readLocalIntentProvenance() {
673
+ const file = localIntentProvenanceFile();
674
+ if (!file || !fs.existsSync(file)) return null;
675
+ try {
676
+ const provenance = JSON.parse(fs.readFileSync(file, 'utf8'));
677
+ if (
678
+ provenance.version !== 1 ||
679
+ typeof provenance.id !== 'string' ||
680
+ !/^[a-f0-9]{64}$/.test(provenance.fingerprint) ||
681
+ !/^[a-f0-9]{64}$/.test(provenance.logIdentity)
682
+ ) {
683
+ return null;
684
+ }
685
+ return provenance;
686
+ } catch {
687
+ return null;
688
+ }
689
+ }
690
+
691
+ function hasMatchingLocalIntentProvenance(intent) {
692
+ const provenance = readLocalIntentProvenance();
693
+ return (
694
+ provenance !== null &&
695
+ provenance.id === intent.id &&
696
+ provenance.logIdentity === localIntentLogIdentity() &&
697
+ provenance.fingerprint ===
698
+ localIntentProvenanceFingerprint({
699
+ id: intent.id,
700
+ ts: intent.tsBegin,
701
+ verify: intent.verify,
702
+ })
703
+ );
704
+ }
705
+
706
+ function clearLocalIntentProvenance(id) {
707
+ const file = localIntentProvenanceFile();
708
+ const provenance = readLocalIntentProvenance();
709
+ if (!file || !provenance || provenance.id !== id) return;
710
+ fs.unlinkSync(file);
711
+ fsyncDirectory(path.dirname(file));
712
+ }
713
+
714
+ function atomicWriteFile(target, content, createMode = 0o644) {
532
715
  const existed = fs.existsSync(target);
533
- const mode = existed ? fs.statSync(target).mode & 0o777 : 0o644;
716
+ const mode = existed ? fs.statSync(target).mode & 0o777 : createMode;
534
717
  const temp = path.join(
535
718
  path.dirname(target),
536
719
  `.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`
@@ -817,6 +1000,7 @@ function fold(events) {
817
1000
  id: ev.id,
818
1001
  tsBegin: ev.ts,
819
1002
  intent: ev.intent,
1003
+ acceptance: Array.isArray(ev.acceptance) ? ev.acceptance : [],
820
1004
  verify: ev.verify || null,
821
1005
  beginHead: ev.head || null,
822
1006
  decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
@@ -824,6 +1008,8 @@ function fold(events) {
824
1008
  decisionPrepares: [],
825
1009
  decisionTerminals: [],
826
1010
  decisionUpdates: [],
1011
+ verificationAttempts: [],
1012
+ verification: null,
827
1013
  status: 'in_progress',
828
1014
  tsEnd: null,
829
1015
  note: null,
@@ -834,6 +1020,20 @@ function fold(events) {
834
1020
  reclaimedAt: null,
835
1021
  });
836
1022
  order.push(ev.id);
1023
+ } else if (ev.type === 'verify') {
1024
+ const rec = records.get(ev.id);
1025
+ if (!rec) fail(`verification event references unknown intent id: ${ev.id}`);
1026
+ if (rec.status !== 'in_progress') {
1027
+ fail(`verification occurred after intent ${ev.id} was closed`);
1028
+ }
1029
+ if (rec.acceptance.length === 0 || !rec.verify) {
1030
+ fail(`verification event references intent ${ev.id} without acceptance criteria`);
1031
+ }
1032
+ if (ev.command !== rec.verify) {
1033
+ fail(`verification command does not match intent ${ev.id}`);
1034
+ }
1035
+ rec.verificationAttempts.push(ev);
1036
+ rec.verification = ev;
837
1037
  } else if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
838
1038
  const rec = records.get(ev.id);
839
1039
  if (!rec) fail(`${ev.type} event references unknown intent id: ${ev.id}`);
@@ -877,6 +1077,18 @@ function fold(events) {
877
1077
  ) {
878
1078
  fail(`linked intent ${ev.id} was closed without reconciling every declared decision`);
879
1079
  }
1080
+ if (ev.status === 'completed' && rec.acceptance.length > 0) {
1081
+ if (!rec.verification || !rec.verification.passed) {
1082
+ fail(`acceptance-bound intent ${ev.id} was completed without successful machine verification`);
1083
+ }
1084
+ if (
1085
+ (ev.schemaVersion || 1) < 4 ||
1086
+ ev.verificationId !== rec.verification.verificationId ||
1087
+ (ev.workspace ?? null) !== rec.verification.workspace
1088
+ ) {
1089
+ fail(`acceptance-bound intent ${ev.id} was completed with stale machine verification`);
1090
+ }
1091
+ }
880
1092
  rec.status = ev.status;
881
1093
  rec.tsEnd = ev.ts;
882
1094
  rec.note = ev.note || null;
@@ -1335,8 +1547,19 @@ function parseArgs(argv, spec, usageKey) {
1335
1547
  function render(rec) {
1336
1548
  const lines = [`[${rec.id}] ${rec.status}`];
1337
1549
  lines.push(` intent: ${rec.intent}`);
1550
+ for (const criterion of rec.acceptance) lines.push(` accept: ${criterion}`);
1338
1551
  if (rec.decisions.length > 0) lines.push(` decisions: ${rec.decisions.join(', ')}`);
1339
1552
  if (rec.verify) lines.push(` verify: ${rec.verify}`);
1553
+ if (rec.verification) {
1554
+ const state = rec.verification.passed ? 'passed' : 'failed';
1555
+ const workspace = rec.verification.workspace
1556
+ ? `, workspace ${rec.verification.workspace.slice(0, 12)}`
1557
+ : ', workspace unavailable';
1558
+ lines.push(
1559
+ ` machine-verification: ${state} (exit ${rec.verification.exitCode ?? '-'}, ` +
1560
+ `${rec.verification.durationMs} ms${workspace})`
1561
+ );
1562
+ }
1340
1563
  if (rec.verifyResult) lines.push(` verify-result: ${rec.verifyResult}`);
1341
1564
  if (rec.note) lines.push(` note: ${rec.note}`);
1342
1565
  if (rec.beginHead || rec.endHead) {
@@ -1347,12 +1570,31 @@ function render(rec) {
1347
1570
  return lines.join('\n');
1348
1571
  }
1349
1572
 
1573
+ function publicVerification(verification) {
1574
+ if (!verification) return null;
1575
+ return {
1576
+ id: verification.verificationId,
1577
+ passed: verification.passed,
1578
+ exitCode: verification.exitCode,
1579
+ signal: verification.signal,
1580
+ durationMs: verification.durationMs,
1581
+ outputHash: verification.outputHash,
1582
+ stdoutBytes: verification.stdoutBytes,
1583
+ stderrBytes: verification.stderrBytes,
1584
+ workspace: verification.workspace,
1585
+ head: verification.head,
1586
+ ranAt: verification.ts,
1587
+ };
1588
+ }
1589
+
1350
1590
  function publicIntent(rec) {
1351
1591
  if (!rec) return null;
1352
1592
  return {
1353
1593
  id: rec.id,
1354
1594
  intent: rec.intent,
1595
+ acceptance: [...rec.acceptance],
1355
1596
  verify: rec.verify,
1597
+ verification: publicVerification(rec.verification),
1356
1598
  decisions: [...rec.decisions],
1357
1599
  status: rec.status,
1358
1600
  note: rec.note,
@@ -1634,7 +1876,8 @@ ${intentLogLanguageParagraph(language)}
1634
1876
 
1635
1877
  1. **Write intent first**, before modifying, creating, or deleting files, or
1636
1878
  making any other non-Git change that may need a rollback:
1637
- \`driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"\`.
1879
+ \`driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"\`.
1880
+ Repeat \`--accept\` when completion has multiple independently observable criteria.
1638
1881
  Add one \`--decision <id>\` for each existing decision this round may change.
1639
1882
  Git operations never need an intent and are not included in the intent log;
1640
1883
  Git maintains their history. This includes inspection, branch and worktree
@@ -1649,8 +1892,17 @@ ${intentLogLanguageParagraph(language)}
1649
1892
  and can be verified on its own.
1650
1893
  2. **Execute only the intent.** Scope change? Close the current intent
1651
1894
  (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1652
- 3. **Verify, then close**: run the declared verification, then
1653
- \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<what the verification showed, written for the next agent>"\`.
1895
+ 3. **Reconcile, verify, then close**: for a linked intent, first reconcile every
1896
+ declared decision as described below. For an acceptance-bound intent, inspect the
1897
+ exact command shown by \`driftseal status\`, then run \`driftseal verify\` to execute it
1898
+ and bind its exit status to the current Git-visible workspace contents. A command
1899
+ sourced from the repository intent log is untrusted and requires
1900
+ \`--allow-tracked-command\` after inspection; locally parked commands do not.
1901
+ An intent without \`--accept\` uses its declared check directly. Then run
1902
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<optional context for the next agent>"\`.
1903
+ DriftSeal rejects \`completed\` when machine verification failed, never ran, or
1904
+ the workspace changed after it. Ignored files are outside the workspace fingerprint.
1905
+ Outside a Git worktree, only the recorded exit status is available.
1654
1906
  Never report success without closing the intent.
1655
1907
  Before closing a linked intent as \`completed\` or \`partial\`, reconcile every
1656
1908
  declared decision with \`driftseal decision update <id> --status <status> --note "<why>"\`.
@@ -1682,8 +1934,30 @@ Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); ${localLo
1682
1934
  ${INTENT_PROTOCOL_END}`;
1683
1935
  }
1684
1936
 
1685
- function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE) {
1686
- const v11 = intentProtocolBlock(version, language)
1937
+ function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1938
+ const v12 = intentProtocolBlock(version, language, localLog)
1939
+ .replace(
1940
+ ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
1941
+ ' Repeat `--accept` when completion has multiple independently observable criteria.',
1942
+ ' `driftseal begin "<what this round will accomplish>" --verify "<command or check that proves it>"`.'
1943
+ )
1944
+ .replace(
1945
+ '3. **Reconcile, verify, then close**: for a linked intent, first reconcile every\n' +
1946
+ ' declared decision as described below. For an acceptance-bound intent, inspect the\n' +
1947
+ ' exact command shown by `driftseal status`, then run `driftseal verify` to execute it\n' +
1948
+ ' and bind its exit status to the current Git-visible workspace contents. A command\n' +
1949
+ ' sourced from the repository intent log is untrusted and requires\n' +
1950
+ ' `--allow-tracked-command` after inspection; locally parked commands do not.\n' +
1951
+ ' An intent without `--accept` uses its declared check directly. Then run\n' +
1952
+ ' `driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<optional context for the next agent>"`.\n' +
1953
+ ' DriftSeal rejects `completed` when machine verification failed, never ran, or\n' +
1954
+ ' the workspace changed after it. Ignored files are outside the workspace fingerprint.\n' +
1955
+ ' Outside a Git worktree, only the recorded exit status is available.',
1956
+ '3. **Verify, then close**: run the declared verification, then\n' +
1957
+ ' `driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<what the verification showed, written for the next agent>"`.'
1958
+ );
1959
+ if (version >= 12) return v12;
1960
+ const v11 = v12
1687
1961
  .replace(
1688
1962
  '-r "<what the verification showed, written for the next agent>"',
1689
1963
  '-r "<verify output>"'
@@ -1876,9 +2150,9 @@ When an intent declares an existing decision with \`--decision <id>\`, use
1876
2150
  Commit \`.decision-log/\` with the code.`;
1877
2151
  }
1878
2152
 
1879
- function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE) {
1880
- if (version >= 11) return decisionProtocolBlock(version, language);
1881
- const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, language), language);
2153
+ function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
2154
+ if (version >= 11) return decisionProtocolBlock(version, language, localLog);
2155
+ const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, language, localLog), language);
1882
2156
  if (version >= 9) return v10;
1883
2157
  const v8 = v10.replace(
1884
2158
  '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
@@ -2627,16 +2901,21 @@ function hookReminder(event, { readOnly = false } = {}) {
2627
2901
  if (event === 'prompt') {
2628
2902
  return (
2629
2903
  'DriftSeal reminder: if this round will modify files or anything else that may need a ' +
2630
- 'rollback, begin an intent first: driftseal begin "<intent>" --verify "<check>". ' +
2904
+ 'rollback, begin an intent first: driftseal begin "<intent>" --accept "<observable outcome>" ' +
2905
+ '--verify "<command>". ' +
2631
2906
  'Questions, read-only exploration, and single-step checks need no intent — skip this ' +
2632
2907
  'reminder when it does not apply.'
2633
2908
  );
2634
2909
  }
2635
2910
  const open = openIntent(fold(readEvents({ file, readOnly })));
2636
2911
  if (open) {
2912
+ const reconciliation = open.decisions.length > 0 ? 'reconcile every linked decision, then ' : '';
2913
+ const verification = open.acceptance.length > 0
2914
+ ? `${reconciliation}inspect and run driftseal verify, then close it with driftseal end`
2915
+ : `${reconciliation}run the declared verification, then close it with driftseal end`;
2637
2916
  return (
2638
2917
  `DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
2639
- 'If its work is done, run the declared verification and close it with driftseal end; ' +
2918
+ `If its work is done, ${verification}; ` +
2640
2919
  'if this turn was unrelated, ignore this reminder.'
2641
2920
  );
2642
2921
  }
@@ -2711,6 +2990,72 @@ function isGitWorkTree(cwd = process.cwd()) {
2711
2990
  return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2712
2991
  }
2713
2992
 
2993
+ /**
2994
+ * Hash the material Git-visible workspace contents rather than trusting the
2995
+ * current commit alone. Any tracked or untracked (non-ignored) content change
2996
+ * makes the verification stale.
2997
+ * The intent event log is excluded because recording verification and closure
2998
+ * necessarily appends to it.
2999
+ */
3000
+ function workspaceFingerprint(cwd = process.cwd()) {
3001
+ if (!isGitWorkTree(cwd)) return null;
3002
+ const root = gitCaptureLine(['rev-parse', '--show-toplevel'], cwd);
3003
+ if (!root) return null;
3004
+ const listing = gitCaptureRaw(
3005
+ ['ls-files', '-z', '--cached', '--others', '--exclude-standard'],
3006
+ root
3007
+ );
3008
+ if (listing === null) return null;
3009
+
3010
+ const excludedFile = path.resolve(logFile());
3011
+ const excludedLockPrefixes = [logDir(), decisionDir()].map((directory) =>
3012
+ path.resolve(directory, '.driftseal.lock')
3013
+ );
3014
+ const files = [...new Set(listing.split('\0').filter(Boolean))].sort();
3015
+ const hash = crypto.createHash('sha256');
3016
+ for (const relative of files) {
3017
+ const target = path.resolve(root, relative);
3018
+ if (
3019
+ target === excludedFile ||
3020
+ excludedLockPrefixes.some(
3021
+ (prefix) => target === prefix || target.startsWith(`${prefix}${path.sep}`) || target.startsWith(`${prefix}.stale.`)
3022
+ )
3023
+ ) {
3024
+ continue;
3025
+ }
3026
+ hash.update(relative, 'utf8');
3027
+ hash.update('\0');
3028
+ let stat;
3029
+ try {
3030
+ stat = fs.lstatSync(target);
3031
+ } catch (err) {
3032
+ if (err.code === 'ENOENT') {
3033
+ hash.update('missing\0');
3034
+ continue;
3035
+ }
3036
+ throw err;
3037
+ }
3038
+ hash.update(String(stat.mode & 0o111));
3039
+ hash.update('\0');
3040
+ if (stat.isSymbolicLink()) {
3041
+ hash.update('symlink\0');
3042
+ hash.update(fs.readlinkSync(target));
3043
+ } else if (stat.isFile()) {
3044
+ hash.update('file\0');
3045
+ hash.update(fs.readFileSync(target));
3046
+ } else if (stat.isDirectory()) {
3047
+ hash.update('directory\0');
3048
+ hash.update(gitCapture(['rev-parse', 'HEAD'], target) || 'no-head');
3049
+ hash.update('\0');
3050
+ hash.update(gitCaptureRaw(['status', '--porcelain=v1', '-z'], target) || '');
3051
+ } else {
3052
+ hash.update('other\0');
3053
+ }
3054
+ hash.update('\0');
3055
+ }
3056
+ return hash.digest('hex');
3057
+ }
3058
+
2714
3059
  /**
2715
3060
  * Warn (without mutating the index or .gitignore) when local log mode is on
2716
3061
  * but the default log paths are still tracked by git. The log directories are
@@ -3577,9 +3922,173 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
3577
3922
  });
3578
3923
  }
3579
3924
 
3925
+ function appendVerificationSpawnError(file, error) {
3926
+ const stat = fs.statSync(file);
3927
+ let prefix = '';
3928
+ if (stat.size > 0) {
3929
+ const fd = fs.openSync(file, 'r');
3930
+ const lastByte = Buffer.alloc(1);
3931
+ try {
3932
+ fs.readSync(fd, lastByte, 0, 1, stat.size - 1);
3933
+ } finally {
3934
+ fs.closeSync(fd);
3935
+ }
3936
+ if (lastByte[0] !== 0x0a) prefix = '\n';
3937
+ }
3938
+ fs.appendFileSync(file, `${prefix}${error.message}\n`, 'utf8');
3939
+ }
3940
+
3941
+ function digestAndReplayVerificationOutput(file, writer, hash) {
3942
+ const fd = fs.openSync(file, 'r');
3943
+ const decoder = new StringDecoder('utf8');
3944
+ const buffer = Buffer.allocUnsafe(VERIFICATION_OUTPUT_CHUNK_BYTES);
3945
+ let bytes = 0;
3946
+ let lastCharacter = null;
3947
+
3948
+ const display = (text) => {
3949
+ if (!text) return;
3950
+ writer(text);
3951
+ lastCharacter = text.at(-1);
3952
+ };
3953
+
3954
+ try {
3955
+ while (true) {
3956
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
3957
+ if (count === 0) break;
3958
+ const chunk = buffer.subarray(0, count);
3959
+ hash.update(chunk);
3960
+ bytes += count;
3961
+ display(decoder.write(chunk));
3962
+ }
3963
+ display(decoder.end());
3964
+ } finally {
3965
+ fs.closeSync(fd);
3966
+ }
3967
+
3968
+ return { bytes, endsWithNewline: lastCharacter === '\n' };
3969
+ }
3970
+
3971
+ function executeVerificationCommand(command) {
3972
+ const spool = fs.mkdtempSync(path.join(os.tmpdir(), 'driftseal-verify-'));
3973
+ const stdoutFile = path.join(spool, 'stdout');
3974
+ const stderrFile = path.join(spool, 'stderr');
3975
+ let stdoutFd;
3976
+ let stderrFd;
3977
+
3978
+ try {
3979
+ stdoutFd = fs.openSync(stdoutFile, 'wx', 0o600);
3980
+ stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
3981
+ const started = process.hrtime.bigint();
3982
+ let result;
3983
+ try {
3984
+ result = spawnSync(command, {
3985
+ cwd: process.cwd(),
3986
+ env: process.env,
3987
+ shell: true,
3988
+ stdio: ['ignore', stdoutFd, stderrFd],
3989
+ });
3990
+ } finally {
3991
+ fs.closeSync(stdoutFd);
3992
+ stdoutFd = undefined;
3993
+ fs.closeSync(stderrFd);
3994
+ stderrFd = undefined;
3995
+ }
3996
+ const durationMs = Number((process.hrtime.bigint() - started) / 1000000n);
3997
+ if (result.error) appendVerificationSpawnError(stderrFile, result.error);
3998
+
3999
+ const hash = crypto.createHash('sha256');
4000
+ const stdout = digestAndReplayVerificationOutput(stdoutFile, writeOutput, hash);
4001
+ if (stdout.bytes > 0 && !stdout.endsWithNewline) printLine();
4002
+ hash.update('\0');
4003
+ const stderr = digestAndReplayVerificationOutput(stderrFile, writeErrorOutput, hash);
4004
+ if (stderr.bytes > 0 && !stderr.endsWithNewline) printError();
4005
+
4006
+ return {
4007
+ result,
4008
+ durationMs,
4009
+ outputHash: hash.digest('hex'),
4010
+ stdoutBytes: stdout.bytes,
4011
+ stderrBytes: stderr.bytes,
4012
+ };
4013
+ } finally {
4014
+ if (stdoutFd !== undefined) fs.closeSync(stdoutFd);
4015
+ if (stderrFd !== undefined) fs.closeSync(stderrFd);
4016
+ fs.rmSync(spool, { recursive: true, force: true });
4017
+ }
4018
+ }
4019
+
4020
+ function runMachineVerification({ allowTrackedCommand = false } = {}) {
4021
+ const snapshot = withMutationLocks([logDir()], () => {
4022
+ const intent = openIntent(fold(readEvents({ repairTail: true })));
4023
+ if (!intent) fail('no intent in progress; nothing to verify');
4024
+ if (intent.acceptance.length === 0) {
4025
+ fail(`intent ${intent.id} has no acceptance criteria; declare them with driftseal begin --accept`);
4026
+ }
4027
+ if (!intent.verify) fail(`intent ${intent.id} has no verification command`);
4028
+ const park = inProgressFile();
4029
+ const parked = park ? parkedOpenIntent(park) : null;
4030
+ const locallyProvenanced = hasMatchingLocalIntentProvenance(intent);
4031
+ return {
4032
+ id: intent.id,
4033
+ command: intent.verify,
4034
+ requiresExplicitTrust:
4035
+ (!parked || parked.id !== intent.id) && !locallyProvenanced,
4036
+ };
4037
+ });
4038
+
4039
+ const displayedCommand = JSON.stringify(snapshot.command);
4040
+ printError(`verification command: ${displayedCommand}`);
4041
+ if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
4042
+ fail(
4043
+ `refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
4044
+ 'no matching local intent provenance was found; ' +
4045
+ 'inspect the command, then re-run with --allow-tracked-command only if you trust it'
4046
+ );
4047
+ }
4048
+
4049
+ const execution = executeVerificationCommand(snapshot.command);
4050
+ const { result, durationMs, outputHash, stdoutBytes, stderrBytes } = execution;
4051
+ const exitCode = Number.isInteger(result.status) ? result.status : 1;
4052
+ const signal = typeof result.signal === 'string' ? result.signal : null;
4053
+ const passed = exitCode === 0 && signal === null;
4054
+ const verificationEvent = {
4055
+ type: 'verify',
4056
+ id: snapshot.id,
4057
+ verificationId: crypto.randomUUID(),
4058
+ ts: new Date().toISOString(),
4059
+ command: snapshot.command,
4060
+ passed,
4061
+ exitCode,
4062
+ signal,
4063
+ durationMs,
4064
+ outputHash,
4065
+ stdoutBytes,
4066
+ stderrBytes,
4067
+ workspace: workspaceFingerprint(),
4068
+ head: gitCapture(['rev-parse', 'HEAD']),
4069
+ };
4070
+
4071
+ const intent = withMutationLocks([logDir()], () => {
4072
+ const events = readEvents({ repairTail: true });
4073
+ const current = openIntent(fold(events));
4074
+ if (!current || current.id !== snapshot.id || current.verify !== snapshot.command) {
4075
+ fail(`intent ${snapshot.id} changed while its verification command was running`);
4076
+ }
4077
+ events.push(appendEvent(verificationEvent));
4078
+ return fold(events).find((candidate) => candidate.id === snapshot.id);
4079
+ });
4080
+ printLine(`${snapshot.id} verification ${passed ? 'passed' : 'failed'} (exit ${exitCode})`);
4081
+ return {
4082
+ intent: publicIntent(intent),
4083
+ verification: publicVerification(intent.verification),
4084
+ exitCode,
4085
+ };
4086
+ }
4087
+
3580
4088
  const commands = {
3581
4089
  begin(argv) {
3582
4090
  const { positionals, flags } = parseArgs(argv, {
4091
+ accept: 'multiple',
3583
4092
  verify: '-v',
3584
4093
  decision: 'multiple',
3585
4094
  force: 'boolean',
@@ -3588,6 +4097,13 @@ const commands = {
3588
4097
  if (!intent) {
3589
4098
  fail(usageFor('begin'));
3590
4099
  }
4100
+ const acceptance = [...new Set((flags.accept || []).map((criterion) => criterion.trim()))];
4101
+ if (acceptance.some((criterion) => criterion.length === 0)) {
4102
+ fail('--accept requires a non-empty observable outcome');
4103
+ }
4104
+ if (acceptance.length > 0 && (!flags.verify || flags.verify.trim().length === 0)) {
4105
+ fail('--accept requires --verify with the exact machine verification command');
4106
+ }
3591
4107
  const requestedDecisions = flags.decision || [];
3592
4108
  const index = requestedDecisions.length > 0 ? decisionIndex() : [];
3593
4109
  const decisions = [
@@ -3628,6 +4144,7 @@ const commands = {
3628
4144
  id,
3629
4145
  ts: new Date().toISOString(),
3630
4146
  intent,
4147
+ acceptance,
3631
4148
  verify: flags.verify || null,
3632
4149
  decisions,
3633
4150
  head: gitCapture(['rev-parse', 'HEAD']),
@@ -3637,6 +4154,16 @@ const commands = {
3637
4154
  return publicIntent(record);
3638
4155
  },
3639
4156
 
4157
+ verify(argv) {
4158
+ const { positionals, flags } = parseArgs(
4159
+ argv,
4160
+ { 'allow-tracked-command': 'boolean' },
4161
+ 'verify'
4162
+ );
4163
+ if (positionals.length > 0) fail(usageFor('verify'));
4164
+ return runMachineVerification({ allowTrackedCommand: flags['allow-tracked-command'] === true });
4165
+ },
4166
+
3640
4167
  end(argv) {
3641
4168
  const { positionals, flags } = parseArgs(argv, {
3642
4169
  status: '-s',
@@ -3661,6 +4188,25 @@ const commands = {
3661
4188
  if (!target) fail('no intent in progress; nothing to end');
3662
4189
  }
3663
4190
 
4191
+ let completionWorkspace = null;
4192
+ let completionVerificationId = null;
4193
+ if (status === 'completed' && target.acceptance.length > 0) {
4194
+ if (!target.verification || !target.verification.passed) {
4195
+ fail(
4196
+ `cannot complete acceptance-bound intent ${target.id} without successful machine verification; ` +
4197
+ 'run: driftseal verify'
4198
+ );
4199
+ }
4200
+ completionWorkspace = workspaceFingerprint();
4201
+ if (completionWorkspace !== target.verification.workspace) {
4202
+ fail(
4203
+ `cannot complete acceptance-bound intent ${target.id}: workspace changed after machine verification; ` +
4204
+ 'run: driftseal verify'
4205
+ );
4206
+ }
4207
+ completionVerificationId = target.verification.verificationId;
4208
+ }
4209
+
3664
4210
  if (['failed', 'abandoned'].includes(status)) {
3665
4211
  const terminalStatus = closeIntentAsEscape(
3666
4212
  events,
@@ -3715,6 +4261,8 @@ const commands = {
3715
4261
  status,
3716
4262
  note: flags.note || null,
3717
4263
  verifyResult: flags['verify-result'] || null,
4264
+ verificationId: completionVerificationId,
4265
+ workspace: completionWorkspace,
3718
4266
  head: gitCapture(['rev-parse', 'HEAD']),
3719
4267
  }));
3720
4268
  const record = fold(events).find((candidate) => candidate.id === target.id);
@@ -4059,7 +4607,10 @@ const commands = {
4059
4607
  knownManagedBlocks: [
4060
4608
  ...sourceLanguages.flatMap((source) => [
4061
4609
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
4610
+ protocolEol(previousIntentProtocolBlock(12, source), eol),
4611
+ protocolEol(previousIntentProtocolBlock(12, source, true), eol),
4062
4612
  protocolEol(previousIntentProtocolBlock(11, source), eol),
4613
+ protocolEol(previousIntentProtocolBlock(11, source, true), eol),
4063
4614
  ]),
4064
4615
  protocolEol(previousIntentProtocolBlock(2), eol),
4065
4616
  protocolEol(previousIntentProtocolBlock(3), eol),
@@ -4083,7 +4634,10 @@ const commands = {
4083
4634
  knownManagedBlocks: [
4084
4635
  ...sourceLanguages.flatMap((source) => [
4085
4636
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
4637
+ protocolEol(previousDecisionProtocolBlock(12, source), eol),
4638
+ protocolEol(previousDecisionProtocolBlock(12, source, true), eol),
4086
4639
  protocolEol(previousDecisionProtocolBlock(11, source), eol),
4640
+ protocolEol(previousDecisionProtocolBlock(11, source, true), eol),
4087
4641
  ]),
4088
4642
  protocolEol(previousDecisionProtocolBlock(2), eol),
4089
4643
  protocolEol(previousDecisionProtocolBlock(3), eol),
@@ -4146,7 +4700,11 @@ const commands = {
4146
4700
  Intent-level write-ahead log for agent sessions.
4147
4701
 
4148
4702
  usage:
4149
- driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]
4703
+ driftseal begin "<intent>" [--accept "<observable outcome>"] [--verify "<command>"]
4704
+ [--decision <id>] [--force]
4705
+ driftseal verify [--allow-tracked-command]
4706
+ run the declared command and bind its result
4707
+ to the current Git-visible workspace contents
4150
4708
  driftseal end [id] [--status completed|partial|failed|abandoned] [--note "..."] [--verify-result "..."]
4151
4709
  driftseal status show the intent currently in progress (re-anchor after drift)
4152
4710
  driftseal log [--last N] [--all] show intent history (--all includes reclaimed records)
@@ -4214,7 +4772,7 @@ function requestedEndStatus(argv) {
4214
4772
  * subcommand, mirroring the value-taking entries of each parseArgs spec.
4215
4773
  */
4216
4774
  const VALUE_TAKING_FLAGS = {
4217
- begin: ['--verify', '-v', '--decision'],
4775
+ begin: ['--accept', '--verify', '-v', '--decision'],
4218
4776
  end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
4219
4777
  log: ['--last', '-n'],
4220
4778
  reclaim: ['--reason', '-r', '--older-than'],
@@ -4319,7 +4877,11 @@ function dispatch(argv) {
4319
4877
  const data = withMutationLocks(resources, () => fn(rest));
4320
4878
  return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
4321
4879
  }
4322
- return { data: fn(rest), exitCode: 0 };
4880
+ const data = fn(rest);
4881
+ return {
4882
+ data,
4883
+ exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0,
4884
+ };
4323
4885
  } catch (err) {
4324
4886
  if (err instanceof HelpRequested) {
4325
4887
  printLine(usageFor(err.usageKey) || usageFor(cmd) || 'run: driftseal help');
@@ -4355,7 +4917,15 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4355
4917
  const previousIntentHome = process.env.DRIFTSEAL_HOME;
4356
4918
  const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
4357
4919
  const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
4920
+ const captures = capture
4921
+ ? { stdout: createBoundedOutputCapture(), stderr: createBoundedOutputCapture() }
4922
+ : null;
4358
4923
  const previousOutput = activeOutput;
4924
+ const finalizeCapturedOutput = () => {
4925
+ if (!captures) return;
4926
+ output.stdout = renderBoundedOutput(captures.stdout);
4927
+ output.stderr = renderBoundedOutput(captures.stderr);
4928
+ };
4359
4929
 
4360
4930
  try {
4361
4931
  process.chdir(fixedRoot);
@@ -4363,14 +4933,16 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4363
4933
  delete process.env.DRIFTSEAL_HOME;
4364
4934
  delete process.env.DRIFTSEAL_DECISION_HOME;
4365
4935
  }
4366
- if (capture) activeOutput = output;
4936
+ if (capture) activeOutput = captures;
4367
4937
  const result = dispatch(argv);
4368
4938
  output.data = result.data;
4369
4939
  output.exitCode = result.exitCode;
4370
4940
  output.readOnly = result.readOnly === true;
4941
+ finalizeCapturedOutput();
4371
4942
  return output;
4372
4943
  } catch (err) {
4373
4944
  if (capture) {
4945
+ finalizeCapturedOutput();
4374
4946
  err.stdout = output.stdout;
4375
4947
  err.stderr = output.stderr;
4376
4948
  }
@@ -4406,13 +4978,17 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
4406
4978
  status() {
4407
4979
  return call(['status']);
4408
4980
  },
4409
- begin({ intent, verify, decisions = [], force = false }) {
4981
+ begin({ intent, acceptance = [], verify, decisions = [], force = false }) {
4410
4982
  const argv = ['begin', intent];
4983
+ for (const criterion of acceptance) appendFlag(argv, '--accept', criterion);
4411
4984
  appendFlag(argv, '--verify', verify);
4412
4985
  for (const decision of decisions) appendFlag(argv, '--decision', decision);
4413
4986
  if (force) argv.push('--force');
4414
4987
  return call(argv);
4415
4988
  },
4989
+ verify({ allowTrackedCommand = false } = {}) {
4990
+ return call(['verify', ...(allowTrackedCommand ? ['--allow-tracked-command'] : [])]);
4991
+ },
4416
4992
  end({ id, status, note, verifyResult } = {}) {
4417
4993
  const argv = ['end'];
4418
4994
  if (id) argv.push(String(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.2.1",
3
+ "version": "1.3.2",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",