driftseal 1.3.0 → 1.4.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
@@ -210,17 +210,28 @@ driftseal verify
210
210
  `driftseal verify` passes the exact stored string to the operating-system shell.
211
211
  The command can therefore read or modify files, access the network, or run any
212
212
  other program available to the current user. Treat it as executable code, not as
213
- passive log data. A locally created open intent is parked in Git metadata and runs
214
- normally. If the open intent came from the repository's tracked intent log,
215
- DriftSeal prints the command to stderr and refuses to execute it until you inspect
216
- it and explicitly run `driftseal verify --allow-tracked-command`. The programmatic
217
- API and MCP tool expose the equivalent `allowTrackedCommand` opt-in.
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.
218
226
 
219
227
  The verification event records the command's exit status, duration, output
220
228
  digest and byte counts, Git HEAD, and a fingerprint of every tracked or
221
229
  untracked non-ignored workspace file except the intent event log. A successful
222
230
  result becomes stale if those workspace contents change. DriftSeal therefore
223
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.
224
235
  Ignored files are deliberately outside this fingerprint. Outside a Git
225
236
  worktree the fingerprint is unavailable, so the gate proves only the command's
226
237
  recorded exit status and cannot detect later content changes.
@@ -240,22 +251,39 @@ driftseal end \
240
251
 
241
252
  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.
242
253
 
243
- Git operations are entirely outside the intent log because Git maintains their
244
- history. Inspection, branch and worktree management, staging, commits, merges,
245
- rebases, cherry-picks, tags, and pushes never need an intent of their own. They
246
- still require normal authorization and safety checks. A command whose result
247
- can be reconstructed from Git state, such as a patch file regenerated from a
248
- commit range or a scratch harness that re-runs, needs no intent either; content
249
- that will be committed and cannot be reconstructed, such as a `.gitignore`
250
- edit, does. Single-step builds and checks, such as compiling or running tests,
251
- also need no intent. Any other non-Git content change starts a new work round.
254
+ Record an intent for changes intended to persist in the project: edits to code,
255
+ configuration, documentation, dependencies, and equivalent project files. The
256
+ boundary does not depend on Git: inside a worktree it includes content intended
257
+ for commit, while outside Git it includes durable project files. Everything else
258
+ is exempt. Git operations are entirely outside the intent log because Git
259
+ maintains their history; inspection, branch and worktree management, staging,
260
+ commits, merges, rebases, cherry-picks, tags, and pushes never need an intent of
261
+ their own, though they still require normal authorization and safety checks.
262
+ Single-step builds and checks, such as compiling or running tests, need no
263
+ intent. Auxiliary file or shell operations whose results remain outside durable
264
+ project content — an `rsync` scratch copy, temp scaffolding — need none either.
265
+ State changes to a remote machine or the local environment are also exempt when
266
+ they do not write durable project content into this workspace. When an external
267
+ operation does bring durable content into the project, record the intent for
268
+ that project-content change, not for the external operation itself.
269
+
270
+ In multi-agent work the scope belongs to the worktree, not the writer. One
271
+ worktree holds one open intent; every agent or subagent changing durable project
272
+ content there re-anchors and continues that matching intent. Agents in separate
273
+ worktrees hold separate intents. A configured project root outside Git follows
274
+ the same single-intent rule. An agent that only receives another agent's changes
275
+ through Git or into a shared worktree records no receiving intent and lets
276
+ `verify` expose misalignment. Handoff files are exempt while ignored or
277
+ otherwise kept outside durable project content and require an intent when
278
+ promoted into it. Taking over work in the same root is a re-anchor, not a
279
+ boundary: resume the open intent when its objective still matches the task.
252
280
 
253
281
  ## Commands
254
282
 
255
283
  | Command | Purpose |
256
284
  | --- | --- |
257
285
  | `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. |
258
- | `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 sourced from the tracked intent log require the explicit opt-in. |
286
+ | `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. |
259
287
  | `driftseal end [id] [-s status] [-n note] [-r verify-result]` | Close an intent honestly. |
260
288
  | `driftseal status` | Show the intent currently in progress. |
261
289
  | `driftseal log [-n N] [--all]` | Review intent history (`--all` includes reclaimed records). |
package/README.zh-CN.md CHANGED
@@ -199,16 +199,23 @@ driftseal verify
199
199
 
200
200
  `driftseal verify` 会把日志中保存的完整字符串交给操作系统 shell。这个命令可以
201
201
  读写文件、访问网络,也可以运行当前用户有权执行的任何程序;因此它是可执行代码,
202
- 不是被动的日志数据。当前 worktree 本地创建的 open intent park Git metadata
203
- 中,可以直接运行。如果 open intent 来自 repository 中被跟踪的 intent log,
204
- DriftSeal 会先把命令输出到 stderr,并拒绝执行;只有检查并信任该命令后,才能显式
205
- 运行 `driftseal verify --allow-tracked-command`。Programmatic API MCP tool 中对应的
206
- 显式开关是 `allowTrackedCommand`。
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 一起复制到别处也不会转移信任。
207
212
 
208
213
  验证事件会记录 exit status、耗时、输出摘要及字节数、Git HEAD,以及当前所有
209
214
  tracked 和未被 ignore 的 untracked 文件的内容指纹(intent event log 除外)。
210
215
  验证后只要这些内容发生变化,成功证据就会过期,必须重新运行;否则 DriftSeal
211
- 会拒绝把 intent 关闭为 `completed`。被 ignore 的文件不在指纹范围内。
216
+ 会拒绝把 intent 关闭为 `completed`。命令输出会先写入临时 spool 文件,而不是
217
+ 受固定大小的内存 buffer 限制;命令退出后再回放并删除。因此 DriftSeal 不再限制
218
+ 输出大小,但实际容量仍受可用磁盘空间约束。被 ignore 的文件不在指纹范围内。
212
219
  如果当前目录不是 Git worktree,指纹不可用;此时 gate 只能证明记录到的 exit
213
220
  status,无法发现之后发生的内容变化。
214
221
 
@@ -228,19 +235,30 @@ driftseal end \
228
235
 
229
236
  如果范围发生变化,先把当前 intent 以 `partial` 或 `abandoned` 关闭,再开启新的 intent。发生 context loss 后,用 `driftseal status` 和 `driftseal log --last 3` 重新锚定当前目标。
230
237
 
231
- Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
232
- worktree、stage、commit、merge、rebase、cherry-pick、tag push 都不需要
233
- 单独开启 intent,但仍须遵守正常的授权与安全要求。结果能从 Git 状态重建的命令
234
- ——比如从 commit range 重新生成的 patch 文件、可以重跑的临时 harness——也不需要
235
- intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`)则需要。编译、跑测试
236
- 等单步构建或检查同样不需要 intent;除此之外的非 Git 内容改动,都要开启新一轮。
238
+ 需要记录 intent 的是准备作为项目内容长期保留的改动,包括代码、配置、文档、依赖及同类
239
+ 项目文件。这个边界不取决于 Git:在 worktree 中,它包括准备提交的内容;在非 Git 项目中,
240
+ 它包括要长期保留的项目文件。其余操作一律免记。Git 会自行维护操作历史,因此查看状态、
241
+ 管理 branch worktree、stage、commit、merge、rebase、cherry-pick、tag push
242
+ 不需要单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查也
243
+ 不需要 intent。结果不会成为持久项目内容的辅助文件或 shell 操作——比如 `rsync` 临时拷贝、
244
+ 临时脚手架——同样免记。远程机器或本机环境的状态变更,只要不把持久项目内容写入当前
245
+ workspace,也不属于 intent log;如果外部操作确实把持久内容带进项目,记录的是落入项目的
246
+ 内容改动,而不是外部操作本身。
247
+
248
+ 多 agent 协作时,intent 的作用域属于 worktree,而不是写入者。一个 worktree 只能有一个
249
+ open intent;在那里改动持久项目内容的所有 agent 和 subagent 都要先重新锚定,再继续同一个
250
+ 匹配的 intent。不同 worktree 各自持有 intent;非 Git 项目的 configured root 同样只持有
251
+ 一个。只通过 Git 或共享 worktree 接收其他 agent 变更的一方不另记 receiving intent,交给
252
+ `verify` 暴露不一致。handoff 文件在被 ignore 或以其他方式排除在持久项目内容之外时免记,
253
+ 一旦转为正式项目内容就需要 intent。在同一个 root 中途接手工作属于重新锚定而非边界:目标
254
+ 仍然匹配就继续沿用 open intent。
237
255
 
238
256
  ## 命令速览
239
257
 
240
258
  | Command | 用途 |
241
259
  | --- | --- |
242
260
  | `driftseal begin "<intent>" [--accept "<outcome>"] [-v "<command>"] [--decision id] [--force]` | 开启一轮工作。可重复使用 `--accept` 声明可观察的完成条件;一旦声明,就必须同时提供验证命令。 |
243
- | `driftseal verify [--allow-tracked-command]` | 执行 acceptance-bound intent 预先声明的命令,并把机器证据绑定到当前 Git 可见的工作区内容;来自 tracked intent log 的命令必须显式 opt in。 |
261
+ | `driftseal verify [--allow-tracked-command]` | 执行 acceptance-bound intent 预先声明的命令,并把机器证据绑定到当前 Git 可见的工作区内容;没有匹配本地 provenance 的命令必须显式 opt in。 |
244
262
  | `driftseal end [id] [-s status] [-n note] [-r verify-result]` | 诚实地关闭 intent。 |
245
263
  | `driftseal status` | 查看当前进行中的 intent。 |
246
264
  | `driftseal log [-n N] [--all]` | 查看 intent 历史(`--all` 包含已回收的记录)。 |
package/bin/driftseal.js CHANGED
@@ -27,6 +27,7 @@ const path = require('path');
27
27
  const crypto = require('crypto');
28
28
  const os = require('os');
29
29
  const { isDeepStrictEqual } = require('util');
30
+ const { StringDecoder } = require('string_decoder');
30
31
  const { execFileSync, spawnSync } = require('child_process');
31
32
  const { version: PACKAGE_VERSION } = require('../package.json');
32
33
 
@@ -40,7 +41,7 @@ const DECISION_STATUSES = [
40
41
  'superseded',
41
42
  ];
42
43
  const EVENT_SCHEMA_VERSION = 4;
43
- const PROTOCOL_VERSION = 13;
44
+ const PROTOCOL_VERSION = 14;
44
45
  const DEFAULT_LOG_LANGUAGE = 'en';
45
46
  const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
46
47
  const LOCK_STALE_MS = 30 * 60 * 1000;
@@ -48,6 +49,10 @@ const LOCK_INIT_STALE_MS = 5 * 1000;
48
49
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
49
50
  const READ_ONLY_LOCK_WAIT_MS = Number(process.env._DRIFTSEAL_TEST_READ_ONLY_LOCK_WAIT_MS) || 1500;
50
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';
51
56
 
52
57
  class DriftSealError extends Error {
53
58
  constructor(message) {
@@ -98,39 +103,70 @@ function usageFor(key) {
98
103
 
99
104
  let activeOutput = null;
100
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
+
101
149
  function printLine(value = '') {
102
150
  const text = String(value);
103
- if (activeOutput) {
104
- activeOutput.stdout += text + '\n';
105
- return;
106
- }
151
+ if (captureOutput('stdout', text + '\n')) return;
107
152
  console.log(text);
108
153
  }
109
154
 
110
155
  function printError(value = '') {
111
156
  const text = String(value);
112
- if (activeOutput) {
113
- activeOutput.stderr += text + '\n';
114
- return;
115
- }
157
+ if (captureOutput('stderr', text + '\n')) return;
116
158
  console.error(text);
117
159
  }
118
160
 
119
161
  function writeOutput(value) {
120
162
  const text = String(value);
121
- if (activeOutput) {
122
- activeOutput.stdout += text;
123
- return;
124
- }
163
+ if (captureOutput('stdout', text)) return;
125
164
  process.stdout.write(text);
126
165
  }
127
166
 
128
167
  function writeErrorOutput(value) {
129
168
  const text = String(value);
130
- if (activeOutput) {
131
- activeOutput.stderr += text;
132
- return;
133
- }
169
+ if (captureOutput('stderr', text)) return;
134
170
  process.stderr.write(text);
135
171
  }
136
172
 
@@ -569,7 +605,12 @@ function parkedOpenIntent(park) {
569
605
 
570
606
  function appendEvent(event) {
571
607
  const park = inProgressFile();
572
- 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
+ }
573
614
 
574
615
  const open = parkedOpenIntent(park);
575
616
  // A park with nothing open left in it belongs in the log; an interrupted end retries here.
@@ -591,9 +632,88 @@ function contentHash(content) {
591
632
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
592
633
  }
593
634
 
594
- 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) {
595
715
  const existed = fs.existsSync(target);
596
- const mode = existed ? fs.statSync(target).mode & 0o777 : 0o644;
716
+ const mode = existed ? fs.statSync(target).mode & 0o777 : createMode;
597
717
  const temp = path.join(
598
718
  path.dirname(target),
599
719
  `.${path.basename(target)}.${process.pid}.${crypto.randomUUID()}.tmp`
@@ -1754,20 +1874,31 @@ MCP and lifecycle hooks are optional adapters.
1754
1874
 
1755
1875
  ${intentLogLanguageParagraph(language)}
1756
1876
 
1757
- 1. **Write intent first**, before modifying, creating, or deleting files, or
1758
- making any other non-Git change that may need a rollback:
1877
+ 1. **Write intent first**, before changing durable project content:
1759
1878
  \`driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"\`.
1760
1879
  Repeat \`--accept\` when completion has multiple independently observable criteria.
1761
1880
  Add one \`--decision <id>\` for each existing decision this round may change.
1762
- Git operations never need an intent and are not included in the intent log;
1763
- Git maintains their history. This includes inspection, branch and worktree
1764
- management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.
1765
- A command whose result can be reconstructed from Git state (for example a
1766
- patch file regenerated from a commit range, or a scratch harness that
1767
- re-runs) needs no intent; content that will be committed and cannot be
1768
- reconstructed (for example a .gitignore edit) does.
1769
- Single-step commands that only build or check work already done, such as
1770
- compiling or running tests, also need no intent.
1881
+ Record intents for changes intended to persist in the project: edits to code,
1882
+ configuration, documentation, dependencies, and equivalent project files,
1883
+ whether or not the project is inside a Git worktree. Everything else is
1884
+ exempt: Git operations (Git maintains their history inspection, branch
1885
+ and worktree management, staging, commits, merges, rebases, cherry-picks,
1886
+ tags, and pushes); single-step commands that only build or check work
1887
+ already done, such as compiling or running tests; auxiliary file or shell
1888
+ operations whose results remain outside durable project content (for example
1889
+ an rsync scratch copy or temp scaffolding); and state changes to a remote
1890
+ machine or the local environment that do not write durable project content
1891
+ into this workspace. When an external operation does bring durable content
1892
+ into the project, record an intent for that project-content change, not for
1893
+ the external operation itself.
1894
+ In multi-agent work, one open intent belongs to one worktree, or to one
1895
+ configured project root outside Git. Every agent or subagent that changes
1896
+ durable project content in the same root first re-anchors and continues its
1897
+ matching open intent; agents working in separate worktrees hold separate
1898
+ intents. An agent that only receives another agent's changes through Git or
1899
+ into a shared worktree records no receiving intent and lets \`verify\` expose
1900
+ misalignment; handoff files are exempt while ignored or otherwise kept
1901
+ outside durable project content and require an intent when promoted into it.
1771
1902
  Size an intent to the smallest unit that leaves the tree self-consistent
1772
1903
  and can be verified on its own.
1773
1904
  2. **Execute only the intent.** Scope change? Close the current intent
@@ -1794,12 +1925,15 @@ ${intentLogLanguageParagraph(language)}
1794
1925
  by the next linked \`decision update\` or successful \`end\`. Closing as
1795
1926
  \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1796
1927
  Git operations remain subject to normal authorization and safety requirements
1797
- even though they do not require an intent. Any non-Git content change made while
1798
- preparing a Git operation does require a new intent, per the step 1 test.
1928
+ even though they do not require an intent. Any content change made while
1929
+ preparing a Git operation still requires an intent when it meets the
1930
+ durable-project-content rule in step 1.
1799
1931
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1800
1932
  doing anything else. The open intent is the source of truth: resume it when its
1801
1933
  objective still matches the current task; otherwise close it (\`partial\` or
1802
- \`abandoned\`, with a note) and \`begin\` a new one.
1934
+ \`abandoned\`, with a note) and \`begin\` a new one. Taking over work in the
1935
+ same root from another agent is the same re-anchor: resume the open intent
1936
+ when its objective still matches.
1803
1937
 
1804
1938
  **Log access goes only through DriftSeal.** Never read, edit, move, or delete
1805
1939
  \`.intent-log/events.jsonl\` (or anything under \`$DRIFTSEAL_HOME\`) directly; use
@@ -1815,7 +1949,75 @@ ${INTENT_PROTOCOL_END}`;
1815
1949
  }
1816
1950
 
1817
1951
  function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1818
- const v12 = intentProtocolBlock(version, language, localLog)
1952
+ const v13 = intentProtocolBlock(version, language, localLog)
1953
+ .replace(
1954
+ '1. **Write intent first**, before changing durable project content:\n' +
1955
+ ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
1956
+ ' Repeat `--accept` when completion has multiple independently observable criteria.\n' +
1957
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
1958
+ ' Record intents for changes intended to persist in the project: edits to code,\n' +
1959
+ ' configuration, documentation, dependencies, and equivalent project files,\n' +
1960
+ ' whether or not the project is inside a Git worktree. Everything else is\n' +
1961
+ ' exempt: Git operations (Git maintains their history — inspection, branch\n' +
1962
+ ' and worktree management, staging, commits, merges, rebases, cherry-picks,\n' +
1963
+ ' tags, and pushes); single-step commands that only build or check work\n' +
1964
+ ' already done, such as compiling or running tests; auxiliary file or shell\n' +
1965
+ ' operations whose results remain outside durable project content (for example\n' +
1966
+ ' an rsync scratch copy or temp scaffolding); and state changes to a remote\n' +
1967
+ ' machine or the local environment that do not write durable project content\n' +
1968
+ ' into this workspace. When an external operation does bring durable content\n' +
1969
+ ' into the project, record an intent for that project-content change, not for\n' +
1970
+ ' the external operation itself.\n' +
1971
+ ' In multi-agent work, one open intent belongs to one worktree, or to one\n' +
1972
+ ' configured project root outside Git. Every agent or subagent that changes\n' +
1973
+ ' durable project content in the same root first re-anchors and continues its\n' +
1974
+ ' matching open intent; agents working in separate worktrees hold separate\n' +
1975
+ " intents. An agent that only receives another agent's changes through Git or\n" +
1976
+ ' into a shared worktree records no receiving intent and lets `verify` expose\n' +
1977
+ ' misalignment; handoff files are exempt while ignored or otherwise kept\n' +
1978
+ ' outside durable project content and require an intent when promoted into it.\n' +
1979
+ ' Size an intent to the smallest unit that leaves the tree self-consistent\n' +
1980
+ ' and can be verified on its own.',
1981
+ '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
1982
+ ' making any other non-Git change that may need a rollback:\n' +
1983
+ ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
1984
+ ' Repeat `--accept` when completion has multiple independently observable criteria.\n' +
1985
+ ' Add one `--decision <id>` for each existing decision this round may change.\n' +
1986
+ ' Git operations never need an intent and are not included in the intent log;\n' +
1987
+ ' Git maintains their history. This includes inspection, branch and worktree\n' +
1988
+ ' management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.\n' +
1989
+ ' A command whose result can be reconstructed from Git state (for example a\n' +
1990
+ ' patch file regenerated from a commit range, or a scratch harness that\n' +
1991
+ ' re-runs) needs no intent; content that will be committed and cannot be\n' +
1992
+ ' reconstructed (for example a .gitignore edit) does.\n' +
1993
+ ' Single-step commands that only build or check work already done, such as\n' +
1994
+ ' compiling or running tests, also need no intent.\n' +
1995
+ ' Size an intent to the smallest unit that leaves the tree self-consistent\n' +
1996
+ ' and can be verified on its own.'
1997
+ )
1998
+ .replace(
1999
+ ' Git operations remain subject to normal authorization and safety requirements\n' +
2000
+ ' even though they do not require an intent. Any content change made while\n' +
2001
+ ' preparing a Git operation still requires an intent when it meets the\n' +
2002
+ ' durable-project-content rule in step 1.',
2003
+ ' Git operations remain subject to normal authorization and safety requirements\n' +
2004
+ ' even though they do not require an intent. Any non-Git content change made while\n' +
2005
+ ' preparing a Git operation does require a new intent, per the step 1 test.'
2006
+ )
2007
+ .replace(
2008
+ '4. **Re-anchor after context loss**: run `driftseal status` and `driftseal log --last 3` before\n' +
2009
+ ' doing anything else. The open intent is the source of truth: resume it when its\n' +
2010
+ ' objective still matches the current task; otherwise close it (`partial` or\n' +
2011
+ ' `abandoned`, with a note) and `begin` a new one. Taking over work in the\n' +
2012
+ ' same root from another agent is the same re-anchor: resume the open intent\n' +
2013
+ ' when its objective still matches.',
2014
+ '4. **Re-anchor after context loss**: run `driftseal status` and `driftseal log --last 3` before\n' +
2015
+ ' doing anything else. The open intent is the source of truth: resume it when its\n' +
2016
+ ' objective still matches the current task; otherwise close it (`partial` or\n' +
2017
+ ' `abandoned`, with a note) and `begin` a new one.'
2018
+ );
2019
+ if (version >= 13) return v13;
2020
+ const v12 = v13
1819
2021
  .replace(
1820
2022
  ' `driftseal begin "<what this round will accomplish>" --accept "<observable outcome>" --verify "<exact command that proves it>"`.\n' +
1821
2023
  ' Repeat `--accept` when completion has multiple independently observable criteria.',
@@ -2780,11 +2982,13 @@ function hookReminder(event, { readOnly = false } = {}) {
2780
2982
  if (!file) return null;
2781
2983
  if (event === 'prompt') {
2782
2984
  return (
2783
- 'DriftSeal reminder: if this round will modify files or anything else that may need a ' +
2784
- 'rollback, begin an intent first: driftseal begin "<intent>" --accept "<observable outcome>" ' +
2985
+ 'DriftSeal reminder: if this round will change durable project content in this workspace ' +
2986
+ '(code, configuration, documentation, dependencies), ' +
2987
+ 'begin an intent first: driftseal begin "<intent>" --accept "<observable outcome>" ' +
2785
2988
  '--verify "<command>". ' +
2786
- 'Questions, read-only exploration, and single-step checks need no intent skip this ' +
2787
- 'reminder when it does not apply.'
2989
+ 'Questions, read-only exploration, single-step checks, temporary work outside durable ' +
2990
+ 'project content, and external state changes that do not write project content here need ' +
2991
+ 'no intent — skip this reminder when it does not apply.'
2788
2992
  );
2789
2993
  }
2790
2994
  const open = openIntent(fold(readEvents({ file, readOnly })));
@@ -3802,6 +4006,101 @@ function absorbGit(baseFile, oursFile, theirsFile, { abandon, dryRun }) {
3802
4006
  });
3803
4007
  }
3804
4008
 
4009
+ function appendVerificationSpawnError(file, error) {
4010
+ const stat = fs.statSync(file);
4011
+ let prefix = '';
4012
+ if (stat.size > 0) {
4013
+ const fd = fs.openSync(file, 'r');
4014
+ const lastByte = Buffer.alloc(1);
4015
+ try {
4016
+ fs.readSync(fd, lastByte, 0, 1, stat.size - 1);
4017
+ } finally {
4018
+ fs.closeSync(fd);
4019
+ }
4020
+ if (lastByte[0] !== 0x0a) prefix = '\n';
4021
+ }
4022
+ fs.appendFileSync(file, `${prefix}${error.message}\n`, 'utf8');
4023
+ }
4024
+
4025
+ function digestAndReplayVerificationOutput(file, writer, hash) {
4026
+ const fd = fs.openSync(file, 'r');
4027
+ const decoder = new StringDecoder('utf8');
4028
+ const buffer = Buffer.allocUnsafe(VERIFICATION_OUTPUT_CHUNK_BYTES);
4029
+ let bytes = 0;
4030
+ let lastCharacter = null;
4031
+
4032
+ const display = (text) => {
4033
+ if (!text) return;
4034
+ writer(text);
4035
+ lastCharacter = text.at(-1);
4036
+ };
4037
+
4038
+ try {
4039
+ while (true) {
4040
+ const count = fs.readSync(fd, buffer, 0, buffer.length, null);
4041
+ if (count === 0) break;
4042
+ const chunk = buffer.subarray(0, count);
4043
+ hash.update(chunk);
4044
+ bytes += count;
4045
+ display(decoder.write(chunk));
4046
+ }
4047
+ display(decoder.end());
4048
+ } finally {
4049
+ fs.closeSync(fd);
4050
+ }
4051
+
4052
+ return { bytes, endsWithNewline: lastCharacter === '\n' };
4053
+ }
4054
+
4055
+ function executeVerificationCommand(command) {
4056
+ const spool = fs.mkdtempSync(path.join(os.tmpdir(), 'driftseal-verify-'));
4057
+ const stdoutFile = path.join(spool, 'stdout');
4058
+ const stderrFile = path.join(spool, 'stderr');
4059
+ let stdoutFd;
4060
+ let stderrFd;
4061
+
4062
+ try {
4063
+ stdoutFd = fs.openSync(stdoutFile, 'wx', 0o600);
4064
+ stderrFd = fs.openSync(stderrFile, 'wx', 0o600);
4065
+ const started = process.hrtime.bigint();
4066
+ let result;
4067
+ try {
4068
+ result = spawnSync(command, {
4069
+ cwd: process.cwd(),
4070
+ env: process.env,
4071
+ shell: true,
4072
+ stdio: ['ignore', stdoutFd, stderrFd],
4073
+ });
4074
+ } finally {
4075
+ fs.closeSync(stdoutFd);
4076
+ stdoutFd = undefined;
4077
+ fs.closeSync(stderrFd);
4078
+ stderrFd = undefined;
4079
+ }
4080
+ const durationMs = Number((process.hrtime.bigint() - started) / 1000000n);
4081
+ if (result.error) appendVerificationSpawnError(stderrFile, result.error);
4082
+
4083
+ const hash = crypto.createHash('sha256');
4084
+ const stdout = digestAndReplayVerificationOutput(stdoutFile, writeOutput, hash);
4085
+ if (stdout.bytes > 0 && !stdout.endsWithNewline) printLine();
4086
+ hash.update('\0');
4087
+ const stderr = digestAndReplayVerificationOutput(stderrFile, writeErrorOutput, hash);
4088
+ if (stderr.bytes > 0 && !stderr.endsWithNewline) printError();
4089
+
4090
+ return {
4091
+ result,
4092
+ durationMs,
4093
+ outputHash: hash.digest('hex'),
4094
+ stdoutBytes: stdout.bytes,
4095
+ stderrBytes: stderr.bytes,
4096
+ };
4097
+ } finally {
4098
+ if (stdoutFd !== undefined) fs.closeSync(stdoutFd);
4099
+ if (stderrFd !== undefined) fs.closeSync(stderrFd);
4100
+ fs.rmSync(spool, { recursive: true, force: true });
4101
+ }
4102
+ }
4103
+
3805
4104
  function runMachineVerification({ allowTrackedCommand = false } = {}) {
3806
4105
  const snapshot = withMutationLocks([logDir()], () => {
3807
4106
  const intent = openIntent(fold(readEvents({ repairTail: true })));
@@ -3811,56 +4110,31 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
3811
4110
  }
3812
4111
  if (!intent.verify) fail(`intent ${intent.id} has no verification command`);
3813
4112
  const park = inProgressFile();
3814
- const parked = park && fs.existsSync(park) ? parkedOpenIntent(park) : null;
4113
+ const parked = park ? parkedOpenIntent(park) : null;
4114
+ const locallyProvenanced = hasMatchingLocalIntentProvenance(intent);
3815
4115
  return {
3816
4116
  id: intent.id,
3817
4117
  command: intent.verify,
3818
- fromTrackedLog: isParkableIntentLog() && (!parked || parked.id !== intent.id),
4118
+ requiresExplicitTrust:
4119
+ (!parked || parked.id !== intent.id) && !locallyProvenanced,
3819
4120
  };
3820
4121
  });
3821
4122
 
3822
4123
  const displayedCommand = JSON.stringify(snapshot.command);
3823
4124
  printError(`verification command: ${displayedCommand}`);
3824
- if (snapshot.fromTrackedLog && !allowTrackedCommand) {
4125
+ if (snapshot.requiresExplicitTrust && !allowTrackedCommand) {
3825
4126
  fail(
3826
- `refusing to execute a verification command sourced from the repository intent log: ${displayedCommand}\n` +
4127
+ `refusing to execute a verification command that DriftSeal cannot confirm was created locally: ${displayedCommand}\n` +
4128
+ 'no matching local intent provenance was found; ' +
3827
4129
  'inspect the command, then re-run with --allow-tracked-command only if you trust it'
3828
4130
  );
3829
4131
  }
3830
4132
 
3831
- const started = process.hrtime.bigint();
3832
- const result = spawnSync(snapshot.command, {
3833
- cwd: process.cwd(),
3834
- env: process.env,
3835
- shell: true,
3836
- encoding: 'utf8',
3837
- stdio: ['ignore', 'pipe', 'pipe'],
3838
- maxBuffer: 16 * 1024 * 1024,
3839
- });
3840
- const durationMs = Number((process.hrtime.bigint() - started) / 1000000n);
3841
- const stdout = result.stdout || '';
3842
- let stderr = result.stderr || '';
3843
- if (result.error) {
3844
- stderr += `${stderr && !stderr.endsWith('\n') ? '\n' : ''}${result.error.message}\n`;
3845
- }
3846
- if (stdout) {
3847
- writeOutput(stdout);
3848
- if (!stdout.endsWith('\n')) printLine();
3849
- }
3850
- if (stderr) {
3851
- writeErrorOutput(stderr);
3852
- if (!stderr.endsWith('\n')) printError();
3853
- }
3854
-
4133
+ const execution = executeVerificationCommand(snapshot.command);
4134
+ const { result, durationMs, outputHash, stdoutBytes, stderrBytes } = execution;
3855
4135
  const exitCode = Number.isInteger(result.status) ? result.status : 1;
3856
4136
  const signal = typeof result.signal === 'string' ? result.signal : null;
3857
4137
  const passed = exitCode === 0 && signal === null;
3858
- const outputHash = crypto
3859
- .createHash('sha256')
3860
- .update(stdout, 'utf8')
3861
- .update('\0')
3862
- .update(stderr, 'utf8')
3863
- .digest('hex');
3864
4138
  const verificationEvent = {
3865
4139
  type: 'verify',
3866
4140
  id: snapshot.id,
@@ -3872,8 +4146,8 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
3872
4146
  signal,
3873
4147
  durationMs,
3874
4148
  outputHash,
3875
- stdoutBytes: Buffer.byteLength(stdout),
3876
- stderrBytes: Buffer.byteLength(stderr),
4149
+ stdoutBytes,
4150
+ stderrBytes,
3877
4151
  workspace: workspaceFingerprint(),
3878
4152
  head: gitCapture(['rev-parse', 'HEAD']),
3879
4153
  };
@@ -4417,6 +4691,8 @@ const commands = {
4417
4691
  knownManagedBlocks: [
4418
4692
  ...sourceLanguages.flatMap((source) => [
4419
4693
  protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
4694
+ protocolEol(previousIntentProtocolBlock(13, source), eol),
4695
+ protocolEol(previousIntentProtocolBlock(13, source, true), eol),
4420
4696
  protocolEol(previousIntentProtocolBlock(12, source), eol),
4421
4697
  protocolEol(previousIntentProtocolBlock(12, source, true), eol),
4422
4698
  protocolEol(previousIntentProtocolBlock(11, source), eol),
@@ -4444,6 +4720,8 @@ const commands = {
4444
4720
  knownManagedBlocks: [
4445
4721
  ...sourceLanguages.flatMap((source) => [
4446
4722
  protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
4723
+ protocolEol(previousDecisionProtocolBlock(13, source), eol),
4724
+ protocolEol(previousDecisionProtocolBlock(13, source, true), eol),
4447
4725
  protocolEol(previousDecisionProtocolBlock(12, source), eol),
4448
4726
  protocolEol(previousDecisionProtocolBlock(12, source, true), eol),
4449
4727
  protocolEol(previousDecisionProtocolBlock(11, source), eol),
@@ -4727,7 +5005,15 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4727
5005
  const previousIntentHome = process.env.DRIFTSEAL_HOME;
4728
5006
  const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
4729
5007
  const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
5008
+ const captures = capture
5009
+ ? { stdout: createBoundedOutputCapture(), stderr: createBoundedOutputCapture() }
5010
+ : null;
4730
5011
  const previousOutput = activeOutput;
5012
+ const finalizeCapturedOutput = () => {
5013
+ if (!captures) return;
5014
+ output.stdout = renderBoundedOutput(captures.stdout);
5015
+ output.stderr = renderBoundedOutput(captures.stderr);
5016
+ };
4731
5017
 
4732
5018
  try {
4733
5019
  process.chdir(fixedRoot);
@@ -4735,14 +5021,16 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4735
5021
  delete process.env.DRIFTSEAL_HOME;
4736
5022
  delete process.env.DRIFTSEAL_DECISION_HOME;
4737
5023
  }
4738
- if (capture) activeOutput = output;
5024
+ if (capture) activeOutput = captures;
4739
5025
  const result = dispatch(argv);
4740
5026
  output.data = result.data;
4741
5027
  output.exitCode = result.exitCode;
4742
5028
  output.readOnly = result.readOnly === true;
5029
+ finalizeCapturedOutput();
4743
5030
  return output;
4744
5031
  } catch (err) {
4745
5032
  if (capture) {
5033
+ finalizeCapturedOutput();
4746
5034
  err.stdout = output.stdout;
4747
5035
  err.stderr = output.stderr;
4748
5036
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",