@draht/tools 2026.7.30 → 2026.9.5-1
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/bin/draht-tools.cjs +472 -41
- package/package.json +2 -2
package/bin/draht-tools.cjs
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
const fs = require("node:fs");
|
|
10
|
+
const crypto = require("node:crypto");
|
|
10
11
|
const path = require("node:path");
|
|
11
|
-
const
|
|
12
|
+
const os = require("node:os");
|
|
13
|
+
const { execSync, execFileSync, spawn, spawnSync } = require("node:child_process");
|
|
12
14
|
|
|
13
15
|
const PLANNING_DIR = ".planning";
|
|
14
16
|
const BANNER_WIDTH = 55;
|
|
@@ -275,7 +277,7 @@ commands["map-codebase"] = function (dir) {
|
|
|
275
277
|
// so `map-codebase` is a single command that produces both the prose docs and the graphify map.
|
|
276
278
|
let kg = null;
|
|
277
279
|
try {
|
|
278
|
-
kg =
|
|
280
|
+
kg = graphBuildOutputs(repoRoot);
|
|
279
281
|
} catch (err) {
|
|
280
282
|
console.error(`\n⚠️ Knowledge graph build failed (run \`draht-tools map-graph\` to retry): ${err.message}`);
|
|
281
283
|
}
|
|
@@ -5095,32 +5097,421 @@ function visRenderReport(map) {
|
|
|
5095
5097
|
return L.join("\n");
|
|
5096
5098
|
}
|
|
5097
5099
|
|
|
5098
|
-
|
|
5100
|
+
// ============================================================================
|
|
5101
|
+
// Go graph engine — delegation shim
|
|
5102
|
+
// Resolution order: $DRAHT_GRAPH_BIN → <this bin/> → ~/.draht/bin → $PATH.
|
|
5103
|
+
// Engine choice: $DRAHT_GRAPH_ENGINE = auto (default) | go | js.
|
|
5104
|
+
// See .planning/kg-integration/SPEC.md § "Go engine cutover".
|
|
5105
|
+
// ============================================================================
|
|
5106
|
+
|
|
5107
|
+
// Commands the Go engine implements with byte-identical stdout AND exit codes.
|
|
5108
|
+
// Anything not listed here always runs the JS implementation below.
|
|
5109
|
+
const GRAPH_GO_COMMANDS = new Set([
|
|
5110
|
+
"map-graph", "map-serve", "graph-context", "graph-impact", "graph-callers",
|
|
5111
|
+
"graph-callees", "graph-path", "graph-query", "graph-hotspots",
|
|
5112
|
+
"graph-clusters", "graph-hook",
|
|
5113
|
+
]);
|
|
5114
|
+
|
|
5115
|
+
// Deliberately NOT "draht-tools": the $PATH scan below would otherwise find the
|
|
5116
|
+
// npm shim for THIS file and spawn it forever. See also DRAHT_GRAPH_DELEGATED.
|
|
5117
|
+
const GRAPH_BIN_NAME = process.platform === "win32" ? "draht-graph.exe" : "draht-graph";
|
|
5118
|
+
|
|
5119
|
+
// Must equal visBuildMap()'s schemaVersion (see the `schemaVersion: 5` literal
|
|
5120
|
+
// above). A stamped binary built for another schema is rejected rather than
|
|
5121
|
+
// silently producing an incompatible MAP.json.
|
|
5122
|
+
const GRAPH_SCHEMA_VERSION = 5;
|
|
5123
|
+
|
|
5124
|
+
// Flip to true only when every gate in SPEC.md § "Requiring the Go engine" is
|
|
5125
|
+
// green. Until then a missing binary is a silent JS fallback, not an error.
|
|
5126
|
+
const GRAPH_REQUIRE_GO = false;
|
|
5127
|
+
|
|
5128
|
+
function graphEngineMode() {
|
|
5129
|
+
const v = String(process.env.DRAHT_GRAPH_ENGINE || "auto").trim().toLowerCase();
|
|
5130
|
+
return v === "go" || v === "js" ? v : "auto";
|
|
5131
|
+
}
|
|
5132
|
+
|
|
5133
|
+
// One statSync per candidate — existence AND the exec bit come from the same call.
|
|
5134
|
+
function graphStat(p) {
|
|
5135
|
+
try {
|
|
5136
|
+
const st = fs.statSync(p);
|
|
5137
|
+
if (!st.isFile()) return null;
|
|
5138
|
+
if (process.platform !== "win32" && (st.mode & 0o111) === 0) return null;
|
|
5139
|
+
return st;
|
|
5140
|
+
} catch {
|
|
5141
|
+
return null;
|
|
5142
|
+
}
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
// Unstamped binaries are trusted (dev builds and $PATH). If a stamp exists,
|
|
5146
|
+
// however, it marks a managed binary and every identity/integrity field is
|
|
5147
|
+
// mandatory; malformed or stale metadata must never turn verification off.
|
|
5148
|
+
// The stamp is written by `install-graph-engine` (packages/draht-claude/cli.mjs,
|
|
5149
|
+
// packages/draht-codex/cli.mjs), not read from the release manifest.json.
|
|
5150
|
+
function graphStampOk(binPath, st) {
|
|
5151
|
+
const stampPath = path.join(path.dirname(binPath), ".draht-graph.json");
|
|
5152
|
+
if (!fs.existsSync(stampPath)) return true;
|
|
5153
|
+
let s;
|
|
5154
|
+
try {
|
|
5155
|
+
s = JSON.parse(fs.readFileSync(stampPath, "utf-8"));
|
|
5156
|
+
} catch {
|
|
5157
|
+
return false;
|
|
5158
|
+
}
|
|
5159
|
+
// JSON.parse("null")/("42")/("[]") all succeed without throwing, so `s` may be
|
|
5160
|
+
// anything JSON-serializable here, not just an object — guard before touching
|
|
5161
|
+
// `.size`/`.schemaVersion` or a malformed stamp file crashes the CLI BEFORE the
|
|
5162
|
+
// JS fallback ever runs (see Phase 4 review finding #1).
|
|
5163
|
+
if (!s || typeof s !== "object" || Array.isArray(s)) return false;
|
|
5164
|
+
if (s.name !== "draht-graph" || typeof s.version !== "string" || s.version.length === 0) return false;
|
|
5165
|
+
if (s.schemaVersion !== GRAPH_SCHEMA_VERSION || !Number.isSafeInteger(s.size) || s.size < 0 || s.size !== st.size) return false;
|
|
5166
|
+
if (typeof s.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(s.sha256)) return false;
|
|
5167
|
+
const actual = crypto.createHash("sha256").update(fs.readFileSync(binPath)).digest("hex");
|
|
5168
|
+
return actual === s.sha256;
|
|
5169
|
+
}
|
|
5170
|
+
|
|
5171
|
+
let graphBinCache; // undefined = unresolved · null = resolved-as-missing · string = path
|
|
5172
|
+
let graphBinTried = []; // candidates actually stat'd — quoted verbatim in the error message
|
|
5173
|
+
let graphBinRejected = []; // candidates that exist + are executable but graphStampOk() rejected
|
|
5174
|
+
function graphResolveBin() {
|
|
5175
|
+
if (graphBinCache !== undefined) return graphBinCache;
|
|
5176
|
+
graphBinCache = null;
|
|
5177
|
+
graphBinTried = [];
|
|
5178
|
+
graphBinRejected = [];
|
|
5179
|
+
|
|
5180
|
+
const override = process.env.DRAHT_GRAPH_BIN;
|
|
5181
|
+
if (override) { // explicit override: never silently ignored
|
|
5182
|
+
graphBinTried.push(override);
|
|
5183
|
+
const st = graphStat(override);
|
|
5184
|
+
if (!st) {
|
|
5185
|
+
console.error(`draht-tools: DRAHT_GRAPH_BIN=${override} is not an executable file`);
|
|
5186
|
+
} else if (!graphStampOk(override, st)) {
|
|
5187
|
+
graphBinRejected.push(override);
|
|
5188
|
+
console.error(`draht-tools: DRAHT_GRAPH_BIN=${override} rejected by its .draht-graph.json provenance stamp (malformed or stale size/schema/SHA-256)`);
|
|
5189
|
+
} else {
|
|
5190
|
+
graphBinCache = override;
|
|
5191
|
+
}
|
|
5192
|
+
return graphBinCache;
|
|
5193
|
+
}
|
|
5194
|
+
|
|
5195
|
+
const home = (() => {
|
|
5196
|
+
try {
|
|
5197
|
+
return os.homedir();
|
|
5198
|
+
} catch {
|
|
5199
|
+
return null;
|
|
5200
|
+
}
|
|
5201
|
+
})();
|
|
5202
|
+
const dirs = [__dirname];
|
|
5203
|
+
if (home) dirs.push(path.join(home, ".draht", "bin"));
|
|
5204
|
+
const cands = [];
|
|
5205
|
+
for (const d of dirs) cands.push(path.join(d, GRAPH_BIN_NAME));
|
|
5206
|
+
for (const d of String(process.env.PATH || "").split(path.delimiter)) {
|
|
5207
|
+
if (d) cands.push(path.join(d, GRAPH_BIN_NAME));
|
|
5208
|
+
}
|
|
5209
|
+
for (const c of cands) {
|
|
5210
|
+
graphBinTried.push(c);
|
|
5211
|
+
const st = graphStat(c);
|
|
5212
|
+
if (!st) continue;
|
|
5213
|
+
if (!graphStampOk(c, st)) {
|
|
5214
|
+
// Found and executable, but its .draht-graph.json provenance stamp is stale
|
|
5215
|
+
// (size/schemaVersion mismatch). NOT silent: a stale stamp otherwise reverts
|
|
5216
|
+
// every user to the ~6x slower JS engine with zero diagnostic (Phase 4 review
|
|
5217
|
+
// finding #3). graphMissingMessage() below surfaces this distinctly too.
|
|
5218
|
+
graphBinRejected.push(c);
|
|
5219
|
+
console.error(`draht-tools: ${c} rejected by its .draht-graph.json provenance stamp (malformed or stale size/schema/SHA-256) — skipping`);
|
|
5220
|
+
continue;
|
|
5221
|
+
}
|
|
5222
|
+
graphBinCache = c;
|
|
5223
|
+
return graphBinCache;
|
|
5224
|
+
}
|
|
5225
|
+
return graphBinCache;
|
|
5226
|
+
}
|
|
5227
|
+
|
|
5228
|
+
function graphMissingMessage() {
|
|
5229
|
+
const rejectedNote = graphBinRejected.length
|
|
5230
|
+
? [
|
|
5231
|
+
"",
|
|
5232
|
+
"Note: the following WERE found but rejected by their .draht-graph.json",
|
|
5233
|
+
"provenance stamp (malformed or stale size/schema/SHA-256) — this is not the same as",
|
|
5234
|
+
"\"not found\"; reinstall or remove the stale .draht-graph.json next to it:",
|
|
5235
|
+
...graphBinRejected.map((p) => ` ${p}`),
|
|
5236
|
+
]
|
|
5237
|
+
: [];
|
|
5238
|
+
return [
|
|
5239
|
+
`draht-tools: the Go graph engine was required (DRAHT_GRAPH_ENGINE=go) but no usable \`draht-graph\` binary was found.`,
|
|
5240
|
+
...rejectedNote,
|
|
5241
|
+
"",
|
|
5242
|
+
"Looked for:",
|
|
5243
|
+
...graphBinTried.map((p) => ` ${p}`),
|
|
5244
|
+
"",
|
|
5245
|
+
"Install it:",
|
|
5246
|
+
" npx draht-claude install-graph-engine # or: npx draht-codex install-graph-engine",
|
|
5247
|
+
"",
|
|
5248
|
+
"Build it from source:",
|
|
5249
|
+
" cd go && make build && mkdir -p ~/.draht/bin && cp bin/draht-tools ~/.draht/bin/draht-graph",
|
|
5250
|
+
"",
|
|
5251
|
+
"Or point at an existing build:",
|
|
5252
|
+
" export DRAHT_GRAPH_BIN=/path/to/draht-graph",
|
|
5253
|
+
"",
|
|
5254
|
+
"Or use the built-in JS engine instead:",
|
|
5255
|
+
" unset DRAHT_GRAPH_ENGINE # (equivalent to DRAHT_GRAPH_ENGINE=auto)",
|
|
5256
|
+
].join("\n");
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
// Delegates and EXITS the process on success; returns normally to fall through to JS.
|
|
5260
|
+
// Value-taking Go-only flags (accepted by the Go engine's own CLI, meaningless to
|
|
5261
|
+
// the JS engine's arg parsers) vs. boolean ones. graphParseArgs/map-graph's own
|
|
5262
|
+
// loop treat an unconsumed flag VALUE as a positional file/dir argument, so these
|
|
5263
|
+
// must be stripped — not just documented as "ignored" — before the JS path ever
|
|
5264
|
+
// sees argv (Phase 4 review finding #4).
|
|
5265
|
+
const GRAPH_GO_ONLY_VALUE_FLAGS = new Set(["jobs", "parser", "cache-dir", "out", "ast-max-bytes", "ast-max-line"]);
|
|
5266
|
+
const GRAPH_GO_ONLY_BOOL_FLAGS = new Set(["verbose", "symbol-signatures"]);
|
|
5267
|
+
function graphStripGoOnlyFlags(args) {
|
|
5268
|
+
const out = [];
|
|
5269
|
+
for (let i = 0; i < args.length; i++) {
|
|
5270
|
+
const a = String(args[i]);
|
|
5271
|
+
const eq = a.indexOf("=");
|
|
5272
|
+
const key = (eq === -1 ? a : a.slice(0, eq)).replace(/^--?/, "");
|
|
5273
|
+
if (GRAPH_GO_ONLY_BOOL_FLAGS.has(key)) continue;
|
|
5274
|
+
if (GRAPH_GO_ONLY_VALUE_FLAGS.has(key)) {
|
|
5275
|
+
if (eq === -1) i++; // also skip the separate value token, e.g. `--out /tmp/x`
|
|
5276
|
+
continue;
|
|
5277
|
+
}
|
|
5278
|
+
out.push(args[i]);
|
|
5279
|
+
}
|
|
5280
|
+
return out;
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5283
|
+
// MAP.json is a committed, hook-refreshed artifact (gsd-post-phase, the git
|
|
5284
|
+
// post-commit hook) — it must not silently change shape depending on whether the
|
|
5285
|
+
// person/CI running map-graph happens to have the Go binary installed. The Go
|
|
5286
|
+
// CLI's own default is `--parser treesitter` (AST; a deliberate improvement, see
|
|
5287
|
+
// go/README.md), which is NOT byte-identical to the JS engine's regex-based
|
|
5288
|
+
// output. Until a project-wide, version-controlled decision to switch the
|
|
5289
|
+
// committed artifact to AST lands (see .planning/kg-integration/SPEC.md § "Go
|
|
5290
|
+
// engine cutover"), pin delegated `map-graph` and `map-serve` runs to
|
|
5291
|
+
// `--parser regex` so the two engines produce identical MAP.json — unless the
|
|
5292
|
+
// caller explicitly asked for a different parser. (Phase 4 review finding #11.)
|
|
5293
|
+
function graphPinRegexParser(cmd, args) {
|
|
5294
|
+
if (cmd !== "map-graph" && cmd !== "map-serve") return args;
|
|
5295
|
+
if (args.some((a) => a === "--parser" || String(a).startsWith("--parser="))) return args;
|
|
5296
|
+
return ["--parser", "regex", ...args];
|
|
5297
|
+
}
|
|
5298
|
+
|
|
5299
|
+
function graphWantsSymbolSignatures(cmd, args) {
|
|
5300
|
+
return cmd === "map-graph" && args.some((a) => a === "--symbol-signatures");
|
|
5301
|
+
}
|
|
5302
|
+
|
|
5303
|
+
function graphSignatureFallbackWarning() {
|
|
5304
|
+
return "draht-tools: --symbol-signatures requires the Go graph engine, but no usable `draht-graph` binary is available; continuing with the JavaScript engine without symbol signatures.";
|
|
5305
|
+
}
|
|
5306
|
+
|
|
5307
|
+
function graphMaybeDelegate(cmd, args) {
|
|
5308
|
+
if (!GRAPH_GO_COMMANDS.has(cmd)) return;
|
|
5309
|
+
if (process.env.DRAHT_GRAPH_DELEGATED) return; // recursion guard, belt and braces
|
|
5310
|
+
const mode = graphEngineMode();
|
|
5311
|
+
if (mode === "js") return;
|
|
5312
|
+
const bin = graphResolveBin();
|
|
5313
|
+
if (!bin) {
|
|
5314
|
+
if (mode === "go" || GRAPH_REQUIRE_GO) {
|
|
5315
|
+
console.error(graphMissingMessage());
|
|
5316
|
+
process.exit(127);
|
|
5317
|
+
}
|
|
5318
|
+
if (graphWantsSymbolSignatures(cmd, args)) console.error(graphSignatureFallbackWarning());
|
|
5319
|
+
return; // auto: silent JS fallback
|
|
5320
|
+
}
|
|
5321
|
+
const res = spawnSync(bin, [cmd, ...graphPinRegexParser(cmd, args)], {
|
|
5322
|
+
stdio: "inherit", // byte-identical stdout/stderr, TTY preserved
|
|
5323
|
+
windowsHide: true,
|
|
5324
|
+
env: { ...process.env, DRAHT_GRAPH_DELEGATED: "1" },
|
|
5325
|
+
});
|
|
5326
|
+
if (res.error) { // raced the stat (deleted / EACCES / ENOEXEC)
|
|
5327
|
+
if (mode === "go" || GRAPH_REQUIRE_GO) {
|
|
5328
|
+
console.error(`draht-tools: failed to execute ${bin}: ${res.error.message}`);
|
|
5329
|
+
process.exit(127);
|
|
5330
|
+
}
|
|
5331
|
+
if (graphWantsSymbolSignatures(cmd, args)) console.error(graphSignatureFallbackWarning());
|
|
5332
|
+
return; // auto: JS fallback
|
|
5333
|
+
}
|
|
5334
|
+
if (res.signal) {
|
|
5335
|
+
const n = os.constants.signals[res.signal];
|
|
5336
|
+
process.exit(n ? 128 + n : 1);
|
|
5337
|
+
}
|
|
5338
|
+
process.exit(res.status === null ? 1 : res.status);
|
|
5339
|
+
}
|
|
5340
|
+
|
|
5341
|
+
// map-codebase needs the built map IN-PROCESS (it prints stats/paths from it), so it
|
|
5342
|
+
// cannot use the stdout-passthrough path. Run the Go engine for its side effects, then
|
|
5343
|
+
// read MAP.json back. Any failure falls through to the JS builder, which is idempotent.
|
|
5344
|
+
function graphBuildOutputs(root, opts = {}) {
|
|
5345
|
+
const mode = graphEngineMode();
|
|
5346
|
+
const bin = mode === "js" ? null : graphResolveBin();
|
|
5347
|
+
if (!bin) {
|
|
5348
|
+
if (mode === "go" || GRAPH_REQUIRE_GO) {
|
|
5349
|
+
console.error(graphMissingMessage());
|
|
5350
|
+
process.exit(127);
|
|
5351
|
+
}
|
|
5352
|
+
return visWriteOutputs(root, opts);
|
|
5353
|
+
}
|
|
5354
|
+
const argv = ["map-graph", "--parser", "regex"]; // see graphPinRegexParser's doc comment
|
|
5355
|
+
if (opts.quiet) argv.push("--quiet");
|
|
5356
|
+
const res = spawnSync(bin, argv, {
|
|
5357
|
+
cwd: root,
|
|
5358
|
+
stdio: ["ignore", "ignore", "inherit"], // its stdout is map-graph's banner, not map-codebase's
|
|
5359
|
+
windowsHide: true,
|
|
5360
|
+
env: { ...process.env, DRAHT_GRAPH_DELEGATED: "1" },
|
|
5361
|
+
});
|
|
5362
|
+
if (res.error || res.status !== 0) return visWriteOutputs(root, opts);
|
|
5099
5363
|
const outDir = path.join(root, PLANNING_DIR, "codebase");
|
|
5364
|
+
const jsonPath = path.join(outDir, "MAP.json");
|
|
5365
|
+
try {
|
|
5366
|
+
return {
|
|
5367
|
+
jsonPath,
|
|
5368
|
+
htmlPath: path.join(outDir, "MAP.html"),
|
|
5369
|
+
reportPath: path.join(outDir, "GRAPH_REPORT.md"),
|
|
5370
|
+
map: JSON.parse(fs.readFileSync(jsonPath, "utf-8")),
|
|
5371
|
+
unchanged: false,
|
|
5372
|
+
};
|
|
5373
|
+
} catch {
|
|
5374
|
+
return visWriteOutputs(root, opts);
|
|
5375
|
+
}
|
|
5376
|
+
}
|
|
5377
|
+
|
|
5378
|
+
const GRAPH_PUBLISH_LOCK = ".map-publish-lock";
|
|
5379
|
+
const GRAPH_LOCK_STALE_MS = 2000;
|
|
5380
|
+
const GRAPH_LOCK_WAIT_MS = 120000;
|
|
5381
|
+
const GRAPH_LOCK_HEARTBEAT_MS = 250;
|
|
5382
|
+
|
|
5383
|
+
function graphSleep(ms) {
|
|
5384
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
5385
|
+
}
|
|
5386
|
+
|
|
5387
|
+
function graphAcquirePublication(outDir) {
|
|
5100
5388
|
ensureDir(outDir);
|
|
5101
|
-
const
|
|
5389
|
+
const lockDir = path.join(fs.realpathSync(outDir), GRAPH_PUBLISH_LOCK);
|
|
5390
|
+
const token = crypto.randomBytes(16).toString("hex");
|
|
5391
|
+
const ownerPath = path.join(lockDir, `.owner-${token}.json`);
|
|
5392
|
+
const deadline = Date.now() + GRAPH_LOCK_WAIT_MS;
|
|
5393
|
+
for (;;) {
|
|
5394
|
+
try {
|
|
5395
|
+
fs.mkdirSync(lockDir, { mode: 0o700 });
|
|
5396
|
+
fs.writeFileSync(ownerPath, JSON.stringify({ token, pid: process.pid }), { mode: 0o600, flag: "wx" });
|
|
5397
|
+
// An IPC channel is an OS-owned parent-liveness signal and cannot be
|
|
5398
|
+
// fooled by PID reuse. The token-specific pathname prevents a delayed
|
|
5399
|
+
// heartbeat from refreshing a successor after stale-lock recovery.
|
|
5400
|
+
const heartbeatScript = `const fs=require("node:fs");const [owner,interval]=process.argv.slice(1);process.on("disconnect",()=>process.exit(0));setInterval(()=>{try{const now=new Date();fs.utimesSync(owner,now,now)}catch{process.exit(0)}},Number(interval));`;
|
|
5401
|
+
const heartbeat = spawn(process.execPath, ["-e", heartbeatScript, ownerPath, String(GRAPH_LOCK_HEARTBEAT_MS)], {
|
|
5402
|
+
stdio: ["ignore", "ignore", "ignore", "ipc"], windowsHide: true,
|
|
5403
|
+
});
|
|
5404
|
+
heartbeat.unref();
|
|
5405
|
+
heartbeat.channel?.unref();
|
|
5406
|
+
return { lockDir, ownerPath, token, heartbeat };
|
|
5407
|
+
} catch (error) {
|
|
5408
|
+
if (error.code !== "EEXIST") {
|
|
5409
|
+
try { fs.rmSync(ownerPath, { force: true }); fs.rmdirSync(lockDir); } catch { /* best effort for our incomplete lock */ }
|
|
5410
|
+
throw error;
|
|
5411
|
+
}
|
|
5412
|
+
let info;
|
|
5413
|
+
let heartbeatPath = null;
|
|
5414
|
+
try {
|
|
5415
|
+
const lockInfo = fs.lstatSync(lockDir);
|
|
5416
|
+
if (!lockInfo.isDirectory()) throw new Error(`graph publication lock path is not a directory: ${lockDir}`);
|
|
5417
|
+
info = lockInfo;
|
|
5418
|
+
const owners = fs.readdirSync(lockDir, { withFileTypes: true })
|
|
5419
|
+
.filter((entry) => entry.isFile() && /^\.owner-[a-f0-9]{32}\.json$/.test(entry.name));
|
|
5420
|
+
if (owners.length === 1) {
|
|
5421
|
+
const candidate = path.join(lockDir, owners[0].name);
|
|
5422
|
+
try {
|
|
5423
|
+
const owner = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
5424
|
+
const token = owners[0].name.slice(".owner-".length, -".json".length);
|
|
5425
|
+
if (owner?.token === token && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
|
|
5426
|
+
info = fs.statSync(candidate);
|
|
5427
|
+
heartbeatPath = candidate;
|
|
5428
|
+
}
|
|
5429
|
+
} catch { /* malformed owner: recover according to directory age */ }
|
|
5430
|
+
}
|
|
5431
|
+
} catch (inspectError) {
|
|
5432
|
+
if (inspectError.code === "ENOENT") continue;
|
|
5433
|
+
throw inspectError;
|
|
5434
|
+
}
|
|
5435
|
+
if (Date.now() - info.mtimeMs > GRAPH_LOCK_STALE_MS) {
|
|
5436
|
+
if (heartbeatPath) {
|
|
5437
|
+
const observed = info.mtimeMs;
|
|
5438
|
+
graphSleep(2 * GRAPH_LOCK_HEARTBEAT_MS);
|
|
5439
|
+
try { if (fs.lstatSync(heartbeatPath).mtimeMs > observed) continue; }
|
|
5440
|
+
catch (recheckError) { if (recheckError.code !== "ENOENT") throw recheckError; }
|
|
5441
|
+
}
|
|
5442
|
+
const stale = `${lockDir}.stale-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
5443
|
+
try { fs.renameSync(lockDir, stale); fs.rmSync(stale, { recursive: true, force: true }); } catch { /* another waiter won */ }
|
|
5444
|
+
continue;
|
|
5445
|
+
}
|
|
5446
|
+
if (Date.now() >= deadline) throw new Error(`timed out waiting for graph publication lock ${lockDir}`);
|
|
5447
|
+
graphSleep(10);
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
|
|
5452
|
+
function graphReleasePublication(lock) {
|
|
5453
|
+
try { lock.heartbeat.kill(); } catch { /* already exited */ }
|
|
5454
|
+
// Token-specific unlink + rmdir is ABA-safe: an obsolete owner can never
|
|
5455
|
+
// remove a successor's owner file, and rmdir fails while that file exists.
|
|
5456
|
+
try { fs.rmSync(lock.ownerPath, { force: true }); } catch { /* stale recovery moved it */ }
|
|
5457
|
+
try { fs.rmdirSync(lock.lockDir); } catch { /* stale recovery or successor owns it */ }
|
|
5458
|
+
}
|
|
5459
|
+
|
|
5460
|
+
function visSourceGeneration(root) {
|
|
5461
|
+
const hash = crypto.createHash("sha256");
|
|
5462
|
+
for (const file of visWalk(root).files) {
|
|
5463
|
+
hash.update(path.relative(root, file).split(path.sep).join("/"));
|
|
5464
|
+
hash.update("\0");
|
|
5465
|
+
try { hash.update(fs.readFileSync(file)); } catch (error) { hash.update(String(error.code || error.message)); }
|
|
5466
|
+
hash.update("\0");
|
|
5467
|
+
}
|
|
5468
|
+
for (const relative of ["GROUPS.json", "FLOWS.json", "STATE.md", "ROADMAP.md", "PROJECT.md", "DOMAIN.md", "DOMAIN-MODEL.md"]) {
|
|
5469
|
+
const file = path.join(root, PLANNING_DIR, relative === "GROUPS.json" || relative === "FLOWS.json" ? "codebase" : "", relative);
|
|
5470
|
+
hash.update(relative);
|
|
5471
|
+
try { hash.update(fs.readFileSync(file)); } catch (error) { hash.update(String(error.code || error.message)); }
|
|
5472
|
+
hash.update("\0");
|
|
5473
|
+
}
|
|
5474
|
+
return hash.digest("hex");
|
|
5475
|
+
}
|
|
5476
|
+
|
|
5477
|
+
function visWriteOutputs(root, opts = {}) {
|
|
5478
|
+
const outDir = path.join(root, PLANNING_DIR, "codebase");
|
|
5102
5479
|
const jsonPath = path.join(outDir, "MAP.json");
|
|
5103
5480
|
const htmlPath = path.join(outDir, "MAP.html");
|
|
5104
5481
|
const reportPath = path.join(outDir, "GRAPH_REPORT.md");
|
|
5105
|
-
|
|
5106
|
-
// generatedAt/buildMs fields, leave every artifact untouched — so the post-commit hook /
|
|
5107
|
-
// stale rebuild never produces spurious git churn. Graph data is deterministic (see verify gate).
|
|
5108
|
-
let unchanged = false;
|
|
5482
|
+
const lock = graphAcquirePublication(outDir);
|
|
5109
5483
|
try {
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5115
|
-
|
|
5116
|
-
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5484
|
+
for (;;) {
|
|
5485
|
+
const before = visSourceGeneration(root);
|
|
5486
|
+
const map = visBuildMap(root);
|
|
5487
|
+
const stage = fs.mkdtempSync(path.join(outDir, ".map-publish-stage-js-"));
|
|
5488
|
+
try {
|
|
5489
|
+
const stageJSON = path.join(stage, "MAP.json");
|
|
5490
|
+
const stageHTML = path.join(stage, "MAP.html");
|
|
5491
|
+
const stageReport = path.join(stage, "GRAPH_REPORT.md");
|
|
5492
|
+
let unchanged = false;
|
|
5493
|
+
try {
|
|
5494
|
+
const prev = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
|
5495
|
+
unchanged = JSON.stringify(Object.assign({}, prev, { generatedAt: 0, buildMs: 0 }))
|
|
5496
|
+
=== JSON.stringify(Object.assign({}, map, { generatedAt: 0, buildMs: 0 }));
|
|
5497
|
+
} catch { /* no/invalid prior file */ }
|
|
5498
|
+
if (!unchanged) {
|
|
5499
|
+
fs.writeFileSync(stageJSON, JSON.stringify(map, null, 2) + "\n", "utf-8");
|
|
5500
|
+
fs.writeFileSync(stageReport, visRenderReport(map), "utf-8");
|
|
5501
|
+
}
|
|
5502
|
+
if (!opts.quiet) fs.writeFileSync(stageHTML, visRenderHtml(jsonPath, map), "utf-8");
|
|
5503
|
+
if (before !== visSourceGeneration(root)) continue;
|
|
5504
|
+
if (!unchanged) fs.renameSync(stageReport, reportPath);
|
|
5505
|
+
if (!opts.quiet) fs.renameSync(stageHTML, htmlPath);
|
|
5506
|
+
if (!unchanged) fs.renameSync(stageJSON, jsonPath);
|
|
5507
|
+
return { jsonPath, htmlPath, reportPath, map, unchanged };
|
|
5508
|
+
} finally {
|
|
5509
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
5512
|
+
} finally {
|
|
5513
|
+
graphReleasePublication(lock);
|
|
5514
|
+
}
|
|
5124
5515
|
}
|
|
5125
5516
|
|
|
5126
5517
|
// --- map-graph ---
|
|
@@ -5157,6 +5548,25 @@ const VIS_WATCH_EXTS = new Set(
|
|
|
5157
5548
|
);
|
|
5158
5549
|
// Workspace/package manifests: don't carry a "code" extension but change the dependency graph.
|
|
5159
5550
|
const VIS_WATCH_MANIFESTS = new Set(["package.json", "pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"]);
|
|
5551
|
+
// Planning inputs consumed verbatim by visSourceGeneration(). Keep this exact allowlist in sync with
|
|
5552
|
+
// that function: all other .planning paths are generated publications, caches, locks, or staging.
|
|
5553
|
+
const VIS_WATCH_PLANNING_INPUTS = new Set([
|
|
5554
|
+
".planning/STATE.md", ".planning/ROADMAP.md", ".planning/PROJECT.md",
|
|
5555
|
+
".planning/DOMAIN.md", ".planning/DOMAIN-MODEL.md",
|
|
5556
|
+
".planning/codebase/GROUPS.json", ".planning/codebase/FLOWS.json",
|
|
5557
|
+
]);
|
|
5558
|
+
|
|
5559
|
+
function visShouldWatchPath(file) {
|
|
5560
|
+
if (VIS_WATCH_PLANNING_INPUTS.has(file)) return true;
|
|
5561
|
+
if (file === ".planning" || file.startsWith(".planning/") || file.includes("/.planning/")) return false;
|
|
5562
|
+
const firstSeg = file.split("/")[0];
|
|
5563
|
+
if (VIS_DEFAULT_IGNORES.has(firstSeg)) return false;
|
|
5564
|
+
if (file.includes("/node_modules/") || file.includes("/.git/")) return false;
|
|
5565
|
+
const base = file.split("/").pop() || "";
|
|
5566
|
+
const dot = base.lastIndexOf(".");
|
|
5567
|
+
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : "";
|
|
5568
|
+
return VIS_WATCH_MANIFESTS.has(base) || VIS_WATCH_EXTS.has(ext);
|
|
5569
|
+
}
|
|
5160
5570
|
|
|
5161
5571
|
commands["map-serve"] = function (...args) {
|
|
5162
5572
|
const http = require("node:http");
|
|
@@ -5183,6 +5593,17 @@ commands["map-serve"] = function (...args) {
|
|
|
5183
5593
|
console.log(`Indexed ${lastBuild.map.stats.files} modules in ${lastBuild.map.buildMs}ms`);
|
|
5184
5594
|
|
|
5185
5595
|
const clients = new Set();
|
|
5596
|
+
const dropClient = (client) => {
|
|
5597
|
+
clients.delete(client);
|
|
5598
|
+
try { client.end(); } catch { /* already closed */ }
|
|
5599
|
+
};
|
|
5600
|
+
const sendClient = (client, message) => {
|
|
5601
|
+
try {
|
|
5602
|
+
if (!client.write(message)) dropClient(client);
|
|
5603
|
+
} catch {
|
|
5604
|
+
dropClient(client);
|
|
5605
|
+
}
|
|
5606
|
+
};
|
|
5186
5607
|
let regenTimer = null;
|
|
5187
5608
|
const regen = () => {
|
|
5188
5609
|
clearTimeout(regenTimer);
|
|
@@ -5192,7 +5613,7 @@ commands["map-serve"] = function (...args) {
|
|
|
5192
5613
|
const ts = new Date().toLocaleTimeString();
|
|
5193
5614
|
console.log(`[${ts}] regenerated MAP.json (${lastBuild.map.stats.files} modules, ${lastBuild.map.buildMs}ms)`);
|
|
5194
5615
|
for (const c of clients) {
|
|
5195
|
-
|
|
5616
|
+
sendClient(c, "data: changed\n\n");
|
|
5196
5617
|
}
|
|
5197
5618
|
} catch (err) {
|
|
5198
5619
|
console.error("regen failed:", err.message);
|
|
@@ -5206,20 +5627,11 @@ commands["map-serve"] = function (...args) {
|
|
|
5206
5627
|
try {
|
|
5207
5628
|
const w = fs.watch(target, { recursive: recursive }, (_evt, fname) => {
|
|
5208
5629
|
if (!fname) return;
|
|
5209
|
-
//
|
|
5210
|
-
//
|
|
5211
|
-
//
|
|
5212
|
-
const f = fname.toString().split(path.sep).join("/");
|
|
5213
|
-
|
|
5214
|
-
if (VIS_DEFAULT_IGNORES.has(firstSeg)) return;
|
|
5215
|
-
if (f.includes("/node_modules/") || f.includes("/.git/")) return;
|
|
5216
|
-
if (f === ".planning" || f.startsWith(".planning/") || f.includes("/.planning/")) return;
|
|
5217
|
-
// Defect 16: only code-language files (+ workspace manifests) trigger a rebuild — a
|
|
5218
|
-
// touched README or a stray editor swapfile used to rebuild the whole graph.
|
|
5219
|
-
const base = f.split("/").pop() || "";
|
|
5220
|
-
const dot = base.lastIndexOf(".");
|
|
5221
|
-
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : "";
|
|
5222
|
-
if (!VIS_WATCH_MANIFESTS.has(base) && !VIS_WATCH_EXTS.has(ext)) return;
|
|
5630
|
+
// Normalize both separators and watcher-relative names to one repo-relative path.
|
|
5631
|
+
// Linux fallback watchers report STATE.md while watching .planning; recursive root
|
|
5632
|
+
// watchers report .planning/STATE.md. Admission must see the same path in both cases.
|
|
5633
|
+
const f = path.relative(cwd, path.resolve(target, fname.toString())).split(path.sep).join("/");
|
|
5634
|
+
if (!visShouldWatchPath(f)) return;
|
|
5223
5635
|
regen();
|
|
5224
5636
|
});
|
|
5225
5637
|
watchers.push(w);
|
|
@@ -5232,9 +5644,12 @@ commands["map-serve"] = function (...args) {
|
|
|
5232
5644
|
tryWatch(cwd, false);
|
|
5233
5645
|
for (const e of fs.readdirSync(cwd, { withFileTypes: true })) {
|
|
5234
5646
|
if (!e.isDirectory()) continue;
|
|
5235
|
-
if (VIS_DEFAULT_IGNORES.has(e.name)) continue;
|
|
5647
|
+
if (VIS_DEFAULT_IGNORES.has(e.name) && e.name !== PLANNING_DIR) continue;
|
|
5236
5648
|
tryWatch(path.join(cwd, e.name), false);
|
|
5237
5649
|
}
|
|
5650
|
+
// GROUPS/FLOWS live one level below .planning. Linux's non-recursive fallback needs
|
|
5651
|
+
// an explicit watcher there; exact path admission still rejects every generated peer.
|
|
5652
|
+
tryWatch(path.join(cwd, PLANNING_DIR, "codebase"), false);
|
|
5238
5653
|
if (watchers.length === 0) console.error("map-serve: file watching unavailable — live reload disabled (use the reload button).");
|
|
5239
5654
|
}
|
|
5240
5655
|
|
|
@@ -5258,7 +5673,7 @@ commands["map-serve"] = function (...args) {
|
|
|
5258
5673
|
"cache-control": "no-cache",
|
|
5259
5674
|
connection: "keep-alive",
|
|
5260
5675
|
});
|
|
5261
|
-
res.write("retry: 2000\n\n");
|
|
5676
|
+
if (!res.write("retry: 2000\n\n")) { dropClient(res); return; }
|
|
5262
5677
|
clients.add(res);
|
|
5263
5678
|
req.on("close", () => clients.delete(res));
|
|
5264
5679
|
} else if (url === "/health") {
|
|
@@ -5279,7 +5694,7 @@ commands["map-serve"] = function (...args) {
|
|
|
5279
5694
|
// EventSource ignores lines starting with ":", so this never fires a message event.
|
|
5280
5695
|
const pingTimer = setInterval(() => {
|
|
5281
5696
|
for (const c of clients) {
|
|
5282
|
-
|
|
5697
|
+
sendClient(c, ": ping\n\n");
|
|
5283
5698
|
}
|
|
5284
5699
|
}, 25000);
|
|
5285
5700
|
|
|
@@ -5635,6 +6050,16 @@ Knowledge Graph (query MAP.json instead of grepping — run map-graph first):
|
|
|
5635
6050
|
graph-clusters [--surprising] Structural neighborhoods (+ surprising/bridge connections)
|
|
5636
6051
|
graph-hook install|uninstall|status Git post-commit hook that refreshes MAP.json
|
|
5637
6052
|
(all accept --json for machine output)
|
|
6053
|
+
|
|
6054
|
+
Knowledge graph commands run through a prebuilt \`draht-graph\` binary when one
|
|
6055
|
+
resolves (~6x faster on a cold build), and fall back to this file's built-in JS
|
|
6056
|
+
engine otherwise. DRAHT_GRAPH_ENGINE=go|js|auto (default auto) forces the choice;
|
|
6057
|
+
DRAHT_GRAPH_BIN=/path/to/draht-graph overrides binary resolution. Go-only flags
|
|
6058
|
+
(--jobs, --parser, --cache-dir, --out, --ast-max-bytes, --verbose) are recognized
|
|
6059
|
+
and forwarded when the Go binary handles the command, and are stripped (not
|
|
6060
|
+
passed through, not an error) when the JS engine handles it instead — so a
|
|
6061
|
+
script using them works either way. Install the binary with
|
|
6062
|
+
\`npx draht-claude install-graph-engine\` (or draht-codex).
|
|
5638
6063
|
create-project [name] Create PROJECT.md
|
|
5639
6064
|
create-requirements Create REQUIREMENTS.md
|
|
5640
6065
|
create-domain-model Generate DOMAIN-MODEL.md from PROJECT.md
|
|
@@ -5687,7 +6112,13 @@ const [cmd, ...args] = process.argv.slice(2);
|
|
|
5687
6112
|
if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h") {
|
|
5688
6113
|
commands.help();
|
|
5689
6114
|
} else if (commands[cmd]) {
|
|
5690
|
-
|
|
6115
|
+
graphMaybeDelegate(cmd, args); // exits the process when the Go engine handles it
|
|
6116
|
+
// Go-only flags (--jobs, --parser, --cache-dir, --out, --ast-max-bytes, --verbose)
|
|
6117
|
+
// mean nothing to the JS engine and, left in, corrupt its own arg parsing (an
|
|
6118
|
+
// unconsumed value token gets read as a positional file/dir — finding #4). Strip
|
|
6119
|
+
// them here so the two engines accept the same command line.
|
|
6120
|
+
const jsArgs = GRAPH_GO_COMMANDS.has(cmd) ? graphStripGoOnlyFlags(args) : args;
|
|
6121
|
+
await commands[cmd](...jsArgs);
|
|
5691
6122
|
} else {
|
|
5692
6123
|
console.error(`Unknown command: ${cmd}\nRun: draht-tools help`);
|
|
5693
6124
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@draht/tools",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.9.5-1",
|
|
4
4
|
"description": "Draht Tools — Get Shit Done CLI (canonical source shared by draht-claude and @draht/coding-agent).",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"CHANGELOG.md"
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
|
-
"test": "node bin/draht-tools.cjs --help > /dev/null"
|
|
16
|
+
"test": "node --test test/delegation.test.mjs test/graph-delegation.test.cjs && node bin/draht-tools.cjs --help > /dev/null"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"draht",
|