@holmes-lab/holmes-kit 0.18.0 → 0.19.0

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 (56) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +3 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/release-docs.d.ts +27 -0
  5. package/dist/holmes/cli/release-docs.js +68 -0
  6. package/dist/holmes/cpg/arch-observe.d.ts +15 -0
  7. package/dist/holmes/cpg/arch-observe.js +19 -0
  8. package/dist/holmes/cpg/cpg-scanner.d.ts +10 -36
  9. package/dist/holmes/cpg/cpg-scanner.js +27 -3
  10. package/dist/holmes/cpg/cycle-detect.d.ts +87 -0
  11. package/dist/holmes/cpg/cycle-detect.js +251 -0
  12. package/dist/holmes/cpg/scan-cache.d.ts +1 -1
  13. package/dist/holmes/cpg/scanned-file.d.ts +36 -0
  14. package/dist/holmes/cpg/scanned-file.js +2 -0
  15. package/dist/holmes/governance/constitution.d.ts +20 -0
  16. package/dist/holmes/governance/constitution.js +17 -0
  17. package/dist/holmes/governance/ledger-store.d.ts +9 -0
  18. package/dist/holmes/governance/ledger-store.js +47 -0
  19. package/dist/holmes/governance/provenance-chain.d.ts +16 -1
  20. package/dist/holmes/governance/provenance-chain.js +5 -3
  21. package/dist/holmes/hooks/pre-tool-use.js +3 -1
  22. package/dist/holmes/hooks/stop.d.ts +14 -0
  23. package/dist/holmes/hooks/stop.js +73 -0
  24. package/dist/holmes/mcp/defuse-bound.d.ts +1 -0
  25. package/dist/holmes/mcp/defuse-bound.js +8 -0
  26. package/dist/holmes/mcp/handlers.d.ts +5 -0
  27. package/dist/holmes/mcp/handlers.js +103 -4
  28. package/dist/holmes/mcp/history-admission.d.ts +15 -0
  29. package/dist/holmes/mcp/history-admission.js +37 -0
  30. package/dist/holmes/mcp/maintenance-analyze.d.ts +8 -0
  31. package/dist/holmes/mcp/maintenance-analyze.js +44 -8
  32. package/dist/holmes/review/evaluation-metrics.d.ts +6 -0
  33. package/dist/holmes/review/evaluation-metrics.js +18 -1
  34. package/dist/holmes/review/paired-power.d.ts +14 -0
  35. package/dist/holmes/review/paired-power.js +57 -0
  36. package/dist/holmes/review/replay-corpus.d.ts +11 -0
  37. package/dist/holmes/review/replay-corpus.js +34 -0
  38. package/dist/holmes/review/run-replay.js +60 -4
  39. package/dist/holmes/review/symbol-truth.d.ts +14 -0
  40. package/dist/holmes/review/symbol-truth.js +23 -0
  41. package/dist/holmes/rtm/defuse-symbols.d.ts +17 -0
  42. package/dist/holmes/rtm/defuse-symbols.js +91 -0
  43. package/dist/holmes/rtm/incremental.js +5 -0
  44. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  45. package/dist/holmes/rtm/rtm-builder.js +32 -5
  46. package/dist/holmes/rtm/rtm-graph.d.ts +11 -0
  47. package/dist/holmes/rtm/rtm-graph.js +13 -0
  48. package/dist/holmes/spec/legacy-fields.d.ts +2 -0
  49. package/dist/holmes/spec/legacy-fields.js +9 -0
  50. package/dist/holmes/spec/legacy-format.d.ts +1 -1
  51. package/dist/holmes/spec/legacy-format.js +4 -1
  52. package/dist/holmes/spec/spec-parser.js +5 -3
  53. package/package.json +1 -1
  54. package/playbooks/author-slice/PLAYBOOK.md +14 -0
  55. package/playbooks/publish/PLAYBOOK.md +32 -0
  56. package/playbooks/tdd-slice/PLAYBOOK.md +14 -0
@@ -0,0 +1,14 @@
1
+ export interface SymbolRange {
2
+ name: string;
3
+ startLine: number;
4
+ endLine: number;
5
+ }
6
+ /**
7
+ * The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
8
+ *
9
+ * A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
10
+ * and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
11
+ * unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
12
+ * to files.
13
+ */
14
+ export declare function symbolsTouched(symbols: readonly SymbolRange[], changedLines: readonly number[]): string[];
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.symbolsTouched = symbolsTouched;
4
+ /**
5
+ * The symbols a commit's changed lines fall inside, in symbol-list order, deduped.
6
+ *
7
+ * A change that lands outside every symbol (an import line, a top-level constant) yields nothing —
8
+ * and a case with no symbol truth drops OUT of the symbol denominator rather than scoring zero. An
9
+ * unanswerable case is not a failed one, which is the same rule `evaluationMetrics` already applies
10
+ * to files.
11
+ */
12
+ function symbolsTouched(symbols, changedLines) {
13
+ if (symbols.length === 0 || changedLines.length === 0)
14
+ return [];
15
+ const out = [];
16
+ for (const s of symbols) {
17
+ // Both ends inclusive: the signature line and the closing line are part of the symbol.
18
+ if (changedLines.some((line) => line >= s.startLine && line <= s.endLine) && !out.includes(s.name)) {
19
+ out.push(s.name);
20
+ }
21
+ }
22
+ return out;
23
+ }
@@ -0,0 +1,17 @@
1
+ import type { DataFlowFacts } from '../cpg/language-parser';
2
+ /**
3
+ * Order a file's function names by how close they sit, in def-use terms, to what the request says.
4
+ *
5
+ * Three tiers, stable within each so the result is deterministic (the downstream lists are
6
+ * position-indexed): functions the request names directly, functions one def-use step from those,
7
+ * then everything else in its original order. A term matching nothing leaves the order untouched —
8
+ * an empty first tier must not shuffle the list.
9
+ */
10
+ export declare function rankSymbolsByDefUse(facts: DataFlowFacts, terms: readonly string[], order: readonly string[]): string[];
11
+ /**
12
+ * Append what def-use suggested behind what the lexical layer matched.
13
+ *
14
+ * Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
15
+ * a second signal rides behind the first rather than replacing it.
16
+ */
17
+ export declare function enrichCandidateSymbols(lexical: readonly string[], suggested: readonly string[]): string[];
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.rankSymbolsByDefUse = rankSymbolsByDefUse;
4
+ exports.enrichCandidateSymbols = enrichCandidateSymbols;
5
+ /** Names a function mentions anywhere the walk recovered them: params, defs, and call arguments. */
6
+ function surfaceOf(facts) {
7
+ const out = new Map();
8
+ const push = (fn, ...words) => {
9
+ const bucket = out.get(fn) ?? out.set(fn, []).get(fn);
10
+ for (const w of words)
11
+ if (w !== '' && !bucket.includes(w))
12
+ bucket.push(w);
13
+ };
14
+ for (const p of facts.params)
15
+ push(p.fn, p.name);
16
+ for (const d of facts.defs)
17
+ push(d.fn, d.name, ...d.expr.refs, ...d.expr.callees);
18
+ for (const c of facts.calls) {
19
+ push(c.fn, c.callee);
20
+ for (const a of c.args)
21
+ push(c.fn, ...a.refs, ...a.callees);
22
+ }
23
+ for (const r of facts.returns)
24
+ push(r.fn, ...r.expr.refs, ...r.expr.callees);
25
+ return out;
26
+ }
27
+ /**
28
+ * Order a file's function names by how close they sit, in def-use terms, to what the request says.
29
+ *
30
+ * Three tiers, stable within each so the result is deterministic (the downstream lists are
31
+ * position-indexed): functions the request names directly, functions one def-use step from those,
32
+ * then everything else in its original order. A term matching nothing leaves the order untouched —
33
+ * an empty first tier must not shuffle the list.
34
+ */
35
+ function rankSymbolsByDefUse(facts, terms, order) {
36
+ if (order.length === 0)
37
+ return [];
38
+ const lowered = terms.map((t) => t.toLowerCase()).filter((t) => t !== '');
39
+ if (lowered.length === 0)
40
+ return [...order];
41
+ const surface = surfaceOf(facts);
42
+ // A function is "named" when a request term appears in its own name or in any identifier its
43
+ // body touches. Substring, not equality: `config` has to reach `configPath`.
44
+ const names = (fn) => [fn, ...(surface.get(fn) ?? [])];
45
+ const direct = new Set();
46
+ for (const fn of new Set([...order, ...surface.keys()])) {
47
+ if (names(fn).some((n) => lowered.some((t) => n.toLowerCase().includes(t))))
48
+ direct.add(fn);
49
+ }
50
+ if (direct.size === 0)
51
+ return [...order];
52
+ // One def-use step: a function that reads what a direct one defined, or that sits on either end
53
+ // of a call with it. Call adjacency counts BOTH ways — the caller of a named function is as
54
+ // relevant as its callee, and a one-directional rule would silently favour one.
55
+ const definedByDirect = new Set();
56
+ for (const d of facts.defs)
57
+ if (direct.has(d.fn))
58
+ definedByDirect.add(d.name);
59
+ const near = new Set();
60
+ const mark = (fn) => { if (!direct.has(fn))
61
+ near.add(fn); };
62
+ for (const d of facts.defs)
63
+ if (d.expr.refs.some((r) => definedByDirect.has(r)))
64
+ mark(d.fn);
65
+ for (const c of facts.calls) {
66
+ if (c.args.some((a) => a.refs.some((r) => definedByDirect.has(r))))
67
+ mark(c.fn);
68
+ if (direct.has(c.fn))
69
+ mark(c.callee);
70
+ if (direct.has(c.callee))
71
+ mark(c.fn);
72
+ }
73
+ for (const r of facts.returns)
74
+ if (r.expr.refs.some((x) => definedByDirect.has(x)))
75
+ mark(r.fn);
76
+ const tier = (fn) => (direct.has(fn) ? 0 : near.has(fn) ? 1 : 2);
77
+ return [...order].sort((a, b) => tier(a) - tier(b) || order.indexOf(a) - order.indexOf(b));
78
+ }
79
+ /**
80
+ * Append what def-use suggested behind what the lexical layer matched.
81
+ *
82
+ * Never in front: this repository has already measured a re-ranker deleting the lexical answer, so
83
+ * a second signal rides behind the first rather than replacing it.
84
+ */
85
+ function enrichCandidateSymbols(lexical, suggested) {
86
+ const out = [...lexical];
87
+ for (const s of suggested)
88
+ if (!out.includes(s))
89
+ out.push(s);
90
+ return out;
91
+ }
@@ -56,7 +56,12 @@ function applyIncremental(graph, changes, ctx) {
56
56
  }
57
57
  if (!ctx.allScanned)
58
58
  return { callEdgesResolved: false };
59
+ // @implements A-SPEC-573.5 — `calls_ambiguous` is rebuilt by the same pass, so it must be cleared
60
+ // by the same pass. Missing it left a stale candidate edge behind when an ambiguity RESOLVED
61
+ // (the second definer was deleted), and incremental stopped converging with a full rebuild —
62
+ // caught by A-SPEC-280's convergence property, which is exactly what it is for.
59
63
  graph.removeEdgesByRel('calls');
64
+ graph.removeEdgesByRel('calls_ambiguous');
60
65
  (0, rtm_builder_1.addCallEdges)(ctx.allScanned(), graph, ctx.buildOptions);
61
66
  return { callEdgesResolved: true };
62
67
  }
@@ -16,6 +16,14 @@ import { ScannedFile } from '../cpg/cpg-scanner';
16
16
  */
17
17
  export interface ResolutionReport {
18
18
  resolved: number;
19
+ /**
20
+ * @implements A-SPEC-573.5
21
+ * Candidate edges kept for a call whose name is defined in more than one place. Census on this
22
+ * repository (2026-09-08): 102 qualified-name and 590 last-segment collisions were being dropped
23
+ * against 3,067 resolved edges — 22.6% of the call graph, at a mean fan-out of 2.29. They ride on
24
+ * the `calls_ambiguous` relation, which every certainty-requiring reader filters out by name.
25
+ */
26
+ ambiguousEdges: number;
19
27
  /** Callee defined in several files: precision-over-recall refused to guess. A real miss. */
20
28
  ambiguous: number;
21
29
  /** Callee defined nowhere in the scan — usually an external package or runtime builtin. */
@@ -139,7 +139,7 @@ function buildRtm(specs, scanned, graph, opts) {
139
139
  // on a 56k-insert graph (216 ms -> 173 ms with a transaction; 88 -> 52 ms with cached statements).
140
140
  // It also makes a failed build atomic — no half-graph that could be mistaken for a complete one.
141
141
  let resolution = {
142
- resolved: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
142
+ resolved: 0, ambiguousEdges: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
143
143
  };
144
144
  graph.transaction(() => {
145
145
  // Add spec nodes and dependencies
@@ -185,7 +185,7 @@ function buildRtm(specs, scanned, graph, opts) {
185
185
  */
186
186
  function addCallEdges(scanned, graph, opts) {
187
187
  const report = {
188
- resolved: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
188
+ resolved: 0, ambiguousEdges: 0, ambiguous: 0, unknownTarget: 0, callerNotNamed: 0, selfReference: 0, moduleScoped: 0,
189
189
  };
190
190
  // @implements A-SPEC-300
191
191
  // Indexes are built PER LANGUAGE FAMILY. Resolution used to look across the whole scan, so a Java
@@ -247,11 +247,37 @@ function addCallEdges(scanned, graph, opts) {
247
247
  }
248
248
  const family = familyOf(f.sourcePath);
249
249
  const table = definedIn.get(family);
250
+ // @implements A-SPEC-573.5 — an ambiguous target is a CANDIDATE SET, not a dead end. Both
251
+ // shapes of ambiguity keep every candidate on `calls_ambiguous`; `calls` still means "one
252
+ // place, certain", so nothing that reads certainty changes. Emitting nothing was buying
253
+ // precision at the cost of 22.6% of the call graph.
254
+ const ambiguousCandidates = (qn, paths) => {
255
+ for (const p of paths) {
256
+ const to = `CODE:${qn}@${p}`;
257
+ if (to === fromId)
258
+ continue; // self-recursion carries no impact, ambiguous or not
259
+ graph.addEdge(fromId, to, 'calls_ambiguous', f.sourcePath, fact(opts, `${f.sourcePath}`, 'name-resolution', null));
260
+ report.ambiguousEdges++;
261
+ }
262
+ };
250
263
  let target = null;
251
264
  if (table?.has(e.to))
252
265
  target = { qn: e.to, paths: table.get(e.to) };
253
- else
254
- target = byLastSegment.get(family)?.get(e.to) ?? null;
266
+ else {
267
+ const byLast = byLastSegment.get(family);
268
+ const hit = byLast?.get(e.to);
269
+ if (hit === null) {
270
+ // The last segment is owned by several qualified names — keep every owner as a candidate.
271
+ for (const [qn, paths] of table ?? []) {
272
+ const last = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn;
273
+ if (last === e.to)
274
+ ambiguousCandidates(qn, paths);
275
+ }
276
+ report.unknownTarget++;
277
+ continue;
278
+ }
279
+ target = hit ?? null;
280
+ }
255
281
  if (!target) {
256
282
  report.unknownTarget++;
257
283
  continue;
@@ -259,9 +285,10 @@ function addCallEdges(scanned, graph, opts) {
259
285
  const sameFile = target.paths.includes(f.sourcePath);
260
286
  const path = sameFile ? f.sourcePath : (target.paths.length === 1 ? target.paths[0] : null);
261
287
  if (!path) {
288
+ ambiguousCandidates(target.qn, target.paths);
262
289
  report.ambiguous++;
263
290
  continue;
264
- } // defined in several files — emit nothing
291
+ }
265
292
  const toId = `CODE:${target.qn}@${path}`;
266
293
  if (toId === fromId) {
267
294
  report.selfReference++;
@@ -136,6 +136,17 @@ export declare class RtmGraph {
136
136
  */
137
137
  maxCalleeInDegree?: number;
138
138
  }): string[];
139
+ /**
140
+ * Every resolved FILE->FILE `imports` edge, as repo-relative path pairs.
141
+ *
142
+ * Exists because cycle detection needs the whole import graph at once, and the alternative —
143
+ * parsing `dumpCanonical()` — builds a multi-megabyte string on every call to answer a question
144
+ * one query answers.
145
+ */
146
+ importEdges(): Array<{
147
+ from: string;
148
+ to: string;
149
+ }>;
139
150
  /**
140
151
  * @implements A-SPEC-289
141
152
  * Repo-relative paths of the files that import this one, via resolved FILE->FILE `imports` edges.
@@ -224,6 +224,19 @@ class RtmGraph {
224
224
  : this.callees(id).filter((callee) => this.callerCount(callee) <= limit);
225
225
  return [...new Set([...this.impactSourcesOf(id), ...downstream])].sort();
226
226
  }
227
+ // @implements A-SPEC-574.2
228
+ /**
229
+ * Every resolved FILE->FILE `imports` edge, as repo-relative path pairs.
230
+ *
231
+ * Exists because cycle detection needs the whole import graph at once, and the alternative —
232
+ * parsing `dumpCanonical()` — builds a multi-megabyte string on every call to answer a question
233
+ * one query answers.
234
+ */
235
+ importEdges() {
236
+ return this.db.prepare("SELECT DISTINCT src, dst FROM edges WHERE rel='imports' ORDER BY src ASC, dst ASC")
237
+ .all()
238
+ .map((r) => ({ from: r.src.replace(/^FILE:/, ''), to: r.dst.replace(/^FILE:/, '') }));
239
+ }
227
240
  /**
228
241
  * @implements A-SPEC-289
229
242
  * Repo-relative paths of the files that import this one, via resolved FILE->FILE `imports` edges.
@@ -0,0 +1,2 @@
1
+ /** Frontmatter key that carries a pre-migration `depends_on` value. */
2
+ export declare const LEGACY_DEPENDS_FIELD = "legacy_depends_on";
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LEGACY_DEPENDS_FIELD = void 0;
4
+ // @implements A-SPEC-574.3
5
+ // One constant, in a module neither side owns. `spec-parser` needed the field name and `legacy-format`
6
+ // needed the parser's `Spec` type, so each imported the other — a cycle held up by a single string.
7
+ // Shared things belong to a third module; that is the whole rule.
8
+ /** Frontmatter key that carries a pre-migration `depends_on` value. */
9
+ exports.LEGACY_DEPENDS_FIELD = 'legacy_depends_on';
@@ -48,7 +48,7 @@ export declare const LEGACY_STATUS_FIELD = "legacy_status";
48
48
  * computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
49
49
  * preservation leaves the judgement to a person who can actually make it.
50
50
  */
51
- export declare const LEGACY_DEPENDS_FIELD = "legacy_depends_on";
51
+ export { LEGACY_DEPENDS_FIELD } from './legacy-fields';
52
52
  /**
53
53
  * Classify a document by FORMAT, not by validity.
54
54
  *
@@ -21,7 +21,10 @@ exports.LEGACY_STATUS_FIELD = 'legacy_status';
21
21
  * computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
22
22
  * preservation leaves the judgement to a person who can actually make it.
23
23
  */
24
- exports.LEGACY_DEPENDS_FIELD = 'legacy_depends_on';
24
+ // @implements A-SPEC-574.3 moved to `legacy-fields` and re-exported, so existing importers
25
+ // keep working while the cycle it created is gone.
26
+ var legacy_fields_1 = require("./legacy-fields");
27
+ Object.defineProperty(exports, "LEGACY_DEPENDS_FIELD", { enumerable: true, get: function () { return legacy_fields_1.LEGACY_DEPENDS_FIELD; } });
25
28
  /**
26
29
  * Classify a document by FORMAT, not by validity.
27
30
  *
@@ -37,7 +37,9 @@ exports.parseDependsOn = parseDependsOn;
37
37
  exports.parseSpec = parseSpec;
38
38
  exports.serializeSpec = serializeSpec;
39
39
  const yaml = __importStar(require("js-yaml"));
40
- const legacy_format_1 = require("./legacy-format");
40
+ // @implements A-SPEC-574.3 — from the shared module, not from `legacy-format`, which imports
41
+ // this file's `Spec` type. That pair was a cycle over one string constant.
42
+ const legacy_fields_1 = require("./legacy-fields");
41
43
  /**
42
44
  * Read `depends_on` in every spelling the corpus contains, keeping what cannot be represented.
43
45
  *
@@ -121,8 +123,8 @@ function parseSpec(input) {
121
123
  const deps = parseDependsOn(fm.depends_on);
122
124
  // @implements A-SPEC-221 — never overwrite an existing preservation: a second round trip must not
123
125
  // chew up what the first one saved, the same rule `legacy_status` follows.
124
- if (deps.legacy !== null && fm[legacy_format_1.LEGACY_DEPENDS_FIELD] === undefined) {
125
- fm[legacy_format_1.LEGACY_DEPENDS_FIELD] = deps.legacy;
126
+ if (deps.legacy !== null && fm[legacy_fields_1.LEGACY_DEPENDS_FIELD] === undefined) {
127
+ fm[legacy_fields_1.LEGACY_DEPENDS_FIELD] = deps.legacy;
126
128
  }
127
129
  return {
128
130
  id: String(fm.id ?? ''),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "@implements A-SPEC-209",
3
3
  "name": "@holmes-lab/holmes-kit",
4
- "version": "0.18.0",
4
+ "version": "0.19.0",
5
5
  "description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
6
6
  "main": "dist/holmes/mcp/server.js",
7
7
  "types": "dist/holmes/mcp/server.d.ts",
@@ -136,3 +136,17 @@ T-SPEC 필수 섹션은 4분면 그대로다: **Normal · Corner · Negative ·
136
136
  번호가 A-SPEC과 T-SPEC에 한정되는 것, 그리고 스펙 폴더 밑 `.ts`가 author 게이트로 강등되지 않는다는 사실. 번호
137
137
  상속 관습은 엔진이 강제하지 않으므로 테스트도 고정하지 않는다 — 관습은 관습이라 말한다. 변이
138
138
  검사로 판별력을 증명한 뒤 신뢰한다.
139
+
140
+ ### 순환 의존을 만들지 않는다
141
+
142
+ 두 모듈이 서로를 import 하면 순환이다. **설계 단계에서 피하는 것이 유일하게 값싼 시점이다** ——
143
+ 코드가 쓰인 뒤에는 공유 타입 추출이나 지연 `require()` 워크어라운드로만 풀 수 있고, 후자는
144
+ 순환을 숨길 뿐 없애지 않는다.
145
+
146
+ - **공유 타입은 별도 모듈로 뺀다.** A 와 B 가 같은 타입을 필요로 하면 그 타입은 A 도 B 도 아닌
147
+ 세 번째 모듈에 있어야 한다.
148
+ - **타입만 필요하면 `import type` 을 쓴다.** TypeScript 가 방출에서 지우므로 런타임 순환이 되지
149
+ 않는다. 값 import 로 두면 타입만 쓰면서도 순환을 만든다.
150
+ - **지연 `require()` 로 순환을 우회하지 않는다.** 그것은 수리가 아니라 청구서 이연이다.
151
+
152
+ 이 저장소는 **스펙 그래프의 무순환을 ART-2 로 집행**한다. 코드 그래프도 같은 기준을 향한다.
@@ -96,6 +96,38 @@ npm publish --access public
96
96
 
97
97
  ---
98
98
 
99
+ ### 6단계: 외부 문서 표면 (External Docs Reach) — 발행이 닿는 곳까지 정직하게
100
+
101
+ npm 만 갱신하고 끝나면, 사람들이 실제로 읽는 문서는 낡은 채로 남는다. **사고 이력**: 이 저장소에는
102
+ GitHub 리모트가 없어(origin 이 로컬 gitea) 0.16.0·0.17.0·0.18.0 **세 릴리스 동안** GitHub README 가
103
+ 한 번도 갱신되지 않았고, `package.json` 의 `homepage` 가 바로 그 문서를 가리킨다.
104
+
105
+ **발행이 성공한 뒤에만** 실행한다 — 실패한 릴리스의 문서를 최신이라고 주장하지 않는다.
106
+
107
+ 1. **대상 파생 (하드코딩 금지)**: `package.json` 의 `repository` 에서 `<owner>/<repo>` 를 얻는다
108
+ (`repoTargetFrom`). 이 플레이북은 **소비 프로젝트에도 설치**되므로 특정 저장소를 박아 두면 남의
109
+ 릴리스가 그 저장소를 덮어쓴다. 파생 실패 → **SKIP(사유: `repository` 필드 없음/비-GitHub)**.
110
+ 2. **덮어쓸 것을 먼저 본다**: `gh api repos/<owner>/<repo>/contents/README.md` 로 원격을 읽어
111
+ 로컬과의 차이를 **보고**한다. 원격이 분기했다면 동기화는 남의 편집을 지우는 행위다 — 사람이
112
+ 알고 결정해야 한다.
113
+ 3. **동기화**: 로컬 `README.md` 를 `gh api --method PUT` 으로 올린다(`sha` 는 2 에서 읽은 값).
114
+ 4. **재조회 검증**: 다시 읽어 로컬과 일치하는지 확인한다. **쓴 것과 남은 것은 다를 수 있다** —
115
+ 검증 없는 "갱신했다"는 주장이지 사실이 아니다.
116
+ 5. **profile README drift 감지 (자동 수정 금지)**: `<owner>/.github` 의 `profile/README.md` 를 읽어
117
+ `profileDriftFindings` 로 주장-현실 불일치를 **보고만** 한다. 포지셔닝 문안은 오너 결정이며
118
+ 릴리스 절차가 정할 것이 아니다.
119
+ 6. **SKIP 은 값이지 침묵이 아니다**: `gh` 미설치 / 미인증(`gh auth status` 실패) / 권한 없음 /
120
+ `repository` 부재 — 각각 **무엇을 하지 않았는지 명시**하고 다음으로 간다. 소비 프로젝트의 발행을
121
+ 우리 편의로 막지 않는다. 다만 **조용한 성공은 금지** — 아무 말 없이 넘어가면 그것은 3단계
122
+ 전부를 한 것처럼 읽힌다.
123
+
124
+ > [!CAUTION]
125
+ > 2.5단계가 로컬 문서에 적용하는 규율("drift 도 누락도 거짓 주장이다")은 **외부 표면에도** 적용된다.
126
+ > 다른 점은 하나뿐이다: repo README 는 로컬의 사본이라 **동기화**하고, profile README 는 독립
127
+ > 문서라 **감지·보고**한다. 무엇이 정본인가가 처방을 정한다.
128
+
129
+ ---
130
+
99
131
  ## 플레이북 트리거 조건
100
132
  - "npm publish"
101
133
  - "release to npm"
@@ -85,3 +85,17 @@ holmes는 기계적으로 판별한다: `red-error`로는 red→green 시퀀스
85
85
 
86
86
  `src/holmes/playbooks/tdd-slice.test.ts` — 이 스킬이 집행 태그(ART-1/4/8)와 red-assertion/red-error
87
87
  구별을 담고, 설치기가 이를 발견함을 고정한다. 상시 스위트 포함.
88
+
89
+ ### 순환 의존을 만들지 않는다
90
+
91
+ 두 모듈이 서로를 import 하면 순환이다. **설계 단계에서 피하는 것이 유일하게 값싼 시점이다** ——
92
+ 코드가 쓰인 뒤에는 공유 타입 추출이나 지연 `require()` 워크어라운드로만 풀 수 있고, 후자는
93
+ 순환을 숨길 뿐 없애지 않는다.
94
+
95
+ - **공유 타입은 별도 모듈로 뺀다.** A 와 B 가 같은 타입을 필요로 하면 그 타입은 A 도 B 도 아닌
96
+ 세 번째 모듈에 있어야 한다.
97
+ - **타입만 필요하면 `import type` 을 쓴다.** TypeScript 가 방출에서 지우므로 런타임 순환이 되지
98
+ 않는다. 값 import 로 두면 타입만 쓰면서도 순환을 만든다.
99
+ - **지연 `require()` 로 순환을 우회하지 않는다.** 그것은 수리가 아니라 청구서 이연이다.
100
+
101
+ 이 저장소는 **스펙 그래프의 무순환을 ART-2 로 집행**한다. 코드 그래프도 같은 기준을 향한다.