driftseal 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/README.zh-CN.md +7 -4
- package/bin/driftseal-mcp.js +28 -5
- package/bin/driftseal.js +496 -111
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,8 +73,9 @@ driftseal skill install --target kimi-code --scope global
|
|
|
73
73
|
| `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
|
|
74
74
|
|
|
75
75
|
Use `--root <repository>` to select a project when running the installer
|
|
76
|
-
elsewhere. Repeated installs of identical content are no-ops
|
|
77
|
-
|
|
76
|
+
elsewhere. Repeated installs of identical content are no-ops, and a skill left
|
|
77
|
+
by an earlier DriftSeal release is upgraded in place; only a skill this
|
|
78
|
+
installer never wrote requires `--force`. MCP and lifecycle hooks are optional
|
|
78
79
|
adapters; enable them only for a concrete host constraint or reminder need, not
|
|
79
80
|
as additional policy layers.
|
|
80
81
|
|
|
@@ -212,9 +213,12 @@ If the scope changes, close the current intent as `partial` or `abandoned`, then
|
|
|
212
213
|
Git operations are entirely outside the intent log because Git maintains their
|
|
213
214
|
history. Inspection, branch and worktree management, staging, commits, merges,
|
|
214
215
|
rebases, cherry-picks, tags, and pushes never need an intent of their own. They
|
|
215
|
-
still require normal authorization and safety checks.
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
still require normal authorization and safety checks. A command whose result
|
|
217
|
+
can be reconstructed from Git state, such as a patch file regenerated from a
|
|
218
|
+
commit range or a scratch harness that re-runs, needs no intent either; content
|
|
219
|
+
that will be committed and cannot be reconstructed, such as a `.gitignore`
|
|
220
|
+
edit, does. Single-step builds and checks, such as compiling or running tests,
|
|
221
|
+
also need no intent. Any other non-Git content change starts a new work round.
|
|
218
222
|
|
|
219
223
|
## Commands
|
|
220
224
|
|
|
@@ -236,7 +240,7 @@ content change starts a new work round.
|
|
|
236
240
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | Install the repository-pinned MCP server into Codex, Kimi Code, OpenCode, Claude Code, or Cursor. |
|
|
237
241
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | Install advisory lifecycle reminders into Kimi Code, Claude Code, or Codex. |
|
|
238
242
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | Emit the reminder a lifecycle hook injects; never blocks. |
|
|
239
|
-
| `driftseal init [--lang <tag>]` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. `--lang` sets the intent/decision log language (BCP 47, default `en`). |
|
|
243
|
+
| `driftseal init [--lang <tag>] [--local-log]` | Add the adoption protocol to `AGENTS.md` and configure the git merge driver. `--lang` sets the intent/decision log language (BCP 47, default `en`). `--local-log` keeps the logs local and untracked instead of committing them with the code; if the logs are already tracked, init warns with the remediation steps and leaves the index and `.gitignore` untouched. |
|
|
240
244
|
| `driftseal --version` or `driftseal -V` | Print the installed DriftSeal version. |
|
|
241
245
|
| `driftseal help` | Print CLI usage. |
|
|
242
246
|
|
package/README.zh-CN.md
CHANGED
|
@@ -71,7 +71,8 @@ driftseal skill install --target kimi-code --scope global
|
|
|
71
71
|
| `cursor` | `.cursor/skills/use-driftseal` | `~/.cursor/skills/use-driftseal` |
|
|
72
72
|
|
|
73
73
|
如果不在目标 repository 中执行,用 `--root <repository>` 明确指定项目。
|
|
74
|
-
|
|
74
|
+
重复安装相同内容不会产生改动;旧版本 DriftSeal 装下的 skill 会被直接原地升级,
|
|
75
|
+
只有安装器从未写过的 skill 才需要显式传入 `--force`。
|
|
75
76
|
MCP 与 lifecycle hook 都是可选适配层;只有确实存在 host 限制或提醒需求时
|
|
76
77
|
才启用,不要把它们叠成额外的 policy 层。
|
|
77
78
|
|
|
@@ -201,8 +202,10 @@ driftseal end \
|
|
|
201
202
|
|
|
202
203
|
Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看状态、管理 branch
|
|
203
204
|
或 worktree、stage、commit、merge、rebase、cherry-pick、tag 和 push 都不需要
|
|
204
|
-
单独开启 intent
|
|
205
|
-
|
|
205
|
+
单独开启 intent,但仍须遵守正常的授权与安全要求。结果能从 Git 状态重建的命令
|
|
206
|
+
——比如从 commit range 重新生成的 patch 文件、可以重跑的临时 harness——也不需要
|
|
207
|
+
intent;会被提交且无法重建的内容改动(比如编辑 `.gitignore`)则需要。编译、跑测试
|
|
208
|
+
等单步构建或检查同样不需要 intent;除此之外的非 Git 内容改动,都要开启新一轮。
|
|
206
209
|
|
|
207
210
|
## 命令速览
|
|
208
211
|
|
|
@@ -224,7 +227,7 @@ Git 操作完全不计入 intent log,因为 Git 会自行维护历史。查看
|
|
|
224
227
|
| `driftseal mcp install --target TARGET [--scope project\|global] [--root path] [--force]` | 把固定到 repository 的 MCP server 安装到 Codex、Kimi Code、OpenCode、Claude Code 或 Cursor。 |
|
|
225
228
|
| `driftseal hook install --target TARGET [--scope project\|global] [--root path] [--force]` | 把建议性的 lifecycle 提醒安装到 Kimi Code、Claude Code 或 Codex。 |
|
|
226
229
|
| `driftseal hook prompt\|stop [--format plain\|claude-code]` | 输出 lifecycle hook 注入的提醒;绝不阻断。 |
|
|
227
|
-
| `driftseal init [--lang <tag>]` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。`--lang` 设置 intent / decision log 的语言(BCP 47,默认 `en
|
|
230
|
+
| `driftseal init [--lang <tag>] [--local-log]` | 把接入协议写入 `AGENTS.md`,并配置 git merge driver。`--lang` 设置 intent / decision log 的语言(BCP 47,默认 `en`)。`--local-log` 让日志保持本地、不入库,不随代码提交;如果日志已被 git 跟踪,init 会打印警告和处理建议,但不会改动 index 或 `.gitignore`。 |
|
|
228
231
|
| `driftseal --version` 或 `driftseal -V` | 输出当前安装的 DriftSeal 版本。 |
|
|
229
232
|
| `driftseal help` | 查看 CLI 用法。 |
|
|
230
233
|
|
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
|
}
|
|
@@ -1984,6 +2169,27 @@ function installMcp(request) {
|
|
|
1984
2169
|
|
|
1985
2170
|
const SKILL_NAME = 'use-driftseal';
|
|
1986
2171
|
|
|
2172
|
+
/*
|
|
2173
|
+
* Every skill tree DriftSeal has ever bundled, oldest first, as skillTreeDigest
|
|
2174
|
+
* hashes. `skill install` treats a directory matching one of these as its own
|
|
2175
|
+
* earlier output and upgrades it in place; anything else is someone's local
|
|
2176
|
+
* skill that only --force may replace. Append the new digest whenever the
|
|
2177
|
+
* bundled skill changes, otherwise the next release cannot upgrade this one;
|
|
2178
|
+
* the "bundled use-driftseal skill is a known release" test fails until you do.
|
|
2179
|
+
*/
|
|
2180
|
+
const SKILL_RELEASE_DIGESTS = new Set([
|
|
2181
|
+
'e996627c96edc7c09599bc454c4225a416aa89e50aef14d4dd569dd21454e882', // 1b76215 initial commit
|
|
2182
|
+
'5f702545b18e117bafcea669b48cdb3b31c98079f97eaa084fd3b4ceff9500e8', // 5c2ec74 intents only for rollback-worthy changes
|
|
2183
|
+
'77f6365590ff181ee6be003930dd1696c363e213368de0f4e89962555af3fabe', // 56380a8 local MCP server
|
|
2184
|
+
'b7e2310eaf20b50b5ec28531471965607e7e070a4f58263e29408b2cd5cdfb11', // 1cc77f6 reclaim markers
|
|
2185
|
+
'08bc63b8dcf0f4f078252179a3fc2ca4ef2632ecb5b9dfe3782a452c3202c2c4', // de4a8a1 skill slimmed into a usage guide
|
|
2186
|
+
'8523459ff81cf0b97a36b30d216956a58d8a3b3b9760f455d7ea334604da335a', // 3a1d6e0 protocol v7 resume semantics
|
|
2187
|
+
'cc98b9348ec222320bfcd285ba3f1f499a42d15b31e9b1d83c35f0206b2d5ba9', // ca16785 CLI-first skill integration
|
|
2188
|
+
'0fd870f8c1b81f8386d986d64742679d56cd1d317c02890830c81876eb9227d6', // da8afd2 1.1.0 absorb
|
|
2189
|
+
'72ddea79940bdf2bce66d491888f11423ae1bd383e1b511028fda617e6f6fb27', // f395778 1.1.6 parked intents
|
|
2190
|
+
'df8bc7035de1a19faf307c92f9bb0f4052e683d1a94881c2c5d5cbef48b67568', // dc9899d 1.1.7 parked intents in absorb (current)
|
|
2191
|
+
]);
|
|
2192
|
+
|
|
1987
2193
|
function skillInstallUsage() {
|
|
1988
2194
|
return 'usage: driftseal skill install --target <codex|kimi-code|opencode|claude-code|cursor> [--scope project|global] [--root <repository>] [--force]';
|
|
1989
2195
|
}
|
|
@@ -2018,13 +2224,14 @@ function skillInstallLocation(target, scope, root) {
|
|
|
2018
2224
|
|
|
2019
2225
|
function parseSkillInstallRequest(argv) {
|
|
2020
2226
|
const [subcommand, ...rest] = argv;
|
|
2227
|
+
if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('skill');
|
|
2021
2228
|
if (subcommand !== 'install') fail(skillInstallUsage());
|
|
2022
2229
|
const { positionals, flags } = parseArgs(rest, {
|
|
2023
2230
|
target: 'single',
|
|
2024
2231
|
scope: 'single',
|
|
2025
2232
|
root: 'single',
|
|
2026
2233
|
force: 'boolean',
|
|
2027
|
-
});
|
|
2234
|
+
}, 'skill');
|
|
2028
2235
|
if (positionals.length > 0 || !flags.target) fail(skillInstallUsage());
|
|
2029
2236
|
|
|
2030
2237
|
const target = flags.target.toLowerCase();
|
|
@@ -2046,7 +2253,13 @@ function parseSkillInstallRequest(argv) {
|
|
|
2046
2253
|
};
|
|
2047
2254
|
}
|
|
2048
2255
|
|
|
2049
|
-
|
|
2256
|
+
/*
|
|
2257
|
+
* Identifies a skill tree by its contents alone: relative paths joined with "/"
|
|
2258
|
+
* and file bytes, never file modes or timestamps. The digest therefore stays
|
|
2259
|
+
* stable across platforms, checkouts, and npm tarballs, which is what lets
|
|
2260
|
+
* SKILL_RELEASE_DIGESTS recognize a skill DriftSeal installed earlier.
|
|
2261
|
+
*/
|
|
2262
|
+
function skillTreeDigest(directory) {
|
|
2050
2263
|
if (!fs.existsSync(directory)) return null;
|
|
2051
2264
|
const digest = crypto.createHash('sha256');
|
|
2052
2265
|
|
|
@@ -2055,12 +2268,12 @@ function directoryDigest(directory) {
|
|
|
2055
2268
|
if (stat.isDirectory()) {
|
|
2056
2269
|
digest.update(`directory\0${relative}\0`);
|
|
2057
2270
|
for (const name of fs.readdirSync(current).sort()) {
|
|
2058
|
-
visit(path.join(current, name), relative ?
|
|
2271
|
+
visit(path.join(current, name), relative ? `${relative}/${name}` : name);
|
|
2059
2272
|
}
|
|
2060
2273
|
return;
|
|
2061
2274
|
}
|
|
2062
2275
|
if (stat.isFile()) {
|
|
2063
|
-
digest.update(`file\0${relative}\0
|
|
2276
|
+
digest.update(`file\0${relative}\0`);
|
|
2064
2277
|
digest.update(fs.readFileSync(current));
|
|
2065
2278
|
digest.update('\0');
|
|
2066
2279
|
return;
|
|
@@ -2069,7 +2282,7 @@ function directoryDigest(directory) {
|
|
|
2069
2282
|
digest.update(`symlink\0${relative}\0${fs.readlinkSync(current)}\0`);
|
|
2070
2283
|
return;
|
|
2071
2284
|
}
|
|
2072
|
-
digest.update(`other\0${relative}\0
|
|
2285
|
+
digest.update(`other\0${relative}\0`);
|
|
2073
2286
|
}
|
|
2074
2287
|
|
|
2075
2288
|
visit(directory, '');
|
|
@@ -2096,15 +2309,18 @@ function installSkill(request) {
|
|
|
2096
2309
|
fail(`bundled ${SKILL_NAME} skill is missing from this DriftSeal installation: ${sourceDir}`);
|
|
2097
2310
|
}
|
|
2098
2311
|
|
|
2099
|
-
const sourceDigest =
|
|
2100
|
-
const existingDigest =
|
|
2312
|
+
const sourceDigest = skillTreeDigest(sourceDir);
|
|
2313
|
+
const existingDigest = skillTreeDigest(skillDir);
|
|
2101
2314
|
if (existingDigest === sourceDigest) {
|
|
2102
2315
|
printLine(`${SKILL_NAME} skill is already installed for ${targetLabel} (${scope}): ${skillDir}`);
|
|
2103
2316
|
return { changed: false, target, scope, root, skillDir };
|
|
2104
2317
|
}
|
|
2105
|
-
|
|
2318
|
+
// An untouched skill from an earlier DriftSeal upgrades on its own; only a
|
|
2319
|
+
// skill this installer never wrote needs the operator to confirm with --force.
|
|
2320
|
+
const upgraded = existingDigest !== null && SKILL_RELEASE_DIGESTS.has(existingDigest);
|
|
2321
|
+
if (existingDigest !== null && !upgraded && !force) {
|
|
2106
2322
|
fail(
|
|
2107
|
-
`${targetLabel} already has a
|
|
2323
|
+
`${targetLabel} already has a ${SKILL_NAME} skill DriftSeal did not install at ${skillDir}; ` +
|
|
2108
2324
|
're-run with --force to replace it'
|
|
2109
2325
|
);
|
|
2110
2326
|
}
|
|
@@ -2134,9 +2350,12 @@ function installSkill(request) {
|
|
|
2134
2350
|
throw err;
|
|
2135
2351
|
}
|
|
2136
2352
|
|
|
2137
|
-
printLine(
|
|
2353
|
+
printLine(
|
|
2354
|
+
`${upgraded ? 'Upgraded' : 'Installed'} ${SKILL_NAME} skill for ` +
|
|
2355
|
+
`${targetLabel} (${scope}): ${skillDir}`
|
|
2356
|
+
);
|
|
2138
2357
|
if (scope === 'project') printLine(`Repository root: ${root}`);
|
|
2139
|
-
return { changed: true, target, scope, root, skillDir };
|
|
2358
|
+
return { changed: true, upgraded, target, scope, root, skillDir };
|
|
2140
2359
|
}
|
|
2141
2360
|
|
|
2142
2361
|
const HOOK_TARGETS = ['kimi-code', 'claude-code', 'codex'];
|
|
@@ -2179,7 +2398,7 @@ function parseHookInstallRequest(argv) {
|
|
|
2179
2398
|
scope: 'single',
|
|
2180
2399
|
root: 'single',
|
|
2181
2400
|
force: 'boolean',
|
|
2182
|
-
});
|
|
2401
|
+
}, 'hook install');
|
|
2183
2402
|
if (positionals.length > 0 || !flags.target) {
|
|
2184
2403
|
fail(hookUsage());
|
|
2185
2404
|
}
|
|
@@ -2402,7 +2621,7 @@ function hookLogFile() {
|
|
|
2402
2621
|
}
|
|
2403
2622
|
|
|
2404
2623
|
/** Advisory reminder text; null when no ancestor has an intent log yet. */
|
|
2405
|
-
function hookReminder(event) {
|
|
2624
|
+
function hookReminder(event, { readOnly = false } = {}) {
|
|
2406
2625
|
const file = hookLogFile();
|
|
2407
2626
|
if (!file) return null;
|
|
2408
2627
|
if (event === 'prompt') {
|
|
@@ -2413,7 +2632,7 @@ function hookReminder(event) {
|
|
|
2413
2632
|
'reminder when it does not apply.'
|
|
2414
2633
|
);
|
|
2415
2634
|
}
|
|
2416
|
-
const open = openIntent(fold(readEvents({ file })));
|
|
2635
|
+
const open = openIntent(fold(readEvents({ file, readOnly })));
|
|
2417
2636
|
if (open) {
|
|
2418
2637
|
return (
|
|
2419
2638
|
`DriftSeal reminder: intent ${open.id} is still in_progress: "${open.intent}". ` +
|
|
@@ -2427,8 +2646,8 @@ function hookReminder(event) {
|
|
|
2427
2646
|
);
|
|
2428
2647
|
}
|
|
2429
2648
|
|
|
2430
|
-
function runHookReminder(event, argv) {
|
|
2431
|
-
const { positionals, flags } = parseArgs(argv, { format: 'single' });
|
|
2649
|
+
function runHookReminder(event, argv, { readOnly = false } = {}) {
|
|
2650
|
+
const { positionals, flags } = parseArgs(argv, { format: 'single' }, `hook ${event}`);
|
|
2432
2651
|
if (positionals.length > 0) fail(hookUsage());
|
|
2433
2652
|
const format = (flags.format || 'plain').toLowerCase();
|
|
2434
2653
|
if (!['plain', 'claude-code'].includes(format)) {
|
|
@@ -2438,7 +2657,7 @@ function runHookReminder(event, argv) {
|
|
|
2438
2657
|
// Hooks must never block the agent: any failure exits quietly with no output.
|
|
2439
2658
|
let reminder = null;
|
|
2440
2659
|
try {
|
|
2441
|
-
reminder = hookReminder(event);
|
|
2660
|
+
reminder = hookReminder(event, { readOnly });
|
|
2442
2661
|
} catch {
|
|
2443
2662
|
reminder = null;
|
|
2444
2663
|
}
|
|
@@ -2477,10 +2696,63 @@ function gitCapture(args, cwd = process.cwd()) {
|
|
|
2477
2696
|
return output === null ? null : output.trim();
|
|
2478
2697
|
}
|
|
2479
2698
|
|
|
2699
|
+
/**
|
|
2700
|
+
* Capture a single git value without touching the payload: only the one
|
|
2701
|
+
* newline git appends is removed, so paths that begin or end with whitespace
|
|
2702
|
+
* survive intact (`gitCapture` would trim them away).
|
|
2703
|
+
*/
|
|
2704
|
+
function gitCaptureLine(args, cwd = process.cwd()) {
|
|
2705
|
+
const output = gitCaptureRaw(args, cwd);
|
|
2706
|
+
if (output === null) return null;
|
|
2707
|
+
return output.endsWith('\n') ? output.slice(0, -1) : output;
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2480
2710
|
function isGitWorkTree(cwd = process.cwd()) {
|
|
2481
2711
|
return gitCapture(['rev-parse', '--is-inside-work-tree'], cwd) === 'true';
|
|
2482
2712
|
}
|
|
2483
2713
|
|
|
2714
|
+
/**
|
|
2715
|
+
* Warn (without mutating the index or .gitignore) when local log mode is on
|
|
2716
|
+
* but the default log paths are still tracked by git. The log directories are
|
|
2717
|
+
* resolved relative to the init cwd (init writes ./AGENTS.md there), so a
|
|
2718
|
+
* nested init checks its own logs rather than the repository root's.
|
|
2719
|
+
*
|
|
2720
|
+
* Paths are read from `ls-files -z` with `:(literal)` pathspecs because git's
|
|
2721
|
+
* human-readable listing C-quotes non-ASCII names and treats `*?[\` as
|
|
2722
|
+
* wildcards. The printed remediation uses the fixed names `.intent-log` and
|
|
2723
|
+
* `.decision-log` and is meant to be run from this directory: git resolves
|
|
2724
|
+
* those pathspecs against the init cwd, so the command stays paste-safe in
|
|
2725
|
+
* POSIX shells, cmd.exe, and PowerShell without embedding the repo-relative
|
|
2726
|
+
* prefix or any shell quoting.
|
|
2727
|
+
*/
|
|
2728
|
+
function warnIfDefaultLogsTracked(cwd = process.cwd()) {
|
|
2729
|
+
if (!isGitWorkTree(cwd)) return;
|
|
2730
|
+
const root = gitCaptureLine(['rev-parse', '--show-toplevel'], cwd);
|
|
2731
|
+
if (!root) return;
|
|
2732
|
+
const prefix = gitCaptureLine(['rev-parse', '--show-prefix'], cwd);
|
|
2733
|
+
if (prefix === null) return;
|
|
2734
|
+
const logNames = ['.intent-log', '.decision-log'];
|
|
2735
|
+
const logDirs = logNames.map((name) => `${prefix}${name}`);
|
|
2736
|
+
const listing = gitCaptureRaw(
|
|
2737
|
+
['ls-files', '-z', '--', ...logDirs.map((name) => `:(literal)${name}`)],
|
|
2738
|
+
root
|
|
2739
|
+
);
|
|
2740
|
+
if (!listing) return;
|
|
2741
|
+
const files = listing.split('\0').filter((file) => file.length > 0);
|
|
2742
|
+
const trackedNames = logNames.filter((name) => {
|
|
2743
|
+
const dir = `${prefix}${name}`;
|
|
2744
|
+
return files.some((file) => file === dir || file.startsWith(`${dir}/`));
|
|
2745
|
+
});
|
|
2746
|
+
if (trackedNames.length === 0) return;
|
|
2747
|
+
const tracked = trackedNames.map((name) => `${prefix}${name}`);
|
|
2748
|
+
printLine(
|
|
2749
|
+
`warning: local log mode is on, but ${tracked.join(' and ')} ` +
|
|
2750
|
+
`${tracked.length === 1 ? 'is' : 'are'} still tracked by git; run ` +
|
|
2751
|
+
`\`git rm -r --cached -- ${trackedNames.join(' ')}\` from this directory ` +
|
|
2752
|
+
'and add them to .gitignore to keep the logs local'
|
|
2753
|
+
);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2484
2756
|
function gitOtherHead(cwd = process.cwd()) {
|
|
2485
2757
|
return (
|
|
2486
2758
|
gitCapture(['rev-parse', '--verify', 'MERGE_HEAD'], cwd) ||
|
|
@@ -2902,6 +3174,7 @@ function abandonOpenIntent(records, targetId, side) {
|
|
|
2902
3174
|
status: 'abandoned',
|
|
2903
3175
|
note: `abandoned during absorb (--abandon-${side})`,
|
|
2904
3176
|
verifyResult: null,
|
|
3177
|
+
head: gitCapture(['rev-parse', 'HEAD']),
|
|
2905
3178
|
},
|
|
2906
3179
|
});
|
|
2907
3180
|
return targetId;
|
|
@@ -3310,10 +3583,10 @@ const commands = {
|
|
|
3310
3583
|
verify: '-v',
|
|
3311
3584
|
decision: 'multiple',
|
|
3312
3585
|
force: 'boolean',
|
|
3313
|
-
});
|
|
3586
|
+
}, 'begin');
|
|
3314
3587
|
const intent = positionals.join(' ').trim();
|
|
3315
3588
|
if (!intent) {
|
|
3316
|
-
fail('
|
|
3589
|
+
fail(usageFor('begin'));
|
|
3317
3590
|
}
|
|
3318
3591
|
const requestedDecisions = flags.decision || [];
|
|
3319
3592
|
const index = requestedDecisions.length > 0 ? decisionIndex() : [];
|
|
@@ -3357,6 +3630,7 @@ const commands = {
|
|
|
3357
3630
|
intent,
|
|
3358
3631
|
verify: flags.verify || null,
|
|
3359
3632
|
decisions,
|
|
3633
|
+
head: gitCapture(['rev-parse', 'HEAD']),
|
|
3360
3634
|
}));
|
|
3361
3635
|
const record = fold(events).find((candidate) => candidate.id === id);
|
|
3362
3636
|
printLine(id);
|
|
@@ -3368,9 +3642,9 @@ const commands = {
|
|
|
3368
3642
|
status: '-s',
|
|
3369
3643
|
note: '-n',
|
|
3370
3644
|
'verify-result': '-r',
|
|
3371
|
-
});
|
|
3645
|
+
}, 'end');
|
|
3372
3646
|
const status = flags.status || 'completed';
|
|
3373
|
-
if (positionals.length > 1) fail('
|
|
3647
|
+
if (positionals.length > 1) fail(usageFor('end'));
|
|
3374
3648
|
if (!END_STATUSES.includes(status)) {
|
|
3375
3649
|
fail(`invalid status "${status}" (expected: ${END_STATUSES.join(', ')})`);
|
|
3376
3650
|
}
|
|
@@ -3441,16 +3715,17 @@ const commands = {
|
|
|
3441
3715
|
status,
|
|
3442
3716
|
note: flags.note || null,
|
|
3443
3717
|
verifyResult: flags['verify-result'] || null,
|
|
3718
|
+
head: gitCapture(['rev-parse', 'HEAD']),
|
|
3444
3719
|
}));
|
|
3445
3720
|
const record = fold(events).find((candidate) => candidate.id === target.id);
|
|
3446
3721
|
printLine(`${target.id} ${status}`);
|
|
3447
3722
|
return publicIntent(record);
|
|
3448
3723
|
},
|
|
3449
3724
|
|
|
3450
|
-
status(argv) {
|
|
3451
|
-
const { positionals } = parseArgs(argv, {});
|
|
3452
|
-
if (positionals.length > 0) fail('
|
|
3453
|
-
const open = openIntent(fold(readEvents({ repairTail: true })));
|
|
3725
|
+
status(argv, { readOnly = false } = {}) {
|
|
3726
|
+
const { positionals } = parseArgs(argv, {}, 'status');
|
|
3727
|
+
if (positionals.length > 0) fail(usageFor('status'));
|
|
3728
|
+
const open = openIntent(fold(readEvents({ repairTail: true, readOnly })));
|
|
3454
3729
|
if (!open) {
|
|
3455
3730
|
printLine('no intent in progress');
|
|
3456
3731
|
return null;
|
|
@@ -3459,10 +3734,10 @@ const commands = {
|
|
|
3459
3734
|
return publicIntent(open);
|
|
3460
3735
|
},
|
|
3461
3736
|
|
|
3462
|
-
log(argv) {
|
|
3463
|
-
const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' });
|
|
3464
|
-
if (positionals.length > 0) fail('
|
|
3465
|
-
let records = fold(readEvents({ repairTail: true }));
|
|
3737
|
+
log(argv, { readOnly = false } = {}) {
|
|
3738
|
+
const { positionals, flags } = parseArgs(argv, { last: '-n', all: 'boolean' }, 'log');
|
|
3739
|
+
if (positionals.length > 0) fail(usageFor('log'));
|
|
3740
|
+
let records = fold(readEvents({ repairTail: true, readOnly }));
|
|
3466
3741
|
if (!flags.all) records = records.filter((record) => !record.reclaimed);
|
|
3467
3742
|
if (flags.last) {
|
|
3468
3743
|
const n = positiveInteger(flags.last, '--last');
|
|
@@ -3482,12 +3757,10 @@ const commands = {
|
|
|
3482
3757
|
'older-than': 'single',
|
|
3483
3758
|
force: 'boolean',
|
|
3484
3759
|
'dry-run': 'boolean',
|
|
3485
|
-
});
|
|
3760
|
+
}, 'reclaim');
|
|
3486
3761
|
const reason = flags.reason && flags.reason.trim();
|
|
3487
3762
|
if (!reason) {
|
|
3488
|
-
fail(
|
|
3489
|
-
'usage: driftseal reclaim [id ...] --reason "<why>" [--older-than <days>] [--force] [--dry-run]'
|
|
3490
|
-
);
|
|
3763
|
+
fail(usageFor('reclaim'));
|
|
3491
3764
|
}
|
|
3492
3765
|
let olderThanDays = 7;
|
|
3493
3766
|
if (flags['older-than'] !== undefined) {
|
|
@@ -3557,10 +3830,10 @@ const commands = {
|
|
|
3557
3830
|
},
|
|
3558
3831
|
|
|
3559
3832
|
unreclaim(argv) {
|
|
3560
|
-
const { positionals, flags } = parseArgs(argv, { reason: '-r' });
|
|
3833
|
+
const { positionals, flags } = parseArgs(argv, { reason: '-r' }, 'unreclaim');
|
|
3561
3834
|
const reason = flags.reason && flags.reason.trim();
|
|
3562
3835
|
if (positionals.length !== 1 || !reason) {
|
|
3563
|
-
fail('
|
|
3836
|
+
fail(usageFor('unreclaim'));
|
|
3564
3837
|
}
|
|
3565
3838
|
const events = readEvents({ repairTail: true });
|
|
3566
3839
|
const record = fold(events).find((candidate) => candidate.id === positionals[0]);
|
|
@@ -3581,6 +3854,7 @@ const commands = {
|
|
|
3581
3854
|
|
|
3582
3855
|
decision(argv) {
|
|
3583
3856
|
const [subcommand, ...rest] = argv;
|
|
3857
|
+
if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('decision');
|
|
3584
3858
|
if (subcommand === 'add') {
|
|
3585
3859
|
const { positionals, flags } = parseArgs(rest, {
|
|
3586
3860
|
context: '-c',
|
|
@@ -3589,12 +3863,12 @@ const commands = {
|
|
|
3589
3863
|
driver: 'multiple',
|
|
3590
3864
|
option: 'multiple',
|
|
3591
3865
|
consequence: 'multiple',
|
|
3592
|
-
});
|
|
3866
|
+
}, 'decision add');
|
|
3593
3867
|
const title = positionals.join(' ').replace(/\s+/g, ' ').trim();
|
|
3594
3868
|
const context = flags.context && flags.context.trim();
|
|
3595
3869
|
const outcome = flags.outcome && flags.outcome.trim();
|
|
3596
3870
|
if (!title || !context || !outcome) {
|
|
3597
|
-
fail('
|
|
3871
|
+
fail(usageFor('decision add'));
|
|
3598
3872
|
}
|
|
3599
3873
|
const status = (flags.status || 'accepted').toLowerCase();
|
|
3600
3874
|
if (!DECISION_STATUSES.includes(status)) {
|
|
@@ -3623,10 +3897,10 @@ const commands = {
|
|
|
3623
3897
|
}
|
|
3624
3898
|
|
|
3625
3899
|
if (subcommand === 'update') {
|
|
3626
|
-
const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' });
|
|
3900
|
+
const { positionals, flags } = parseArgs(rest, { status: '-s', note: '-n' }, 'decision update');
|
|
3627
3901
|
const note = flags.note && flags.note.trim();
|
|
3628
3902
|
if (positionals.length !== 1 || !note) {
|
|
3629
|
-
fail('
|
|
3903
|
+
fail(usageFor('decision update'));
|
|
3630
3904
|
}
|
|
3631
3905
|
|
|
3632
3906
|
let events = readEvents({ repairTail: true });
|
|
@@ -3663,9 +3937,9 @@ const commands = {
|
|
|
3663
3937
|
}
|
|
3664
3938
|
|
|
3665
3939
|
if (subcommand === 'list') {
|
|
3666
|
-
const { positionals, flags } = parseArgs(rest, { last: '-n', status: '-s', count: 'boolean' });
|
|
3940
|
+
const { positionals, flags } = parseArgs(rest, { last: '-n', status: '-s', count: 'boolean' }, 'decision list');
|
|
3667
3941
|
if (positionals.length > 0) {
|
|
3668
|
-
fail('
|
|
3942
|
+
fail(usageFor('decision list'));
|
|
3669
3943
|
}
|
|
3670
3944
|
if (flags.count && flags.last) fail('--count cannot be combined with --last');
|
|
3671
3945
|
const last = flags.last && positiveInteger(flags.last, '--last');
|
|
@@ -3700,16 +3974,16 @@ const commands = {
|
|
|
3700
3974
|
}
|
|
3701
3975
|
|
|
3702
3976
|
if (subcommand === 'show') {
|
|
3703
|
-
const { positionals } = parseArgs(rest, {});
|
|
3977
|
+
const { positionals } = parseArgs(rest, {}, 'decision show');
|
|
3704
3978
|
if (positionals.length !== 1 || !/^\d+$/.test(positionals[0])) {
|
|
3705
|
-
fail('
|
|
3979
|
+
fail(usageFor('decision show'));
|
|
3706
3980
|
}
|
|
3707
3981
|
const decision = findDecision(positionals[0]);
|
|
3708
3982
|
writeOutput(decision.content);
|
|
3709
3983
|
return publicDecision(decision, { includeContent: true });
|
|
3710
3984
|
}
|
|
3711
3985
|
|
|
3712
|
-
fail('
|
|
3986
|
+
fail(usageFor('decision'));
|
|
3713
3987
|
},
|
|
3714
3988
|
|
|
3715
3989
|
mcp(argv) {
|
|
@@ -3721,13 +3995,14 @@ const commands = {
|
|
|
3721
3995
|
return installSkill(parseSkillInstallRequest(argv));
|
|
3722
3996
|
},
|
|
3723
3997
|
|
|
3724
|
-
hook(argv) {
|
|
3998
|
+
hook(argv, { readOnly = false } = {}) {
|
|
3725
3999
|
const [subcommand, ...rest] = argv;
|
|
4000
|
+
if (subcommand === '--help' || subcommand === '-h') throw new HelpRequested('hook');
|
|
3726
4001
|
if (subcommand === 'install') {
|
|
3727
4002
|
return installHook(parseHookInstallRequest(rest));
|
|
3728
4003
|
}
|
|
3729
4004
|
if (HOOK_EVENTS.includes(subcommand)) {
|
|
3730
|
-
return runHookReminder(subcommand, rest);
|
|
4005
|
+
return runHookReminder(subcommand, rest, { readOnly });
|
|
3731
4006
|
}
|
|
3732
4007
|
fail(hookUsage());
|
|
3733
4008
|
},
|
|
@@ -3739,7 +4014,7 @@ const commands = {
|
|
|
3739
4014
|
'abandon-theirs': 'boolean',
|
|
3740
4015
|
'abandon-ours': 'boolean',
|
|
3741
4016
|
'dry-run': 'boolean',
|
|
3742
|
-
});
|
|
4017
|
+
}, 'absorb');
|
|
3743
4018
|
if (flags['abandon-theirs'] && flags['abandon-ours']) {
|
|
3744
4019
|
fail('cannot combine --abandon-theirs and --abandon-ours');
|
|
3745
4020
|
}
|
|
@@ -3756,15 +4031,24 @@ const commands = {
|
|
|
3756
4031
|
},
|
|
3757
4032
|
|
|
3758
4033
|
init(argv) {
|
|
3759
|
-
const { positionals, flags } = parseArgs(argv, { lang: 'single' });
|
|
3760
|
-
if (positionals.length > 0) fail('
|
|
4034
|
+
const { positionals, flags } = parseArgs(argv, { lang: 'single', 'local-log': 'boolean' }, 'init');
|
|
4035
|
+
if (positionals.length > 0) fail(usageFor('init'));
|
|
3761
4036
|
const target = path.join(process.cwd(), 'AGENTS.md');
|
|
3762
4037
|
const existed = fs.existsSync(target);
|
|
3763
4038
|
const current = existed ? fs.readFileSync(target, 'utf8') : '';
|
|
3764
4039
|
const eol = current.includes('\r\n') ? '\r\n' : '\n';
|
|
3765
4040
|
const language = resolveInitLogLanguage(flags.lang, current);
|
|
3766
|
-
const
|
|
3767
|
-
|
|
4041
|
+
const localLog = resolveInitLocalLog(flags['local-log'] === true, current);
|
|
4042
|
+
// Accept default blocks in the persisted language too, so a single run can
|
|
4043
|
+
// switch language and enable local mode on a default or v11 protocol.
|
|
4044
|
+
const sourceLanguages = [language];
|
|
4045
|
+
if (flags.lang !== undefined) {
|
|
4046
|
+
for (const persisted of persistedLogLanguages(current)) {
|
|
4047
|
+
if (!sourceLanguages.includes(persisted)) sourceLanguages.push(persisted);
|
|
4048
|
+
}
|
|
4049
|
+
}
|
|
4050
|
+
const intentBlock = protocolEol(intentProtocolBlock(PROTOCOL_VERSION, language, localLog), eol);
|
|
4051
|
+
const decisionBlock = protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, language, localLog), eol);
|
|
3768
4052
|
let updated = current;
|
|
3769
4053
|
const intent = upgradeManagedBlock({
|
|
3770
4054
|
content: updated,
|
|
@@ -3773,6 +4057,10 @@ const commands = {
|
|
|
3773
4057
|
versionPattern: /^<!-- driftseal-version: (\d+) -->\r?$/m,
|
|
3774
4058
|
replacement: intentBlock,
|
|
3775
4059
|
knownManagedBlocks: [
|
|
4060
|
+
...sourceLanguages.flatMap((source) => [
|
|
4061
|
+
protocolEol(intentProtocolBlock(PROTOCOL_VERSION, source), eol),
|
|
4062
|
+
protocolEol(previousIntentProtocolBlock(11, source), eol),
|
|
4063
|
+
]),
|
|
3776
4064
|
protocolEol(previousIntentProtocolBlock(2), eol),
|
|
3777
4065
|
protocolEol(previousIntentProtocolBlock(3), eol),
|
|
3778
4066
|
protocolEol(previousIntentProtocolBlock(4), eol),
|
|
@@ -3793,6 +4081,10 @@ const commands = {
|
|
|
3793
4081
|
versionPattern: /^<!-- driftseal-decisions-version: (\d+) -->\r?$/m,
|
|
3794
4082
|
replacement: decisionBlock,
|
|
3795
4083
|
knownManagedBlocks: [
|
|
4084
|
+
...sourceLanguages.flatMap((source) => [
|
|
4085
|
+
protocolEol(decisionProtocolBlock(PROTOCOL_VERSION, source), eol),
|
|
4086
|
+
protocolEol(previousDecisionProtocolBlock(11, source), eol),
|
|
4087
|
+
]),
|
|
3796
4088
|
protocolEol(previousDecisionProtocolBlock(2), eol),
|
|
3797
4089
|
protocolEol(previousDecisionProtocolBlock(3), eol),
|
|
3798
4090
|
protocolEol(previousDecisionProtocolBlock(4), eol),
|
|
@@ -3829,6 +4121,8 @@ const commands = {
|
|
|
3829
4121
|
printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
|
|
3830
4122
|
}
|
|
3831
4123
|
|
|
4124
|
+
if (localLog) warnIfDefaultLogsTracked();
|
|
4125
|
+
|
|
3832
4126
|
if (updated === current && !attributes.changed && !driver.changed) {
|
|
3833
4127
|
printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
|
|
3834
4128
|
return { changed: false, target };
|
|
@@ -3882,8 +4176,10 @@ usage:
|
|
|
3882
4176
|
targets: kimi-code (global only), claude-code, codex (prompt only)
|
|
3883
4177
|
driftseal hook prompt|stop [--format plain|claude-code]
|
|
3884
4178
|
emit the reminder a lifecycle hook injects; never blocks
|
|
3885
|
-
driftseal init [--lang <tag>]
|
|
4179
|
+
driftseal init [--lang <tag>] [--local-log]
|
|
4180
|
+
inject protocols into ./AGENTS.md and configure the git merge driver
|
|
3886
4181
|
--lang sets the intent/decision log language (BCP 47, default: en)
|
|
4182
|
+
--local-log keeps the logs local and untracked instead of committing them
|
|
3887
4183
|
driftseal --version | -V print the installed DriftSeal version
|
|
3888
4184
|
driftseal help
|
|
3889
4185
|
|
|
@@ -3913,6 +4209,43 @@ function requestedEndStatus(argv) {
|
|
|
3913
4209
|
return 'completed';
|
|
3914
4210
|
}
|
|
3915
4211
|
|
|
4212
|
+
/**
|
|
4213
|
+
* Flags that consume the following token as their value, keyed by command or
|
|
4214
|
+
* subcommand, mirroring the value-taking entries of each parseArgs spec.
|
|
4215
|
+
*/
|
|
4216
|
+
const VALUE_TAKING_FLAGS = {
|
|
4217
|
+
begin: ['--verify', '-v', '--decision'],
|
|
4218
|
+
end: ['--status', '-s', '--note', '-n', '--verify-result', '-r'],
|
|
4219
|
+
log: ['--last', '-n'],
|
|
4220
|
+
reclaim: ['--reason', '-r', '--older-than'],
|
|
4221
|
+
unreclaim: ['--reason', '-r'],
|
|
4222
|
+
absorb: ['--decisions'],
|
|
4223
|
+
init: ['--lang'],
|
|
4224
|
+
'decision add': ['--context', '-c', '--outcome', '-o', '--status', '-s', '--driver', '--option', '--consequence'],
|
|
4225
|
+
'decision update': ['--status', '-s', '--note', '-n'],
|
|
4226
|
+
'decision list': ['--last', '-n', '--status', '-s'],
|
|
4227
|
+
'hook install': ['--target', '--scope', '--root'],
|
|
4228
|
+
'hook prompt': ['--format'],
|
|
4229
|
+
'hook stop': ['--format'],
|
|
4230
|
+
'mcp install': ['--target', '--scope', '--root'],
|
|
4231
|
+
'skill install': ['--target', '--scope', '--root'],
|
|
4232
|
+
};
|
|
4233
|
+
|
|
4234
|
+
/**
|
|
4235
|
+
* Pre-lock help probe. A bare --help/-h token is help unless it is the value of
|
|
4236
|
+
* a known value-taking flag given without `=`, so a real mutation whose flag
|
|
4237
|
+
* value happens to be --help still enters the locked path and fails in parseArgs.
|
|
4238
|
+
*/
|
|
4239
|
+
function wantsHelpBeforeLock(cmd, rest) {
|
|
4240
|
+
const key = Object.hasOwn(VALUE_TAKING_FLAGS, `${cmd} ${rest[0]}`) ? `${cmd} ${rest[0]}` : cmd;
|
|
4241
|
+
const valueFlags = VALUE_TAKING_FLAGS[key] || [];
|
|
4242
|
+
return rest.some((arg, index) => {
|
|
4243
|
+
if (arg !== '--help' && arg !== '-h') return false;
|
|
4244
|
+
const previous = index > 0 ? rest[index - 1] : null;
|
|
4245
|
+
return previous === null || !valueFlags.includes(previous);
|
|
4246
|
+
});
|
|
4247
|
+
}
|
|
4248
|
+
|
|
3916
4249
|
function mutationResources(cmd, argv) {
|
|
3917
4250
|
if (cmd === 'skill') return [parseSkillInstallRequest(argv).skillsDir];
|
|
3918
4251
|
if (cmd === 'mcp') return [parseMcpInstallRequest(argv).configDir];
|
|
@@ -3940,18 +4273,60 @@ function dispatch(argv) {
|
|
|
3940
4273
|
}
|
|
3941
4274
|
const fn = commands[cmd];
|
|
3942
4275
|
if (!fn) fail(`unknown command: ${cmd} (run: driftseal help)`);
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
4276
|
+
try {
|
|
4277
|
+
// Help must print even while another session holds the mutation lock. The
|
|
4278
|
+
// probe is spec-aware so it never bypasses the lock for a real mutation: a
|
|
4279
|
+
// --help token consumed as a flag value is left for parseArgs to reject.
|
|
4280
|
+
if (wantsHelpBeforeLock(cmd, rest)) return { data: fn(rest), exitCode: 0 };
|
|
4281
|
+
const mutates =
|
|
4282
|
+
['begin', 'end', 'init', 'skill', 'mcp', 'reclaim', 'unreclaim', 'absorb'].includes(cmd) ||
|
|
4283
|
+
(cmd === 'hook' && rest[0] === 'install') ||
|
|
4284
|
+
(cmd === 'decision' && ['add', 'update'].includes(rest[0]));
|
|
4285
|
+
const readsIntentLog =
|
|
4286
|
+
['status', 'log'].includes(cmd) || (cmd === 'hook' && ['prompt', 'stop'].includes(rest[0]));
|
|
4287
|
+
if (mutates || readsIntentLog) {
|
|
4288
|
+
if (readsIntentLog) {
|
|
4289
|
+
let resources;
|
|
4290
|
+
if (cmd === 'hook') {
|
|
4291
|
+
// Hooks read the nearest ancestor log, not the cwd-relative one: lock
|
|
4292
|
+
// the directory of the file the hook will actually read (the park a
|
|
4293
|
+
// writer flushes under that same lock lives in the repo's Git metadata).
|
|
4294
|
+
// With no ancestor log the hook prints nothing, so skip locking — this
|
|
4295
|
+
// also avoids creating a spurious <cwd>/.intent-log.
|
|
4296
|
+
const hookFile = hookLogFile();
|
|
4297
|
+
if (!hookFile) return { data: fn(rest), exitCode: 0 };
|
|
4298
|
+
resources = [path.dirname(hookFile)];
|
|
4299
|
+
} else {
|
|
4300
|
+
resources = [logDir()];
|
|
4301
|
+
}
|
|
4302
|
+
const locked = withMutationLocks(resources, () => fn(rest), {
|
|
4303
|
+
tryWaitMs: READ_ONLY_LOCK_WAIT_MS,
|
|
4304
|
+
});
|
|
4305
|
+
if (locked !== null) {
|
|
4306
|
+
return {
|
|
4307
|
+
data: locked.data,
|
|
4308
|
+
exitCode:
|
|
4309
|
+
locked.data && Number.isInteger(locked.data.exitCode) ? locked.data.exitCode : 0,
|
|
4310
|
+
};
|
|
4311
|
+
}
|
|
4312
|
+
// Degrade to a lock-free read-only read rather than blocking re-anchoring.
|
|
4313
|
+
if (cmd === 'hook') printError(READ_ONLY_NOTICE);
|
|
4314
|
+
else printLine(READ_ONLY_NOTICE);
|
|
4315
|
+
const data = fn(rest, { readOnly: true });
|
|
4316
|
+
return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0, readOnly: true };
|
|
4317
|
+
}
|
|
4318
|
+
const resources = mutationResources(cmd, rest);
|
|
4319
|
+
const data = withMutationLocks(resources, () => fn(rest));
|
|
4320
|
+
return { data, exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0 };
|
|
4321
|
+
}
|
|
4322
|
+
return { data: fn(rest), exitCode: 0 };
|
|
4323
|
+
} catch (err) {
|
|
4324
|
+
if (err instanceof HelpRequested) {
|
|
4325
|
+
printLine(usageFor(err.usageKey) || usageFor(cmd) || 'run: driftseal help');
|
|
4326
|
+
return { data: null, exitCode: 0 };
|
|
4327
|
+
}
|
|
4328
|
+
throw err;
|
|
3953
4329
|
}
|
|
3954
|
-
return { data: fn(rest), exitCode: 0 };
|
|
3955
4330
|
}
|
|
3956
4331
|
|
|
3957
4332
|
function repositoryRoot(root) {
|
|
@@ -3979,7 +4354,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
3979
4354
|
const previousCwd = process.cwd();
|
|
3980
4355
|
const previousIntentHome = process.env.DRIFTSEAL_HOME;
|
|
3981
4356
|
const previousDecisionHome = process.env.DRIFTSEAL_DECISION_HOME;
|
|
3982
|
-
const output = { stdout: '', stderr: '', data: null, exitCode: 0 };
|
|
4357
|
+
const output = { stdout: '', stderr: '', data: null, exitCode: 0, readOnly: false };
|
|
3983
4358
|
const previousOutput = activeOutput;
|
|
3984
4359
|
|
|
3985
4360
|
try {
|
|
@@ -3992,6 +4367,7 @@ function runCommand(argv, { root = process.cwd(), isolateStorage = false, captur
|
|
|
3992
4367
|
const result = dispatch(argv);
|
|
3993
4368
|
output.data = result.data;
|
|
3994
4369
|
output.exitCode = result.exitCode;
|
|
4370
|
+
output.readOnly = result.readOnly === true;
|
|
3995
4371
|
return output;
|
|
3996
4372
|
} catch (err) {
|
|
3997
4373
|
if (capture) {
|
|
@@ -4015,9 +4391,18 @@ function appendFlag(argv, flag, value) {
|
|
|
4015
4391
|
|
|
4016
4392
|
function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
|
|
4017
4393
|
const fixedRoot = repositoryRoot(root);
|
|
4018
|
-
|
|
4394
|
+
let lastReadOnly = false;
|
|
4395
|
+
const call = (argv) => {
|
|
4396
|
+
const output = runCommand(argv, { root: fixedRoot, isolateStorage, capture: true });
|
|
4397
|
+
lastReadOnly = output.readOnly;
|
|
4398
|
+
return output.data;
|
|
4399
|
+
};
|
|
4019
4400
|
return Object.freeze({
|
|
4020
4401
|
root: fixedRoot,
|
|
4402
|
+
/** True when the most recent call fell back to a lock-free read-only snapshot. */
|
|
4403
|
+
get readOnly() {
|
|
4404
|
+
return lastReadOnly;
|
|
4405
|
+
},
|
|
4021
4406
|
status() {
|
|
4022
4407
|
return call(['status']);
|
|
4023
4408
|
},
|