@draht/tools 2026.7.13 → 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.
Files changed (2) hide show
  1. package/bin/draht-tools.cjs +472 -41
  2. package/package.json +2 -2
@@ -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 { execSync, execFileSync } = require("node:child_process");
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 = visWriteOutputs(repoRoot);
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
- function visWriteOutputs(root, opts = {}) {
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 map = visBuildMap(root);
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
- // Idempotent write: if the new map differs from the committed file ONLY in the volatile
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
- const prev = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
5111
- unchanged = JSON.stringify(Object.assign({}, prev, { generatedAt: 0, buildMs: 0 }))
5112
- === JSON.stringify(Object.assign({}, map, { generatedAt: 0, buildMs: 0 }));
5113
- } catch { /* no/invalid prior file → write fresh */ }
5114
- if (!unchanged) {
5115
- fs.writeFileSync(jsonPath, JSON.stringify(map, null, 2) + "\n", "utf-8");
5116
- fs.writeFileSync(reportPath, visRenderReport(map), "utf-8"); // a fresh human report is a parity feature
5117
- }
5118
- // MAP.html is a derived view artifact (not the git-committed source of truth), so on any full
5119
- // (non-quiet) build we always refresh it — otherwise template/visualization changes never reach
5120
- // MAP.html when the underlying graph data is unchanged. The JSON/report stay gated above so the
5121
- // committed data never churns. The post-commit hook uses --quiet and skips the HTML entirely.
5122
- if (!opts.quiet) fs.writeFileSync(htmlPath, visRenderHtml(jsonPath, map), "utf-8");
5123
- return { jsonPath, htmlPath, reportPath, map, unchanged };
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
- try { c.write("data: changed\n\n"); } catch { /* empty */ }
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
- // Defect 16: normalize to posix separators BEFORE any segment/substring check — the
5210
- // previous checks below (`/node_modules/`, `/.git/`, …) silently never matched on win32,
5211
- // where fs.watch reports paths with backslashes.
5212
- const f = fname.toString().split(path.sep).join("/");
5213
- const firstSeg = f.split("/")[0];
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
- try { c.write(": ping\n\n"); } catch { /* empty */ }
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
- await commands[cmd](...args);
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.7.13",
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",