driftseal 1.1.7 → 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
@@ -213,9 +213,12 @@ If the scope changes, close the current intent as `partial` or `abandoned`, then
213
213
  Git operations are entirely outside the intent log because Git maintains their
214
214
  history. Inspection, branch and worktree management, staging, commits, merges,
215
215
  rebases, cherry-picks, tags, and pushes never need an intent of their own. They
216
- still require normal authorization and safety checks. Single-step builds and
217
- checks, such as compiling or running tests, also need no intent. Any non-Git
218
- 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.
219
222
 
220
223
  ## Commands
221
224
 
@@ -237,7 +240,7 @@ content change starts a new work round.
237
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. |
238
241
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
239
242
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
240
- | `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. |
241
244
  | `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
242
245
  | `driftseal help` | Print CLI usage. |
243
246
 
package/README.zh-CN.md CHANGED
@@ -202,8 +202,10 @@ driftseal end \
202
202
 
203
203
  Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
204
204
  或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
205
- 单独开启 intent,但仍须遵守正常的授权与安全要求。编译、跑测试等单步构建或检查
206
- 也不需要 intent;只要要做非 Git 内容改动,就开启新一轮。
205
+ 单独开启 intent,但仍须遵守正常的授权与安全要求。结果能从 Git 状态重建的命令
206
+ ——比如从 commit range 重新生成的 patch 文件、可以重跑的临时 harness——也不需要
207
+ intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`)则需要。编译、跑测试
208
+ 等单步构建或检查同样不需要 intent;除此之外的非 Git 内容改动,都要开启新一轮。
207
209
 
208
210
  ## 命令速览
209
211
 
@@ -225,7 +227,7 @@ Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看
225
227
  | `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
226
228
  | `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
227
229
  | `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
228
- | `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`。 |
229
231
  | `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
230
232
  | `driftseal help` | 查看 CLI 用法。 |
231
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
  }
@@ -2039,13 +2224,14 @@ function skillInstallLocation(target, scope, root) {
2039
2224
 
2040
2225
  function parseSkillInstallRequest(argv) {
2041
2226
  const [subcommand, ...rest] = argv;
2227
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('skill');
2042
2228
  if (subcommand !== 'install') fail(skillInstallUsage());
2043
2229
  const { positionals, flags } = parseArgs(rest, {
2044
2230
  target: 'single',
2045
2231
  scope: 'single',
2046
2232
  root: 'single',
2047
2233
  force: 'boolean',
2048
- });
2234
+ }, 'skill');
2049
2235
  if (positionals.length > 0 || !flags.target) fail(skillInstallUsage());
2050
2236
 
2051
2237
  const target = flags.target.toLowerCase();
@@ -2212,7 +2398,7 @@ function parseHookInstallRequest(argv) {
2212
2398
  scope: 'single',
2213
2399
  root: 'single',
2214
2400
  force: 'boolean',
2215
- });
2401
+ }, 'hook install');
2216
2402
  if (positionals.length > 0 || !flags.target) {
2217
2403
  fail(hookUsage());
2218
2404
  }
@@ -2435,7 +2621,7 @@ function hookLogFile() {
2435
2621
  }
2436
2622
 
2437
2623
  /** Advisory reminder text; null when no ancestor has an intent log yet. */
2438
- function hookReminder(event) {
2624
+ function hookReminder(event, { readOnly = false } = {}) {
2439
2625
  const file = hookLogFile();
2440
2626
  if (!file) return null;
2441
2627
  if (event === 'prompt') {
@@ -2446,7 +2632,7 @@ function hookReminder(event) {
2446
2632
  'reminder when it does not apply.'
2447
2633
  );
2448
2634
  }
2449
- const open = openIntent(fold(readEvents({ file })));
2635
+ const open = openIntent(fold(readEvents({ file, readOnly })));
2450
2636
  if (open) {
2451
2637
  return (
2452
2638
  `DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
@@ -2460,8 +2646,8 @@ function hookReminder(event) {
2460
2646
  );
2461
2647
  }
2462
2648
 
2463
- function runHookReminder(event, argv) {
2464
- const { positionals, flags } = parseArgs(argv, { format: 'single' });
2649
+ function runHookReminder(event, argv, { readOnly = false } = {}) {
2650
+ const { positionals, flags } = parseArgs(argv, { format: 'single' }, `hook ${event}`);
2465
2651
  if (positionals.length > 0) fail(hookUsage());
2466
2652
  const format = (flags.format || 'plain').toLowerCase();
2467
2653
  if (!['plain', 'claude-code'].includes(format)) {
@@ -2471,7 +2657,7 @@ function runHookReminder(event, argv) {
2471
2657
  // Hooks must never block the agent: any failure exits quietly with no output.
2472
2658
  let reminder = null;
2473
2659
  try {
2474
- reminder = hookReminder(event);
2660
+ reminder = hookReminder(event, { readOnly });
2475
2661
  } catch {
2476
2662
  reminder = null;
2477
2663
  }
@@ -2510,10 +2696,63 @@ function gitCapture(args, cwd = process.cwd()) {
2510
2696
  return output === null ? null : output.trim();
2511
2697
  }
2512
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
+
2513
2710
  function isGitWorkTree(cwd = process.cwd()) {
2514
2711
  return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
2515
2712
  }
2516
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
+
2517
2756
  function gitOtherHead(cwd = process.cwd()) {
2518
2757
  return (
2519
2758
  gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
@@ -2935,6 +3174,7 @@ function abandonOpenIntent(records, targetId, side) {
2935
3174
  status: 'abandoned',
2936
3175
  note: `abandoned during absorb (--abandon-${side})`,
2937
3176
  verifyResult: null,
3177
+ head: gitCapture(['rev-parse', 'HEAD']),
2938
3178
  },
2939
3179
  });
2940
3180
  return targetId;
@@ -3343,10 +3583,10 @@ const commands = {
3343
3583
  verify: '-v',
3344
3584
  decision: 'multiple',
3345
3585
  force: 'boolean',
3346
- });
3586
+ }, 'begin');
3347
3587
  const intent = positionals.join(' ').trim();
3348
3588
  if (!intent) {
3349
- fail('usage: driftseal begin "<intent>" [--verify "<how to verify>"] [--decision <id>] [--force]');
3589
+ fail(usageFor('begin'));
3350
3590
  }
3351
3591
  const requestedDecisions = flags.decision || [];
3352
3592
  const index = requestedDecisions.length > 0 ? decisionIndex() : [];
@@ -3390,6 +3630,7 @@ const commands = {
3390
3630
  intent,
3391
3631
  verify: flags.verify || null,
3392
3632
  decisions,
3633
+ head: gitCapture(['rev-parse', 'HEAD']),
3393
3634
  }));
3394
3635
  const record = fold(events).find((candidate) => candidate.id === id);
3395
3636
  printLine(id);
@@ -3401,9 +3642,9 @@ const commands = {
3401
3642
  status: '-s',
3402
3643
  note: '-n',
3403
3644
  'verify-result': '-r',
3404
- });
3645
+ }, 'end');
3405
3646
  const status = flags.status || 'completed';
3406
- if (positionals.length > 1) fail('usage: driftseal end [id] [options]');
3647
+ if (positionals.length > 1) fail(usageFor('end'));
3407
3648
  if (!END_STATUSES.includes(status)) {
3408
3649
  fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
3409
3650
  }
@@ -3474,16 +3715,17 @@ const commands = {
3474
3715
  status,
3475
3716
  note: flags.note || null,
3476
3717
  verifyResult: flags['verify-result'] || null,
3718
+ head: gitCapture(['rev-parse', 'HEAD']),
3477
3719
  }));
3478
3720
  const record = fold(events).find((candidate) => candidate.id === target.id);
3479
3721
  printLine(`${target.id} ${status}`);
3480
3722
  return publicIntent(record);
3481
3723
  },
3482
3724
 
3483
- status(argv) {
3484
- const { positionals } = parseArgs(argv, {});
3485
- if (positionals.length > 0) fail('usage: driftseal status');
3486
- 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 })));
3487
3729
  if (!open) {
3488
3730
  printLine('no intent in progress');
3489
3731
  return null;
@@ -3492,10 +3734,10 @@ const commands = {
3492
3734
  return publicIntent(open);
3493
3735
  },
3494
3736
 
3495
- log(argv) {
3496
- const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' });
3497
- if (positionals.length > 0) fail('usage: driftseal log [--last N] [--all]');
3498
- 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 }));
3499
3741
  if (!flags.all) records = records.filter((record) => !record.reclaimed);
3500
3742
  if (flags.last) {
3501
3743
  const n = positiveInteger(flags.last, '--last');
@@ -3515,12 +3757,10 @@ const commands = {
3515
3757
  'older-than': 'single',
3516
3758
  force: 'boolean',
3517
3759
  'dry-run': 'boolean',
3518
- });
3760
+ }, 'reclaim');
3519
3761
  const reason = flags.reason && flags.reason.trim();
3520
3762
  if (!reason) {
3521
- fail(
3522
- 'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]'
3523
- );
3763
+ fail(usageFor('reclaim'));
3524
3764
  }
3525
3765
  let olderThanDays = 7;
3526
3766
  if (flags['older-than'] !== undefined) {
@@ -3590,10 +3830,10 @@ const commands = {
3590
3830
  },
3591
3831
 
3592
3832
  unreclaim(argv) {
3593
- const { positionals, flags } = parseArgs(argv, { reason: '-r' });
3833
+ const { positionals, flags } = parseArgs(argv, { reason: '-r' }, 'unreclaim');
3594
3834
  const reason = flags.reason && flags.reason.trim();
3595
3835
  if (positionals.length !== 1 || !reason) {
3596
- fail('usage: driftseal unreclaim <id> --reason "<why>"');
3836
+ fail(usageFor('unreclaim'));
3597
3837
  }
3598
3838
  const events = readEvents({ repairTail: true });
3599
3839
  const record = fold(events).find((candidate) => candidate.id === positionals[0]);
@@ -3614,6 +3854,7 @@ const commands = {
3614
3854
 
3615
3855
  decision(argv) {
3616
3856
  const [subcommand, ...rest] = argv;
3857
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('decision');
3617
3858
  if (subcommand === 'add') {
3618
3859
  const { positionals, flags } = parseArgs(rest, {
3619
3860
  context: '-c',
@@ -3622,12 +3863,12 @@ const commands = {
3622
3863
  driver: 'multiple',
3623
3864
  option: 'multiple',
3624
3865
  consequence: 'multiple',
3625
- });
3866
+ }, 'decision add');
3626
3867
  const title = positionals.join(' ').replace(/\s+/g, ' ').trim();
3627
3868
  const context = flags.context && flags.context.trim();
3628
3869
  const outcome = flags.outcome && flags.outcome.trim();
3629
3870
  if (!title || !context || !outcome) {
3630
- fail('usage: driftseal decision add "<title>" --context "..." --outcome "..." [options]');
3871
+ fail(usageFor('decision add'));
3631
3872
  }
3632
3873
  const status = (flags.status || 'accepted').toLowerCase();
3633
3874
  if (!DECISION_STATUSES.includes(status)) {
@@ -3656,10 +3897,10 @@ const commands = {
3656
3897
  }
3657
3898
 
3658
3899
  if (subcommand === 'update') {
3659
- const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' });
3900
+ const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' }, 'decision update');
3660
3901
  const note = flags.note && flags.note.trim();
3661
3902
  if (positionals.length !== 1 || !note) {
3662
- fail('usage: driftseal decision update <id> [--status <status>] --note "<what changed or was confirmed>"');
3903
+ fail(usageFor('decision update'));
3663
3904
  }
3664
3905
 
3665
3906
  let events = readEvents({ repairTail: true });
@@ -3696,9 +3937,9 @@ const commands = {
3696
3937
  }
3697
3938
 
3698
3939
  if (subcommand === 'list') {
3699
- 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');
3700
3941
  if (positionals.length > 0) {
3701
- fail('usage: driftseal decision list [--status STATUS] [--last N | --count]');
3942
+ fail(usageFor('decision list'));
3702
3943
  }
3703
3944
  if (flags.count && flags.last) fail('--count cannot be combined with --last');
3704
3945
  const last = flags.last && positiveInteger(flags.last, '--last');
@@ -3733,16 +3974,16 @@ const commands = {
3733
3974
  }
3734
3975
 
3735
3976
  if (subcommand === 'show') {
3736
- const { positionals } = parseArgs(rest, {});
3977
+ const { positionals } = parseArgs(rest, {}, 'decision show');
3737
3978
  if (positionals.length !== 1 || !/^\d+$/.test(positionals[0])) {
3738
- fail('usage: driftseal decision show <id>');
3979
+ fail(usageFor('decision show'));
3739
3980
  }
3740
3981
  const decision = findDecision(positionals[0]);
3741
3982
  writeOutput(decision.content);
3742
3983
  return publicDecision(decision, { includeContent: true });
3743
3984
  }
3744
3985
 
3745
- fail('usage: driftseal decision add|update|list|show (run: driftseal help)');
3986
+ fail(usageFor('decision'));
3746
3987
  },
3747
3988
 
3748
3989
  mcp(argv) {
@@ -3754,13 +3995,14 @@ const commands = {
3754
3995
  return installSkill(parseSkillInstallRequest(argv));
3755
3996
  },
3756
3997
 
3757
- hook(argv) {
3998
+ hook(argv, { readOnly = false } = {}) {
3758
3999
  const [subcommand, ...rest] = argv;
4000
+ if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('hook');
3759
4001
  if (subcommand === 'install') {
3760
4002
  return installHook(parseHookInstallRequest(rest));
3761
4003
  }
3762
4004
  if (HOOK_EVENTS.includes(subcommand)) {
3763
- return runHookReminder(subcommand, rest);
4005
+ return runHookReminder(subcommand, rest, { readOnly });
3764
4006
  }
3765
4007
  fail(hookUsage());
3766
4008
  },
@@ -3772,7 +4014,7 @@ const commands = {
3772
4014
  'abandon-theirs': 'boolean',
3773
4015
  'abandon-ours': 'boolean',
3774
4016
  'dry-run': 'boolean',
3775
- });
4017
+ }, 'absorb');
3776
4018
  if (flags['abandon-theirs'] && flags['abandon-ours']) {
3777
4019
  fail('cannot combine --abandon-theirs and --abandon-ours');
3778
4020
  }
@@ -3789,15 +4031,24 @@ const commands = {
3789
4031
  },
3790
4032
 
3791
4033
  init(argv) {
3792
- const { positionals, flags } = parseArgs(argv, { lang: 'single' });
3793
- 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'));
3794
4036
  const target = path.join(process.cwd(), 'AGENTS.md');
3795
4037
  const existed = fs.existsSync(target);
3796
4038
  const current = existed ? fs.readFileSync(target, 'utf8') : '';
3797
4039
  const eol = current.includes('\r\n') ? '\r\n' : '\n';
3798
4040
  const language = resolveInitLogLanguage(flags.lang, current);
3799
- const intentBlock = protocolEol(intentProtocolBlock(PROTOCOL_VERSION, language), eol);
3800
- 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);
3801
4052
  let updated = current;
3802
4053
  const intent = upgradeManagedBlock({
3803
4054
  content: updated,
@@ -3806,6 +4057,10 @@ const commands = {
3806
4057
  versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
3807
4058
  replacement: intentBlock,
3808
4059
  knownManagedBlocks: [
4060
+ ...sourceLanguages.flatMap((source) => [
4061
+ protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
4062
+ protocolEol(previousIntentProtocolBlock(11, source), eol),
4063
+ ]),
3809
4064
  protocolEol(previousIntentProtocolBlock(2), eol),
3810
4065
  protocolEol(previousIntentProtocolBlock(3), eol),
3811
4066
  protocolEol(previousIntentProtocolBlock(4), eol),
@@ -3826,6 +4081,10 @@ const commands = {
3826
4081
  versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
3827
4082
  replacement: decisionBlock,
3828
4083
  knownManagedBlocks: [
4084
+ ...sourceLanguages.flatMap((source) => [
4085
+ protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
4086
+ protocolEol(previousDecisionProtocolBlock(11, source), eol),
4087
+ ]),
3829
4088
  protocolEol(previousDecisionProtocolBlock(2), eol),
3830
4089
  protocolEol(previousDecisionProtocolBlock(3), eol),
3831
4090
  protocolEol(previousDecisionProtocolBlock(4), eol),
@@ -3862,6 +4121,8 @@ const commands = {
3862
4121
  printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
3863
4122
  }
3864
4123
 
4124
+ if (localLog) warnIfDefaultLogsTracked();
4125
+
3865
4126
  if (updated === current && !attributes.changed && !driver.changed) {
3866
4127
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
3867
4128
  return { changed: false, target };
@@ -3915,8 +4176,10 @@ usage:
3915
4176
  targets: kimi-code (global only), claude-code, codex (prompt only)
3916
4177
  driftseal hook prompt|stop [--format plain|claude-code]
3917
4178
  emit the reminder a lifecycle hook injects; never blocks
3918
- 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
3919
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
3920
4183
  driftseal --version | -V print the installed DriftSeal version
3921
4184
  driftseal help
3922
4185
 
@@ -3946,6 +4209,43 @@ function requestedEndStatus(argv) {
3946
4209
  return 'completed';
3947
4210
  }
3948
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
+
3949
4249
  function mutationResources(cmd, argv) {
3950
4250
  if (cmd === 'skill') return [parseSkillInstallRequest(argv).skillsDir];
3951
4251
  if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
@@ -3973,18 +4273,60 @@ function dispatch(argv) {
3973
4273
  }
3974
4274
  const fn = commands[cmd];
3975
4275
  if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
3976
- const mutates =
3977
- ['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
3978
- (cmd === 'hook' && rest[0] === 'install') ||
3979
- (cmd === 'decision' && ['add', 'update'].includes(rest[0]));
3980
- const readsIntentLog =
3981
- ['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
3982
- if (mutates || readsIntentLog) {
3983
- const resources = readsIntentLog ? [logDir()] : mutationResources(cmd, rest);
3984
- const data = withMutationLocks(resources, () => fn(rest));
3985
- 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;
3986
4329
  }
3987
- return { data: fn(rest), exitCode: 0 };
3988
4330
  }
3989
4331
 
3990
4332
  function repositoryRoot(root) {
@@ -4012,7 +4354,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4012
4354
  const previousCwd = process.cwd();
4013
4355
  const previousIntentHome = process.env.DRIFTSEAL_HOME;
4014
4356
  const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
4015
- const output = { stdout: '', stderr: '', data: null, exitCode: 0 };
4357
+ const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
4016
4358
  const previousOutput = activeOutput;
4017
4359
 
4018
4360
  try {
@@ -4025,6 +4367,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
4025
4367
  const result = dispatch(argv);
4026
4368
  output.data = result.data;
4027
4369
  output.exitCode = result.exitCode;
4370
+ output.readOnly = result.readOnly === true;
4028
4371
  return output;
4029
4372
  } catch (err) {
4030
4373
  if (capture) {
@@ -4048,9 +4391,18 @@ function appendFlag(argv, flag, value) {
4048
4391
 
4049
4392
  function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
4050
4393
  const fixedRoot = repositoryRoot(root);
4051
- 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
+ };
4052
4400
  return Object.freeze({
4053
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
+ },
4054
4406
  status() {
4055
4407
  return call(['status']);
4056
4408
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.1.7",
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",