driftseal 1.1.7 → 1.2.1
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 +8 -5
- package/README.zh-CN.md +6 -4
- package/bin/driftseal-mcp.js +28 -5
- package/bin/driftseal.js +455 -103
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ driftseal skill install --target kimi-code --scope global
|
|
|
67
67
|
| Target | Project scope | Global scope |
|
|
68
68
|
| --- | --- | --- |
|
|
69
69
|
| `codex` | `.agents/skills/use-driftseal` | `~/.agents/skills/use-driftseal` |
|
|
70
|
-
| `kimi-code` | `.kimi/skills/use-driftseal` | `~/.kimi/skills/use-driftseal` |
|
|
70
|
+
| `kimi-code` | `.kimi-code/skills/use-driftseal` | `~/.kimi-code/skills/use-driftseal` |
|
|
71
71
|
| `opencode` | `.opencode/skills/use-driftseal` | `~/.config/opencode/skills/use-driftseal` |
|
|
72
72
|
| `claude-code` | `.claude/skills/use-driftseal` | `~/.claude/skills/use-driftseal` |
|
|
73
73
|
| `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
|
|
@@ -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.
|
|
217
|
-
|
|
218
|
-
|
|
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
|
@@ -65,7 +65,7 @@ driftseal skill install --target kimi-code --scope global
|
|
|
65
65
|
| Target | 项目级 scope | 全局 scope |
|
|
66
66
|
| --- | --- | --- |
|
|
67
67
|
| `codex` | `.agents/skills/use-driftseal` | `~/.agents/skills/use-driftseal` |
|
|
68
|
-
| `kimi-code` | `.kimi/skills/use-driftseal` | `~/.kimi/skills/use-driftseal` |
|
|
68
|
+
| `kimi-code` | `.kimi-code/skills/use-driftseal` | `~/.kimi-code/skills/use-driftseal` |
|
|
69
69
|
| `opencode` | `.opencode/skills/use-driftseal` | `~/.config/opencode/skills/use-driftseal` |
|
|
70
70
|
| `claude-code` | `.claude/skills/use-driftseal` | `~/.claude/skills/use-driftseal` |
|
|
71
71
|
| `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
|
|
@@ -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
|
-
|
|
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
|
|
package/bin/driftseal-mcp.js
CHANGED
|
@@ -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: {
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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(
|
|
299
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
701
|
-
|
|
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 "<
|
|
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
|
-
|
|
1515
|
-
the
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
}
|
|
@@ -2017,8 +2202,8 @@ function skillInstallLocation(target, scope, root) {
|
|
|
2017
2202
|
global: path.join(home, '.agents', 'skills'),
|
|
2018
2203
|
},
|
|
2019
2204
|
'kimi-code': {
|
|
2020
|
-
project: path.join(root, '.kimi', 'skills'),
|
|
2021
|
-
global: path.join(home, '.kimi', 'skills'),
|
|
2205
|
+
project: path.join(root, '.kimi-code', 'skills'),
|
|
2206
|
+
global: path.join(home, '.kimi-code', 'skills'),
|
|
2022
2207
|
},
|
|
2023
2208
|
opencode: {
|
|
2024
2209
|
project: path.join(root, '.opencode', 'skills'),
|
|
@@ -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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
|
3800
|
-
|
|
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>]
|
|
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
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
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
|
-
|
|
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
|
},
|