driftseal 1.1.1 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -132,6 +132,7 @@ The v1 server provides:
132
132
  | --- | --- |
133
133
  | `driftseal_status`, `driftseal_log` | Read the current intent and intent history. |
134
134
  | `driftseal_begin`, `driftseal_end` | Open and honestly close a work round. |
135
+ | `driftseal_absorb` | Repair merge collisions or absorb another worktree's logs while remapping colliding IDs. |
135
136
  | `driftseal_reclaim`, `driftseal_unreclaim` | Hide meaningless closed records behind append-only markers, or restore them. |
136
137
  | `driftseal_decision_list`, `driftseal_decision_show` | Find and read MADR records. |
137
138
  | `driftseal_decision_add`, `driftseal_decision_update` | Add selective decisions and reconcile linked ones. |
@@ -139,6 +140,12 @@ The v1 server provides:
139
140
  | `driftseal://intents/recent` | Read the ten most recent intents as a JSON resource. |
140
141
  | `driftseal://decisions` | Read the decision catalog as a JSON resource. |
141
142
 
143
+ `driftseal_absorb` accepts optional incoming intent-log and decision-directory
144
+ paths, an `ours` or `theirs` abandon strategy, and a dry-run mode. Incoming
145
+ paths are read-only sources; all repaired output stays under the repository
146
+ fixed at server startup. The Git merge-driver form remains a CLI-only plumbing
147
+ command.
148
+
142
149
  MCP changes only the execution surface. It does not add policy beyond the
143
150
  repository's `AGENTS.md`, and the companion skill remains limited to discovery
144
151
  and recovery guidance.
package/README.zh-CN.md CHANGED
@@ -128,6 +128,7 @@ v1 server 提供:
128
128
  | --- | --- |
129
129
  | `driftseal_status`, `driftseal_log` | 读取当前 intent 和 intent 历史。 |
130
130
  | `driftseal_begin`, `driftseal_end` | 开启并诚实关闭一轮工作。 |
131
+ | `driftseal_absorb` | 修复 merge 撞号,或吸收另一条 worktree 日志并重编号冲突 ID。 |
131
132
  | `driftseal_reclaim`, `driftseal_unreclaim` | 用 append-only 标记隐藏已无意义的已关闭记录,或将其恢复。 |
132
133
  | `driftseal_decision_list`, `driftseal_decision_show` | 查找并读取 MADR record。 |
133
134
  | `driftseal_decision_add`, `driftseal_decision_update` | 克制地增加 decision,并 reconcile 已关联的 decision。 |
@@ -135,6 +136,10 @@ v1 server 提供:
135
136
  | `driftseal://intents/recent` | 以 JSON resource 读取最近十条 intent。 |
136
137
  | `driftseal://decisions` | 以 JSON resource 读取 decision catalog。 |
137
138
 
139
+ `driftseal_absorb` 可以接收另一份 intent log、decision 目录、`ours` 或 `theirs`
140
+ 放弃策略,以及 dry-run 模式。传入的路径只作为只读来源;修复后的内容仍只会写入
141
+ server 启动时固定的 repository。Git merge driver 形式仍是 CLI 专用的底层命令。
142
+
138
143
  MCP 只替换执行入口,不会在 repository 的 `AGENTS.md` 之外增加 policy;
139
144
  配套 skill 也仍只负责发现与恢复工作流。
140
145
 
@@ -88,6 +88,19 @@ function registerTools(server, api, z) {
88
88
  file: z.string(),
89
89
  });
90
90
  const decisionWithContent = decisionRecord.extend({ content: z.string() });
91
+ const absorbResult = z.object({
92
+ mappings: z.array(
93
+ z.object({
94
+ kind: z.enum(['intent', 'decision']),
95
+ from: z.string(),
96
+ to: z.string(),
97
+ })
98
+ ),
99
+ abandoned: z.string().nullable(),
100
+ copies: z.array(z.string()),
101
+ outputFile: z.string(),
102
+ exitCode: z.number().int(),
103
+ });
91
104
  const closedStatus = z.enum(END_STATUSES);
92
105
  const decisionStatus = z.enum(DECISION_STATUSES);
93
106
  const decisionId = z.string().regex(/^\d+$/, 'decision id must contain only digits');
@@ -181,6 +194,41 @@ function registerTools(server, api, z) {
181
194
  })
182
195
  );
183
196
 
197
+ server.registerTool(
198
+ 'driftseal_absorb',
199
+ {
200
+ title: 'Absorb another DriftSeal lineage',
201
+ description:
202
+ 'Repair the fixed repository after a merge collision or absorb another worktree\'s intent and decision logs, remapping colliding IDs. Omit otherLog to repair the current repository. This rewrites only the fixed repository; incoming paths are read-only sources.',
203
+ inputSchema: {
204
+ otherLog: z
205
+ .string()
206
+ .optional()
207
+ .describe('Incoming events.jsonl path. Relative paths resolve from the fixed repository.'),
208
+ otherDecisions: z
209
+ .string()
210
+ .optional()
211
+ .describe('Incoming decision directory. Relative paths resolve from the fixed repository.'),
212
+ abandon: z
213
+ .enum(['ours', 'theirs'])
214
+ .optional()
215
+ .describe('Side whose open intent to abandon when both lineages have one in progress.'),
216
+ dryRun: z.boolean().default(false),
217
+ },
218
+ outputSchema: { root: z.string(), result: absorbResult },
219
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
220
+ },
221
+ async (input) =>
222
+ guarded(() => {
223
+ const result = api.absorb(input);
224
+ const action = input.dryRun ? 'Absorb dry run' : 'Absorb';
225
+ return success(
226
+ { root: api.root, result },
227
+ `${action} completed with ${result.mappings.length} ID remapping(s).`
228
+ );
229
+ })
230
+ );
231
+
184
232
  server.registerTool(
185
233
  'driftseal_reclaim',
186
234
  {
package/bin/driftseal.js CHANGED
@@ -1686,7 +1686,7 @@ function directoryDigest(directory) {
1686
1686
  function visit(current, relative) {
1687
1687
  const stat = fs.lstatSync(current);
1688
1688
  if (stat.isDirectory()) {
1689
- digest.update(`directory\0${relative}\0${stat.mode & 0o777}\0`);
1689
+ digest.update(`directory\0${relative}\0`);
1690
1690
  for (const name of fs.readdirSync(current).sort()) {
1691
1691
  visit(path.join(current, name), relative ? path.join(relative, name) : name);
1692
1692
  }
@@ -1709,6 +1709,19 @@ function directoryDigest(directory) {
1709
1709
  return digest.digest('hex');
1710
1710
  }
1711
1711
 
1712
+ function preserveRegularFileModes(source, destination) {
1713
+ for (const name of fs.readdirSync(source)) {
1714
+ const sourcePath = path.join(source, name);
1715
+ const destinationPath = path.join(destination, name);
1716
+ const stat = fs.lstatSync(sourcePath);
1717
+ if (stat.isDirectory()) {
1718
+ preserveRegularFileModes(sourcePath, destinationPath);
1719
+ } else if (stat.isFile()) {
1720
+ fs.chmodSync(destinationPath, stat.mode & 0o777);
1721
+ }
1722
+ }
1723
+ }
1724
+
1712
1725
  function installSkill(request) {
1713
1726
  const { force, root, scope, skillDir, skillsDir, target, targetLabel } = request;
1714
1727
  const sourceDir = path.join(__dirname, '..', 'skills', SKILL_NAME);
@@ -1736,6 +1749,7 @@ function installSkill(request) {
1736
1749
  let movedExisting = false;
1737
1750
  try {
1738
1751
  fs.cpSync(sourceDir, temporary, { recursive: true, errorOnExist: true });
1752
+ preserveRegularFileModes(sourceDir, temporary);
1739
1753
  if (existingDigest !== null) {
1740
1754
  fs.renameSync(skillDir, backup);
1741
1755
  movedExisting = true;
@@ -2301,6 +2315,7 @@ function planDecisionAbsorb({ oursEntries, theirsEntries, baseEntries = [], base
2301
2315
  const mappings = [];
2302
2316
  const copies = [];
2303
2317
  const decisionMap = new Map();
2318
+ const hashMap = new Map();
2304
2319
  const usedIds = new Set(oursEntries.map((entry) => entry.id));
2305
2320
  const oursById = new Map();
2306
2321
  const baseById = new Map();
@@ -2377,17 +2392,21 @@ function planDecisionAbsorb({ oursEntries, theirsEntries, baseEntries = [], base
2377
2392
  usedIds.add(newId);
2378
2393
  decisionMap.set(entry.id, newId);
2379
2394
  mappings.push({ kind: 'decision', from: entry.id, to: newId });
2395
+ const rewritten = rewriteDecisionId(theirsContent, newId);
2396
+ const originalHash = contentHash(theirsContent);
2397
+ const rewrittenHash = contentHash(rewritten);
2398
+ if (originalHash !== rewrittenHash) hashMap.set(originalHash, rewrittenHash);
2380
2399
  copies.push({
2381
2400
  fromFile: entry.file,
2382
2401
  toFile: `${newId}-${decisionSlugFromFile(entry.file)}.md`,
2383
- content: rewriteDecisionId(theirsContent, newId),
2402
+ content: rewritten,
2384
2403
  removeFile: entry.file !== ours.file ? entry.file : null,
2385
2404
  });
2386
2405
  }
2387
- return { decisionMap, mappings, copies };
2406
+ return { decisionMap, hashMap, mappings, copies };
2388
2407
  }
2389
2408
 
2390
- function remapEvent(event, intentMap, decisionMap) {
2409
+ function remapEvent(event, intentMap, decisionMap, hashMap = new Map()) {
2391
2410
  const next = { ...event };
2392
2411
  if (intentMap.has(event.id)) next.id = intentMap.get(event.id);
2393
2412
  if (Array.isArray(next.decisions) && next.decisions.length > 0) {
@@ -2400,10 +2419,15 @@ function remapEvent(event, intentMap, decisionMap) {
2400
2419
  const normalized = normalizeDecisionId(next.decisionId);
2401
2420
  if (decisionMap.has(normalized)) next.decisionId = decisionMap.get(normalized);
2402
2421
  }
2422
+ for (const field of ['oldHash', 'newHash', 'fileHash']) {
2423
+ if (typeof next[field] === 'string' && hashMap.has(next[field])) {
2424
+ next[field] = hashMap.get(next[field]);
2425
+ }
2426
+ }
2403
2427
  return next;
2404
2428
  }
2405
2429
 
2406
- function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2430
+ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap, hashMap = new Map()) {
2407
2431
  const intentMap = new Map();
2408
2432
  const mappings = [];
2409
2433
  const used = [...oursUsedEvents];
@@ -2415,14 +2439,14 @@ function remapTheirsRecords(theirsNew, oursUsedEvents, decisionMap) {
2415
2439
  intentMap.set(event.id, newId);
2416
2440
  mappings.push({ kind: 'intent', from: event.id, to: newId });
2417
2441
  }
2418
- event = remapEvent(event, intentMap, decisionMap);
2442
+ event = remapEvent(event, intentMap, decisionMap, hashMap);
2419
2443
  used.push(event);
2420
2444
  return { event };
2421
2445
  });
2422
2446
  return { records, mappings };
2423
2447
  }
2424
2448
 
2425
- function repairDuplicateIntentRecords(records, decisionMap) {
2449
+ function repairDuplicateIntentRecords(records, decisionMap, hashMap = new Map()) {
2426
2450
  const seenBegins = new Set();
2427
2451
  const intentMap = new Map();
2428
2452
  const used = [];
@@ -2440,7 +2464,12 @@ function repairDuplicateIntentRecords(records, decisionMap) {
2440
2464
  } else if (event.type === 'begin') {
2441
2465
  seenBegins.add(event.id);
2442
2466
  }
2443
- const remapped = remapEvent(event, intentMap, incomingSide ? decisionMap : new Map());
2467
+ const remapped = remapEvent(
2468
+ event,
2469
+ intentMap,
2470
+ incomingSide ? decisionMap : new Map(),
2471
+ incomingSide ? hashMap : new Map()
2472
+ );
2444
2473
  const changed = remapped !== event && JSON.stringify(remapped) !== JSON.stringify(event);
2445
2474
  result.push(changed ? { event: remapped } : record);
2446
2475
  used.push(result.at(-1).event);
@@ -2658,7 +2687,8 @@ function absorbFromStreams(ours, theirs, baseRecords, options) {
2658
2687
  const remapped = remapTheirsRecords(
2659
2688
  streams.theirsNew,
2660
2689
  [...streams.base, ...streams.oursNew].map((record) => record.event),
2661
- decisionPlan.decisionMap
2690
+ decisionPlan.decisionMap,
2691
+ decisionPlan.hashMap
2662
2692
  );
2663
2693
  const result = [...streams.base, ...streams.oursNew, ...remapped.records];
2664
2694
  return finishAbsorb({
@@ -2755,7 +2785,11 @@ function absorbLogs(otherFile, otherDecisions, { abandon, dryRun }) {
2755
2785
  baseEntries: gitBase ? gitDecisionEntries(gitBase) : [],
2756
2786
  baseIds: gitBase ? gitDecisionIds(gitBase) : new Set(),
2757
2787
  });
2758
- const repaired = repairDuplicateIntentRecords(loaded.records, decisionPlan.decisionMap);
2788
+ const repaired = repairDuplicateIntentRecords(
2789
+ loaded.records,
2790
+ decisionPlan.decisionMap,
2791
+ decisionPlan.hashMap
2792
+ );
2759
2793
  if (decisionPlan.mappings.length > 0 && !repaired.incomingSide) {
2760
2794
  fail(
2761
2795
  'cannot determine which intent records own the duplicate decision; ' +
@@ -3568,6 +3602,17 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
3568
3602
  if (all) argv.push('--all');
3569
3603
  return call(argv);
3570
3604
  },
3605
+ absorb({ otherLog, otherDecisions, abandon, dryRun = false } = {}) {
3606
+ if (abandon && !['ours', 'theirs'].includes(abandon)) {
3607
+ fail('absorb abandon must be "ours" or "theirs"');
3608
+ }
3609
+ const argv = ['absorb'];
3610
+ if (otherLog) argv.push(String(otherLog));
3611
+ appendFlag(argv, '--decisions', otherDecisions);
3612
+ if (abandon) argv.push(`--abandon-${abandon}`);
3613
+ if (dryRun) argv.push('--dry-run');
3614
+ return call(argv);
3615
+ },
3571
3616
  reclaim({ ids = [], reason, olderThan, force = false, dryRun = false }) {
3572
3617
  const argv = ['reclaim', ...ids.map(String), '--reason', reason];
3573
3618
  appendFlag(argv, '--older-than', olderThan);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftseal",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "Seal intent, verification, and decisions into an auditable workflow for agentic coding",
5
5
  "keywords": [
6
6
  "driftseal",