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/README.md +111 -346
- package/dist/dld.mjs +2271 -1385
- package/package.json +1 -1
- package/skills/dld-adjust/SKILL.md +41 -24
- package/skills/dld-audit/SKILL.md +11 -4
- package/skills/dld-audit-auto/SKILL.md +20 -5
- package/skills/dld-common/SKILL.md +1 -1
- package/skills/dld-common/scripts/dld.mjs +2271 -1385
- package/skills/dld-decide/SKILL.md +21 -5
- package/skills/dld-implement/SKILL.md +28 -7
- package/skills/dld-init/SKILL.md +2 -2
- package/skills/dld-lookup/SKILL.md +27 -5
- package/skills/dld-plan/SKILL.md +21 -5
- package/skills/dld-reindex/SKILL.md +73 -24
- package/skills/dld-retrofit/SKILL.md +2 -2
- package/skills/dld-snapshot/SKILL.md +49 -18
- package/skills/dld-status/SKILL.md +1 -1
- package/templates/rules/dld-workflow.md +2 -1
- package/templates/skills/dld-adjust/SKILL.md +40 -23
- package/templates/skills/dld-audit/SKILL.md +10 -3
- package/templates/skills/dld-audit-auto/SKILL.md +19 -4
- package/templates/skills/dld-decide/SKILL.md +20 -4
- package/templates/skills/dld-implement/SKILL.md +27 -6
- package/templates/skills/dld-init/SKILL.md +1 -1
- package/templates/skills/dld-lookup/SKILL.md +26 -4
- package/templates/skills/dld-plan/SKILL.md +20 -4
- package/templates/skills/dld-reindex/SKILL.md +72 -23
- package/templates/skills/dld-retrofit/SKILL.md +1 -1
- package/templates/skills/dld-snapshot/SKILL.md +48 -17
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:
|
|
4003
|
+
const { start, key, sep: sep9, value } = collItem;
|
|
4004
4004
|
const keyProps = resolveProps.resolveProps(start, {
|
|
4005
4005
|
indicator: "explicit-key-ind",
|
|
4006
|
-
next: key ??
|
|
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 && !
|
|
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(
|
|
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,
|
|
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
|
|
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 +=
|
|
4166
|
-
|
|
4165
|
+
comment += sep9 + cb;
|
|
4166
|
+
sep9 = "";
|
|
4167
4167
|
break;
|
|
4168
4168
|
}
|
|
4169
4169
|
case "newline":
|
|
4170
4170
|
if (comment)
|
|
4171
|
-
|
|
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:
|
|
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 ??
|
|
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 && !
|
|
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 && !
|
|
4280
|
-
const valueNode = value ? composeNode(ctx, value, props, onError) : composeEmptyNode(ctx, props.end,
|
|
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(
|
|
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 (
|
|
4304
|
-
for (const st of
|
|
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,
|
|
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
|
|
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 +=
|
|
4519
|
-
|
|
4518
|
+
value += sep9 + indent.slice(trimIndent) + content;
|
|
4519
|
+
sep9 = "\n";
|
|
4520
4520
|
} else if (indent.length > trimIndent || content[0] === " ") {
|
|
4521
|
-
if (
|
|
4522
|
-
|
|
4523
|
-
else if (!prevMoreIndented &&
|
|
4524
|
-
|
|
4525
|
-
value +=
|
|
4526
|
-
|
|
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 (
|
|
4529
|
+
if (sep9 === "\n")
|
|
4530
4530
|
value += "\n";
|
|
4531
4531
|
else
|
|
4532
|
-
|
|
4532
|
+
sep9 = "\n";
|
|
4533
4533
|
} else {
|
|
4534
|
-
value +=
|
|
4535
|
-
|
|
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
|
|
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 (
|
|
4724
|
-
res +=
|
|
4723
|
+
if (sep9 === "\n")
|
|
4724
|
+
res += sep9;
|
|
4725
4725
|
else
|
|
4726
|
-
|
|
4726
|
+
sep9 = "\n";
|
|
4727
4727
|
} else {
|
|
4728
|
-
res +=
|
|
4729
|
-
|
|
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 +
|
|
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:
|
|
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 (
|
|
5553
|
-
for (const st of
|
|
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
|
|
6720
|
+
let sep9;
|
|
6721
6721
|
if (scalar.end) {
|
|
6722
|
-
|
|
6723
|
-
|
|
6722
|
+
sep9 = scalar.end;
|
|
6723
|
+
sep9.push(this.sourceToken);
|
|
6724
6724
|
delete scalar.end;
|
|
6725
6725
|
} else
|
|
6726
|
-
|
|
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:
|
|
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
|
|
6885
|
-
|
|
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:
|
|
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
|
|
7087
|
-
|
|
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:
|
|
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.
|
|
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/
|
|
7459
|
-
import { join as
|
|
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/
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
|
|
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
|
|
7531
|
-
const
|
|
7532
|
-
|
|
7533
|
-
|
|
7534
|
-
|
|
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
|
-
|
|
7552
|
-
function findProjectRoot(ctx) {
|
|
7479
|
+
function createFileExclusive(ctx, path, content, existsMessage) {
|
|
7480
|
+
const temp = tempPathFor(path);
|
|
7553
7481
|
try {
|
|
7554
|
-
|
|
7482
|
+
ctx.fs.writeFile(temp, content);
|
|
7483
|
+
ctx.fs.link(temp, path);
|
|
7555
7484
|
} catch (error) {
|
|
7556
|
-
if (error instanceof
|
|
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
|
|
7561
|
-
|
|
7562
|
-
|
|
7563
|
-
}
|
|
7564
|
-
|
|
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
|
|
7603
|
-
import { basename, join as
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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 (!
|
|
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 (!
|
|
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
|
|
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/
|
|
7792
|
-
var
|
|
7793
|
-
|
|
7794
|
-
|
|
7795
|
-
|
|
7796
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
7814
|
-
const temp = tempPathFor(path);
|
|
7769
|
+
const prBase = base.startsWith("origin/") ? base.slice("origin/".length) : base;
|
|
7770
|
+
let output;
|
|
7815
7771
|
try {
|
|
7816
|
-
ctx.
|
|
7817
|
-
|
|
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
|
|
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
|
|
7803
|
+
function parsePrList(output) {
|
|
7804
|
+
let value;
|
|
7826
7805
|
try {
|
|
7827
|
-
|
|
7806
|
+
value = JSON.parse(output);
|
|
7828
7807
|
} catch {
|
|
7808
|
+
return void 0;
|
|
7829
7809
|
}
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
7836
|
-
|
|
7837
|
-
|
|
7838
|
-
|
|
7839
|
-
|
|
7840
|
-
|
|
7841
|
-
|
|
7842
|
-
|
|
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
|
|
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
|
|
7859
|
-
|
|
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
|
|
7863
|
-
|
|
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)
|
|
7865
|
+
if (branch !== "" && branch !== current) candidates.push(upstream);
|
|
8065
7866
|
}
|
|
8066
|
-
|
|
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.
|
|
8099
|
-
|
|
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
|
-
|
|
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
|
|
7952
|
+
const git = gitAt(ctx, paths.root);
|
|
7953
|
+
const added = localAdditions(git, paths, base);
|
|
8107
7954
|
const local = added.flatMap((path) => {
|
|
8108
|
-
const id =
|
|
8109
|
-
return RECORD_FILE.test(
|
|
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((
|
|
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/
|
|
8138
|
-
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
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
|
-
|
|
8152
|
-
|
|
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
|
|
8157
|
-
|
|
8158
|
-
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
|
|
8163
|
-
|
|
8164
|
-
|
|
8165
|
-
|
|
8166
|
-
|
|
8167
|
-
|
|
8168
|
-
|
|
8169
|
-
|
|
8170
|
-
const
|
|
8171
|
-
if (
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
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
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
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
|
-
|
|
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/
|
|
8192
|
-
|
|
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
|
-
|
|
8203
|
-
|
|
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
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
const
|
|
8209
|
-
if (
|
|
8210
|
-
const
|
|
8211
|
-
|
|
8212
|
-
|
|
8213
|
-
|
|
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
|
-
|
|
8223
|
-
|
|
8224
|
-
|
|
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
|
|
8230
|
-
|
|
8231
|
-
|
|
8232
|
-
|
|
8233
|
-
}
|
|
8234
|
-
|
|
8235
|
-
|
|
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
|
-
|
|
8272
|
-
|
|
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
|
|
8276
|
-
|
|
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
|
|
8279
|
-
const
|
|
8280
|
-
if (
|
|
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
|
|
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("
|
|
8285
|
-
return false;
|
|
8314
|
+
return ctx.git(["rev-parse", "--show-toplevel"]).trim();
|
|
8286
8315
|
} catch (error) {
|
|
8287
|
-
if (error instanceof GitCommandError
|
|
8316
|
+
if (error instanceof GitCommandError) throw new DldError("not a git repository");
|
|
8288
8317
|
throw error;
|
|
8289
8318
|
}
|
|
8290
8319
|
}
|
|
8291
|
-
function
|
|
8292
|
-
|
|
8293
|
-
|
|
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
|
|
8312
|
-
|
|
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,
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
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/
|
|
8327
|
-
var
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
|
|
8331
|
-
usage: `Usage: dld
|
|
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
|
-
|
|
8334
|
-
|
|
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>
|
|
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({
|
|
8342
|
-
|
|
8343
|
-
|
|
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
|
|
8346
|
-
|
|
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
|
-
|
|
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/
|
|
8353
|
-
import { join as
|
|
8354
|
-
|
|
8355
|
-
|
|
8356
|
-
|
|
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
|
|
8414
|
+
return doc;
|
|
8359
8415
|
}
|
|
8360
|
-
function
|
|
8361
|
-
const
|
|
8362
|
-
|
|
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
|
|
8368
|
-
|
|
8369
|
-
|
|
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
|
|
8376
|
-
|
|
8377
|
-
|
|
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
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
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/
|
|
8409
|
-
|
|
8410
|
-
|
|
8411
|
-
|
|
8412
|
-
|
|
8413
|
-
|
|
8414
|
-
|
|
8415
|
-
|
|
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
|
|
8418
|
-
if (!
|
|
8419
|
-
throw new DldError(`
|
|
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
|
|
8422
|
-
|
|
8423
|
-
|
|
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
|
|
8426
|
-
|
|
8427
|
-
const
|
|
8428
|
-
const
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
|
|
8435
|
-
|
|
8436
|
-
|
|
8437
|
-
}
|
|
8438
|
-
|
|
8439
|
-
|
|
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
|
|
8442
|
-
|
|
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/
|
|
8446
|
-
var
|
|
8447
|
-
name: "
|
|
8448
|
-
summary: "
|
|
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
|
|
8521
|
+
usage: `Usage: dld collect-active-decisions [--include-proposed]
|
|
8451
8522
|
|
|
8452
|
-
|
|
8523
|
+
Print each accepted record in ID order, separated by ===DLD_DECISION_BOUNDARY=== lines.
|
|
8453
8524
|
|
|
8454
8525
|
Options:
|
|
8455
|
-
--
|
|
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(
|
|
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/
|
|
8498
|
-
|
|
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/
|
|
8512
|
-
import {
|
|
8513
|
-
|
|
8514
|
-
|
|
8515
|
-
|
|
8516
|
-
|
|
8517
|
-
|
|
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
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
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
|
|
8552
|
-
|
|
8553
|
-
|
|
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
|
-
|
|
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
|
|
8563
|
-
|
|
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/
|
|
8567
|
-
var
|
|
8568
|
-
|
|
8569
|
-
|
|
8570
|
-
|
|
8571
|
-
|
|
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
|
-
|
|
8583
|
-
|
|
8584
|
-
|
|
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
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
".
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
8609
|
-
|
|
8610
|
-
|
|
8611
|
-
|
|
8612
|
-
|
|
8613
|
-
|
|
8614
|
-
|
|
8615
|
-
|
|
8616
|
-
|
|
8617
|
-
"
|
|
8618
|
-
|
|
8619
|
-
|
|
8620
|
-
|
|
8621
|
-
|
|
8622
|
-
|
|
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
|
-
|
|
8625
|
-
|
|
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
|
|
8631
|
-
|
|
8632
|
-
"-
|
|
8633
|
-
|
|
8634
|
-
|
|
8635
|
-
|
|
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
|
|
8655
|
-
|
|
8656
|
-
|
|
8657
|
-
|
|
8658
|
-
const
|
|
8659
|
-
const
|
|
8660
|
-
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
|
|
8664
|
-
|
|
8665
|
-
|
|
8666
|
-
|
|
8667
|
-
|
|
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
|
|
8738
|
+
return {
|
|
8739
|
+
commit: git("rev-parse", "--short", "HEAD").trim(),
|
|
8740
|
+
onto: git("rev-parse", "--short", onto).trim()
|
|
8741
|
+
};
|
|
8672
8742
|
}
|
|
8673
|
-
function
|
|
8674
|
-
|
|
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
|
|
8678
|
-
|
|
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
|
-
|
|
8682
|
-
|
|
8683
|
-
|
|
8684
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
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/
|
|
8698
|
-
var
|
|
8699
|
-
name: "
|
|
8700
|
-
summary: "
|
|
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
|
|
8788
|
+
usage: `Usage: dld commit-reindex --base <ref> [--squash] < plan
|
|
8703
8789
|
|
|
8704
|
-
|
|
8705
|
-
|
|
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 (
|
|
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({
|
|
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
|
|
8714
|
-
|
|
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/
|
|
8722
|
-
import {
|
|
8723
|
-
|
|
8724
|
-
|
|
8725
|
-
|
|
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
|
|
8825
|
+
return value;
|
|
8756
8826
|
}
|
|
8757
|
-
function
|
|
8758
|
-
const
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
return {
|
|
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/
|
|
8765
|
-
var
|
|
8766
|
-
name: "
|
|
8767
|
-
summary: "
|
|
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:
|
|
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 {
|
|
8779
|
-
|
|
8780
|
-
|
|
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/
|
|
8787
|
-
import {
|
|
8788
|
-
|
|
8789
|
-
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
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
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
8816
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
const
|
|
8821
|
-
|
|
8822
|
-
|
|
8823
|
-
|
|
8824
|
-
|
|
8825
|
-
|
|
8826
|
-
|
|
8827
|
-
|
|
8828
|
-
|
|
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
|
-
|
|
8846
|
-
|
|
8847
|
-
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
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
|
-
|
|
8870
|
-
|
|
8871
|
-
|
|
8872
|
-
|
|
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/
|
|
8876
|
-
var
|
|
8877
|
-
name: "
|
|
8878
|
-
summary: "
|
|
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
|
|
8936
|
+
usage: `Usage: dld create-decision --id <DL-NNN> --title <title> [options]
|
|
8881
8937
|
|
|
8882
|
-
|
|
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
|
-
--
|
|
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({
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
8894
|
-
|
|
8895
|
-
|
|
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/
|
|
8903
|
-
|
|
8904
|
-
|
|
8905
|
-
|
|
8906
|
-
|
|
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/
|
|
8909
|
-
|
|
8910
|
-
var
|
|
8911
|
-
function
|
|
8912
|
-
const
|
|
8913
|
-
|
|
8914
|
-
|
|
8915
|
-
|
|
8916
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
const
|
|
8920
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
9363
|
-
const read = (file) => regular(file) ? ctx.fs.readFile(
|
|
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 =
|
|
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,
|
|
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(
|
|
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(
|
|
9519
|
+
if (ctx.fs.lexists(join14(root, path))) plan.removals.push(path);
|
|
9424
9520
|
} else if (channels.has(channel)) {
|
|
9425
|
-
refuseSymlinkedDir(ctx, root,
|
|
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(
|
|
9533
|
+
const exists = (file) => ctx.fs.exists(join14(root, file));
|
|
9436
9534
|
return {
|
|
9437
9535
|
exists,
|
|
9438
|
-
read: (file) => exists(file) && !ctx.fs.isDirectory(
|
|
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 =
|
|
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 &&
|
|
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(
|
|
9488
|
-
const agentsLinked = ctx.fs.lexists(
|
|
9489
|
-
if (ctx.fs.isRegularFile(
|
|
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(
|
|
9501
|
-
const claudeMd =
|
|
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(
|
|
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
|
|
9530
|
-
if (!ctx.fs.exists(
|
|
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 =
|
|
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) !==
|
|
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(
|
|
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
|
-
|
|
9559
|
-
|
|
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
|
-
|
|
9562
|
-
|
|
9563
|
-
|
|
9564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9570
|
-
|
|
9571
|
-
const
|
|
9572
|
-
|
|
9573
|
-
|
|
9574
|
-
|
|
9575
|
-
const
|
|
9576
|
-
|
|
9577
|
-
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9581
|
-
|
|
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
|
-
|
|
10284
|
+
diff.extra = [...existing].filter((path) => !files.has(path)).sort();
|
|
10285
|
+
return diff;
|
|
9585
10286
|
}
|
|
9586
|
-
function
|
|
9587
|
-
|
|
9588
|
-
|
|
9589
|
-
|
|
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
|
-
|
|
9594
|
-
|
|
9595
|
-
|
|
9596
|
-
|
|
9597
|
-
|
|
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 =
|
|
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
|
|
9613
|
-
|
|
9614
|
-
|
|
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 &&
|
|
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 =
|
|
10365
|
+
const dir = join18(root, layout.dir);
|
|
9620
10366
|
if (!ctx.fs.isDirectory(dir)) return;
|
|
9621
|
-
const linked = ctx.fs.readDir(dir).filter((entry) =>
|
|
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 =
|
|
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(
|
|
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(
|
|
9666
|
-
stamps.push({ path, version: SKILL_STAMP.exec(ctx.fs.readFile(
|
|
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
|
-
|
|
9726
|
-
|
|
9727
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
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 (
|
|
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
|
|
9966
|
-
const
|
|
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 (
|
|
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
|
|
10005
|
-
const
|
|
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 (
|
|
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
|
|
10070
|
-
const
|
|
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
|
|
10085
|
-
origin/
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 ${
|
|
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 ${
|
|
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(
|
|
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,
|
|
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) {
|