driftseal 1.1.6 → 1.2.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
@@ -73,8 +73,9 @@ driftseal skill install --target kimi-code --scope global
73
73
  | `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
74
74
 
75
75
  Use `--root <repository>` to select a project when running the installer
76
- elsewhere. Repeated installs of identical content are no-ops; a different
77
- existing skill requires `--force`. MCP and lifecycle hooks are optional
76
+ elsewhere. Repeated installs of identical content are no-ops, and a skill left
77
+ by an earlier DriftSeal release is upgraded in place; only a skill this
78
+ installer never wrote requires `--force`. MCP and lifecycle hooks are optional
78
79
  adapters; enable them only for a concrete host constraint or reminder need, not
79
80
  as additional policy layers.
80
81
 
@@ -212,9 +213,12 @@ If the scope changes, close the current intent as `partial` or `abandoned`, then
212
213
  Git operations are entirely outside the intent log because Git maintains their
213
214
  history. Inspection, branch and worktree management, staging, commits, merges,
214
215
  rebases, cherry-picks, tags, and pushes never need an intent of their own. They
215
- still require normal authorization and safety checks. Single-step builds and
216
- checks, such as compiling or running tests, also need no intent. Any non-Git
217
- content change starts a new work round.
216
+ still require normal authorization and safety checks. A command whose result
217
+ can be reconstructed from Git state, such as a patch file regenerated from a
218
+ commit range or a scratch harness that re-runs, needs no intent either; content
219
+ that will be committed and cannot be reconstructed, such as a `.gitignore`
220
+ edit, does. Single-step builds and checks, such as compiling or running tests,
221
+ also need no intent. Any other non-Git content change starts a new work round.
218
222
 
219
223
  ## Commands
220
224
 
@@ -236,7 +240,7 @@ content change starts a new work round.
236
240
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | Install the repository-pinned MCP server into Codex, Kimi Code, OpenCode, Claude Code, or Cursor. |
237
241
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
238
242
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
239
- | `driftseal init [--lang <tag>]` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. `--lang` sets the intent/decision log language (BCP 47, default `en`). |
243
+ | `driftseal init [--lang <tag>] [--local-log]` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. `--lang` sets the intent/decision log language (BCP 47, default `en`). `--local-log` keeps the logs local and untracked instead of committing them with the code; if the logs are already tracked, init warns with the remediation steps and leaves the index and `.gitignore` untouched. |
240
244
  | `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
241
245
  | `driftseal help` | Print CLI usage. |
242
246
 
package/README.zh-CN.md CHANGED
@@ -71,7 +71,8 @@ driftseal skill install --target kimi-code --scope global
71
71
  | `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
72
72
 
73
73
  如果不在目标 repository 中执行,用 `--root <repository>` 明确指定项目。
74
- 重复安装相同内容不会产生改动;目标位置已有不同版本时必须显式传入 `--force`。
74
+ 重复安装相同内容不会产生改动;旧版本 DriftSeal 装下的 skill 会被直接原地升级,
75
+ 只有安装器从未写过的 skill 才需要显式传入 `--force`。
75
76
  MCP 与 lifecycle hook 都是可选适配层;只有确实存在 host 限制或提醒需求时
76
77
  才启用,不要把它们叠成额外的 policy 层。
77
78
 
@@ -201,8 +202,10 @@ driftseal end \
201
202
 
202
203
  Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
203
204
  或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
204
- 单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查
205
- 也不需要 intent;只要要做非 Git 内容改动,就开启新一轮。
205
+ 单独开启 intent,但仍须遵守正常的授权与安全要求。结果能从 Git 状态重建的命令
206
+ ——比如从 commit range 重新生成的 patch 文件、可以重跑的临时 harness——也不需要
207
+ intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`)则需要。编译、跑测试
208
+ 等单步构建或检查同样不需要 intent;除此之外的非 Git 内容改动,都要开启新一轮。
206
209
 
207
210
  ## 命令速览
208
211
 
@@ -224,7 +227,7 @@ Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看
224
227
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
225
228
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
226
229
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
227
- | `driftseal init [--lang <tag>]` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。`--lang` 设置 intent / decision log 的语言(BCP 47,默认 `en`)。 |
230
+ | `driftseal init [--lang <tag>] [--local-log]` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。`--lang` 设置 intent / decision log 的语言(BCP 47,默认 `en`)。`--local-log` 让日志保持本地、不入库,不随代码提交;如果日志已被 git 跟踪,init 会打印警告和处理建议,但不会改动 index 或 `.gitignore`。 |
228
231
  | `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
229
232
  | `driftseal help` | 查看 CLI 用法。 |
230
233
 
@@ -6,6 +6,8 @@ const { createApi, DECISION_STATUSES, END_STATUSES } = require('./driftseal.js')
6
6
 
7
7
  const SERVER_NAME = 'driftseal';
8
8
  const SERVER_VERSION = require('../package.json').version;
9
+ const READ_ONLY_SUFFIX =
10
+ '(read-only: another mutation holds the lock; this snapshot may be incomplete)';
9
11
 
10
12
  function parseArguments(argv) {
11
13
  let root = process.cwd();
@@ -75,11 +77,14 @@ function registerTools(server, api, z) {
75
77
  status: z.enum(END_STATUSES).or(z.literal('in_progress')),
76
78
  note: z.string().nullable(),
77
79
  verifyResult: z.string().nullable(),
80
+ beginHead: z.string().nullable(),
81
+ endHead: z.string().nullable(),
78
82
  beganAt: z.string(),
79
83
  endedAt: z.string().nullable(),
80
84
  reclaimed: z.boolean(),
81
85
  reclaimReason: z.string().nullable(),
82
86
  reclaimedAt: z.string().nullable(),
87
+ readOnly: z.boolean().optional(),
83
88
  });
84
89
  const decisionRecord = z.object({
85
90
  id: z.string(),
@@ -115,15 +120,24 @@ function registerTools(server, api, z) {
115
120
  description:
116
121
  'Inspect the one intent currently in progress before repository work or after context loss. Returns null when no intent is open.',
117
122
  inputSchema: {},
118
- outputSchema: { root: z.string(), intent: intentRecord.nullable() },
123
+ outputSchema: {
124
+ root: z.string(),
125
+ intent: intentRecord.nullable(),
126
+ readOnly: z.boolean().optional(),
127
+ },
119
128
  annotations: readOnly,
120
129
  },
121
130
  async () =>
122
131
  guarded(() => {
123
132
  const intent = api.status();
133
+ const readOnly = api.readOnly;
134
+ const snapshot = intent && readOnly ? { ...intent, readOnly: true } : intent;
135
+ const summary = snapshot
136
+ ? `Intent ${snapshot.id} is ${snapshot.status}.`
137
+ : 'No DriftSeal intent is in progress.';
124
138
  return success(
125
- { root: api.root, intent },
126
- intent ? `Intent ${intent.id} is ${intent.status}.` : 'No DriftSeal intent is in progress.'
139
+ { root: api.root, intent: snapshot, ...(readOnly ? { readOnly: true } : {}) },
140
+ readOnly ? `${summary} ${READ_ONLY_SUFFIX}` : summary
127
141
  );
128
142
  })
129
143
  );
@@ -184,13 +198,22 @@ function registerTools(server, api, z) {
184
198
  last: z.number().int().positive().max(100).optional(),
185
199
  includeReclaimed: z.boolean().default(false),
186
200
  },
187
- outputSchema: { root: z.string(), intents: z.array(intentRecord) },
201
+ outputSchema: {
202
+ root: z.string(),
203
+ intents: z.array(intentRecord),
204
+ readOnly: z.boolean().optional(),
205
+ },
188
206
  annotations: readOnly,
189
207
  },
190
208
  async (input) =>
191
209
  guarded(() => {
192
210
  const intents = api.log({ last: input.last, all: input.includeReclaimed });
193
- return success({ root: api.root, intents }, `Found ${intents.length} DriftSeal intent records.`);
211
+ const readOnly = api.readOnly;
212
+ const summary = `Found ${intents.length} DriftSeal intent records.`;
213
+ return success(
214
+ { root: api.root, intents, ...(readOnly ? { readOnly: true } : {}) },
215
+ readOnly ? `${summary} ${READ_ONLY_SUFFIX}` : summary
216
+ );
194
217
  })
195
218
  );
196
219
 
package/bin/driftseal.js CHANGED
@@ -38,11 +38,13 @@ const DECISION_STATUSES = [
38
38
  'superseded',
39
39
  ];
40
40
  const EVENT_SCHEMA_VERSION = 3;
41
- const PROTOCOL_VERSION = 11;
41
+ const PROTOCOL_VERSION = 12;
42
42
  const DEFAULT_LOG_LANGUAGE = 'en';
43
43
  const IN_PROGRESS_GIT_PATH = 'driftseal-in-progress.jsonl';
44
44
  const LOCK_STALE_MS = 30 * 60 * 1000;
45
45
  const LOCK_INIT_STALE_MS = 5 * 1000;
46
+ const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
47
+ const READ_ONLY_LOCK_WAIT_MS = Number(process.env._DRIFTSEAL_TEST_READ_ONLY_LOCK_WAIT_MS) || 1500;
46
48
  const MAX_DECISION_SLUG_LENGTH = 180;
47
49
 
48
50
  class DriftSealError extends Error {
@@ -52,6 +54,44 @@ class DriftSealError extends Error {
52
54
  }
53
55
  }
54
56
 
57
+ /** Thrown by parseArgs on --help/-h; dispatch prints the usage and exits 0. */
58
+ class HelpRequested extends DriftSealError {
59
+ constructor(usageKey) {
60
+ super('help requested');
61
+ this.name = 'HelpRequested';
62
+ this.usageKey = usageKey || null;
63
+ }
64
+ }
65
+
66
+ /** Single source of truth for per-command usage lines. */
67
+ function usageFor(key) {
68
+ const lines = {
69
+ begin: 'usage: driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]',
70
+ end: 'usage: driftseal end [id] [options]',
71
+ status: 'usage: driftseal status',
72
+ log: 'usage: driftseal log [--last N] [--all]',
73
+ reclaim:
74
+ 'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]',
75
+ unreclaim: 'usage: driftseal unreclaim <id> --reason "<why>"',
76
+ absorb: absorbUsage(),
77
+ init: 'usage: driftseal init [--lang <tag>] [--local-log]',
78
+ decision: 'usage: driftseal decision add|update|list|show (run: driftseal help)',
79
+ 'decision add':
80
+ 'usage: driftseal decision add "<title>" --context "..." --outcome "..." [options]',
81
+ 'decision update':
82
+ 'usage: driftseal decision update <id> [--status <status>] --note "<what changed or was confirmed>"',
83
+ 'decision list': 'usage: driftseal decision list [--status STATUS] [--last N | --count]',
84
+ 'decision show': 'usage: driftseal decision show <id>',
85
+ hook: hookUsage(),
86
+ 'hook install': hookUsage(),
87
+ 'hook prompt': hookUsage(),
88
+ 'hook stop': hookUsage(),
89
+ mcp: mcpInstallUsage(),
90
+ skill: skillInstallUsage(),
91
+ };
92
+ return lines[key] || null;
93
+ }
94
+
55
95
  let activeOutput = null;
56
96
 
57
97
  function printLine(value = '') {
@@ -97,6 +137,11 @@ function decisionDir() {
97
137
  return process.env.DRIFTSEAL_DECISION_HOME || path.join(process.cwd(), '.decision-log');
98
138
  }
99
139
 
140
+ // A corrupt log line must not wedge reads; a non-string head degrades to null.
141
+ function normalizeHead(value) {
142
+ return typeof value === 'string' ? value : null;
143
+ }
144
+
100
145
  function normalizeEvent(event, line) {
101
146
  if (!event || typeof event !== 'object' || Array.isArray(event)) {
102
147
  fail(`invalid event object on log line ${line}`);
@@ -127,12 +172,12 @@ function normalizeEvent(event, line) {
127
172
  if (new Set(decisions).size !== decisions.length) {
128
173
  fail(`duplicate linked decision on log line ${line}`);
129
174
  }
130
- return { ...event, decisions };
175
+ return { ...event, decisions, head: normalizeHead(event.head) };
131
176
  }
132
177
 
133
178
  if (event.type === 'end') {
134
179
  if (!END_STATUSES.includes(event.status)) fail(`invalid end event on log line ${line}`);
135
- return event;
180
+ return { ...event, head: normalizeHead(event.head) };
136
181
  }
137
182
 
138
183
  if (event.type === 'reclaim' || event.type === 'unreclaim') {
@@ -236,8 +281,17 @@ function shouldAttachInProgress(file) {
236
281
  return live !== null && sameResolvedPath(file, live);
237
282
  }
238
283
 
239
- function readJsonlRecordsFromFile(file, { repairTail = false } = {}) {
284
+ function readJsonlRecordsFromFile(file, { repairTail = false, readOnly = false } = {}) {
240
285
  if (!fs.existsSync(file)) return [];
286
+ if (
287
+ readOnly &&
288
+ process.env._DRIFTSEAL_TEST_UNLINK_PARK_BEFORE_READ === '1' &&
289
+ path.basename(file) === IN_PROGRESS_GIT_PATH
290
+ ) {
291
+ // Simulate a writer flushing and unlinking the park between the existence
292
+ // check and the read; the next attempt sees the park as absent.
293
+ fs.unlinkSync(file);
294
+ }
241
295
  let content = fs.readFileSync(file, 'utf8');
242
296
  const rawLines = content.split('\n');
243
297
  if (content.length > 0 && !content.endsWith('\n')) {
@@ -245,14 +299,16 @@ function readJsonlRecordsFromFile(file, { repairTail = false } = {}) {
245
299
  try {
246
300
  JSON.parse(tail);
247
301
  } catch {
248
- if (!repairTail) fail(`corrupt final log line in ${file}`);
249
302
  const validLength = content.lastIndexOf('\n') + 1;
250
- const fd = fs.openSync(file, 'r+');
251
- try {
252
- fs.ftruncateSync(fd, Buffer.byteLength(content.slice(0, validLength), 'utf8'));
253
- fs.fsyncSync(fd);
254
- } finally {
255
- fs.closeSync(fd);
303
+ if (!readOnly) {
304
+ if (!repairTail) fail(`corrupt final log line in ${file}`);
305
+ const fd = fs.openSync(file, 'r+');
306
+ try {
307
+ fs.ftruncateSync(fd, Buffer.byteLength(content.slice(0, validLength), 'utf8'));
308
+ fs.fsyncSync(fd);
309
+ } finally {
310
+ fs.closeSync(fd);
311
+ }
256
312
  }
257
313
  content = content.slice(0, validLength);
258
314
  }
@@ -279,9 +335,9 @@ function overlayIsCommitted(committedEvents, overlayEvents) {
279
335
  }
280
336
 
281
337
  /** How a parked overlay lines up with the committed log; touches neither file. */
282
- function planInProgressOverlay(committedEvents, park, { repairTail = false } = {}) {
338
+ function planInProgressOverlay(committedEvents, park, { repairTail = false, readOnly = false } = {}) {
283
339
  if (!park || !fs.existsSync(park)) return null;
284
- const overlayRecords = readJsonlRecordsFromFile(park, { repairTail });
340
+ const overlayRecords = readJsonlRecordsFromFile(park, { repairTail, readOnly });
285
341
  const overlayEvents = overlayRecords.map((record) => record.event);
286
342
  if (overlayEvents.length === 0 || overlayIsCommitted(committedEvents, overlayEvents)) {
287
343
  return { park, records: [], mappings: [], alreadyCommitted: true };
@@ -295,29 +351,55 @@ function discardInProgressLog(park) {
295
351
  fsyncDirectory(path.dirname(park));
296
352
  }
297
353
 
298
- function reconcileInProgressRecords(committedEvents, { repairTail = false, park = inProgressFile() } = {}) {
299
- const plan = planInProgressOverlay(committedEvents, park, { repairTail });
354
+ function reconcileInProgressRecords(
355
+ committedEvents,
356
+ { repairTail = false, readOnly = false, park = inProgressFile() } = {}
357
+ ) {
358
+ const plan = planInProgressOverlay(committedEvents, park, { repairTail, readOnly });
300
359
  if (!plan) return [];
301
360
  if (plan.alreadyCommitted) {
302
- discardInProgressLog(park);
361
+ if (!readOnly) discardInProgressLog(park);
303
362
  return [];
304
363
  }
305
- if (plan.mappings.length > 0) writeJsonl(park, plan.records);
364
+ if (!readOnly && plan.mappings.length > 0) writeJsonl(park, plan.records);
306
365
  return plan.records;
307
366
  }
308
367
 
309
- function readEvents({ repairTail = false, file = logFile() } = {}) {
310
- const records = readJsonlRecordsFromFile(file, { repairTail });
368
+ const READ_ONLY_SNAPSHOT_ATTEMPTS = 3;
369
+
370
+ function readEventsSnapshot(file, { repairTail = false, readOnly = false } = {}) {
371
+ const records = readJsonlRecordsFromFile(file, { repairTail, readOnly });
311
372
  const events = records.map((record) => record.event);
312
373
  if (!shouldAttachInProgress(file)) return events;
313
374
  return events.concat(
314
375
  reconcileInProgressRecords(events, {
315
376
  repairTail,
377
+ readOnly,
316
378
  park: worktreeInProgressFile(),
317
379
  }).map((record) => record.event)
318
380
  );
319
381
  }
320
382
 
383
+ function readEvents({ repairTail = false, readOnly = false, file = logFile() } = {}) {
384
+ if (!readOnly) return readEventsSnapshot(file, { repairTail, readOnly });
385
+ // Lock-free reads race with a writer flushing the park: the park can pass the
386
+ // existence check and be unlinked before the read. Retry the whole main+park
387
+ // snapshot, because the flush may have appended the park to the main log.
388
+ for (let attempt = 1; ; attempt++) {
389
+ try {
390
+ return readEventsSnapshot(file, { repairTail, readOnly });
391
+ } catch (err) {
392
+ if (err.code !== 'ENOENT') throw err;
393
+ if (attempt >= READ_ONLY_SNAPSHOT_ATTEMPTS) {
394
+ // The park keeps vanishing; treat it as absent and read the main log only.
395
+ return readJsonlRecordsFromFile(file, { repairTail, readOnly }).map(
396
+ (record) => record.event
397
+ );
398
+ }
399
+ }
400
+ }
401
+ }
402
+
321
403
  function parseJsonlRecords(content, source = 'log') {
322
404
  return content
323
405
  .split('\n')
@@ -599,17 +681,23 @@ function clearStaleLock(lock) {
599
681
  return true;
600
682
  }
601
683
 
602
- function acquireMutationLock(resource) {
684
+ const LOCK_WAIT_SIGNAL = new Int32Array(new SharedArrayBuffer(4));
685
+
686
+ function acquireMutationLock(resource, { waitMs = 0, intervalMs = 100 } = {}) {
603
687
  ensureDirectoryDurable(resource);
604
688
  const lock = path.join(resource, '.driftseal.lock');
605
- for (let attempt = 0; attempt < 2; attempt++) {
689
+ const deadline = Date.now() + waitMs;
690
+ for (;;) {
606
691
  try {
607
692
  fs.mkdirSync(lock, { mode: 0o700 });
608
693
  break;
609
694
  } catch (err) {
610
- if (err.code !== 'EEXIST' || attempt > 0 || !clearStaleLock(lock)) {
695
+ if (err.code === 'EEXIST' && clearStaleLock(lock)) continue;
696
+ if (err.code !== 'EEXIST' || Date.now() >= deadline) {
697
+ if (waitMs > 0) return null;
611
698
  fail(`another DriftSeal mutation is in progress (lock: ${lock})`);
612
699
  }
700
+ Atomics.wait(LOCK_WAIT_SIGNAL, 0, 0, intervalMs);
613
701
  }
614
702
  }
615
703
  let token;
@@ -664,7 +752,7 @@ function acquireMutationLock(resource) {
664
752
  };
665
753
  }
666
754
 
667
- function withMutationLocks(resources, action) {
755
+ function withMutationLocks(resources, action, { tryWaitMs } = {}) {
668
756
  const roots = [
669
757
  ...new Set(
670
758
  resources.map((resource) => {
@@ -697,8 +785,16 @@ function withMutationLocks(resources, action) {
697
785
  let actionFailed = false;
698
786
  process.once('exit', bestEffortCleanup);
699
787
  try {
700
- for (const root of roots) releases.push(acquireMutationLock(root));
701
- return action();
788
+ for (const root of roots) {
789
+ const release =
790
+ tryWaitMs === undefined
791
+ ? acquireMutationLock(root)
792
+ : acquireMutationLock(root, { waitMs: tryWaitMs });
793
+ if (!release) return null;
794
+ releases.push(release);
795
+ }
796
+ const data = action();
797
+ return tryWaitMs === undefined ? data : { acquired: true, data };
702
798
  } catch (err) {
703
799
  actionFailed = true;
704
800
  throw err;
@@ -722,6 +818,7 @@ function fold(events) {
722
818
  tsBegin: ev.ts,
723
819
  intent: ev.intent,
724
820
  verify: ev.verify || null,
821
+ beginHead: ev.head || null,
725
822
  decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
726
823
  schemaVersion: ev.schemaVersion || 1,
727
824
  decisionPrepares: [],
@@ -731,6 +828,7 @@ function fold(events) {
731
828
  tsEnd: null,
732
829
  note: null,
733
830
  verifyResult: null,
831
+ endHead: null,
734
832
  reclaimed: false,
735
833
  reclaimReason: null,
736
834
  reclaimedAt: null,
@@ -783,6 +881,7 @@ function fold(events) {
783
881
  rec.tsEnd = ev.ts;
784
882
  rec.note = ev.note || null;
785
883
  rec.verifyResult = ev.verifyResult || null;
884
+ rec.endHead = ev.head || null;
786
885
  } else if (ev.type === 'decision_reconcile_prepare') {
787
886
  const rec = records.get(ev.id);
788
887
  if (!rec) fail(`decision reconciliation references unknown intent id: ${ev.id}`);
@@ -1160,6 +1259,7 @@ function closeIntentAsEscape(events, record, requestedStatus, note, verifyResult
1160
1259
  status,
1161
1260
  note: note || null,
1162
1261
  verifyResult: verifyResult || null,
1262
+ head: gitCapture(['rev-parse', 'HEAD']),
1163
1263
  })
1164
1264
  );
1165
1265
  return status;
@@ -1184,7 +1284,7 @@ function looksLikeFlag(value, spec) {
1184
1284
  }
1185
1285
 
1186
1286
  /** Minimal flag parser: positionals + --flag value / --flag=value / -x value */
1187
- function parseArgs(argv, spec) {
1287
+ function parseArgs(argv, spec, usageKey) {
1188
1288
  const positionals = [];
1189
1289
  const flags = {};
1190
1290
  const assignFlag = (name, value) => {
@@ -1198,6 +1298,7 @@ function parseArgs(argv, spec) {
1198
1298
  };
1199
1299
  for (let i = 0; i < argv.length; i++) {
1200
1300
  const arg = argv[i];
1301
+ if (arg === '--help' || arg === '-h') throw new HelpRequested(usageKey);
1201
1302
  if (arg.startsWith('--')) {
1202
1303
  const eq = arg.indexOf('=');
1203
1304
  const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
@@ -1238,6 +1339,9 @@ function render(rec) {
1238
1339
  if (rec.verify) lines.push(` verify: ${rec.verify}`);
1239
1340
  if (rec.verifyResult) lines.push(` verify-result: ${rec.verifyResult}`);
1240
1341
  if (rec.note) lines.push(` note: ${rec.note}`);
1342
+ if (rec.beginHead || rec.endHead) {
1343
+ lines.push(` head: ${rec.beginHead || '-'}..${rec.endHead || '-'}`);
1344
+ }
1241
1345
  lines.push(` began: ${rec.tsBegin}` + (rec.tsEnd ? ` ended: ${rec.tsEnd}` : ''));
1242
1346
  if (rec.reclaimed) lines.push(` reclaimed: ${rec.reclaimReason}`);
1243
1347
  return lines.join('\n');
@@ -1253,6 +1357,8 @@ function publicIntent(rec) {
1253
1357
  status: rec.status,
1254
1358
  note: rec.note,
1255
1359
  verifyResult: rec.verifyResult,
1360
+ beginHead: rec.beginHead,
1361
+ endHead: rec.endHead,
1256
1362
  beganAt: rec.tsBegin,
1257
1363
  endedAt: rec.tsEnd,
1258
1364
  reclaimed: rec.reclaimed,
@@ -1278,6 +1384,7 @@ const DECISION_PROTOCOL_MARKER = '<!-- driftseal-decisions -->';
1278
1384
  const DECISION_PROTOCOL_END = '<!-- /driftseal-decisions -->';
1279
1385
  const LOG_LANGUAGE_COMMENT_RE = /^<!-- driftseal-log-language: ([^>\r\n]+) -->\r?$/m;
1280
1386
  const LOG_LANGUAGE_PROSE_RE = /\*\*Log language:\*\* `([^`]+)`/;
1387
+ const LOCAL_LOG_COMMENT_RE = /^<!-- driftseal-local-log: true -->\r?$/m;
1281
1388
  const IRREGULAR_GRANDFATHERED_TAGS = new Map([
1282
1389
  ['en-gb-oed', 'en-GB-oed'],
1283
1390
  ['i-ami', 'i-ami'],
@@ -1461,6 +1568,37 @@ function resolveInitLogLanguage(requested, content) {
1461
1568
  return languages.size === 1 ? [...languages][0] : DEFAULT_LOG_LANGUAGE;
1462
1569
  }
1463
1570
 
1571
+ function resolveInitLocalLog(requested, content) {
1572
+ if (requested) return true;
1573
+ const intent = extractManagedBlock(content, INTENT_PROTOCOL_MARKER, INTENT_PROTOCOL_END);
1574
+ if (intent && LOCAL_LOG_COMMENT_RE.test(intent)) return true;
1575
+ const decision = extractManagedBlock(content, DECISION_PROTOCOL_MARKER, DECISION_PROTOCOL_END);
1576
+ return Boolean(decision && LOCAL_LOG_COMMENT_RE.test(decision));
1577
+ }
1578
+
1579
+ /**
1580
+ * Log languages persisted in the current managed blocks, collected leniently
1581
+ * (no failure on malformed or conflicting declarations: init --lang may be
1582
+ * fixing exactly that). Lets init recognize default blocks written in the
1583
+ * existing language when --lang switches to a new one.
1584
+ */
1585
+ function persistedLogLanguages(content) {
1586
+ const languages = new Set();
1587
+ const blocks = [
1588
+ extractManagedBlock(content, INTENT_PROTOCOL_MARKER, INTENT_PROTOCOL_END),
1589
+ extractManagedBlock(content, DECISION_PROTOCOL_MARKER, DECISION_PROTOCOL_END),
1590
+ ];
1591
+ for (const block of blocks) {
1592
+ if (!block) continue;
1593
+ const comment = block.match(LOG_LANGUAGE_COMMENT_RE);
1594
+ const prose = block.match(LOG_LANGUAGE_PROSE_RE);
1595
+ const value = comment ? comment[1] : prose ? prose[1] : null;
1596
+ const canonical = value ? wellFormedBcp47(value.trim()) : null;
1597
+ if (canonical) languages.add(canonical);
1598
+ }
1599
+ return languages;
1600
+ }
1601
+
1464
1602
  function protocolBlockKey(block) {
1465
1603
  return block
1466
1604
  .replace(/^<!-- driftseal-log-language: [^>\r\n]+ -->\r?$/m, '<!-- driftseal-log-language: -->')
@@ -1479,10 +1617,10 @@ function stripDecisionLogLanguage(block, language = DEFAULT_LOG_LANGUAGE) {
1479
1617
  .replace(`\n${decisionLogLanguageParagraph(language)}\n`, '');
1480
1618
  }
1481
1619
 
1482
- function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE) {
1620
+ function intentProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1483
1621
  return `${INTENT_PROTOCOL_MARKER}
1484
1622
  <!-- driftseal-version: ${version} -->
1485
- <!-- driftseal-log-language: ${language} -->
1623
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
1486
1624
 
1487
1625
  ## Agent protocol: intent write-ahead log
1488
1626
 
@@ -1501,23 +1639,31 @@ ${intentLogLanguageParagraph(language)}
1501
1639
  Git operations never need an intent and are not included in the intent log;
1502
1640
  Git maintains their history. This includes inspection, branch and worktree
1503
1641
  management, staging, commits, merges, rebases, cherry-picks, tags, and pushes.
1642
+ A command whose result can be reconstructed from Git state (for example a
1643
+ patch file regenerated from a commit range, or a scratch harness that
1644
+ re-runs) needs no intent; content that will be committed and cannot be
1645
+ reconstructed (for example a .gitignore edit) does.
1504
1646
  Single-step commands that only build or check work already done, such as
1505
1647
  compiling or running tests, also need no intent.
1648
+ Size an intent to the smallest unit that leaves the tree self-consistent
1649
+ and can be verified on its own.
1506
1650
  2. **Execute only the intent.** Scope change? Close the current intent
1507
1651
  (\`driftseal end -s partial|abandoned -n "<why>"\`) and \`driftseal begin\` a new one.
1508
1652
  3. **Verify, then close**: run the declared verification, then
1509
- \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<verify output>"\`.
1653
+ \`driftseal end -s completed|partial|failed|abandoned -n "<what happened>" -r "<what the verification showed, written for the next agent>"\`.
1510
1654
  Never report success without closing the intent.
1511
1655
  Before closing a linked intent as \`completed\` or \`partial\`, reconcile every
1512
1656
  declared decision with \`driftseal decision update <id> --status <status> --note "<why>"\`.
1513
1657
  DriftSeal rejects a successful close when a declared decision was not reconciled.
1514
- Do not edit a decision after reconciling it; run \`decision update\` again so
1515
- the final content hash is recorded. Interrupted reconciliation is recovered
1658
+ To revise a decision's prose, edit the file, then run \`decision update\` to
1659
+ record the new content hash. Do not edit a decision after reconciling it;
1660
+ run \`decision update\` again so the final content hash is recorded.
1661
+ Interrupted reconciliation is recovered
1516
1662
  by the next linked \`decision update\` or successful \`end\`. Closing as
1517
1663
  \`failed\` or \`abandoned\` cancels pending recovery for that intent.
1518
1664
  Git operations remain subject to normal authorization and safety requirements
1519
1665
  even though they do not require an intent. Any non-Git content change made while
1520
- preparing a Git operation does require a new intent.
1666
+ preparing a Git operation does require a new intent, per the step 1 test.
1521
1667
  4. **Re-anchor after context loss**: run \`driftseal status\` and \`driftseal log --last 3\` before
1522
1668
  doing anything else. The open intent is the source of truth: resume it when its
1523
1669
  objective still matches the current task; otherwise close it (\`partial\` or
@@ -1532,12 +1678,42 @@ After a merge collision, run \`driftseal absorb\` rather than editing the log;
1532
1678
  if both sides still have an open intent, add \`--abandon-theirs\` or
1533
1679
  \`--abandon-ours\`.
1534
1680
 
1535
- Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); commit it with the code.
1681
+ Log: \`.intent-log/events.jsonl\` (override with \`$DRIFTSEAL_HOME\`); ${localLog ? 'this repository keeps the log local and untracked; do not add it to commits.' : 'commit it with the code.'}
1536
1682
  ${INTENT_PROTOCOL_END}`;
1537
1683
  }
1538
1684
 
1539
- function previousIntentProtocolBlock(version) {
1540
- const v10 = stripIntentLogLanguage(intentProtocolBlock(version, DEFAULT_LOG_LANGUAGE));
1685
+ function previousIntentProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE) {
1686
+ const v11 = intentProtocolBlock(version, language)
1687
+ .replace(
1688
+ '-r "<what the verification showed, written for the next agent>"',
1689
+ '-r "<verify output>"'
1690
+ )
1691
+ .replace(
1692
+ '\n A command whose result can be reconstructed from Git state (for example a\n' +
1693
+ ' patch file regenerated from a commit range, or a scratch harness that\n' +
1694
+ ' re-runs) needs no intent; content that will be committed and cannot be\n' +
1695
+ ' reconstructed (for example a .gitignore edit) does.',
1696
+ ''
1697
+ )
1698
+ .replace(
1699
+ '\n Size an intent to the smallest unit that leaves the tree self-consistent\n' +
1700
+ ' and can be verified on its own.',
1701
+ ''
1702
+ )
1703
+ .replace(
1704
+ ' To revise a decision\'s prose, edit the file, then run `decision update` to\n' +
1705
+ ' record the new content hash. Do not edit a decision after reconciling it;\n' +
1706
+ ' run `decision update` again so the final content hash is recorded.\n' +
1707
+ ' Interrupted reconciliation is recovered',
1708
+ ' Do not edit a decision after reconciling it; run `decision update` again so\n' +
1709
+ ' the final content hash is recorded. Interrupted reconciliation is recovered'
1710
+ )
1711
+ .replace(
1712
+ 'preparing a Git operation does require a new intent, per the step 1 test.',
1713
+ 'preparing a Git operation does require a new intent.'
1714
+ );
1715
+ if (version >= 11) return v11;
1716
+ const v10 = stripIntentLogLanguage(v11, language);
1541
1717
  if (version >= 10) return v10;
1542
1718
  const v9 = v10.replace(
1543
1719
  '1. **Write intent first**, before modifying, creating, or deleting files, or\n' +
@@ -1618,10 +1794,10 @@ function protocolEol(content, eol) {
1618
1794
  return eol === '\n' ? content : content.replace(/\n/g, eol);
1619
1795
  }
1620
1796
 
1621
- function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE) {
1797
+ function decisionProtocolBlock(version = PROTOCOL_VERSION, language = DEFAULT_LOG_LANGUAGE, localLog = false) {
1622
1798
  return `${DECISION_PROTOCOL_MARKER}
1623
1799
  <!-- driftseal-decisions-version: ${version} -->
1624
- <!-- driftseal-log-language: ${language} -->
1800
+ <!-- driftseal-log-language: ${language} -->${localLog ? '\n<!-- driftseal-local-log: true -->' : ''}
1625
1801
 
1626
1802
  ## Agent protocol: decision log
1627
1803
 
@@ -1645,7 +1821,7 @@ When an intent declares an existing decision with \`--decision <id>\`, use
1645
1821
  \`driftseal decision update\` to record its status transition or explicit confirmation.
1646
1822
  After a merge, colliding decision ids are remapped with \`driftseal absorb\`;
1647
1823
  concurrent edits of a shared decision are not auto-merged.
1648
- Commit \`.decision-log/\` with the code.
1824
+ ${localLog ? 'Keep `.decision-log/` local and untracked; do not add it to commits.' : 'Commit `.decision-log/` with the code.'}
1649
1825
  ${DECISION_PROTOCOL_END}`;
1650
1826
  }
1651
1827
 
@@ -1700,8 +1876,9 @@ When an intent declares an existing decision with \`--decision <id>\`, use
1700
1876
  Commit \`.decision-log/\` with the code.`;
1701
1877
  }
1702
1878
 
1703
- function previousDecisionProtocolBlock(version) {
1704
- const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, DEFAULT_LOG_LANGUAGE));
1879
+ function previousDecisionProtocolBlock(version, language = DEFAULT_LOG_LANGUAGE) {
1880
+ if (version >= 11) return decisionProtocolBlock(version, language);
1881
+ const v10 = stripDecisionLogLanguage(decisionProtocolBlock(version, language), language);
1705
1882
  if (version >= 9) return v10;
1706
1883
  const v8 = v10.replace(
1707
1884
  '\nAfter a merge, colliding decision ids are remapped with `driftseal absorb`;\n' +
@@ -1744,11 +1921,18 @@ function upgradeManagedBlock({
1744
1921
  `protocol version ${version} requires a newer DriftSeal client (supported: ${PROTOCOL_VERSION})`
1745
1922
  );
1746
1923
  }
1747
- if (
1748
- block !== replacement &&
1749
- !knownManagedBlocks.includes(block) &&
1750
- protocolBlockKey(block) !== protocolBlockKey(replacement)
1751
- ) {
1924
+ // A block counts as unmodified when it matches the replacement or a known
1925
+ // released block, either exactly or once log-language declarations are
1926
+ // neutralized. The lenient key comparison covers every candidate, not just
1927
+ // the replacement, so `--lang` still repairs a block whose comment and
1928
+ // prose disagree even when the same run also toggles local log mode.
1929
+ const key = protocolBlockKey(block);
1930
+ const recognized =
1931
+ block === replacement ||
1932
+ knownManagedBlocks.includes(block) ||
1933
+ key === protocolBlockKey(replacement) ||
1934
+ knownManagedBlocks.some((known) => key === protocolBlockKey(known));
1935
+ if (!recognized) {
1752
1936
  fail(`cannot safely upgrade customized protocol block beginning with ${marker}`);
1753
1937
  }
1754
1938
  return {
@@ -1815,6 +1999,7 @@ function mcpConfigLocation(target, scope, root) {
1815
1999
 
1816
2000
  function parseMcpInstallRequest(argv) {
1817
2001
  const [subcommand, ...rest] = argv;
2002
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('mcp');
1818
2003
  if (subcommand !== 'install') {
1819
2004
  fail(mcpInstallUsage());
1820
2005
  }
@@ -1823,7 +2008,7 @@ function parseMcpInstallRequest(argv) {
1823
2008
  scope: 'single',
1824
2009
  root: 'single',
1825
2010
  force: 'boolean',
1826
- });
2011
+ }, 'mcp');
1827
2012
  if (positionals.length > 0 || !flags.target) {
1828
2013
  fail(mcpInstallUsage());
1829
2014
  }
@@ -1984,6 +2169,27 @@ function installMcp(request) {
1984
2169
 
1985
2170
  const SKILL_NAME = 'use-driftseal';
1986
2171
 
2172
+ /*
2173
+ * Every skill tree DriftSeal has ever bundled, oldest first, as skillTreeDigest
2174
+ * hashes. `skill install` treats a directory matching one of these as its own
2175
+ * earlier output and upgrades it in place; anything else is someone's local
2176
+ * skill that only --force may replace. Append the new digest whenever the
2177
+ * bundled skill changes, otherwise the next release cannot upgrade this one;
2178
+ * the "bundled use-driftseal skill is a known release" test fails until you do.
2179
+ */
2180
+ const SKILL_RELEASE_DIGESTS = new Set([
2181
+ 'e996627c96edc7c09599bc454c4225a416aa89e50aef14d4dd569dd21454e882', // 1b76215 initial commit
2182
+ '5f702545b18e117bafcea669b48cdb3b31c98079f97eaa084fd3b4ceff9500e8', // 5c2ec74 intents only for rollback-worthy changes
2183
+ '77f6365590ff181ee6be003930dd1696c363e213368de0f4e89962555af3fabe', // 56380a8 local MCP server
2184
+ 'b7e2310eaf20b50b5ec28531471965607e7e070a4f58263e29408b2cd5cdfb11', // 1cc77f6 reclaim markers
2185
+ '08bc63b8dcf0f4f078252179a3fc2ca4ef2632ecb5b9dfe3782a452c3202c2c4', // de4a8a1 skill slimmed into a usage guide
2186
+ '8523459ff81cf0b97a36b30d216956a58d8a3b3b9760f455d7ea334604da335a', // 3a1d6e0 protocol v7 resume semantics
2187
+ 'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
2188
+ '0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
2189
+ '72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
2190
+ 'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb (current)
2191
+ ]);
2192
+
1987
2193
  function skillInstallUsage() {
1988
2194
  return 'usage: driftseal skill install --target <codex|kimi-code|opencode|claude-code|cursor> [--scope project|global] [--root <repository>] [--force]';
1989
2195
  }
@@ -2018,13 +2224,14 @@ function skillInstallLocation(target, scope, root) {
2018
2224
 
2019
2225
  function parseSkillInstallRequest(argv) {
2020
2226
  const [subcommand, ...rest] = argv;
2227
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('skill');
2021
2228
  if (subcommand !== 'install') fail(skillInstallUsage());
2022
2229
  const { positionals, flags } = parseArgs(rest, {
2023
2230
  target: 'single',
2024
2231
  scope: 'single',
2025
2232
  root: 'single',
2026
2233
  force: 'boolean',
2027
- });
2234
+ }, 'skill');
2028
2235
  if (positionals.length > 0 || !flags.target) fail(skillInstallUsage());
2029
2236
 
2030
2237
  const target = flags.target.toLowerCase();
@@ -2046,7 +2253,13 @@ function parseSkillInstallRequest(argv) {
2046
2253
  };
2047
2254
  }
2048
2255
 
2049
- function directoryDigest(directory) {
2256
+ /*
2257
+ * Identifies a skill tree by its contents alone: relative paths joined with "/"
2258
+ * and file bytes, never file modes or timestamps. The digest therefore stays
2259
+ * stable across platforms, checkouts, and npm tarballs, which is what lets
2260
+ * SKILL_RELEASE_DIGESTS recognize a skill DriftSeal installed earlier.
2261
+ */
2262
+ function skillTreeDigest(directory) {
2050
2263
  if (!fs.existsSync(directory)) return null;
2051
2264
  const digest = crypto.createHash('sha256');
2052
2265
 
@@ -2055,12 +2268,12 @@ function directoryDigest(directory) {
2055
2268
  if (stat.isDirectory()) {
2056
2269
  digest.update(`directory\0${relative}\0`);
2057
2270
  for (const name of fs.readdirSync(current).sort()) {
2058
- visit(path.join(current, name), relative ? path.join(relative, name) : name);
2271
+ visit(path.join(current, name), relative ? `${relative}/${name}` : name);
2059
2272
  }
2060
2273
  return;
2061
2274
  }
2062
2275
  if (stat.isFile()) {
2063
- digest.update(`file\0${relative}\0${stat.mode & 0o777}\0`);
2276
+ digest.update(`file\0${relative}\0`);
2064
2277
  digest.update(fs.readFileSync(current));
2065
2278
  digest.update('\0');
2066
2279
  return;
@@ -2069,7 +2282,7 @@ function directoryDigest(directory) {
2069
2282
  digest.update(`symlink\0${relative}\0${fs.readlinkSync(current)}\0`);
2070
2283
  return;
2071
2284
  }
2072
- digest.update(`other\0${relative}\0${stat.mode}\0`);
2285
+ digest.update(`other\0${relative}\0`);
2073
2286
  }
2074
2287
 
2075
2288
  visit(directory, '');
@@ -2096,15 +2309,18 @@ function installSkill(request) {
2096
2309
  fail(`bundled ${SKILL_NAME} skill is missing from this DriftSeal installation: ${sourceDir}`);
2097
2310
  }
2098
2311
 
2099
- const sourceDigest = directoryDigest(sourceDir);
2100
- const existingDigest = directoryDigest(skillDir);
2312
+ const sourceDigest = skillTreeDigest(sourceDir);
2313
+ const existingDigest = skillTreeDigest(skillDir);
2101
2314
  if (existingDigest === sourceDigest) {
2102
2315
  printLine(`${SKILL_NAME} skill is already installed for ${targetLabel} (${scope}): ${skillDir}`);
2103
2316
  return { changed: false, target, scope, root, skillDir };
2104
2317
  }
2105
- if (existingDigest !== null && !force) {
2318
+ // An untouched skill from an earlier DriftSeal upgrades on its own; only a
2319
+ // skill this installer never wrote needs the operator to confirm with --force.
2320
+ const upgraded = existingDigest !== null && SKILL_RELEASE_DIGESTS.has(existingDigest);
2321
+ if (existingDigest !== null && !upgraded && !force) {
2106
2322
  fail(
2107
- `${targetLabel} already has a different ${SKILL_NAME} skill at ${skillDir}; ` +
2323
+ `${targetLabel} already has a ${SKILL_NAME} skill DriftSeal did not install at ${skillDir}; ` +
2108
2324
  're-run with --force to replace it'
2109
2325
  );
2110
2326
  }
@@ -2134,9 +2350,12 @@ function installSkill(request) {
2134
2350
  throw err;
2135
2351
  }
2136
2352
 
2137
- printLine(`Installed ${SKILL_NAME} skill for ${targetLabel} (${scope}): ${skillDir}`);
2353
+ printLine(
2354
+ `${upgraded ? 'Upgraded' : 'Installed'} ${SKILL_NAME} skill for ` +
2355
+ `${targetLabel} (${scope}): ${skillDir}`
2356
+ );
2138
2357
  if (scope === 'project') printLine(`Repository root: ${root}`);
2139
- return { changed: true, target, scope, root, skillDir };
2358
+ return { changed: true, upgraded, target, scope, root, skillDir };
2140
2359
  }
2141
2360
 
2142
2361
  const HOOK_TARGETS = ['kimi-code', 'claude-code', 'codex'];
@@ -2179,7 +2398,7 @@ function parseHookInstallRequest(argv) {
2179
2398
  scope: 'single',
2180
2399
  root: 'single',
2181
2400
  force: 'boolean',
2182
- });
2401
+ }, 'hook install');
2183
2402
  if (positionals.length > 0 || !flags.target) {
2184
2403
  fail(hookUsage());
2185
2404
  }
@@ -2402,7 +2621,7 @@ function hookLogFile() {
2402
2621
  }
2403
2622
 
2404
2623
  /** Advisory reminder text; null when no ancestor has an intent log yet. */
2405
- function hookReminder(event) {
2624
+ function hookReminder(event, { readOnly = false } = {}) {
2406
2625
  const file = hookLogFile();
2407
2626
  if (!file) return null;
2408
2627
  if (event === 'prompt') {
@@ -2413,7 +2632,7 @@ function hookReminder(event) {
2413
2632
  'reminder when it does not apply.'
2414
2633
  );
2415
2634
  }
2416
- const open = openIntent(fold(readEvents({ file })));
2635
+ const open = openIntent(fold(readEvents({ file, readOnly })));
2417
2636
  if (open) {
2418
2637
  return (
2419
2638
  `DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
@@ -2427,8 +2646,8 @@ function hookReminder(event) {
2427
2646
  );
2428
2647
  }
2429
2648
 
2430
- function runHookReminder(event, argv) {
2431
- const { positionals, flags } = parseArgs(argv, { format: 'single' });
2649
+ function runHookReminder(event, argv, { readOnly = false } = {}) {
2650
+ const { positionals, flags } = parseArgs(argv, { format: 'single' }, `hook ${event}`);
2432
2651
  if (positionals.length > 0) fail(hookUsage());
2433
2652
  const format = (flags.format || 'plain').toLowerCase();
2434
2653
  if (!['plain', 'claude-code'].includes(format)) {
@@ -2438,7 +2657,7 @@ function runHookReminder(event, argv) {
2438
2657
  // Hooks must never block the agent: any failure exits quietly with no output.
2439
2658
  let reminder = null;
2440
2659
  try {
2441
- reminder = hookReminder(event);
2660
+ reminder = hookReminder(event, { readOnly });
2442
2661
  } catch {
2443
2662
  reminder = null;
2444
2663
  }
@@ -2477,10 +2696,63 @@ function gitCapture(args, cwd = process.cwd()) {
2477
2696
  return output === null ? null : output.trim();
2478
2697
  }
2479
2698
 
2699
+ /**
2700
+ * Capture a single git value without touching the payload: only the one
2701
+ * newline git appends is removed, so paths that begin or end with whitespace
2702
+ * survive intact (`gitCapture` would trim them away).
2703
+ */
2704
+ function gitCaptureLine(args, cwd = process.cwd()) {
2705
+ const output = gitCaptureRaw(args, cwd);
2706
+ if (output === null) return null;
2707
+ return output.endsWith('\n') ? output.slice(0, -1) : output;
2708
+ }
2709
+
2480
2710
  function isGitWorkTree(cwd = process.cwd()) {
2481
2711
  return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2482
2712
  }
2483
2713
 
2714
+ /**
2715
+ * Warn (without mutating the index or .gitignore) when local log mode is on
2716
+ * but the default log paths are still tracked by git. The log directories are
2717
+ * resolved relative to the init cwd (init writes ./AGENTS.md there), so a
2718
+ * nested init checks its own logs rather than the repository root's.
2719
+ *
2720
+ * Paths are read from `ls-files -z` with `:(literal)` pathspecs because git's
2721
+ * human-readable listing C-quotes non-ASCII names and treats `*?[\` as
2722
+ * wildcards. The printed remediation uses the fixed names `.intent-log` and
2723
+ * `.decision-log` and is meant to be run from this directory: git resolves
2724
+ * those pathspecs against the init cwd, so the command stays paste-safe in
2725
+ * POSIX shells, cmd.exe, and PowerShell without embedding the repo-relative
2726
+ * prefix or any shell quoting.
2727
+ */
2728
+ function warnIfDefaultLogsTracked(cwd = process.cwd()) {
2729
+ if (!isGitWorkTree(cwd)) return;
2730
+ const root = gitCaptureLine(['rev-parse', '--show-toplevel'], cwd);
2731
+ if (!root) return;
2732
+ const prefix = gitCaptureLine(['rev-parse', '--show-prefix'], cwd);
2733
+ if (prefix === null) return;
2734
+ const logNames = ['.intent-log', '.decision-log'];
2735
+ const logDirs = logNames.map((name) => `${prefix}${name}`);
2736
+ const listing = gitCaptureRaw(
2737
+ ['ls-files', '-z', '--', ...logDirs.map((name) => `:(literal)${name}`)],
2738
+ root
2739
+ );
2740
+ if (!listing) return;
2741
+ const files = listing.split('\0').filter((file) => file.length > 0);
2742
+ const trackedNames = logNames.filter((name) => {
2743
+ const dir = `${prefix}${name}`;
2744
+ return files.some((file) => file === dir || file.startsWith(`${dir}/`));
2745
+ });
2746
+ if (trackedNames.length === 0) return;
2747
+ const tracked = trackedNames.map((name) => `${prefix}${name}`);
2748
+ printLine(
2749
+ `warning: local log mode is on, but ${tracked.join(' and ')} ` +
2750
+ `${tracked.length === 1 ? 'is' : 'are'} still tracked by git; run ` +
2751
+ `\`git rm -r --cached -- ${trackedNames.join(' ')}\` from this directory ` +
2752
+ 'and add them to .gitignore to keep the logs local'
2753
+ );
2754
+ }
2755
+
2484
2756
  function gitOtherHead(cwd = process.cwd()) {
2485
2757
  return (
2486
2758
  gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
@@ -2902,6 +3174,7 @@ function abandonOpenIntent(records, targetId, side) {
2902
3174
  status: 'abandoned',
2903
3175
  note: `abandoned during absorb (--abandon-${side})`,
2904
3176
  verifyResult: null,
3177
+ head: gitCapture(['rev-parse', 'HEAD']),
2905
3178
  },
2906
3179
  });
2907
3180
  return targetId;
@@ -3310,10 +3583,10 @@ const commands = {
3310
3583
  verify: '-v',
3311
3584
  decision: 'multiple',
3312
3585
  force: 'boolean',
3313
- });
3586
+ }, 'begin');
3314
3587
  const intent = positionals.join(' ').trim();
3315
3588
  if (!intent) {
3316
- fail('usage: driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]');
3589
+ fail(usageFor('begin'));
3317
3590
  }
3318
3591
  const requestedDecisions = flags.decision || [];
3319
3592
  const index = requestedDecisions.length > 0 ? decisionIndex() : [];
@@ -3357,6 +3630,7 @@ const commands = {
3357
3630
  intent,
3358
3631
  verify: flags.verify || null,
3359
3632
  decisions,
3633
+ head: gitCapture(['rev-parse', 'HEAD']),
3360
3634
  }));
3361
3635
  const record = fold(events).find((candidate) => candidate.id === id);
3362
3636
  printLine(id);
@@ -3368,9 +3642,9 @@ const commands = {
3368
3642
  status: '-s',
3369
3643
  note: '-n',
3370
3644
  'verify-result': '-r',
3371
- });
3645
+ }, 'end');
3372
3646
  const status = flags.status || 'completed';
3373
- if (positionals.length > 1) fail('usage: driftseal end [id] [options]');
3647
+ if (positionals.length > 1) fail(usageFor('end'));
3374
3648
  if (!END_STATUSES.includes(status)) {
3375
3649
  fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
3376
3650
  }
@@ -3441,16 +3715,17 @@ const commands = {
3441
3715
  status,
3442
3716
  note: flags.note || null,
3443
3717
  verifyResult: flags['verify-result'] || null,
3718
+ head: gitCapture(['rev-parse', 'HEAD']),
3444
3719
  }));
3445
3720
  const record = fold(events).find((candidate) => candidate.id === target.id);
3446
3721
  printLine(`${target.id} ${status}`);
3447
3722
  return publicIntent(record);
3448
3723
  },
3449
3724
 
3450
- status(argv) {
3451
- const { positionals } = parseArgs(argv, {});
3452
- if (positionals.length > 0) fail('usage: driftseal status');
3453
- const open = openIntent(fold(readEvents({ repairTail: true })));
3725
+ status(argv, { readOnly = false } = {}) {
3726
+ const { positionals } = parseArgs(argv, {}, 'status');
3727
+ if (positionals.length > 0) fail(usageFor('status'));
3728
+ const open = openIntent(fold(readEvents({ repairTail: true, readOnly })));
3454
3729
  if (!open) {
3455
3730
  printLine('no intent in progress');
3456
3731
  return null;
@@ -3459,10 +3734,10 @@ const commands = {
3459
3734
  return publicIntent(open);
3460
3735
  },
3461
3736
 
3462
- log(argv) {
3463
- const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' });
3464
- if (positionals.length > 0) fail('usage: driftseal log [--last N] [--all]');
3465
- let records = fold(readEvents({ repairTail: true }));
3737
+ log(argv, { readOnly = false } = {}) {
3738
+ const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
3739
+ if (positionals.length > 0) fail(usageFor('log'));
3740
+ let records = fold(readEvents({ repairTail: true, readOnly }));
3466
3741
  if (!flags.all) records = records.filter((record) => !record.reclaimed);
3467
3742
  if (flags.last) {
3468
3743
  const n = positiveInteger(flags.last, '--last');
@@ -3482,12 +3757,10 @@ const commands = {
3482
3757
  'older-than': 'single',
3483
3758
  force: 'boolean',
3484
3759
  'dry-run': 'boolean',
3485
- });
3760
+ }, 'reclaim');
3486
3761
  const reason = flags.reason && flags.reason.trim();
3487
3762
  if (!reason) {
3488
- fail(
3489
- 'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]'
3490
- );
3763
+ fail(usageFor('reclaim'));
3491
3764
  }
3492
3765
  let olderThanDays = 7;
3493
3766
  if (flags['older-than'] !== undefined) {
@@ -3557,10 +3830,10 @@ const commands = {
3557
3830
  },
3558
3831
 
3559
3832
  unreclaim(argv) {
3560
- const { positionals, flags } = parseArgs(argv, { reason: '-r' });
3833
+ const { positionals, flags } = parseArgs(argv, { reason: '-r' }, 'unreclaim');
3561
3834
  const reason = flags.reason && flags.reason.trim();
3562
3835
  if (positionals.length !== 1 || !reason) {
3563
- fail('usage: driftseal unreclaim <id> --reason "<why>"');
3836
+ fail(usageFor('unreclaim'));
3564
3837
  }
3565
3838
  const events = readEvents({ repairTail: true });
3566
3839
  const record = fold(events).find((candidate) => candidate.id === positionals[0]);
@@ -3581,6 +3854,7 @@ const commands = {
3581
3854
 
3582
3855
  decision(argv) {
3583
3856
  const [subcommand, ...rest] = argv;
3857
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('decision');
3584
3858
  if (subcommand === 'add') {
3585
3859
  const { positionals, flags } = parseArgs(rest, {
3586
3860
  context: '-c',
@@ -3589,12 +3863,12 @@ const commands = {
3589
3863
  driver: 'multiple',
3590
3864
  option: 'multiple',
3591
3865
  consequence: 'multiple',
3592
- });
3866
+ }, 'decision add');
3593
3867
  const title = positionals.join(' ').replace(/\s+/g, ' ').trim();
3594
3868
  const context = flags.context && flags.context.trim();
3595
3869
  const outcome = flags.outcome && flags.outcome.trim();
3596
3870
  if (!title || !context || !outcome) {
3597
- fail('usage: driftseal decision add "<title>" --context "..." --outcome "..." [options]');
3871
+ fail(usageFor('decision add'));
3598
3872
  }
3599
3873
  const status = (flags.status || 'accepted').toLowerCase();
3600
3874
  if (!DECISION_STATUSES.includes(status)) {
@@ -3623,10 +3897,10 @@ const commands = {
3623
3897
  }
3624
3898
 
3625
3899
  if (subcommand === 'update') {
3626
- const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' });
3900
+ const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' }, 'decision update');
3627
3901
  const note = flags.note && flags.note.trim();
3628
3902
  if (positionals.length !== 1 || !note) {
3629
- fail('usage: driftseal decision update <id> [--status <status>] --note "<what changed or was confirmed>"');
3903
+ fail(usageFor('decision update'));
3630
3904
  }
3631
3905
 
3632
3906
  let events = readEvents({ repairTail: true });
@@ -3663,9 +3937,9 @@ const commands = {
3663
3937
  }
3664
3938
 
3665
3939
  if (subcommand === 'list') {
3666
- const { positionals, flags } = parseArgs(rest, { last: '-n', status: '-s', count: 'boolean' });
3940
+ const { positionals, flags } = parseArgs(rest, { last: '-n', status: '-s', count: 'boolean' }, 'decision list');
3667
3941
  if (positionals.length > 0) {
3668
- fail('usage: driftseal decision list [--status STATUS] [--last N | --count]');
3942
+ fail(usageFor('decision list'));
3669
3943
  }
3670
3944
  if (flags.count && flags.last) fail('--count cannot be combined with --last');
3671
3945
  const last = flags.last && positiveInteger(flags.last, '--last');
@@ -3700,16 +3974,16 @@ const commands = {
3700
3974
  }
3701
3975
 
3702
3976
  if (subcommand === 'show') {
3703
- const { positionals } = parseArgs(rest, {});
3977
+ const { positionals } = parseArgs(rest, {}, 'decision show');
3704
3978
  if (positionals.length !== 1 || !/^\d+$/.test(positionals[0])) {
3705
- fail('usage: driftseal decision show <id>');
3979
+ fail(usageFor('decision show'));
3706
3980
  }
3707
3981
  const decision = findDecision(positionals[0]);
3708
3982
  writeOutput(decision.content);
3709
3983
  return publicDecision(decision, { includeContent: true });
3710
3984
  }
3711
3985
 
3712
- fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
3986
+ fail(usageFor('decision'));
3713
3987
  },
3714
3988
 
3715
3989
  mcp(argv) {
@@ -3721,13 +3995,14 @@ const commands = {
3721
3995
  return installSkill(parseSkillInstallRequest(argv));
3722
3996
  },
3723
3997
 
3724
- hook(argv) {
3998
+ hook(argv, { readOnly = false } = {}) {
3725
3999
  const [subcommand, ...rest] = argv;
4000
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('hook');
3726
4001
  if (subcommand === 'install') {
3727
4002
  return installHook(parseHookInstallRequest(rest));
3728
4003
  }
3729
4004
  if (HOOK_EVENTS.includes(subcommand)) {
3730
- return runHookReminder(subcommand, rest);
4005
+ return runHookReminder(subcommand, rest, { readOnly });
3731
4006
  }
3732
4007
  fail(hookUsage());
3733
4008
  },
@@ -3739,7 +4014,7 @@ const commands = {
3739
4014
  'abandon-theirs': 'boolean',
3740
4015
  'abandon-ours': 'boolean',
3741
4016
  'dry-run': 'boolean',
3742
- });
4017
+ }, 'absorb');
3743
4018
  if (flags['abandon-theirs'] && flags['abandon-ours']) {
3744
4019
  fail('cannot combine --abandon-theirs and --abandon-ours');
3745
4020
  }
@@ -3756,15 +4031,24 @@ const commands = {
3756
4031
  },
3757
4032
 
3758
4033
  init(argv) {
3759
- const { positionals, flags } = parseArgs(argv, { lang: 'single' });
3760
- if (positionals.length > 0) fail('usage: driftseal init [--lang <tag>]');
4034
+ const { positionals, flags } = parseArgs(argv, { lang: 'single', 'local-log': 'boolean' }, 'init');
4035
+ if (positionals.length > 0) fail(usageFor('init'));
3761
4036
  const target = path.join(process.cwd(), 'AGENTS.md');
3762
4037
  const existed = fs.existsSync(target);
3763
4038
  const current = existed ? fs.readFileSync(target, 'utf8') : '';
3764
4039
  const eol = current.includes('\r\n') ? '\r\n' : '\n';
3765
4040
  const language = resolveInitLogLanguage(flags.lang, current);
3766
- const intentBlock = protocolEol(intentProtocolBlock(PROTOCOL_VERSION, language), eol);
3767
- const decisionBlock = protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, language), eol);
4041
+ const localLog = resolveInitLocalLog(flags['local-log'] === true, current);
4042
+ // Accept default blocks in the persisted language too, so a single run can
4043
+ // switch language and enable local mode on a default or v11 protocol.
4044
+ const sourceLanguages = [language];
4045
+ if (flags.lang !== undefined) {
4046
+ for (const persisted of persistedLogLanguages(current)) {
4047
+ if (!sourceLanguages.includes(persisted)) sourceLanguages.push(persisted);
4048
+ }
4049
+ }
4050
+ const intentBlock = protocolEol(intentProtocolBlock(PROTOCOL_VERSION, language, localLog), eol);
4051
+ const decisionBlock = protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, language, localLog), eol);
3768
4052
  let updated = current;
3769
4053
  const intent = upgradeManagedBlock({
3770
4054
  content: updated,
@@ -3773,6 +4057,10 @@ const commands = {
3773
4057
  versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
3774
4058
  replacement: intentBlock,
3775
4059
  knownManagedBlocks: [
4060
+ ...sourceLanguages.flatMap((source) => [
4061
+ protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
4062
+ protocolEol(previousIntentProtocolBlock(11, source), eol),
4063
+ ]),
3776
4064
  protocolEol(previousIntentProtocolBlock(2), eol),
3777
4065
  protocolEol(previousIntentProtocolBlock(3), eol),
3778
4066
  protocolEol(previousIntentProtocolBlock(4), eol),
@@ -3793,6 +4081,10 @@ const commands = {
3793
4081
  versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
3794
4082
  replacement: decisionBlock,
3795
4083
  knownManagedBlocks: [
4084
+ ...sourceLanguages.flatMap((source) => [
4085
+ protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
4086
+ protocolEol(previousDecisionProtocolBlock(11, source), eol),
4087
+ ]),
3796
4088
  protocolEol(previousDecisionProtocolBlock(2), eol),
3797
4089
  protocolEol(previousDecisionProtocolBlock(3), eol),
3798
4090
  protocolEol(previousDecisionProtocolBlock(4), eol),
@@ -3829,6 +4121,8 @@ const commands = {
3829
4121
  printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
3830
4122
  }
3831
4123
 
4124
+ if (localLog) warnIfDefaultLogsTracked();
4125
+
3832
4126
  if (updated === current && !attributes.changed && !driver.changed) {
3833
4127
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
3834
4128
  return { changed: false, target };
@@ -3882,8 +4176,10 @@ usage:
3882
4176
  targets: kimi-code (global only), claude-code, codex (prompt only)
3883
4177
  driftseal hook prompt|stop [--format plain|claude-code]
3884
4178
  emit the reminder a lifecycle hook injects; never blocks
3885
- driftseal init [--lang <tag>] inject protocols into ./AGENTS.md and configure the git merge driver
4179
+ driftseal init [--lang <tag>] [--local-log]
4180
+ inject protocols into ./AGENTS.md and configure the git merge driver
3886
4181
  --lang sets the intent/decision log language (BCP 47, default: en)
4182
+ --local-log keeps the logs local and untracked instead of committing them
3887
4183
  driftseal --version | -V print the installed DriftSeal version
3888
4184
  driftseal help
3889
4185
 
@@ -3913,6 +4209,43 @@ function requestedEndStatus(argv) {
3913
4209
  return 'completed';
3914
4210
  }
3915
4211
 
4212
+ /**
4213
+ * Flags that consume the following token as their value, keyed by command or
4214
+ * subcommand, mirroring the value-taking entries of each parseArgs spec.
4215
+ */
4216
+ const VALUE_TAKING_FLAGS = {
4217
+ begin: ['--verify', '-v', '--decision'],
4218
+ end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
4219
+ log: ['--last', '-n'],
4220
+ reclaim: ['--reason', '-r', '--older-than'],
4221
+ unreclaim: ['--reason', '-r'],
4222
+ absorb: ['--decisions'],
4223
+ init: ['--lang'],
4224
+ 'decision add': ['--context', '-c', '--outcome', '-o', '--status', '-s', '--driver', '--option', '--consequence'],
4225
+ 'decision update': ['--status', '-s', '--note', '-n'],
4226
+ 'decision list': ['--last', '-n', '--status', '-s'],
4227
+ 'hook install': ['--target', '--scope', '--root'],
4228
+ 'hook prompt': ['--format'],
4229
+ 'hook stop': ['--format'],
4230
+ 'mcp install': ['--target', '--scope', '--root'],
4231
+ 'skill install': ['--target', '--scope', '--root'],
4232
+ };
4233
+
4234
+ /**
4235
+ * Pre-lock help probe. A bare --help/-h token is help unless it is the value of
4236
+ * a known value-taking flag given without `=`, so a real mutation whose flag
4237
+ * value happens to be --help still enters the locked path and fails in parseArgs.
4238
+ */
4239
+ function wantsHelpBeforeLock(cmd, rest) {
4240
+ const key = Object.hasOwn(VALUE_TAKING_FLAGS, `${cmd} ${rest[0]}`) ? `${cmd} ${rest[0]}` : cmd;
4241
+ const valueFlags = VALUE_TAKING_FLAGS[key] || [];
4242
+ return rest.some((arg, index) => {
4243
+ if (arg !== '--help' && arg !== '-h') return false;
4244
+ const previous = index > 0 ? rest[index - 1] : null;
4245
+ return previous === null || !valueFlags.includes(previous);
4246
+ });
4247
+ }
4248
+
3916
4249
  function mutationResources(cmd, argv) {
3917
4250
  if (cmd === 'skill') return [parseSkillInstallRequest(argv).skillsDir];
3918
4251
  if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
@@ -3940,18 +4273,60 @@ function dispatch(argv) {
3940
4273
  }
3941
4274
  const fn = commands[cmd];
3942
4275
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
3943
- const mutates =
3944
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
3945
- (cmd === 'hook' && rest[0] === 'install') ||
3946
- (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
3947
- const readsIntentLog =
3948
- ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
3949
- if (mutates || readsIntentLog) {
3950
- const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
3951
- const data = withMutationLocks(resources, () => fn(rest));
3952
- return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
4276
+ try {
4277
+ // Help must print even while another session holds the mutation lock. The
4278
+ // probe is spec-aware so it never bypasses the lock for a real mutation: a
4279
+ // --help token consumed as a flag value is left for parseArgs to reject.
4280
+ if (wantsHelpBeforeLock(cmd, rest)) return { data: fn(rest), exitCode: 0 };
4281
+ const mutates =
4282
+ ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
4283
+ (cmd === 'hook' && rest[0] === 'install') ||
4284
+ (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
4285
+ const readsIntentLog =
4286
+ ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
4287
+ if (mutates || readsIntentLog) {
4288
+ if (readsIntentLog) {
4289
+ let resources;
4290
+ if (cmd === 'hook') {
4291
+ // Hooks read the nearest ancestor log, not the cwd-relative one: lock
4292
+ // the directory of the file the hook will actually read (the park a
4293
+ // writer flushes under that same lock lives in the repo's Git metadata).
4294
+ // With no ancestor log the hook prints nothing, so skip locking — this
4295
+ // also avoids creating a spurious <cwd>/.intent-log.
4296
+ const hookFile = hookLogFile();
4297
+ if (!hookFile) return { data: fn(rest), exitCode: 0 };
4298
+ resources = [path.dirname(hookFile)];
4299
+ } else {
4300
+ resources = [logDir()];
4301
+ }
4302
+ const locked = withMutationLocks(resources, () => fn(rest), {
4303
+ tryWaitMs: READ_ONLY_LOCK_WAIT_MS,
4304
+ });
4305
+ if (locked !== null) {
4306
+ return {
4307
+ data: locked.data,
4308
+ exitCode:
4309
+ locked.data && Number.isInteger(locked.data.exitCode) ? locked.data.exitCode : 0,
4310
+ };
4311
+ }
4312
+ // Degrade to a lock-free read-only read rather than blocking re-anchoring.
4313
+ if (cmd === 'hook') printError(READ_ONLY_NOTICE);
4314
+ else printLine(READ_ONLY_NOTICE);
4315
+ const data = fn(rest, { readOnly: true });
4316
+ return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0, readOnly: true };
4317
+ }
4318
+ const resources = mutationResources(cmd, rest);
4319
+ const data = withMutationLocks(resources, () => fn(rest));
4320
+ return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
4321
+ }
4322
+ return { data: fn(rest), exitCode: 0 };
4323
+ } catch (err) {
4324
+ if (err instanceof HelpRequested) {
4325
+ printLine(usageFor(err.usageKey) || usageFor(cmd) || 'run: driftseal help');
4326
+ return { data: null, exitCode: 0 };
4327
+ }
4328
+ throw err;
3953
4329
  }
3954
- return { data: fn(rest), exitCode: 0 };
3955
4330
  }
3956
4331
 
3957
4332
  function repositoryRoot(root) {
@@ -3979,7 +4354,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
3979
4354
  const previousCwd = process.cwd();
3980
4355
  const previousIntentHome = process.env.DRIFTSEAL_HOME;
3981
4356
  const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
3982
- const output = { stdout: '', stderr: '', data: null, exitCode: 0 };
4357
+ const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
3983
4358
  const previousOutput = activeOutput;
3984
4359
 
3985
4360
  try {
@@ -3992,6 +4367,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
3992
4367
  const result = dispatch(argv);
3993
4368
  output.data = result.data;
3994
4369
  output.exitCode = result.exitCode;
4370
+ output.readOnly = result.readOnly === true;
3995
4371
  return output;
3996
4372
  } catch (err) {
3997
4373
  if (capture) {
@@ -4015,9 +4391,18 @@ function appendFlag(argv, flag, value) {
4015
4391
 
4016
4392
  function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
4017
4393
  const fixedRoot = repositoryRoot(root);
4018
- const call = (argv) => runCommand(argv, { root: fixedRoot, isolateStorage, capture: true }).data;
4394
+ let lastReadOnly = false;
4395
+ const call = (argv) => {
4396
+ const output = runCommand(argv, { root: fixedRoot, isolateStorage, capture: true });
4397
+ lastReadOnly = output.readOnly;
4398
+ return output.data;
4399
+ };
4019
4400
  return Object.freeze({
4020
4401
  root: fixedRoot,
4402
+ /** True when the most recent call fell back to a lock-free read-only snapshot. */
4403
+ get readOnly() {
4404
+ return lastReadOnly;
4405
+ },
4021
4406
  status() {
4022
4407
  return call(['status']);
4023
4408
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",