@remnic/core 9.3.689 → 9.3.690

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 (158) hide show
  1. package/dist/access-boundary.d.ts +5 -4
  2. package/dist/access-boundary.js +10 -9
  3. package/dist/access-cli.js +86 -22
  4. package/dist/access-cli.js.map +1 -1
  5. package/dist/access-http.d.ts +4 -3
  6. package/dist/access-http.js +13 -12
  7. package/dist/access-mcp.d.ts +11 -3
  8. package/dist/access-mcp.js +12 -11
  9. package/dist/access-operations.d.ts +11 -5
  10. package/dist/access-operations.js +13 -10
  11. package/dist/{access-service-DmCHJ4cH.d.ts → access-service-Dujr3MTm.d.ts} +62 -2
  12. package/dist/access-service.d.ts +4 -3
  13. package/dist/access-service.js +9 -8
  14. package/dist/access-surface-catalog.d.ts +4 -3
  15. package/dist/access-surface-catalog.js +2 -0
  16. package/dist/access-surface-catalog.js.map +1 -1
  17. package/dist/bootstrap.d.ts +3 -2
  18. package/dist/briefing.d.ts +1 -0
  19. package/dist/briefing.js +3 -2
  20. package/dist/buffer.d.ts +1 -0
  21. package/dist/{catalog-COqWZlZ6.d.ts → catalog-CKPtJ114.d.ts} +1 -1
  22. package/dist/causal-consolidation.js +4 -3
  23. package/dist/causal-consolidation.js.map +1 -1
  24. package/dist/{chunk-B4XVLHJA.js → chunk-2N6UNJSX.js} +2 -2
  25. package/dist/{chunk-OWFY6NGQ.js → chunk-46URPRE6.js} +2 -2
  26. package/dist/chunk-46URPRE6.js.map +1 -0
  27. package/dist/{chunk-4N3TFFPH.js → chunk-54PVJDO5.js} +2 -2
  28. package/dist/{chunk-PH3HOKYW.js → chunk-67MMWC74.js} +2 -2
  29. package/dist/{chunk-STOEE37X.js → chunk-BKAMHZYR.js} +2 -2
  30. package/dist/{chunk-JZBFL7RI.js → chunk-CE6CBRCV.js} +429 -2237
  31. package/dist/chunk-CE6CBRCV.js.map +1 -0
  32. package/dist/{chunk-UHUZXWDX.js → chunk-CP2NZQLT.js} +15 -4
  33. package/dist/chunk-CP2NZQLT.js.map +1 -0
  34. package/dist/{chunk-BLIWOONZ.js → chunk-CUNTLEJP.js} +4 -12
  35. package/dist/chunk-CUNTLEJP.js.map +1 -0
  36. package/dist/{chunk-W63OY3J7.js → chunk-CWE74HRG.js} +3 -3
  37. package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
  38. package/dist/{chunk-SAEZIIID.js → chunk-LXIEXSHU.js} +2 -2
  39. package/dist/{chunk-Z2M6YTAJ.js → chunk-NSVXK7M5.js} +33 -4
  40. package/dist/chunk-NSVXK7M5.js.map +1 -0
  41. package/dist/{chunk-UXLZOVCN.js → chunk-OMKUJTVJ.js} +37 -5
  42. package/dist/chunk-OMKUJTVJ.js.map +1 -0
  43. package/dist/{chunk-GS55WYRL.js → chunk-PHZKALOE.js} +2 -2
  44. package/dist/{chunk-6O6A6YUO.js → chunk-RGNEARXW.js} +3 -3
  45. package/dist/{chunk-4FE2K57M.js → chunk-RTFAZOIR.js} +2 -2
  46. package/dist/{chunk-UTYBJR7M.js → chunk-SB6CQTKP.js} +2 -2
  47. package/dist/chunk-SVOZFLIQ.js +16 -0
  48. package/dist/chunk-SVOZFLIQ.js.map +1 -0
  49. package/dist/{chunk-JPETDXED.js → chunk-TYF3D4MS.js} +2 -2
  50. package/dist/{chunk-WIHPNY65.js → chunk-UD5OKH4J.js} +2 -2
  51. package/dist/{chunk-QANVLERJ.js → chunk-UPIBE2DK.js} +2 -2
  52. package/dist/{chunk-RHXFYIHA.js → chunk-WROKD3XC.js} +33 -18
  53. package/dist/chunk-WROKD3XC.js.map +1 -0
  54. package/dist/{chunk-2SNKUSQC.js → chunk-X5J3JZR3.js} +4 -4
  55. package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
  56. package/dist/{chunk-QSQW54U5.js → chunk-XKUKJIOY.js} +30 -7
  57. package/dist/chunk-XKUKJIOY.js.map +1 -0
  58. package/dist/{chunk-XU7363OX.js → chunk-Z7KILAOU.js} +603 -9
  59. package/dist/chunk-Z7KILAOU.js.map +1 -0
  60. package/dist/chunk-ZU7N3S6V.js +2190 -0
  61. package/dist/chunk-ZU7N3S6V.js.map +1 -0
  62. package/dist/{chunk-T422SYM6.js → chunk-ZUDM75KG.js} +5 -5
  63. package/dist/{cli-D8nZ2MPH.d.ts → cli-BkDp6WNi.d.ts} +2 -2
  64. package/dist/cli.d.ts +5 -4
  65. package/dist/cli.js +25 -23
  66. package/dist/compounding/engine.d.ts +1 -0
  67. package/dist/compounding/engine.js +3 -2
  68. package/dist/connectors/codex-materialize-runner.js +3 -2
  69. package/dist/connectors/index.js +3 -2
  70. package/dist/consolidation-provenance-check.d.ts +1 -0
  71. package/dist/consolidation-undo.d.ts +1 -0
  72. package/dist/contradiction/index.d.ts +1 -0
  73. package/dist/conversation-index/backend.js +2 -2
  74. package/dist/entity-retrieval.d.ts +1 -0
  75. package/dist/entity-retrieval.js +3 -2
  76. package/dist/explicit-capture.d.ts +3 -2
  77. package/dist/index.d.ts +6 -5
  78. package/dist/index.js +34 -31
  79. package/dist/index.js.map +1 -1
  80. package/dist/lcm/engine.js +2 -2
  81. package/dist/lcm/index.js +2 -2
  82. package/dist/maintenance/memory-governance.js +3 -2
  83. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -2
  84. package/dist/maintenance/rebuild-memory-projection.js +4 -3
  85. package/dist/mcp-memory-inspector-app.d.ts +4 -3
  86. package/dist/memory-worth-outcomes.d.ts +1 -0
  87. package/dist/namespaces/migrate.d.ts +2 -1
  88. package/dist/namespaces/migrate.js +8 -7
  89. package/dist/namespaces/search.js +4 -4
  90. package/dist/namespaces/storage.d.ts +2 -1
  91. package/dist/namespaces/storage.js +3 -2
  92. package/dist/operator-toolkit.d.ts +1 -0
  93. package/dist/operator-toolkit.js +11 -9
  94. package/dist/{orchestrator-CA6ouzBn.d.ts → orchestrator-B7ixmUkP.d.ts} +145 -1
  95. package/dist/orchestrator.d.ts +3 -2
  96. package/dist/orchestrator.js +18 -16
  97. package/dist/schemas.d.ts +10 -10
  98. package/dist/search/factory.js +3 -3
  99. package/dist/search/index.js +3 -3
  100. package/dist/semantic-consolidation.js +4 -3
  101. package/dist/semantic-rule-promotion.js +3 -2
  102. package/dist/semantic-rule-verifier.js +3 -2
  103. package/dist/storage.d.ts +3 -27
  104. package/dist/storage.js +5 -3
  105. package/dist/structured-attributes.d.ts +29 -0
  106. package/dist/structured-attributes.js +8 -0
  107. package/dist/structured-attributes.js.map +1 -0
  108. package/dist/temporal-supersession.d.ts +1 -0
  109. package/dist/tier-migration.d.ts +1 -0
  110. package/dist/verified-recall.js +3 -2
  111. package/package.json +2 -2
  112. package/src/access-boundary.ts +2 -1
  113. package/src/access-cli.test.ts +40 -0
  114. package/src/access-cli.ts +90 -2
  115. package/src/access-http.ts +35 -6
  116. package/src/access-mcp.ts +34 -0
  117. package/src/access-operations.ts +45 -0
  118. package/src/access-service.ts +60 -0
  119. package/src/access-surface-catalog.test.ts +1 -1
  120. package/src/access-surface-catalog.ts +2 -0
  121. package/src/cli.ts +18 -0
  122. package/src/coding/architecture-card.test.ts +544 -0
  123. package/src/coding/architecture-card.ts +687 -0
  124. package/src/coding/architecture-surfaces.test.ts +579 -0
  125. package/src/coding/architecture-surfaces.ts +457 -0
  126. package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
  127. package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
  128. package/src/maintenance/namespace-planner.ts +74 -16
  129. package/src/operator-toolkit.ts +25 -0
  130. package/src/orchestrator.ts +144 -0
  131. package/src/storage.ts +6 -20
  132. package/src/structured-attributes.ts +39 -0
  133. package/dist/chunk-BLIWOONZ.js.map +0 -1
  134. package/dist/chunk-JZBFL7RI.js.map +0 -1
  135. package/dist/chunk-OWFY6NGQ.js.map +0 -1
  136. package/dist/chunk-QSQW54U5.js.map +0 -1
  137. package/dist/chunk-RHXFYIHA.js.map +0 -1
  138. package/dist/chunk-UHUZXWDX.js.map +0 -1
  139. package/dist/chunk-UXLZOVCN.js.map +0 -1
  140. package/dist/chunk-XU7363OX.js.map +0 -1
  141. package/dist/chunk-Z2M6YTAJ.js.map +0 -1
  142. /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
  143. /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
  144. /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
  145. /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
  146. /package/dist/{chunk-W63OY3J7.js.map → chunk-CWE74HRG.js.map} +0 -0
  147. /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
  148. /package/dist/{chunk-SAEZIIID.js.map → chunk-LXIEXSHU.js.map} +0 -0
  149. /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
  150. /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
  151. /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
  152. /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
  153. /package/dist/{chunk-JPETDXED.js.map → chunk-TYF3D4MS.js.map} +0 -0
  154. /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
  155. /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
  156. /package/dist/{chunk-2SNKUSQC.js.map → chunk-X5J3JZR3.js.map} +0 -0
  157. /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
  158. /package/dist/{chunk-T422SYM6.js.map → chunk-ZUDM75KG.js.map} +0 -0
@@ -0,0 +1,687 @@
1
+ /**
2
+ * Architecture card — pure deterministic builder (issue #1548 Track A PR 3).
3
+ *
4
+ * Produces a compact, sorted, byte-stable markdown card describing a
5
+ * repository: manifests, top-level directories, language histogram by
6
+ * extension, and entry points derived from manifests. The deterministic
7
+ * card is useful on its own; an optional LLM summary pass (gated behind
8
+ * `architectureCardLlmSummary`) can later prepend a human-readable
9
+ * overview using the existing extraction engine.
10
+ *
11
+ * Design rules honoured:
12
+ * - rule 38: every multi-value field is sorted before serialising so
13
+ * two runs over the same fixture produce byte-identical output.
14
+ * - rule 24/51: `repoRoot` must be an absolute, existing directory;
15
+ * invalid input is rejected with a descriptive error.
16
+ * - rule 34: scan failures produce a tagged outcome, never a crash.
17
+ * - rule 48: the LLM pass is opt-in (default off).
18
+ * - rule 13: on LLM failure the deterministic card ships unchanged.
19
+ * - Privacy + speed: file *contents* are never read except for manifest
20
+ * files (package.json, Cargo.toml, go.mod, pyproject.toml, pom.xml).
21
+ *
22
+ * This module is deliberately pure: no orchestrator references, no
23
+ * config side-effects, no namespace wiring. Callers inject the repo
24
+ * root and receive a string.
25
+ */
26
+ import { readFile, readdir, lstat } from "node:fs/promises";
27
+ import path from "node:path";
28
+ import { expandTildePath } from "../utils/path.js";
29
+
30
+ // ──────────────────────────────────────────────────────────────────────────
31
+ // Public types
32
+ // ──────────────────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Maximum byte size of the serialised card. When the deterministic card
36
+ * exceeds this, it is truncated with a visible marker so consumers know
37
+ * information was elided (rule 34 — never silently incomplete).
38
+ */
39
+ export const ARCHITECTURE_CARD_MAX_BYTES = 4096;
40
+
41
+ /**
42
+ * Visible marker appended when the card is truncated. Exported so callers
43
+ * that read stored card content can detect truncation from the content
44
+ * itself, without depending on a frontmatter tag that may go stale on
45
+ * content-only updates (cursor review: "stale truncated tag on update").
46
+ */
47
+ export const ARCHITECTURE_CARD_TRUNCATION_MARKER = "… card truncated to fit size cap …";
48
+
49
+ /**
50
+ * Maximum byte size of the LLM summary prefix. The summary is additive —
51
+ * the deterministic card (manifests, languages, entry points) must ALWAYS
52
+ * survive truncation. Clamping the summary before prepending prevents a
53
+ * misbehaving or prompt-injected summariser from crowding out the
54
+ * deterministic sections (codex review).
55
+ */
56
+ export const ARCHITECTURE_CARD_MAX_SUMMARY_BYTES = 1024;
57
+
58
+ /**
59
+ * Manifest files the scanner knows how to parse for project metadata
60
+ * (name, entry points). Single source of truth (rule 53 analog).
61
+ */
62
+ export const KNOWN_MANIFEST_FILES = [
63
+ "package.json",
64
+ "Cargo.toml",
65
+ "go.mod",
66
+ "pyproject.toml",
67
+ "setup.py",
68
+ "pom.xml",
69
+ "build.gradle",
70
+ "build.gradle.kts",
71
+ "Gemfile",
72
+ "composer.json",
73
+ "mix.exs",
74
+ "deno.json",
75
+ ] as const;
76
+
77
+ /**
78
+ * Directories the scanner skips (would distort the histogram or are
79
+ * universally non-source). Kept conservative — operators with unusual
80
+ * layouts still get useful output.
81
+ */
82
+ export const SCAN_IGNORE_DIRS: ReadonlySet<string> = new Set([
83
+ "node_modules",
84
+ ".git",
85
+ ".svn",
86
+ ".hg",
87
+ "vendor",
88
+ "__pycache__",
89
+ ".venv",
90
+ "venv",
91
+ "env",
92
+ "dist",
93
+ "build",
94
+ "target",
95
+ "out",
96
+ ".next",
97
+ ".nuxt",
98
+ "coverage",
99
+ ".cache",
100
+ ".turbo",
101
+ ".idea",
102
+ ".vscode",
103
+ ]);
104
+
105
+ /**
106
+ * Result of a deterministic architecture-card build.
107
+ */
108
+ export interface ArchitectureCard {
109
+ /** The markdown card body (may be truncated to fit the byte cap). */
110
+ readonly content: string;
111
+ /** ISO timestamp of the build. */
112
+ readonly generatedAt: string;
113
+ /** Byte length of `content`. */
114
+ readonly byteSize: number;
115
+ /** Whether the card was truncated to fit `maxBytes`. */
116
+ readonly truncated: boolean;
117
+ }
118
+
119
+ /**
120
+ * Injectable LLM summariser. Receives the deterministic card and the
121
+ * repo root; returns a summary string, or `null` to keep the
122
+ * deterministic card unchanged. Implementations MUST NOT throw —
123
+ * failures should return `null` (rule 13).
124
+ */
125
+ export type ArchitectureCardSummariser = (
126
+ deterministicCard: string,
127
+ repoRoot: string,
128
+ ) => Promise<string | null>;
129
+
130
+ /**
131
+ * Minimal chat-completion surface for the architecture-card summariser.
132
+ * Structural — satisfied by both `FallbackLlmClient` (gateway model
133
+ * chain) and `LocalLlmClient` (Ollama / OpenAI-compatible local
134
+ * endpoints) without this pure module importing either (rule 48 — the
135
+ * builder stays free of orchestrator/client references). The shape
136
+ * mirrors `Orchestrator.fastLlmForRerank` so callers resolve the client
137
+ * gateway-first, matching LCM routing precedence.
138
+ */
139
+ export interface ArchitectureCardLlmClient {
140
+ chatCompletion(
141
+ messages: Array<{ role: "system" | "user" | "assistant"; content: string }>,
142
+ options?: {
143
+ temperature?: number;
144
+ maxTokens?: number;
145
+ timeoutMs?: number;
146
+ operation?: string;
147
+ priority?: "background" | "recall-critical";
148
+ },
149
+ ): Promise<{ content: string } | null>;
150
+ }
151
+
152
+ const ARCHITECTURE_CARD_SUMMARY_SYSTEM_PROMPT = `You write a concise overview for a repository architecture card.
153
+ Given the deterministic card (languages, manifests, entry points), produce a 3-5 sentence plain-text overview naming the dominant language, the primary entry point, and the project's shape.
154
+ Rules:
155
+ - Never invent facts absent from the input.
156
+ - No headings, no markdown — just sentences.
157
+ - Keep it under 600 characters.`;
158
+
159
+ /**
160
+ * Build an {@link ArchitectureCardSummariser} backed by an LLM client.
161
+ * The client is a structural type so this module does NOT import
162
+ * `LocalLlmClient` / `FallbackLlmClient` (rule 48 — pure builder). The
163
+ * caller resolves which client to use (gateway-first, matching LCM) and
164
+ * may pass `null` when none is configured — then the summariser is
165
+ * `undefined` so the builder's LLM branch stays inert (no silent no-op).
166
+ *
167
+ * Failures return `null` (rule 13) so the deterministic card ships
168
+ * unchanged; `buildArchitectureCard` additionally defends against
169
+ * implementations that throw.
170
+ */
171
+ export function createArchitectureCardSummariser(
172
+ client: ArchitectureCardLlmClient | null,
173
+ ): ArchitectureCardSummariser | undefined {
174
+ if (!client) return undefined;
175
+ return async (deterministicCard, repoRoot) => {
176
+ const response = await client.chatCompletion(
177
+ [
178
+ { role: "system", content: ARCHITECTURE_CARD_SUMMARY_SYSTEM_PROMPT },
179
+ { role: "user", content: `Repository: ${path.basename(repoRoot) || repoRoot}\n\n${deterministicCard}` },
180
+ ],
181
+ {
182
+ temperature: 0.2,
183
+ maxTokens: 512,
184
+ operation: "architecture-card-summary",
185
+ priority: "background",
186
+ },
187
+ );
188
+ return response?.content ?? null;
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Optional dependencies for `buildArchitectureCard`.
194
+ */
195
+ export interface BuildArchitectureCardOptions {
196
+ /** Injected clock — makes tests deterministic. */
197
+ now?: Date;
198
+ /** Override the byte cap (defaults to {@link ARCHITECTURE_CARD_MAX_BYTES}). */
199
+ maxBytes?: number;
200
+ /**
201
+ * When `true`, invoke `summariser` to prepend an LLM overview.
202
+ * Callers gate this on `codingKnowledge.architectureCardLlmSummary`
203
+ * (rule 48 — opt-in).
204
+ */
205
+ llmSummary?: boolean;
206
+ /** The summariser implementation (required when `llmSummary` is true). */
207
+ summariser?: ArchitectureCardSummariser;
208
+ }
209
+
210
+ /**
211
+ * Tagged failure from the build (rule 34).
212
+ */
213
+ export type ArchitectureCardBuildFailure =
214
+ | { ok: false; code: "invalid_root" | "scan_failed"; detail: string };
215
+
216
+ /**
217
+ * Tagged result: success carries the card; failure carries a code.
218
+ */
219
+ export type ArchitectureCardBuildResult =
220
+ | { ok: true; card: ArchitectureCard }
221
+ | ArchitectureCardBuildFailure;
222
+
223
+ // ──────────────────────────────────────────────────────────────────────────
224
+ // Public entry — buildArchitectureCard
225
+ // ──────────────────────────────────────────────────────────────────────────
226
+
227
+ /**
228
+ * Deterministically build an architecture card for a repository.
229
+ *
230
+ * The scan:
231
+ * 1. Reads top-level directory entries (sorted, rule 38).
232
+ * 2. Parses any present manifest files for project name + entry points.
233
+ * 3. Walks up to two levels deep to compute a language histogram by
234
+ * extension (capped to keep the scan fast on large repos).
235
+ * 4. Renders a compact markdown card, sorted throughout.
236
+ *
237
+ * File *contents* are never read except for manifest files. The walker
238
+ * only reads directory entries and file extensions (privacy + speed).
239
+ */
240
+ export async function buildArchitectureCard(
241
+ repoRoot: string,
242
+ options: BuildArchitectureCardOptions = {},
243
+ ): Promise<ArchitectureCardBuildResult> {
244
+ if (typeof repoRoot !== "string" || repoRoot.length === 0) {
245
+ return { ok: false, code: "invalid_root", detail: "repoRoot must be a non-empty string" };
246
+ }
247
+ // Expand `~` per rule 17 — codingContext.rootPath often carries a tilde
248
+ // that Node's path.resolve does NOT expand (cursor review: tilde not expanded).
249
+ const absoluteRoot = path.resolve(expandTildePath(repoRoot));
250
+ try {
251
+ // Use lstat, not stat, so symlinked manifests/dirs are NOT followed
252
+ // (codex review: symlinks could read outside the repo root).
253
+ const rootStat = await lstat(absoluteRoot);
254
+ if (!rootStat.isDirectory()) {
255
+ return { ok: false, code: "invalid_root", detail: `not a directory: ${absoluteRoot}` };
256
+ }
257
+ } catch (err) {
258
+ return {
259
+ ok: false,
260
+ code: "invalid_root",
261
+ detail: `repoRoot not accessible: ${err instanceof Error ? err.message : String(err)}`,
262
+ };
263
+ }
264
+
265
+ // Probe root readability BEFORE scanning. An unreadable root (EACCES,
266
+ // transient I/O) must surface as scan_failed, not a silent empty card —
267
+ // otherwise refresh could overwrite a valid card with a sparse scan (codex
268
+ // review). lstat can succeed while readdir is denied.
269
+ try {
270
+ await readdir(absoluteRoot);
271
+ } catch (err) {
272
+ return {
273
+ ok: false,
274
+ code: "scan_failed",
275
+ detail: `repoRoot not readable: ${err instanceof Error ? err.message : String(err)}`,
276
+ };
277
+ }
278
+ try {
279
+ const scan = await scanRepository(absoluteRoot);
280
+ const deterministic = renderCard(scan, options.now ?? new Date());
281
+ const maxBytes = options.maxBytes ?? ARCHITECTURE_CARD_MAX_BYTES;
282
+
283
+ let content = deterministic;
284
+ if (options.llmSummary === true && options.summariser) {
285
+ try {
286
+ const summary = await options.summariser(deterministic, absoluteRoot);
287
+ if (typeof summary === "string" && summary.trim().length > 0) {
288
+ // Reserve the deterministic card's budget FIRST — it must ALWAYS
289
+ // survive (rule 34 + codex/kilo review). The final `capToBytes`
290
+ // truncates from the END, so the summary is prepended only into the
291
+ // space left after the deterministic card + separator, additionally
292
+ // clamped to the summary cap. A summary can never crowd out or
293
+ // truncate the deterministic sections when the card fits the cap.
294
+ const separator = "\n\n---\n\n";
295
+ const detBytes = Buffer.byteLength(deterministic, "utf-8");
296
+ const sepBytes = Buffer.byteLength(separator, "utf-8");
297
+ const summaryBudget = Math.min(
298
+ ARCHITECTURE_CARD_MAX_SUMMARY_BYTES,
299
+ maxBytes - detBytes - sepBytes,
300
+ );
301
+ if (summaryBudget > 0) {
302
+ const clampedSummary = clampSummaryToBytes(summary.trim(), summaryBudget);
303
+ if (clampedSummary.length > 0) {
304
+ content = `${clampedSummary}${separator}${deterministic}`;
305
+ }
306
+ }
307
+ // else: the deterministic card already fills the cap — ship it
308
+ // alone rather than prepend a summary that would truncate it.
309
+ }
310
+ } catch (err) {
311
+ // rule 13: LLM failure → deterministic card ships unchanged.
312
+ // Intentionally swallowed — the summariser contract says "don't
313
+ // throw", but we defend against implementations that do.
314
+ void err;
315
+ }
316
+ }
317
+
318
+ const { text: truncatedContent, truncated } = capToBytes(content, maxBytes);
319
+ return {
320
+ ok: true,
321
+ card: {
322
+ content: truncatedContent,
323
+ generatedAt: (options.now ?? new Date()).toISOString(),
324
+ byteSize: Buffer.byteLength(truncatedContent, "utf-8"),
325
+ truncated,
326
+ },
327
+ };
328
+ } catch (err) {
329
+ return {
330
+ ok: false,
331
+ code: "scan_failed",
332
+ detail: err instanceof Error ? err.message : String(err),
333
+ };
334
+ }
335
+ }
336
+
337
+ // ──────────────────────────────────────────────────────────────────────────
338
+ // Scan — directory walk + manifest parse (deterministic)
339
+ // ──────────────────────────────────────────────────────────────────────────
340
+
341
+ interface RepoScan {
342
+ root: string;
343
+ topDirs: string[];
344
+ manifests: ParsedManifest[];
345
+ languageHistogram: LanguageEntry[];
346
+ }
347
+
348
+ interface LanguageEntry {
349
+ ext: string;
350
+ count: number;
351
+ }
352
+
353
+ interface ParsedManifest {
354
+ filename: string;
355
+ name: string | null;
356
+ entryPoints: string[];
357
+ }
358
+
359
+ async function scanRepository(root: string): Promise<RepoScan> {
360
+ const topEntries = await readSortedDirEntries(root);
361
+ const topDirs = topEntries.filter((e) => e.isDirectory && !SCAN_IGNORE_DIRS.has(e.name)).map((e) => e.name);
362
+ const manifestFiles = topEntries
363
+ .filter((e) => e.isFile && KNOWN_MANIFEST_FILES.includes(e.name as (typeof KNOWN_MANIFEST_FILES)[number]))
364
+ .map((e) => e.name);
365
+
366
+ const manifests: ParsedManifest[] = [];
367
+ for (const mf of sortStrings(manifestFiles)) {
368
+ const parsed = await parseManifest(path.join(root, mf));
369
+ manifests.push({ filename: mf, name: parsed.name, entryPoints: sortStrings(parsed.entryPoints) });
370
+ }
371
+
372
+ const languageHistogram = await computeLanguageHistogram(root, topEntries);
373
+
374
+ return {
375
+ root: path.basename(root) || root,
376
+ topDirs: sortStrings(topDirs),
377
+ manifests,
378
+ languageHistogram: sortLanguageHistogram(languageHistogram),
379
+ };
380
+ }
381
+
382
+ interface DirEntry {
383
+ name: string;
384
+ isDirectory: boolean;
385
+ isFile: boolean;
386
+ }
387
+
388
+ async function readSortedDirEntries(dir: string): Promise<DirEntry[]> {
389
+ let names: string[];
390
+ try {
391
+ names = await readdir(dir);
392
+ } catch {
393
+ return [];
394
+ }
395
+ const entries: DirEntry[] = [];
396
+ for (const name of sortStrings(names)) {
397
+ try {
398
+ const s = await lstat(path.join(dir, name));
399
+ // Skip symlinks — they could point outside the repo root (codex review).
400
+ if (s.isSymbolicLink()) continue;
401
+ entries.push({ name, isDirectory: s.isDirectory(), isFile: s.isFile() });
402
+ } catch {
403
+ // vanished between readdir+stat — skip.
404
+ }
405
+ }
406
+ return entries;
407
+ }
408
+
409
+ async function computeLanguageHistogram(
410
+ root: string,
411
+ topEntries: DirEntry[],
412
+ ): Promise<LanguageEntry[]> {
413
+ const counts = new Map<string, number>();
414
+ const dirsToScan = topEntries
415
+ .filter((e) => e.isDirectory && !SCAN_IGNORE_DIRS.has(e.name))
416
+ .map((e) => path.join(root, e.name));
417
+ // Also count files in the root itself.
418
+ const rootFiles = topEntries.filter((e) => e.isFile);
419
+
420
+ const visitDir = async (dir: string, depth: number): Promise<void> => {
421
+ if (depth > 2) return; // cap walk depth for speed
422
+ let entries: DirEntry[];
423
+ try {
424
+ entries = await readSortedDirEntries(dir);
425
+ } catch {
426
+ return;
427
+ }
428
+ for (const entry of entries) {
429
+ if (entry.isDirectory) {
430
+ if (!SCAN_IGNORE_DIRS.has(entry.name)) {
431
+ await visitDir(path.join(dir, entry.name), depth + 1);
432
+ }
433
+ } else if (entry.isFile) {
434
+ const ext = path.extname(entry.name).toLowerCase();
435
+ if (ext.length > 0) {
436
+ counts.set(ext, (counts.get(ext) ?? 0) + 1);
437
+ }
438
+ }
439
+ }
440
+ };
441
+
442
+ // Count root-level files first.
443
+ for (const f of rootFiles) {
444
+ const ext = path.extname(f.name).toLowerCase();
445
+ if (ext.length > 0) counts.set(ext, (counts.get(ext) ?? 0) + 1);
446
+ }
447
+ // Walk subdirectories (breadth-first-ish via sequential await).
448
+ for (const dir of dirsToScan) {
449
+ await visitDir(dir, 0);
450
+ }
451
+
452
+ return Array.from(counts.entries()).map(([ext, count]) => ({ ext, count }));
453
+ }
454
+
455
+ // ──────────────────────────────────────────────────────────────────────────
456
+ // Manifest parse — narrow, known formats only
457
+ // ──────────────────────────────────────────────────────────────────────────
458
+
459
+ async function parseManifest(filePath: string): Promise<{ name: string | null; entryPoints: string[] }> {
460
+ const filename = path.basename(filePath);
461
+ try {
462
+ const raw = await readFile(filePath, "utf-8");
463
+ switch (filename) {
464
+ case "package.json":
465
+ return parsePackageJson(raw);
466
+ case "pyproject.toml":
467
+ case "setup.py":
468
+ return parsePythonProject(raw, filename);
469
+ case "go.mod":
470
+ return parseGoMod(raw);
471
+ case "Cargo.toml":
472
+ return parseCargoToml(raw);
473
+ default:
474
+ // Other manifests: extract nothing; presence alone is useful.
475
+ return { name: null, entryPoints: [] };
476
+ }
477
+ } catch {
478
+ return { name: null, entryPoints: [] };
479
+ }
480
+ }
481
+
482
+ function parsePackageJson(raw: string): { name: string | null; entryPoints: string[] } {
483
+ let parsed: unknown;
484
+ try {
485
+ parsed = JSON.parse(raw);
486
+ } catch {
487
+ return { name: null, entryPoints: [] };
488
+ }
489
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
490
+ return { name: null, entryPoints: [] };
491
+ }
492
+ const obj = parsed as Record<string, unknown>;
493
+ const name = typeof obj["name"] === "string" ? obj["name"] : null;
494
+ const entryPoints: string[] = [];
495
+ const bin = obj["bin"];
496
+ if (typeof bin === "string") {
497
+ entryPoints.push(bin);
498
+ } else if (typeof bin === "object" && bin !== null && !Array.isArray(bin)) {
499
+ for (const [key, value] of Object.entries(bin)) {
500
+ // Record the target path when it is a string (e.g. {remnic:'dist/cli.js'}
501
+ // → 'dist/cli.js'); fall back to the bin-key form for non-string
502
+ // values. `bin` is already narrowed to a record by the typeof/!null/
503
+ // !isArray guard above, so no cast is needed (codex review P2).
504
+ entryPoints.push(typeof value === "string" ? value : `bin/${key}`);
505
+ }
506
+ }
507
+ const main = typeof obj["main"] === "string" ? obj["main"] : null;
508
+ if (main) entryPoints.push(main);
509
+ const module = typeof obj["module"] === "string" ? obj["module"] : null;
510
+ if (module) entryPoints.push(module);
511
+ const scripts = obj["scripts"];
512
+ if (typeof scripts === "object" && scripts !== null && !Array.isArray(scripts)) {
513
+ const scriptKeys = Object.keys(scripts as Record<string, unknown>);
514
+ // Only surface the most useful entry-point scripts.
515
+ for (const key of ["start", "dev", "serve"]) {
516
+ if (scriptKeys.includes(key)) entryPoints.push(`scripts.${key}`);
517
+ }
518
+ }
519
+ return { name, entryPoints };
520
+ }
521
+
522
+ function parsePythonProject(raw: string, _filename: string): { name: string | null; entryPoints: string[] } {
523
+ // pyproject.toml: extract [project] name; setup.py: extract name=.
524
+ const entryPoints: string[] = [];
525
+ let name: string | null = null;
526
+ const nameMatch = raw.match(/^name\s*=\s*["']([^"']+)["']/m);
527
+ if (nameMatch) name = nameMatch[1] ?? null;
528
+ // Anchor on the next `[section]` header OR end-of-string (`$`). Do NOT use
529
+ // `\Z` — JavaScript regex treats it as a literal `Z`, so a `[project.scripts]`
530
+ // table at EOF (the common layout) never matched and every console-script
531
+ // entry point was dropped from the card (codex P2 review).
532
+ const scriptsMatch = raw.match(/\[project\.scripts\]([\s\S]*?)(?:\n\[|$)/);
533
+ if (scriptsMatch && scriptsMatch[1]) {
534
+ for (const line of scriptsMatch[1].split("\n")) {
535
+ const m = line.match(/^([a-zA-Z0-9_-]+)\s*=/);
536
+ if (m && m[1]) entryPoints.push(`scripts.${m[1]}`);
537
+ }
538
+ }
539
+ return { name, entryPoints };
540
+ }
541
+
542
+ function parseGoMod(raw: string): { name: string | null; entryPoints: string[] } {
543
+ const moduleMatch = raw.match(/^module\s+(\S+)/m);
544
+ const name = moduleMatch && moduleMatch[1] ? moduleMatch[1] : null;
545
+ return { name, entryPoints: name ? ["main.go"] : [] };
546
+ }
547
+
548
+ function parseCargoToml(raw: string): { name: string | null; entryPoints: string[] } {
549
+ const nameMatch = raw.match(/^name\s*=\s*"([^"]+)"/m);
550
+ const name = nameMatch && nameMatch[1] ? nameMatch[1] : null;
551
+ const entryPoints: string[] = [];
552
+ if (/\[\[bin\]\]/.test(raw)) entryPoints.push("src/bin/");
553
+ if (name) entryPoints.push("src/main.rs");
554
+ return { name, entryPoints };
555
+ }
556
+
557
+ // ──────────────────────────────────────────────────────────────────────────
558
+ // Render — deterministic markdown
559
+ // ──────────────────────────────────────────────────────────────────────────
560
+
561
+ function renderCard(scan: RepoScan, now: Date): string {
562
+ const lines: string[] = [];
563
+ lines.push(`# Architecture Card — ${scan.root}`);
564
+ lines.push("");
565
+ lines.push(`_Generated ${now.toISOString()}_`);
566
+ lines.push("");
567
+
568
+ // Project name from the first manifest that provides one.
569
+ const namedManifest = scan.manifests.find((m) => m.name !== null);
570
+ if (namedManifest && namedManifest.name) {
571
+ lines.push(`**Project:** ${namedManifest.name}`);
572
+ lines.push("");
573
+ }
574
+
575
+ // Manifests
576
+ if (scan.manifests.length > 0) {
577
+ lines.push("## Manifests");
578
+ for (const m of scan.manifests) {
579
+ const label = m.name ? `${m.filename} (${m.name})` : m.filename;
580
+ lines.push(`- ${label}`);
581
+ }
582
+ lines.push("");
583
+ }
584
+
585
+ // Top-level directories
586
+ if (scan.topDirs.length > 0) {
587
+ lines.push("## Top-level directories");
588
+ // Wrap into columns of 4 for compactness.
589
+ const cols = 4;
590
+ for (let i = 0; i < scan.topDirs.length; i += cols) {
591
+ const row = scan.topDirs.slice(i, i + cols).join(" · ");
592
+ lines.push(`- ${row}`);
593
+ }
594
+ lines.push("");
595
+ }
596
+
597
+ // Language histogram (top 10 by count, ties broken alphabetically)
598
+ if (scan.languageHistogram.length > 0) {
599
+ lines.push("## Languages (by file count)");
600
+ const top = scan.languageHistogram.slice(0, 10);
601
+ for (const entry of top) {
602
+ lines.push(`- ${entry.ext}: ${entry.count}`);
603
+ }
604
+ lines.push("");
605
+ }
606
+
607
+ // Entry points
608
+ const allEntryPoints = sortStrings(scan.manifests.flatMap((m) => m.entryPoints));
609
+ if (allEntryPoints.length > 0) {
610
+ lines.push("## Entry points");
611
+ for (const ep of allEntryPoints) {
612
+ lines.push(`- ${ep}`);
613
+ }
614
+ lines.push("");
615
+ }
616
+
617
+ return lines.join("\n").trimEnd() + "\n";
618
+ }
619
+
620
+ // ──────────────────────────────────────────────────────────────────────────
621
+ // Helpers — sort + cap
622
+ // ──────────────────────────────────────────────────────────────────────────
623
+
624
+ function sortStrings(values: string[]): string[] {
625
+ return [...values].sort((a, b) => a.localeCompare(b));
626
+ }
627
+
628
+ function sortLanguageHistogram(entries: LanguageEntry[]): LanguageEntry[] {
629
+ return [...entries].sort((a, b) => {
630
+ if (b.count !== a.count) return b.count - a.count;
631
+ return a.ext.localeCompare(b.ext);
632
+ });
633
+ }
634
+
635
+ /**
636
+ * Cap a string to `maxBytes` of UTF-8. If truncation occurs, append a
637
+ * visible marker on its own line (rule 34 — never silently incomplete).
638
+ */
639
+ function capToBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
640
+ const byteLen = Buffer.byteLength(text, "utf-8");
641
+ if (byteLen <= maxBytes) {
642
+ return { text, truncated: false };
643
+ }
644
+ const marker = `\n\n_${ARCHITECTURE_CARD_TRUNCATION_MARKER}_`;
645
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
646
+ const budget = maxBytes - markerBytes;
647
+ if (budget <= 0) {
648
+ // Extremely tight cap — return just the marker.
649
+ return { text: marker.trimStart(), truncated: true };
650
+ }
651
+ // Walk the string to find a UTF-8-safe cut point.
652
+ let cut = 0;
653
+ let running = 0;
654
+ const chars = [...text];
655
+ for (let i = 0; i < chars.length; i++) {
656
+ const charBytes = Buffer.byteLength(chars[i], "utf-8");
657
+ if (running + charBytes > budget) break;
658
+ running += charBytes;
659
+ cut = i + 1;
660
+ }
661
+ return { text: chars.slice(0, cut).join("") + marker, truncated: true };
662
+ }
663
+
664
+ /**
665
+ * Clamp an LLM SUMMARY to `maxBytes` of UTF-8. Unlike {@link capToBytes}, this
666
+ * uses a neutral trailing ellipsis (never the card-truncation marker) and keeps
667
+ * the result — ellipsis included — within `maxBytes`. A clamped summary must
668
+ * never imply the deterministic CARD was truncated, nor spill past its reserved
669
+ * budget into the deterministic sections (cursor/codex review).
670
+ */
671
+ function clampSummaryToBytes(text: string, maxBytes: number): string {
672
+ if (maxBytes <= 0) return "";
673
+ if (Buffer.byteLength(text, "utf-8") <= maxBytes) return text;
674
+ const ellipsis = " …";
675
+ const budget = maxBytes - Buffer.byteLength(ellipsis, "utf-8");
676
+ if (budget <= 0) return "";
677
+ let cut = 0;
678
+ let running = 0;
679
+ const chars = [...text];
680
+ for (let i = 0; i < chars.length; i++) {
681
+ const charBytes = Buffer.byteLength(chars[i], "utf-8");
682
+ if (running + charBytes > budget) break;
683
+ running += charBytes;
684
+ cut = i + 1;
685
+ }
686
+ return chars.slice(0, cut).join("") + ellipsis;
687
+ }