dld-kit 1.0.0-rc.4 → 1.0.0-rc.5

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/dist/dld.mjs CHANGED
@@ -4000,10 +4000,10 @@ var require_resolve_block_map = __commonJS({
4000
4000
  let offset = bm.offset;
4001
4001
  let commentEnd = null;
4002
4002
  for (const collItem of bm.items) {
4003
- const { start, key, sep: sep5, value } = collItem;
4003
+ const { start, key, sep: sep9, value } = collItem;
4004
4004
  const keyProps = resolveProps.resolveProps(start, {
4005
4005
  indicator: "explicit-key-ind",
4006
- next: key ?? sep5?.[0],
4006
+ next: key ?? sep9?.[0],
4007
4007
  offset,
4008
4008
  onError,
4009
4009
  parentIndent: bm.indent,
@@ -4017,7 +4017,7 @@ var require_resolve_block_map = __commonJS({
4017
4017
  else if ("indent" in key && key.indent !== bm.indent)
4018
4018
  onError(offset, "BAD_INDENT", startColMsg);
4019
4019
  }
4020
- if (!keyProps.anchor && !keyProps.tag && !sep5) {
4020
+ if (!keyProps.anchor && !keyProps.tag && !sep9) {
4021
4021
  commentEnd = keyProps.end;
4022
4022
  if (keyProps.comment) {
4023
4023
  if (map.comment)
@@ -4041,7 +4041,7 @@ var require_resolve_block_map = __commonJS({
4041
4041
  ctx.atKey = false;
4042
4042
  if (utilMapIncludes.mapIncludes(ctx, map.items, keyNode))
4043
4043
  onError(keyStart, "DUPLICATE_KEY", "Map keys must be unique");
4044
- const valueProps = resolveProps.resolveProps(sep5 ?? [], {
4044
+ const valueProps = resolveProps.resolveProps(sep9 ?? [], {
4045
4045
  indicator: "map-value-ind",
4046
4046
  next: value,
4047
4047
  offset: keyNode.range[2],
@@ -4057,7 +4057,7 @@ var require_resolve_block_map = __commonJS({
4057
4057
  if (ctx.options.strict && keyProps.start < valueProps.found.offset - 1024)
4058
4058
  onError(keyNode.range, "KEY_OVER_1024_CHARS", "The : indicator must be at most 1024 chars after the start of an implicit block mapping key");
4059
4059
  }
4060
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep5, null, valueProps, onError);
4060
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : composeEmptyNode(ctx, offset, sep9, null, valueProps, onError);
4061
4061
  if (ctx.schema.compat)
4062
4062
  utilFlowIndentCheck.flowIndentCheck(bm.indent, value, onError);
4063
4063
  offset = valueNode.range[2];
@@ -4148,7 +4148,7 @@ var require_resolve_end = __commonJS({
4148
4148
  let comment = "";
4149
4149
  if (end) {
4150
4150
  let hasSpace = false;
4151
- let sep5 = "";
4151
+ let sep9 = "";
4152
4152
  for (const token of end) {
4153
4153
  const { source, type } = token;
4154
4154
  switch (type) {
@@ -4162,13 +4162,13 @@ var require_resolve_end = __commonJS({
4162
4162
  if (!comment)
4163
4163
  comment = cb;
4164
4164
  else
4165
- comment += sep5 + cb;
4166
- sep5 = "";
4165
+ comment += sep9 + cb;
4166
+ sep9 = "";
4167
4167
  break;
4168
4168
  }
4169
4169
  case "newline":
4170
4170
  if (comment)
4171
- sep5 += source;
4171
+ sep9 += source;
4172
4172
  hasSpace = true;
4173
4173
  break;
4174
4174
  default:
@@ -4211,18 +4211,18 @@ var require_resolve_flow_collection = __commonJS({
4211
4211
  let offset = fc.offset + fc.start.source.length;
4212
4212
  for (let i = 0; i < fc.items.length; ++i) {
4213
4213
  const collItem = fc.items[i];
4214
- const { start, key, sep: sep5, value } = collItem;
4214
+ const { start, key, sep: sep9, value } = collItem;
4215
4215
  const props = resolveProps.resolveProps(start, {
4216
4216
  flow: fcName,
4217
4217
  indicator: "explicit-key-ind",
4218
- next: key ?? sep5?.[0],
4218
+ next: key ?? sep9?.[0],
4219
4219
  offset,
4220
4220
  onError,
4221
4221
  parentIndent: fc.indent,
4222
4222
  startOnNewline: false
4223
4223
  });
4224
4224
  if (!props.found) {
4225
- if (!props.anchor && !props.tag && !sep5 && !value) {
4225
+ if (!props.anchor && !props.tag && !sep9 && !value) {
4226
4226
  if (i === 0 && props.comma)
4227
4227
  onError(props.comma, "UNEXPECTED_TOKEN", `Unexpected , in ${fcName}`);
4228
4228
  else if (i < fc.items.length - 1)
@@ -4276,8 +4276,8 @@ var require_resolve_flow_collection = __commonJS({
4276
4276
  }
4277
4277
  }
4278
4278
  }
4279
- if (!isMap2 && !sep5 && !props.found) {
4280
- const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep5, null, props, onError);
4279
+ if (!isMap2 && !sep9 && !props.found) {
4280
+ const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end, sep9, null, props, onError);
4281
4281
  coll.items.push(valueNode);
4282
4282
  offset = valueNode.range[2];
4283
4283
  if (isBlock(value))
@@ -4289,7 +4289,7 @@ var require_resolve_flow_collection = __commonJS({
4289
4289
  if (isBlock(key))
4290
4290
  onError(keyNode.range, "BLOCK_IN_FLOW", blockMsg);
4291
4291
  ctx.atKey = false;
4292
- const valueProps = resolveProps.resolveProps(sep5 ?? [], {
4292
+ const valueProps = resolveProps.resolveProps(sep9 ?? [], {
4293
4293
  flow: fcName,
4294
4294
  indicator: "map-value-ind",
4295
4295
  next: value,
@@ -4300,8 +4300,8 @@ var require_resolve_flow_collection = __commonJS({
4300
4300
  });
4301
4301
  if (valueProps.found) {
4302
4302
  if (!isMap2 && !props.found && ctx.options.strict) {
4303
- if (sep5)
4304
- for (const st of sep5) {
4303
+ if (sep9)
4304
+ for (const st of sep9) {
4305
4305
  if (st === valueProps.found)
4306
4306
  break;
4307
4307
  if (st.type === "newline") {
@@ -4318,7 +4318,7 @@ var require_resolve_flow_collection = __commonJS({
4318
4318
  else
4319
4319
  onError(valueProps.start, "MISSING_CHAR", `Missing , or : between ${fcName} items`);
4320
4320
  }
4321
- const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep5, null, valueProps, onError) : null;
4321
+ const valueNode = value ? composeNode(ctx, value, valueProps, onError) : valueProps.found ? composeEmptyNode(ctx, valueProps.end, sep9, null, valueProps, onError) : null;
4322
4322
  if (valueNode) {
4323
4323
  if (isBlock(value))
4324
4324
  onError(valueNode.range, "BLOCK_IN_FLOW", blockMsg);
@@ -4498,7 +4498,7 @@ var require_resolve_block_scalar = __commonJS({
4498
4498
  chompStart = i + 1;
4499
4499
  }
4500
4500
  let value = "";
4501
- let sep5 = "";
4501
+ let sep9 = "";
4502
4502
  let prevMoreIndented = false;
4503
4503
  for (let i = 0; i < contentStart; ++i)
4504
4504
  value += lines[i][0].slice(trimIndent) + "\n";
@@ -4515,24 +4515,24 @@ var require_resolve_block_scalar = __commonJS({
4515
4515
  indent = "";
4516
4516
  }
4517
4517
  if (type === Scalar.Scalar.BLOCK_LITERAL) {
4518
- value += sep5 + indent.slice(trimIndent) + content;
4519
- sep5 = "\n";
4518
+ value += sep9 + indent.slice(trimIndent) + content;
4519
+ sep9 = "\n";
4520
4520
  } else if (indent.length > trimIndent || content[0] === " ") {
4521
- if (sep5 === " ")
4522
- sep5 = "\n";
4523
- else if (!prevMoreIndented && sep5 === "\n")
4524
- sep5 = "\n\n";
4525
- value += sep5 + indent.slice(trimIndent) + content;
4526
- sep5 = "\n";
4521
+ if (sep9 === " ")
4522
+ sep9 = "\n";
4523
+ else if (!prevMoreIndented && sep9 === "\n")
4524
+ sep9 = "\n\n";
4525
+ value += sep9 + indent.slice(trimIndent) + content;
4526
+ sep9 = "\n";
4527
4527
  prevMoreIndented = true;
4528
4528
  } else if (content === "") {
4529
- if (sep5 === "\n")
4529
+ if (sep9 === "\n")
4530
4530
  value += "\n";
4531
4531
  else
4532
- sep5 = "\n";
4532
+ sep9 = "\n";
4533
4533
  } else {
4534
- value += sep5 + content;
4535
- sep5 = " ";
4534
+ value += sep9 + content;
4535
+ sep9 = " ";
4536
4536
  prevMoreIndented = false;
4537
4537
  }
4538
4538
  }
@@ -4715,25 +4715,25 @@ var require_resolve_flow_scalar = __commonJS({
4715
4715
  trimBoth = /^[ \t]+|[ \t]+$/g;
4716
4716
  }
4717
4717
  let res = match[1].replace(trimEnd, "");
4718
- let sep5 = " ";
4718
+ let sep9 = " ";
4719
4719
  let pos = line.lastIndex;
4720
4720
  while (match = line.exec(source)) {
4721
4721
  const lm = match[1].replace(trimBoth, "");
4722
4722
  if (lm === "") {
4723
- if (sep5 === "\n")
4724
- res += sep5;
4723
+ if (sep9 === "\n")
4724
+ res += sep9;
4725
4725
  else
4726
- sep5 = "\n";
4726
+ sep9 = "\n";
4727
4727
  } else {
4728
- res += sep5 + lm;
4729
- sep5 = " ";
4728
+ res += sep9 + lm;
4729
+ sep9 = " ";
4730
4730
  }
4731
4731
  pos = line.lastIndex;
4732
4732
  }
4733
4733
  const last = /[ \t]*(.*)/sy;
4734
4734
  last.lastIndex = pos;
4735
4735
  match = last.exec(source);
4736
- return res + sep5 + (match?.[1] ?? "");
4736
+ return res + sep9 + (match?.[1] ?? "");
4737
4737
  }
4738
4738
  function doubleQuotedValue(source, onError) {
4739
4739
  let res = "";
@@ -5543,14 +5543,14 @@ var require_cst_stringify = __commonJS({
5543
5543
  }
5544
5544
  }
5545
5545
  }
5546
- function stringifyItem({ start, key, sep: sep5, value }) {
5546
+ function stringifyItem({ start, key, sep: sep9, value }) {
5547
5547
  let res = "";
5548
5548
  for (const st of start)
5549
5549
  res += st.source;
5550
5550
  if (key)
5551
5551
  res += stringifyToken(key);
5552
- if (sep5)
5553
- for (const st of sep5)
5552
+ if (sep9)
5553
+ for (const st of sep9)
5554
5554
  res += st.source;
5555
5555
  if (value)
5556
5556
  res += stringifyToken(value);
@@ -6717,18 +6717,18 @@ var require_parser = __commonJS({
6717
6717
  if (this.type === "map-value-ind") {
6718
6718
  const prev = getPrevProps(this.peek(2));
6719
6719
  const start = getFirstKeyStartProps(prev);
6720
- let sep5;
6720
+ let sep9;
6721
6721
  if (scalar.end) {
6722
- sep5 = scalar.end;
6723
- sep5.push(this.sourceToken);
6722
+ sep9 = scalar.end;
6723
+ sep9.push(this.sourceToken);
6724
6724
  delete scalar.end;
6725
6725
  } else
6726
- sep5 = [this.sourceToken];
6726
+ sep9 = [this.sourceToken];
6727
6727
  const map = {
6728
6728
  type: "block-map",
6729
6729
  offset: scalar.offset,
6730
6730
  indent: scalar.indent,
6731
- items: [{ start, key: scalar, sep: sep5 }]
6731
+ items: [{ start, key: scalar, sep: sep9 }]
6732
6732
  };
6733
6733
  this.onKeyLine = true;
6734
6734
  this.stack[this.stack.length - 1] = map;
@@ -6881,15 +6881,15 @@ var require_parser = __commonJS({
6881
6881
  } else if (isFlowToken(it.key) && !includesToken(it.sep, "newline")) {
6882
6882
  const start2 = getFirstKeyStartProps(it.start);
6883
6883
  const key = it.key;
6884
- const sep5 = it.sep;
6885
- sep5.push(this.sourceToken);
6884
+ const sep9 = it.sep;
6885
+ sep9.push(this.sourceToken);
6886
6886
  delete it.key;
6887
6887
  delete it.sep;
6888
6888
  this.stack.push({
6889
6889
  type: "block-map",
6890
6890
  offset: this.offset,
6891
6891
  indent: this.indent,
6892
- items: [{ start: start2, key, sep: sep5 }]
6892
+ items: [{ start: start2, key, sep: sep9 }]
6893
6893
  });
6894
6894
  } else if (start.length > 0) {
6895
6895
  it.sep = it.sep.concat(start, this.sourceToken);
@@ -7083,13 +7083,13 @@ var require_parser = __commonJS({
7083
7083
  const prev = getPrevProps(parent);
7084
7084
  const start = getFirstKeyStartProps(prev);
7085
7085
  fixFlowSeqItems(fc);
7086
- const sep5 = fc.end.splice(1, fc.end.length);
7087
- sep5.push(this.sourceToken);
7086
+ const sep9 = fc.end.splice(1, fc.end.length);
7087
+ sep9.push(this.sourceToken);
7088
7088
  const map = {
7089
7089
  type: "block-map",
7090
7090
  offset: fc.offset,
7091
7091
  indent: fc.indent,
7092
- items: [{ start, key: fc, sep: sep5 }]
7092
+ items: [{ start, key: fc, sep: sep9 }]
7093
7093
  };
7094
7094
  this.onKeyLine = true;
7095
7095
  this.stack[this.stack.length - 1] = map;
@@ -7372,10 +7372,11 @@ import { createInterface } from "node:readline/promises";
7372
7372
  import { fileURLToPath } from "node:url";
7373
7373
 
7374
7374
  // package.json
7375
- var version = "1.0.0-rc.4";
7375
+ var version = "1.0.0-rc.5";
7376
7376
 
7377
7377
  // src/core/errors.ts
7378
7378
  var EXIT_ERROR = 1;
7379
+ var EXIT_USAGE = 2;
7379
7380
  var DldError = class extends Error {
7380
7381
  exitCode;
7381
7382
  constructor(message, exitCode = EXIT_ERROR) {
@@ -7423,7 +7424,6 @@ var GhCommandError = class extends DldError {
7423
7424
  // src/cli/command.ts
7424
7425
  import { parseArgs } from "node:util";
7425
7426
  var EXIT_OK = 0;
7426
- var EXIT_USAGE = 2;
7427
7427
  var UsageError = class extends DldError {
7428
7428
  constructor(message) {
7429
7429
  super(message, EXIT_USAGE);
@@ -7455,121 +7455,46 @@ function requestsHelp(config) {
7455
7455
  return tokens.some((token) => token.kind === "option" && token.name === "help");
7456
7456
  }
7457
7457
 
7458
- // src/core/project.ts
7459
- import { join as join2 } from "node:path";
7458
+ // src/core/decision-edits.ts
7459
+ import { basename as basename4, join as join3, relative as relative3, sep as sep2 } from "node:path";
7460
7460
 
7461
- // src/core/config.ts
7462
- var import_yaml = __toESM(require_dist(), 1);
7463
- import { join } from "node:path";
7464
- var CONFIG_FILE = "dld.config.yaml";
7465
- function loadConfig(ctx, root) {
7466
- const path = join(root, CONFIG_FILE);
7467
- if (!ctx.fs.exists(path)) {
7468
- throw new DldError(`${CONFIG_FILE} not found. Run /dld-init first.`);
7469
- }
7470
- return parseConfig(ctx.fs.readFile(path));
7471
- }
7472
- function parseConfig(text) {
7473
- const doc = (0, import_yaml.parseDocument)(text, { logLevel: "silent" });
7474
- const problem = doc.errors[0] ?? doc.warnings[0];
7475
- if (problem !== void 0) throw invalid(`not valid YAML: ${problem.message}`);
7476
- const raw = doc.toJS();
7477
- if (!isRecord(raw)) throw invalid("expected a mapping of keys to values");
7478
- const mode = raw.mode;
7479
- if (mode !== "flat" && mode !== "namespaced") {
7480
- throw invalid("'mode' must be 'flat' or 'namespaced'");
7481
- }
7482
- const namespaces = optionalStringList(raw.namespaces, "namespaces");
7483
- if (mode === "namespaced" && namespaces.length === 0) {
7484
- throw invalid("'namespaces' must list at least one namespace when 'mode' is 'namespaced'");
7485
- }
7486
- return {
7487
- decisionsDir: requiredString(raw.decisions_dir, "decisions_dir"),
7488
- mode,
7489
- namespaces,
7490
- annotationPrefix: optionalString(raw.annotation_prefix, "annotation_prefix") ?? "@decision",
7491
- // @decision(DL-023)
7492
- annotationExclude: repoRelativePatterns(raw.annotation_exclude, "annotation_exclude"),
7493
- implementReview: optionalBoolean(raw.implement_review, "implement_review") ?? true,
7494
- snapshotArtifacts: snapshotArtifacts(raw.snapshot_artifacts)
7495
- };
7496
- }
7497
- function invalid(message) {
7498
- return new DldError(`${CONFIG_FILE}: ${message}`);
7499
- }
7500
- function isRecord(value) {
7501
- return typeof value === "object" && value !== null && !Array.isArray(value);
7502
- }
7503
- function requiredString(value, key) {
7504
- const result = optionalString(value, key);
7505
- if (result === void 0) throw invalid(`'${key}' is required`);
7506
- return result;
7507
- }
7508
- function optionalString(value, key) {
7509
- if (value === void 0 || value === null) return void 0;
7510
- if (typeof value !== "string" || value.trim() === "") {
7511
- throw invalid(`'${key}' must be a non-empty string`);
7512
- }
7513
- return value;
7514
- }
7515
- function optionalBoolean(value, key) {
7516
- if (value === void 0 || value === null) return void 0;
7517
- if (typeof value !== "boolean") throw invalid(`'${key}' must be true or false`);
7518
- return value;
7519
- }
7520
- function optionalStringList(value, key) {
7521
- if (value === void 0 || value === null) return [];
7522
- if (!Array.isArray(value)) throw invalid(`'${key}' must be a list of strings`);
7523
- return value.map((item) => {
7524
- if (typeof item !== "string" || item.trim() === "") {
7525
- throw invalid(`'${key}' must be a list of non-empty strings`);
7526
- }
7527
- return item;
7528
- });
7461
+ // src/core/files.ts
7462
+ import { basename, dirname, join } from "node:path";
7463
+ var TEMP_PREFIX = ".dld-tmp-";
7464
+ function tempPathFor(path) {
7465
+ const suffix = Math.random().toString(36).slice(2, 10);
7466
+ return join(dirname(path), `${TEMP_PREFIX}${basename(path)}.${suffix}`);
7529
7467
  }
7530
- function repoRelativePatterns(value, key) {
7531
- const patterns = optionalStringList(value, key);
7532
- const outside = patterns.find((pattern) => /^([/\\]|[A-Za-z]:|\.\.([/\\]|$))/.test(pattern));
7533
- if (outside !== void 0) {
7534
- throw invalid(`'${key}' patterns must be relative to the repository root, got '${outside}'`);
7468
+ function writeFileAtomic(ctx, path, content, mode) {
7469
+ const temp = tempPathFor(path);
7470
+ try {
7471
+ ctx.fs.writeFile(temp, content);
7472
+ if (mode !== void 0) ctx.fs.chmod(temp, mode);
7473
+ ctx.fs.rename(temp, path);
7474
+ } catch (error) {
7475
+ removeQuietly(ctx, temp);
7476
+ throw error;
7535
7477
  }
7536
- return patterns;
7537
- }
7538
- function snapshotArtifacts(value) {
7539
- const key = "snapshot_artifacts";
7540
- if (value === void 0 || value === null) return [];
7541
- if (!Array.isArray(value)) throw invalid(`'${key}' must be a list of {title, prompt} entries`);
7542
- return value.map((item) => {
7543
- if (!isRecord(item)) throw invalid(`'${key}' entries must have 'title' and 'prompt'`);
7544
- return {
7545
- title: requiredString(item.title, `${key}[].title`),
7546
- prompt: requiredString(item.prompt, `${key}[].prompt`)
7547
- };
7548
- });
7549
7478
  }
7550
-
7551
- // src/core/project.ts
7552
- function findProjectRoot(ctx) {
7479
+ function createFileExclusive(ctx, path, content, existsMessage) {
7480
+ const temp = tempPathFor(path);
7553
7481
  try {
7554
- return ctx.git(["rev-parse", "--show-toplevel"]).trim();
7482
+ ctx.fs.writeFile(temp, content);
7483
+ ctx.fs.link(temp, path);
7555
7484
  } catch (error) {
7556
- if (error instanceof GitCommandError) throw new DldError("not a git repository");
7485
+ if (error instanceof FsError && error.code === "EEXIST") throw new DldError(existsMessage);
7557
7486
  throw error;
7487
+ } finally {
7488
+ removeQuietly(ctx, temp);
7558
7489
  }
7559
7490
  }
7560
- function resolvePaths(root, config) {
7561
- const decisionsDir = join2(root, config.decisionsDir);
7562
- return { root, decisionsDir, recordsDir: join2(decisionsDir, "records") };
7563
- }
7564
- function loadProject(ctx) {
7565
- const root = findProjectRoot(ctx);
7566
- const config = loadConfig(ctx, root);
7567
- return { config, paths: resolvePaths(root, config) };
7491
+ function removeQuietly(ctx, path) {
7492
+ try {
7493
+ ctx.fs.remove(path);
7494
+ } catch {
7495
+ }
7568
7496
  }
7569
7497
 
7570
- // src/core/snapshot.ts
7571
- import { basename as basename3, join as join6, relative as relative3 } from "node:path";
7572
-
7573
7498
  // src/core/git.ts
7574
7499
  import { relative, sep } from "node:path";
7575
7500
  function gitAt(ctx, root) {
@@ -7599,8 +7524,8 @@ function resolveStateCommit(git, value) {
7599
7524
  }
7600
7525
 
7601
7526
  // src/core/records.ts
7602
- var import_yaml2 = __toESM(require_dist(), 1);
7603
- import { basename, join as join3 } from "node:path";
7527
+ var import_yaml = __toESM(require_dist(), 1);
7528
+ import { basename as basename2, join as join2, relative as relative2 } from "node:path";
7604
7529
  var STATUSES = ["proposed", "accepted", "deprecated", "superseded"];
7605
7530
  var RECORD_FILE = /^DL-(\d+)\.md$/;
7606
7531
  var DECISION_ID = /^DL-\d+$/;
@@ -7613,7 +7538,7 @@ function listRecordFiles(ctx, recordsDir) {
7613
7538
  const walk = (dir) => {
7614
7539
  const entries = ctx.fs.readDir(dir).sort((a, b) => a.name < b.name ? -1 : 1);
7615
7540
  for (const entry of entries) {
7616
- const path = join3(dir, entry.name);
7541
+ const path = join2(dir, entry.name);
7617
7542
  if (entry.isDirectory) walk(path);
7618
7543
  else if (entry.isFile && RECORD_FILE.test(entry.name)) found.push(path);
7619
7544
  }
@@ -7622,12 +7547,25 @@ function listRecordFiles(ctx, recordsDir) {
7622
7547
  return found;
7623
7548
  }
7624
7549
  function recordNumber(path) {
7625
- const digits = RECORD_FILE.exec(basename(path))?.[1];
7550
+ const digits = RECORD_FILE.exec(basename2(path))?.[1];
7626
7551
  if (digits === void 0) throw new Error(`not a decision record file: ${path}`);
7627
7552
  return Number.parseInt(digits, 10);
7628
7553
  }
7629
- function findRecordFile(ctx, recordsDir, id) {
7630
- return listRecordFiles(ctx, recordsDir).find((path) => basename(path) === `${id}.md`);
7554
+ function findRecordFile(ctx, recordsDir, id, root) {
7555
+ const matches = listRecordFiles(ctx, recordsDir).filter((path) => basename2(path) === `${id}.md`);
7556
+ if (matches.length > 1) {
7557
+ const shown = matches.map((path) => root === void 0 ? path : relative2(root, path));
7558
+ throw new DldError(`decision ${id} is in more than one file: ${shown.join(", ")}`);
7559
+ }
7560
+ return matches[0];
7561
+ }
7562
+ function duplicateRecordFiles(ctx, recordsDir) {
7563
+ const byNumber = /* @__PURE__ */ new Map();
7564
+ for (const path of listRecordFiles(ctx, recordsDir)) {
7565
+ const number = recordNumber(path);
7566
+ byNumber.set(number, [...byNumber.get(number) ?? [], path]);
7567
+ }
7568
+ return [...byNumber.entries()].filter(([, paths]) => paths.length > 1).sort(([a], [b]) => a - b).map(([, paths]) => paths);
7631
7569
  }
7632
7570
  var isDelimiter = (line) => line === "---" || line === "---\r";
7633
7571
  function frontmatterBlock(text) {
@@ -7642,15 +7580,21 @@ function recordBody(text) {
7642
7580
  const block = frontmatterBlock(text);
7643
7581
  return block === void 0 ? text : block.lines.slice(block.end + 1).join("\n");
7644
7582
  }
7583
+ function recordHead(text) {
7584
+ const block = frontmatterBlock(text);
7585
+ if (block === void 0) return void 0;
7586
+ return `${block.lines.slice(0, block.end + 1).join("\n")}
7587
+ `;
7588
+ }
7645
7589
  function parseRecord(text, source) {
7646
7590
  const invalid2 = (message) => new DldError(`${source}: ${message}`);
7647
7591
  const block = frontmatterBlock(text);
7648
7592
  if (block === void 0) throw invalid2("no frontmatter between --- lines");
7649
7593
  const frontmatter = block.lines.slice(block.start + 1, block.end).map((line) => line.replace(/\r$/, ""));
7650
- const doc = (0, import_yaml2.parseDocument)(frontmatter.join("\n"), { logLevel: "silent" });
7594
+ const doc = (0, import_yaml.parseDocument)(frontmatter.join("\n"), { logLevel: "silent" });
7651
7595
  const problem = doc.errors[0] ?? doc.warnings[0];
7652
7596
  const raw = problem === void 0 ? doc.toJS() : legacyFields(frontmatter);
7653
- if (!isRecord2(raw)) throw invalid2("frontmatter must be a mapping");
7597
+ if (!isRecord(raw)) throw invalid2("frontmatter must be a mapping");
7654
7598
  const string = (key) => {
7655
7599
  const value = raw[key];
7656
7600
  if (value === void 0 || value === null) return void 0;
@@ -7707,13 +7651,13 @@ function references(value, invalid2) {
7707
7651
  if (value === void 0 || value === null) return [];
7708
7652
  if (!Array.isArray(value)) throw invalid2("'references' must be a list");
7709
7653
  return value.map((item) => {
7710
- if (!isRecord2(item) || typeof item.path !== "string") {
7654
+ if (!isRecord(item) || typeof item.path !== "string") {
7711
7655
  throw invalid2("'references' entries must have a 'path'");
7712
7656
  }
7713
7657
  return typeof item.symbol === "string" ? { path: item.path, symbol: item.symbol } : { path: item.path };
7714
7658
  });
7715
7659
  }
7716
- function isRecord2(value) {
7660
+ function isRecord(value) {
7717
7661
  return typeof value === "object" && value !== null && !Array.isArray(value);
7718
7662
  }
7719
7663
  function setStatus(text, status, source) {
@@ -7779,270 +7723,111 @@ function formatTimestamp(date) {
7779
7723
  return `${date.toISOString().slice(0, 19)}Z`;
7780
7724
  }
7781
7725
 
7726
+ // src/core/reindex.ts
7727
+ import { basename as basename3 } from "node:path";
7728
+
7782
7729
  // src/core/ids.ts
7783
7730
  function formatId(n) {
7784
7731
  return `DL-${String(n).padStart(3, "0")}`;
7785
7732
  }
7733
+ function isCanonicalId(id) {
7734
+ return /^DL-(\d{3}|[1-9]\d{3,})$/.test(id);
7735
+ }
7736
+ function canonicalSpelling(id) {
7737
+ const digits = /^dl-(\d+)$/i.exec(id)?.[1];
7738
+ if (digits === void 0) return void 0;
7739
+ const number = Number(digits);
7740
+ return Number.isSafeInteger(number) ? formatId(number) : void 0;
7741
+ }
7786
7742
  function nextId(ctx, recordsDir) {
7787
7743
  const highest = listRecordFiles(ctx, recordsDir).map(recordNumber).reduce((max, n) => Math.max(max, n), 0);
7788
7744
  return formatId(highest + 1);
7789
7745
  }
7790
7746
 
7791
- // src/core/state.ts
7792
- var import_yaml3 = __toESM(require_dist(), 1);
7793
- import { join as join5, relative as relative2 } from "node:path";
7794
-
7795
- // src/core/files.ts
7796
- import { basename as basename2, dirname, join as join4 } from "node:path";
7797
- var TEMP_PREFIX = ".dld-tmp-";
7798
- function tempPathFor(path) {
7799
- const suffix = Math.random().toString(36).slice(2, 10);
7800
- return join4(dirname(path), `${TEMP_PREFIX}${basename2(path)}.${suffix}`);
7801
- }
7802
- function writeFileAtomic(ctx, path, content, mode) {
7803
- const temp = tempPathFor(path);
7747
+ // src/core/open-prs.ts
7748
+ var GITHUB_REMOTE = /github\.com[:/]/;
7749
+ var PR_LIMIT = "100";
7750
+ var COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
7751
+ function openPrIds(ctx, paths, base) {
7752
+ const skipped = (reason) => ({ claims: [], skipped: reason });
7804
7753
  try {
7805
- ctx.fs.writeFile(temp, content);
7806
- if (mode !== void 0) ctx.fs.chmod(temp, mode);
7807
- ctx.fs.rename(temp, path);
7754
+ ctx.gh(["--version"]);
7808
7755
  } catch (error) {
7809
- removeQuietly(ctx, temp);
7756
+ if (error instanceof ToolNotFoundError) return skipped("gh CLI not installed");
7757
+ if (!(error instanceof GhCommandError)) throw error;
7758
+ }
7759
+ const git = gitAt(ctx, paths.root);
7760
+ if (!GITHUB_REMOTE.test(gitOrEmpty(git, "remote", "get-url", "origin"))) {
7761
+ return skipped("origin is not a GitHub remote");
7762
+ }
7763
+ try {
7764
+ ctx.gh(["auth", "status"]);
7765
+ } catch (error) {
7766
+ if (error instanceof GhCommandError) return skipped("gh not authenticated");
7810
7767
  throw error;
7811
7768
  }
7812
- }
7813
- function createFileExclusive(ctx, path, content, existsMessage) {
7814
- const temp = tempPathFor(path);
7769
+ const prBase = base.startsWith("origin/") ? base.slice("origin/".length) : base;
7770
+ let output;
7815
7771
  try {
7816
- ctx.fs.writeFile(temp, content);
7817
- ctx.fs.link(temp, path);
7772
+ output = ctx.gh([
7773
+ "pr",
7774
+ "list",
7775
+ "--state",
7776
+ "open",
7777
+ `--base=${prBase}`,
7778
+ "--json",
7779
+ "files,headRefName,headRefOid,isCrossRepository",
7780
+ "--limit",
7781
+ PR_LIMIT
7782
+ ]);
7818
7783
  } catch (error) {
7819
- if (error instanceof FsError && error.code === "EEXIST") throw new DldError(existsMessage);
7784
+ if (error instanceof GhCommandError) {
7785
+ return skipped(`gh pr list failed: ${firstLine(error.stderr) || "no error output"}`);
7786
+ }
7820
7787
  throw error;
7821
- } finally {
7822
- removeQuietly(ctx, temp);
7823
7788
  }
7789
+ const prs = parsePrList(output);
7790
+ if (prs === void 0) return skipped("gh pr list returned unexpected output");
7791
+ const current = gitOrEmpty(git, "rev-parse", "--abbrev-ref", "HEAD").trim();
7792
+ const prefix = `${recordsPathspec(paths)}/`;
7793
+ const claims = [];
7794
+ for (const pr of prs) {
7795
+ if (!pr.isCrossRepository && pr.headRefName === current) continue;
7796
+ for (const path of pr.files) {
7797
+ if (!path.startsWith(prefix)) continue;
7798
+ for (const id of path.match(DECISION_MENTION) ?? []) claims.push({ id, head: pr.headRefOid });
7799
+ }
7800
+ }
7801
+ return { claims };
7824
7802
  }
7825
- function removeQuietly(ctx, path) {
7803
+ function parsePrList(output) {
7804
+ let value;
7826
7805
  try {
7827
- ctx.fs.remove(path);
7806
+ value = JSON.parse(output);
7828
7807
  } catch {
7808
+ return void 0;
7829
7809
  }
7830
- }
7831
-
7832
- // src/core/state.ts
7833
- var STATE_FILE = ".dld-state.yaml";
7834
- function statePath(paths) {
7835
- return join5(paths.decisionsDir, STATE_FILE);
7836
- }
7837
- function loadStateDocument(ctx, paths) {
7838
- const path = statePath(paths);
7839
- if (!ctx.fs.exists(path)) return new import_yaml3.Document(void 0, { schema: "failsafe" });
7840
- const source = relative2(paths.root, path);
7841
- const doc = (0, import_yaml3.parseDocument)(ctx.fs.readFile(path), { schema: "failsafe", logLevel: "error" });
7842
- const problem = doc.errors[0] ?? doc.warnings[0];
7843
- if (problem !== void 0) throw new DldError(`${source}: not valid YAML: ${problem.message}`);
7844
- if (doc.contents !== null && !(0, import_yaml3.isMap)(doc.contents)) {
7845
- throw new DldError(`${source}: expected a mapping of sections`);
7810
+ if (!Array.isArray(value)) return void 0;
7811
+ const prs = [];
7812
+ for (const item of value) {
7813
+ if (!isObject(item)) return void 0;
7814
+ const files = Array.isArray(item.files) ? item.files : [];
7815
+ prs.push({
7816
+ headRefName: typeof item.headRefName === "string" ? item.headRefName : void 0,
7817
+ headRefOid: typeof item.headRefOid === "string" && COMMIT_ID.test(item.headRefOid) ? item.headRefOid : void 0,
7818
+ isCrossRepository: item.isCrossRepository === true,
7819
+ files: files.flatMap(
7820
+ (file) => isObject(file) && typeof file.path === "string" ? [file.path] : []
7821
+ )
7822
+ });
7846
7823
  }
7847
- return doc;
7848
- }
7849
- function readStateSection(ctx, paths, section) {
7850
- const value = loadStateDocument(ctx, paths).toJS()?.[section];
7851
- return typeof value === "object" && value !== null && !Array.isArray(value) ? { ...value } : void 0;
7824
+ return prs;
7852
7825
  }
7853
- function writeStateSection(ctx, paths, section, value) {
7854
- const doc = loadStateDocument(ctx, paths);
7855
- doc.set(section, value);
7856
- writeFileAtomic(ctx, statePath(paths), String(doc));
7826
+ function isObject(value) {
7827
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7857
7828
  }
7858
- function stateString(section, key) {
7859
- const value = section?.[key];
7860
- return typeof value === "string" && value !== "" ? value : void 0;
7861
- }
7862
- function shortHead(ctx, root) {
7863
- try {
7864
- return ctx.git(["-C", root, "rev-parse", "--short", "HEAD"]).trim();
7865
- } catch (error) {
7866
- if (error instanceof GitCommandError) return "unknown";
7867
- throw error;
7868
- }
7869
- }
7870
-
7871
- // src/core/snapshot.ts
7872
- var DECISION_BOUNDARY = "===DLD_DECISION_BOUNDARY===";
7873
- var BUILT_IN_ARTIFACTS = ["SNAPSHOT.md", "OVERVIEW.md"];
7874
- function readRecords(ctx, { paths }) {
7875
- return listRecordFiles(ctx, paths.recordsDir).map((path) => {
7876
- const text = ctx.fs.readFile(path);
7877
- const record = parseRecord(text, relative3(paths.root, path));
7878
- return { path, number: recordNumber(path), text, accepted: record.status === "accepted" };
7879
- }).sort((a, b) => a.number - b.number);
7880
- }
7881
- function collectActiveDecisions(ctx, project) {
7882
- if (!ctx.fs.isDirectory(project.paths.recordsDir)) {
7883
- throw new DldError(`records directory not found at ${project.paths.recordsDir}`);
7884
- }
7885
- return readRecords(ctx, project).filter((record) => record.accepted).map((record) => record.text).join(`${DECISION_BOUNDARY}
7886
- `);
7887
- }
7888
- function detectSnapshotChanges(ctx, project) {
7889
- const { paths } = project;
7890
- const state = readStateSection(ctx, paths, "snapshot");
7891
- if (state === void 0) return { mode: "full" };
7892
- if (BUILT_IN_ARTIFACTS.some((name) => !ctx.fs.exists(join6(paths.decisionsDir, name)))) {
7893
- return { mode: "full" };
7894
- }
7895
- const included = stateString(state, "decisions_included");
7896
- if (included === void 0 || !/^\d+$/.test(included)) return { mode: "full" };
7897
- const includedNumber = Number.parseInt(included, 10);
7898
- const newDecisions = readRecords(ctx, project).filter((record) => record.number > includedNumber && record.accepted).map((record) => formatId(record.number));
7899
- const git = gitAt(ctx, paths.root);
7900
- let stored = stateString(state, "commit_hash");
7901
- const lastRun = stateString(state, "last_run");
7902
- if ((stored === void 0 || stored === "unknown") && lastRun !== void 0) {
7903
- stored = gitOrEmpty(git, "log", `--until=${lastRun}`, "--format=%h", "-1").trim() || void 0;
7904
- }
7905
- const commit2 = resolveStateCommit(git, stored);
7906
- let modifiedDecisions = [];
7907
- let commitRange = "";
7908
- if (commit2 !== void 0 && commit2 !== shortHead(ctx, paths.root)) {
7909
- commitRange = `${commit2}..HEAD`;
7910
- const numbers = nulSeparated(
7911
- gitOrEmpty(git, "diff", "-z", "--name-only", commitRange, "--", recordsPathspec(paths))
7912
- ).map((path) => basename3(path)).filter((name) => RECORD_FILE.test(name)).map((name) => recordNumber(name)).filter((number) => number <= includedNumber);
7913
- modifiedDecisions = [...new Set(numbers)].sort((a, b) => a - b).map(formatId);
7914
- }
7915
- return { mode: "incremental", newDecisions, modifiedDecisions, commitRange };
7916
- }
7917
- function formatSnapshotChanges(changes) {
7918
- if (changes.mode === "full") return "mode: full\n";
7919
- return [
7920
- "mode: incremental",
7921
- `new_decisions: ${changes.newDecisions.join(", ")}`,
7922
- `modified_decisions: ${changes.modifiedDecisions.join(", ")}`,
7923
- `commit_range: ${changes.commitRange}`,
7924
- ""
7925
- ].join("\n");
7926
- }
7927
- function updateSnapshotState(ctx, project, customArtifacts) {
7928
- const { paths } = project;
7929
- const timestamp = formatTimestamp(ctx.now());
7930
- const commit2 = shortHead(ctx, paths.root);
7931
- const highest = readRecords(ctx, project).filter((record) => record.accepted).reduce((max, record) => Math.max(max, record.number), 0);
7932
- const artifacts = /* @__PURE__ */ Object.create(null);
7933
- for (const name of [...BUILT_IN_ARTIFACTS, ...customArtifacts]) artifacts[name] = timestamp;
7934
- writeStateSection(ctx, paths, "snapshot", {
7935
- last_run: timestamp,
7936
- commit_hash: commit2,
7937
- decisions_included: String(highest),
7938
- artifacts
7939
- });
7940
- return { timestamp, commit: commit2, highest };
7941
- }
7942
-
7943
- // src/cli/commands/collect-active-decisions.ts
7944
- var collectActiveDecisionsCommand = {
7945
- name: "collect-active-decisions",
7946
- summary: "Print every accepted decision record",
7947
- internal: true,
7948
- usage: "Usage: dld collect-active-decisions\n\nPrint each accepted record in ID order, separated by ===DLD_DECISION_BOUNDARY=== lines.\n",
7949
- run(args, io, ctx) {
7950
- parseCommandArgs({ args: [...args], options: {} });
7951
- io.stdout(collectActiveDecisions(ctx, loadProject(ctx)));
7952
- return EXIT_OK;
7953
- }
7954
- };
7955
-
7956
- // src/core/commit-reindex.ts
7957
- import { join as join7, posix as posix2 } from "node:path";
7958
-
7959
- // src/core/reindex.ts
7960
- import { basename as basename4 } from "node:path";
7961
-
7962
- // src/core/open-prs.ts
7963
- var GITHUB_REMOTE = /github\.com[:/]/;
7964
- var PR_LIMIT = "100";
7965
- var COMMIT_ID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
7966
- function openPrIds(ctx, paths, base) {
7967
- const skipped = (reason) => ({ claims: [], skipped: reason });
7968
- try {
7969
- ctx.gh(["--version"]);
7970
- } catch (error) {
7971
- if (error instanceof ToolNotFoundError) return skipped("gh CLI not installed");
7972
- if (!(error instanceof GhCommandError)) throw error;
7973
- }
7974
- const git = gitAt(ctx, paths.root);
7975
- if (!GITHUB_REMOTE.test(gitOrEmpty(git, "remote", "get-url", "origin"))) {
7976
- return skipped("origin is not a GitHub remote");
7977
- }
7978
- try {
7979
- ctx.gh(["auth", "status"]);
7980
- } catch (error) {
7981
- if (error instanceof GhCommandError) return skipped("gh not authenticated");
7982
- throw error;
7983
- }
7984
- const prBase = base.startsWith("origin/") ? base.slice("origin/".length) : base;
7985
- let output;
7986
- try {
7987
- output = ctx.gh([
7988
- "pr",
7989
- "list",
7990
- "--state",
7991
- "open",
7992
- `--base=${prBase}`,
7993
- "--json",
7994
- "files,headRefName,headRefOid,isCrossRepository",
7995
- "--limit",
7996
- PR_LIMIT
7997
- ]);
7998
- } catch (error) {
7999
- if (error instanceof GhCommandError) {
8000
- return skipped(`gh pr list failed: ${firstLine(error.stderr) || "no error output"}`);
8001
- }
8002
- throw error;
8003
- }
8004
- const prs = parsePrList(output);
8005
- if (prs === void 0) return skipped("gh pr list returned unexpected output");
8006
- const current = gitOrEmpty(git, "rev-parse", "--abbrev-ref", "HEAD").trim();
8007
- const prefix = `${recordsPathspec(paths)}/`;
8008
- const claims = [];
8009
- for (const pr of prs) {
8010
- if (!pr.isCrossRepository && pr.headRefName === current) continue;
8011
- for (const path of pr.files) {
8012
- if (!path.startsWith(prefix)) continue;
8013
- for (const id of path.match(DECISION_MENTION) ?? []) claims.push({ id, head: pr.headRefOid });
8014
- }
8015
- }
8016
- return { claims };
8017
- }
8018
- function parsePrList(output) {
8019
- let value;
8020
- try {
8021
- value = JSON.parse(output);
8022
- } catch {
8023
- return void 0;
8024
- }
8025
- if (!Array.isArray(value)) return void 0;
8026
- const prs = [];
8027
- for (const item of value) {
8028
- if (!isObject(item)) return void 0;
8029
- const files = Array.isArray(item.files) ? item.files : [];
8030
- prs.push({
8031
- headRefName: typeof item.headRefName === "string" ? item.headRefName : void 0,
8032
- headRefOid: typeof item.headRefOid === "string" && COMMIT_ID.test(item.headRefOid) ? item.headRefOid : void 0,
8033
- isCrossRepository: item.isCrossRepository === true,
8034
- files: files.flatMap(
8035
- (file) => isObject(file) && typeof file.path === "string" ? [file.path] : []
8036
- )
8037
- });
8038
- }
8039
- return prs;
8040
- }
8041
- function isObject(value) {
8042
- return typeof value === "object" && value !== null && !Array.isArray(value);
8043
- }
8044
- function firstLine(text) {
8045
- return text.split("\n", 1)[0]?.trim() ?? "";
7829
+ function firstLine(text) {
7830
+ return text.split("\n", 1)[0]?.trim() ?? "";
8046
7831
  }
8047
7832
 
8048
7833
  // src/core/reindex.ts
@@ -8051,8 +7836,20 @@ var idNumber = (id) => Number.parseInt(id.slice(3), 10);
8051
7836
  function compareIds(a, b) {
8052
7837
  return idNumber(a) - idNumber(b) || (a < b ? -1 : a > b ? 1 : 0);
8053
7838
  }
8054
- function resolveBase(ctx) {
7839
+ function resolveBase(ctx, baseBranch) {
8055
7840
  const git = (...args) => ctx.git(args);
7841
+ const tried = [];
7842
+ const firstExisting = (...refs) => refs.find((ref) => {
7843
+ tried.push(ref);
7844
+ return gitOrEmpty(git, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`) !== "";
7845
+ });
7846
+ if (baseBranch !== void 0) {
7847
+ const found2 = firstExisting(`origin/${baseBranch}`, baseBranch);
7848
+ if (found2 !== void 0) return found2;
7849
+ throw new DldError(
7850
+ `base_branch '${baseBranch}' not found (tried ${tried.join(", ")}). Fetch first, or fix base_branch in dld.config.yaml.`
7851
+ );
7852
+ }
8056
7853
  const current = gitOrEmpty(git, "rev-parse", "--abbrev-ref", "HEAD").trim();
8057
7854
  const upstream = gitOrEmpty(
8058
7855
  git,
@@ -8061,12 +7858,26 @@ function resolveBase(ctx) {
8061
7858
  "--symbolic-full-name",
8062
7859
  "@{upstream}"
8063
7860
  ).trim();
7861
+ const candidates = [];
8064
7862
  if (upstream !== "") {
8065
7863
  const slash = upstream.indexOf("/");
8066
7864
  const branch = slash === -1 ? upstream : upstream.slice(slash + 1);
8067
- if (branch !== "" && branch !== current) return upstream;
7865
+ if (branch !== "" && branch !== current) candidates.push(upstream);
8068
7866
  }
8069
- return DEFAULT_BASE;
7867
+ const remoteDefault = gitOrEmpty(
7868
+ git,
7869
+ "symbolic-ref",
7870
+ "--quiet",
7871
+ "--short",
7872
+ "refs/remotes/origin/HEAD"
7873
+ ).trim();
7874
+ if (remoteDefault !== "") candidates.push(remoteDefault);
7875
+ candidates.push(DEFAULT_BASE, "main");
7876
+ const found = firstExisting(...new Set(candidates));
7877
+ if (found !== void 0) return found;
7878
+ throw new DldError(
7879
+ `no base branch found (tried ${tried.join(", ")}). Set base_branch in dld.config.yaml, or run git remote set-head origin --auto.`
7880
+ );
8070
7881
  }
8071
7882
  function verifyBase(ctx, paths, base, hint = "") {
8072
7883
  if (base.startsWith("-")) throw new DldError(`--base must be a git ref, got '${base}'`);
@@ -8141,8 +7952,8 @@ function collisionsOf(ctx, paths, base) {
8141
7952
  const git = gitAt(ctx, paths.root);
8142
7953
  const added = localAdditions(git, paths, base);
8143
7954
  const local = added.flatMap((path) => {
8144
- const id = basename4(path, ".md");
8145
- return RECORD_FILE.test(basename4(path)) ? [{ path, id }] : [];
7955
+ const id = basename3(path, ".md");
7956
+ return RECORD_FILE.test(basename3(path)) ? [{ path, id }] : [];
8146
7957
  });
8147
7958
  if (local.length === 0) return { collisions: [], localIds: [], taken: [] };
8148
7959
  const taken = takenIds(ctx, paths, base);
@@ -8169,840 +7980,1134 @@ function formatRename({ path, oldId, newId }) {
8169
7980
  return `${path} ${oldId} ${newId}`;
8170
7981
  }
8171
7982
 
8172
- // src/core/rename-plan.ts
8173
- import { posix } from "node:path";
8174
- function renameProblem(paths, rename) {
8175
- const { path, oldId, newId } = rename;
8176
- if (!DECISION_ID.test(oldId) || !DECISION_ID.test(newId)) return "IDs must match DL-[0-9]+.";
8177
- if (oldId === newId) return "old and new IDs are the same.";
8178
- const segments = path.split("/");
8179
- if (path.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) {
8180
- return `path '${path}' must be relative to the project root, without '.' or '..' segments.`;
8181
- }
8182
- const records = recordsPathspec(paths);
8183
- if (!path.startsWith(`${records}/`)) {
8184
- return `path '${path}' is not under ${records}/.`;
7983
+ // src/core/decision-edits.ts
7984
+ function editBase(ctx, project, base) {
7985
+ if (base !== void 0) {
7986
+ verifyBase(ctx, project.paths, base, " Fetch first or pass --base.");
7987
+ return base;
8185
7988
  }
8186
- if (posix.basename(path) !== `${oldId}.md`) {
8187
- return `path '${path}' is not named ${oldId}.md.`;
7989
+ return resolveBase(ctx, project.config.baseBranch);
7990
+ }
7991
+ function checkDecisionEdits(ctx, { paths }, base, ids = [], { uncommitted = false } = {}) {
7992
+ const git = gitAt(ctx, paths.root);
7993
+ const records = integratedRecords(ctx, paths, base);
7994
+ const allowed = uncommitted ? /* @__PURE__ */ new Set() : trailerIds(git, records.onto);
7995
+ const allow = (edit) => (edit.state === "edited" || edit.state === "deleted") && allowed.has(edit.id) ? { ...edit, state: "allowed" } : edit;
7996
+ const stateOf = (id) => allow(rawStateOf(id));
7997
+ const rawStateOf = (id) => {
7998
+ const located = locate(ctx, paths, records, id);
7999
+ if (located === void 0) {
8000
+ const local = findRecordFile(ctx, paths.recordsDir, id, paths.root);
8001
+ if (local === void 0) throw new DldError(`decision ${id} not found.`);
8002
+ return { path: toPosix(relative3(paths.root, local)), id, state: "draft" };
8003
+ }
8004
+ const path = located.current ?? located.basePath;
8005
+ if (isProposed(located.baseText, located.basePath)) return { path, id, state: "draft" };
8006
+ const reference = uncommitted ? atHead(git, located) : { text: located.baseText, path };
8007
+ if (reference === void 0) return { path, id, state: "integrated" };
8008
+ if (located.current === void 0) return { path, id, state: "deleted" };
8009
+ const now = ctx.fs.readFile(join3(paths.root, located.current));
8010
+ const same = lockedContent(now, path) === lockedContent(reference.text, reference.path);
8011
+ return { path, id, state: same ? "integrated" : "edited" };
8012
+ };
8013
+ if (ids.length > 0) return ids.map(stateOf);
8014
+ const changed = nulSeparated(
8015
+ gitOrEmpty(
8016
+ git,
8017
+ "diff",
8018
+ "-z",
8019
+ "--no-renames",
8020
+ "--name-only",
8021
+ uncommitted ? "HEAD" : records.onto,
8022
+ "--",
8023
+ recordsPathspec(paths)
8024
+ )
8025
+ );
8026
+ const candidates = new Set(
8027
+ changed.map((path) => basename4(path)).filter((name) => RECORD_FILE.test(name)).map((name) => basename4(name, ".md")).filter((id) => records.byId.has(id))
8028
+ );
8029
+ return [...candidates].map(stateOf).filter(({ state }) => state === "edited" || state === "deleted" || state === "allowed").sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
8030
+ }
8031
+ var EDIT_TRAILER = "Decision-Edit";
8032
+ function trailerIds(git, onto) {
8033
+ const values = gitOrEmpty(
8034
+ git,
8035
+ "log",
8036
+ `--format=%(trailers:key=${EDIT_TRAILER},valueonly)`,
8037
+ `${onto}..HEAD`
8038
+ );
8039
+ return new Set(values.split(/[\s,]+/).filter((value) => DECISION_ID.test(value)));
8040
+ }
8041
+ function integratedRecords(ctx, paths, base) {
8042
+ const onto = mergeBase(ctx, paths, base);
8043
+ const byId = /* @__PURE__ */ new Map();
8044
+ const listing = gitOrEmpty(
8045
+ gitAt(ctx, paths.root),
8046
+ "ls-tree",
8047
+ "-r",
8048
+ "-z",
8049
+ "--name-only",
8050
+ onto,
8051
+ "--",
8052
+ recordsPathspec(paths)
8053
+ );
8054
+ for (const path of nulSeparated(listing)) {
8055
+ const name = basename4(path);
8056
+ if (RECORD_FILE.test(name)) byId.set(basename4(name, ".md"), path);
8057
+ }
8058
+ return { onto, byId };
8059
+ }
8060
+ function locate(ctx, paths, { onto, byId }, id) {
8061
+ const basePath = byId.get(id);
8062
+ if (basePath === void 0) return void 0;
8063
+ const baseText = gitAt(ctx, paths.root)("show", `${onto}:${basePath}`);
8064
+ const moved = findRecordFile(ctx, paths.recordsDir, id, paths.root);
8065
+ if (ctx.fs.isRegularFile(join3(paths.root, basePath))) {
8066
+ return { id, basePath, baseText, current: basePath };
8067
+ }
8068
+ const current = moved === void 0 ? void 0 : toPosix(relative3(paths.root, moved));
8069
+ return { id, basePath, baseText, current };
8070
+ }
8071
+ function atHead(git, { basePath, current }) {
8072
+ for (const path of /* @__PURE__ */ new Set([current, basePath])) {
8073
+ if (path === void 0) continue;
8074
+ const listed = gitOrEmpty(
8075
+ git,
8076
+ "--literal-pathspecs",
8077
+ "ls-tree",
8078
+ "--name-only",
8079
+ "HEAD",
8080
+ "--",
8081
+ path
8082
+ );
8083
+ if (listed !== "") return { text: git("show", `HEAD:${path}`), path };
8188
8084
  }
8189
8085
  return void 0;
8190
8086
  }
8191
- function parseRenamePlan(paths, text) {
8192
- const renames = [];
8193
- const lineNumbers = [];
8194
- const seen = { path: /* @__PURE__ */ new Set(), old: /* @__PURE__ */ new Set(), new: /* @__PURE__ */ new Set() };
8195
- text.split("\n").forEach((raw, index) => {
8196
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
8197
- if (line.trim() === "") return;
8198
- const fail = (message) => new DldError(`rename plan line ${index + 1}: ${message}`);
8199
- const fields = line.split(" ");
8200
- const [path, oldId, newId] = fields;
8201
- if (fields.length !== 3 || path === void 0 || oldId === void 0 || newId === void 0) {
8202
- throw fail("expected <path>\\t<DL-OLD>\\t<DL-NEW>.");
8203
- }
8204
- const rename = { path, oldId, newId };
8205
- const problem = renameProblem(paths, rename);
8206
- if (problem !== void 0) throw fail(problem);
8207
- if (seen.path.has(path)) throw fail(`path '${path}' appears more than once.`);
8208
- if (seen.old.has(oldId)) throw fail(`${oldId} is renamed more than once.`);
8209
- if (seen.new.has(newId)) throw fail(`${newId} is the target of more than one rename.`);
8210
- seen.path.add(path);
8211
- seen.old.add(oldId);
8212
- seen.new.add(newId);
8213
- renames.push(rename);
8214
- lineNumbers.push(index + 1);
8087
+ function toPosix(path) {
8088
+ return path.split(sep2).join("/");
8089
+ }
8090
+ function isProposed(text, source) {
8091
+ try {
8092
+ return parseRecord(text, source).status === "proposed";
8093
+ } catch {
8094
+ return false;
8095
+ }
8096
+ }
8097
+ function restoreDecisionProse(ctx, { paths }, base, ids, { uncommitted = false } = {}) {
8098
+ const git = gitAt(ctx, paths.root);
8099
+ const records = integratedRecords(ctx, paths, base);
8100
+ const targets = ids.map((id) => {
8101
+ const located = locate(ctx, paths, records, id);
8102
+ if (located === void 0 || isProposed(located.baseText, located.basePath)) {
8103
+ throw new DldError(`${id} is a draft (not on the base branch, or still proposed there).`);
8104
+ }
8105
+ const reference = uncommitted ? atHead(git, located) : { text: located.baseText, path: located.basePath };
8106
+ if (reference === void 0) throw new DldError(`${id} is not in HEAD; nothing to restore.`);
8107
+ return { located, reference };
8215
8108
  });
8216
- renames.forEach(({ newId }, index) => {
8217
- if (seen.old.has(newId)) {
8218
- throw new DldError(
8219
- `rename plan line ${lineNumbers[index]}: ${newId} is both renamed and a rename target.`
8220
- );
8109
+ return targets.map(({ located, reference }) => {
8110
+ const path = located.current ?? reference.path;
8111
+ const full = join3(paths.root, path);
8112
+ const current = located.current === void 0 ? void 0 : recordHead(ctx.fs.readFile(full));
8113
+ const referenceHead = recordHead(reference.text);
8114
+ if (current === void 0 || referenceHead === void 0) {
8115
+ writeFileAtomic(ctx, full, reference.text);
8116
+ if (located.current === void 0) git("--literal-pathspecs", "add", "--", path);
8117
+ } else {
8118
+ const eol = current.includes("\r\n") ? "\r\n" : "\n";
8119
+ const body = recordBody(reference.text).replace(/\r?\n/g, eol);
8120
+ writeFileAtomic(ctx, full, withLockedLines(current, referenceHead, eol) + body);
8221
8121
  }
8122
+ return path;
8222
8123
  });
8223
- return renames;
8124
+ }
8125
+ var LOCKED_KEYS = ["id", "timestamp"];
8126
+ function lockedContent(text, source) {
8127
+ const normalized = text.replace(/\r\n/g, "\n");
8128
+ let fields;
8129
+ try {
8130
+ const record = parseRecord(normalized, source);
8131
+ fields = JSON.stringify([record.id, record.timestamp ?? null]);
8132
+ } catch {
8133
+ fields = recordHead(normalized) ?? "";
8134
+ }
8135
+ return `${fields}
8136
+ ${recordBody(normalized).trimEnd()}`;
8137
+ }
8138
+ function withLockedLines(head, baseHead, eol) {
8139
+ let result = head;
8140
+ for (const key of LOCKED_KEYS) {
8141
+ const pattern = new RegExp(`^${key}:[^\\r\\n]*`, "m");
8142
+ const line = baseHead.match(pattern)?.[0];
8143
+ if (line === void 0) continue;
8144
+ result = pattern.test(result) ? result.replace(pattern, () => line) : result.replace(/^---\r?\n/, (open) => `${open}${line}${eol}`);
8145
+ }
8146
+ return result;
8147
+ }
8148
+ function formatRecordEdit({ path, id, state }) {
8149
+ return `${path} ${id} ${state}`;
8224
8150
  }
8225
8151
 
8226
- // src/core/commit-reindex.ts
8227
- var SUBJECT_LIST_LIMIT = 3;
8228
- function reindexMessage(renames, originalSubjects = "") {
8229
- const pairs = renames.map(({ oldId, newId }) => `${oldId} -> ${newId}`);
8230
- const subject = renames.length > SUBJECT_LIST_LIMIT ? `reindex ${renames.length} local decisions to avoid base-branch collisions` : `reindex local decisions: ${pairs.join(", ")}`;
8231
- const message = `${subject}
8232
-
8233
- Renames:
8234
- ${pairs.map((pair) => `- ${pair}`).join("\n")}`;
8235
- return originalSubjects === "" ? message : `${message}
8152
+ // src/core/project.ts
8153
+ import { join as join5 } from "node:path";
8236
8154
 
8237
- Squashed from original branch commits:
8238
- ${originalSubjects}`;
8239
- }
8240
- function commitReindex(ctx, { paths }, planText, base, { squash = false } = {}) {
8241
- verifyBase(ctx, paths, base);
8242
- const renames = parseRenamePlan(paths, planText);
8243
- if (renames.length === 0) throw new DldError("no rename plan on stdin.");
8244
- for (const { oldId, newId, path } of renames) {
8245
- const renamed = posix2.join(posix2.dirname(path), `${newId}.md`);
8246
- if (!ctx.fs.isRegularFile(join7(paths.root, renamed))) {
8247
- throw new DldError(`${renamed} not found. Run rename-decision for ${oldId} first.`);
8248
- }
8155
+ // src/core/config.ts
8156
+ var import_yaml2 = __toESM(require_dist(), 1);
8157
+ import { join as join4 } from "node:path";
8158
+ var CONFIG_FILE = "dld.config.yaml";
8159
+ var DECISION_EDITS = ["block", "ask", "allow"];
8160
+ var CONFIG_KEYS = [
8161
+ "decisions_dir",
8162
+ "mode",
8163
+ "namespaces",
8164
+ "annotation_prefix",
8165
+ "annotation_exclude",
8166
+ "implement_review",
8167
+ "decision_edits",
8168
+ "snapshot_artifacts",
8169
+ "base_branch"
8170
+ ];
8171
+ function loadConfig(ctx, root) {
8172
+ const path = join4(root, CONFIG_FILE);
8173
+ if (!ctx.fs.exists(path)) {
8174
+ throw new DldError(`${CONFIG_FILE} not found. Run /dld-init first.`);
8249
8175
  }
8250
- const git = gitAt(ctx, paths.root);
8251
- return squash ? squashReindex(ctx, git, paths, renames, base) : commitOnTop(ctx, git, paths, renames);
8176
+ return parseConfig(ctx.fs.readFile(path), (message) => ctx.warn(message));
8252
8177
  }
8253
- function commitOnTop(ctx, git, paths, renames) {
8254
- const stage = /* @__PURE__ */ new Set();
8255
- for (const { path, newId } of renames) {
8256
- stage.add(path);
8257
- stage.add(posix2.join(posix2.dirname(path), `${newId}.md`));
8178
+ function parseConfig(text, warn = () => {
8179
+ }) {
8180
+ const doc = (0, import_yaml2.parseDocument)(text, { logLevel: "silent" });
8181
+ const problem = doc.errors[0] ?? doc.warnings[0];
8182
+ if (problem !== void 0) throw invalid(`not valid YAML: ${problem.message}`);
8183
+ const raw = doc.toJS();
8184
+ if (!isRecord2(raw)) throw invalid("expected a mapping of keys to values");
8185
+ for (const key of Object.keys(raw)) {
8186
+ if (!CONFIG_KEYS.some((known) => known === key)) warn(unknownKeyMessage(key));
8258
8187
  }
8259
- for (const file of nulSeparated(git("diff", "-z", "--no-renames", "--name-only", "HEAD"))) {
8260
- stage.add(file);
8188
+ const mode = raw.mode;
8189
+ if (mode !== "flat" && mode !== "namespaced") {
8190
+ throw invalid("'mode' must be 'flat' or 'namespaced'");
8261
8191
  }
8262
- const head = git("rev-parse", "HEAD").trim();
8263
- const tree = git("write-tree").trim();
8264
- let stepName = "staging";
8265
- try {
8266
- for (const path of [...stage].sort()) {
8267
- stepName = `staging ${path}`;
8268
- stagePath(ctx, git, join7(paths.root, path), path);
8269
- }
8270
- stepName = "checking the staged changes";
8271
- if (!hasStagedChanges(git)) {
8272
- throw new DldError(
8273
- "nothing to commit. The reindex may have already been committed, or the plan didn't match the working tree."
8274
- );
8275
- }
8276
- stepName = "committing";
8277
- commit(git, reindexMessage(renames));
8278
- } catch (error) {
8279
- try {
8280
- git("read-tree", tree);
8281
- } catch (restoreError) {
8282
- throw new DldError(
8283
- `commit-reindex failed while ${stepName}: ${describe(error)}
8284
- Restoring the index also failed: ${describe(restoreError)}
8285
- To restore by hand: git read-tree ${tree}`
8286
- );
8287
- }
8288
- if (!(error instanceof DldError)) throw error;
8289
- throw new DldError(
8290
- `commit-reindex failed while ${stepName}: ${error.message}
8291
- The index was restored.`,
8292
- error.exitCode
8293
- );
8192
+ const namespaces = optionalStringList(raw.namespaces, "namespaces");
8193
+ const unsafe = namespaces.find((name) => !/^[A-Za-z0-9._-]+$/.test(name) || /^\.\.?$/.test(name));
8194
+ if (unsafe !== void 0) {
8195
+ throw invalid(`'namespaces' entries must be plain directory names, got '${unsafe}'`);
8196
+ }
8197
+ if (mode === "namespaced" && namespaces.length === 0) {
8198
+ throw invalid("'namespaces' must list at least one namespace when 'mode' is 'namespaced'");
8294
8199
  }
8295
8200
  return {
8296
- commit: git("rev-parse", "--short", "HEAD").trim(),
8297
- onto: git("rev-parse", "--short", head).trim()
8201
+ decisionsDir: requiredString(raw.decisions_dir, "decisions_dir"),
8202
+ mode,
8203
+ namespaces,
8204
+ annotationPrefix: optionalString(raw.annotation_prefix, "annotation_prefix") ?? "@decision",
8205
+ // @decision(DL-023)
8206
+ annotationExclude: repoRelativePatterns(raw.annotation_exclude, "annotation_exclude"),
8207
+ implementReview: optionalBoolean(raw.implement_review, "implement_review") ?? true,
8208
+ decisionEdits: decisionEdits(raw.decision_edits),
8209
+ snapshotArtifacts: snapshotArtifacts(raw.snapshot_artifacts),
8210
+ ...optionalBaseBranch(raw.base_branch)
8298
8211
  };
8299
8212
  }
8300
- function commit(git, message) {
8301
- try {
8302
- git("commit", "--quiet", "-m", message);
8303
- } catch (error) {
8304
- if (error instanceof GitCommandError) throw new DldError(`git commit failed: ${error.stderr}`);
8305
- throw error;
8213
+ function unknownKeyMessage(key) {
8214
+ let closest;
8215
+ let best = 3;
8216
+ for (const known of CONFIG_KEYS) {
8217
+ const distance = editDistance(key, known);
8218
+ if (distance < best) [closest, best] = [known, distance];
8219
+ }
8220
+ const hint = closest === void 0 ? "" : `; did you mean '${closest}'?`;
8221
+ return `${CONFIG_FILE}: unknown key '${key}'${hint}`;
8222
+ }
8223
+ function editDistance(a, b) {
8224
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
8225
+ for (let i = 1; i <= a.length; i++) {
8226
+ const current = [i];
8227
+ for (let j = 1; j <= b.length; j++) {
8228
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
8229
+ current[j] = Math.min(
8230
+ (previous[j] ?? 0) + 1,
8231
+ (current[j - 1] ?? 0) + 1,
8232
+ (previous[j - 1] ?? 0) + cost
8233
+ );
8234
+ }
8235
+ previous = current;
8306
8236
  }
8237
+ return previous[b.length] ?? 0;
8307
8238
  }
8308
- function squashReindex(ctx, git, paths, renames, base) {
8309
- const onto = mergeBase(ctx, paths, base);
8310
- const head = git("rev-parse", "HEAD").trim();
8311
- if (onto === head) throw new DldError("HEAD is already at the merge-base \u2014 nothing to squash.");
8312
- const indexRel = posix2.join(decisionsPathspec(paths), "INDEX.md");
8313
- const branchFiles = nulSeparated(
8314
- git("diff", "-z", "--no-renames", "--name-only", "--diff-filter=AMRD", `${onto}..HEAD`)
8315
- );
8316
- const stage = /* @__PURE__ */ new Set();
8317
- for (const { path, newId } of renames) {
8318
- stage.add(path);
8319
- stage.add(posix2.join(posix2.dirname(path), `${newId}.md`));
8239
+ function optionalBaseBranch(value) {
8240
+ const branch = optionalString(value, "base_branch");
8241
+ if (branch === void 0) return {};
8242
+ if (branch.startsWith("-") || branch === "@" || /[\s~^:?*[\\]|\.\.|@\{|^\/|\/$|\.lock$|\.$|^origin\//.test(branch)) {
8243
+ throw invalid(
8244
+ `'base_branch' must be a branch name such as main or develop, without origin/, got '${branch}'`
8245
+ );
8320
8246
  }
8321
- for (const file of branchFiles) if (file !== indexRel) stage.add(file);
8322
- const subjects = git("log", "--reverse", "--format=- %s", `${onto}..HEAD`).replace(/\n+$/, "");
8323
- const message = reindexMessage(renames, subjects);
8324
- const indexFull = join7(paths.root, indexRel);
8325
- if (ctx.fs.lexists(indexFull) && !ctx.fs.isRegularFile(indexFull)) {
8326
- throw new DldError(`${indexRel} is not a regular file.`);
8247
+ return { baseBranch: branch };
8248
+ }
8249
+ function invalid(message) {
8250
+ return new DldError(`${CONFIG_FILE}: ${message}`);
8251
+ }
8252
+ function isRecord2(value) {
8253
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8254
+ }
8255
+ function requiredString(value, key) {
8256
+ const result = optionalString(value, key);
8257
+ if (result === void 0) throw invalid(`'${key}' is required`);
8258
+ return result;
8259
+ }
8260
+ function optionalString(value, key) {
8261
+ if (value === void 0 || value === null) return void 0;
8262
+ if (typeof value !== "string" || value.trim() === "") {
8263
+ throw invalid(`'${key}' must be a non-empty string`);
8327
8264
  }
8328
- const saved = {
8329
- head,
8330
- tree: git("write-tree").trim(),
8331
- index: ctx.fs.isRegularFile(indexFull) ? { bytes: ctx.fs.readBytes(indexFull), mode: ctx.fs.fileMode(indexFull) } : void 0
8332
- };
8333
- let stepName = "resetting to the merge-base";
8334
- const step = (name, run2) => {
8335
- stepName = name;
8336
- return run2();
8337
- };
8338
- try {
8339
- step("resetting to the merge-base", () => git("reset", "--quiet", onto));
8340
- step(`restoring ${indexRel}`, () => {
8341
- if (existsAtHead(git, indexRel)) {
8342
- git("--literal-pathspecs", "checkout", "HEAD", "--", indexRel);
8343
- } else {
8344
- ctx.fs.remove(indexFull);
8345
- }
8346
- });
8347
- for (const path of [...stage].sort()) {
8348
- step(`staging ${path}`, () => stagePath(ctx, git, join7(paths.root, path), path));
8265
+ return value;
8266
+ }
8267
+ function optionalBoolean(value, key) {
8268
+ if (value === void 0 || value === null) return void 0;
8269
+ if (typeof value !== "boolean") throw invalid(`'${key}' must be true or false`);
8270
+ return value;
8271
+ }
8272
+ function optionalStringList(value, key) {
8273
+ if (value === void 0 || value === null) return [];
8274
+ if (!Array.isArray(value)) throw invalid(`'${key}' must be a list of strings`);
8275
+ return value.map((item) => {
8276
+ if (typeof item !== "string" || item.trim() === "") {
8277
+ throw invalid(`'${key}' must be a list of non-empty strings`);
8349
8278
  }
8350
- step("checking the staged changes", () => {
8351
- if (!hasStagedChanges(git)) {
8352
- throw new DldError(
8353
- "nothing to commit after squash. The reindex may have already been applied, or the plan didn't match the branch state."
8354
- );
8355
- }
8356
- });
8357
- step("committing", () => commit(git, message));
8358
- } catch (error) {
8359
- throw rollback(ctx, git, indexFull, saved, stepName, error);
8279
+ return item;
8280
+ });
8281
+ }
8282
+ function repoRelativePatterns(value, key) {
8283
+ const patterns = optionalStringList(value, key);
8284
+ const outside = patterns.find((pattern) => /^([/\\]|[A-Za-z]:|\.\.([/\\]|$))/.test(pattern));
8285
+ if (outside !== void 0) {
8286
+ throw invalid(`'${key}' patterns must be relative to the repository root, got '${outside}'`);
8360
8287
  }
8361
- return {
8362
- commit: git("rev-parse", "--short", "HEAD").trim(),
8363
- onto: git("rev-parse", "--short", onto).trim()
8364
- };
8288
+ return patterns;
8365
8289
  }
8366
- function existsAtHead(git, path) {
8367
- return git("--literal-pathspecs", "ls-tree", "-z", "HEAD", "--", path) !== "";
8290
+ function decisionEdits(value) {
8291
+ if (value === void 0 || value === null) return "block";
8292
+ const allowed = DECISION_EDITS;
8293
+ if (!allowed.includes(value)) {
8294
+ throw invalid(`'decision_edits' must be one of ${DECISION_EDITS.join(", ")}`);
8295
+ }
8296
+ return value;
8368
8297
  }
8369
- function stagePath(ctx, git, full, path) {
8370
- const known = ctx.fs.lexists(full) || git("--literal-pathspecs", "ls-files", "-z", "--", path) !== "";
8371
- if (known) git("--literal-pathspecs", "add", "-A", "--", path);
8298
+ function snapshotArtifacts(value) {
8299
+ const key = "snapshot_artifacts";
8300
+ if (value === void 0 || value === null) return [];
8301
+ if (!Array.isArray(value)) throw invalid(`'${key}' must be a list of {title, prompt} entries`);
8302
+ return value.map((item) => {
8303
+ if (!isRecord2(item)) throw invalid(`'${key}' entries must have 'title' and 'prompt'`);
8304
+ return {
8305
+ title: requiredString(item.title, `${key}[].title`),
8306
+ prompt: requiredString(item.prompt, `${key}[].prompt`)
8307
+ };
8308
+ });
8372
8309
  }
8373
- function hasStagedChanges(git) {
8310
+
8311
+ // src/core/project.ts
8312
+ function findProjectRoot(ctx) {
8374
8313
  try {
8375
- git("diff", "--cached", "--quiet");
8376
- return false;
8314
+ return ctx.git(["rev-parse", "--show-toplevel"]).trim();
8377
8315
  } catch (error) {
8378
- if (error instanceof GitCommandError && error.status === 1) return true;
8316
+ if (error instanceof GitCommandError) throw new DldError("not a git repository");
8379
8317
  throw error;
8380
8318
  }
8381
8319
  }
8382
- function rollback(ctx, git, indexFull, saved, stepName, cause) {
8383
- try {
8384
- git("reset", "--quiet", "--soft", saved.head);
8385
- git("read-tree", saved.tree);
8386
- if (saved.index === void 0) ctx.fs.remove(indexFull);
8387
- else writeFileAtomic(ctx, indexFull, saved.index.bytes, saved.index.mode);
8388
- } catch (rollbackError) {
8389
- return new DldError(
8390
- `commit-reindex failed while ${stepName}: ${describe(cause)}
8391
- Rolling back also failed: ${describe(rollbackError)}
8392
- To restore by hand: git reset --soft ${saved.head} && git read-tree ${saved.tree}`
8393
- );
8394
- }
8395
- if (!(cause instanceof DldError)) return cause;
8396
- return new DldError(
8397
- `commit-reindex failed while ${stepName}: ${cause.message}
8398
- The branch was restored to ${saved.head}.`,
8399
- cause.exitCode
8400
- );
8320
+ function resolvePaths(root, config) {
8321
+ const decisionsDir = join5(root, config.decisionsDir);
8322
+ return { root, decisionsDir, recordsDir: join5(decisionsDir, "records") };
8401
8323
  }
8402
- function describe(error) {
8403
- return error instanceof Error ? error.message : String(error);
8324
+ function loadProject(ctx) {
8325
+ const root = findProjectRoot(ctx);
8326
+ const config = loadConfig(ctx, root);
8327
+ return { config, paths: resolvePaths(root, config) };
8404
8328
  }
8405
8329
 
8406
8330
  // src/cli/commands/base-option.ts
8407
- function baseOption(value, fallback = DEFAULT_BASE) {
8408
- const base = value ?? fallback;
8409
- if (base.startsWith("-")) throw new UsageError(`--base must be a git ref, got '${base}'`);
8410
- return base;
8331
+ function baseOption(value, defaults) {
8332
+ if (value === void 0 && defaults !== void 0) {
8333
+ return resolveBase(defaults.ctx, defaults.project.config.baseBranch);
8334
+ }
8335
+ if (value === void 0 || value === "") throw new UsageError("--base is required.");
8336
+ if (value.startsWith("-")) throw new UsageError(`--base must be a git ref, got '${value}'`);
8337
+ return value;
8411
8338
  }
8339
+ var DEFAULT_BASE_HELP = "default: the base resolve-base picks";
8412
8340
  function skippedNotice(reason) {
8413
8341
  return `[dld-reindex] open PRs not scanned: ${reason}
8414
8342
  `;
8415
8343
  }
8416
8344
 
8417
- // src/cli/commands/commit-reindex.ts
8418
- var commitReindexCommand = {
8419
- name: "commit-reindex",
8420
- summary: "Commit the renamed decisions",
8421
- internal: true,
8422
- usage: `Usage: dld commit-reindex --base <ref> [--squash] < plan
8345
+ // src/cli/commands/check-decision-edits.ts
8346
+ var EXIT_BLOCKED = 3;
8347
+ var checkDecisionEditsCommand = {
8348
+ name: "check-decision-edits",
8349
+ summary: "List edits to the prose of decisions already on the base branch (CI gate)",
8350
+ usage: `Usage: dld check-decision-edits [--base <ref>] [--uncommitted] [DL-NNN ...]
8423
8351
 
8424
- Read a rename plan on standard input and commit the renames, which rename-decision has
8425
- already applied. By default this adds one commit on top of HEAD with the plan's paths and
8426
- every tracked file changed since HEAD; untracked files are left out. On failure the index is
8427
- restored.
8352
+ A decision is integrated when it exists at the merge-base of the base branch and HEAD;
8353
+ otherwise it is a draft (so is one still proposed there). Without IDs, print
8354
+ <path>\\t<DL-NNN>\\t<state> for each integrated decision whose body (the text after the
8355
+ frontmatter), id or timestamp changed (edited), or that is gone (deleted). With IDs, print
8356
+ one line per ID with its state: draft, integrated, edited, deleted or allowed.
8428
8357
 
8429
- With --squash, the branch's commits since the merge-base with <ref> are squashed into one
8430
- reindex commit instead, and INDEX.md is left at its merge-base state. On failure the branch,
8431
- index and INDEX.md are restored.
8358
+ A commit on the branch with a "Decision-Edit: DL-NNN[, DL-NNN ...]" trailer allows edits to
8359
+ the decisions it names: they are printed as allowed. The trailer must be in the message's last
8360
+ paragraph, which holds only trailers. Trailers do not apply with --uncommitted.
8361
+
8362
+ Without IDs, exits 3 when decision_edits is block (the default) and an edited or deleted
8363
+ decision was printed. With IDs, and otherwise, exits 0. Errors exit 1, usage errors 2.
8432
8364
 
8433
8365
  Options:
8434
- --base <ref> Base ref (required)
8435
- --squash Squash the branch instead of adding a commit
8366
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
8367
+ --uncommitted Compare with HEAD instead of the merge-base: only uncommitted changes
8436
8368
  `,
8437
8369
  run(args, io, ctx) {
8438
- const { values } = parseCommandArgs({
8370
+ const { values, positionals: ids } = parseCommandArgs({
8439
8371
  args: [...args],
8440
- options: { base: { type: "string" }, squash: { type: "boolean" } }
8372
+ options: { base: { type: "string" }, uncommitted: { type: "boolean" } },
8373
+ allowPositionals: true
8441
8374
  });
8442
- if (!values.base) throw new DldError("--base is required.");
8443
- const base = baseOption(values.base);
8375
+ const bad = ids.find((id) => !DECISION_ID.test(id));
8376
+ if (bad !== void 0) throw new UsageError(`expected decision IDs like DL-001, got '${bad}'`);
8444
8377
  const project = loadProject(ctx);
8445
- const { commit: commit2, onto } = commitReindex(ctx, project, ctx.readStdin(), base, {
8446
- squash: values.squash === true
8378
+ const base = editBase(
8379
+ ctx,
8380
+ project,
8381
+ values.base === void 0 ? void 0 : baseOption(values.base)
8382
+ );
8383
+ const edits = checkDecisionEdits(ctx, project, base, ids, {
8384
+ uncommitted: values.uncommitted === true
8447
8385
  });
8448
- io.stdout(`Created reindex commit ${commit2} on top of ${onto}
8386
+ for (const edit of edits) io.stdout(`${formatRecordEdit(edit)}
8449
8387
  `);
8450
- return EXIT_OK;
8388
+ const changed = edits.some(({ state }) => state === "edited" || state === "deleted");
8389
+ const blocked = ids.length === 0 && changed && project.config.decisionEdits === "block";
8390
+ return blocked ? EXIT_BLOCKED : EXIT_OK;
8451
8391
  }
8452
8392
  };
8453
8393
 
8454
- // src/core/init.ts
8455
- import { join as join8 } from "node:path";
8456
- function parseMode(value) {
8457
- if (value !== "flat" && value !== "namespaced") {
8458
- throw new DldError(`mode must be 'flat' or 'namespaced', got '${value}'.`);
8394
+ // src/core/snapshot.ts
8395
+ import { basename as basename5, join as join7, relative as relative5 } from "node:path";
8396
+
8397
+ // src/core/state.ts
8398
+ var import_yaml3 = __toESM(require_dist(), 1);
8399
+ import { join as join6, relative as relative4 } from "node:path";
8400
+ var STATE_FILE = ".dld-state.yaml";
8401
+ function statePath(paths) {
8402
+ return join6(paths.decisionsDir, STATE_FILE);
8403
+ }
8404
+ function loadStateDocument(ctx, paths) {
8405
+ const path = statePath(paths);
8406
+ if (!ctx.fs.exists(path)) return new import_yaml3.Document(void 0, { schema: "failsafe" });
8407
+ const source = relative4(paths.root, path);
8408
+ const doc = (0, import_yaml3.parseDocument)(ctx.fs.readFile(path), { schema: "failsafe", logLevel: "error" });
8409
+ const problem = doc.errors[0] ?? doc.warnings[0];
8410
+ if (problem !== void 0) throw new DldError(`${source}: not valid YAML: ${problem.message}`);
8411
+ if (doc.contents !== null && !(0, import_yaml3.isMap)(doc.contents)) {
8412
+ throw new DldError(`${source}: expected a mapping of sections`);
8459
8413
  }
8460
- return value;
8414
+ return doc;
8461
8415
  }
8462
- function renderConfig(mode, namespaces) {
8463
- const lines = ["decisions_dir: decisions", `mode: ${mode}`];
8464
- if (mode === "namespaced") lines.push("namespaces:", ...namespaces.map((ns) => ` - ${ns}`));
8465
- lines.push("annotation_prefix: '@decision'");
8466
- return `${lines.join("\n")}
8467
- `;
8416
+ function readStateSection(ctx, paths, section) {
8417
+ const value = loadStateDocument(ctx, paths).toJS()?.[section];
8418
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? { ...value } : void 0;
8468
8419
  }
8469
- function createConfig(ctx, root, mode, namespaces) {
8470
- if (mode === "namespaced" && namespaces.length === 0) {
8471
- throw new DldError("namespaced mode requires at least one namespace.");
8472
- }
8473
- const path = join8(root, CONFIG_FILE);
8474
- createFileExclusive(ctx, path, renderConfig(mode, namespaces), `${CONFIG_FILE} already exists.`);
8475
- return path;
8420
+ function writeStateSection(ctx, paths, section, value) {
8421
+ const doc = loadStateDocument(ctx, paths);
8422
+ doc.set(section, value);
8423
+ writeFileAtomic(ctx, statePath(paths), String(doc));
8476
8424
  }
8477
- function createDirectories(ctx, { config, paths }) {
8478
- ctx.fs.mkdir(paths.decisionsDir);
8479
- ctx.fs.mkdir(paths.recordsDir);
8480
- if (config.mode !== "namespaced") return;
8481
- for (const namespace of config.namespaces) {
8482
- const dir = join8(paths.recordsDir, namespace);
8483
- ctx.fs.mkdir(dir);
8484
- const keep = join8(dir, ".gitkeep");
8485
- if (!ctx.fs.exists(keep)) createFileExclusive(ctx, keep, "", `${keep} already exists.`);
8425
+ function stateString(section, key) {
8426
+ const value = section?.[key];
8427
+ return typeof value === "string" && value !== "" ? value : void 0;
8428
+ }
8429
+ function shortHead(ctx, root) {
8430
+ try {
8431
+ return ctx.git(["-C", root, "rev-parse", "--short", "HEAD"]).trim();
8432
+ } catch (error) {
8433
+ if (error instanceof GitCommandError) return "unknown";
8434
+ throw error;
8486
8435
  }
8487
8436
  }
8488
8437
 
8489
- // src/cli/commands/create-config.ts
8490
- var createConfigCommand = {
8491
- name: "create-config",
8492
- summary: "Create dld.config.yaml at the project root",
8493
- internal: true,
8494
- usage: "Usage: dld create-config <flat|namespaced> [namespace ...]\n\nCreate dld.config.yaml. Namespaced mode needs at least one namespace.\n",
8495
- run(args, io, ctx) {
8496
- const { positionals } = parseCommandArgs({
8497
- args: [...args],
8498
- options: {},
8499
- allowPositionals: true
8500
- });
8501
- const [mode, ...namespaces] = positionals;
8502
- if (mode === void 0) throw new UsageError("missing <flat|namespaced>");
8503
- const path = createConfig(ctx, findProjectRoot(ctx), parseMode(mode), namespaces);
8504
- io.stdout(`Created ${path}
8505
- `);
8506
- return EXIT_OK;
8438
+ // src/core/snapshot.ts
8439
+ var DECISION_BOUNDARY = "===DLD_DECISION_BOUNDARY===";
8440
+ var BUILT_IN_ARTIFACTS = ["SNAPSHOT.md", "OVERVIEW.md"];
8441
+ var isSnapshotted = (status) => status === "accepted" || status === "proposed";
8442
+ function readRecords(ctx, { paths }) {
8443
+ return listRecordFiles(ctx, paths.recordsDir).map((path) => {
8444
+ const text = ctx.fs.readFile(path);
8445
+ const record = parseRecord(text, relative5(paths.root, path));
8446
+ return { path, number: recordNumber(path), text, status: record.status };
8447
+ }).sort((a, b) => a.number - b.number);
8448
+ }
8449
+ function collectActiveDecisions(ctx, project, { includeProposed = false } = {}) {
8450
+ if (!ctx.fs.isDirectory(project.paths.recordsDir)) {
8451
+ throw new DldError(`records directory not found at ${project.paths.recordsDir}`);
8507
8452
  }
8508
- };
8509
-
8510
- // src/core/decisions.ts
8511
- import { join as join9, relative as relative4 } from "node:path";
8512
- function updateStatus(ctx, project, id, status) {
8513
- const path = findRecordFile(ctx, project.paths.recordsDir, id);
8514
- if (path === void 0) throw new DldError(`decision ${id} not found.`);
8515
- const text = ctx.fs.readFile(path);
8516
- writeFileAtomic(ctx, path, setStatus(text, status, relative4(project.paths.root, path)));
8517
- return path;
8453
+ const wanted = includeProposed ? isSnapshotted : (status) => status === "accepted";
8454
+ return readRecords(ctx, project).filter((record) => wanted(record.status)).map((record) => record.text).join(`${DECISION_BOUNDARY}
8455
+ `);
8518
8456
  }
8519
- function createDecision(ctx, project, input) {
8520
- if (!DECISION_ID.test(input.id)) {
8521
- throw new DldError(`invalid decision ID '${input.id}'; expected DL-<digits>, e.g. DL-001.`);
8457
+ function detectSnapshotChanges(ctx, project) {
8458
+ const { paths } = project;
8459
+ const state = readStateSection(ctx, paths, "snapshot");
8460
+ if (state === void 0) return { mode: "full" };
8461
+ if (BUILT_IN_ARTIFACTS.some((name) => !ctx.fs.exists(join7(paths.decisionsDir, name)))) {
8462
+ return { mode: "full" };
8522
8463
  }
8523
- const namespaced = project.config.mode === "namespaced" && input.namespace !== void 0;
8524
- if (namespaced && !isSafeDirName(input.namespace ?? "")) {
8525
- throw new DldError(`invalid namespace '${input.namespace}'.`);
8464
+ const included = stateString(state, "decisions_included");
8465
+ if (included === void 0 || !/^\d+$/.test(included)) return { mode: "full" };
8466
+ const includedNumber = Number.parseInt(included, 10);
8467
+ const proposedIncluded = stateString(state, "includes_proposed") === "true";
8468
+ const newDecisions = readRecords(ctx, project).filter(
8469
+ (record) => record.number > includedNumber && isSnapshotted(record.status) || !proposedIncluded && record.status === "proposed"
8470
+ ).map((record) => formatId(record.number));
8471
+ const git = gitAt(ctx, paths.root);
8472
+ let stored = stateString(state, "commit_hash");
8473
+ const lastRun = stateString(state, "last_run");
8474
+ if ((stored === void 0 || stored === "unknown") && lastRun !== void 0) {
8475
+ stored = gitOrEmpty(git, "log", `--until=${lastRun}`, "--format=%h", "-1").trim() || void 0;
8526
8476
  }
8527
- const dir = namespaced ? join9(project.paths.recordsDir, input.namespace ?? "") : project.paths.recordsDir;
8528
- ctx.fs.mkdir(dir);
8529
- const path = join9(dir, `${input.id}.md`);
8530
- const content = renderNewRecord({
8531
- id: input.id,
8532
- title: input.title,
8533
- timestamp: formatTimestamp(ctx.now()),
8534
- ...namespaced && input.namespace !== void 0 ? { namespace: input.namespace } : {},
8535
- tags: input.tags,
8536
- supersedes: input.supersedes,
8537
- amends: input.amends,
8538
- body: input.body
8539
- });
8540
- createFileExclusive(ctx, path, content, `${path} already exists.`);
8541
- return path;
8477
+ const commit2 = resolveStateCommit(git, stored);
8478
+ let modifiedDecisions = [];
8479
+ let commitRange = "";
8480
+ if (commit2 !== void 0 && commit2 !== shortHead(ctx, paths.root)) {
8481
+ commitRange = `${commit2}..HEAD`;
8482
+ const numbers = nulSeparated(
8483
+ gitOrEmpty(git, "diff", "-z", "--name-only", commitRange, "--", recordsPathspec(paths))
8484
+ ).map((path) => basename5(path)).filter((name) => RECORD_FILE.test(name)).map((name) => recordNumber(name)).filter((number) => number <= includedNumber);
8485
+ modifiedDecisions = [...new Set(numbers)].sort((a, b) => a - b).map(formatId);
8486
+ }
8487
+ return { mode: "incremental", newDecisions, modifiedDecisions, commitRange };
8488
+ }
8489
+ function formatSnapshotChanges(changes) {
8490
+ if (changes.mode === "full") return "mode: full\n";
8491
+ return [
8492
+ "mode: incremental",
8493
+ `new_decisions: ${changes.newDecisions.join(", ")}`,
8494
+ `modified_decisions: ${changes.modifiedDecisions.join(", ")}`,
8495
+ `commit_range: ${changes.commitRange}`,
8496
+ ""
8497
+ ].join("\n");
8542
8498
  }
8543
- function isSafeDirName(name) {
8544
- return name !== "." && name !== ".." && /^[A-Za-z0-9._-]+$/.test(name);
8499
+ function updateSnapshotState(ctx, project, customArtifacts) {
8500
+ const { paths } = project;
8501
+ const timestamp = formatTimestamp(ctx.now());
8502
+ const commit2 = shortHead(ctx, paths.root);
8503
+ const highest = readRecords(ctx, project).filter((record) => isSnapshotted(record.status)).reduce((max, record) => Math.max(max, record.number), 0);
8504
+ const artifacts = /* @__PURE__ */ Object.create(null);
8505
+ for (const name of [...BUILT_IN_ARTIFACTS, ...customArtifacts]) artifacts[name] = timestamp;
8506
+ writeStateSection(ctx, paths, "snapshot", {
8507
+ last_run: timestamp,
8508
+ commit_hash: commit2,
8509
+ decisions_included: String(highest),
8510
+ includes_proposed: "true",
8511
+ artifacts
8512
+ });
8513
+ return { timestamp, commit: commit2, highest };
8545
8514
  }
8546
8515
 
8547
- // src/cli/commands/create-decision.ts
8548
- var createDecisionCommand = {
8549
- name: "create-decision",
8550
- summary: "Create a proposed decision record",
8516
+ // src/cli/commands/collect-active-decisions.ts
8517
+ var collectActiveDecisionsCommand = {
8518
+ name: "collect-active-decisions",
8519
+ summary: "Print every accepted decision record",
8551
8520
  internal: true,
8552
- usage: `Usage: dld create-decision --id <DL-NNN> --title <title> [options]
8521
+ usage: `Usage: dld collect-active-decisions [--include-proposed]
8553
8522
 
8554
- Create a decision record with status proposed and print its path.
8523
+ Print each accepted record in ID order, separated by ===DLD_DECISION_BOUNDARY=== lines.
8555
8524
 
8556
8525
  Options:
8557
- --id <DL-NNN> Decision ID (required)
8558
- --title <title> Title (required)
8559
- --namespace <name> Namespace directory (namespaced projects only)
8560
- --tags <a,b> Comma-separated tags
8561
- --supersedes <DL-X,...> Decisions this one supersedes
8562
- --amends <DL-X,...> Decisions this one amends
8563
- --body-stdin Read the markdown body from standard input
8526
+ --include-proposed Print proposed records too, in the same ID order
8564
8527
  `,
8565
8528
  run(args, io, ctx) {
8566
8529
  const { values } = parseCommandArgs({
8567
8530
  args: [...args],
8568
- options: {
8569
- id: { type: "string" },
8570
- title: { type: "string" },
8571
- namespace: { type: "string" },
8572
- tags: { type: "string" },
8573
- supersedes: { type: "string" },
8574
- amends: { type: "string" },
8575
- "body-stdin": { type: "boolean" }
8576
- }
8577
- });
8578
- const body = values["body-stdin"] === true ? ctx.readStdin() : "";
8579
- const { id, title } = values;
8580
- if (id === void 0 || id === "" || title === void 0 || title === "") {
8581
- throw new DldError("--id and --title are required.");
8582
- }
8583
- const namespace = values.namespace === "" ? void 0 : values.namespace;
8584
- const path = createDecision(ctx, loadProject(ctx), {
8585
- id,
8586
- title,
8587
- ...namespace === void 0 ? {} : { namespace },
8588
- tags: values.tags ?? "",
8589
- supersedes: values.supersedes ?? "",
8590
- amends: values.amends ?? "",
8591
- body
8531
+ options: { "include-proposed": { type: "boolean" } }
8592
8532
  });
8593
- io.stdout(`${path}
8594
- `);
8533
+ io.stdout(
8534
+ collectActiveDecisions(ctx, loadProject(ctx), {
8535
+ includeProposed: values["include-proposed"] === true
8536
+ })
8537
+ );
8595
8538
  return EXIT_OK;
8596
8539
  }
8597
8540
  };
8598
8541
 
8599
- // src/cli/commands/create-directories.ts
8600
- var createDirectoriesCommand = {
8601
- name: "create-directories",
8602
- summary: "Create the decisions directory structure from the config",
8603
- internal: true,
8604
- usage: "Usage: dld create-directories\n\nCreate the decisions and records directories, and one per namespace.\n",
8605
- run(args, io, ctx) {
8606
- parseCommandArgs({ args: [...args], options: {} });
8607
- createDirectories(ctx, loadProject(ctx));
8608
- io.stdout("Created decisions directory structure.\n");
8609
- return EXIT_OK;
8610
- }
8611
- };
8542
+ // src/core/commit-reindex.ts
8543
+ import { join as join8, posix as posix2 } from "node:path";
8612
8544
 
8613
- // src/core/index-file.ts
8614
- import { basename as basename5, join as join10, relative as relative5, sep as sep2 } from "node:path";
8615
- var INDEX_FILE = "INDEX.md";
8616
- function renderIndex(rows, mode) {
8617
- const namespaced = mode === "namespaced";
8618
- const lines = [
8619
- "# Decision Log",
8620
- "",
8621
- namespaced ? "| ID | Title | Status | Namespace | Tags |" : "| ID | Title | Status | Tags |",
8622
- namespaced ? "|----|-------|--------|-----------|------|" : "|----|-------|--------|------|"
8623
- ];
8624
- const sorted = [...rows].sort((a, b) => b.number - a.number);
8625
- for (const { record } of sorted) {
8626
- const cells = [record.id, record.title, record.status];
8627
- if (namespaced) cells.push(record.namespace ?? "");
8628
- cells.push(record.tags.join(", "));
8629
- lines.push(`| ${cells.join(" | ")} |`);
8545
+ // src/core/rename-plan.ts
8546
+ import { posix } from "node:path";
8547
+ function renameProblem(paths, rename) {
8548
+ const { path, oldId, newId } = rename;
8549
+ if (!DECISION_ID.test(oldId) || !DECISION_ID.test(newId)) return "IDs must match DL-[0-9]+.";
8550
+ if (!isCanonicalId(newId)) {
8551
+ const canonical = canonicalSpelling(newId);
8552
+ return `new ID '${newId}' is not canonical${canonical === void 0 ? "" : `; use ${canonical}`}.`;
8630
8553
  }
8631
- return `${lines.join("\n")}
8632
- `;
8633
- }
8634
- function indexPath(paths) {
8635
- return join10(paths.decisionsDir, INDEX_FILE);
8636
- }
8637
- function collectIndexRows(ctx, paths, includeBase) {
8638
- const localFiles = listRecordFiles(ctx, paths.recordsDir);
8639
- const rows = localFiles.map((path) => ({
8640
- number: recordNumber(path),
8641
- record: parseRecord(ctx.fs.readFile(path), relative5(paths.root, path))
8642
- }));
8643
- if (includeBase === void 0) return rows;
8644
- const git = (...args) => ctx.git(["-C", paths.root, ...args]);
8645
- try {
8646
- git("rev-parse", "--verify", "--quiet", `${includeBase}^{commit}`);
8647
- } catch (error) {
8648
- if (error instanceof GitCommandError) {
8649
- throw new DldError(`--include-base ref '${includeBase}' not found.`);
8650
- }
8651
- throw error;
8554
+ if (oldId === newId) return "old and new IDs are the same.";
8555
+ const segments = path.split("/");
8556
+ if (path.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) {
8557
+ return `path '${path}' must be relative to the project root, without '.' or '..' segments.`;
8652
8558
  }
8653
- const localNames = new Set(localFiles.map((path) => basename5(path)));
8654
- const recordsRel = relative5(paths.root, paths.recordsDir).split(sep2).join("/");
8655
- const basePaths = git("ls-tree", "-r", "--name-only", includeBase, "--", recordsRel).split("\n").filter((path) => RECORD_FILE.test(basename5(path)) && !localNames.has(basename5(path)));
8656
- for (const path of basePaths) {
8657
- rows.push({
8658
- number: recordNumber(path),
8659
- record: parseRecord(git("show", `${includeBase}:${path}`), `${includeBase}:${path}`)
8660
- });
8559
+ const records = recordsPathspec(paths);
8560
+ if (!path.startsWith(`${records}/`)) {
8561
+ return `path '${path}' is not under ${records}/.`;
8661
8562
  }
8662
- return rows;
8563
+ if (posix.basename(path) !== `${oldId}.md`) {
8564
+ return `path '${path}' is not named ${oldId}.md.`;
8565
+ }
8566
+ return void 0;
8663
8567
  }
8664
- function writeIndex(ctx, paths, content) {
8665
- writeFileAtomic(ctx, indexPath(paths), content);
8568
+ function parseRenamePlan(paths, text) {
8569
+ const renames = [];
8570
+ const lineNumbers = [];
8571
+ const seen = { path: /* @__PURE__ */ new Set(), old: /* @__PURE__ */ new Set(), new: /* @__PURE__ */ new Set() };
8572
+ text.split("\n").forEach((raw, index) => {
8573
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
8574
+ if (line.trim() === "") return;
8575
+ const fail = (message) => new DldError(`rename plan line ${index + 1}: ${message}`, EXIT_USAGE);
8576
+ const fields = line.split(" ");
8577
+ const [path, oldId, newId] = fields;
8578
+ if (fields.length !== 3 || path === void 0 || oldId === void 0 || newId === void 0) {
8579
+ throw fail("expected <path>\\t<DL-OLD>\\t<DL-NEW>.");
8580
+ }
8581
+ const rename = { path, oldId, newId };
8582
+ const problem = renameProblem(paths, rename);
8583
+ if (problem !== void 0) throw fail(problem);
8584
+ if (seen.path.has(path)) throw fail(`path '${path}' appears more than once.`);
8585
+ if (seen.old.has(oldId)) throw fail(`${oldId} is renamed more than once.`);
8586
+ if (seen.new.has(newId)) throw fail(`${newId} is the target of more than one rename.`);
8587
+ seen.path.add(path);
8588
+ seen.old.add(oldId);
8589
+ seen.new.add(newId);
8590
+ renames.push(rename);
8591
+ lineNumbers.push(index + 1);
8592
+ });
8593
+ renames.forEach(({ newId }, index) => {
8594
+ if (seen.old.has(newId)) {
8595
+ throw new DldError(
8596
+ `rename plan line ${lineNumbers[index]}: ${newId} is both renamed and a rename target.`
8597
+ );
8598
+ }
8599
+ });
8600
+ return renames;
8666
8601
  }
8667
8602
 
8668
- // src/cli/commands/create-empty-index.ts
8669
- var createEmptyIndexCommand = {
8670
- name: "create-empty-index",
8671
- summary: "Write an INDEX.md with no decisions",
8672
- internal: true,
8673
- usage: "Usage: dld create-empty-index\n\nWrite INDEX.md in the decisions directory with only the table header.\n",
8674
- run(args, io, ctx) {
8675
- parseCommandArgs({ args: [...args], options: {} });
8676
- const { config, paths } = loadProject(ctx);
8677
- writeIndex(ctx, paths, renderIndex([], config.mode));
8678
- io.stdout(`Created ${indexPath(paths)}
8679
- `);
8680
- return EXIT_OK;
8681
- }
8682
- };
8603
+ // src/core/commit-reindex.ts
8604
+ var SUBJECT_LIST_LIMIT = 3;
8605
+ function reindexMessage(renames, originalSubjects = "") {
8606
+ const pairs = renames.map(({ oldId, newId }) => `${oldId} -> ${newId}`);
8607
+ const subject = renames.length > SUBJECT_LIST_LIMIT ? `reindex ${renames.length} local decisions to avoid base-branch collisions` : `reindex local decisions: ${pairs.join(", ")}`;
8608
+ const message = `${subject}
8683
8609
 
8684
- // src/cli/commands/detect-snapshot-changes.ts
8685
- var detectSnapshotChangesCommand = {
8686
- name: "detect-snapshot-changes",
8687
- summary: "Report what changed since the last snapshot",
8688
- internal: true,
8689
- usage: "Usage: dld detect-snapshot-changes\n\nPrint mode (full or incremental), new_decisions, modified_decisions and commit_range.\n",
8690
- run(args, io, ctx) {
8691
- parseCommandArgs({ args: [...args], options: {} });
8692
- io.stdout(formatSnapshotChanges(detectSnapshotChanges(ctx, loadProject(ctx))));
8693
- return EXIT_OK;
8694
- }
8695
- };
8610
+ Renames:
8611
+ ${pairs.map((pair) => `- ${pair}`).join("\n")}`;
8612
+ return originalSubjects === "" ? message : `${message}
8696
8613
 
8697
- // src/core/annotations.ts
8698
- import { join as join11, relative as relative6, sep as sep3 } from "node:path";
8699
- var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
8700
- ".git",
8701
- // Agent configuration directories, which hold installed skills.
8702
- ".claude",
8703
- ".agents",
8704
- ".agent",
8705
- ".codex",
8706
- ".cursor",
8707
- ".opencode",
8708
- ".pi",
8709
- ".tessl",
8710
- "node_modules",
8711
- "vendor",
8712
- ".venv",
8713
- "__pycache__",
8714
- "target",
8715
- "dist",
8716
- "build",
8717
- "out",
8718
- ".next",
8719
- ".gradle",
8720
- "coverage"
8721
- ]);
8722
- var EXCLUDED_FILE = /(\.lock|\.min\.js|\.min\.css|\.map)$/;
8723
- var BINARY_SNIFF_BYTES = 8192;
8724
- function scanOptionsFor({ config, paths }) {
8725
- return {
8726
- root: paths.root,
8727
- decisionsDir: paths.decisionsDir,
8728
- prefix: config.annotationPrefix,
8729
- exclude: config.annotationExclude
8730
- };
8614
+ Squashed from original branch commits:
8615
+ ${originalSubjects}`;
8731
8616
  }
8732
- function listScannableFiles(ctx, options) {
8733
- const output = ctx.git([
8734
- "-C",
8735
- options.root,
8736
- "ls-files",
8737
- "-z",
8738
- "--cached",
8739
- "--others",
8740
- "--exclude-standard",
8741
- // @decision(DL-023)
8742
- "--",
8743
- ...(options.exclude ?? []).map((pattern) => `:(exclude,glob)${pattern}`)
8744
- ]);
8745
- const decisionsRel = relative6(options.root, options.decisionsDir).split(sep3).join("/");
8746
- const files = /* @__PURE__ */ new Set();
8747
- for (const file of output.split("\0")) {
8748
- if (file === "" || EXCLUDED_FILE.test(file)) continue;
8749
- if (file === decisionsRel || file.startsWith(`${decisionsRel}/`)) continue;
8750
- const dirs = file.split("/").slice(0, -1);
8751
- if (dirs.some((dir) => EXCLUDED_DIRS.has(dir))) continue;
8752
- files.add(file);
8617
+ function commitReindex(ctx, { paths }, planText, base, { squash = false } = {}) {
8618
+ verifyBase(ctx, paths, base);
8619
+ const renames = parseRenamePlan(paths, planText);
8620
+ if (renames.length === 0) throw new DldError("no rename plan on stdin.");
8621
+ for (const { oldId, newId, path } of renames) {
8622
+ const renamed = posix2.join(posix2.dirname(path), `${newId}.md`);
8623
+ if (!ctx.fs.isRegularFile(join8(paths.root, renamed))) {
8624
+ throw new DldError(`${renamed} not found. Run rename-decision for ${oldId} first.`);
8625
+ }
8753
8626
  }
8754
- return [...files].sort();
8755
- }
8756
- function escapeRegExp(text) {
8757
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8627
+ const git = gitAt(ctx, paths.root);
8628
+ return squash ? squashReindex(ctx, git, paths, renames, base) : commitOnTop(ctx, git, paths, renames);
8758
8629
  }
8759
- function scanAnnotations(ctx, options) {
8760
- const pattern = new RegExp(`${escapeRegExp(options.prefix)}\\((DL-\\d+)\\)`, "g");
8761
- const found = [];
8762
- for (const file of listScannableFiles(ctx, options)) {
8763
- const path = join11(options.root, file);
8764
- if (!ctx.fs.isRegularFile(path)) continue;
8765
- const text = ctx.fs.readFile(path);
8766
- if (text.slice(0, BINARY_SNIFF_BYTES).includes("\0")) continue;
8767
- text.split("\n").forEach((content, index) => {
8768
- for (const match of content.matchAll(pattern)) {
8769
- if (match[1] !== void 0) found.push({ file, line: index + 1, id: match[1] });
8770
- }
8771
- });
8630
+ function commitOnTop(ctx, git, paths, renames) {
8631
+ const stage = /* @__PURE__ */ new Set();
8632
+ for (const { path, newId } of renames) {
8633
+ stage.add(path);
8634
+ stage.add(posix2.join(posix2.dirname(path), `${newId}.md`));
8772
8635
  }
8773
- return found;
8774
- }
8775
- function missingAnnotations(ctx, options, ids) {
8776
- const annotated = new Set(scanAnnotations(ctx, options).map((annotation) => annotation.id));
8777
- return ids.filter((id) => !annotated.has(id));
8636
+ for (const file of nulSeparated(git("diff", "-z", "--no-renames", "--name-only", "HEAD"))) {
8637
+ stage.add(file);
8638
+ }
8639
+ const head = git("rev-parse", "HEAD").trim();
8640
+ const tree = git("write-tree").trim();
8641
+ let stepName = "staging";
8642
+ try {
8643
+ for (const path of [...stage].sort()) {
8644
+ stepName = `staging ${path}`;
8645
+ stagePath(ctx, git, join8(paths.root, path), path);
8646
+ }
8647
+ stepName = "checking the staged changes";
8648
+ if (!hasStagedChanges(git)) {
8649
+ throw new DldError(
8650
+ "nothing to commit. The reindex may have already been committed, or the plan didn't match the working tree."
8651
+ );
8652
+ }
8653
+ stepName = "committing";
8654
+ commit(git, reindexMessage(renames));
8655
+ } catch (error) {
8656
+ try {
8657
+ git("read-tree", tree);
8658
+ } catch (restoreError) {
8659
+ throw new DldError(
8660
+ `commit-reindex failed while ${stepName}: ${describe(error)}
8661
+ Restoring the index also failed: ${describe(restoreError)}
8662
+ To restore by hand: git read-tree ${tree}`
8663
+ );
8664
+ }
8665
+ if (!(error instanceof DldError)) throw error;
8666
+ throw new DldError(
8667
+ `commit-reindex failed while ${stepName}: ${error.message}
8668
+ The index was restored.`,
8669
+ error.exitCode
8670
+ );
8671
+ }
8672
+ return {
8673
+ commit: git("rev-parse", "--short", "HEAD").trim(),
8674
+ onto: git("rev-parse", "--short", head).trim()
8675
+ };
8778
8676
  }
8779
- function formatAnnotation({ file, line, id }) {
8780
- return `${file}:${line}:${id}`;
8677
+ function commit(git, message) {
8678
+ try {
8679
+ git("commit", "--quiet", "-m", message);
8680
+ } catch (error) {
8681
+ if (error instanceof GitCommandError) throw new DldError(`git commit failed: ${error.stderr}`);
8682
+ throw error;
8683
+ }
8781
8684
  }
8782
-
8783
- // src/cli/commands/find-annotations.ts
8784
- var findAnnotationsCommand = {
8785
- name: "find-annotations",
8786
- summary: "List every annotation in the codebase",
8787
- internal: true,
8788
- usage: "Usage: dld find-annotations\n\nPrint <file>:<line>:<DL-NNN> for every annotation, one per line.\n",
8789
- run(args, io, ctx) {
8790
- parseCommandArgs({ args: [...args], options: {} });
8791
- for (const annotation of scanAnnotations(ctx, scanOptionsFor(loadProject(ctx)))) {
8792
- io.stdout(`${formatAnnotation(annotation)}
8793
- `);
8794
- }
8795
- return EXIT_OK;
8685
+ function squashReindex(ctx, git, paths, renames, base) {
8686
+ const onto = mergeBase(ctx, paths, base);
8687
+ const head = git("rev-parse", "HEAD").trim();
8688
+ if (onto === head) throw new DldError("HEAD is already at the merge-base \u2014 nothing to squash.");
8689
+ const indexRel = posix2.join(decisionsPathspec(paths), "INDEX.md");
8690
+ const branchFiles = nulSeparated(
8691
+ git("diff", "-z", "--no-renames", "--name-only", "--diff-filter=AMRD", `${onto}..HEAD`)
8692
+ );
8693
+ const stage = /* @__PURE__ */ new Set();
8694
+ for (const { path, newId } of renames) {
8695
+ stage.add(path);
8696
+ stage.add(posix2.join(posix2.dirname(path), `${newId}.md`));
8796
8697
  }
8797
- };
8798
-
8799
- // src/cli/commands/find-collisions.ts
8800
- var findCollisionsCommand = {
8801
- name: "find-collisions",
8802
- summary: "List locally added decisions whose IDs are taken",
8698
+ for (const file of branchFiles) if (file !== indexRel) stage.add(file);
8699
+ const subjects = git("log", "--reverse", "--format=- %s", `${onto}..HEAD`).replace(/\n+$/, "");
8700
+ const message = reindexMessage(renames, subjects);
8701
+ const indexFull = join8(paths.root, indexRel);
8702
+ if (ctx.fs.lexists(indexFull) && !ctx.fs.isRegularFile(indexFull)) {
8703
+ throw new DldError(`${indexRel} is not a regular file.`);
8704
+ }
8705
+ const saved = {
8706
+ head,
8707
+ tree: git("write-tree").trim(),
8708
+ index: ctx.fs.isRegularFile(indexFull) ? { bytes: ctx.fs.readBytes(indexFull), mode: ctx.fs.fileMode(indexFull) } : void 0
8709
+ };
8710
+ let stepName = "resetting to the merge-base";
8711
+ const step = (name, run2) => {
8712
+ stepName = name;
8713
+ return run2();
8714
+ };
8715
+ try {
8716
+ step("resetting to the merge-base", () => git("reset", "--quiet", onto));
8717
+ step(`restoring ${indexRel}`, () => {
8718
+ if (existsAtHead(git, indexRel)) {
8719
+ git("--literal-pathspecs", "checkout", "HEAD", "--", indexRel);
8720
+ } else {
8721
+ ctx.fs.remove(indexFull);
8722
+ }
8723
+ });
8724
+ for (const path of [...stage].sort()) {
8725
+ step(`staging ${path}`, () => stagePath(ctx, git, join8(paths.root, path), path));
8726
+ }
8727
+ step("checking the staged changes", () => {
8728
+ if (!hasStagedChanges(git)) {
8729
+ throw new DldError(
8730
+ "nothing to commit after squash. The reindex may have already been applied, or the plan didn't match the branch state."
8731
+ );
8732
+ }
8733
+ });
8734
+ step("committing", () => commit(git, message));
8735
+ } catch (error) {
8736
+ throw rollback(ctx, git, indexFull, saved, stepName, error);
8737
+ }
8738
+ return {
8739
+ commit: git("rev-parse", "--short", "HEAD").trim(),
8740
+ onto: git("rev-parse", "--short", onto).trim()
8741
+ };
8742
+ }
8743
+ function existsAtHead(git, path) {
8744
+ return git("--literal-pathspecs", "ls-tree", "-z", "HEAD", "--", path) !== "";
8745
+ }
8746
+ function stagePath(ctx, git, full, path) {
8747
+ const known = ctx.fs.lexists(full) || git("--literal-pathspecs", "ls-files", "-z", "--", path) !== "";
8748
+ if (known) git("--literal-pathspecs", "add", "-A", "--", path);
8749
+ }
8750
+ function hasStagedChanges(git) {
8751
+ try {
8752
+ git("diff", "--cached", "--quiet");
8753
+ return false;
8754
+ } catch (error) {
8755
+ if (error instanceof GitCommandError && error.status === 1) return true;
8756
+ throw error;
8757
+ }
8758
+ }
8759
+ function rollback(ctx, git, indexFull, saved, stepName, cause) {
8760
+ try {
8761
+ git("reset", "--quiet", "--soft", saved.head);
8762
+ git("read-tree", saved.tree);
8763
+ if (saved.index === void 0) ctx.fs.remove(indexFull);
8764
+ else writeFileAtomic(ctx, indexFull, saved.index.bytes, saved.index.mode);
8765
+ } catch (rollbackError) {
8766
+ return new DldError(
8767
+ `commit-reindex failed while ${stepName}: ${describe(cause)}
8768
+ Rolling back also failed: ${describe(rollbackError)}
8769
+ To restore by hand: git reset --soft ${saved.head} && git read-tree ${saved.tree}`
8770
+ );
8771
+ }
8772
+ if (!(cause instanceof DldError)) return cause;
8773
+ return new DldError(
8774
+ `commit-reindex failed while ${stepName}: ${cause.message}
8775
+ The branch was restored to ${saved.head}.`,
8776
+ cause.exitCode
8777
+ );
8778
+ }
8779
+ function describe(error) {
8780
+ return error instanceof Error ? error.message : String(error);
8781
+ }
8782
+
8783
+ // src/cli/commands/commit-reindex.ts
8784
+ var commitReindexCommand = {
8785
+ name: "commit-reindex",
8786
+ summary: "Commit the renamed decisions",
8803
8787
  internal: true,
8804
- usage: `Usage: dld find-collisions [--base <ref>]
8788
+ usage: `Usage: dld commit-reindex --base <ref> [--squash] < plan
8805
8789
 
8806
- Print <path>\\t<DL-NNN> for each decision added on this branch whose ID is taken on the base
8807
- branch or in an open pull request.
8790
+ Read a rename plan on standard input and commit the renames, which rename-decision has
8791
+ already applied. By default this adds one commit on top of HEAD with the plan's paths and
8792
+ every tracked file changed since HEAD; untracked files are left out. On failure the index is
8793
+ restored.
8794
+
8795
+ With --squash, the branch's commits since the merge-base with <ref> are squashed into one
8796
+ reindex commit instead, and INDEX.md is left at its merge-base state. On failure the branch,
8797
+ index and INDEX.md are restored.
8808
8798
 
8809
8799
  Options:
8810
- --base <ref> Base ref (default: origin/main)
8800
+ --base <ref> Base ref (required)
8801
+ --squash Squash the branch instead of adding a commit
8811
8802
  `,
8812
8803
  run(args, io, ctx) {
8813
- const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
8804
+ const { values } = parseCommandArgs({
8805
+ args: [...args],
8806
+ options: { base: { type: "string" }, squash: { type: "boolean" } }
8807
+ });
8814
8808
  const base = baseOption(values.base);
8815
- const { collisions, skipped } = findCollisions(ctx, loadProject(ctx), base);
8816
- for (const { path, id } of collisions) io.stdout(`${path} ${id}
8809
+ const project = loadProject(ctx);
8810
+ const { commit: commit2, onto } = commitReindex(ctx, project, ctx.readStdin(), base, {
8811
+ squash: values.squash === true
8812
+ });
8813
+ io.stdout(`Created reindex commit ${commit2} on top of ${onto}
8817
8814
  `);
8818
- if (skipped !== void 0) io.stderr(skippedNotice(skipped));
8819
8815
  return EXIT_OK;
8820
8816
  }
8821
8817
  };
8822
8818
 
8823
- // src/core/audit.ts
8824
- import { basename as basename6, relative as relative7, sep as sep4 } from "node:path";
8825
- var MENTION = /DL-\d+/g;
8826
- var toPosix = (path) => path.split(sep4).join("/");
8827
- var idNumber2 = (id) => Number.parseInt(id.slice(3), 10);
8828
- function recordsChangedSinceAudit(ctx, { paths }) {
8829
- const git = gitAt(ctx, paths.root);
8830
- const stored = stateString(readStateSection(ctx, paths, "audit"), "commit_hash");
8831
- const commit2 = resolveStateCommit(git, stored);
8832
- if (commit2 === void 0) return void 0;
8833
- const records = recordsPathspec(paths);
8834
- return /* @__PURE__ */ new Set([
8835
- ...nulSeparated(git("diff", "-z", "--name-only", commit2, "--", records)),
8836
- ...nulSeparated(git("ls-files", "-z", "--others", "--exclude-standard", "--", records))
8837
- ]);
8819
+ // src/core/init.ts
8820
+ import { join as join9 } from "node:path";
8821
+ function parseMode(value) {
8822
+ if (value !== "flat" && value !== "namespaced") {
8823
+ throw new DldError(`mode must be 'flat' or 'namespaced', got '${value}'.`);
8824
+ }
8825
+ return value;
8838
8826
  }
8839
- function findMissingAmends(ctx, project, { all }) {
8840
- const { paths } = project;
8841
- const changed = all ? void 0 : recordsChangedSinceAudit(ctx, project);
8842
- const found = [];
8843
- const files = listRecordFiles(ctx, paths.recordsDir).sort(
8844
- (a, b) => recordNumber(a) - recordNumber(b)
8845
- );
8846
- for (const file of files) {
8847
- const rel = toPosix(relative7(paths.root, file));
8848
- if (changed !== void 0 && !changed.has(rel)) continue;
8849
- const text = ctx.fs.readFile(file);
8850
- const record = parseRecord(text, rel);
8851
- const source = basename6(file, ".md");
8852
- const declared = /* @__PURE__ */ new Set([...record.supersedes, ...record.amends]);
8853
- const mentioned = new Set(recordBody(text).match(MENTION) ?? []);
8854
- const refs = [...mentioned].filter((id) => id !== source && !declared.has(id)).sort((a, b) => idNumber2(a) - idNumber2(b));
8855
- for (const referenced of refs) found.push({ source, referenced });
8827
+ function renderConfig(mode, namespaces) {
8828
+ const lines = ["decisions_dir: decisions", `mode: ${mode}`];
8829
+ if (mode === "namespaced") lines.push("namespaces:", ...namespaces.map((ns) => ` - ${ns}`));
8830
+ lines.push("annotation_prefix: '@decision'");
8831
+ return `${lines.join("\n")}
8832
+ `;
8833
+ }
8834
+ function createConfig(ctx, root, mode, namespaces) {
8835
+ if (mode === "namespaced" && namespaces.length === 0) {
8836
+ throw new DldError("namespaced mode requires at least one namespace.");
8856
8837
  }
8857
- return found;
8838
+ const path = join9(root, CONFIG_FILE);
8839
+ createFileExclusive(ctx, path, renderConfig(mode, namespaces), `${CONFIG_FILE} already exists.`);
8840
+ return path;
8858
8841
  }
8859
- function updateAuditState(ctx, { paths }) {
8860
- const timestamp = formatTimestamp(ctx.now());
8861
- const commit2 = shortHead(ctx, paths.root);
8862
- writeStateSection(ctx, paths, "audit", { last_run: timestamp, commit_hash: commit2 });
8863
- return { timestamp, commit: commit2 };
8842
+ function createDirectories(ctx, { config, paths }) {
8843
+ ctx.fs.mkdir(paths.decisionsDir);
8844
+ ctx.fs.mkdir(paths.recordsDir);
8845
+ if (config.mode !== "namespaced") return;
8846
+ for (const namespace of config.namespaces) {
8847
+ const dir = join9(paths.recordsDir, namespace);
8848
+ ctx.fs.mkdir(dir);
8849
+ const keep = join9(dir, ".gitkeep");
8850
+ if (!ctx.fs.exists(keep)) createFileExclusive(ctx, keep, "", `${keep} already exists.`);
8851
+ }
8864
8852
  }
8865
8853
 
8866
- // src/cli/commands/find-missing-amends.ts
8867
- var findMissingAmendsCommand = {
8868
- name: "find-missing-amends",
8869
- summary: "List decision IDs mentioned in a body but not declared",
8854
+ // src/cli/commands/create-config.ts
8855
+ var createConfigCommand = {
8856
+ name: "create-config",
8857
+ summary: "Create dld.config.yaml at the project root",
8870
8858
  internal: true,
8871
- usage: `Usage: dld find-missing-amends [--all]
8872
-
8873
- Print <source-id>:<referenced-id> for each decision ID a record's body mentions without
8874
- listing it in supersedes or amends. Only records changed since the last audit are checked.
8875
-
8876
- Options:
8877
- --all Check every record
8878
- `,
8859
+ usage: "Usage: dld create-config <flat|namespaced> [namespace ...]\n\nCreate dld.config.yaml. Namespaced mode needs at least one namespace.\n",
8879
8860
  run(args, io, ctx) {
8880
- const { values } = parseCommandArgs({ args: [...args], options: { all: { type: "boolean" } } });
8881
- const found = findMissingAmends(ctx, loadProject(ctx), { all: values.all === true });
8882
- for (const { source, referenced } of found) io.stdout(`${source}:${referenced}
8861
+ const { positionals } = parseCommandArgs({
8862
+ args: [...args],
8863
+ options: {},
8864
+ allowPositionals: true
8865
+ });
8866
+ const [mode, ...namespaces] = positionals;
8867
+ if (mode === void 0) throw new UsageError("missing <flat|namespaced>");
8868
+ const path = createConfig(ctx, findProjectRoot(ctx), parseMode(mode), namespaces);
8869
+ io.stdout(`Created ${path}
8883
8870
  `);
8884
8871
  return EXIT_OK;
8885
8872
  }
8886
8873
  };
8887
8874
 
8888
- // src/core/rename.ts
8889
- import { Buffer as Buffer2 } from "node:buffer";
8890
- import { join as join12, posix as posix3 } from "node:path";
8891
- var idPattern = (id, flags = "") => new RegExp(`${id}(?![0-9])`, flags);
8892
- function changedFiles(ctx, paths, commit2) {
8893
- return nulSeparated(
8894
- gitAt(ctx, paths.root)(
8895
- "diff",
8896
- "-z",
8897
- "--find-renames",
8898
- "--name-only",
8899
- "--diff-filter=AMR",
8900
- commit2
8901
- )
8875
+ // src/core/decisions.ts
8876
+ import { join as join10, relative as relative6 } from "node:path";
8877
+ function updateStatus(ctx, project, id, status) {
8878
+ const path = findRecordFile(ctx, project.paths.recordsDir, id, project.paths.root);
8879
+ if (path === void 0) throw new DldError(`decision ${id} not found.`);
8880
+ const text = ctx.fs.readFile(path);
8881
+ writeFileAtomic(ctx, path, setStatus(text, status, relative6(project.paths.root, path)));
8882
+ return path;
8883
+ }
8884
+ function createDecision(ctx, project, input) {
8885
+ if (!isCanonicalId(input.id)) {
8886
+ const canonical = canonicalSpelling(input.id);
8887
+ throw new DldError(
8888
+ canonical === void 0 ? `invalid decision ID '${input.id}'; expected DL-<digits>, e.g. DL-001.` : `decision ID '${input.id}' is not canonical; use ${canonical}.`,
8889
+ EXIT_USAGE
8890
+ );
8891
+ }
8892
+ const number = Number.parseInt(input.id.slice(3), 10);
8893
+ const { config } = project;
8894
+ if (input.namespace !== void 0) {
8895
+ if (config.mode !== "namespaced") {
8896
+ throw new DldError("--namespace is only valid in a namespaced project.", EXIT_USAGE);
8897
+ }
8898
+ if (!config.namespaces.includes(input.namespace)) {
8899
+ throw new DldError(
8900
+ `unknown namespace '${input.namespace}'; configured namespaces: ${config.namespaces.join(", ")}.`,
8901
+ EXIT_USAGE
8902
+ );
8903
+ }
8904
+ }
8905
+ const existing = listRecordFiles(ctx, project.paths.recordsDir).find(
8906
+ (path2) => recordNumber(path2) === number
8902
8907
  );
8903
- }
8904
- function rewriteFile(ctx, path, edit) {
8905
- if (!ctx.fs.isRegularFile(path)) return;
8906
- const bytes = ctx.fs.readBytes(path);
8907
- if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return;
8908
- const text = Buffer2.from(bytes).toString("latin1");
8909
- const edited = edit(text);
8910
- if (edited === text) return;
8911
- writeFileAtomic(ctx, path, Buffer2.from(edited, "latin1"), ctx.fs.fileMode(path));
8912
- }
8913
- function renameDecision(ctx, project, rename, base) {
8914
- const { paths, config } = project;
8915
- const { path, oldId, newId } = rename;
8916
- const problem = renameProblem(paths, rename);
8917
- if (problem !== void 0) throw new DldError(problem);
8918
- const full = join12(paths.root, path);
8919
- if (!ctx.fs.isRegularFile(full)) throw new DldError(`${path} not found.`);
8920
- const record = parseRecord(ctx.fs.readFile(full), path);
8921
- if (record.id !== oldId) throw new DldError(`${path} has id ${record.id}, not ${oldId}.`);
8922
- const newPath = posix3.join(posix3.dirname(path), `${newId}.md`);
8923
- const newFull = join12(paths.root, newPath);
8924
- if (ctx.fs.lexists(newFull)) throw new DldError(`${newPath} already exists.`);
8925
- verifyBase(ctx, paths, base);
8926
- const since = mergeBase(ctx, paths, base);
8927
- const scannable = new Set(listScannableFiles(ctx, scanOptionsFor(project)));
8928
- gitAt(ctx, paths.root)("mv", "--", path, newPath);
8929
- const substitute = (text) => text.replace(idPattern(oldId, "g"), newId);
8930
- rewriteFile(ctx, newFull, (text) => substitute(setId(text, oldId, newId)));
8931
- const decisions = `${decisionsPathspec(paths)}/`;
8932
- const prefix = Buffer2.from(config.annotationPrefix, "utf8").toString("latin1");
8933
- const oldAnnotation = `${prefix}(${oldId})`;
8934
- const newAnnotation = `${prefix}(${newId})`;
8935
- for (const file of changedFiles(ctx, paths, since)) {
8936
- if (file === newPath) continue;
8937
- if (file.startsWith(decisions)) {
8938
- rewriteFile(ctx, join12(paths.root, file), substitute);
8939
- } else if (scannable.has(file)) {
8940
- rewriteFile(
8941
- ctx,
8942
- join12(paths.root, file),
8943
- (text) => text.split(oldAnnotation).join(newAnnotation)
8944
- );
8945
- }
8946
- }
8947
- return newPath;
8948
- }
8949
- function findStaleMentions(ctx, { paths }, planText, base) {
8950
- const renames = parseRenamePlan(paths, planText);
8951
- if (renames.length === 0) throw new DldError("no rename plan on stdin.");
8952
- verifyBase(ctx, paths, base);
8953
- const decisions = `${decisionsPathspec(paths)}/`;
8954
- const files = changedFiles(ctx, paths, mergeBase(ctx, paths, base)).flatMap((file) => {
8955
- if (file.startsWith(decisions)) return [];
8956
- const full = join12(paths.root, file);
8957
- if (!ctx.fs.isRegularFile(full)) return [];
8958
- const text = ctx.fs.readFile(full);
8959
- if (text.slice(0, BINARY_SNIFF_BYTES).includes("\0")) return [];
8960
- return [{ file, lines: text.split("\n").map((line) => line.replace(/\r$/, "")) }];
8961
- });
8962
- const found = [];
8963
- for (const { oldId, newId } of renames) {
8964
- const pattern = idPattern(oldId);
8965
- for (const { file, lines } of files) {
8966
- lines.forEach((text, index) => {
8967
- if (pattern.test(text)) found.push({ file, line: index + 1, oldId, newId, text });
8968
- });
8969
- }
8970
- }
8971
- return found;
8972
- }
8973
- function formatStaleMention({ file, line, oldId, newId, text }) {
8974
- return `${file} ${line} ${oldId} ${newId} ${text}`;
8908
+ if (existing !== void 0) {
8909
+ throw new DldError(
8910
+ `decision ${input.id} already exists: ${relative6(project.paths.root, existing)}`
8911
+ );
8912
+ }
8913
+ const namespaced = input.namespace !== void 0;
8914
+ const dir = namespaced ? join10(project.paths.recordsDir, input.namespace ?? "") : project.paths.recordsDir;
8915
+ ctx.fs.mkdir(dir);
8916
+ const path = join10(dir, `${input.id}.md`);
8917
+ const content = renderNewRecord({
8918
+ id: input.id,
8919
+ title: input.title,
8920
+ timestamp: formatTimestamp(ctx.now()),
8921
+ ...namespaced && input.namespace !== void 0 ? { namespace: input.namespace } : {},
8922
+ tags: input.tags,
8923
+ supersedes: input.supersedes,
8924
+ amends: input.amends,
8925
+ body: input.body
8926
+ });
8927
+ createFileExclusive(ctx, path, content, `${path} already exists.`);
8928
+ return path;
8975
8929
  }
8976
8930
 
8977
- // src/cli/commands/find-stale-mentions.ts
8978
- var findStaleMentionsCommand = {
8979
- name: "find-stale-mentions",
8980
- summary: "List remaining mentions of renamed decision IDs",
8931
+ // src/cli/commands/create-decision.ts
8932
+ var createDecisionCommand = {
8933
+ name: "create-decision",
8934
+ summary: "Create a proposed decision record",
8981
8935
  internal: true,
8982
- usage: `Usage: dld find-stale-mentions --base <ref> < plan
8936
+ usage: `Usage: dld create-decision --id <DL-NNN> --title <title> [options]
8983
8937
 
8984
- Read a rename plan (<path>\\t<DL-OLD>\\t<DL-NEW> per line) on standard input and print
8985
- <path>\\t<line>\\t<DL-OLD>\\t<DL-NEW>\\t<text> for each remaining DL-OLD mention in changed
8986
- files outside the decisions directory.
8938
+ Create a decision record with status proposed and print its path.
8987
8939
 
8988
8940
  Options:
8989
- --base <ref> Base ref for the local change set (required)
8941
+ --id <DL-NNN> Decision ID (required)
8942
+ --title <title> Title (required)
8943
+ --namespace <name> Namespace directory (namespaced projects only)
8944
+ --tags <a,b> Comma-separated tags
8945
+ --supersedes <DL-X,...> Decisions this one supersedes
8946
+ --amends <DL-X,...> Decisions this one amends
8947
+ --body-stdin Read the markdown body from standard input
8990
8948
  `,
8991
8949
  run(args, io, ctx) {
8992
- const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
8993
- if (!values.base) throw new DldError("--base is required.");
8994
- const base = baseOption(values.base);
8995
- const project = loadProject(ctx);
8996
- for (const mention of findStaleMentions(ctx, project, ctx.readStdin(), base)) {
8997
- io.stdout(`${formatStaleMention(mention)}
8998
- `);
8950
+ const { values } = parseCommandArgs({
8951
+ args: [...args],
8952
+ options: {
8953
+ id: { type: "string" },
8954
+ title: { type: "string" },
8955
+ namespace: { type: "string" },
8956
+ tags: { type: "string" },
8957
+ supersedes: { type: "string" },
8958
+ amends: { type: "string" },
8959
+ "body-stdin": { type: "boolean" }
8960
+ }
8961
+ });
8962
+ const body = values["body-stdin"] === true ? ctx.readStdin() : "";
8963
+ const { id, title } = values;
8964
+ if (id === void 0 || id === "" || title === void 0 || title === "") {
8965
+ throw new UsageError("--id and --title are required.");
8999
8966
  }
8967
+ const namespace = values.namespace === "" ? void 0 : values.namespace;
8968
+ const path = createDecision(ctx, loadProject(ctx), {
8969
+ id,
8970
+ title,
8971
+ ...namespace === void 0 ? {} : { namespace },
8972
+ tags: values.tags ?? "",
8973
+ supersedes: values.supersedes ?? "",
8974
+ amends: values.amends ?? "",
8975
+ body
8976
+ });
8977
+ io.stdout(`${path}
8978
+ `);
9000
8979
  return EXIT_OK;
9001
8980
  }
9002
8981
  };
9003
8982
 
9004
- // src/cli/commands/init.ts
9005
- import { join as join17, relative as relative8 } from "node:path";
8983
+ // src/cli/commands/create-directories.ts
8984
+ var createDirectoriesCommand = {
8985
+ name: "create-directories",
8986
+ summary: "Create the decisions directory structure from the config",
8987
+ internal: true,
8988
+ usage: "Usage: dld create-directories\n\nCreate the decisions and records directories, and one per namespace.\n",
8989
+ run(args, io, ctx) {
8990
+ parseCommandArgs({ args: [...args], options: {} });
8991
+ createDirectories(ctx, loadProject(ctx));
8992
+ io.stdout("Created decisions directory structure.\n");
8993
+ return EXIT_OK;
8994
+ }
8995
+ };
8996
+
8997
+ // src/core/index-file.ts
8998
+ import { basename as basename6, join as join11, relative as relative7, sep as sep3 } from "node:path";
8999
+ var INDEX_FILE = "INDEX.md";
9000
+ function renderIndex(rows, mode) {
9001
+ const namespaced = mode === "namespaced";
9002
+ const lines = [
9003
+ "# Decision Log",
9004
+ "",
9005
+ namespaced ? "| ID | Title | Status | Namespace | Tags |" : "| ID | Title | Status | Tags |",
9006
+ namespaced ? "|----|-------|--------|-----------|------|" : "|----|-------|--------|------|"
9007
+ ];
9008
+ const sorted = [...rows].sort((a, b) => b.number - a.number);
9009
+ for (const { record } of sorted) {
9010
+ const cells = [record.id, record.title, record.status];
9011
+ if (namespaced) cells.push(record.namespace ?? "");
9012
+ cells.push(record.tags.join(", "));
9013
+ lines.push(`| ${cells.join(" | ")} |`);
9014
+ }
9015
+ return `${lines.join("\n")}
9016
+ `;
9017
+ }
9018
+ function indexPath(paths) {
9019
+ return join11(paths.decisionsDir, INDEX_FILE);
9020
+ }
9021
+ function collectIndexRows(ctx, paths, includeBase) {
9022
+ const localFiles = listRecordFiles(ctx, paths.recordsDir);
9023
+ const rows = localFiles.map((path) => ({
9024
+ number: recordNumber(path),
9025
+ record: parseRecord(ctx.fs.readFile(path), relative7(paths.root, path))
9026
+ }));
9027
+ if (includeBase === void 0) return rows;
9028
+ const git = (...args) => ctx.git(["-C", paths.root, ...args]);
9029
+ try {
9030
+ git("rev-parse", "--verify", "--quiet", `${includeBase}^{commit}`);
9031
+ } catch (error) {
9032
+ if (error instanceof GitCommandError) {
9033
+ throw new DldError(`--include-base ref '${includeBase}' not found.`);
9034
+ }
9035
+ throw error;
9036
+ }
9037
+ const localNames = new Set(localFiles.map((path) => basename6(path)));
9038
+ const recordsRel = relative7(paths.root, paths.recordsDir).split(sep3).join("/");
9039
+ const basePaths = git("ls-tree", "-r", "--name-only", includeBase, "--", recordsRel).split("\n").filter((path) => RECORD_FILE.test(basename6(path)) && !localNames.has(basename6(path)));
9040
+ for (const path of basePaths) {
9041
+ rows.push({
9042
+ number: recordNumber(path),
9043
+ record: parseRecord(git("show", `${includeBase}:${path}`), `${includeBase}:${path}`)
9044
+ });
9045
+ }
9046
+ return rows;
9047
+ }
9048
+ function writeIndex(ctx, paths, content) {
9049
+ writeFileAtomic(ctx, indexPath(paths), content);
9050
+ }
9051
+
9052
+ // src/cli/install.ts
9053
+ import { relative as relative8, sep as sep4 } from "node:path";
9054
+
9055
+ // src/generate/gitattributes.ts
9056
+ import { join as join12 } from "node:path";
9057
+ var GITATTRIBUTES = ".gitattributes";
9058
+ var INDEX_MERGE_COMMENT = "# dld-kit: keep both sides' rows when merging the decision index";
9059
+ var MERGE_ATTRIBUTE = /^([-!]?merge(=|$)|binary$)/;
9060
+ var SPECIAL = /[\s"\\*?[\]]|^[!#]|(^|\/)\.\.(\/|$)/;
9061
+ function unquote(pattern) {
9062
+ return pattern.length > 1 && pattern.startsWith('"') && pattern.endsWith('"') ? pattern.slice(1, -1).replace(/\\(.)/g, "$1") : pattern;
9063
+ }
9064
+ function planIndexMergeAttribute(ctx, root, index) {
9065
+ const path = join12(root, GITATTRIBUTES);
9066
+ const wanted = `/${index} merge=union`;
9067
+ if (SPECIAL.test(index)) {
9068
+ return {
9069
+ warnings: [
9070
+ `the decision index ${index} cannot be named in ${GITATTRIBUTES} as a plain pattern, so dld-kit does not add its merge=union line. Add a line that matches it yourself, or two branches that add decisions will conflict on it.`
9071
+ ]
9072
+ };
9073
+ }
9074
+ if (ctx.fs.lexists(path) && !ctx.fs.isRegularFile(path)) {
9075
+ return {
9076
+ warnings: [
9077
+ `${GITATTRIBUTES} is not a regular file, so dld-kit does not edit it. Add this line to it yourself: ${wanted}`
9078
+ ]
9079
+ };
9080
+ }
9081
+ const text = ctx.fs.lexists(path) ? ctx.fs.readFile(path) : "";
9082
+ const lines = text.split(/\r?\n/);
9083
+ const warnings = [];
9084
+ let set = false;
9085
+ lines.forEach((line, index_) => {
9086
+ const [raw, ...attributes] = line.trim().split(/\s+/);
9087
+ if (raw === void 0 || raw === "" || raw.startsWith("#")) return;
9088
+ const pattern = unquote(raw);
9089
+ const forIndex = pattern === `/${index}` || pattern === index;
9090
+ if (forIndex && attributes.some((attribute) => MERGE_ATTRIBUTE.test(attribute))) set = true;
9091
+ if (!forIndex && lines[index_ - 1] === INDEX_MERGE_COMMENT) {
9092
+ warnings.push(
9093
+ `${GITATTRIBUTES} still sets a merge driver for ${pattern}, an earlier decisions directory. Remove that line if the index is no longer there.`
9094
+ );
9095
+ }
9096
+ });
9097
+ if (set) return { warnings };
9098
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
9099
+ const separator = text === "" ? "" : text.endsWith("\n") ? eol : eol + eol;
9100
+ return {
9101
+ content: `${text}${separator}${INDEX_MERGE_COMMENT}${eol}${wanted}${eol}`,
9102
+ creates: !ctx.fs.lexists(path),
9103
+ warnings
9104
+ };
9105
+ }
9106
+ function applyIndexMergeAttribute(ctx, root, plan) {
9107
+ if (plan.content === void 0) return false;
9108
+ writeFileAtomic(ctx, join12(root, GITATTRIBUTES), plan.content);
9109
+ return true;
9110
+ }
9006
9111
 
9007
9112
  // src/generate/harnesses.ts
9008
9113
  import { join as join13 } from "node:path";
@@ -9219,491 +9324,995 @@ var HARNESS_NAMES = HARNESSES.map((harness) => harness.name);
9219
9324
  function findHarness(name) {
9220
9325
  return HARNESSES.find((harness) => harness.name === name);
9221
9326
  }
9222
- function detectHarnesses(ctx, root) {
9223
- const found = (marker) => ctx.fs.lexists(join13(root, marker));
9224
- const detected = [];
9225
- for (const harness of HARNESSES) {
9226
- const marker = harness.markers.find(found);
9227
- if (marker !== void 0) detected.push({ harness, marker });
9327
+ function detectHarnesses(ctx, root) {
9328
+ const found = (marker) => ctx.fs.lexists(join13(root, marker));
9329
+ const detected = [];
9330
+ for (const harness of HARNESSES) {
9331
+ const marker = harness.markers.find(found);
9332
+ if (marker !== void 0) detected.push({ harness, marker });
9333
+ }
9334
+ const generic = GENERIC_MARKERS.find(found);
9335
+ const agentsLayoutFound = detected.some(({ harness }) => harness.layout === AGENTS_LAYOUT);
9336
+ const codex = findHarness("codex");
9337
+ if (generic !== void 0 && !agentsLayoutFound && codex !== void 0) {
9338
+ detected.push({ harness: codex, marker: generic });
9339
+ detected.sort((a, b) => HARNESSES.indexOf(a.harness) - HARNESSES.indexOf(b.harness));
9340
+ }
9341
+ return detected;
9342
+ }
9343
+ function targetsFor(harnesses) {
9344
+ return {
9345
+ layouts: new Set(harnesses.map((harness) => harness.layout)),
9346
+ rules: new Set(harnesses.map((harness) => harness.rule))
9347
+ };
9348
+ }
9349
+
9350
+ // src/generate/rule.ts
9351
+ import { dirname as dirname2, join as join14, posix as posix3 } from "node:path";
9352
+
9353
+ // templates/rules/dld-workflow.md
9354
+ var dld_workflow_default = "# DLD (Decision-Linked Development)\n\nThis project uses Decision-Linked Development. Decision records (DL-*.md) live in the `records/` subdirectory of the decisions directory set in `dld.config.yaml` (`decisions/` by default). High-level docs (INDEX.md, OVERVIEW.md, SNAPSHOT.md) live in the decisions directory.\n\n## Rules\n\n- When you encounter `@decision(DL-XXX)` annotations in code, read the referenced decision with the dld-lookup skill BEFORE modifying the annotated code.\n- ALWAYS look up and verify related decisions before modifying annotated code. Do not skip this step.\n- NEVER modify code in a way that contradicts an existing decision without first confirming with the user. If the change requires breaking a previous decision, a new decision must be recorded (with the dld-decide skill) that explicitly supersedes the old one. If it only partially modifies a previous decision, record it as an amendment instead.\n- Decision records that are not on the base branch yet (e.g. `main`), or still `proposed` there, are drafts: edit them freely, whatever their status. On records already on the base branch, the frontmatter can always be updated (except `id` and `timestamp`), but change the prose below it only through the dld-adjust skill, which follows `decision_edits` in `dld.config.yaml`: `block` (the default) never edits it and records an amendment or superseding decision instead, `ask` edits after the user confirms, `allow` edits.\n\n## Skills\n\n- dld-decide: record a new decision\n- dld-plan: break down a feature into multiple grouped decisions\n- dld-implement: implement proposed decisions\n- dld-lookup: query decisions by ID, tag, or code path\n- dld-adjust: adjust or update existing decisions\n- dld-audit: scan for drift between decisions and code\n- dld-snapshot: regenerate SNAPSHOT.md and OVERVIEW.md from the decision log\n- dld-status: a quick overview of the decision log state\n- dld-retrofit: generate decisions from an existing codebase\n- dld-reindex: resolve decision-ID collisions with the base branch and open PRs\n";
9355
+
9356
+ // src/generate/rule.ts
9357
+ var RULE_TEXT = dld_workflow_default;
9358
+ var RULE_SOURCE = "templates/rules/dld-workflow.md";
9359
+ var DEFAULT_PREFIX = "@decision";
9360
+ function ruleTextFor(prefix) {
9361
+ return RULE_TEXT.replaceAll(`\`${DEFAULT_PREFIX}(`, () => `\`${prefix}(`);
9362
+ }
9363
+ function projectRuleText(ctx, root) {
9364
+ if (!ctx.fs.exists(join14(root, CONFIG_FILE))) return RULE_TEXT;
9365
+ try {
9366
+ return ruleTextFor(loadConfig({ ...ctx, warn: () => {
9367
+ } }, root).annotationPrefix);
9368
+ } catch {
9369
+ return RULE_TEXT;
9370
+ }
9371
+ }
9372
+ var CLAUDE_RULE_FILE = ".claude/rules/dld-workflow.md";
9373
+ var AGENTS_RULE_FILE = ".agents/rules/dld-workflow.md";
9374
+ var CLAUDE_IMPORT_FILE = ".claude/CLAUDE.md";
9375
+ var BLOCK_START = "<!-- dld-kit:start -->";
9376
+ var BLOCK_END = "<!-- dld-kit:end -->";
9377
+ var AGENTS_MD = "AGENTS.md";
9378
+ var CLAUDE_MD = "CLAUDE.md";
9379
+ var CLAUDE_FILES = [CLAUDE_MD, CLAUDE_IMPORT_FILE, "CLAUDE.local.md"];
9380
+ var SHARED_CLAUDE_FILES = [CLAUDE_MD, CLAUDE_IMPORT_FILE];
9381
+ var RULE_FILES = {
9382
+ "claude-file": CLAUDE_RULE_FILE,
9383
+ "agents-file": AGENTS_RULE_FILE
9384
+ };
9385
+ function notice(version2, what) {
9386
+ return `<!-- Generated by dld-kit ${version2} from ${RULE_SOURCE}. Do not edit: updating the rule overwrites this ${what}. -->`;
9387
+ }
9388
+ var STAMP = /<!-- Generated by dld-kit (\S+) from /;
9389
+ function ruleVersion(content) {
9390
+ return STAMP.exec(content)?.[1];
9391
+ }
9392
+ function renderRuleFile(channel, text, version2) {
9393
+ const frontmatter = channel === "agents-file" ? "---\ntrigger: always_on\n---\n" : "";
9394
+ return `${frontmatter}${notice(version2, "file")}
9395
+
9396
+ ${text}`;
9397
+ }
9398
+ function renderImportFile(version2) {
9399
+ return `<!-- Generated by dld-kit ${version2} for Claude Code: it loads AGENTS.md, which holds the DLD rule, through the import below. Do not edit: updating the rule overwrites this file. -->
9400
+
9401
+ @../AGENTS.md
9402
+ `;
9403
+ }
9404
+ var IMPORT_STAMP = /^<!-- Generated by dld-kit (\S+) for Claude Code:/;
9405
+ function importFileVersion(content) {
9406
+ const version2 = IMPORT_STAMP.exec(content)?.[1];
9407
+ if (version2 === void 0) return void 0;
9408
+ const afterNotice = (text) => text.replace(/\r\n/g, "\n").replace(/^[^\n]*\n/, "");
9409
+ return afterNotice(content) === afterNotice(renderImportFile(version2)) ? version2 : void 0;
9410
+ }
9411
+ function editedImportWarnings(content) {
9412
+ if (!IMPORT_STAMP.test(content) || importFileVersion(content) !== void 0) return [];
9413
+ const warning = `${CLAUDE_IMPORT_FILE} holds content dld-kit did not write, so dld-kit leaves it as it is and no longer updates it. Remove its "Generated by dld-kit" line to keep it as your own file.`;
9414
+ if (importsAgentsMd(content, CLAUDE_IMPORT_FILE)) return [warning];
9415
+ return [
9416
+ `${warning} It no longer imports AGENTS.md, so Claude Code may not load the DLD rule from it: add the line @../AGENTS.md back.`
9417
+ ];
9418
+ }
9419
+ function renderBlock(text, version2) {
9420
+ const body = text.endsWith("\n") ? text.slice(0, -1) : text;
9421
+ return [BLOCK_START, notice(version2, "block"), "", ...body.split("\n"), BLOCK_END];
9422
+ }
9423
+ function findBlock(content, file) {
9424
+ const starts = [];
9425
+ const ends = [];
9426
+ let offset = 0;
9427
+ for (const line of content.split("\n")) {
9428
+ const text = line.endsWith("\r") ? line.slice(0, -1) : line;
9429
+ if (text === BLOCK_START) starts.push(offset);
9430
+ if (text === BLOCK_END) ends.push(offset);
9431
+ offset += line.length + 1;
9432
+ }
9433
+ if (starts.length === 0 && ends.length === 0) return void 0;
9434
+ const [from] = starts;
9435
+ const [end] = ends;
9436
+ if (starts.length !== 1 || ends.length !== 1 || from === void 0 || end === void 0) {
9437
+ throw malformed(file);
9438
+ }
9439
+ if (end < from) throw malformed(file);
9440
+ return { from, to: end + BLOCK_END.length };
9441
+ }
9442
+ function malformed(file) {
9443
+ return new DldError(
9444
+ `${file}: expected one '${BLOCK_START}' line followed by one '${BLOCK_END}' line; fix the dld-kit markers by hand`
9445
+ );
9446
+ }
9447
+ function upsertBlock(content, block, file) {
9448
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
9449
+ const rendered = block.join(eol);
9450
+ const span = findBlock(content, file);
9451
+ if (span !== void 0) return content.slice(0, span.from) + rendered + content.slice(span.to);
9452
+ if (content === "") return rendered + eol;
9453
+ const separator = content.endsWith("\n") ? eol : eol + eol;
9454
+ return content + separator + rendered + eol;
9455
+ }
9456
+ function planRule(ctx, root, channels, version2, { harnesses = [] } = {}, text = RULE_TEXT) {
9457
+ const plan = { writes: [], removals: [], warnings: [] };
9458
+ const regular = (file) => ctx.fs.isRegularFile(join14(root, file));
9459
+ const read = (file) => regular(file) ? ctx.fs.readFile(join14(root, file)) : "";
9460
+ const resolved = (file) => {
9461
+ const full = join14(root, file);
9462
+ return ctx.fs.exists(full) ? ctx.fs.realPath(full) : full;
9463
+ };
9464
+ let blockFiles = [AGENTS_MD, CLAUDE_MD].filter(
9465
+ (file) => regular(file) && findBlock(read(file), file) !== void 0
9466
+ );
9467
+ if (blockFiles.length === 0 && channels.has("block")) {
9468
+ const targets = harnesses.filter((h) => h.rule === "block");
9469
+ const file = blockPlacement(ctx, root, targets);
9470
+ blockFiles = [file];
9471
+ plan.warnings.push(...switchWarnings(ctx, root, file, targets));
9472
+ }
9473
+ const pending = /* @__PURE__ */ new Map();
9474
+ for (const file of blockFiles) {
9475
+ const content = read(file);
9476
+ const updated = upsertBlock(content, renderBlock(text, version2), file);
9477
+ pending.set(file, updated);
9478
+ if (updated !== content) plan.writes.push({ path: file, content: updated });
9479
+ }
9480
+ const linkedDir = symlinkedPart(ctx, root, dirname2(CLAUDE_IMPORT_FILE));
9481
+ const ownsImport = linkedDir === void 0 && importFileVersion(read(CLAUDE_IMPORT_FILE)) !== void 0;
9482
+ const wantsImport = channels.has("claude-file") && blockFiles.includes(AGENTS_MD) && !SHARED_CLAUDE_FILES.some((file) => ctx.fs.lexists(join14(root, file)));
9483
+ if (ownsImport || wantsImport && linkedDir === void 0) {
9484
+ const content = renderImportFile(version2);
9485
+ pending.set(CLAUDE_IMPORT_FILE, content);
9486
+ if (read(CLAUDE_IMPORT_FILE) !== content) {
9487
+ plan.writes.push({ path: CLAUDE_IMPORT_FILE, content });
9488
+ }
9489
+ } else if (wantsImport) {
9490
+ plan.warnings.push(
9491
+ `${linkedDir} is a symlink, so dld-kit does not write ${CLAUDE_IMPORT_FILE}, which would import AGENTS.md for Claude Code. Claude Code reads the rule block in AGENTS.md by itself only while the project has no CLAUDE.md, ${CLAUDE_IMPORT_FILE} or CLAUDE.local.md.`
9492
+ );
9493
+ }
9494
+ const withBlock = new Set(blockFiles.map(resolved));
9495
+ const current = projectView(ctx, root);
9496
+ const view = {
9497
+ exists: (file) => pending.has(file) || current.exists(file),
9498
+ read: (file) => pending.get(file) ?? current.read(file)
9499
+ };
9500
+ const reads = (harness) => instructionFiles(harness, view)[0];
9501
+ const readsBlock = (harness) => instructionFiles(harness, view).some((file) => withBlock.has(resolved(file)));
9502
+ const ownerOf = (channel) => HARNESSES.find((h) => h.rule === channel);
9503
+ const loadsBlock = (channel) => {
9504
+ const owner = ownerOf(channel);
9505
+ return owner !== void 0 && readsBlock(owner);
9506
+ };
9507
+ if (blockFiles.length > 0) {
9508
+ plan.warnings.push(
9509
+ ...blindWarnings(
9510
+ blockFiles,
9511
+ harnesses.filter((h) => h.rule === "block" && !readsBlock(h)),
9512
+ reads
9513
+ )
9514
+ );
9515
+ }
9516
+ for (const channel of ["claude-file", "agents-file"]) {
9517
+ const path = RULE_FILES[channel];
9518
+ if (loadsBlock(channel)) {
9519
+ if (ctx.fs.lexists(join14(root, path))) plan.removals.push(path);
9520
+ } else if (channels.has(channel)) {
9521
+ refuseSymlinkedDir(ctx, root, dirname2(path));
9522
+ const content = renderRuleFile(channel, text, version2);
9523
+ if (read(path) !== content) plan.writes.push({ path, content });
9524
+ }
9525
+ }
9526
+ const ruleInstalled = blockFiles.length > 0 || channels.size > 0;
9527
+ plan.warnings.push(...legacyBlockWarnings(read(CLAUDE_MD), ruleInstalled));
9528
+ if (linkedDir === void 0)
9529
+ plan.warnings.push(...editedImportWarnings(read(CLAUDE_IMPORT_FILE)));
9530
+ return plan;
9531
+ }
9532
+ function projectView(ctx, root) {
9533
+ const exists = (file) => ctx.fs.exists(join14(root, file));
9534
+ return {
9535
+ exists,
9536
+ read: (file) => exists(file) && !ctx.fs.isDirectory(join14(root, file)) ? ctx.fs.readFile(join14(root, file)) : ""
9537
+ };
9538
+ }
9539
+ function instructionFiles(harness, view) {
9540
+ if (harness.name !== "claude") {
9541
+ if (harness.readsAll) return harness.instructions.filter((f) => view.exists(f));
9542
+ const file = harness.instructions.find((f) => view.exists(f));
9543
+ return file === void 0 ? [] : [file];
9544
+ }
9545
+ const files = CLAUDE_FILES.filter((file) => view.exists(file));
9546
+ if (files.length === 0) return view.exists(AGENTS_MD) ? [AGENTS_MD] : [];
9547
+ const imported = files.some((file) => importsAgentsMd(view.read(file), file));
9548
+ return imported && view.exists(AGENTS_MD) ? [...files, AGENTS_MD] : files;
9549
+ }
9550
+ var FENCE = /^ {0,3}(`{3,}|~{3,})/;
9551
+ function importsAgentsMd(content, file) {
9552
+ const dir = posix3.dirname(file);
9553
+ let fence;
9554
+ for (const line of content.split(/\r?\n/)) {
9555
+ const marker = FENCE.exec(line)?.[1];
9556
+ if (marker !== void 0) {
9557
+ if (fence === void 0) fence = marker;
9558
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
9559
+ continue;
9560
+ }
9561
+ if (fence !== void 0) continue;
9562
+ const prose = line.replace(/`[^`]*`/g, "");
9563
+ for (const match of prose.matchAll(/(?:^|\s)@(\S+)/g)) {
9564
+ const target = match[1];
9565
+ if (target !== void 0 && posix3.normalize(posix3.join(dir, target)) === AGENTS_MD) {
9566
+ return true;
9567
+ }
9568
+ }
9569
+ }
9570
+ return false;
9571
+ }
9572
+ function blindWarnings(blockFiles, blind, reads) {
9573
+ const byFile = /* @__PURE__ */ new Map();
9574
+ for (const harness of blind) {
9575
+ const file = reads(harness) ?? harness.instructions[0] ?? AGENTS_MD;
9576
+ byFile.set(file, [...byFile.get(file) ?? [], harness]);
9577
+ }
9578
+ return [...byFile].map(([file, harnesses]) => {
9579
+ const names = harnesses.map((h) => h.title).join(" and ");
9580
+ const one = harnesses.length === 1;
9581
+ return `The dld-kit rule block is in ${blockFiles.join(" and ")}, but ${names} ${one ? "reads" : "read"} ${file}. To cover ${one ? "it" : "them"}, move the block (the dld-kit:start line through the dld-kit:end line) into ${file}, then run dld install-rule.`;
9582
+ });
9583
+ }
9584
+ function blockPlacement(ctx, root, targets) {
9585
+ if (ctx.fs.isRegularFile(join14(root, AGENTS_MD))) return AGENTS_MD;
9586
+ const agentsLinked = ctx.fs.lexists(join14(root, AGENTS_MD));
9587
+ if (ctx.fs.isRegularFile(join14(root, CLAUDE_MD)) && (agentsLinked || targets.every((h) => h.instructions.includes(CLAUDE_MD)))) {
9588
+ return CLAUDE_MD;
9589
+ }
9590
+ if (agentsLinked) {
9591
+ throw new DldError(
9592
+ `${AGENTS_MD} is not a regular file (a symlink?); dld-kit does not edit through it. Point it at a regular file or add the dld-kit block by hand.`
9593
+ );
9594
+ }
9595
+ return AGENTS_MD;
9596
+ }
9597
+ function switchWarnings(ctx, root, file, targets) {
9598
+ if (file !== AGENTS_MD || ctx.fs.lexists(join14(root, AGENTS_MD))) return [];
9599
+ const claudeMd = join14(root, CLAUDE_MD);
9600
+ if (!ctx.fs.exists(claudeMd) || ctx.fs.isDirectory(claudeMd)) return [];
9601
+ const switching = targets.filter((h) => !h.readsAll && h.instructions.includes(CLAUDE_MD));
9602
+ if (switching.length === 0) return [];
9603
+ const names = switching.map((h) => h.title).join(" and ");
9604
+ return [
9605
+ `dld-kit puts the rule block in a new AGENTS.md. ${names} will read AGENTS.md instead of CLAUDE.md from now on, so move anything ${switching.length === 1 ? "it needs" : "they need"} from CLAUDE.md into AGENTS.md.`
9606
+ ];
9607
+ }
9608
+ var LEGACY_HEADING = /^## DLD \(Decision-Linked Development\)\s*$/;
9609
+ function legacyBlockWarnings(claudeMd, ruleInstalled) {
9610
+ const span = findBlock(claudeMd, CLAUDE_MD);
9611
+ const outside = span === void 0 ? claudeMd : claudeMd.slice(0, span.from) + claudeMd.slice(span.to);
9612
+ if (!outside.split(/\r?\n/).some((line) => LEGACY_HEADING.test(line))) return [];
9613
+ const section = "CLAUDE.md has a '## DLD (Decision-Linked Development)' section from an older dld-init.";
9614
+ return [
9615
+ ruleInstalled ? `${section} dld-kit now installs the rule separately, so that section can be removed.` : `${section} Install the rule with dld install-rule --agent <name> before removing it.`
9616
+ ];
9617
+ }
9618
+ function loadsRule(ctx, root, harness) {
9619
+ if (harness.rule !== "block" && ctx.fs.exists(join14(root, RULE_FILES[harness.rule]))) {
9620
+ return true;
9621
+ }
9622
+ const view = projectView(ctx, root);
9623
+ return instructionFiles(harness, view).some(
9624
+ (file) => view.read(file).split(/\r?\n/).some((line) => line === BLOCK_START || LEGACY_HEADING.test(line))
9625
+ );
9626
+ }
9627
+ function sessionContext(ctx, root, harness, text) {
9628
+ if (!ctx.fs.exists(join14(root, CONFIG_FILE))) return void 0;
9629
+ return loadsRule(ctx, root, harness) ? void 0 : text ?? projectRuleText(ctx, root);
9630
+ }
9631
+ function symlinkedPart(ctx, root, dir) {
9632
+ let current = "";
9633
+ for (const part of dir.split("/")) {
9634
+ current = current === "" ? part : `${current}/${part}`;
9635
+ const full = join14(root, current);
9636
+ if (!ctx.fs.lexists(full)) return void 0;
9637
+ if (!ctx.fs.exists(full) || ctx.fs.realPath(full) !== join14(ctx.fs.realPath(root), current)) {
9638
+ return current;
9639
+ }
9640
+ }
9641
+ return void 0;
9642
+ }
9643
+ function refuseSymlinkedDir(ctx, root, dir) {
9644
+ const linked = symlinkedPart(ctx, root, dir);
9645
+ if (linked !== void 0) {
9646
+ throw new DldError(
9647
+ `${linked} is a symlink; dld-kit does not install through symlinks. Replace it with a directory (dld-kit keeps each agent's copy separate).`
9648
+ );
9649
+ }
9650
+ }
9651
+ function installedRuleChannels(ctx, root) {
9652
+ const channels = /* @__PURE__ */ new Set();
9653
+ for (const channel of ["claude-file", "agents-file"]) {
9654
+ if (ctx.fs.lexists(join14(root, RULE_FILES[channel]))) channels.add(channel);
9655
+ }
9656
+ if (importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE)) !== void 0) {
9657
+ channels.add("claude-file");
9658
+ }
9659
+ for (const file of [AGENTS_MD, CLAUDE_MD]) {
9660
+ const path = join14(root, file);
9661
+ if (ctx.fs.isRegularFile(path) && findBlock(ctx.fs.readFile(path), file) !== void 0) {
9662
+ channels.add("block");
9663
+ }
9664
+ }
9665
+ return channels;
9666
+ }
9667
+ function installedRuleStamps(ctx, root) {
9668
+ const stamps = [];
9669
+ const regular = (file) => ctx.fs.isRegularFile(join14(root, file));
9670
+ for (const file of [CLAUDE_RULE_FILE, AGENTS_RULE_FILE].filter(regular)) {
9671
+ stamps.push({ path: file, version: ruleVersion(ctx.fs.readFile(join14(root, file))) });
9228
9672
  }
9229
- const generic = GENERIC_MARKERS.find(found);
9230
- const agentsLayoutFound = detected.some(({ harness }) => harness.layout === AGENTS_LAYOUT);
9231
- const codex = findHarness("codex");
9232
- if (generic !== void 0 && !agentsLayoutFound && codex !== void 0) {
9233
- detected.push({ harness: codex, marker: generic });
9234
- detected.sort((a, b) => HARNESSES.indexOf(a.harness) - HARNESSES.indexOf(b.harness));
9673
+ const imported = importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE));
9674
+ if (imported !== void 0) stamps.push({ path: CLAUDE_IMPORT_FILE, version: imported });
9675
+ for (const file of [AGENTS_MD, CLAUDE_MD].filter(regular)) {
9676
+ const content = ctx.fs.readFile(join14(root, file));
9677
+ const span = findBlock(content, file);
9678
+ if (span !== void 0) {
9679
+ stamps.push({ path: file, version: ruleVersion(content.slice(span.from, span.to)) });
9680
+ }
9235
9681
  }
9236
- return detected;
9682
+ return stamps;
9237
9683
  }
9238
- function targetsFor(harnesses) {
9239
- return {
9240
- layouts: new Set(harnesses.map((harness) => harness.layout)),
9241
- rules: new Set(harnesses.map((harness) => harness.rule))
9242
- };
9684
+ function applyRulePlan(ctx, root, plan) {
9685
+ for (const { path, content } of plan.writes) {
9686
+ const full = join14(root, path);
9687
+ ctx.fs.mkdir(dirname2(full));
9688
+ if (ctx.fs.lexists(full) && !ctx.fs.isRegularFile(full)) ctx.fs.remove(full);
9689
+ writeFileAtomic(ctx, full, content);
9690
+ }
9691
+ for (const path of plan.removals) {
9692
+ const full = join14(root, path);
9693
+ ctx.fs.remove(full);
9694
+ if (ctx.fs.isDirectory(dirname2(full)) && ctx.fs.readDir(dirname2(full)).length === 0) {
9695
+ ctx.fs.removeDir(dirname2(full));
9696
+ }
9697
+ }
9243
9698
  }
9244
9699
 
9245
- // src/generate/install.ts
9246
- import { dirname as dirname4, join as join16 } from "node:path";
9247
-
9248
- // src/generate/generate.ts
9249
- import { dirname as dirname2, join as join14 } from "node:path";
9250
- var MANIFEST = "SKILL.md";
9251
- var normalMode = (mode) => mode & 73 ? 493 : 420;
9252
- function listFiles(ctx, dir, other = () => {
9253
- }, prefix = "") {
9254
- if (!ctx.fs.isDirectory(dir)) return [];
9255
- const found = [];
9256
- for (const entry of ctx.fs.readDir(dir).sort((a, b) => a.name < b.name ? -1 : 1)) {
9257
- if (entry.name.startsWith(".")) continue;
9258
- const rel = `${prefix}${entry.name}`;
9259
- if (entry.isDirectory) {
9260
- found.push(...listFiles(ctx, join14(dir, entry.name), other, `${rel}/`));
9261
- } else {
9262
- if (!entry.isFile) other(rel);
9263
- found.push(rel);
9700
+ // src/cli/install.ts
9701
+ var AGENT_HELP = `Agents: ${HARNESS_NAMES.join(", ")}. --agent takes a comma-separated list and can be repeated.`;
9702
+ function parseAgents(values) {
9703
+ const names = (values ?? []).flatMap((value) => value.split(",")).map((name) => name.trim());
9704
+ const harnesses = /* @__PURE__ */ new Set();
9705
+ for (const name of names) {
9706
+ if (name === "") continue;
9707
+ const harness = findHarness(name);
9708
+ if (harness === void 0) {
9709
+ throw new UsageError(`unknown agent '${name}'; expected one of: ${HARNESS_NAMES.join(", ")}`);
9264
9710
  }
9711
+ harnesses.add(harness);
9265
9712
  }
9266
- return found;
9713
+ return HARNESSES.filter((harness) => harnesses.has(harness));
9267
9714
  }
9268
- var utf8 = new TextDecoder("utf-8", { fatal: true });
9269
- function readSupportingFile(ctx, path, source) {
9270
- try {
9271
- return utf8.decode(ctx.fs.readBytes(path));
9272
- } catch (error) {
9273
- if (error instanceof TypeError) throw new DldError(`${source}: not UTF-8 text`);
9274
- throw error;
9715
+ async function selectHarnesses(io, detected, requested, yes) {
9716
+ const selected = /* @__PURE__ */ new Set([...detected.map((d) => d.harness), ...requested]);
9717
+ const ordered = () => HARNESSES.filter((harness) => selected.has(harness));
9718
+ if (io.prompt === void 0 || yes) {
9719
+ if (selected.size === 0) {
9720
+ throw new UsageError(
9721
+ `no agent detected in this project; name them with --agent (${HARNESS_NAMES.join(", ")})`
9722
+ );
9723
+ }
9724
+ return ordered();
9725
+ }
9726
+ const found = new Map(detected.map((d) => [d.harness, d.marker]));
9727
+ const width = Math.max(...HARNESS_NAMES.map((name) => name.length));
9728
+ for (; ; ) {
9729
+ const lines = HARNESSES.map((harness, i) => {
9730
+ const box = selected.has(harness) ? "[x]" : "[ ]";
9731
+ const marker = found.get(harness);
9732
+ const note = marker === void 0 ? "" : ` (found ${marker})`;
9733
+ return ` ${i + 1}. ${box} ${harness.name.padEnd(width)} ${harness.title}${note}
9734
+ `;
9735
+ });
9736
+ io.stdout(`Install DLD for these agents:
9737
+ ${lines.join("")}`);
9738
+ const answer = (await io.prompt("Numbers to toggle, or Enter to continue: ")).trim();
9739
+ if (answer === "") {
9740
+ if (selected.size > 0) return ordered();
9741
+ io.stdout("Select at least one agent.\n");
9742
+ continue;
9743
+ }
9744
+ const picks = answer.split(/[\s,]+/).map((pick) => HARNESSES[Number(pick) - 1]);
9745
+ if (picks.some((harness) => harness === void 0)) {
9746
+ io.stdout(`Enter numbers from 1 to ${HARNESSES.length}.
9747
+ `);
9748
+ continue;
9749
+ }
9750
+ for (const harness of picks) {
9751
+ if (harness === void 0) continue;
9752
+ if (selected.has(harness)) selected.delete(harness);
9753
+ else selected.add(harness);
9754
+ }
9275
9755
  }
9276
9756
  }
9277
- function manifest(template, adapter, { body, usesDld }, version2) {
9278
- const fields = adapter.fields.flatMap((field) => template.lines[field] ?? []);
9279
- return [
9280
- "---",
9281
- ...fields,
9282
- ...adapter.extraFrontmatter,
9283
- ...usesDld ? adapter.dldFrontmatter : [],
9284
- "metadata:",
9285
- ` dld-kit-version: "${version2}"`,
9286
- "---",
9287
- `<!-- Generated by dld-kit from ${template.source}. Do not edit: updating the skills overwrites this file. -->`,
9288
- body
9289
- ].join("\n");
9757
+ function cliPath(io) {
9758
+ if (io.cliPath === void 0) throw new DldError("cannot locate the running dld file");
9759
+ return io.cliPath;
9290
9760
  }
9291
- function generateSkills(ctx, templatesDir, adapter, version2, { sourceDir = "templates/skills", extraFiles = /* @__PURE__ */ new Map() } = {}) {
9292
- const skills = ctx.fs.readDir(templatesDir).filter((entry) => entry.isDirectory && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
9293
- const provided = /* @__PURE__ */ new Set();
9294
- const support = /* @__PURE__ */ new Map();
9295
- for (const skill of skills) {
9296
- const files = listFiles(ctx, join14(templatesDir, skill), (rel) => {
9297
- throw new DldError(`${sourceDir}/${skill}/${rel}: templates must be regular files`);
9298
- }).filter((file) => file !== MANIFEST);
9299
- support.set(skill, files);
9300
- for (const file of files) provided.add(`${skill}/${file}`);
9761
+ function printReport(io, report2) {
9762
+ for (const { dir, written, removed, unchanged, removedPaths } of report2.skills) {
9763
+ io.stdout(`${dir}: ${written} written, ${removed} removed, ${unchanged} unchanged
9764
+ `);
9765
+ for (const path of removedPaths) io.stdout(`Removed ${path}
9766
+ `);
9301
9767
  }
9302
- for (const path of extraFiles.keys()) {
9303
- if (provided.has(path)) throw new DldError(`${sourceDir}/${path}: also generated; remove it`);
9304
- provided.add(path);
9768
+ for (const path of report2.ruleWritten) {
9769
+ io.stdout(
9770
+ path === CLAUDE_IMPORT_FILE ? `Wrote ${path} (imports AGENTS.md for Claude Code)
9771
+ ` : `Wrote the DLD rule to ${path}
9772
+ `
9773
+ );
9305
9774
  }
9306
- const output = /* @__PURE__ */ new Map();
9307
- for (const skill of skills) {
9308
- const path = join14(templatesDir, skill, MANIFEST);
9309
- const source = `${sourceDir}/${skill}/${MANIFEST}`;
9310
- const template = parseTemplate(ctx.fs.readFile(path), skill, source);
9311
- const rendered = renderBody(template, {
9312
- exists: (ref) => provided.has(`${ref.skill}/${ref.path}`),
9313
- script: (ref) => adapter.scriptRef(skill, ref),
9314
- dld: () => adapter.dld(skill),
9315
- dldSetup: () => adapter.dldSetup(skill)
9316
- });
9317
- if (!template.internal || adapter.internalManifest) {
9318
- output.set(`${skill}/${MANIFEST}`, {
9319
- content: manifest(template, adapter, rendered, version2),
9320
- mode: 420
9321
- });
9322
- }
9323
- for (const file of support.get(skill) ?? []) {
9324
- const full = join14(templatesDir, skill, file);
9325
- output.set(`${skill}/${file}`, {
9326
- content: readSupportingFile(ctx, full, `${sourceDir}/${skill}/${file}`),
9327
- mode: normalMode(ctx.fs.fileMode(full))
9328
- });
9329
- }
9775
+ for (const path of report2.ruleRemoved) io.stdout(`Removed ${path} (the rule is in the block)
9776
+ `);
9777
+ for (const warning of report2.warnings) io.stderr(`Warning: ${warning}
9778
+ `);
9779
+ }
9780
+ function ensureIndexMergeAttribute(io, ctx, project) {
9781
+ const { root } = project.paths;
9782
+ const index = relative8(root, indexPath(project.paths)).split(sep4).join("/");
9783
+ const plan = planIndexMergeAttribute(ctx, root, index);
9784
+ if (applyIndexMergeAttribute(ctx, root, plan)) {
9785
+ const verb = plan.creates === true ? "Created" : "Updated";
9786
+ io.stdout(`${verb} ${GITATTRIBUTES}: ${index} merges keep the rows from both sides
9787
+ `);
9788
+ }
9789
+ for (const warning of plan.warnings) io.stderr(`Warning: ${warning}
9790
+ `);
9791
+ }
9792
+
9793
+ // src/cli/commands/create-empty-index.ts
9794
+ var createEmptyIndexCommand = {
9795
+ name: "create-empty-index",
9796
+ summary: "Write an INDEX.md with no decisions",
9797
+ internal: true,
9798
+ usage: "Usage: dld create-empty-index\n\nWrite INDEX.md in the decisions directory with only the table header, and add its\nunion-merge line to .gitattributes.\n",
9799
+ run(args, io, ctx) {
9800
+ parseCommandArgs({ args: [...args], options: {} });
9801
+ const project = loadProject(ctx);
9802
+ const { config, paths } = project;
9803
+ writeIndex(ctx, paths, renderIndex([], config.mode));
9804
+ io.stdout(`Created ${indexPath(paths)}
9805
+ `);
9806
+ ensureIndexMergeAttribute(io, ctx, project);
9807
+ return EXIT_OK;
9808
+ }
9809
+ };
9810
+
9811
+ // src/cli/commands/detect-snapshot-changes.ts
9812
+ var detectSnapshotChangesCommand = {
9813
+ name: "detect-snapshot-changes",
9814
+ summary: "Report what changed since the last snapshot",
9815
+ internal: true,
9816
+ usage: "Usage: dld detect-snapshot-changes\n\nPrint mode (full or incremental), new_decisions, modified_decisions and commit_range.\n",
9817
+ run(args, io, ctx) {
9818
+ parseCommandArgs({ args: [...args], options: {} });
9819
+ io.stdout(formatSnapshotChanges(detectSnapshotChanges(ctx, loadProject(ctx))));
9820
+ return EXIT_OK;
9821
+ }
9822
+ };
9823
+
9824
+ // src/core/annotations.ts
9825
+ import { join as join15, relative as relative9, sep as sep5 } from "node:path";
9826
+ var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
9827
+ ".git",
9828
+ // Agent configuration directories, which hold installed skills.
9829
+ ".claude",
9830
+ ".agents",
9831
+ ".agent",
9832
+ ".codex",
9833
+ ".cursor",
9834
+ ".opencode",
9835
+ ".pi",
9836
+ ".tessl",
9837
+ "node_modules",
9838
+ "vendor",
9839
+ ".venv",
9840
+ "__pycache__",
9841
+ "target",
9842
+ "dist",
9843
+ "build",
9844
+ "out",
9845
+ ".next",
9846
+ ".gradle",
9847
+ "coverage"
9848
+ ]);
9849
+ var EXCLUDED_FILE = /(\.lock|\.min\.js|\.min\.css|\.map)$/;
9850
+ var BINARY_SNIFF_BYTES = 8192;
9851
+ var MAX_SCAN_BYTES = 16 * 1024 * 1024;
9852
+ function scanOptionsFor({ config, paths }) {
9853
+ return {
9854
+ root: paths.root,
9855
+ decisionsDir: paths.decisionsDir,
9856
+ prefix: config.annotationPrefix,
9857
+ exclude: config.annotationExclude
9858
+ };
9859
+ }
9860
+ function listScannableFiles(ctx, options) {
9861
+ const output = ctx.git([
9862
+ "-C",
9863
+ options.root,
9864
+ "ls-files",
9865
+ "-z",
9866
+ "--cached",
9867
+ "--others",
9868
+ "--exclude-standard",
9869
+ // @decision(DL-023)
9870
+ "--",
9871
+ ...(options.exclude ?? []).map((pattern) => `:(exclude,glob)${pattern}`)
9872
+ ]);
9873
+ const decisionsRel = relative9(options.root, options.decisionsDir).split(sep5).join("/");
9874
+ const files = /* @__PURE__ */ new Set();
9875
+ for (const file of output.split("\0")) {
9876
+ if (file === "" || EXCLUDED_FILE.test(file)) continue;
9877
+ if (file === decisionsRel || file.startsWith(`${decisionsRel}/`)) continue;
9878
+ const dirs = file.split("/").slice(0, -1);
9879
+ if (dirs.some((dir) => EXCLUDED_DIRS.has(dir))) continue;
9880
+ files.add(file);
9330
9881
  }
9331
- for (const [path, file] of extraFiles) output.set(path, { ...file, mode: normalMode(file.mode) });
9332
- return new Map([...output].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
9882
+ return [...files].sort();
9333
9883
  }
9334
- function ownedSkill(path, files, generated) {
9335
- const skill = path.split("/", 1)[0] ?? "";
9336
- return generated.has(skill) || skill.startsWith("dld-") || files.has(path);
9884
+ function escapeRegExp(text) {
9885
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9337
9886
  }
9338
- function diffOutput(ctx, dir, files) {
9339
- const generated = new Set([...files.keys()].map((path) => path.split("/", 1)[0] ?? ""));
9340
- const existing = new Set(
9341
- listFiles(ctx, dir).filter((path) => ownedSkill(path, files, generated))
9342
- );
9343
- const diff = { changed: [], missing: [], extra: [] };
9344
- for (const [path, file] of files) {
9345
- const full = join14(dir, path);
9346
- if (!existing.has(path) || !ctx.fs.isRegularFile(full)) diff.missing.push(path);
9347
- else if (ctx.fs.readFile(full) !== file.content || normalMode(ctx.fs.fileMode(full)) !== file.mode) {
9348
- diff.changed.push(path);
9887
+ function readScannable(ctx, root, file, onSkip) {
9888
+ const path = join15(root, file);
9889
+ let text;
9890
+ try {
9891
+ if (!ctx.fs.isRegularFile(path)) return void 0;
9892
+ if (ctx.fs.fileSize(path) > MAX_SCAN_BYTES) {
9893
+ onSkip({ file, reason: `larger than ${MAX_SCAN_BYTES / 1024 / 1024} MB` });
9894
+ return void 0;
9349
9895
  }
9896
+ text = ctx.fs.readFile(path);
9897
+ } catch (error) {
9898
+ if (!(error instanceof FsError)) throw error;
9899
+ onSkip({ file, reason: error.message });
9900
+ return void 0;
9350
9901
  }
9351
- diff.extra = [...existing].filter((path) => !files.has(path)).sort();
9352
- return diff;
9902
+ return text.slice(0, BINARY_SNIFF_BYTES).includes("\0") ? void 0 : text;
9353
9903
  }
9354
- function writeOutput(ctx, dir, files) {
9355
- const diff = diffOutput(ctx, dir, files);
9356
- for (const path of diff.extra) ctx.fs.remove(join14(dir, path));
9357
- for (const path of [...diff.missing, ...diff.changed]) {
9358
- const file = files.get(path);
9359
- if (file === void 0) continue;
9360
- const full = join14(dir, path);
9361
- ctx.fs.mkdir(join14(full, ".."));
9362
- if (ctx.fs.lexists(full) && !ctx.fs.isRegularFile(full)) ctx.fs.remove(full);
9363
- writeFileAtomic(ctx, full, file.content, file.mode);
9904
+ function scanAnnotations(ctx, options, onSkip = () => {
9905
+ }, onNearMiss = () => {
9906
+ }) {
9907
+ const prefix = escapeRegExp(options.prefix);
9908
+ const pattern = new RegExp(`${prefix}\\((DL-\\d+)\\)`, "g");
9909
+ const exact = new RegExp(`^${prefix}\\(DL-\\d+\\)$`);
9910
+ const loose = new RegExp(`${prefix}\\([^()\\n]*\\)`, "gi");
9911
+ const found = [];
9912
+ for (const file of listScannableFiles(ctx, options)) {
9913
+ const text = readScannable(ctx, options.root, file, onSkip);
9914
+ if (text === void 0) continue;
9915
+ text.split("\n").forEach((content, index) => {
9916
+ const line = index + 1;
9917
+ for (const match of content.matchAll(pattern)) {
9918
+ if (match[1] !== void 0) found.push({ file, line, id: match[1] });
9919
+ }
9920
+ for (const [candidate] of content.matchAll(loose)) {
9921
+ if (!exact.test(candidate) && /dl-\d/i.test(candidate)) {
9922
+ onNearMiss({ file, line, text: candidate });
9923
+ }
9924
+ }
9925
+ });
9364
9926
  }
9365
- removeEmptyParents(ctx, dir, diff.extra);
9366
- return diff;
9927
+ return found;
9367
9928
  }
9368
- function removeEmptyParents(ctx, dir, paths) {
9369
- const parents = /* @__PURE__ */ new Set();
9370
- for (const path of paths) {
9371
- for (let parent = dirname2(path); parent !== "."; parent = dirname2(parent)) parents.add(parent);
9372
- }
9373
- const deepestFirst = [...parents].sort((a, b) => b.split("/").length - a.split("/").length);
9374
- for (const parent of deepestFirst) {
9375
- const full = join14(dir, parent);
9376
- if (ctx.fs.isDirectory(full) && ctx.fs.readDir(full).length === 0) ctx.fs.removeDir(full);
9377
- }
9929
+ function formatNearMiss({ file, line, text }) {
9930
+ return `${file}:${line}: not an annotation: ${text}`;
9378
9931
  }
9379
-
9380
- // src/generate/rule.ts
9381
- import { dirname as dirname3, join as join15, posix as posix4 } from "node:path";
9382
-
9383
- // templates/rules/dld-workflow.md
9384
- var dld_workflow_default = "# DLD (Decision-Linked Development)\n\nThis project uses Decision-Linked Development. Decision records (DL-*.md) live in the `records/` subdirectory of the decisions directory set in `dld.config.yaml` (`decisions/` by default). High-level docs (INDEX.md, OVERVIEW.md, SNAPSHOT.md) live in the decisions directory.\n\n## Rules\n\n- When you encounter `@decision(DL-XXX)` annotations in code, read the referenced decision with the dld-lookup skill BEFORE modifying the annotated code.\n- ALWAYS look up and verify related decisions before modifying annotated code. Do not skip this step.\n- NEVER modify code in a way that contradicts an existing decision without first confirming with the user. If the change requires breaking a previous decision, a new decision must be recorded (with the dld-decide skill) that explicitly supersedes the old one. If it only partially modifies a previous decision, record it as an amendment instead.\n\n## Skills\n\n- dld-decide: record a new decision\n- dld-plan: break down a feature into multiple grouped decisions\n- dld-implement: implement proposed decisions\n- dld-lookup: query decisions by ID, tag, or code path\n- dld-adjust: adjust or update existing decisions\n- dld-audit: scan for drift between decisions and code\n- dld-snapshot: regenerate SNAPSHOT.md and OVERVIEW.md from the decision log\n- dld-status: a quick overview of the decision log state\n- dld-retrofit: generate decisions from an existing codebase\n- dld-reindex: resolve decision-ID collisions with the base branch and open PRs\n";
9385
-
9386
- // src/generate/rule.ts
9387
- var RULE_TEXT = dld_workflow_default;
9388
- var RULE_SOURCE = "templates/rules/dld-workflow.md";
9389
- var CLAUDE_RULE_FILE = ".claude/rules/dld-workflow.md";
9390
- var AGENTS_RULE_FILE = ".agents/rules/dld-workflow.md";
9391
- var CLAUDE_IMPORT_FILE = ".claude/CLAUDE.md";
9392
- var BLOCK_START = "<!-- dld-kit:start -->";
9393
- var BLOCK_END = "<!-- dld-kit:end -->";
9394
- var AGENTS_MD = "AGENTS.md";
9395
- var CLAUDE_MD = "CLAUDE.md";
9396
- var CLAUDE_FILES = [CLAUDE_MD, CLAUDE_IMPORT_FILE, "CLAUDE.local.md"];
9397
- var SHARED_CLAUDE_FILES = [CLAUDE_MD, CLAUDE_IMPORT_FILE];
9398
- var RULE_FILES = {
9399
- "claude-file": CLAUDE_RULE_FILE,
9400
- "agents-file": AGENTS_RULE_FILE
9401
- };
9402
- function notice(version2, what) {
9403
- return `<!-- Generated by dld-kit ${version2} from ${RULE_SOURCE}. Do not edit: updating the rule overwrites this ${what}. -->`;
9932
+ function missingAnnotations(ctx, options, ids, onSkip, onNearMiss) {
9933
+ const annotated = new Set(
9934
+ scanAnnotations(ctx, options, onSkip, onNearMiss).map((annotation) => annotation.id)
9935
+ );
9936
+ return ids.filter((id) => !annotated.has(id));
9404
9937
  }
9405
- var STAMP = /<!-- Generated by dld-kit (\S+) from /;
9406
- function ruleVersion(content) {
9407
- return STAMP.exec(content)?.[1];
9938
+ function formatSkipped({ file, reason }) {
9939
+ return `Warning: skipped ${file} (${reason}); annotations in it are not counted`;
9408
9940
  }
9409
- function renderRuleFile(channel, text, version2) {
9410
- const frontmatter = channel === "agents-file" ? "---\ntrigger: always_on\n---\n" : "";
9411
- return `${frontmatter}${notice(version2, "file")}
9412
-
9413
- ${text}`;
9941
+ function formatAnnotation({ file, line, id }) {
9942
+ return `${file}:${line}:${id}`;
9414
9943
  }
9415
- function renderImportFile(version2) {
9416
- return `<!-- Generated by dld-kit ${version2} for Claude Code: it loads AGENTS.md, which holds the DLD rule, through the import below. Do not edit: updating the rule overwrites this file. -->
9417
9944
 
9418
- @../AGENTS.md
9419
- `;
9420
- }
9421
- var IMPORT_STAMP = /^<!-- Generated by dld-kit (\S+) for Claude Code:/;
9422
- function importFileVersion(content) {
9423
- return IMPORT_STAMP.exec(content)?.[1];
9424
- }
9425
- function renderBlock(text, version2) {
9426
- const body = text.endsWith("\n") ? text.slice(0, -1) : text;
9427
- return [BLOCK_START, notice(version2, "block"), "", ...body.split("\n"), BLOCK_END];
9428
- }
9429
- function findBlock(content, file) {
9430
- const starts = [];
9431
- const ends = [];
9432
- let offset = 0;
9433
- for (const line of content.split("\n")) {
9434
- const text = line.endsWith("\r") ? line.slice(0, -1) : line;
9435
- if (text === BLOCK_START) starts.push(offset);
9436
- if (text === BLOCK_END) ends.push(offset);
9437
- offset += line.length + 1;
9945
+ // src/cli/commands/find-annotations.ts
9946
+ var findAnnotationsCommand = {
9947
+ name: "find-annotations",
9948
+ summary: "List every annotation in the codebase",
9949
+ internal: true,
9950
+ usage: "Usage: dld find-annotations\n\nPrint <file>:<line>:<DL-NNN> for every annotation, one per line.\n",
9951
+ run(args, io, ctx) {
9952
+ parseCommandArgs({ args: [...args], options: {} });
9953
+ const annotations = scanAnnotations(
9954
+ ctx,
9955
+ scanOptionsFor(loadProject(ctx)),
9956
+ (skipped) => io.stderr(`${formatSkipped(skipped)}
9957
+ `),
9958
+ // @decision(DL-074)
9959
+ (nearMiss) => ctx.warn(formatNearMiss(nearMiss))
9960
+ );
9961
+ for (const annotation of annotations) {
9962
+ io.stdout(`${formatAnnotation(annotation)}
9963
+ `);
9964
+ }
9965
+ return EXIT_OK;
9438
9966
  }
9439
- if (starts.length === 0 && ends.length === 0) return void 0;
9440
- const [from] = starts;
9441
- const [end] = ends;
9442
- if (starts.length !== 1 || ends.length !== 1 || from === void 0 || end === void 0) {
9443
- throw malformed(file);
9967
+ };
9968
+
9969
+ // src/cli/commands/find-collisions.ts
9970
+ var findCollisionsCommand = {
9971
+ name: "find-collisions",
9972
+ summary: "List locally added decisions whose IDs are taken",
9973
+ internal: true,
9974
+ usage: `Usage: dld find-collisions [--base <ref>]
9975
+
9976
+ Print <path>\\t<DL-NNN> for each decision added on this branch whose ID is taken on the base
9977
+ branch or in an open pull request.
9978
+
9979
+ Options:
9980
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
9981
+ `,
9982
+ run(args, io, ctx) {
9983
+ const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
9984
+ const project = loadProject(ctx);
9985
+ const base = baseOption(values.base, { ctx, project });
9986
+ const { collisions, skipped } = findCollisions(ctx, project, base);
9987
+ for (const { path, id } of collisions) io.stdout(`${path} ${id}
9988
+ `);
9989
+ if (skipped !== void 0) io.stderr(skippedNotice(skipped));
9990
+ return EXIT_OK;
9444
9991
  }
9445
- if (end < from) throw malformed(file);
9446
- return { from, to: end + BLOCK_END.length };
9447
- }
9448
- function malformed(file) {
9449
- return new DldError(
9450
- `${file}: expected one '${BLOCK_START}' line followed by one '${BLOCK_END}' line; fix the dld-kit markers by hand`
9451
- );
9452
- }
9453
- function upsertBlock(content, block, file) {
9454
- const eol = content.includes("\r\n") ? "\r\n" : "\n";
9455
- const rendered = block.join(eol);
9456
- const span = findBlock(content, file);
9457
- if (span !== void 0) return content.slice(0, span.from) + rendered + content.slice(span.to);
9458
- if (content === "") return rendered + eol;
9459
- const separator = content.endsWith("\n") ? eol : eol + eol;
9460
- return content + separator + rendered + eol;
9992
+ };
9993
+
9994
+ // src/core/audit.ts
9995
+ import { basename as basename7, relative as relative10, sep as sep6 } from "node:path";
9996
+ var MENTION = /DL-\d+/g;
9997
+ var toPosix2 = (path) => path.split(sep6).join("/");
9998
+ var idNumber2 = (id) => Number.parseInt(id.slice(3), 10);
9999
+ function recordsChangedSinceAudit(ctx, { paths }) {
10000
+ const git = gitAt(ctx, paths.root);
10001
+ const stored = stateString(readStateSection(ctx, paths, "audit"), "commit_hash");
10002
+ const commit2 = resolveStateCommit(git, stored);
10003
+ if (commit2 === void 0) return void 0;
10004
+ const records = recordsPathspec(paths);
10005
+ return /* @__PURE__ */ new Set([
10006
+ ...nulSeparated(git("diff", "-z", "--name-only", commit2, "--", records)),
10007
+ ...nulSeparated(git("ls-files", "-z", "--others", "--exclude-standard", "--", records))
10008
+ ]);
9461
10009
  }
9462
- function planRule(ctx, root, channels, version2, { harnesses = [] } = {}, text = RULE_TEXT) {
9463
- const plan = { writes: [], removals: [], warnings: [] };
9464
- const regular = (file) => ctx.fs.isRegularFile(join15(root, file));
9465
- const read = (file) => regular(file) ? ctx.fs.readFile(join15(root, file)) : "";
9466
- const resolved = (file) => {
9467
- const full = join15(root, file);
9468
- return ctx.fs.exists(full) ? ctx.fs.realPath(full) : full;
9469
- };
9470
- let blockFiles = [AGENTS_MD, CLAUDE_MD].filter(
9471
- (file) => regular(file) && findBlock(read(file), file) !== void 0
10010
+ function findMissingAmends(ctx, project, { all }) {
10011
+ const { paths } = project;
10012
+ const changed = all ? void 0 : recordsChangedSinceAudit(ctx, project);
10013
+ const found = [];
10014
+ const files = listRecordFiles(ctx, paths.recordsDir).sort(
10015
+ (a, b) => recordNumber(a) - recordNumber(b)
9472
10016
  );
9473
- if (blockFiles.length === 0 && channels.has("block")) {
9474
- const targets = harnesses.filter((h) => h.rule === "block");
9475
- const file = blockPlacement(ctx, root, targets);
9476
- blockFiles = [file];
9477
- plan.warnings.push(...switchWarnings(ctx, root, file, targets));
9478
- }
9479
- const pending = /* @__PURE__ */ new Map();
9480
- for (const file of blockFiles) {
9481
- const content = read(file);
9482
- const updated = upsertBlock(content, renderBlock(text, version2), file);
9483
- pending.set(file, updated);
9484
- if (updated !== content) plan.writes.push({ path: file, content: updated });
9485
- }
9486
- const linkedDir = symlinkedPart(ctx, root, dirname3(CLAUDE_IMPORT_FILE));
9487
- const ownsImport = linkedDir === void 0 && importFileVersion(read(CLAUDE_IMPORT_FILE)) !== void 0;
9488
- const wantsImport = channels.has("claude-file") && blockFiles.includes(AGENTS_MD) && !SHARED_CLAUDE_FILES.some((file) => ctx.fs.lexists(join15(root, file)));
9489
- if (ownsImport || wantsImport && linkedDir === void 0) {
9490
- const content = renderImportFile(version2);
9491
- pending.set(CLAUDE_IMPORT_FILE, content);
9492
- if (read(CLAUDE_IMPORT_FILE) !== content) {
9493
- plan.writes.push({ path: CLAUDE_IMPORT_FILE, content });
9494
- }
9495
- } else if (wantsImport) {
9496
- plan.warnings.push(
9497
- `${linkedDir} is a symlink, so dld-kit does not write ${CLAUDE_IMPORT_FILE}, which would import AGENTS.md for Claude Code. Claude Code reads the rule block in AGENTS.md by itself only while the project has no CLAUDE.md, ${CLAUDE_IMPORT_FILE} or CLAUDE.local.md.`
9498
- );
9499
- }
9500
- const withBlock = new Set(blockFiles.map(resolved));
9501
- const current = projectView(ctx, root);
9502
- const view = {
9503
- exists: (file) => pending.has(file) || current.exists(file),
9504
- read: (file) => pending.get(file) ?? current.read(file)
9505
- };
9506
- const reads = (harness) => instructionFiles(harness, view)[0];
9507
- const readsBlock = (harness) => instructionFiles(harness, view).some((file) => withBlock.has(resolved(file)));
9508
- const ownerOf = (channel) => HARNESSES.find((h) => h.rule === channel);
9509
- const loadsBlock = (channel) => {
9510
- const owner = ownerOf(channel);
9511
- return owner !== void 0 && readsBlock(owner);
9512
- };
9513
- if (blockFiles.length > 0) {
9514
- plan.warnings.push(
9515
- ...blindWarnings(
9516
- blockFiles,
9517
- harnesses.filter((h) => h.rule === "block" && !readsBlock(h)),
9518
- reads
9519
- )
9520
- );
9521
- }
9522
- for (const channel of ["claude-file", "agents-file"]) {
9523
- const path = RULE_FILES[channel];
9524
- if (loadsBlock(channel)) {
9525
- if (ctx.fs.lexists(join15(root, path))) plan.removals.push(path);
9526
- } else if (channels.has(channel)) {
9527
- refuseSymlinkedDir(ctx, root, dirname3(path));
9528
- const content = renderRuleFile(channel, text, version2);
9529
- if (read(path) !== content) plan.writes.push({ path, content });
9530
- }
10017
+ for (const file of files) {
10018
+ const rel = toPosix2(relative10(paths.root, file));
10019
+ if (changed !== void 0 && !changed.has(rel)) continue;
10020
+ const text = ctx.fs.readFile(file);
10021
+ const record = parseRecord(text, rel);
10022
+ const source = basename7(file, ".md");
10023
+ const declared = /* @__PURE__ */ new Set([...record.supersedes, ...record.amends]);
10024
+ const mentioned = new Set(recordBody(text).match(MENTION) ?? []);
10025
+ const refs = [...mentioned].filter((id) => id !== source && !declared.has(id)).sort((a, b) => idNumber2(a) - idNumber2(b));
10026
+ for (const referenced of refs) found.push({ source, referenced });
9531
10027
  }
9532
- const ruleInstalled = blockFiles.length > 0 || channels.size > 0;
9533
- plan.warnings.push(...legacyBlockWarnings(read(CLAUDE_MD), ruleInstalled));
9534
- return plan;
10028
+ return found;
9535
10029
  }
9536
- function projectView(ctx, root) {
9537
- const exists = (file) => ctx.fs.exists(join15(root, file));
9538
- return {
9539
- exists,
9540
- read: (file) => exists(file) && !ctx.fs.isDirectory(join15(root, file)) ? ctx.fs.readFile(join15(root, file)) : ""
9541
- };
10030
+ function updateAuditState(ctx, { paths }) {
10031
+ const timestamp = formatTimestamp(ctx.now());
10032
+ const commit2 = shortHead(ctx, paths.root);
10033
+ writeStateSection(ctx, paths, "audit", { last_run: timestamp, commit_hash: commit2 });
10034
+ return { timestamp, commit: commit2 };
9542
10035
  }
9543
- function instructionFiles(harness, view) {
9544
- if (harness.name !== "claude") {
9545
- if (harness.readsAll) return harness.instructions.filter((f) => view.exists(f));
9546
- const file = harness.instructions.find((f) => view.exists(f));
9547
- return file === void 0 ? [] : [file];
10036
+
10037
+ // src/cli/commands/find-missing-amends.ts
10038
+ var findMissingAmendsCommand = {
10039
+ name: "find-missing-amends",
10040
+ summary: "List decision IDs mentioned in a body but not declared",
10041
+ internal: true,
10042
+ usage: `Usage: dld find-missing-amends [--all]
10043
+
10044
+ Print <source-id>:<referenced-id> for each decision ID a record's body mentions without
10045
+ listing it in supersedes or amends. Only records changed since the last audit are checked.
10046
+
10047
+ Options:
10048
+ --all Check every record
10049
+ `,
10050
+ run(args, io, ctx) {
10051
+ const { values } = parseCommandArgs({ args: [...args], options: { all: { type: "boolean" } } });
10052
+ const found = findMissingAmends(ctx, loadProject(ctx), { all: values.all === true });
10053
+ for (const { source, referenced } of found) io.stdout(`${source}:${referenced}
10054
+ `);
10055
+ return EXIT_OK;
9548
10056
  }
9549
- const files = CLAUDE_FILES.filter((file) => view.exists(file));
9550
- if (files.length === 0) return view.exists(AGENTS_MD) ? [AGENTS_MD] : [];
9551
- const imported = files.some((file) => importsAgentsMd(view.read(file), file));
9552
- return imported && view.exists(AGENTS_MD) ? [...files, AGENTS_MD] : files;
10057
+ };
10058
+
10059
+ // src/core/rename.ts
10060
+ import { Buffer as Buffer2 } from "node:buffer";
10061
+ import { join as join16, posix as posix4 } from "node:path";
10062
+ var idPattern = (id, flags = "") => new RegExp(`${id}(?![0-9])`, flags);
10063
+ function changedFiles(ctx, paths, commit2) {
10064
+ return nulSeparated(
10065
+ gitAt(ctx, paths.root)(
10066
+ "diff",
10067
+ "-z",
10068
+ "--find-renames",
10069
+ "--name-only",
10070
+ "--diff-filter=AMR",
10071
+ commit2
10072
+ )
10073
+ );
9553
10074
  }
9554
- var FENCE = /^ {0,3}(`{3,}|~{3,})/;
9555
- function importsAgentsMd(content, file) {
9556
- const dir = posix4.dirname(file);
9557
- let fence;
9558
- for (const line of content.split(/\r?\n/)) {
9559
- const marker = FENCE.exec(line)?.[1];
9560
- if (marker !== void 0) {
9561
- if (fence === void 0) fence = marker;
9562
- else if (marker[0] === fence[0] && marker.length >= fence.length) fence = void 0;
9563
- continue;
9564
- }
9565
- if (fence !== void 0) continue;
9566
- const prose = line.replace(/`[^`]*`/g, "");
9567
- for (const match of prose.matchAll(/(?:^|\s)@(\S+)/g)) {
9568
- const target = match[1];
9569
- if (target !== void 0 && posix4.normalize(posix4.join(dir, target)) === AGENTS_MD) {
9570
- return true;
9571
- }
10075
+ function rewriteFile(ctx, path, edit) {
10076
+ if (!ctx.fs.isRegularFile(path)) return;
10077
+ const bytes = ctx.fs.readBytes(path);
10078
+ if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return;
10079
+ const text = Buffer2.from(bytes).toString("latin1");
10080
+ const edited = edit(text);
10081
+ if (edited === text) return;
10082
+ writeFileAtomic(ctx, path, Buffer2.from(edited, "latin1"), ctx.fs.fileMode(path));
10083
+ }
10084
+ function renameDecision(ctx, project, rename, base) {
10085
+ const { paths, config } = project;
10086
+ const { path, oldId, newId } = rename;
10087
+ const problem = renameProblem(paths, rename);
10088
+ if (problem !== void 0) throw new DldError(problem, EXIT_USAGE);
10089
+ const full = join16(paths.root, path);
10090
+ if (!ctx.fs.isRegularFile(full)) throw new DldError(`${path} not found.`);
10091
+ const record = parseRecord(ctx.fs.readFile(full), path);
10092
+ if (record.id !== oldId) throw new DldError(`${path} has id ${record.id}, not ${oldId}.`);
10093
+ const newPath = posix4.join(posix4.dirname(path), `${newId}.md`);
10094
+ const newFull = join16(paths.root, newPath);
10095
+ if (ctx.fs.lexists(newFull)) throw new DldError(`${newPath} already exists.`);
10096
+ verifyBase(ctx, paths, base);
10097
+ const since = mergeBase(ctx, paths, base);
10098
+ const scannable = new Set(listScannableFiles(ctx, scanOptionsFor(project)));
10099
+ gitAt(ctx, paths.root)("mv", "--", path, newPath);
10100
+ const substitute = (text) => text.replace(idPattern(oldId, "g"), newId);
10101
+ rewriteFile(ctx, newFull, (text) => substitute(setId(text, oldId, newId)));
10102
+ const decisions = `${decisionsPathspec(paths)}/`;
10103
+ const prefix = Buffer2.from(config.annotationPrefix, "utf8").toString("latin1");
10104
+ const oldAnnotation = `${prefix}(${oldId})`;
10105
+ const newAnnotation = `${prefix}(${newId})`;
10106
+ for (const file of changedFiles(ctx, paths, since)) {
10107
+ if (file === newPath) continue;
10108
+ if (file.startsWith(decisions)) {
10109
+ rewriteFile(ctx, join16(paths.root, file), substitute);
10110
+ } else if (scannable.has(file)) {
10111
+ rewriteFile(
10112
+ ctx,
10113
+ join16(paths.root, file),
10114
+ (text) => text.split(oldAnnotation).join(newAnnotation)
10115
+ );
9572
10116
  }
9573
10117
  }
9574
- return false;
10118
+ return newPath;
9575
10119
  }
9576
- function blindWarnings(blockFiles, blind, reads) {
9577
- const byFile = /* @__PURE__ */ new Map();
9578
- for (const harness of blind) {
9579
- const file = reads(harness) ?? harness.instructions[0] ?? AGENTS_MD;
9580
- byFile.set(file, [...byFile.get(file) ?? [], harness]);
9581
- }
9582
- return [...byFile].map(([file, harnesses]) => {
9583
- const names = harnesses.map((h) => h.title).join(" and ");
9584
- const one = harnesses.length === 1;
9585
- return `The dld-kit rule block is in ${blockFiles.join(" and ")}, but ${names} ${one ? "reads" : "read"} ${file}. To cover ${one ? "it" : "them"}, move the block (the dld-kit:start line through the dld-kit:end line) into ${file}, then run dld install-rule.`;
10120
+ function findStaleMentions(ctx, { paths }, planText, base) {
10121
+ const renames = parseRenamePlan(paths, planText);
10122
+ if (renames.length === 0) throw new DldError("no rename plan on stdin.");
10123
+ verifyBase(ctx, paths, base);
10124
+ const decisions = `${decisionsPathspec(paths)}/`;
10125
+ const files = changedFiles(ctx, paths, mergeBase(ctx, paths, base)).flatMap((file) => {
10126
+ if (file.startsWith(decisions)) return [];
10127
+ const full = join16(paths.root, file);
10128
+ if (!ctx.fs.isRegularFile(full)) return [];
10129
+ const text = ctx.fs.readFile(full);
10130
+ if (text.slice(0, BINARY_SNIFF_BYTES).includes("\0")) return [];
10131
+ return [{ file, lines: text.split("\n").map((line) => line.replace(/\r$/, "")) }];
9586
10132
  });
9587
- }
9588
- function blockPlacement(ctx, root, targets) {
9589
- if (ctx.fs.isRegularFile(join15(root, AGENTS_MD))) return AGENTS_MD;
9590
- const agentsLinked = ctx.fs.lexists(join15(root, AGENTS_MD));
9591
- if (ctx.fs.isRegularFile(join15(root, CLAUDE_MD)) && (agentsLinked || targets.every((h) => h.instructions.includes(CLAUDE_MD)))) {
9592
- return CLAUDE_MD;
9593
- }
9594
- if (agentsLinked) {
9595
- throw new DldError(
9596
- `${AGENTS_MD} is not a regular file (a symlink?); dld-kit does not edit through it. Point it at a regular file or add the dld-kit block by hand.`
9597
- );
10133
+ const found = [];
10134
+ for (const { oldId, newId } of renames) {
10135
+ const pattern = idPattern(oldId);
10136
+ for (const { file, lines } of files) {
10137
+ lines.forEach((text, index) => {
10138
+ if (pattern.test(text)) found.push({ file, line: index + 1, oldId, newId, text });
10139
+ });
10140
+ }
9598
10141
  }
9599
- return AGENTS_MD;
9600
- }
9601
- function switchWarnings(ctx, root, file, targets) {
9602
- if (file !== AGENTS_MD || ctx.fs.lexists(join15(root, AGENTS_MD))) return [];
9603
- const claudeMd = join15(root, CLAUDE_MD);
9604
- if (!ctx.fs.exists(claudeMd) || ctx.fs.isDirectory(claudeMd)) return [];
9605
- const switching = targets.filter((h) => !h.readsAll && h.instructions.includes(CLAUDE_MD));
9606
- if (switching.length === 0) return [];
9607
- const names = switching.map((h) => h.title).join(" and ");
9608
- return [
9609
- `dld-kit puts the rule block in a new AGENTS.md. ${names} will read AGENTS.md instead of CLAUDE.md from now on, so move anything ${switching.length === 1 ? "it needs" : "they need"} from CLAUDE.md into AGENTS.md.`
9610
- ];
10142
+ return found;
9611
10143
  }
9612
- var LEGACY_HEADING = /^## DLD \(Decision-Linked Development\)\s*$/;
9613
- function legacyBlockWarnings(claudeMd, ruleInstalled) {
9614
- const span = findBlock(claudeMd, CLAUDE_MD);
9615
- const outside = span === void 0 ? claudeMd : claudeMd.slice(0, span.from) + claudeMd.slice(span.to);
9616
- if (!outside.split(/\r?\n/).some((line) => LEGACY_HEADING.test(line))) return [];
9617
- const section = "CLAUDE.md has a '## DLD (Decision-Linked Development)' section from an older dld-init.";
9618
- return [
9619
- ruleInstalled ? `${section} dld-kit now installs the rule separately, so that section can be removed.` : `${section} Install the rule with dld install-rule --agent <name> before removing it.`
9620
- ];
10144
+ function formatStaleMention({ file, line, oldId, newId, text }) {
10145
+ return `${file} ${line} ${oldId} ${newId} ${text}`;
9621
10146
  }
9622
- function loadsRule(ctx, root, harness) {
9623
- if (harness.rule !== "block" && ctx.fs.exists(join15(root, RULE_FILES[harness.rule]))) {
9624
- return true;
10147
+
10148
+ // src/cli/commands/find-stale-mentions.ts
10149
+ var findStaleMentionsCommand = {
10150
+ name: "find-stale-mentions",
10151
+ summary: "List remaining mentions of renamed decision IDs",
10152
+ internal: true,
10153
+ usage: `Usage: dld find-stale-mentions --base <ref> < plan
10154
+
10155
+ Read a rename plan (<path>\\t<DL-OLD>\\t<DL-NEW> per line) on standard input and print
10156
+ <path>\\t<line>\\t<DL-OLD>\\t<DL-NEW>\\t<text> for each remaining DL-OLD mention in changed
10157
+ files outside the decisions directory.
10158
+
10159
+ Options:
10160
+ --base <ref> Base ref for the local change set (required)
10161
+ `,
10162
+ run(args, io, ctx) {
10163
+ const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
10164
+ const base = baseOption(values.base);
10165
+ const project = loadProject(ctx);
10166
+ for (const mention of findStaleMentions(ctx, project, ctx.readStdin(), base)) {
10167
+ io.stdout(`${formatStaleMention(mention)}
10168
+ `);
10169
+ }
10170
+ return EXIT_OK;
9625
10171
  }
9626
- const view = projectView(ctx, root);
9627
- return instructionFiles(harness, view).some(
9628
- (file) => view.read(file).split(/\r?\n/).some((line) => line === BLOCK_START || LEGACY_HEADING.test(line))
9629
- );
9630
- }
9631
- function sessionContext(ctx, root, harness, text = RULE_TEXT) {
9632
- if (!ctx.fs.exists(join15(root, CONFIG_FILE))) return void 0;
9633
- return loadsRule(ctx, root, harness) ? void 0 : text;
9634
- }
9635
- function symlinkedPart(ctx, root, dir) {
9636
- let current = "";
9637
- for (const part of dir.split("/")) {
9638
- current = current === "" ? part : `${current}/${part}`;
9639
- const full = join15(root, current);
9640
- if (!ctx.fs.lexists(full)) return void 0;
9641
- if (!ctx.fs.exists(full) || ctx.fs.realPath(full) !== join15(ctx.fs.realPath(root), current)) {
9642
- return current;
10172
+ };
10173
+
10174
+ // src/cli/commands/init.ts
10175
+ import { join as join19, relative as relative11 } from "node:path";
10176
+
10177
+ // src/generate/install.ts
10178
+ import { dirname as dirname4, join as join18 } from "node:path";
10179
+
10180
+ // src/generate/generate.ts
10181
+ import { dirname as dirname3, join as join17 } from "node:path";
10182
+ var MANIFEST = "SKILL.md";
10183
+ var normalMode = (mode) => mode & 73 ? 493 : 420;
10184
+ function listFiles(ctx, dir, other = () => {
10185
+ }, prefix = "") {
10186
+ if (!ctx.fs.isDirectory(dir)) return [];
10187
+ const found = [];
10188
+ for (const entry of ctx.fs.readDir(dir).sort((a, b) => a.name < b.name ? -1 : 1)) {
10189
+ if (entry.name.startsWith(".")) continue;
10190
+ const rel = `${prefix}${entry.name}`;
10191
+ if (entry.isDirectory) {
10192
+ found.push(...listFiles(ctx, join17(dir, entry.name), other, `${rel}/`));
10193
+ } else {
10194
+ if (!entry.isFile) other(rel);
10195
+ found.push(rel);
9643
10196
  }
9644
10197
  }
9645
- return void 0;
10198
+ return found;
9646
10199
  }
9647
- function refuseSymlinkedDir(ctx, root, dir) {
9648
- const linked = symlinkedPart(ctx, root, dir);
9649
- if (linked !== void 0) {
9650
- throw new DldError(
9651
- `${linked} is a symlink; dld-kit does not install through symlinks. Replace it with a directory (dld-kit keeps each agent's copy separate).`
9652
- );
10200
+ var utf8 = new TextDecoder("utf-8", { fatal: true });
10201
+ function readSupportingFile(ctx, path, source) {
10202
+ try {
10203
+ return utf8.decode(ctx.fs.readBytes(path));
10204
+ } catch (error) {
10205
+ if (error instanceof TypeError) throw new DldError(`${source}: not UTF-8 text`);
10206
+ throw error;
9653
10207
  }
9654
10208
  }
9655
- function installedRuleChannels(ctx, root) {
9656
- const channels = /* @__PURE__ */ new Set();
9657
- for (const channel of ["claude-file", "agents-file"]) {
9658
- if (ctx.fs.lexists(join15(root, RULE_FILES[channel]))) channels.add(channel);
10209
+ function manifest(template, adapter, { body, usesDld }, version2) {
10210
+ const fields = adapter.fields.flatMap((field) => template.lines[field] ?? []);
10211
+ return [
10212
+ "---",
10213
+ ...fields,
10214
+ ...adapter.extraFrontmatter,
10215
+ ...usesDld ? adapter.dldFrontmatter : [],
10216
+ "metadata:",
10217
+ ` dld-kit-version: "${version2}"`,
10218
+ "---",
10219
+ `<!-- Generated by dld-kit from ${template.source}. Do not edit: updating the skills overwrites this file. -->`,
10220
+ body
10221
+ ].join("\n");
10222
+ }
10223
+ function generateSkills(ctx, templatesDir, adapter, version2, { sourceDir = "templates/skills", extraFiles = /* @__PURE__ */ new Map() } = {}) {
10224
+ const skills = ctx.fs.readDir(templatesDir).filter((entry) => entry.isDirectory && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
10225
+ const provided = /* @__PURE__ */ new Set();
10226
+ const support = /* @__PURE__ */ new Map();
10227
+ for (const skill of skills) {
10228
+ const files = listFiles(ctx, join17(templatesDir, skill), (rel) => {
10229
+ throw new DldError(`${sourceDir}/${skill}/${rel}: templates must be regular files`);
10230
+ }).filter((file) => file !== MANIFEST);
10231
+ support.set(skill, files);
10232
+ for (const file of files) provided.add(`${skill}/${file}`);
9659
10233
  }
9660
- if (importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE)) !== void 0) {
9661
- channels.add("claude-file");
10234
+ for (const path of extraFiles.keys()) {
10235
+ if (provided.has(path)) throw new DldError(`${sourceDir}/${path}: also generated; remove it`);
10236
+ provided.add(path);
9662
10237
  }
9663
- for (const file of [AGENTS_MD, CLAUDE_MD]) {
9664
- const path = join15(root, file);
9665
- if (ctx.fs.isRegularFile(path) && findBlock(ctx.fs.readFile(path), file) !== void 0) {
9666
- channels.add("block");
10238
+ const output = /* @__PURE__ */ new Map();
10239
+ for (const skill of skills) {
10240
+ const path = join17(templatesDir, skill, MANIFEST);
10241
+ const source = `${sourceDir}/${skill}/${MANIFEST}`;
10242
+ const template = parseTemplate(ctx.fs.readFile(path), skill, source);
10243
+ const rendered = renderBody(template, {
10244
+ exists: (ref) => provided.has(`${ref.skill}/${ref.path}`),
10245
+ script: (ref) => adapter.scriptRef(skill, ref),
10246
+ dld: () => adapter.dld(skill),
10247
+ dldSetup: () => adapter.dldSetup(skill)
10248
+ });
10249
+ if (!template.internal || adapter.internalManifest) {
10250
+ output.set(`${skill}/${MANIFEST}`, {
10251
+ content: manifest(template, adapter, rendered, version2),
10252
+ mode: 420
10253
+ });
10254
+ }
10255
+ for (const file of support.get(skill) ?? []) {
10256
+ const full = join17(templatesDir, skill, file);
10257
+ output.set(`${skill}/${file}`, {
10258
+ content: readSupportingFile(ctx, full, `${sourceDir}/${skill}/${file}`),
10259
+ mode: normalMode(ctx.fs.fileMode(full))
10260
+ });
9667
10261
  }
9668
10262
  }
9669
- return channels;
10263
+ for (const [path, file] of extraFiles) output.set(path, { ...file, mode: normalMode(file.mode) });
10264
+ return new Map([...output].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
9670
10265
  }
9671
- function installedRuleStamps(ctx, root) {
9672
- const stamps = [];
9673
- const regular = (file) => ctx.fs.isRegularFile(join15(root, file));
9674
- for (const file of [CLAUDE_RULE_FILE, AGENTS_RULE_FILE].filter(regular)) {
9675
- stamps.push({ path: file, version: ruleVersion(ctx.fs.readFile(join15(root, file))) });
9676
- }
9677
- const imported = importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE));
9678
- if (imported !== void 0) stamps.push({ path: CLAUDE_IMPORT_FILE, version: imported });
9679
- for (const file of [AGENTS_MD, CLAUDE_MD].filter(regular)) {
9680
- const content = ctx.fs.readFile(join15(root, file));
9681
- const span = findBlock(content, file);
9682
- if (span !== void 0) {
9683
- stamps.push({ path: file, version: ruleVersion(content.slice(span.from, span.to)) });
10266
+ var anyDldSkill = (skill) => skill.startsWith("dld-");
10267
+ function ownedSkill(path, files, generated, owns) {
10268
+ const skill = path.split("/", 1)[0] ?? "";
10269
+ return generated.has(skill) || owns(skill) || files.has(path);
10270
+ }
10271
+ function diffOutput(ctx, dir, files, owns = anyDldSkill) {
10272
+ const generated = new Set([...files.keys()].map((path) => path.split("/", 1)[0] ?? ""));
10273
+ const existing = new Set(
10274
+ listFiles(ctx, dir).filter((path) => ownedSkill(path, files, generated, owns))
10275
+ );
10276
+ const diff = { changed: [], missing: [], extra: [] };
10277
+ for (const [path, file] of files) {
10278
+ const full = join17(dir, path);
10279
+ if (!existing.has(path) || !ctx.fs.isRegularFile(full)) diff.missing.push(path);
10280
+ else if (ctx.fs.readFile(full) !== file.content || normalMode(ctx.fs.fileMode(full)) !== file.mode) {
10281
+ diff.changed.push(path);
9684
10282
  }
9685
10283
  }
9686
- return stamps;
10284
+ diff.extra = [...existing].filter((path) => !files.has(path)).sort();
10285
+ return diff;
9687
10286
  }
9688
- function applyRulePlan(ctx, root, plan) {
9689
- for (const { path, content } of plan.writes) {
9690
- const full = join15(root, path);
9691
- ctx.fs.mkdir(dirname3(full));
10287
+ function writeOutput(ctx, dir, files, owns = anyDldSkill) {
10288
+ const diff = diffOutput(ctx, dir, files, owns);
10289
+ for (const path of diff.extra) ctx.fs.remove(join17(dir, path));
10290
+ for (const path of [...diff.missing, ...diff.changed]) {
10291
+ const file = files.get(path);
10292
+ if (file === void 0) continue;
10293
+ const full = join17(dir, path);
10294
+ ctx.fs.mkdir(join17(full, ".."));
9692
10295
  if (ctx.fs.lexists(full) && !ctx.fs.isRegularFile(full)) ctx.fs.remove(full);
9693
- writeFileAtomic(ctx, full, content);
10296
+ writeFileAtomic(ctx, full, file.content, file.mode);
9694
10297
  }
9695
- for (const path of plan.removals) {
9696
- const full = join15(root, path);
9697
- ctx.fs.remove(full);
9698
- if (ctx.fs.isDirectory(dirname3(full)) && ctx.fs.readDir(dirname3(full)).length === 0) {
9699
- ctx.fs.removeDir(dirname3(full));
9700
- }
10298
+ removeEmptyParents(ctx, dir, diff.extra);
10299
+ return diff;
10300
+ }
10301
+ function removeEmptyParents(ctx, dir, paths) {
10302
+ const parents = /* @__PURE__ */ new Set();
10303
+ for (const path of paths) {
10304
+ for (let parent = dirname3(path); parent !== "."; parent = dirname3(parent)) parents.add(parent);
10305
+ }
10306
+ const deepestFirst = [...parents].sort((a, b) => b.split("/").length - a.split("/").length);
10307
+ for (const parent of deepestFirst) {
10308
+ const full = join17(dir, parent);
10309
+ if (ctx.fs.isDirectory(full) && ctx.fs.readDir(full).length === 0) ctx.fs.removeDir(full);
9701
10310
  }
9702
10311
  }
9703
10312
 
9704
10313
  // src/generate/install.ts
9705
10314
  function packageSource(ctx, cliPath2, command) {
9706
- const templatesDir = join16(dirname4(cliPath2), "..", "templates", "skills");
10315
+ const templatesDir = join18(dirname4(cliPath2), "..", "templates", "skills");
9707
10316
  if (!ctx.fs.isDirectory(templatesDir)) {
9708
10317
  throw new DldError(
9709
10318
  `dld ${command} installs skills from the dld-kit npm package, but this copy of dld (${cliPath2}) has no templates beside it. Run it from the package instead: npx dld-kit@latest ${command}`
@@ -9711,16 +10320,51 @@ function packageSource(ctx, cliPath2, command) {
9711
10320
  }
9712
10321
  return { templatesDir, cli: ctx.fs.readFile(cliPath2) };
9713
10322
  }
9714
- var isOwned = (name) => name.startsWith("dld-");
9715
- function ownedSkills(ctx, root, layout) {
9716
- const dir = join16(root, layout.dir);
10323
+ var SHIPPED_SKILLS = /* @__PURE__ */ new Set([
10324
+ "dld-adjust",
10325
+ "dld-audit",
10326
+ "dld-audit-auto",
10327
+ "dld-common",
10328
+ "dld-decide",
10329
+ "dld-implement",
10330
+ "dld-init",
10331
+ "dld-lookup",
10332
+ "dld-plan",
10333
+ "dld-reindex",
10334
+ "dld-retrofit",
10335
+ "dld-snapshot",
10336
+ "dld-status"
10337
+ ]);
10338
+ var isShipped = (name) => SHIPPED_SKILLS.has(name);
10339
+ function dldEntries(ctx, root, layout) {
10340
+ const dir = join18(root, layout.dir);
9717
10341
  if (!ctx.fs.isDirectory(dir)) return [];
9718
- return ctx.fs.readDir(dir).filter((entry) => !entry.isFile && isOwned(entry.name)).map((entry) => entry.name).sort();
10342
+ return ctx.fs.readDir(dir).filter((entry) => !entry.isFile && entry.name.startsWith("dld-")).sort((a, b) => a.name < b.name ? -1 : 1);
10343
+ }
10344
+ function dldKitSkills(ctx, root, layout) {
10345
+ const dir = join18(root, layout.dir);
10346
+ const owned = /* @__PURE__ */ new Set();
10347
+ for (const entry of dldEntries(ctx, root, layout)) {
10348
+ if (isShipped(entry.name)) {
10349
+ owned.add(entry.name);
10350
+ continue;
10351
+ }
10352
+ if (!entry.isDirectory) continue;
10353
+ const skillMd = join18(dir, entry.name, "SKILL.md");
10354
+ if (!ctx.fs.isRegularFile(skillMd)) continue;
10355
+ const content = ctx.fs.readFile(skillMd);
10356
+ const notice2 = `Generated by dld-kit from templates/skills/${entry.name}/SKILL.md.`;
10357
+ if (SKILL_STAMP.test(content) && content.includes(notice2)) owned.add(entry.name);
10358
+ }
10359
+ return owned;
10360
+ }
10361
+ function ownedSkills(ctx, root, layout) {
10362
+ return [...dldKitSkills(ctx, root, layout)].sort();
9719
10363
  }
9720
10364
  function refuseSymlinkedSkills(ctx, root, layout) {
9721
- const dir = join16(root, layout.dir);
10365
+ const dir = join18(root, layout.dir);
9722
10366
  if (!ctx.fs.isDirectory(dir)) return;
9723
- const linked = ctx.fs.readDir(dir).filter((entry) => isOwned(entry.name) && !entry.isDirectory && !entry.isFile).map((entry) => `${layout.dir}/${entry.name}`).sort();
10367
+ const linked = ctx.fs.readDir(dir).filter((entry) => isShipped(entry.name) && !entry.isDirectory && !entry.isFile).map((entry) => `${layout.dir}/${entry.name}`).sort();
9724
10368
  if (linked.length === 0) return;
9725
10369
  throw new DldError(
9726
10370
  `${linked.join(", ")} ${linked.length === 1 ? "is a symlink" : "are symlinks"}, so another installer, such as npx skills, manages these skills. Update them with npx skills update, or remove them and run this command again. To set up DLD without touching them, run the dld-init skill or dld install-rule.`
@@ -9728,7 +10372,7 @@ function refuseSymlinkedSkills(ctx, root, layout) {
9728
10372
  }
9729
10373
  var SKILLS_LOCK = "skills-lock.json";
9730
10374
  function skillsLockWarning(ctx, root) {
9731
- const path = join16(root, SKILLS_LOCK);
10375
+ const path = join18(root, SKILLS_LOCK);
9732
10376
  if (!ctx.fs.isRegularFile(path)) return [];
9733
10377
  let skills;
9734
10378
  try {
@@ -9737,7 +10381,7 @@ function skillsLockWarning(ctx, root) {
9737
10381
  return [];
9738
10382
  }
9739
10383
  if (typeof skills !== "object" || skills === null) return [];
9740
- const listed = Object.keys(skills).filter(isOwned).sort();
10384
+ const listed = Object.keys(skills).filter(isShipped).sort();
9741
10385
  if (listed.length === 0) return [];
9742
10386
  return [
9743
10387
  `${SKILLS_LOCK} lists ${listed.join(", ")}: npx skills manages those skills, so dld update and npx skills update overwrite each other's copies. Update them with one of the two.`
@@ -9764,8 +10408,8 @@ function installedStamps(ctx, root) {
9764
10408
  for (const layout of LAYOUTS) {
9765
10409
  for (const skill of ownedSkills(ctx, root, layout)) {
9766
10410
  const path = `${layout.dir}/${skill}/SKILL.md`;
9767
- if (!ctx.fs.isRegularFile(join16(root, path))) continue;
9768
- stamps.push({ path, version: SKILL_STAMP.exec(ctx.fs.readFile(join16(root, path)))?.[1] });
10411
+ if (!ctx.fs.isRegularFile(join18(root, path))) continue;
10412
+ stamps.push({ path, version: SKILL_STAMP.exec(ctx.fs.readFile(join18(root, path)))?.[1] });
9769
10413
  }
9770
10414
  }
9771
10415
  return [...stamps, ...installedRuleStamps(ctx, root)];
@@ -9824,23 +10468,40 @@ function planInstall(ctx, root, request) {
9824
10468
  const files = generateSkills(ctx, source.templatesDir, layout.adapter, request.version, {
9825
10469
  extraFiles: cli
9826
10470
  });
9827
- return { layout, files };
9828
- });
9829
- const rule = planRule(ctx, root, request.rules, request.version, {
9830
- harnesses: request.harnesses
10471
+ const owned = dldKitSkills(ctx, root, layout);
10472
+ const owns = (skill) => owned.has(skill);
10473
+ return { layout, files, owns };
9831
10474
  });
10475
+ const rule = planRule(
10476
+ ctx,
10477
+ root,
10478
+ request.rules,
10479
+ request.version,
10480
+ { harnesses: request.harnesses },
10481
+ request.ruleText
10482
+ );
9832
10483
  const warnings = layouts.length > 0 ? skillsLockWarning(ctx, root) : [];
10484
+ for (const { layout, files, owns } of skills) {
10485
+ const generated = new Set([...files.keys()].map((path) => path.split("/", 1)[0] ?? ""));
10486
+ for (const { name } of dldEntries(ctx, root, layout)) {
10487
+ if (generated.has(name) || owns(name)) continue;
10488
+ warnings.push(
10489
+ `${layout.dir}/${name} is not a dld-kit skill (no dld-kit stamp), so it is left in place.`
10490
+ );
10491
+ }
10492
+ }
9833
10493
  return { skills, rule, warnings };
9834
10494
  }
9835
10495
  function applyInstall(ctx, root, plan) {
9836
- const skills = plan.skills.map(({ layout, files }) => {
9837
- const diff = writeOutput(ctx, join16(root, layout.dir), files);
10496
+ const skills = plan.skills.map(({ layout, files, owns }) => {
10497
+ const diff = writeOutput(ctx, join18(root, layout.dir), files, owns);
9838
10498
  const written = diff.changed.length + diff.missing.length;
9839
10499
  return {
9840
10500
  dir: layout.dir,
9841
10501
  written,
9842
10502
  removed: diff.extra.length,
9843
- unchanged: files.size - written
10503
+ unchanged: files.size - written,
10504
+ removedPaths: diff.extra.map((path) => `${layout.dir}/${path}`)
9844
10505
  };
9845
10506
  });
9846
10507
  applyRulePlan(ctx, root, plan.rule);
@@ -9852,85 +10513,6 @@ function applyInstall(ctx, root, plan) {
9852
10513
  };
9853
10514
  }
9854
10515
 
9855
- // src/cli/install.ts
9856
- var AGENT_HELP = `Agents: ${HARNESS_NAMES.join(", ")}. --agent takes a comma-separated list and can be repeated.`;
9857
- function parseAgents(values) {
9858
- const names = (values ?? []).flatMap((value) => value.split(",")).map((name) => name.trim());
9859
- const harnesses = /* @__PURE__ */ new Set();
9860
- for (const name of names) {
9861
- if (name === "") continue;
9862
- const harness = findHarness(name);
9863
- if (harness === void 0) {
9864
- throw new UsageError(`unknown agent '${name}'; expected one of: ${HARNESS_NAMES.join(", ")}`);
9865
- }
9866
- harnesses.add(harness);
9867
- }
9868
- return HARNESSES.filter((harness) => harnesses.has(harness));
9869
- }
9870
- async function selectHarnesses(io, detected, requested, yes) {
9871
- const selected = /* @__PURE__ */ new Set([...detected.map((d) => d.harness), ...requested]);
9872
- const ordered = () => HARNESSES.filter((harness) => selected.has(harness));
9873
- if (io.prompt === void 0 || yes) {
9874
- if (selected.size === 0) {
9875
- throw new UsageError(
9876
- `no agent detected in this project; name them with --agent (${HARNESS_NAMES.join(", ")})`
9877
- );
9878
- }
9879
- return ordered();
9880
- }
9881
- const found = new Map(detected.map((d) => [d.harness, d.marker]));
9882
- const width = Math.max(...HARNESS_NAMES.map((name) => name.length));
9883
- for (; ; ) {
9884
- const lines = HARNESSES.map((harness, i) => {
9885
- const box = selected.has(harness) ? "[x]" : "[ ]";
9886
- const marker = found.get(harness);
9887
- const note = marker === void 0 ? "" : ` (found ${marker})`;
9888
- return ` ${i + 1}. ${box} ${harness.name.padEnd(width)} ${harness.title}${note}
9889
- `;
9890
- });
9891
- io.stdout(`Install DLD for these agents:
9892
- ${lines.join("")}`);
9893
- const answer = (await io.prompt("Numbers to toggle, or Enter to continue: ")).trim();
9894
- if (answer === "") {
9895
- if (selected.size > 0) return ordered();
9896
- io.stdout("Select at least one agent.\n");
9897
- continue;
9898
- }
9899
- const picks = answer.split(/[\s,]+/).map((pick) => HARNESSES[Number(pick) - 1]);
9900
- if (picks.some((harness) => harness === void 0)) {
9901
- io.stdout(`Enter numbers from 1 to ${HARNESSES.length}.
9902
- `);
9903
- continue;
9904
- }
9905
- for (const harness of picks) {
9906
- if (harness === void 0) continue;
9907
- if (selected.has(harness)) selected.delete(harness);
9908
- else selected.add(harness);
9909
- }
9910
- }
9911
- }
9912
- function cliPath(io) {
9913
- if (io.cliPath === void 0) throw new DldError("cannot locate the running dld file");
9914
- return io.cliPath;
9915
- }
9916
- function printReport(io, report2) {
9917
- for (const { dir, written, removed, unchanged } of report2.skills) {
9918
- io.stdout(`${dir}: ${written} written, ${removed} removed, ${unchanged} unchanged
9919
- `);
9920
- }
9921
- for (const path of report2.ruleWritten) {
9922
- io.stdout(
9923
- path === CLAUDE_IMPORT_FILE ? `Wrote ${path} (imports AGENTS.md for Claude Code)
9924
- ` : `Wrote the DLD rule to ${path}
9925
- `
9926
- );
9927
- }
9928
- for (const path of report2.ruleRemoved) io.stdout(`Removed ${path} (the rule is in the block)
9929
- `);
9930
- for (const warning of report2.warnings) io.stderr(`Warning: ${warning}
9931
- `);
9932
- }
9933
-
9934
10516
  // src/cli/commands/init.ts
9935
10517
  var initCommand = {
9936
10518
  name: "init",
@@ -9967,7 +10549,7 @@ ${AGENT_HELP}
9967
10549
  throw new UsageError("--namespaces needs at least one namespace");
9968
10550
  }
9969
10551
  const root = findProjectRoot(ctx);
9970
- if (ctx.fs.lexists(join17(root, CONFIG_FILE))) {
10552
+ if (ctx.fs.lexists(join19(root, CONFIG_FILE))) {
9971
10553
  throw new DldError(
9972
10554
  `DLD is already set up here (${CONFIG_FILE} exists). Run dld update to refresh the skills and rule, or dld update --agent <name> to add an agent.`
9973
10555
  );
@@ -9993,10 +10575,11 @@ ${AGENT_HELP}
9993
10575
  createDirectories(ctx, project);
9994
10576
  writeIndex(ctx, project.paths, renderIndex([], project.config.mode));
9995
10577
  const report2 = applyInstall(ctx, root, plan);
9996
- const index = relative8(root, indexPath(project.paths));
10578
+ const index = relative11(root, indexPath(project.paths));
9997
10579
  io.stdout(`Created ${CONFIG_FILE} and ${index}
9998
10580
  `);
9999
10581
  printReport(io, report2);
10582
+ ensureIndexMergeAttribute(io, ctx, project);
10000
10583
  io.stdout(
10001
10584
  `
10002
10585
  DLD is set up for: ${harnesses.map((harness) => harness.name).join(", ")}. Commit these files so everyone gets the same skills and rule.
@@ -10038,7 +10621,9 @@ ${AGENT_HELP}
10038
10621
  rules,
10039
10622
  version,
10040
10623
  force: values.force === true,
10041
- harnesses: requested
10624
+ harnesses: requested,
10625
+ // @decision(DL-074)
10626
+ ruleText: projectRuleText(ctx, root)
10042
10627
  });
10043
10628
  const report2 = applyInstall(ctx, root, plan);
10044
10629
  printReport(io, report2);
@@ -10049,6 +10634,50 @@ ${AGENT_HELP}
10049
10634
  }
10050
10635
  };
10051
10636
 
10637
+ // src/core/proposed.ts
10638
+ import { basename as basename8, relative as relative12, sep as sep7 } from "node:path";
10639
+ function listProposed(ctx, project, base) {
10640
+ const { paths } = project;
10641
+ verifyBase(ctx, paths, base, " Fetch first or pass --base.");
10642
+ const atBase = integratedRecords(ctx, paths, base).byId;
10643
+ return listRecordFiles(ctx, paths.recordsDir).map((file) => {
10644
+ const path = relative12(paths.root, file).split(sep7).join("/");
10645
+ return { file, path, record: parseRecord(ctx.fs.readFile(file), path) };
10646
+ }).filter(({ record }) => record.status === "proposed").sort((a, b) => recordNumber(a.file) - recordNumber(b.file)).map(({ file, path, record }) => {
10647
+ const id = basename8(file, ".md");
10648
+ return { id, title: record.title, path, addedOnBranch: !atBase.has(id) };
10649
+ });
10650
+ }
10651
+ function formatProposed({ id, title, addedOnBranch }) {
10652
+ return `${id} ${addedOnBranch ? "branch" : "base"} ${title}`;
10653
+ }
10654
+
10655
+ // src/cli/commands/list-proposed.ts
10656
+ var listProposedCommand = {
10657
+ name: "list-proposed",
10658
+ summary: "List proposed decisions, marking those added on this branch",
10659
+ internal: true,
10660
+ usage: `Usage: dld list-proposed [--base <ref>]
10661
+
10662
+ Print <DL-NNN>\\t<branch|base>\\t<title> for each proposed decision, ascending by ID: branch
10663
+ when its file does not exist at the merge-base with the base branch (added on this branch,
10664
+ committed or not), base otherwise.
10665
+
10666
+ Options:
10667
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
10668
+ `,
10669
+ run(args, io, ctx) {
10670
+ const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
10671
+ const project = loadProject(ctx);
10672
+ const base = baseOption(values.base, { ctx, project });
10673
+ for (const proposed of listProposed(ctx, project, base)) {
10674
+ io.stdout(`${formatProposed(proposed)}
10675
+ `);
10676
+ }
10677
+ return EXIT_OK;
10678
+ }
10679
+ };
10680
+
10052
10681
  // src/cli/commands/list-taken-ids.ts
10053
10682
  var listTakenIdsCommand = {
10054
10683
  name: "list-taken-ids",
@@ -10060,12 +10689,13 @@ Print the decision IDs on the base branch and in records touched by open pull re
10060
10689
  (via gh, when available), one per line, sorted.
10061
10690
 
10062
10691
  Options:
10063
- --base <ref> Base ref (default: origin/main)
10692
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
10064
10693
  `,
10065
10694
  run(args, io, ctx) {
10066
10695
  const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
10067
- const base = baseOption(values.base);
10068
- const { ids, skipped } = listTakenIds(ctx, loadProject(ctx), base);
10696
+ const project = loadProject(ctx);
10697
+ const base = baseOption(values.base, { ctx, project });
10698
+ const { ids, skipped } = listTakenIds(ctx, project, base);
10069
10699
  for (const id of ids) io.stdout(`${id}
10070
10700
  `);
10071
10701
  if (skipped !== void 0) io.stderr(skippedNotice(skipped));
@@ -10099,12 +10729,13 @@ Print <path>\\t<DL-OLD>\\t<DL-NEW> for each colliding local decision, assigning
10099
10729
  IDs. Prints nothing when there are no collisions.
10100
10730
 
10101
10731
  Options:
10102
- --base <ref> Base ref (default: origin/main)
10732
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
10103
10733
  `,
10104
10734
  run(args, io, ctx) {
10105
10735
  const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
10106
- const base = baseOption(values.base);
10107
- const { renames, skipped } = planRenames(ctx, loadProject(ctx), base);
10736
+ const project = loadProject(ctx);
10737
+ const base = baseOption(values.base, { ctx, project });
10738
+ const { renames, skipped } = planRenames(ctx, project, base);
10108
10739
  for (const rename of renames) io.stdout(`${formatRename(rename)}
10109
10740
  `);
10110
10741
  if (skipped !== void 0) io.stderr(skippedNotice(skipped));
@@ -10113,6 +10744,7 @@ Options:
10113
10744
  };
10114
10745
 
10115
10746
  // src/cli/commands/regenerate-index.ts
10747
+ import { basename as basename9, relative as relative13 } from "node:path";
10116
10748
  var regenerateIndexCommand = {
10117
10749
  name: "regenerate-index",
10118
10750
  summary: "Rebuild INDEX.md from the decision records",
@@ -10133,6 +10765,11 @@ Options:
10133
10765
  if (!ctx.fs.isDirectory(paths.recordsDir)) {
10134
10766
  throw new DldError(`records directory not found at ${paths.recordsDir}`);
10135
10767
  }
10768
+ for (const files of duplicateRecordFiles(ctx, paths.recordsDir)) {
10769
+ const ids = [...new Set(files.map((path) => basename9(path, ".md")))].join(" / ");
10770
+ const shown = files.map((path) => relative13(paths.root, path)).join(", ");
10771
+ ctx.warn(`${ids} is in more than one file: ${shown}`);
10772
+ }
10136
10773
  const rows = collectIndexRows(ctx, paths, values["include-base"]);
10137
10774
  writeIndex(ctx, paths, renderIndex(rows, config.mode));
10138
10775
  io.stdout(rows.length === 0 ? "INDEX.md regenerated (empty).\n" : "INDEX.md regenerated.\n");
@@ -10140,6 +10777,76 @@ Options:
10140
10777
  }
10141
10778
  };
10142
10779
 
10780
+ // src/core/related.ts
10781
+ import { basename as basename10, relative as relative14, sep as sep8 } from "node:path";
10782
+ var RELATIONS = ["amends", "supersedes", "amended-by", "superseded-by"];
10783
+ var idNumber3 = (id) => DECISION_ID.test(id) ? Number.parseInt(id.slice(3), 10) : Number.POSITIVE_INFINITY;
10784
+ function relatedDecisions(ctx, project, id) {
10785
+ const { paths } = project;
10786
+ const targetFile = findRecordFile(ctx, paths.recordsDir, id, paths.root);
10787
+ if (targetFile === void 0) throw new DldError(`decision ${id} not found.`);
10788
+ const records = listRecordFiles(ctx, paths.recordsDir).map((file) => {
10789
+ const path = relative14(paths.root, file).split(sep8).join("/");
10790
+ return { file, id: basename10(file, ".md"), record: parseRecord(ctx.fs.readFile(file), path) };
10791
+ });
10792
+ const byId = new Map(records.map((entry) => [entry.id, entry.record]));
10793
+ const target = records.find((entry) => entry.file === targetFile)?.record;
10794
+ if (target === void 0) throw new DldError(`decision ${id} not found.`);
10795
+ const related = [];
10796
+ const add = (relation, other) => {
10797
+ const record = byId.get(other);
10798
+ related.push({
10799
+ relation,
10800
+ id: other,
10801
+ status: record?.status ?? "missing",
10802
+ title: record?.title ?? ""
10803
+ });
10804
+ };
10805
+ for (const other of target.amends) add("amends", other);
10806
+ for (const other of target.supersedes) add("supersedes", other);
10807
+ for (const entry of records) {
10808
+ if (entry.file === targetFile) continue;
10809
+ if (entry.record.amends.includes(id)) add("amended-by", entry.id);
10810
+ if (entry.record.supersedes.includes(id)) add("superseded-by", entry.id);
10811
+ }
10812
+ return related.sort(
10813
+ (a, b) => idNumber3(a.id) - idNumber3(b.id) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0) || RELATIONS.indexOf(a.relation) - RELATIONS.indexOf(b.relation)
10814
+ );
10815
+ }
10816
+ function formatRelated({ relation, id, status, title }) {
10817
+ return `${relation} ${id} ${status} ${title}`;
10818
+ }
10819
+
10820
+ // src/cli/commands/related-decisions.ts
10821
+ var relatedDecisionsCommand = {
10822
+ name: "related-decisions",
10823
+ summary: "List the decisions a decision amends or supersedes, and those that amend or supersede it",
10824
+ internal: true,
10825
+ usage: `Usage: dld related-decisions <DL-NNN>
10826
+
10827
+ Print <relation>\\t<DL-NNN>\\t<status>\\t<title> for each related decision, ascending by ID.
10828
+ Relations: amends and supersedes (from the decision's own frontmatter), amended-by and
10829
+ superseded-by (records whose amends or supersedes list it). A listed ID with no record has
10830
+ status missing. Exits 1 if the decision does not exist.
10831
+ `,
10832
+ run(args, io, ctx) {
10833
+ const { positionals } = parseCommandArgs({
10834
+ args: [...args],
10835
+ options: {},
10836
+ allowPositionals: true
10837
+ });
10838
+ const [id, ...extra] = positionals;
10839
+ if (id === void 0 || !DECISION_ID.test(id))
10840
+ throw new UsageError("expected one decision ID like DL-001");
10841
+ if (extra.length > 0) throw new UsageError(`unexpected argument '${extra[0]}'`);
10842
+ for (const related of relatedDecisions(ctx, loadProject(ctx), id)) {
10843
+ io.stdout(`${formatRelated(related)}
10844
+ `);
10845
+ }
10846
+ return EXIT_OK;
10847
+ }
10848
+ };
10849
+
10143
10850
  // src/cli/commands/rename-decision.ts
10144
10851
  var renameDecisionCommand = {
10145
10852
  name: "rename-decision",
@@ -10154,7 +10861,7 @@ Options:
10154
10861
  --old <DL-OLD> Current ID (required)
10155
10862
  --new <DL-NEW> New ID (required)
10156
10863
  --path <path> Record path relative to the project root (required)
10157
- --base <ref> Base ref for the local change set (default: origin/main)
10864
+ --base <ref> Base ref for the local change set (${DEFAULT_BASE_HELP})
10158
10865
  `,
10159
10866
  run(args, io, ctx) {
10160
10867
  const { values } = parseCommandArgs({
@@ -10168,8 +10875,9 @@ Options:
10168
10875
  });
10169
10876
  const { old: oldId, new: newId, path } = values;
10170
10877
  if (!oldId || !newId || !path) throw new DldError("--old, --new, and --path are required.");
10171
- const base = baseOption(values.base);
10172
- const newPath = renameDecision(ctx, loadProject(ctx), { path, oldId, newId }, base);
10878
+ const project = loadProject(ctx);
10879
+ const base = baseOption(values.base, { ctx, project });
10880
+ const newPath = renameDecision(ctx, project, { path, oldId, newId }, base);
10173
10881
  io.stdout(`${path} -> ${newPath}
10174
10882
  `);
10175
10883
  return EXIT_OK;
@@ -10177,19 +10885,63 @@ Options:
10177
10885
  };
10178
10886
 
10179
10887
  // src/cli/commands/resolve-base.ts
10888
+ import { join as join20 } from "node:path";
10180
10889
  var resolveBaseCommand = {
10181
10890
  name: "resolve-base",
10182
10891
  summary: "Print the base ref to check decision IDs against",
10183
10892
  internal: true,
10184
10893
  usage: `Usage: dld resolve-base
10185
10894
 
10186
- Print the current branch's upstream when it tracks a differently named branch, otherwise
10187
- origin/main.
10895
+ Print the base ref: base_branch from dld.config.yaml (origin/<name>, else <name>); else the
10896
+ current branch's upstream when it tracks a differently named branch; else origin/HEAD; else
10897
+ origin/main, then main. Only a ref that exists is printed; when none does, exit 1.
10188
10898
  `,
10189
10899
  run(args, io, ctx) {
10190
10900
  parseCommandArgs({ args: [...args], options: {} });
10191
- io.stdout(`${resolveBase(ctx)}
10901
+ const root = findProjectRoot(ctx);
10902
+ const config = ctx.fs.exists(join20(root, CONFIG_FILE)) ? loadConfig(ctx, root) : void 0;
10903
+ io.stdout(`${resolveBase(ctx, config?.baseBranch)}
10904
+ `);
10905
+ return EXIT_OK;
10906
+ }
10907
+ };
10908
+
10909
+ // src/cli/commands/restore-decision-prose.ts
10910
+ var restoreDecisionProseCommand = {
10911
+ name: "restore-decision-prose",
10912
+ summary: "Restore the body of decisions already on the base branch",
10913
+ internal: true,
10914
+ usage: `Usage: dld restore-decision-prose [--base <ref>] [--uncommitted] <DL-NNN> [DL-NNN ...]
10915
+
10916
+ Put back the body (the text after the frontmatter), id and timestamp of each decision as
10917
+ they are at the merge-base of the base branch and HEAD, keeping the rest of the current
10918
+ frontmatter. A deleted decision
10919
+ is restored whole and staged. Fails for a draft.
10920
+
10921
+ Options:
10922
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
10923
+ --uncommitted Restore from HEAD instead of the merge-base: undo only uncommitted edits
10924
+ `,
10925
+ run(args, io, ctx) {
10926
+ const { values, positionals: ids } = parseCommandArgs({
10927
+ args: [...args],
10928
+ options: { base: { type: "string" }, uncommitted: { type: "boolean" } },
10929
+ allowPositionals: true
10930
+ });
10931
+ if (ids.length === 0) throw new UsageError("expected at least one decision ID");
10932
+ const bad = ids.find((id) => !DECISION_ID.test(id));
10933
+ if (bad !== void 0) throw new UsageError(`expected decision IDs like DL-001, got '${bad}'`);
10934
+ const project = loadProject(ctx);
10935
+ const base = editBase(
10936
+ ctx,
10937
+ project,
10938
+ values.base === void 0 ? void 0 : baseOption(values.base)
10939
+ );
10940
+ const uncommitted = values.uncommitted === true;
10941
+ for (const path of restoreDecisionProse(ctx, project, base, ids, { uncommitted })) {
10942
+ io.stdout(`Restored ${path}
10192
10943
  `);
10944
+ }
10193
10945
  return EXIT_OK;
10194
10946
  }
10195
10947
  };
@@ -10238,7 +10990,7 @@ Agents: ${HARNESS_NAMES.join(", ")}.
10238
10990
  };
10239
10991
 
10240
10992
  // src/cli/commands/update.ts
10241
- import { join as join18 } from "node:path";
10993
+ import { join as join21 } from "node:path";
10242
10994
  var updateCommand = {
10243
10995
  name: "update",
10244
10996
  summary: "Refresh the installed DLD skills and rule to this version",
@@ -10261,9 +11013,10 @@ ${AGENT_HELP}
10261
11013
  });
10262
11014
  const requested = parseAgents(values.agent);
10263
11015
  const root = findProjectRoot(ctx);
10264
- if (!ctx.fs.lexists(join18(root, CONFIG_FILE))) {
11016
+ if (!ctx.fs.lexists(join21(root, CONFIG_FILE))) {
10265
11017
  throw new DldError(`DLD is not set up here (${CONFIG_FILE} not found). Run dld init first.`);
10266
11018
  }
11019
+ const project = loadProject(ctx);
10267
11020
  const installed = installedTargets(ctx, root);
10268
11021
  const added = targetsFor(requested);
10269
11022
  const layouts = /* @__PURE__ */ new Set([...installed.layouts, ...added.layouts]);
@@ -10279,11 +11032,14 @@ ${AGENT_HELP}
10279
11032
  rules,
10280
11033
  version,
10281
11034
  force: values.force === true,
10282
- harnesses: requested
11035
+ harnesses: requested,
11036
+ // @decision(DL-074)
11037
+ ruleText: ruleTextFor(project.config.annotationPrefix)
10283
11038
  });
10284
11039
  const report2 = applyInstall(ctx, root, plan);
10285
11040
  report2.warnings.push(...missingAgentsRuleWarning({ layouts, rules }));
10286
11041
  printReport(io, report2);
11042
+ ensureIndexMergeAttribute(io, ctx, project);
10287
11043
  return EXIT_OK;
10288
11044
  }
10289
11045
  };
@@ -10373,7 +11129,15 @@ var verifyAnnotationsCommand = {
10373
11129
  if (ids.length === 0) throw new UsageError("expected at least one decision ID");
10374
11130
  const options = scanOptionsFor(loadProject(ctx));
10375
11131
  const prefix = options.prefix;
10376
- const missing = missingAnnotations(ctx, options, ids);
11132
+ const missing = missingAnnotations(
11133
+ ctx,
11134
+ options,
11135
+ ids,
11136
+ (skipped) => io.stderr(`${formatSkipped(skipped)}
11137
+ `),
11138
+ // @decision(DL-074)
11139
+ (nearMiss) => ctx.warn(formatNearMiss(nearMiss))
11140
+ );
10377
11141
  if (missing.length > 0) {
10378
11142
  io.stdout(`MISSING annotations in source code for: ${missing.join(" ")}
10379
11143
  `);
@@ -10394,14 +11158,18 @@ var COMMANDS = [
10394
11158
  updateCommand,
10395
11159
  installRuleCommand,
10396
11160
  sessionContextCommand,
11161
+ checkDecisionEditsCommand,
10397
11162
  createConfigCommand,
10398
11163
  createDirectoriesCommand,
10399
11164
  createEmptyIndexCommand,
10400
11165
  nextIdCommand,
10401
11166
  createDecisionCommand,
10402
11167
  updateStatusCommand,
11168
+ listProposedCommand,
11169
+ relatedDecisionsCommand,
10403
11170
  regenerateIndexCommand,
10404
11171
  verifyAnnotationsCommand,
11172
+ restoreDecisionProseCommand,
10405
11173
  findAnnotationsCommand,
10406
11174
  findMissingAmendsCommand,
10407
11175
  updateAuditStateCommand,
@@ -10422,7 +11190,7 @@ function usage(commands) {
10422
11190
  const list = (group) => group.map((c) => ` ${c.name.padEnd(25)} ${c.summary}`).join("\n");
10423
11191
  const setup = commands.filter((c) => !c.internal);
10424
11192
  const internal = commands.filter((c) => c.internal);
10425
- const sections = [`Setup commands:
11193
+ const sections = [`Setup and CI commands:
10426
11194
  ${list(setup)}`];
10427
11195
  if (internal.length > 0) sections.push(`${INTERNAL_HEADING}:
10428
11196
  ${list(internal)}`);
@@ -10457,8 +11225,18 @@ function run(argv, io, ctx, commands = COMMANDS) {
10457
11225
  ${usage(commands)}`);
10458
11226
  return EXIT_USAGE;
10459
11227
  }
11228
+ const printed = /* @__PURE__ */ new Set();
11229
+ const runCtx = {
11230
+ ...ctx,
11231
+ warn: (message) => {
11232
+ if (printed.has(message)) return;
11233
+ printed.add(message);
11234
+ io.stderr(`Warning: ${message}
11235
+ `);
11236
+ }
11237
+ };
10460
11238
  try {
10461
- const result = command.run(rest, io, ctx);
11239
+ const result = command.run(rest, io, runCtx);
10462
11240
  if (typeof result === "number") return result;
10463
11241
  return result.catch((error) => report(error, command, io));
10464
11242
  } catch (error) {
@@ -10514,6 +11292,7 @@ var nodeFileSystem = {
10514
11292
  lexists: (path) => fsCall("read", path, () => lstatSync(path, { throwIfNoEntry: false }) !== void 0),
10515
11293
  isRegularFile: (path) => fsCall("read", path, () => lstatSync(path, { throwIfNoEntry: false })?.isFile() ?? false),
10516
11294
  readFile: (path) => fsCall("read", path, () => readFileSync(path, "utf8")),
11295
+ fileSize: (path) => fsCall("read", path, () => lstatSync(path).size),
10517
11296
  readBytes: (path) => fsCall("read", path, () => readFileSync(path)),
10518
11297
  readDir: (path) => fsCall(
10519
11298
  "read",
@@ -10597,7 +11376,12 @@ function createNodeContext(cwd, env) {
10597
11376
  gh: nodeGh(cwd, env),
10598
11377
  env,
10599
11378
  readStdin: () => fsCall("read", "standard input", () => readFileSync(0, "utf8")),
10600
- now: () => /* @__PURE__ */ new Date()
11379
+ now: () => /* @__PURE__ */ new Date(),
11380
+ // @decision(DL-079)
11381
+ warn: (message) => {
11382
+ process.stderr.write(`Warning: ${message}
11383
+ `);
11384
+ }
10601
11385
  };
10602
11386
  }
10603
11387
  function hasCode(error, code) {