dld-kit 1.0.0-rc.3 → 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.3";
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,267 +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;
7852
- }
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));
7824
+ return prs;
7857
7825
  }
7858
- function stateString(section, key) {
7859
- const value = section?.[key];
7860
- return typeof value === "string" && value !== "" ? value : void 0;
7826
+ function isObject(value) {
7827
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7861
7828
  }
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 commit = resolveStateCommit(git, stored);
7906
- let modifiedDecisions = [];
7907
- let commitRange = "";
7908
- if (commit !== void 0 && commit !== shortHead(ctx, paths.root)) {
7909
- commitRange = `${commit}..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 commit = 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: commit,
7937
- decisions_included: String(highest),
7938
- artifacts
7939
- });
7940
- return { timestamp, commit, 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
- function openPrIds(ctx, paths, base) {
7966
- const skipped = (reason) => ({ ids: [], skipped: reason });
7967
- try {
7968
- ctx.gh(["--version"]);
7969
- } catch (error) {
7970
- if (error instanceof ToolNotFoundError) return skipped("gh CLI not installed");
7971
- if (!(error instanceof GhCommandError)) throw error;
7972
- }
7973
- const git = gitAt(ctx, paths.root);
7974
- if (!GITHUB_REMOTE.test(gitOrEmpty(git, "remote", "get-url", "origin"))) {
7975
- return skipped("origin is not a GitHub remote");
7976
- }
7977
- try {
7978
- ctx.gh(["auth", "status"]);
7979
- } catch (error) {
7980
- if (error instanceof GhCommandError) return skipped("gh not authenticated");
7981
- throw error;
7982
- }
7983
- const prBase = base.startsWith("origin/") ? base.slice("origin/".length) : base;
7984
- let output;
7985
- try {
7986
- output = ctx.gh([
7987
- "pr",
7988
- "list",
7989
- "--state",
7990
- "open",
7991
- `--base=${prBase}`,
7992
- "--json",
7993
- "files,headRefName,isCrossRepository",
7994
- "--limit",
7995
- PR_LIMIT
7996
- ]);
7997
- } catch (error) {
7998
- if (error instanceof GhCommandError) {
7999
- return skipped(`gh pr list failed: ${firstLine(error.stderr) || "no error output"}`);
8000
- }
8001
- throw error;
8002
- }
8003
- const prs = parsePrList(output);
8004
- if (prs === void 0) return skipped("gh pr list returned unexpected output");
8005
- const current = gitOrEmpty(git, "rev-parse", "--abbrev-ref", "HEAD").trim();
8006
- const prefix = `${recordsPathspec(paths)}/`;
8007
- const ids = [];
8008
- for (const pr of prs) {
8009
- if (!pr.isCrossRepository && pr.headRefName === current) continue;
8010
- for (const path of pr.files) {
8011
- if (path.startsWith(prefix)) ids.push(...path.match(DECISION_MENTION) ?? []);
8012
- }
8013
- }
8014
- return { ids };
8015
- }
8016
- function parsePrList(output) {
8017
- let value;
8018
- try {
8019
- value = JSON.parse(output);
8020
- } catch {
8021
- return void 0;
8022
- }
8023
- if (!Array.isArray(value)) return void 0;
8024
- const prs = [];
8025
- for (const item of value) {
8026
- if (!isObject(item)) return void 0;
8027
- const files = Array.isArray(item.files) ? item.files : [];
8028
- prs.push({
8029
- headRefName: typeof item.headRefName === "string" ? item.headRefName : void 0,
8030
- isCrossRepository: item.isCrossRepository === true,
8031
- files: files.flatMap(
8032
- (file) => isObject(file) && typeof file.path === "string" ? [file.path] : []
8033
- )
8034
- });
8035
- }
8036
- return prs;
8037
- }
8038
- function isObject(value) {
8039
- return typeof value === "object" && value !== null && !Array.isArray(value);
8040
- }
8041
- function firstLine(text) {
8042
- return text.split("\n", 1)[0]?.trim() ?? "";
7829
+ function firstLine(text) {
7830
+ return text.split("\n", 1)[0]?.trim() ?? "";
8043
7831
  }
8044
7832
 
8045
7833
  // src/core/reindex.ts
@@ -8048,8 +7836,20 @@ var idNumber = (id) => Number.parseInt(id.slice(3), 10);
8048
7836
  function compareIds(a, b) {
8049
7837
  return idNumber(a) - idNumber(b) || (a < b ? -1 : a > b ? 1 : 0);
8050
7838
  }
8051
- function resolveBase(ctx) {
7839
+ function resolveBase(ctx, baseBranch) {
8052
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
+ }
8053
7853
  const current = gitOrEmpty(git, "rev-parse", "--abbrev-ref", "HEAD").trim();
8054
7854
  const upstream = gitOrEmpty(
8055
7855
  git,
@@ -8058,12 +7858,26 @@ function resolveBase(ctx) {
8058
7858
  "--symbolic-full-name",
8059
7859
  "@{upstream}"
8060
7860
  ).trim();
7861
+ const candidates = [];
8061
7862
  if (upstream !== "") {
8062
7863
  const slash = upstream.indexOf("/");
8063
7864
  const branch = slash === -1 ? upstream : upstream.slice(slash + 1);
8064
- if (branch !== "" && branch !== current) return upstream;
7865
+ if (branch !== "" && branch !== current) candidates.push(upstream);
8065
7866
  }
8066
- 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
+ );
8067
7881
  }
8068
7882
  function verifyBase(ctx, paths, base, hint = "") {
8069
7883
  if (base.startsWith("-")) throw new DldError(`--base must be a git ref, got '${base}'`);
@@ -8095,24 +7909,56 @@ function takenIds(ctx, paths, base) {
8095
7909
  gitOrEmpty(git, "ls-tree", "-r", "-z", "--name-only", base, "--", recordsPathspec(paths))
8096
7910
  ).flatMap((path) => path.match(DECISION_MENTION) ?? []);
8097
7911
  const scan = openPrIds(ctx, paths, base);
8098
- const ids = [.../* @__PURE__ */ new Set([...onBase, ...scan.ids])].sort(compareIds);
8099
- return scan.skipped === void 0 ? { ids } : { ids, skipped: scan.skipped };
7912
+ const ids = [.../* @__PURE__ */ new Set([...onBase, ...scan.claims.map(({ id }) => id)])].sort(compareIds);
7913
+ const taken = { ids, onBase: new Set(onBase), claims: scan.claims };
7914
+ return scan.skipped === void 0 ? taken : { ...taken, skipped: scan.skipped };
7915
+ }
7916
+ function isAncestor(git, ancestor, commit2) {
7917
+ try {
7918
+ git("merge-base", "--is-ancestor", ancestor, commit2);
7919
+ return true;
7920
+ } catch (error) {
7921
+ if (error instanceof GitCommandError) return false;
7922
+ throw error;
7923
+ }
7924
+ }
7925
+ function collides(git, base, { path, id }, taken) {
7926
+ if (taken.onBase.has(id)) return true;
7927
+ const claims = taken.claims.filter((claim) => claim.id === id);
7928
+ if (claims.length === 0) return false;
7929
+ const added = git(
7930
+ "--literal-pathspecs",
7931
+ "log",
7932
+ // log.follow or log.showSignature in the user's config would change the output.
7933
+ "--no-follow",
7934
+ "--no-show-signature",
7935
+ "-1",
7936
+ "--format=%H",
7937
+ "--diff-filter=A",
7938
+ `${base}..HEAD`,
7939
+ "--",
7940
+ path
7941
+ ).trim();
7942
+ return claims.some(
7943
+ ({ head }) => head === void 0 || added === "" || !isAncestor(git, added, head)
7944
+ );
8100
7945
  }
8101
7946
  function listTakenIds(ctx, { paths }, base) {
8102
7947
  verifyBase(ctx, paths, base, " Fetch first or pass --base.");
8103
- return takenIds(ctx, paths, base);
7948
+ const { ids, skipped } = takenIds(ctx, paths, base);
7949
+ return skipped === void 0 ? { ids } : { ids, skipped };
8104
7950
  }
8105
7951
  function collisionsOf(ctx, paths, base) {
8106
- const added = localAdditions(gitAt(ctx, paths.root), paths, base);
7952
+ const git = gitAt(ctx, paths.root);
7953
+ const added = localAdditions(git, paths, base);
8107
7954
  const local = added.flatMap((path) => {
8108
- const id = basename4(path, ".md");
8109
- return RECORD_FILE.test(basename4(path)) ? [{ path, id }] : [];
7955
+ const id = basename3(path, ".md");
7956
+ return RECORD_FILE.test(basename3(path)) ? [{ path, id }] : [];
8110
7957
  });
8111
7958
  if (local.length === 0) return { collisions: [], localIds: [], taken: [] };
8112
7959
  const taken = takenIds(ctx, paths, base);
8113
- const takenSet = new Set(taken.ids);
8114
7960
  const result = {
8115
- collisions: local.filter(({ id }) => takenSet.has(id)),
7961
+ collisions: local.filter((record) => collides(git, base, record, taken)),
8116
7962
  localIds: local.map(({ id }) => id),
8117
7963
  taken: taken.ids
8118
7964
  };
@@ -8134,790 +7980,1151 @@ function formatRename({ path, oldId, newId }) {
8134
7980
  return `${path} ${oldId} ${newId}`;
8135
7981
  }
8136
7982
 
8137
- // src/core/rename-plan.ts
8138
- import { posix } from "node:path";
8139
- function renameProblem(paths, rename) {
8140
- const { path, oldId, newId } = rename;
8141
- if (!DECISION_ID.test(oldId) || !DECISION_ID.test(newId)) return "IDs must match DL-[0-9]+.";
8142
- if (oldId === newId) return "old and new IDs are the same.";
8143
- const segments = path.split("/");
8144
- if (path.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) {
8145
- return `path '${path}' must be relative to the project root, without '.' or '..' segments.`;
8146
- }
8147
- const records = recordsPathspec(paths);
8148
- if (!path.startsWith(`${records}/`)) {
8149
- 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;
8150
7988
  }
8151
- if (posix.basename(path) !== `${oldId}.md`) {
8152
- 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 };
8153
8084
  }
8154
8085
  return void 0;
8155
8086
  }
8156
- function parseRenamePlan(paths, text) {
8157
- const renames = [];
8158
- const lineNumbers = [];
8159
- const seen = { path: /* @__PURE__ */ new Set(), old: /* @__PURE__ */ new Set(), new: /* @__PURE__ */ new Set() };
8160
- text.split("\n").forEach((raw, index) => {
8161
- const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
8162
- if (line.trim() === "") return;
8163
- const fail = (message) => new DldError(`rename plan line ${index + 1}: ${message}`);
8164
- const fields = line.split(" ");
8165
- const [path, oldId, newId] = fields;
8166
- if (fields.length !== 3 || path === void 0 || oldId === void 0 || newId === void 0) {
8167
- throw fail("expected <path>\\t<DL-OLD>\\t<DL-NEW>.");
8168
- }
8169
- const rename = { path, oldId, newId };
8170
- const problem = renameProblem(paths, rename);
8171
- if (problem !== void 0) throw fail(problem);
8172
- if (seen.path.has(path)) throw fail(`path '${path}' appears more than once.`);
8173
- if (seen.old.has(oldId)) throw fail(`${oldId} is renamed more than once.`);
8174
- if (seen.new.has(newId)) throw fail(`${newId} is the target of more than one rename.`);
8175
- seen.path.add(path);
8176
- seen.old.add(oldId);
8177
- seen.new.add(newId);
8178
- renames.push(rename);
8179
- 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 };
8180
8108
  });
8181
- renames.forEach(({ newId }, index) => {
8182
- if (seen.old.has(newId)) {
8183
- throw new DldError(
8184
- `rename plan line ${lineNumbers[index]}: ${newId} is both renamed and a rename target.`
8185
- );
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);
8186
8121
  }
8122
+ return path;
8187
8123
  });
8188
- 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}`;
8189
8150
  }
8190
8151
 
8191
- // src/core/commit-reindex.ts
8192
- var SUBJECT_LIST_LIMIT = 3;
8193
- function reindexMessage(renames, originalSubjects) {
8194
- const pairs = renames.map(({ oldId, newId }) => `${oldId} -> ${newId}`);
8195
- const subject = renames.length > SUBJECT_LIST_LIMIT ? `reindex ${renames.length} local decisions to avoid base-branch collisions` : `reindex local decisions: ${pairs.join(", ")}`;
8196
- const message = `${subject}
8197
-
8198
- Renames:
8199
- ${pairs.map((pair) => `- ${pair}`).join("\n")}`;
8200
- return originalSubjects === "" ? message : `${message}
8152
+ // src/core/project.ts
8153
+ import { join as join5 } from "node:path";
8201
8154
 
8202
- Squashed from original branch commits:
8203
- ${originalSubjects}`;
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.`);
8175
+ }
8176
+ return parseConfig(ctx.fs.readFile(path), (message) => ctx.warn(message));
8204
8177
  }
8205
- function commitReindex(ctx, { paths }, planText, base) {
8206
- const git = gitAt(ctx, paths.root);
8207
- verifyBase(ctx, paths, base);
8208
- const renames = parseRenamePlan(paths, planText);
8209
- if (renames.length === 0) throw new DldError("no rename plan on stdin.");
8210
- const onto = mergeBase(ctx, paths, base);
8211
- const head = git("rev-parse", "HEAD").trim();
8212
- if (onto === head) throw new DldError("HEAD is already at the merge-base \u2014 nothing to squash.");
8213
- const indexRel = posix2.join(decisionsPathspec(paths), "INDEX.md");
8214
- const branchFiles = nulSeparated(
8215
- git("diff", "-z", "--no-renames", "--name-only", "--diff-filter=AMRD", `${onto}..HEAD`)
8216
- );
8217
- const stage = /* @__PURE__ */ new Set();
8218
- for (const { path, newId } of renames) {
8219
- stage.add(path);
8220
- 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));
8221
8187
  }
8222
- for (const file of branchFiles) if (file !== indexRel) stage.add(file);
8223
- const subjects = git("log", "--reverse", "--format=- %s", `${onto}..HEAD`).replace(/\n+$/, "");
8224
- const message = reindexMessage(renames, subjects);
8225
- const indexFull = join7(paths.root, indexRel);
8226
- if (ctx.fs.lexists(indexFull) && !ctx.fs.isRegularFile(indexFull)) {
8227
- throw new DldError(`${indexRel} is not a regular file.`);
8188
+ const mode = raw.mode;
8189
+ if (mode !== "flat" && mode !== "namespaced") {
8190
+ throw invalid("'mode' must be 'flat' or 'namespaced'");
8228
8191
  }
8229
- const saved = {
8230
- head,
8231
- tree: git("write-tree").trim(),
8232
- index: ctx.fs.isRegularFile(indexFull) ? { bytes: ctx.fs.readBytes(indexFull), mode: ctx.fs.fileMode(indexFull) } : void 0
8233
- };
8234
- let stepName = "resetting to the merge-base";
8235
- const step = (name, run2) => {
8236
- stepName = name;
8237
- return run2();
8238
- };
8239
- try {
8240
- step("resetting to the merge-base", () => git("reset", "--quiet", onto));
8241
- step(`restoring ${indexRel}`, () => {
8242
- if (existsAtHead(git, indexRel)) {
8243
- git("--literal-pathspecs", "checkout", "HEAD", "--", indexRel);
8244
- } else {
8245
- ctx.fs.remove(indexFull);
8246
- }
8247
- });
8248
- for (const path of [...stage].sort()) {
8249
- step(`staging ${path}`, () => stagePath(ctx, git, join7(paths.root, path), path));
8250
- }
8251
- step("checking the staged changes", () => {
8252
- if (!hasStagedChanges(git)) {
8253
- throw new DldError(
8254
- "nothing to commit after squash. The reindex may have already been applied, or the plan didn't match the branch state."
8255
- );
8256
- }
8257
- });
8258
- step("committing", () => {
8259
- try {
8260
- git("commit", "--quiet", "-m", message);
8261
- } catch (error) {
8262
- if (error instanceof GitCommandError)
8263
- throw new DldError(`git commit failed: ${error.stderr}`);
8264
- throw error;
8265
- }
8266
- });
8267
- } catch (error) {
8268
- throw rollback(ctx, git, indexFull, saved, stepName, error);
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'");
8269
8199
  }
8270
8200
  return {
8271
- commit: git("rev-parse", "--short", "HEAD").trim(),
8272
- onto: git("rev-parse", "--short", onto).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)
8273
8211
  };
8274
8212
  }
8275
- function existsAtHead(git, path) {
8276
- return git("--literal-pathspecs", "ls-tree", "-z", "HEAD", "--", path) !== "";
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;
8236
+ }
8237
+ return previous[b.length] ?? 0;
8277
8238
  }
8278
- function stagePath(ctx, git, full, path) {
8279
- const known = ctx.fs.lexists(full) || git("--literal-pathspecs", "ls-files", "-z", "--", path) !== "";
8280
- if (known) git("--literal-pathspecs", "add", "-A", "--", path);
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
+ );
8246
+ }
8247
+ return { baseBranch: branch };
8281
8248
  }
8282
- function hasStagedChanges(git) {
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`);
8264
+ }
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`);
8278
+ }
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}'`);
8287
+ }
8288
+ return patterns;
8289
+ }
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;
8297
+ }
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
+ });
8309
+ }
8310
+
8311
+ // src/core/project.ts
8312
+ function findProjectRoot(ctx) {
8283
8313
  try {
8284
- git("diff", "--cached", "--quiet");
8285
- return false;
8314
+ return ctx.git(["rev-parse", "--show-toplevel"]).trim();
8286
8315
  } catch (error) {
8287
- if (error instanceof GitCommandError && error.status === 1) return true;
8316
+ if (error instanceof GitCommandError) throw new DldError("not a git repository");
8288
8317
  throw error;
8289
8318
  }
8290
8319
  }
8291
- function rollback(ctx, git, indexFull, saved, stepName, cause) {
8292
- try {
8293
- git("reset", "--quiet", "--soft", saved.head);
8294
- git("read-tree", saved.tree);
8295
- if (saved.index === void 0) ctx.fs.remove(indexFull);
8296
- else writeFileAtomic(ctx, indexFull, saved.index.bytes, saved.index.mode);
8297
- } catch (rollbackError) {
8298
- return new DldError(
8299
- `commit-reindex failed while ${stepName}: ${describe(cause)}
8300
- Rolling back also failed: ${describe(rollbackError)}
8301
- To restore by hand: git reset --soft ${saved.head} && git read-tree ${saved.tree}`
8302
- );
8303
- }
8304
- if (!(cause instanceof DldError)) return cause;
8305
- return new DldError(
8306
- `commit-reindex failed while ${stepName}: ${cause.message}
8307
- The branch was restored to ${saved.head}.`,
8308
- cause.exitCode
8309
- );
8320
+ function resolvePaths(root, config) {
8321
+ const decisionsDir = join5(root, config.decisionsDir);
8322
+ return { root, decisionsDir, recordsDir: join5(decisionsDir, "records") };
8310
8323
  }
8311
- function describe(error) {
8312
- 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) };
8313
8328
  }
8314
8329
 
8315
8330
  // src/cli/commands/base-option.ts
8316
- function baseOption(value, fallback = DEFAULT_BASE) {
8317
- const base = value ?? fallback;
8318
- if (base.startsWith("-")) throw new UsageError(`--base must be a git ref, got '${base}'`);
8319
- 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;
8320
8338
  }
8339
+ var DEFAULT_BASE_HELP = "default: the base resolve-base picks";
8321
8340
  function skippedNotice(reason) {
8322
8341
  return `[dld-reindex] open PRs not scanned: ${reason}
8323
8342
  `;
8324
8343
  }
8325
8344
 
8326
- // src/cli/commands/commit-reindex.ts
8327
- var commitReindexCommand = {
8328
- name: "commit-reindex",
8329
- summary: "Squash the branch into one reindex commit",
8330
- internal: true,
8331
- usage: `Usage: dld commit-reindex --base <ref> < 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 ...]
8351
+
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.
8357
+
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.
8332
8361
 
8333
- Read a rename plan on standard input and squash the branch's commits since the merge-base
8334
- with <ref> into one reindex commit. INDEX.md is left at its merge-base state. On failure the
8335
- branch, index and INDEX.md are restored.
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.
8336
8364
 
8337
8365
  Options:
8338
- --base <ref> Base ref (required)
8366
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
8367
+ --uncommitted Compare with HEAD instead of the merge-base: only uncommitted changes
8339
8368
  `,
8340
8369
  run(args, io, ctx) {
8341
- const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
8342
- if (!values.base) throw new DldError("--base is required.");
8343
- const base = baseOption(values.base);
8370
+ const { values, positionals: ids } = parseCommandArgs({
8371
+ args: [...args],
8372
+ options: { base: { type: "string" }, uncommitted: { type: "boolean" } },
8373
+ allowPositionals: true
8374
+ });
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}'`);
8344
8377
  const project = loadProject(ctx);
8345
- const { commit, onto } = commitReindex(ctx, project, ctx.readStdin(), base);
8346
- io.stdout(`Created reindex commit ${commit} on top of ${onto}
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
8385
+ });
8386
+ for (const edit of edits) io.stdout(`${formatRecordEdit(edit)}
8347
8387
  `);
8348
- 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;
8349
8391
  }
8350
8392
  };
8351
8393
 
8352
- // src/core/init.ts
8353
- import { join as join8 } from "node:path";
8354
- function parseMode(value) {
8355
- if (value !== "flat" && value !== "namespaced") {
8356
- 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`);
8357
8413
  }
8358
- return value;
8414
+ return doc;
8359
8415
  }
8360
- function renderConfig(mode, namespaces) {
8361
- const lines = ["decisions_dir: decisions", `mode: ${mode}`];
8362
- if (mode === "namespaced") lines.push("namespaces:", ...namespaces.map((ns) => ` - ${ns}`));
8363
- lines.push("annotation_prefix: '@decision'");
8364
- return `${lines.join("\n")}
8365
- `;
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;
8366
8419
  }
8367
- function createConfig(ctx, root, mode, namespaces) {
8368
- if (mode === "namespaced" && namespaces.length === 0) {
8369
- throw new DldError("namespaced mode requires at least one namespace.");
8370
- }
8371
- const path = join8(root, CONFIG_FILE);
8372
- createFileExclusive(ctx, path, renderConfig(mode, namespaces), `${CONFIG_FILE} already exists.`);
8373
- 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));
8374
8424
  }
8375
- function createDirectories(ctx, { config, paths }) {
8376
- ctx.fs.mkdir(paths.decisionsDir);
8377
- ctx.fs.mkdir(paths.recordsDir);
8378
- if (config.mode !== "namespaced") return;
8379
- for (const namespace of config.namespaces) {
8380
- const dir = join8(paths.recordsDir, namespace);
8381
- ctx.fs.mkdir(dir);
8382
- const keep = join8(dir, ".gitkeep");
8383
- if (!ctx.fs.exists(keep)) createFileExclusive(ctx, keep, "", `${keep} already exists.`);
8384
- }
8425
+ function stateString(section, key) {
8426
+ const value = section?.[key];
8427
+ return typeof value === "string" && value !== "" ? value : void 0;
8385
8428
  }
8386
-
8387
- // src/cli/commands/create-config.ts
8388
- var createConfigCommand = {
8389
- name: "create-config",
8390
- summary: "Create dld.config.yaml at the project root",
8391
- internal: true,
8392
- usage: "Usage: dld create-config <flat|namespaced> [namespace ...]\n\nCreate dld.config.yaml. Namespaced mode needs at least one namespace.\n",
8393
- run(args, io, ctx) {
8394
- const { positionals } = parseCommandArgs({
8395
- args: [...args],
8396
- options: {},
8397
- allowPositionals: true
8398
- });
8399
- const [mode, ...namespaces] = positionals;
8400
- if (mode === void 0) throw new UsageError("missing <flat|namespaced>");
8401
- const path = createConfig(ctx, findProjectRoot(ctx), parseMode(mode), namespaces);
8402
- io.stdout(`Created ${path}
8403
- `);
8404
- return EXIT_OK;
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;
8405
8435
  }
8406
- };
8436
+ }
8407
8437
 
8408
- // src/core/decisions.ts
8409
- import { join as join9, relative as relative4 } from "node:path";
8410
- function updateStatus(ctx, project, id, status) {
8411
- const path = findRecordFile(ctx, project.paths.recordsDir, id);
8412
- if (path === void 0) throw new DldError(`decision ${id} not found.`);
8413
- const text = ctx.fs.readFile(path);
8414
- writeFileAtomic(ctx, path, setStatus(text, status, relative4(project.paths.root, path)));
8415
- return path;
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);
8416
8448
  }
8417
- function createDecision(ctx, project, input) {
8418
- if (!DECISION_ID.test(input.id)) {
8419
- throw new DldError(`invalid decision ID '${input.id}'; expected DL-<digits>, e.g. DL-001.`);
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}`);
8420
8452
  }
8421
- const namespaced = project.config.mode === "namespaced" && input.namespace !== void 0;
8422
- if (namespaced && !isSafeDirName(input.namespace ?? "")) {
8423
- throw new DldError(`invalid namespace '${input.namespace}'.`);
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
+ `);
8456
+ }
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" };
8424
8463
  }
8425
- const dir = namespaced ? join9(project.paths.recordsDir, input.namespace ?? "") : project.paths.recordsDir;
8426
- ctx.fs.mkdir(dir);
8427
- const path = join9(dir, `${input.id}.md`);
8428
- const content = renderNewRecord({
8429
- id: input.id,
8430
- title: input.title,
8431
- timestamp: formatTimestamp(ctx.now()),
8432
- ...namespaced && input.namespace !== void 0 ? { namespace: input.namespace } : {},
8433
- tags: input.tags,
8434
- supersedes: input.supersedes,
8435
- amends: input.amends,
8436
- body: input.body
8437
- });
8438
- createFileExclusive(ctx, path, content, `${path} already exists.`);
8439
- return path;
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;
8476
+ }
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");
8440
8498
  }
8441
- function isSafeDirName(name) {
8442
- 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 };
8443
8514
  }
8444
8515
 
8445
- // src/cli/commands/create-decision.ts
8446
- var createDecisionCommand = {
8447
- name: "create-decision",
8448
- 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",
8449
8520
  internal: true,
8450
- usage: `Usage: dld create-decision --id <DL-NNN> --title <title> [options]
8521
+ usage: `Usage: dld collect-active-decisions [--include-proposed]
8451
8522
 
8452
- 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.
8453
8524
 
8454
8525
  Options:
8455
- --id <DL-NNN> Decision ID (required)
8456
- --title <title> Title (required)
8457
- --namespace <name> Namespace directory (namespaced projects only)
8458
- --tags <a,b> Comma-separated tags
8459
- --supersedes <DL-X,...> Decisions this one supersedes
8460
- --amends <DL-X,...> Decisions this one amends
8461
- --body-stdin Read the markdown body from standard input
8526
+ --include-proposed Print proposed records too, in the same ID order
8462
8527
  `,
8463
8528
  run(args, io, ctx) {
8464
8529
  const { values } = parseCommandArgs({
8465
8530
  args: [...args],
8466
- options: {
8467
- id: { type: "string" },
8468
- title: { type: "string" },
8469
- namespace: { type: "string" },
8470
- tags: { type: "string" },
8471
- supersedes: { type: "string" },
8472
- amends: { type: "string" },
8473
- "body-stdin": { type: "boolean" }
8474
- }
8475
- });
8476
- const body = values["body-stdin"] === true ? ctx.readStdin() : "";
8477
- const { id, title } = values;
8478
- if (id === void 0 || id === "" || title === void 0 || title === "") {
8479
- throw new DldError("--id and --title are required.");
8480
- }
8481
- const namespace = values.namespace === "" ? void 0 : values.namespace;
8482
- const path = createDecision(ctx, loadProject(ctx), {
8483
- id,
8484
- title,
8485
- ...namespace === void 0 ? {} : { namespace },
8486
- tags: values.tags ?? "",
8487
- supersedes: values.supersedes ?? "",
8488
- amends: values.amends ?? "",
8489
- body
8531
+ options: { "include-proposed": { type: "boolean" } }
8490
8532
  });
8491
- io.stdout(`${path}
8492
- `);
8533
+ io.stdout(
8534
+ collectActiveDecisions(ctx, loadProject(ctx), {
8535
+ includeProposed: values["include-proposed"] === true
8536
+ })
8537
+ );
8493
8538
  return EXIT_OK;
8494
8539
  }
8495
8540
  };
8496
8541
 
8497
- // src/cli/commands/create-directories.ts
8498
- var createDirectoriesCommand = {
8499
- name: "create-directories",
8500
- summary: "Create the decisions directory structure from the config",
8501
- internal: true,
8502
- usage: "Usage: dld create-directories\n\nCreate the decisions and records directories, and one per namespace.\n",
8503
- run(args, io, ctx) {
8504
- parseCommandArgs({ args: [...args], options: {} });
8505
- createDirectories(ctx, loadProject(ctx));
8506
- io.stdout("Created decisions directory structure.\n");
8507
- return EXIT_OK;
8508
- }
8509
- };
8542
+ // src/core/commit-reindex.ts
8543
+ import { join as join8, posix as posix2 } from "node:path";
8510
8544
 
8511
- // src/core/index-file.ts
8512
- import { basename as basename5, join as join10, relative as relative5, sep as sep2 } from "node:path";
8513
- var INDEX_FILE = "INDEX.md";
8514
- function renderIndex(rows, mode) {
8515
- const namespaced = mode === "namespaced";
8516
- const lines = [
8517
- "# Decision Log",
8518
- "",
8519
- namespaced ? "| ID | Title | Status | Namespace | Tags |" : "| ID | Title | Status | Tags |",
8520
- namespaced ? "|----|-------|--------|-----------|------|" : "|----|-------|--------|------|"
8521
- ];
8522
- const sorted = [...rows].sort((a, b) => b.number - a.number);
8523
- for (const { record } of sorted) {
8524
- const cells = [record.id, record.title, record.status];
8525
- if (namespaced) cells.push(record.namespace ?? "");
8526
- cells.push(record.tags.join(", "));
8527
- 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}`}.`;
8528
8553
  }
8529
- return `${lines.join("\n")}
8530
- `;
8531
- }
8532
- function indexPath(paths) {
8533
- return join10(paths.decisionsDir, INDEX_FILE);
8534
- }
8535
- function collectIndexRows(ctx, paths, includeBase) {
8536
- const localFiles = listRecordFiles(ctx, paths.recordsDir);
8537
- const rows = localFiles.map((path) => ({
8538
- number: recordNumber(path),
8539
- record: parseRecord(ctx.fs.readFile(path), relative5(paths.root, path))
8540
- }));
8541
- if (includeBase === void 0) return rows;
8542
- const git = (...args) => ctx.git(["-C", paths.root, ...args]);
8543
- try {
8544
- git("rev-parse", "--verify", "--quiet", `${includeBase}^{commit}`);
8545
- } catch (error) {
8546
- if (error instanceof GitCommandError) {
8547
- throw new DldError(`--include-base ref '${includeBase}' not found.`);
8548
- }
8549
- 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.`;
8550
8558
  }
8551
- const localNames = new Set(localFiles.map((path) => basename5(path)));
8552
- const recordsRel = relative5(paths.root, paths.recordsDir).split(sep2).join("/");
8553
- const basePaths = git("ls-tree", "-r", "--name-only", includeBase, "--", recordsRel).split("\n").filter((path) => RECORD_FILE.test(basename5(path)) && !localNames.has(basename5(path)));
8554
- for (const path of basePaths) {
8555
- rows.push({
8556
- number: recordNumber(path),
8557
- record: parseRecord(git("show", `${includeBase}:${path}`), `${includeBase}:${path}`)
8558
- });
8559
+ const records = recordsPathspec(paths);
8560
+ if (!path.startsWith(`${records}/`)) {
8561
+ return `path '${path}' is not under ${records}/.`;
8559
8562
  }
8560
- return rows;
8563
+ if (posix.basename(path) !== `${oldId}.md`) {
8564
+ return `path '${path}' is not named ${oldId}.md.`;
8565
+ }
8566
+ return void 0;
8561
8567
  }
8562
- function writeIndex(ctx, paths, content) {
8563
- 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;
8564
8601
  }
8565
8602
 
8566
- // src/cli/commands/create-empty-index.ts
8567
- var createEmptyIndexCommand = {
8568
- name: "create-empty-index",
8569
- summary: "Write an INDEX.md with no decisions",
8570
- internal: true,
8571
- usage: "Usage: dld create-empty-index\n\nWrite INDEX.md in the decisions directory with only the table header.\n",
8572
- run(args, io, ctx) {
8573
- parseCommandArgs({ args: [...args], options: {} });
8574
- const { config, paths } = loadProject(ctx);
8575
- writeIndex(ctx, paths, renderIndex([], config.mode));
8576
- io.stdout(`Created ${indexPath(paths)}
8577
- `);
8578
- return EXIT_OK;
8579
- }
8580
- };
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}
8581
8609
 
8582
- // src/cli/commands/detect-snapshot-changes.ts
8583
- var detectSnapshotChangesCommand = {
8584
- name: "detect-snapshot-changes",
8585
- summary: "Report what changed since the last snapshot",
8586
- internal: true,
8587
- usage: "Usage: dld detect-snapshot-changes\n\nPrint mode (full or incremental), new_decisions, modified_decisions and commit_range.\n",
8588
- run(args, io, ctx) {
8589
- parseCommandArgs({ args: [...args], options: {} });
8590
- io.stdout(formatSnapshotChanges(detectSnapshotChanges(ctx, loadProject(ctx))));
8591
- return EXIT_OK;
8592
- }
8593
- };
8610
+ Renames:
8611
+ ${pairs.map((pair) => `- ${pair}`).join("\n")}`;
8612
+ return originalSubjects === "" ? message : `${message}
8594
8613
 
8595
- // src/core/annotations.ts
8596
- import { join as join11, relative as relative6, sep as sep3 } from "node:path";
8597
- var EXCLUDED_DIRS = /* @__PURE__ */ new Set([
8598
- ".git",
8599
- // Agent configuration directories, which hold installed skills.
8600
- ".claude",
8601
- ".agents",
8602
- ".agent",
8603
- ".codex",
8604
- ".cursor",
8605
- ".opencode",
8606
- ".pi",
8607
- ".tessl",
8608
- "node_modules",
8609
- "vendor",
8610
- ".venv",
8611
- "__pycache__",
8612
- "target",
8613
- "dist",
8614
- "build",
8615
- "out",
8616
- ".next",
8617
- ".gradle",
8618
- "coverage"
8619
- ]);
8620
- var EXCLUDED_FILE = /(\.lock|\.min\.js|\.min\.css|\.map)$/;
8621
- var BINARY_SNIFF_BYTES = 8192;
8622
- function scanOptionsFor({ config, paths }) {
8614
+ Squashed from original branch commits:
8615
+ ${originalSubjects}`;
8616
+ }
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
+ }
8626
+ }
8627
+ const git = gitAt(ctx, paths.root);
8628
+ return squash ? squashReindex(ctx, git, paths, renames, base) : commitOnTop(ctx, git, paths, renames);
8629
+ }
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`));
8635
+ }
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
+ }
8623
8672
  return {
8624
- root: paths.root,
8625
- decisionsDir: paths.decisionsDir,
8626
- prefix: config.annotationPrefix,
8627
- exclude: config.annotationExclude
8673
+ commit: git("rev-parse", "--short", "HEAD").trim(),
8674
+ onto: git("rev-parse", "--short", head).trim()
8628
8675
  };
8629
8676
  }
8630
- function listScannableFiles(ctx, options) {
8631
- const output = ctx.git([
8632
- "-C",
8633
- options.root,
8634
- "ls-files",
8635
- "-z",
8636
- "--cached",
8637
- "--others",
8638
- "--exclude-standard",
8639
- // @decision(DL-023)
8640
- "--",
8641
- ...(options.exclude ?? []).map((pattern) => `:(exclude,glob)${pattern}`)
8642
- ]);
8643
- const decisionsRel = relative6(options.root, options.decisionsDir).split(sep3).join("/");
8644
- const files = /* @__PURE__ */ new Set();
8645
- for (const file of output.split("\0")) {
8646
- if (file === "" || EXCLUDED_FILE.test(file)) continue;
8647
- if (file === decisionsRel || file.startsWith(`${decisionsRel}/`)) continue;
8648
- const dirs = file.split("/").slice(0, -1);
8649
- if (dirs.some((dir) => EXCLUDED_DIRS.has(dir))) continue;
8650
- files.add(file);
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;
8651
8683
  }
8652
- return [...files].sort();
8653
8684
  }
8654
- function escapeRegExp(text) {
8655
- return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8656
- }
8657
- function scanAnnotations(ctx, options) {
8658
- const pattern = new RegExp(`${escapeRegExp(options.prefix)}\\((DL-\\d+)\\)`, "g");
8659
- const found = [];
8660
- for (const file of listScannableFiles(ctx, options)) {
8661
- const path = join11(options.root, file);
8662
- if (!ctx.fs.isRegularFile(path)) continue;
8663
- const text = ctx.fs.readFile(path);
8664
- if (text.slice(0, BINARY_SNIFF_BYTES).includes("\0")) continue;
8665
- text.split("\n").forEach((content, index) => {
8666
- for (const match of content.matchAll(pattern)) {
8667
- if (match[1] !== void 0) found.push({ file, line: index + 1, id: match[1] });
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`));
8697
+ }
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
+ );
8668
8732
  }
8669
8733
  });
8734
+ step("committing", () => commit(git, message));
8735
+ } catch (error) {
8736
+ throw rollback(ctx, git, indexFull, saved, stepName, error);
8670
8737
  }
8671
- return found;
8738
+ return {
8739
+ commit: git("rev-parse", "--short", "HEAD").trim(),
8740
+ onto: git("rev-parse", "--short", onto).trim()
8741
+ };
8672
8742
  }
8673
- function missingAnnotations(ctx, options, ids) {
8674
- const annotated = new Set(scanAnnotations(ctx, options).map((annotation) => annotation.id));
8675
- return ids.filter((id) => !annotated.has(id));
8743
+ function existsAtHead(git, path) {
8744
+ return git("--literal-pathspecs", "ls-tree", "-z", "HEAD", "--", path) !== "";
8676
8745
  }
8677
- function formatAnnotation({ file, line, id }) {
8678
- return `${file}:${line}:${id}`;
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);
8679
8749
  }
8680
-
8681
- // src/cli/commands/find-annotations.ts
8682
- var findAnnotationsCommand = {
8683
- name: "find-annotations",
8684
- summary: "List every annotation in the codebase",
8685
- internal: true,
8686
- usage: "Usage: dld find-annotations\n\nPrint <file>:<line>:<DL-NNN> for every annotation, one per line.\n",
8687
- run(args, io, ctx) {
8688
- parseCommandArgs({ args: [...args], options: {} });
8689
- for (const annotation of scanAnnotations(ctx, scanOptionsFor(loadProject(ctx)))) {
8690
- io.stdout(`${formatAnnotation(annotation)}
8691
- `);
8692
- }
8693
- return EXIT_OK;
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;
8694
8757
  }
8695
- };
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
+ }
8696
8782
 
8697
- // src/cli/commands/find-collisions.ts
8698
- var findCollisionsCommand = {
8699
- name: "find-collisions",
8700
- summary: "List locally added decisions whose IDs are taken",
8783
+ // src/cli/commands/commit-reindex.ts
8784
+ var commitReindexCommand = {
8785
+ name: "commit-reindex",
8786
+ summary: "Commit the renamed decisions",
8701
8787
  internal: true,
8702
- usage: `Usage: dld find-collisions [--base <ref>]
8788
+ usage: `Usage: dld commit-reindex --base <ref> [--squash] < plan
8703
8789
 
8704
- Print <path>\\t<DL-NNN> for each decision added on this branch whose ID is taken on the base
8705
- 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.
8706
8798
 
8707
8799
  Options:
8708
- --base <ref> Base ref (default: origin/main)
8800
+ --base <ref> Base ref (required)
8801
+ --squash Squash the branch instead of adding a commit
8709
8802
  `,
8710
8803
  run(args, io, ctx) {
8711
- 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
+ });
8712
8808
  const base = baseOption(values.base);
8713
- const { collisions, skipped } = findCollisions(ctx, loadProject(ctx), base);
8714
- 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}
8715
8814
  `);
8716
- if (skipped !== void 0) io.stderr(skippedNotice(skipped));
8717
8815
  return EXIT_OK;
8718
8816
  }
8719
8817
  };
8720
8818
 
8721
- // src/core/audit.ts
8722
- import { basename as basename6, relative as relative7, sep as sep4 } from "node:path";
8723
- var MENTION = /DL-\d+/g;
8724
- var toPosix = (path) => path.split(sep4).join("/");
8725
- var idNumber2 = (id) => Number.parseInt(id.slice(3), 10);
8726
- function recordsChangedSinceAudit(ctx, { paths }) {
8727
- const git = gitAt(ctx, paths.root);
8728
- const stored = stateString(readStateSection(ctx, paths, "audit"), "commit_hash");
8729
- const commit = resolveStateCommit(git, stored);
8730
- if (commit === void 0) return void 0;
8731
- const records = recordsPathspec(paths);
8732
- return /* @__PURE__ */ new Set([
8733
- ...nulSeparated(git("diff", "-z", "--name-only", commit, "--", records)),
8734
- ...nulSeparated(git("ls-files", "-z", "--others", "--exclude-standard", "--", records))
8735
- ]);
8736
- }
8737
- function findMissingAmends(ctx, project, { all }) {
8738
- const { paths } = project;
8739
- const changed = all ? void 0 : recordsChangedSinceAudit(ctx, project);
8740
- const found = [];
8741
- const files = listRecordFiles(ctx, paths.recordsDir).sort(
8742
- (a, b) => recordNumber(a) - recordNumber(b)
8743
- );
8744
- for (const file of files) {
8745
- const rel = toPosix(relative7(paths.root, file));
8746
- if (changed !== void 0 && !changed.has(rel)) continue;
8747
- const text = ctx.fs.readFile(file);
8748
- const record = parseRecord(text, rel);
8749
- const source = basename6(file, ".md");
8750
- const declared = /* @__PURE__ */ new Set([...record.supersedes, ...record.amends]);
8751
- const mentioned = new Set(recordBody(text).match(MENTION) ?? []);
8752
- const refs = [...mentioned].filter((id) => id !== source && !declared.has(id)).sort((a, b) => idNumber2(a) - idNumber2(b));
8753
- for (const referenced of refs) found.push({ source, referenced });
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}'.`);
8754
8824
  }
8755
- return found;
8825
+ return value;
8756
8826
  }
8757
- function updateAuditState(ctx, { paths }) {
8758
- const timestamp = formatTimestamp(ctx.now());
8759
- const commit = shortHead(ctx, paths.root);
8760
- writeStateSection(ctx, paths, "audit", { last_run: timestamp, commit_hash: commit });
8761
- return { timestamp, commit };
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.");
8837
+ }
8838
+ const path = join9(root, CONFIG_FILE);
8839
+ createFileExclusive(ctx, path, renderConfig(mode, namespaces), `${CONFIG_FILE} already exists.`);
8840
+ return path;
8841
+ }
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
+ }
8762
8852
  }
8763
8853
 
8764
- // src/cli/commands/find-missing-amends.ts
8765
- var findMissingAmendsCommand = {
8766
- name: "find-missing-amends",
8767
- 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",
8768
8858
  internal: true,
8769
- usage: `Usage: dld find-missing-amends [--all]
8770
-
8771
- Print <source-id>:<referenced-id> for each decision ID a record's body mentions without
8772
- listing it in supersedes or amends. Only records changed since the last audit are checked.
8773
-
8774
- Options:
8775
- --all Check every record
8776
- `,
8859
+ usage: "Usage: dld create-config <flat|namespaced> [namespace ...]\n\nCreate dld.config.yaml. Namespaced mode needs at least one namespace.\n",
8777
8860
  run(args, io, ctx) {
8778
- const { values } = parseCommandArgs({ args: [...args], options: { all: { type: "boolean" } } });
8779
- const found = findMissingAmends(ctx, loadProject(ctx), { all: values.all === true });
8780
- 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}
8781
8870
  `);
8782
8871
  return EXIT_OK;
8783
8872
  }
8784
8873
  };
8785
8874
 
8786
- // src/core/rename.ts
8787
- import { Buffer as Buffer2 } from "node:buffer";
8788
- import { join as join12, posix as posix3 } from "node:path";
8789
- var idPattern = (id, flags = "") => new RegExp(`${id}(?![0-9])`, flags);
8790
- function changedFiles(ctx, paths, commit) {
8791
- return nulSeparated(
8792
- gitAt(ctx, paths.root)(
8793
- "diff",
8794
- "-z",
8795
- "--find-renames",
8796
- "--name-only",
8797
- "--diff-filter=AMR",
8798
- commit
8799
- )
8800
- );
8801
- }
8802
- function rewriteFile(ctx, path, edit) {
8803
- if (!ctx.fs.isRegularFile(path)) return;
8804
- const bytes = ctx.fs.readBytes(path);
8805
- if (bytes.subarray(0, BINARY_SNIFF_BYTES).includes(0)) return;
8806
- const text = Buffer2.from(bytes).toString("latin1");
8807
- const edited = edit(text);
8808
- if (edited === text) return;
8809
- writeFileAtomic(ctx, path, Buffer2.from(edited, "latin1"), ctx.fs.fileMode(path));
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;
8810
8883
  }
8811
- function renameDecision(ctx, project, rename, base) {
8812
- const { paths, config } = project;
8813
- const { path, oldId, newId } = rename;
8814
- const problem = renameProblem(paths, rename);
8815
- if (problem !== void 0) throw new DldError(problem);
8816
- const full = join12(paths.root, path);
8817
- if (!ctx.fs.isRegularFile(full)) throw new DldError(`${path} not found.`);
8818
- const record = parseRecord(ctx.fs.readFile(full), path);
8819
- if (record.id !== oldId) throw new DldError(`${path} has id ${record.id}, not ${oldId}.`);
8820
- const newPath = posix3.join(posix3.dirname(path), `${newId}.md`);
8821
- const newFull = join12(paths.root, newPath);
8822
- if (ctx.fs.lexists(newFull)) throw new DldError(`${newPath} already exists.`);
8823
- verifyBase(ctx, paths, base);
8824
- const since = mergeBase(ctx, paths, base);
8825
- const scannable = new Set(listScannableFiles(ctx, scanOptionsFor(project)));
8826
- gitAt(ctx, paths.root)("mv", "--", path, newPath);
8827
- const substitute = (text) => text.replace(idPattern(oldId, "g"), newId);
8828
- rewriteFile(ctx, newFull, (text) => substitute(setId(text, oldId, newId)));
8829
- const decisions = `${decisionsPathspec(paths)}/`;
8830
- const prefix = Buffer2.from(config.annotationPrefix, "utf8").toString("latin1");
8831
- const oldAnnotation = `${prefix}(${oldId})`;
8832
- const newAnnotation = `${prefix}(${newId})`;
8833
- for (const file of changedFiles(ctx, paths, since)) {
8834
- if (file === newPath) continue;
8835
- if (file.startsWith(decisions)) {
8836
- rewriteFile(ctx, join12(paths.root, file), substitute);
8837
- } else if (scannable.has(file)) {
8838
- rewriteFile(
8839
- ctx,
8840
- join12(paths.root, file),
8841
- (text) => text.split(oldAnnotation).join(newAnnotation)
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
8842
8902
  );
8843
8903
  }
8844
8904
  }
8845
- return newPath;
8846
- }
8847
- function findStaleMentions(ctx, { paths }, planText, base) {
8848
- const renames = parseRenamePlan(paths, planText);
8849
- if (renames.length === 0) return [];
8850
- verifyBase(ctx, paths, base);
8851
- const decisions = `${decisionsPathspec(paths)}/`;
8852
- const files = changedFiles(ctx, paths, mergeBase(ctx, paths, base)).flatMap((file) => {
8853
- if (file.startsWith(decisions)) return [];
8854
- const full = join12(paths.root, file);
8855
- if (!ctx.fs.isRegularFile(full)) return [];
8856
- const text = ctx.fs.readFile(full);
8857
- if (text.slice(0, BINARY_SNIFF_BYTES).includes("\0")) return [];
8858
- return [{ file, lines: text.split("\n").map((line) => line.replace(/\r$/, "")) }];
8859
- });
8860
- const found = [];
8861
- for (const { oldId, newId } of renames) {
8862
- const pattern = idPattern(oldId);
8863
- for (const { file, lines } of files) {
8864
- lines.forEach((text, index) => {
8865
- if (pattern.test(text)) found.push({ file, line: index + 1, oldId, newId, text });
8866
- });
8867
- }
8905
+ const existing = listRecordFiles(ctx, project.paths.recordsDir).find(
8906
+ (path2) => recordNumber(path2) === number
8907
+ );
8908
+ if (existing !== void 0) {
8909
+ throw new DldError(
8910
+ `decision ${input.id} already exists: ${relative6(project.paths.root, existing)}`
8911
+ );
8868
8912
  }
8869
- return found;
8870
- }
8871
- function formatStaleMention({ file, line, oldId, newId, text }) {
8872
- return `${file} ${line} ${oldId} ${newId} ${text}`;
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;
8873
8929
  }
8874
8930
 
8875
- // src/cli/commands/find-stale-mentions.ts
8876
- var findStaleMentionsCommand = {
8877
- name: "find-stale-mentions",
8878
- 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",
8879
8935
  internal: true,
8880
- usage: `Usage: dld find-stale-mentions --base <ref> < plan
8936
+ usage: `Usage: dld create-decision --id <DL-NNN> --title <title> [options]
8881
8937
 
8882
- Read a rename plan (<path>\\t<DL-OLD>\\t<DL-NEW> per line) on standard input and print
8883
- <path>\\t<line>\\t<DL-OLD>\\t<DL-NEW>\\t<text> for each remaining DL-OLD mention in changed
8884
- files outside the decisions directory.
8938
+ Create a decision record with status proposed and print its path.
8885
8939
 
8886
8940
  Options:
8887
- --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
8888
8948
  `,
8889
8949
  run(args, io, ctx) {
8890
- const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
8891
- if (!values.base) throw new DldError("--base is required.");
8892
- const base = baseOption(values.base);
8893
- const project = loadProject(ctx);
8894
- for (const mention of findStaleMentions(ctx, project, ctx.readStdin(), base)) {
8895
- io.stdout(`${formatStaleMention(mention)}
8896
- `);
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.");
8897
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
+ `);
8898
8979
  return EXIT_OK;
8899
8980
  }
8900
8981
  };
8901
8982
 
8902
- // src/cli/commands/init.ts
8903
- import { join as join17, relative as relative8 } from "node:path";
8904
-
8905
- // src/generate/harnesses.ts
8906
- import { join as join13 } 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
+ };
8907
8996
 
8908
- // src/generate/template.ts
8909
- var import_yaml4 = __toESM(require_dist(), 1);
8910
- var TEMPLATE_FIELDS = ["name", "description", "compatibility", "internal"];
8911
- function parseTemplate(text, skill, source) {
8912
- const fail = (line, message) => new DldError(`${source}:${line}: ${message}`);
8913
- if (text.includes("\r")) throw fail(1, "must use LF line endings, not CRLF");
8914
- const lines = text.split("\n");
8915
- if (lines[0] !== "---") throw fail(1, "must start with a --- frontmatter line");
8916
- const end = lines.indexOf("---", 1);
8917
- if (end === -1) throw fail(1, "frontmatter has no closing --- line");
8918
- const doc = (0, import_yaml4.parseDocument)(lines.slice(1, end).join("\n"), { logLevel: "silent" });
8919
- const problem = doc.errors[0];
8920
- if (problem !== void 0) {
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
+ }
9111
+
9112
+ // src/generate/harnesses.ts
9113
+ import { join as join13 } from "node:path";
9114
+
9115
+ // src/generate/template.ts
9116
+ var import_yaml4 = __toESM(require_dist(), 1);
9117
+ var TEMPLATE_FIELDS = ["name", "description", "compatibility", "internal"];
9118
+ function parseTemplate(text, skill, source) {
9119
+ const fail = (line, message) => new DldError(`${source}:${line}: ${message}`);
9120
+ if (text.includes("\r")) throw fail(1, "must use LF line endings, not CRLF");
9121
+ const lines = text.split("\n");
9122
+ if (lines[0] !== "---") throw fail(1, "must start with a --- frontmatter line");
9123
+ const end = lines.indexOf("---", 1);
9124
+ if (end === -1) throw fail(1, "frontmatter has no closing --- line");
9125
+ const doc = (0, import_yaml4.parseDocument)(lines.slice(1, end).join("\n"), { logLevel: "silent" });
9126
+ const problem = doc.errors[0];
9127
+ if (problem !== void 0) {
8921
9128
  const line = 1 + (problem.linePos?.[0].line ?? 1);
8922
9129
  throw fail(line, `frontmatter is not valid YAML: ${problem.message.split("\n", 1)[0]}`);
8923
9130
  }
@@ -9140,150 +9347,28 @@ function targetsFor(harnesses) {
9140
9347
  };
9141
9348
  }
9142
9349
 
9143
- // src/generate/install.ts
9144
- import { dirname as dirname4, join as join16 } from "node:path";
9145
-
9146
- // src/generate/generate.ts
9147
- import { dirname as dirname2, join as join14 } from "node:path";
9148
- var MANIFEST = "SKILL.md";
9149
- var normalMode = (mode) => mode & 73 ? 493 : 420;
9150
- function listFiles(ctx, dir, other = () => {
9151
- }, prefix = "") {
9152
- if (!ctx.fs.isDirectory(dir)) return [];
9153
- const found = [];
9154
- for (const entry of ctx.fs.readDir(dir).sort((a, b) => a.name < b.name ? -1 : 1)) {
9155
- if (entry.name.startsWith(".")) continue;
9156
- const rel = `${prefix}${entry.name}`;
9157
- if (entry.isDirectory) {
9158
- found.push(...listFiles(ctx, join14(dir, entry.name), other, `${rel}/`));
9159
- } else {
9160
- if (!entry.isFile) other(rel);
9161
- found.push(rel);
9162
- }
9163
- }
9164
- return found;
9165
- }
9166
- var utf8 = new TextDecoder("utf-8", { fatal: true });
9167
- function readSupportingFile(ctx, path, source) {
9168
- try {
9169
- return utf8.decode(ctx.fs.readBytes(path));
9170
- } catch (error) {
9171
- if (error instanceof TypeError) throw new DldError(`${source}: not UTF-8 text`);
9172
- throw error;
9173
- }
9174
- }
9175
- function manifest(template, adapter, { body, usesDld }, version2) {
9176
- const fields = adapter.fields.flatMap((field) => template.lines[field] ?? []);
9177
- return [
9178
- "---",
9179
- ...fields,
9180
- ...adapter.extraFrontmatter,
9181
- ...usesDld ? adapter.dldFrontmatter : [],
9182
- "metadata:",
9183
- ` dld-kit-version: "${version2}"`,
9184
- "---",
9185
- `<!-- Generated by dld-kit from ${template.source}. Do not edit: updating the skills overwrites this file. -->`,
9186
- body
9187
- ].join("\n");
9188
- }
9189
- function generateSkills(ctx, templatesDir, adapter, version2, { sourceDir = "templates/skills", extraFiles = /* @__PURE__ */ new Map() } = {}) {
9190
- const skills = ctx.fs.readDir(templatesDir).filter((entry) => entry.isDirectory && !entry.name.startsWith(".")).map((entry) => entry.name).sort();
9191
- const provided = /* @__PURE__ */ new Set();
9192
- const support = /* @__PURE__ */ new Map();
9193
- for (const skill of skills) {
9194
- const files = listFiles(ctx, join14(templatesDir, skill), (rel) => {
9195
- throw new DldError(`${sourceDir}/${skill}/${rel}: templates must be regular files`);
9196
- }).filter((file) => file !== MANIFEST);
9197
- support.set(skill, files);
9198
- for (const file of files) provided.add(`${skill}/${file}`);
9199
- }
9200
- for (const path of extraFiles.keys()) {
9201
- if (provided.has(path)) throw new DldError(`${sourceDir}/${path}: also generated; remove it`);
9202
- provided.add(path);
9203
- }
9204
- const output = /* @__PURE__ */ new Map();
9205
- for (const skill of skills) {
9206
- const path = join14(templatesDir, skill, MANIFEST);
9207
- const source = `${sourceDir}/${skill}/${MANIFEST}`;
9208
- const template = parseTemplate(ctx.fs.readFile(path), skill, source);
9209
- const rendered = renderBody(template, {
9210
- exists: (ref) => provided.has(`${ref.skill}/${ref.path}`),
9211
- script: (ref) => adapter.scriptRef(skill, ref),
9212
- dld: () => adapter.dld(skill),
9213
- dldSetup: () => adapter.dldSetup(skill)
9214
- });
9215
- if (!template.internal || adapter.internalManifest) {
9216
- output.set(`${skill}/${MANIFEST}`, {
9217
- content: manifest(template, adapter, rendered, version2),
9218
- mode: 420
9219
- });
9220
- }
9221
- for (const file of support.get(skill) ?? []) {
9222
- const full = join14(templatesDir, skill, file);
9223
- output.set(`${skill}/${file}`, {
9224
- content: readSupportingFile(ctx, full, `${sourceDir}/${skill}/${file}`),
9225
- mode: normalMode(ctx.fs.fileMode(full))
9226
- });
9227
- }
9228
- }
9229
- for (const [path, file] of extraFiles) output.set(path, { ...file, mode: normalMode(file.mode) });
9230
- return new Map([...output].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
9231
- }
9232
- function ownedSkill(path, files, generated) {
9233
- const skill = path.split("/", 1)[0] ?? "";
9234
- return generated.has(skill) || skill.startsWith("dld-") || files.has(path);
9235
- }
9236
- function diffOutput(ctx, dir, files) {
9237
- const generated = new Set([...files.keys()].map((path) => path.split("/", 1)[0] ?? ""));
9238
- const existing = new Set(
9239
- listFiles(ctx, dir).filter((path) => ownedSkill(path, files, generated))
9240
- );
9241
- const diff = { changed: [], missing: [], extra: [] };
9242
- for (const [path, file] of files) {
9243
- const full = join14(dir, path);
9244
- if (!existing.has(path) || !ctx.fs.isRegularFile(full)) diff.missing.push(path);
9245
- else if (ctx.fs.readFile(full) !== file.content || normalMode(ctx.fs.fileMode(full)) !== file.mode) {
9246
- diff.changed.push(path);
9247
- }
9248
- }
9249
- diff.extra = [...existing].filter((path) => !files.has(path)).sort();
9250
- return diff;
9251
- }
9252
- function writeOutput(ctx, dir, files) {
9253
- const diff = diffOutput(ctx, dir, files);
9254
- for (const path of diff.extra) ctx.fs.remove(join14(dir, path));
9255
- for (const path of [...diff.missing, ...diff.changed]) {
9256
- const file = files.get(path);
9257
- if (file === void 0) continue;
9258
- const full = join14(dir, path);
9259
- ctx.fs.mkdir(join14(full, ".."));
9260
- if (ctx.fs.lexists(full) && !ctx.fs.isRegularFile(full)) ctx.fs.remove(full);
9261
- writeFileAtomic(ctx, full, file.content, file.mode);
9262
- }
9263
- removeEmptyParents(ctx, dir, diff.extra);
9264
- return diff;
9265
- }
9266
- function removeEmptyParents(ctx, dir, paths) {
9267
- const parents = /* @__PURE__ */ new Set();
9268
- for (const path of paths) {
9269
- for (let parent = dirname2(path); parent !== "."; parent = dirname2(parent)) parents.add(parent);
9270
- }
9271
- const deepestFirst = [...parents].sort((a, b) => b.split("/").length - a.split("/").length);
9272
- for (const parent of deepestFirst) {
9273
- const full = join14(dir, parent);
9274
- if (ctx.fs.isDirectory(full) && ctx.fs.readDir(full).length === 0) ctx.fs.removeDir(full);
9275
- }
9276
- }
9277
-
9278
9350
  // src/generate/rule.ts
9279
- import { dirname as dirname3, join as join15, posix as posix4 } from "node:path";
9351
+ import { dirname as dirname2, join as join14, posix as posix3 } from "node:path";
9280
9352
 
9281
9353
  // templates/rules/dld-workflow.md
9282
- 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) before rebasing\n";
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";
9283
9355
 
9284
9356
  // src/generate/rule.ts
9285
9357
  var RULE_TEXT = dld_workflow_default;
9286
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
+ }
9287
9372
  var CLAUDE_RULE_FILE = ".claude/rules/dld-workflow.md";
9288
9373
  var AGENTS_RULE_FILE = ".agents/rules/dld-workflow.md";
9289
9374
  var CLAUDE_IMPORT_FILE = ".claude/CLAUDE.md";
@@ -9318,7 +9403,18 @@ function renderImportFile(version2) {
9318
9403
  }
9319
9404
  var IMPORT_STAMP = /^<!-- Generated by dld-kit (\S+) for Claude Code:/;
9320
9405
  function importFileVersion(content) {
9321
- return IMPORT_STAMP.exec(content)?.[1];
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
+ ];
9322
9418
  }
9323
9419
  function renderBlock(text, version2) {
9324
9420
  const body = text.endsWith("\n") ? text.slice(0, -1) : text;
@@ -9359,10 +9455,10 @@ function upsertBlock(content, block, file) {
9359
9455
  }
9360
9456
  function planRule(ctx, root, channels, version2, { harnesses = [] } = {}, text = RULE_TEXT) {
9361
9457
  const plan = { writes: [], removals: [], warnings: [] };
9362
- const regular = (file) => ctx.fs.isRegularFile(join15(root, file));
9363
- const read = (file) => regular(file) ? ctx.fs.readFile(join15(root, file)) : "";
9458
+ const regular = (file) => ctx.fs.isRegularFile(join14(root, file));
9459
+ const read = (file) => regular(file) ? ctx.fs.readFile(join14(root, file)) : "";
9364
9460
  const resolved = (file) => {
9365
- const full = join15(root, file);
9461
+ const full = join14(root, file);
9366
9462
  return ctx.fs.exists(full) ? ctx.fs.realPath(full) : full;
9367
9463
  };
9368
9464
  let blockFiles = [AGENTS_MD, CLAUDE_MD].filter(
@@ -9381,9 +9477,9 @@ function planRule(ctx, root, channels, version2, { harnesses = [] } = {}, text =
9381
9477
  pending.set(file, updated);
9382
9478
  if (updated !== content) plan.writes.push({ path: file, content: updated });
9383
9479
  }
9384
- const linkedDir = symlinkedPart(ctx, root, dirname3(CLAUDE_IMPORT_FILE));
9480
+ const linkedDir = symlinkedPart(ctx, root, dirname2(CLAUDE_IMPORT_FILE));
9385
9481
  const ownsImport = linkedDir === void 0 && importFileVersion(read(CLAUDE_IMPORT_FILE)) !== void 0;
9386
- const wantsImport = channels.has("claude-file") && blockFiles.includes(AGENTS_MD) && !SHARED_CLAUDE_FILES.some((file) => ctx.fs.lexists(join15(root, file)));
9482
+ const wantsImport = channels.has("claude-file") && blockFiles.includes(AGENTS_MD) && !SHARED_CLAUDE_FILES.some((file) => ctx.fs.lexists(join14(root, file)));
9387
9483
  if (ownsImport || wantsImport && linkedDir === void 0) {
9388
9484
  const content = renderImportFile(version2);
9389
9485
  pending.set(CLAUDE_IMPORT_FILE, content);
@@ -9420,22 +9516,24 @@ function planRule(ctx, root, channels, version2, { harnesses = [] } = {}, text =
9420
9516
  for (const channel of ["claude-file", "agents-file"]) {
9421
9517
  const path = RULE_FILES[channel];
9422
9518
  if (loadsBlock(channel)) {
9423
- if (ctx.fs.lexists(join15(root, path))) plan.removals.push(path);
9519
+ if (ctx.fs.lexists(join14(root, path))) plan.removals.push(path);
9424
9520
  } else if (channels.has(channel)) {
9425
- refuseSymlinkedDir(ctx, root, dirname3(path));
9521
+ refuseSymlinkedDir(ctx, root, dirname2(path));
9426
9522
  const content = renderRuleFile(channel, text, version2);
9427
9523
  if (read(path) !== content) plan.writes.push({ path, content });
9428
9524
  }
9429
9525
  }
9430
9526
  const ruleInstalled = blockFiles.length > 0 || channels.size > 0;
9431
9527
  plan.warnings.push(...legacyBlockWarnings(read(CLAUDE_MD), ruleInstalled));
9528
+ if (linkedDir === void 0)
9529
+ plan.warnings.push(...editedImportWarnings(read(CLAUDE_IMPORT_FILE)));
9432
9530
  return plan;
9433
9531
  }
9434
9532
  function projectView(ctx, root) {
9435
- const exists = (file) => ctx.fs.exists(join15(root, file));
9533
+ const exists = (file) => ctx.fs.exists(join14(root, file));
9436
9534
  return {
9437
9535
  exists,
9438
- read: (file) => exists(file) && !ctx.fs.isDirectory(join15(root, file)) ? ctx.fs.readFile(join15(root, file)) : ""
9536
+ read: (file) => exists(file) && !ctx.fs.isDirectory(join14(root, file)) ? ctx.fs.readFile(join14(root, file)) : ""
9439
9537
  };
9440
9538
  }
9441
9539
  function instructionFiles(harness, view) {
@@ -9451,7 +9549,7 @@ function instructionFiles(harness, view) {
9451
9549
  }
9452
9550
  var FENCE = /^ {0,3}(`{3,}|~{3,})/;
9453
9551
  function importsAgentsMd(content, file) {
9454
- const dir = posix4.dirname(file);
9552
+ const dir = posix3.dirname(file);
9455
9553
  let fence;
9456
9554
  for (const line of content.split(/\r?\n/)) {
9457
9555
  const marker = FENCE.exec(line)?.[1];
@@ -9464,7 +9562,7 @@ function importsAgentsMd(content, file) {
9464
9562
  const prose = line.replace(/`[^`]*`/g, "");
9465
9563
  for (const match of prose.matchAll(/(?:^|\s)@(\S+)/g)) {
9466
9564
  const target = match[1];
9467
- if (target !== void 0 && posix4.normalize(posix4.join(dir, target)) === AGENTS_MD) {
9565
+ if (target !== void 0 && posix3.normalize(posix3.join(dir, target)) === AGENTS_MD) {
9468
9566
  return true;
9469
9567
  }
9470
9568
  }
@@ -9484,9 +9582,9 @@ function blindWarnings(blockFiles, blind, reads) {
9484
9582
  });
9485
9583
  }
9486
9584
  function blockPlacement(ctx, root, targets) {
9487
- if (ctx.fs.isRegularFile(join15(root, AGENTS_MD))) return AGENTS_MD;
9488
- const agentsLinked = ctx.fs.lexists(join15(root, AGENTS_MD));
9489
- if (ctx.fs.isRegularFile(join15(root, CLAUDE_MD)) && (agentsLinked || targets.every((h) => h.instructions.includes(CLAUDE_MD)))) {
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)))) {
9490
9588
  return CLAUDE_MD;
9491
9589
  }
9492
9590
  if (agentsLinked) {
@@ -9497,8 +9595,8 @@ function blockPlacement(ctx, root, targets) {
9497
9595
  return AGENTS_MD;
9498
9596
  }
9499
9597
  function switchWarnings(ctx, root, file, targets) {
9500
- if (file !== AGENTS_MD || ctx.fs.lexists(join15(root, AGENTS_MD))) return [];
9501
- const claudeMd = join15(root, CLAUDE_MD);
9598
+ if (file !== AGENTS_MD || ctx.fs.lexists(join14(root, AGENTS_MD))) return [];
9599
+ const claudeMd = join14(root, CLAUDE_MD);
9502
9600
  if (!ctx.fs.exists(claudeMd) || ctx.fs.isDirectory(claudeMd)) return [];
9503
9601
  const switching = targets.filter((h) => !h.readsAll && h.instructions.includes(CLAUDE_MD));
9504
9602
  if (switching.length === 0) return [];
@@ -9518,7 +9616,7 @@ function legacyBlockWarnings(claudeMd, ruleInstalled) {
9518
9616
  ];
9519
9617
  }
9520
9618
  function loadsRule(ctx, root, harness) {
9521
- if (harness.rule !== "block" && ctx.fs.exists(join15(root, RULE_FILES[harness.rule]))) {
9619
+ if (harness.rule !== "block" && ctx.fs.exists(join14(root, RULE_FILES[harness.rule]))) {
9522
9620
  return true;
9523
9621
  }
9524
9622
  const view = projectView(ctx, root);
@@ -9526,17 +9624,17 @@ function loadsRule(ctx, root, harness) {
9526
9624
  (file) => view.read(file).split(/\r?\n/).some((line) => line === BLOCK_START || LEGACY_HEADING.test(line))
9527
9625
  );
9528
9626
  }
9529
- function sessionContext(ctx, root, harness, text = RULE_TEXT) {
9530
- if (!ctx.fs.exists(join15(root, CONFIG_FILE))) return void 0;
9531
- return loadsRule(ctx, root, harness) ? void 0 : text;
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);
9532
9630
  }
9533
9631
  function symlinkedPart(ctx, root, dir) {
9534
9632
  let current = "";
9535
9633
  for (const part of dir.split("/")) {
9536
9634
  current = current === "" ? part : `${current}/${part}`;
9537
- const full = join15(root, current);
9635
+ const full = join14(root, current);
9538
9636
  if (!ctx.fs.lexists(full)) return void 0;
9539
- if (!ctx.fs.exists(full) || ctx.fs.realPath(full) !== join15(ctx.fs.realPath(root), current)) {
9637
+ if (!ctx.fs.exists(full) || ctx.fs.realPath(full) !== join14(ctx.fs.realPath(root), current)) {
9540
9638
  return current;
9541
9639
  }
9542
9640
  }
@@ -9550,58 +9648,671 @@ function refuseSymlinkedDir(ctx, root, dir) {
9550
9648
  );
9551
9649
  }
9552
9650
  }
9553
- function installedRuleChannels(ctx, root) {
9554
- const channels = /* @__PURE__ */ new Set();
9555
- for (const channel of ["claude-file", "agents-file"]) {
9556
- if (ctx.fs.lexists(join15(root, RULE_FILES[channel]))) channels.add(channel);
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))) });
9672
+ }
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
+ }
9681
+ }
9682
+ return stamps;
9683
+ }
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
+ }
9698
+ }
9699
+
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(", ")}`);
9710
+ }
9711
+ harnesses.add(harness);
9712
+ }
9713
+ return HARNESSES.filter((harness) => harnesses.has(harness));
9714
+ }
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
+ }
9755
+ }
9756
+ }
9757
+ function cliPath(io) {
9758
+ if (io.cliPath === void 0) throw new DldError("cannot locate the running dld file");
9759
+ return io.cliPath;
9760
+ }
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
+ `);
9767
+ }
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
+ );
9774
+ }
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);
9881
+ }
9882
+ return [...files].sort();
9883
+ }
9884
+ function escapeRegExp(text) {
9885
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
9886
+ }
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;
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;
9901
+ }
9902
+ return text.slice(0, BINARY_SNIFF_BYTES).includes("\0") ? void 0 : text;
9903
+ }
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
+ });
9926
+ }
9927
+ return found;
9928
+ }
9929
+ function formatNearMiss({ file, line, text }) {
9930
+ return `${file}:${line}: not an annotation: ${text}`;
9931
+ }
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));
9937
+ }
9938
+ function formatSkipped({ file, reason }) {
9939
+ return `Warning: skipped ${file} (${reason}); annotations in it are not counted`;
9940
+ }
9941
+ function formatAnnotation({ file, line, id }) {
9942
+ return `${file}:${line}:${id}`;
9943
+ }
9944
+
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;
9966
+ }
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;
9991
+ }
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
+ ]);
10009
+ }
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)
10016
+ );
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 });
10027
+ }
10028
+ return found;
10029
+ }
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 };
10035
+ }
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;
10056
+ }
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
+ );
10074
+ }
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
+ );
10116
+ }
10117
+ }
10118
+ return newPath;
10119
+ }
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$/, "")) }];
10132
+ });
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
+ }
10141
+ }
10142
+ return found;
10143
+ }
10144
+ function formatStaleMention({ file, line, oldId, newId, text }) {
10145
+ return `${file} ${line} ${oldId} ${newId} ${text}`;
10146
+ }
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;
10171
+ }
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);
10196
+ }
10197
+ }
10198
+ return found;
10199
+ }
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;
10207
+ }
10208
+ }
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}`);
9557
10233
  }
9558
- if (importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE)) !== void 0) {
9559
- 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);
9560
10237
  }
9561
- for (const file of [AGENTS_MD, CLAUDE_MD]) {
9562
- const path = join15(root, file);
9563
- if (ctx.fs.isRegularFile(path) && findBlock(ctx.fs.readFile(path), file) !== void 0) {
9564
- 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
+ });
9565
10261
  }
9566
10262
  }
9567
- 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));
9568
10265
  }
9569
- function installedRuleStamps(ctx, root) {
9570
- const stamps = [];
9571
- const regular = (file) => ctx.fs.isRegularFile(join15(root, file));
9572
- for (const file of [CLAUDE_RULE_FILE, AGENTS_RULE_FILE].filter(regular)) {
9573
- stamps.push({ path: file, version: ruleVersion(ctx.fs.readFile(join15(root, file))) });
9574
- }
9575
- const imported = importFileVersion(projectView(ctx, root).read(CLAUDE_IMPORT_FILE));
9576
- if (imported !== void 0) stamps.push({ path: CLAUDE_IMPORT_FILE, version: imported });
9577
- for (const file of [AGENTS_MD, CLAUDE_MD].filter(regular)) {
9578
- const content = ctx.fs.readFile(join15(root, file));
9579
- const span = findBlock(content, file);
9580
- if (span !== void 0) {
9581
- 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);
9582
10282
  }
9583
10283
  }
9584
- return stamps;
10284
+ diff.extra = [...existing].filter((path) => !files.has(path)).sort();
10285
+ return diff;
9585
10286
  }
9586
- function applyRulePlan(ctx, root, plan) {
9587
- for (const { path, content } of plan.writes) {
9588
- const full = join15(root, path);
9589
- 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, ".."));
9590
10295
  if (ctx.fs.lexists(full) && !ctx.fs.isRegularFile(full)) ctx.fs.remove(full);
9591
- writeFileAtomic(ctx, full, content);
10296
+ writeFileAtomic(ctx, full, file.content, file.mode);
9592
10297
  }
9593
- for (const path of plan.removals) {
9594
- const full = join15(root, path);
9595
- ctx.fs.remove(full);
9596
- if (ctx.fs.isDirectory(dirname3(full)) && ctx.fs.readDir(dirname3(full)).length === 0) {
9597
- ctx.fs.removeDir(dirname3(full));
9598
- }
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);
9599
10310
  }
9600
10311
  }
9601
10312
 
9602
10313
  // src/generate/install.ts
9603
10314
  function packageSource(ctx, cliPath2, command) {
9604
- const templatesDir = join16(dirname4(cliPath2), "..", "templates", "skills");
10315
+ const templatesDir = join18(dirname4(cliPath2), "..", "templates", "skills");
9605
10316
  if (!ctx.fs.isDirectory(templatesDir)) {
9606
10317
  throw new DldError(
9607
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}`
@@ -9609,16 +10320,51 @@ function packageSource(ctx, cliPath2, command) {
9609
10320
  }
9610
10321
  return { templatesDir, cli: ctx.fs.readFile(cliPath2) };
9611
10322
  }
9612
- var isOwned = (name) => name.startsWith("dld-");
9613
- function ownedSkills(ctx, root, layout) {
9614
- 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);
9615
10341
  if (!ctx.fs.isDirectory(dir)) return [];
9616
- 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();
9617
10363
  }
9618
10364
  function refuseSymlinkedSkills(ctx, root, layout) {
9619
- const dir = join16(root, layout.dir);
10365
+ const dir = join18(root, layout.dir);
9620
10366
  if (!ctx.fs.isDirectory(dir)) return;
9621
- 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();
9622
10368
  if (linked.length === 0) return;
9623
10369
  throw new DldError(
9624
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.`
@@ -9626,7 +10372,7 @@ function refuseSymlinkedSkills(ctx, root, layout) {
9626
10372
  }
9627
10373
  var SKILLS_LOCK = "skills-lock.json";
9628
10374
  function skillsLockWarning(ctx, root) {
9629
- const path = join16(root, SKILLS_LOCK);
10375
+ const path = join18(root, SKILLS_LOCK);
9630
10376
  if (!ctx.fs.isRegularFile(path)) return [];
9631
10377
  let skills;
9632
10378
  try {
@@ -9635,7 +10381,7 @@ function skillsLockWarning(ctx, root) {
9635
10381
  return [];
9636
10382
  }
9637
10383
  if (typeof skills !== "object" || skills === null) return [];
9638
- const listed = Object.keys(skills).filter(isOwned).sort();
10384
+ const listed = Object.keys(skills).filter(isShipped).sort();
9639
10385
  if (listed.length === 0) return [];
9640
10386
  return [
9641
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.`
@@ -9662,8 +10408,8 @@ function installedStamps(ctx, root) {
9662
10408
  for (const layout of LAYOUTS) {
9663
10409
  for (const skill of ownedSkills(ctx, root, layout)) {
9664
10410
  const path = `${layout.dir}/${skill}/SKILL.md`;
9665
- if (!ctx.fs.isRegularFile(join16(root, path))) continue;
9666
- 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] });
9667
10413
  }
9668
10414
  }
9669
10415
  return [...stamps, ...installedRuleStamps(ctx, root)];
@@ -9722,23 +10468,40 @@ function planInstall(ctx, root, request) {
9722
10468
  const files = generateSkills(ctx, source.templatesDir, layout.adapter, request.version, {
9723
10469
  extraFiles: cli
9724
10470
  });
9725
- return { layout, files };
9726
- });
9727
- const rule = planRule(ctx, root, request.rules, request.version, {
9728
- harnesses: request.harnesses
10471
+ const owned = dldKitSkills(ctx, root, layout);
10472
+ const owns = (skill) => owned.has(skill);
10473
+ return { layout, files, owns };
9729
10474
  });
10475
+ const rule = planRule(
10476
+ ctx,
10477
+ root,
10478
+ request.rules,
10479
+ request.version,
10480
+ { harnesses: request.harnesses },
10481
+ request.ruleText
10482
+ );
9730
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
+ }
9731
10493
  return { skills, rule, warnings };
9732
10494
  }
9733
10495
  function applyInstall(ctx, root, plan) {
9734
- const skills = plan.skills.map(({ layout, files }) => {
9735
- 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);
9736
10498
  const written = diff.changed.length + diff.missing.length;
9737
10499
  return {
9738
10500
  dir: layout.dir,
9739
10501
  written,
9740
10502
  removed: diff.extra.length,
9741
- unchanged: files.size - written
10503
+ unchanged: files.size - written,
10504
+ removedPaths: diff.extra.map((path) => `${layout.dir}/${path}`)
9742
10505
  };
9743
10506
  });
9744
10507
  applyRulePlan(ctx, root, plan.rule);
@@ -9750,85 +10513,6 @@ function applyInstall(ctx, root, plan) {
9750
10513
  };
9751
10514
  }
9752
10515
 
9753
- // src/cli/install.ts
9754
- var AGENT_HELP = `Agents: ${HARNESS_NAMES.join(", ")}. --agent takes a comma-separated list and can be repeated.`;
9755
- function parseAgents(values) {
9756
- const names = (values ?? []).flatMap((value) => value.split(",")).map((name) => name.trim());
9757
- const harnesses = /* @__PURE__ */ new Set();
9758
- for (const name of names) {
9759
- if (name === "") continue;
9760
- const harness = findHarness(name);
9761
- if (harness === void 0) {
9762
- throw new UsageError(`unknown agent '${name}'; expected one of: ${HARNESS_NAMES.join(", ")}`);
9763
- }
9764
- harnesses.add(harness);
9765
- }
9766
- return HARNESSES.filter((harness) => harnesses.has(harness));
9767
- }
9768
- async function selectHarnesses(io, detected, requested, yes) {
9769
- const selected = /* @__PURE__ */ new Set([...detected.map((d) => d.harness), ...requested]);
9770
- const ordered = () => HARNESSES.filter((harness) => selected.has(harness));
9771
- if (io.prompt === void 0 || yes) {
9772
- if (selected.size === 0) {
9773
- throw new UsageError(
9774
- `no agent detected in this project; name them with --agent (${HARNESS_NAMES.join(", ")})`
9775
- );
9776
- }
9777
- return ordered();
9778
- }
9779
- const found = new Map(detected.map((d) => [d.harness, d.marker]));
9780
- const width = Math.max(...HARNESS_NAMES.map((name) => name.length));
9781
- for (; ; ) {
9782
- const lines = HARNESSES.map((harness, i) => {
9783
- const box = selected.has(harness) ? "[x]" : "[ ]";
9784
- const marker = found.get(harness);
9785
- const note = marker === void 0 ? "" : ` (found ${marker})`;
9786
- return ` ${i + 1}. ${box} ${harness.name.padEnd(width)} ${harness.title}${note}
9787
- `;
9788
- });
9789
- io.stdout(`Install DLD for these agents:
9790
- ${lines.join("")}`);
9791
- const answer = (await io.prompt("Numbers to toggle, or Enter to continue: ")).trim();
9792
- if (answer === "") {
9793
- if (selected.size > 0) return ordered();
9794
- io.stdout("Select at least one agent.\n");
9795
- continue;
9796
- }
9797
- const picks = answer.split(/[\s,]+/).map((pick) => HARNESSES[Number(pick) - 1]);
9798
- if (picks.some((harness) => harness === void 0)) {
9799
- io.stdout(`Enter numbers from 1 to ${HARNESSES.length}.
9800
- `);
9801
- continue;
9802
- }
9803
- for (const harness of picks) {
9804
- if (harness === void 0) continue;
9805
- if (selected.has(harness)) selected.delete(harness);
9806
- else selected.add(harness);
9807
- }
9808
- }
9809
- }
9810
- function cliPath(io) {
9811
- if (io.cliPath === void 0) throw new DldError("cannot locate the running dld file");
9812
- return io.cliPath;
9813
- }
9814
- function printReport(io, report2) {
9815
- for (const { dir, written, removed, unchanged } of report2.skills) {
9816
- io.stdout(`${dir}: ${written} written, ${removed} removed, ${unchanged} unchanged
9817
- `);
9818
- }
9819
- for (const path of report2.ruleWritten) {
9820
- io.stdout(
9821
- path === CLAUDE_IMPORT_FILE ? `Wrote ${path} (imports AGENTS.md for Claude Code)
9822
- ` : `Wrote the DLD rule to ${path}
9823
- `
9824
- );
9825
- }
9826
- for (const path of report2.ruleRemoved) io.stdout(`Removed ${path} (the rule is in the block)
9827
- `);
9828
- for (const warning of report2.warnings) io.stderr(`Warning: ${warning}
9829
- `);
9830
- }
9831
-
9832
10516
  // src/cli/commands/init.ts
9833
10517
  var initCommand = {
9834
10518
  name: "init",
@@ -9865,7 +10549,7 @@ ${AGENT_HELP}
9865
10549
  throw new UsageError("--namespaces needs at least one namespace");
9866
10550
  }
9867
10551
  const root = findProjectRoot(ctx);
9868
- if (ctx.fs.lexists(join17(root, CONFIG_FILE))) {
10552
+ if (ctx.fs.lexists(join19(root, CONFIG_FILE))) {
9869
10553
  throw new DldError(
9870
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.`
9871
10555
  );
@@ -9891,10 +10575,11 @@ ${AGENT_HELP}
9891
10575
  createDirectories(ctx, project);
9892
10576
  writeIndex(ctx, project.paths, renderIndex([], project.config.mode));
9893
10577
  const report2 = applyInstall(ctx, root, plan);
9894
- const index = relative8(root, indexPath(project.paths));
10578
+ const index = relative11(root, indexPath(project.paths));
9895
10579
  io.stdout(`Created ${CONFIG_FILE} and ${index}
9896
10580
  `);
9897
10581
  printReport(io, report2);
10582
+ ensureIndexMergeAttribute(io, ctx, project);
9898
10583
  io.stdout(
9899
10584
  `
9900
10585
  DLD is set up for: ${harnesses.map((harness) => harness.name).join(", ")}. Commit these files so everyone gets the same skills and rule.
@@ -9936,7 +10621,9 @@ ${AGENT_HELP}
9936
10621
  rules,
9937
10622
  version,
9938
10623
  force: values.force === true,
9939
- harnesses: requested
10624
+ harnesses: requested,
10625
+ // @decision(DL-074)
10626
+ ruleText: projectRuleText(ctx, root)
9940
10627
  });
9941
10628
  const report2 = applyInstall(ctx, root, plan);
9942
10629
  printReport(io, report2);
@@ -9947,6 +10634,50 @@ ${AGENT_HELP}
9947
10634
  }
9948
10635
  };
9949
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
+
9950
10681
  // src/cli/commands/list-taken-ids.ts
9951
10682
  var listTakenIdsCommand = {
9952
10683
  name: "list-taken-ids",
@@ -9958,12 +10689,13 @@ Print the decision IDs on the base branch and in records touched by open pull re
9958
10689
  (via gh, when available), one per line, sorted.
9959
10690
 
9960
10691
  Options:
9961
- --base <ref> Base ref (default: origin/main)
10692
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
9962
10693
  `,
9963
10694
  run(args, io, ctx) {
9964
10695
  const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
9965
- const base = baseOption(values.base);
9966
- 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);
9967
10699
  for (const id of ids) io.stdout(`${id}
9968
10700
  `);
9969
10701
  if (skipped !== void 0) io.stderr(skippedNotice(skipped));
@@ -9997,12 +10729,13 @@ Print <path>\\t<DL-OLD>\\t<DL-NEW> for each colliding local decision, assigning
9997
10729
  IDs. Prints nothing when there are no collisions.
9998
10730
 
9999
10731
  Options:
10000
- --base <ref> Base ref (default: origin/main)
10732
+ --base <ref> Base ref (${DEFAULT_BASE_HELP})
10001
10733
  `,
10002
10734
  run(args, io, ctx) {
10003
10735
  const { values } = parseCommandArgs({ args: [...args], options: { base: { type: "string" } } });
10004
- const base = baseOption(values.base);
10005
- 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);
10006
10739
  for (const rename of renames) io.stdout(`${formatRename(rename)}
10007
10740
  `);
10008
10741
  if (skipped !== void 0) io.stderr(skippedNotice(skipped));
@@ -10011,6 +10744,7 @@ Options:
10011
10744
  };
10012
10745
 
10013
10746
  // src/cli/commands/regenerate-index.ts
10747
+ import { basename as basename9, relative as relative13 } from "node:path";
10014
10748
  var regenerateIndexCommand = {
10015
10749
  name: "regenerate-index",
10016
10750
  summary: "Rebuild INDEX.md from the decision records",
@@ -10031,6 +10765,11 @@ Options:
10031
10765
  if (!ctx.fs.isDirectory(paths.recordsDir)) {
10032
10766
  throw new DldError(`records directory not found at ${paths.recordsDir}`);
10033
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
+ }
10034
10773
  const rows = collectIndexRows(ctx, paths, values["include-base"]);
10035
10774
  writeIndex(ctx, paths, renderIndex(rows, config.mode));
10036
10775
  io.stdout(rows.length === 0 ? "INDEX.md regenerated (empty).\n" : "INDEX.md regenerated.\n");
@@ -10038,6 +10777,76 @@ Options:
10038
10777
  }
10039
10778
  };
10040
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
+
10041
10850
  // src/cli/commands/rename-decision.ts
10042
10851
  var renameDecisionCommand = {
10043
10852
  name: "rename-decision",
@@ -10052,7 +10861,7 @@ Options:
10052
10861
  --old <DL-OLD> Current ID (required)
10053
10862
  --new <DL-NEW> New ID (required)
10054
10863
  --path <path> Record path relative to the project root (required)
10055
- --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})
10056
10865
  `,
10057
10866
  run(args, io, ctx) {
10058
10867
  const { values } = parseCommandArgs({
@@ -10066,8 +10875,9 @@ Options:
10066
10875
  });
10067
10876
  const { old: oldId, new: newId, path } = values;
10068
10877
  if (!oldId || !newId || !path) throw new DldError("--old, --new, and --path are required.");
10069
- const base = baseOption(values.base);
10070
- 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);
10071
10881
  io.stdout(`${path} -> ${newPath}
10072
10882
  `);
10073
10883
  return EXIT_OK;
@@ -10075,19 +10885,63 @@ Options:
10075
10885
  };
10076
10886
 
10077
10887
  // src/cli/commands/resolve-base.ts
10888
+ import { join as join20 } from "node:path";
10078
10889
  var resolveBaseCommand = {
10079
10890
  name: "resolve-base",
10080
10891
  summary: "Print the base ref to check decision IDs against",
10081
10892
  internal: true,
10082
10893
  usage: `Usage: dld resolve-base
10083
10894
 
10084
- Print the current branch's upstream when it tracks a differently named branch, otherwise
10085
- 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.
10086
10898
  `,
10087
10899
  run(args, io, ctx) {
10088
10900
  parseCommandArgs({ args: [...args], options: {} });
10089
- 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}
10090
10943
  `);
10944
+ }
10091
10945
  return EXIT_OK;
10092
10946
  }
10093
10947
  };
@@ -10136,7 +10990,7 @@ Agents: ${HARNESS_NAMES.join(", ")}.
10136
10990
  };
10137
10991
 
10138
10992
  // src/cli/commands/update.ts
10139
- import { join as join18 } from "node:path";
10993
+ import { join as join21 } from "node:path";
10140
10994
  var updateCommand = {
10141
10995
  name: "update",
10142
10996
  summary: "Refresh the installed DLD skills and rule to this version",
@@ -10159,9 +11013,10 @@ ${AGENT_HELP}
10159
11013
  });
10160
11014
  const requested = parseAgents(values.agent);
10161
11015
  const root = findProjectRoot(ctx);
10162
- if (!ctx.fs.lexists(join18(root, CONFIG_FILE))) {
11016
+ if (!ctx.fs.lexists(join21(root, CONFIG_FILE))) {
10163
11017
  throw new DldError(`DLD is not set up here (${CONFIG_FILE} not found). Run dld init first.`);
10164
11018
  }
11019
+ const project = loadProject(ctx);
10165
11020
  const installed = installedTargets(ctx, root);
10166
11021
  const added = targetsFor(requested);
10167
11022
  const layouts = /* @__PURE__ */ new Set([...installed.layouts, ...added.layouts]);
@@ -10177,11 +11032,14 @@ ${AGENT_HELP}
10177
11032
  rules,
10178
11033
  version,
10179
11034
  force: values.force === true,
10180
- harnesses: requested
11035
+ harnesses: requested,
11036
+ // @decision(DL-074)
11037
+ ruleText: ruleTextFor(project.config.annotationPrefix)
10181
11038
  });
10182
11039
  const report2 = applyInstall(ctx, root, plan);
10183
11040
  report2.warnings.push(...missingAgentsRuleWarning({ layouts, rules }));
10184
11041
  printReport(io, report2);
11042
+ ensureIndexMergeAttribute(io, ctx, project);
10185
11043
  return EXIT_OK;
10186
11044
  }
10187
11045
  };
@@ -10194,8 +11052,8 @@ var updateAuditStateCommand = {
10194
11052
  usage: "Usage: dld update-audit-state\n\nRecord the current time and HEAD commit as the last audit.\n",
10195
11053
  run(args, io, ctx) {
10196
11054
  parseCommandArgs({ args: [...args], options: {} });
10197
- const { timestamp, commit } = updateAuditState(ctx, loadProject(ctx));
10198
- io.stdout(`Audit state updated: ${timestamp} at ${commit}
11055
+ const { timestamp, commit: commit2 } = updateAuditState(ctx, loadProject(ctx));
11056
+ io.stdout(`Audit state updated: ${timestamp} at ${commit2}
10199
11057
  `);
10200
11058
  return EXIT_OK;
10201
11059
  }
@@ -10217,8 +11075,8 @@ SNAPSHOT.md, OVERVIEW.md and any custom artifacts named.
10217
11075
  options: {},
10218
11076
  allowPositionals: true
10219
11077
  });
10220
- const { timestamp, commit, highest } = updateSnapshotState(ctx, loadProject(ctx), positionals);
10221
- io.stdout(`Snapshot state updated: ${timestamp} at ${commit} (through ${formatId(highest)})
11078
+ const { timestamp, commit: commit2, highest } = updateSnapshotState(ctx, loadProject(ctx), positionals);
11079
+ io.stdout(`Snapshot state updated: ${timestamp} at ${commit2} (through ${formatId(highest)})
10222
11080
  `);
10223
11081
  return EXIT_OK;
10224
11082
  }
@@ -10271,7 +11129,15 @@ var verifyAnnotationsCommand = {
10271
11129
  if (ids.length === 0) throw new UsageError("expected at least one decision ID");
10272
11130
  const options = scanOptionsFor(loadProject(ctx));
10273
11131
  const prefix = options.prefix;
10274
- 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
+ );
10275
11141
  if (missing.length > 0) {
10276
11142
  io.stdout(`MISSING annotations in source code for: ${missing.join(" ")}
10277
11143
  `);
@@ -10292,14 +11158,18 @@ var COMMANDS = [
10292
11158
  updateCommand,
10293
11159
  installRuleCommand,
10294
11160
  sessionContextCommand,
11161
+ checkDecisionEditsCommand,
10295
11162
  createConfigCommand,
10296
11163
  createDirectoriesCommand,
10297
11164
  createEmptyIndexCommand,
10298
11165
  nextIdCommand,
10299
11166
  createDecisionCommand,
10300
11167
  updateStatusCommand,
11168
+ listProposedCommand,
11169
+ relatedDecisionsCommand,
10301
11170
  regenerateIndexCommand,
10302
11171
  verifyAnnotationsCommand,
11172
+ restoreDecisionProseCommand,
10303
11173
  findAnnotationsCommand,
10304
11174
  findMissingAmendsCommand,
10305
11175
  updateAuditStateCommand,
@@ -10320,7 +11190,7 @@ function usage(commands) {
10320
11190
  const list = (group) => group.map((c) => ` ${c.name.padEnd(25)} ${c.summary}`).join("\n");
10321
11191
  const setup = commands.filter((c) => !c.internal);
10322
11192
  const internal = commands.filter((c) => c.internal);
10323
- const sections = [`Setup commands:
11193
+ const sections = [`Setup and CI commands:
10324
11194
  ${list(setup)}`];
10325
11195
  if (internal.length > 0) sections.push(`${INTERNAL_HEADING}:
10326
11196
  ${list(internal)}`);
@@ -10355,8 +11225,18 @@ function run(argv, io, ctx, commands = COMMANDS) {
10355
11225
  ${usage(commands)}`);
10356
11226
  return EXIT_USAGE;
10357
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
+ };
10358
11238
  try {
10359
- const result = command.run(rest, io, ctx);
11239
+ const result = command.run(rest, io, runCtx);
10360
11240
  if (typeof result === "number") return result;
10361
11241
  return result.catch((error) => report(error, command, io));
10362
11242
  } catch (error) {
@@ -10412,6 +11292,7 @@ var nodeFileSystem = {
10412
11292
  lexists: (path) => fsCall("read", path, () => lstatSync(path, { throwIfNoEntry: false }) !== void 0),
10413
11293
  isRegularFile: (path) => fsCall("read", path, () => lstatSync(path, { throwIfNoEntry: false })?.isFile() ?? false),
10414
11294
  readFile: (path) => fsCall("read", path, () => readFileSync(path, "utf8")),
11295
+ fileSize: (path) => fsCall("read", path, () => lstatSync(path).size),
10415
11296
  readBytes: (path) => fsCall("read", path, () => readFileSync(path)),
10416
11297
  readDir: (path) => fsCall(
10417
11298
  "read",
@@ -10495,7 +11376,12 @@ function createNodeContext(cwd, env) {
10495
11376
  gh: nodeGh(cwd, env),
10496
11377
  env,
10497
11378
  readStdin: () => fsCall("read", "standard input", () => readFileSync(0, "utf8")),
10498
- now: () => /* @__PURE__ */ new Date()
11379
+ now: () => /* @__PURE__ */ new Date(),
11380
+ // @decision(DL-079)
11381
+ warn: (message) => {
11382
+ process.stderr.write(`Warning: ${message}
11383
+ `);
11384
+ }
10499
11385
  };
10500
11386
  }
10501
11387
  function hasCode(error, code) {