knodin 0.6.0 → 0.7.3

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 (48) hide show
  1. package/README.md +26 -3
  2. package/dist/bin/cli.js +168 -12
  3. package/dist/bin/launcher.js +11 -0
  4. package/dist/src/agent-integration.js +3 -1
  5. package/dist/src/cli-args.js +8 -1
  6. package/dist/src/cli-model.js +35 -1
  7. package/dist/src/codeflow-replay.js +80 -0
  8. package/dist/src/competitive-cold-mcp.js +40 -0
  9. package/dist/src/competitive-manifest.js +106 -25
  10. package/dist/src/competitive-runner.js +37 -3
  11. package/dist/src/competitive-sandbox.js +1 -1
  12. package/dist/src/diagnostics.js +449 -0
  13. package/dist/src/engine/git-history.js +289 -0
  14. package/dist/src/engine/index.js +417 -70
  15. package/dist/src/engine/scip-import.js +408 -0
  16. package/dist/src/execution-profile.js +203 -0
  17. package/dist/src/failure-diagnosis.js +69 -10
  18. package/dist/src/hook-manager-integration.js +156 -0
  19. package/dist/src/init.js +319 -33
  20. package/dist/src/lifecycle-health.js +42 -4
  21. package/dist/src/output-telemetry.js +4 -0
  22. package/dist/src/progressive-evidence.js +473 -0
  23. package/dist/src/pure-compression-cli.js +101 -0
  24. package/dist/src/release-preflight.js +510 -0
  25. package/dist/src/repository-management.js +142 -0
  26. package/dist/src/response-budget.js +11 -1
  27. package/dist/src/server.js +22 -2
  28. package/dist/src/structural-fast-path.js +303 -0
  29. package/dist/src/structural-snapshot.js +33 -0
  30. package/dist/src/tools/knodin-tools.js +105 -14
  31. package/dist/src/update-ceremony.js +158 -0
  32. package/docs/CLI.md +22 -0
  33. package/docs/COMMAND-OUTPUT-COMPRESSION.md +31 -15
  34. package/docs/CONTAINED-EXECUTION.md +77 -0
  35. package/docs/DIAGNOSTICS.md +45 -0
  36. package/docs/DOCTOR-AND-UPDATES.md +5 -2
  37. package/docs/GIT-HISTORY-REVIEW.md +39 -0
  38. package/docs/MCP.md +15 -0
  39. package/docs/PROGRESSIVE-EVIDENCE.md +37 -0
  40. package/docs/REPOSITORIES-AND-WORKTREES.md +30 -0
  41. package/docs/SCIP-IMPORT.md +57 -0
  42. package/docs/SIGNED-UPDATES.md +5 -0
  43. package/docs/TELEMETRY.md +4 -0
  44. package/docs/releases/0.7.0.md +24 -0
  45. package/docs/releases/0.7.1.md +21 -0
  46. package/docs/releases/0.7.2.md +21 -0
  47. package/docs/releases/0.7.3.md +23 -0
  48. package/package.json +33 -2
@@ -397,18 +397,16 @@ function truncateUtf8(value, byteBudget) {
397
397
  return output;
398
398
  }
399
399
  function sourceSnippet(repo, file, line, contextLines, remainingBytes) {
400
- if (remainingBytes <= 0)
401
- return { snippet: null, truncated: true };
402
400
  const absolute = path.join(repo, file);
403
401
  let lines;
404
402
  try {
405
403
  const stat = fs.statSync(absolute);
406
404
  if (!stat.isFile() || stat.size > MAX_SOURCE_FILE_BYTES)
407
- return { snippet: null, truncated: true };
405
+ return { snippet: null, truncated: true, omittedBytes: 0 };
408
406
  lines = fs.readFileSync(absolute, "utf8").split(/\r?\n/);
409
407
  }
410
408
  catch {
411
- return { snippet: null, truncated: true };
409
+ return { snippet: null, truncated: true, omittedBytes: 0 };
412
410
  }
413
411
  const startLine = Math.max(1, line - contextLines);
414
412
  const endLine = Math.min(lines.length, line + contextLines);
@@ -416,12 +414,16 @@ function sourceSnippet(repo, file, line, contextLines, remainingBytes) {
416
414
  .slice(startLine - 1, endLine)
417
415
  .map((value, index) => `${startLine + index}: ${value.slice(0, 2_000)}`)
418
416
  .join("\n");
417
+ const contentBytes = Buffer.byteLength(content);
418
+ if (remainingBytes <= 0)
419
+ return { snippet: null, truncated: true, omittedBytes: contentBytes };
419
420
  const bounded = truncateUtf8(content, remainingBytes);
420
421
  if (!bounded)
421
- return { snippet: null, truncated: true };
422
+ return { snippet: null, truncated: true, omittedBytes: contentBytes };
422
423
  return {
423
424
  snippet: { file, startLine, endLine, content: bounded },
424
425
  truncated: bounded !== content,
426
+ omittedBytes: contentBytes - Buffer.byteLength(bounded),
425
427
  };
426
428
  }
427
429
  function diagnosisSource(repo, request) {
@@ -464,7 +466,7 @@ function diagnosisSource(repo, request) {
464
466
  }
465
467
  async function relationsForOwner(graph, repo, file, owner, limit) {
466
468
  if (!owner?.symbol)
467
- return { tests: [], upstream: [], downstream: [] };
469
+ return { tests: [], upstream: [], downstream: [], omitted: 0 };
468
470
  const selector = owner.identity ? { identity: owner.identity, file } : { file };
469
471
  const query = (pattern, options) => graph.query(pattern, owner.symbol ?? "", repo, undefined, limit, pattern === "impact" ? 2 : undefined, undefined, selector, options);
470
472
  const [tests, upstream, downstream] = await Promise.all([
@@ -472,11 +474,17 @@ async function relationsForOwner(graph, repo, file, owner, limit) {
472
474
  query("impact", { direction: "upstream", includeTests: true }),
473
475
  query("impact", { direction: "downstream", includeTests: true }),
474
476
  ]);
475
- return {
477
+ const mapped = {
476
478
  tests: relationRows(tests, limit),
477
479
  upstream: relationRows(upstream, limit),
478
480
  downstream: relationRows(downstream, limit),
479
481
  };
482
+ return {
483
+ ...mapped,
484
+ omitted: Math.max(0, tests.count - mapped.tests.length) +
485
+ Math.max(0, upstream.count - mapped.upstream.length) +
486
+ Math.max(0, downstream.count - mapped.downstream.length),
487
+ };
480
488
  }
481
489
  function ownerEvidence(owner) {
482
490
  if (!owner?.symbol)
@@ -502,10 +510,13 @@ async function diagnoseResolvedReference(graph, repo, reference, file, options)
502
510
  file,
503
511
  package: owningPackage(repo, file),
504
512
  owner: ownerEvidence(owner),
505
- ...relationships,
513
+ tests: relationships.tests,
514
+ upstream: relationships.upstream,
515
+ downstream: relationships.downstream,
506
516
  recentChanges: options.recentCommitLimit > 0 ? recentChanges(repo, file, options.recentCommitLimit) : [],
507
517
  },
508
518
  source: sourceSnippet(repo, file, evidenceLineNumber, options.contextLines, options.remainingContextBytes),
519
+ omittedRelations: relationships.omitted,
509
520
  };
510
521
  }
511
522
  function diagnosisLimitations(inputComplete, referenceCount, contextTruncated, freshnessState) {
@@ -531,17 +542,22 @@ function diagnosisStatus(resolvedCount, unresolvedCount, inputComplete, contextT
531
542
  }
532
543
  export async function diagnoseFailure(graph, repo, request) {
533
544
  const maxDiagnostics = integer("maxDiagnostics", request.maxDiagnostics, DEFAULT_DIAGNOSTICS, 1, MAX_DIAGNOSTICS);
545
+ const diagnosticOffset = integer("diagnosticOffset", request.diagnosticOffset, 0, 0, 1_000_000);
534
546
  const contextLines = integer("contextLines", request.contextLines, 2, 0, 10);
535
547
  const contextByteBudget = integer("contextByteBudget", request.contextByteBudget, DEFAULT_CONTEXT_BYTES, 256, MAX_CONTEXT_BYTES);
536
548
  const relationLimit = integer("relationLimit", request.relationLimit, DEFAULT_RELATIONS, 1, MAX_RELATIONS);
537
549
  const recentCommitLimit = integer("recentCommitLimit", request.recentCommitLimit, 3, 0, 10);
538
550
  const input = diagnosisSource(repo, request);
539
- const references = extractDiagnosticReferences(input.content, maxDiagnostics);
551
+ const detectedReferences = extractDiagnosticReferences(input.content, Number.MAX_SAFE_INTEGER);
552
+ const references = detectedReferences.slice(diagnosticOffset, diagnosticOffset + maxDiagnostics);
553
+ const omittedDiagnostics = Math.max(0, detectedReferences.length - diagnosticOffset - references.length);
540
554
  const diagnostics = [];
541
555
  const unresolved = [];
542
556
  const snippets = [];
543
557
  let contextBytes = 0;
544
558
  let contextTruncated = false;
559
+ let omittedRelations = 0;
560
+ let omittedContextBytes = 0;
545
561
  for (const reference of references) {
546
562
  const resolved = await resolveReferencePath(graph, repo, reference);
547
563
  if (!resolved.file) {
@@ -563,10 +579,28 @@ export async function diagnoseFailure(graph, repo, request) {
563
579
  contextBytes += Buffer.byteLength(resolvedDiagnosis.source.snippet.content);
564
580
  }
565
581
  contextTruncated ||= resolvedDiagnosis.source.truncated;
582
+ omittedRelations += resolvedDiagnosis.omittedRelations;
583
+ omittedContextBytes += resolvedDiagnosis.source.omittedBytes;
566
584
  diagnostics.push(resolvedDiagnosis.diagnostic);
567
585
  }
568
586
  const health = await graph.status(repo, { audit: "cached" });
569
- const status = diagnosisStatus(diagnostics.length, unresolved.length, input.complete, contextTruncated, health.freshness.state);
587
+ const status = diagnosisStatus(diagnostics.length, unresolved.length + omittedDiagnostics, input.complete, contextTruncated, health.freshness.state);
588
+ const omissionReasons = [];
589
+ if (omittedDiagnostics > 0)
590
+ omissionReasons.push("diagnostic-item-budget");
591
+ if (omittedRelations > 0)
592
+ omissionReasons.push("relation-item-budget");
593
+ if (contextTruncated)
594
+ omissionReasons.push("context-byte-budget");
595
+ if (!input.complete)
596
+ omissionReasons.push("input-byte-budget");
597
+ const confidenceReasons = [
598
+ ...(diagnostics.length === 0 ? ["no-safe-source-reference"] : []),
599
+ ...(unresolved.length > 0 ? ["unresolved-source-reference"] : []),
600
+ ...omissionReasons,
601
+ ...(health.freshness.state !== "fresh" ? [`freshness-${health.freshness.state}`] : []),
602
+ ];
603
+ const returnedRelations = diagnostics.reduce((total, diagnostic) => total + diagnostic.tests.length + diagnostic.upstream.length + diagnostic.downstream.length, 0);
570
604
  return {
571
605
  schemaVersion: 1,
572
606
  status,
@@ -585,6 +619,31 @@ export async function diagnoseFailure(graph, repo, request) {
585
619
  snippets,
586
620
  },
587
621
  freshness: health.freshness,
622
+ confidence: {
623
+ level: diagnostics.length === 0 ? "refused" : status === "resolved" ? "exact" : "qualified",
624
+ reasons: confidenceReasons,
625
+ },
626
+ omissions: {
627
+ diagnostics: omittedDiagnostics,
628
+ relations: omittedRelations,
629
+ contextBytes: omittedContextBytes,
630
+ reasons: omissionReasons,
631
+ continuation: omittedDiagnostics > 0 && input.artifactId
632
+ ? { artifactId: input.artifactId, nextDiagnostic: diagnosticOffset + references.length }
633
+ : null,
634
+ },
635
+ telemetry: {
636
+ commandRerun: false,
637
+ referencesDetected: detectedReferences.length,
638
+ resolved: diagnostics.length,
639
+ unresolved: unresolved.length,
640
+ caps: {
641
+ diagnostics: maxDiagnostics,
642
+ relationsPerKind: relationLimit,
643
+ contextBytes: contextByteBudget,
644
+ },
645
+ returned: { diagnostics: diagnostics.length, relations: returnedRelations, contextBytes },
646
+ },
588
647
  limitations: diagnosisLimitations(input.complete, references.length, contextTruncated, health.freshness.state),
589
648
  };
590
649
  }
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { isMap, parseDocument } from "yaml";
4
+ export const KNODIN_LEFTHOOK_COMMAND = "knodin-refresh";
5
+ const MANAGED_COMMENT = "Managed locally by knodin init; repository hook configuration is not modified.";
6
+ const LEFTHOOK_CONFIGS = [
7
+ { main: "lefthook.yml", local: "lefthook-local.yml" },
8
+ { main: "lefthook.yaml", local: "lefthook-local.yaml" },
9
+ { main: ".lefthook.yml", local: ".lefthook-local.yml" },
10
+ { main: ".lefthook.yaml", local: ".lefthook-local.yaml" },
11
+ { main: ".config/lefthook.yml", local: ".config/lefthook-local.yml" },
12
+ { main: ".config/lefthook.yaml", local: ".config/lefthook-local.yaml" },
13
+ ];
14
+ const HOOK_COMMANDS = {
15
+ "post-commit": { run: ".knodin/hooks/manager-dispatch.sh post-commit" },
16
+ "post-checkout": {
17
+ run: ".knodin/hooks/manager-dispatch.sh post-checkout {1} {2} {3}",
18
+ },
19
+ "post-merge": { run: ".knodin/hooks/manager-dispatch.sh post-merge {1}" },
20
+ "post-rewrite": {
21
+ run: ".knodin/hooks/manager-dispatch.sh post-rewrite {1}",
22
+ use_stdin: true,
23
+ },
24
+ };
25
+ export const LEFTHOOK_DISPATCHER = `#!/bin/sh
26
+ # knodin manager-native lifecycle dispatcher. Generated by knodin init.
27
+ set -u
28
+ [ "\${KNODIN_WRAPPER_ACTIVE:-0}" = "1" ] && exit 0
29
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
30
+ [ -n "$REPO_ROOT" ] || exit 0
31
+ BACKGROUND="$REPO_ROOT/.knodin/hooks/background-index.sh"
32
+ [ -x "$BACKGROUND" ] || exit 0
33
+ HOOK="\${1:-}"
34
+ shift || true
35
+ case "$HOOK" in
36
+ post-commit)
37
+ set -- commit
38
+ ;;
39
+ post-checkout)
40
+ [ "$#" -ge 2 ] || exit 0
41
+ set -- checkout "$1" "$2"
42
+ ;;
43
+ post-merge)
44
+ BEFORE="$(git rev-parse ORIG_HEAD 2>/dev/null || true)"
45
+ AFTER="$(git rev-parse HEAD 2>/dev/null || true)"
46
+ [ -n "$BEFORE" ] && [ -n "$AFTER" ] || exit 0
47
+ set -- merge "$BEFORE" "$AFTER"
48
+ ;;
49
+ post-rewrite)
50
+ INPUT="$(mktemp "$REPO_ROOT/.knodin/hooks/rewrite-input.XXXXXX")" || exit 0
51
+ cat > "$INPUT"
52
+ set -- rewrite "$INPUT"
53
+ ;;
54
+ *) exit 0 ;;
55
+ esac
56
+ nohup "$BACKGROUND" "$@" >/dev/null 2>&1 &
57
+ exit 0
58
+ `;
59
+ function lefthookFiles(repo) {
60
+ for (const candidate of LEFTHOOK_CONFIGS) {
61
+ if (fs.existsSync(path.join(repo, candidate.main)))
62
+ return candidate;
63
+ }
64
+ return null;
65
+ }
66
+ function assertRegularOrMissing(target) {
67
+ try {
68
+ const stat = fs.lstatSync(target);
69
+ if (!stat.isFile() || stat.isSymbolicLink())
70
+ throw new Error(`knodin init: refusing non-regular hook-manager config: ${target}`);
71
+ }
72
+ catch (error) {
73
+ if (error.code !== "ENOENT")
74
+ throw error;
75
+ }
76
+ }
77
+ function atomicWrite(target, content, mode) {
78
+ fs.mkdirSync(path.dirname(target), { recursive: true });
79
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
80
+ fs.writeFileSync(temporary, content, { encoding: "utf-8", mode, flag: "wx" });
81
+ fs.renameSync(temporary, target);
82
+ if (mode !== undefined)
83
+ fs.chmodSync(target, mode);
84
+ }
85
+ function ensureMap(document, keys) {
86
+ const existing = document.getIn(keys, true);
87
+ if (existing !== undefined && !isMap(existing))
88
+ throw new Error(`knodin init: cannot merge Lefthook integration because ${keys.join(".")} is not a map`);
89
+ }
90
+ /**
91
+ * Add a local-only Lefthook overlay. The repository-owned main configuration is
92
+ * never edited; an existing local overlay is parsed and merged by key.
93
+ */
94
+ export function installHookManagerIntegration(repoPath) {
95
+ const repo = path.resolve(repoPath);
96
+ const files = lefthookFiles(repo);
97
+ if (!files)
98
+ return null;
99
+ const localPath = path.join(repo, files.local);
100
+ assertRegularOrMissing(localPath);
101
+ const existing = fs.existsSync(localPath) ? fs.readFileSync(localPath, "utf-8") : "";
102
+ const document = parseDocument(existing || "{}\n", { keepSourceTokens: true });
103
+ if (document.errors.length > 0)
104
+ throw new Error(`knodin init: cannot parse ${files.local}: ${document.errors[0]?.message}`);
105
+ if (!isMap(document.contents))
106
+ throw new Error(`knodin init: cannot merge ${files.local}: root must be a map`);
107
+ if (!existing)
108
+ document.commentBefore = MANAGED_COMMENT;
109
+ for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
110
+ ensureMap(document, [hook]);
111
+ ensureMap(document, [hook, "commands"]);
112
+ document.setIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND], command);
113
+ }
114
+ atomicWrite(localPath, document.toString({ lineWidth: 0 }));
115
+ const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
116
+ assertRegularOrMissing(dispatcher);
117
+ atomicWrite(dispatcher, LEFTHOOK_DISPATCHER, 0o755);
118
+ return {
119
+ manager: "lefthook",
120
+ localConfig: files.local,
121
+ dispatcher: ".knodin/hooks/manager-dispatch.sh",
122
+ };
123
+ }
124
+ export function inspectLefthookIntegration(repoPath) {
125
+ const repo = path.resolve(repoPath);
126
+ const files = lefthookFiles(repo);
127
+ if (!files)
128
+ return { configured: false, localConfig: null };
129
+ try {
130
+ const document = parseDocument(fs.readFileSync(path.join(repo, files.local), "utf-8"));
131
+ if (document.errors.length > 0)
132
+ return { configured: false, localConfig: files.local };
133
+ for (const [hook, command] of Object.entries(HOOK_COMMANDS)) {
134
+ const run = document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "run"]);
135
+ if (run !== command.run)
136
+ return { configured: false, localConfig: files.local };
137
+ if ("use_stdin" in command &&
138
+ document.getIn([hook, "commands", KNODIN_LEFTHOOK_COMMAND, "use_stdin"]) !== true)
139
+ return { configured: false, localConfig: files.local };
140
+ }
141
+ const dispatcher = path.join(repo, ".knodin", "hooks", "manager-dispatch.sh");
142
+ const stat = fs.statSync(dispatcher);
143
+ return {
144
+ configured: stat.isFile() &&
145
+ (stat.mode & 0o111) !== 0 &&
146
+ fs.readFileSync(dispatcher, "utf-8") === LEFTHOOK_DISPATCHER,
147
+ localConfig: files.local,
148
+ };
149
+ }
150
+ catch {
151
+ return { configured: false, localConfig: files.local };
152
+ }
153
+ }
154
+ export function isActiveLefthookHook(content) {
155
+ return /\bcall_lefthook\b|\blefthook(?:\s|")/.test(content);
156
+ }