@ngockhoale/ukit 3.0.9 → 3.0.11

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 (46) hide show
  1. package/CHANGELOG.md +15 -1
  2. package/bin/ukit +5 -9
  3. package/package.json +1 -1
  4. package/scripts/bench/memory-baseline.mjs +1 -1
  5. package/scripts/bench/memory-bench.mjs +1 -1
  6. package/scripts/bench/memory-canary.mjs +12 -2
  7. package/scripts/bench/recorder-overhead.mjs +1 -1
  8. package/scripts/bench/sqlite-spike.mjs +1 -1
  9. package/scripts/measure-decision-gateway.mjs +1 -1
  10. package/src/cli/commands/memory.js +3 -0
  11. package/src/cli/deadline.js +19 -0
  12. package/src/core/agentRuntime/eventStore.js +38 -10
  13. package/src/core/agentRuntime/supervisor.js +41 -9
  14. package/src/core/agentRuntime/vmEngine.js +33 -2
  15. package/src/core/codeintel/freshness.js +13 -9
  16. package/src/core/codeintel/retriever.js +25 -20
  17. package/src/core/fileOps.js +19 -4
  18. package/src/core/memory/memoryFlags.js +14 -6
  19. package/src/core/metadata.js +111 -81
  20. package/src/core/token/index.js +69 -28
  21. package/src/index/buildIndex.js +7 -0
  22. package/src/index/impactContext.js +14 -5
  23. package/src/render/instructionRenderer.js +71 -19
  24. package/template_project/.claude/hooks/auto-prune-bash.sh +60 -83
  25. package/template_project/.claude/hooks/block-dangerous.mjs +42 -11
  26. package/template_project/.claude/skills/docs-manager/init-project-docs.sh +59 -16
  27. package/template_project/.claude/skills/docx/scripts/document.py +31 -8
  28. package/template_project/.claude/skills/frontend-vue/composables/indexDBStore.js +26 -5
  29. package/template_project/.claude/skills/frontend-vue/composables/useRequest.js +24 -12
  30. package/template_project/.claude/skills/frontend-vue/composables/useSession.js +16 -11
  31. package/template_project/.claude/skills/frontend-vue/composables/useWebSocket.js +25 -2
  32. package/template_project/.claude/skills/pptx/scripts/rearrange.py +11 -2
  33. package/template_project/.claude/skills/root-cause-tracing/find-polluter.sh +20 -5
  34. package/template_project/.claude/skills/webapp-testing/scripts/with_server.py +37 -6
  35. package/template_project/.claude/ukit/index/cache-utils.mjs +25 -19
  36. package/template_project/.claude/ukit/index/impact-context.mjs +21 -13
  37. package/template_project/.claude/ukit/index/lib/index-core.mjs +96 -14
  38. package/template_project/.claude/ukit/index/query-index.mjs +13 -11
  39. package/template_project/.claude/ukit/index/resolve-context.mjs +12 -11
  40. package/template_project/.claude/ukit/index/route-task.mjs +44 -25
  41. package/template_project/.claude/ukit/index/triage.mjs +12 -11
  42. package/template_project/.claude/ukit/index/verify-context.mjs +12 -11
  43. package/template_project/.claude/ukit/runtime/async-lock.mjs +33 -0
  44. package/template_project/.claude/ukit/runtime/output-compression.mjs +9 -2
  45. package/template_project/.claude/ukit/runtime/resumable-run.mjs +18 -2
  46. package/template_project/.claude/ukit/runtime/token-utils.mjs +60 -18
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path';
2
- import { ensureDir, writeJson, readJsonIfExists } from './fileOps.js';
2
+ import { ensureDir, writeJson, readJsonIfExists, withFileLock } from './fileOps.js';
3
3
 
4
4
  function normalizeTrackedRelativePath(relativePath) {
5
5
  if (typeof relativePath !== 'string') {
@@ -33,100 +33,130 @@ export async function writeInstallMetadata({
33
33
  projectRoot,
34
34
  managedRelativePaths = null,
35
35
  retainedManagedRelativePaths = [],
36
+ lockStaleMs,
37
+ lockMaxWaitMs,
36
38
  }) {
37
- // Accumulate file tracking across re-installs so files written in earlier runs
38
- // remain tracked (they are 'skip'/'unchanged' on re-install and don't appear in
39
- // the current writes, but we still need to remove them on uninstall).
40
- const previousData = await readJsonIfExists(installMetaPath);
41
- const previousFiles = previousData?.tool === 'ukit' && Array.isArray(previousData?.files)
42
- ? previousData.files
43
- : [];
44
- const managedPathSet = Array.isArray(managedRelativePaths)
45
- ? new Set(managedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean))
46
- : null;
47
- const retainedManagedPathSet = new Set(
48
- retainedManagedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean),
49
- );
50
-
51
- // Convert writes to tracked entries stored as relative paths for portability.
52
- const newEntries = projectRoot
53
- ? writes.map((w) => ({
54
- p: normalizeTrackedRelativePath(path.relative(projectRoot, w.targetPath)),
55
- t: w.type, // 'link', 'command', 'agent', 'hook', 'config', 'skill', etc.
56
- })).filter((entry) => {
57
- if (!entry.p) {
58
- return false;
59
- }
60
- if (!managedPathSet) {
61
- return true;
62
- }
63
- return managedPathSet.has(entry.p) || retainedManagedPathSet.has(entry.p);
64
- })
65
- : [];
66
-
67
- // Merge: keep entries from previous runs for paths not re-written this run,
68
- // then append new entries (updating type info for any that were re-written).
69
- const newPaths = new Set(newEntries.map((e) => e.p));
70
- const retained = previousFiles.filter((e) => {
71
- const p = normalizeTrackedRelativePath(typeof e === 'string' ? e : e?.p);
72
- if (!p) {
73
- return false;
74
- }
75
- if (managedPathSet && !managedPathSet.has(p) && !retainedManagedPathSet.has(p)) {
76
- return false;
77
- }
78
- return !newPaths.has(p);
79
- });
80
- const allFiles = [...retained, ...newEntries];
81
-
82
- const metadata = {
83
- tool: 'ukit',
84
- version: packageVersion,
85
- installedAt: new Date().toISOString(),
86
- manifest: {
87
- name: manifest.name,
88
- version: manifest.version,
89
- },
90
- stack: stackContext,
91
- providers: providerContext,
92
- summary,
93
- files: allFiles,
94
- };
95
-
96
- await ensureDir(path.dirname(installMetaPath));
97
- await writeJson(installMetaPath, metadata);
39
+ const writeResult = await withFileLock(installMetaPath, async () => {
40
+ // Accumulate file tracking across re-installs so files written in earlier
41
+ // runs remain tracked (they are 'skip'/'unchanged' on re-install and don't
42
+ // appear in the current writes, but we still need to remove them on
43
+ // uninstall). The read→merge→write runs under withFileLock (C79-12):
44
+ // concurrent installs used to read the same pre-state and last-writer-wins
45
+ // the merge, silently losing tracked-file entries.
46
+ const previousData = await readJsonIfExists(installMetaPath);
47
+ const previousFiles = previousData?.tool === 'ukit' && Array.isArray(previousData?.files)
48
+ ? previousData.files
49
+ : [];
50
+ const managedPathSet = Array.isArray(managedRelativePaths)
51
+ ? new Set(managedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean))
52
+ : null;
53
+ const retainedManagedPathSet = new Set(
54
+ retainedManagedRelativePaths.map((value) => normalizeTrackedRelativePath(value)).filter(Boolean),
55
+ );
56
+
57
+ // Convert writes to tracked entries stored as relative paths for portability.
58
+ const newEntries = projectRoot
59
+ ? writes.map((w) => ({
60
+ p: normalizeTrackedRelativePath(path.relative(projectRoot, w.targetPath)),
61
+ t: w.type, // 'link', 'command', 'agent', 'hook', 'config', 'skill', etc.
62
+ })).filter((entry) => {
63
+ if (!entry.p) {
64
+ return false;
65
+ }
66
+ if (!managedPathSet) {
67
+ return true;
68
+ }
69
+ return managedPathSet.has(entry.p) || retainedManagedPathSet.has(entry.p);
70
+ })
71
+ : [];
72
+
73
+ // Merge: keep entries from previous runs for paths not re-written this run,
74
+ // then append new entries (updating type info for any that were re-written).
75
+ const newPaths = new Set(newEntries.map((e) => e.p));
76
+ const retained = previousFiles.filter((e) => {
77
+ const p = normalizeTrackedRelativePath(typeof e === 'string' ? e : e?.p);
78
+ if (!p) {
79
+ return false;
80
+ }
81
+ if (managedPathSet && !managedPathSet.has(p) && !retainedManagedPathSet.has(p)) {
82
+ return false;
83
+ }
84
+ return !newPaths.has(p);
85
+ });
86
+ const allFiles = [...retained, ...newEntries];
87
+
88
+ const metadata = {
89
+ tool: 'ukit',
90
+ version: packageVersion,
91
+ installedAt: new Date().toISOString(),
92
+ manifest: {
93
+ name: manifest.name,
94
+ version: manifest.version,
95
+ },
96
+ stack: stackContext,
97
+ providers: providerContext,
98
+ summary,
99
+ files: allFiles,
100
+ };
101
+
102
+ await ensureDir(path.dirname(installMetaPath));
103
+ await writeJson(installMetaPath, metadata);
104
+ return 'written';
105
+ }, { staleMs: lockStaleMs, maxWaitMs: lockMaxWaitMs });
106
+
107
+ if (writeResult === undefined) {
108
+ // FR-012 / SPEC §14: lock-wait expiry degrades explicitly — warn and skip
109
+ // the bookkeeping write rather than aborting the install or writing
110
+ // unlocked (the drop is also journaled by withFileLock).
111
+ console.warn(
112
+ `[UKit] Skipping install metadata write — install.json lock stayed held past the wait budget (${installMetaPath}). Tracked files for this run may be incomplete.`,
113
+ );
114
+ }
98
115
  }
99
116
 
100
117
  export async function removeTrackedPathsFromMetadata({
101
118
  installMetaPath,
102
119
  projectRoot,
103
120
  removeRelativePaths = [],
121
+ lockStaleMs,
122
+ lockMaxWaitMs,
104
123
  }) {
105
124
  if (!removeRelativePaths.length) {
106
125
  return;
107
126
  }
108
127
 
109
- const metadata = await readJsonIfExists(installMetaPath);
110
- if (!metadata || metadata.tool !== 'ukit' || !Array.isArray(metadata.files)) {
111
- return;
112
- }
113
-
114
- const targetPaths = [...new Set(removeRelativePaths
115
- .map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
116
- .filter(Boolean))];
128
+ const removeResult = await withFileLock(installMetaPath, async () => {
129
+ const metadata = await readJsonIfExists(installMetaPath);
130
+ if (!metadata || metadata.tool !== 'ukit' || !Array.isArray(metadata.files)) {
131
+ return 'noop';
132
+ }
117
133
 
118
- const filteredFiles = metadata.files.filter((entry) => {
119
- const p = typeof entry === 'string' ? entry : entry?.p;
120
- if (!p) {
121
- return true;
134
+ const targetPaths = [...new Set(removeRelativePaths
135
+ .map((relPath) => (typeof relPath === 'string' ? relPath.trim().replace(/\\/g, '/') : ''))
136
+ .filter(Boolean))];
137
+
138
+ const filteredFiles = metadata.files.filter((entry) => {
139
+ const p = typeof entry === 'string' ? entry : entry?.p;
140
+ if (!p) {
141
+ return true;
142
+ }
143
+ const normalized = p.trim().replace(/\\/g, '/');
144
+ return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
145
+ });
146
+
147
+ if (filteredFiles.length === metadata.files.length) {
148
+ return 'noop';
122
149
  }
123
- const normalized = p.trim().replace(/\\/g, '/');
124
- return !targetPaths.some((targetPath) => isSameOrDescendantPath(normalized, targetPath));
125
- });
126
150
 
127
- if (filteredFiles.length === metadata.files.length) {
128
- return;
129
- }
151
+ await writeJson(installMetaPath, { ...metadata, files: filteredFiles });
152
+ return 'written';
153
+ }, { staleMs: lockStaleMs, maxWaitMs: lockMaxWaitMs });
130
154
 
131
- await writeJson(installMetaPath, { ...metadata, files: filteredFiles });
155
+ if (removeResult === undefined) {
156
+ // Same explicit-degrade contract as writeInstallMetadata: warn, skip the
157
+ // write, never mutate install.json unlocked.
158
+ console.warn(
159
+ `[UKit] Skipping install metadata update — install.json lock stayed held past the wait budget (${installMetaPath}). Removed paths may still be listed as tracked.`,
160
+ );
161
+ }
132
162
  }
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- import { readJsonIfExists, writeJson } from '../fileOps.js';
2
+ import { readJsonIfExists, writeJson, withFileLock } from '../fileOps.js';
3
3
  import { buildRuntimePaths } from '../runtimePaths.js';
4
4
 
5
5
  export const DEFAULT_PROMPT_CACHE_MAX_ENTRIES = 20;
@@ -265,15 +265,39 @@ export async function readPromptCacheEntry(
265
265
  lastHitAt: Date.now(),
266
266
  hitCount: (entry.hitCount ?? 0) + 1,
267
267
  };
268
- const entries = [
269
- touchedEntry,
270
- ...cache.entries.slice(0, entryIndex),
271
- ...cache.entries.slice(entryIndex + 1),
272
- ].slice(0, maxEntries);
273
268
  try {
274
- const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
275
- await writePromptCacheDocument(projectRoot, nextDocument);
276
- return nextDocument.entries[0] ?? touchedEntry;
269
+ const runtimePaths = buildRuntimePaths(projectRoot);
270
+ // SPEC §5 FR-019: the touch's read-merge-write runs under the cache-file
271
+ // lock — concurrent hook processes used to merge over the same snapshot
272
+ // and drop hitCounts/entries. The lock RE-READS the document so the merge
273
+ // applies to the freshest state, and an entry a concurrent delete already
274
+ // dropped is never resurrected.
275
+ const locked = await withFileLock(runtimePaths.promptCachePath, async () => {
276
+ const lockedCache = await readPromptCacheDocument(projectRoot, { maxEntries });
277
+ const lockedIndex = lockedCache.entries.findIndex(
278
+ (lockedEntry) => lockedEntry.requestKey === requestKey,
279
+ );
280
+ if (lockedIndex < 0) {
281
+ return null;
282
+ }
283
+ const mergedEntry = {
284
+ ...lockedCache.entries[lockedIndex],
285
+ updatedAt: Date.now(),
286
+ lastHitAt: Date.now(),
287
+ hitCount: (lockedCache.entries[lockedIndex].hitCount ?? 0) + 1,
288
+ };
289
+ const entries = [
290
+ mergedEntry,
291
+ ...lockedCache.entries.slice(0, lockedIndex),
292
+ ...lockedCache.entries.slice(lockedIndex + 1),
293
+ ].slice(0, maxEntries);
294
+ const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
295
+ await writePromptCacheDocument(projectRoot, nextDocument);
296
+ return nextDocument.entries[0] ?? mergedEntry;
297
+ });
298
+ // Lock-busy (fail-closed skip, journaled) or entry-gone-under-lock →
299
+ // degrade to the in-memory touched entry, same as the failure path below.
300
+ return locked ?? touchedEntry;
277
301
  } catch {
278
302
  // TASK-006 (SPEC §5 FR-009): the touch-write is a side effect of a READ —
279
303
  // a full/unwritable cache (EACCES, ENOSPC, ...) must never reject the
@@ -295,14 +319,23 @@ export async function writePromptCacheEntry(
295
319
  return readPromptCacheDocument(projectRoot, { maxEntries });
296
320
  }
297
321
 
298
- const cache = await readPromptCacheDocument(projectRoot, { maxEntries });
299
- const entries = [
300
- normalizedEntry,
301
- ...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
302
- ].slice(0, maxEntries);
303
- const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
304
- await writePromptCacheDocument(projectRoot, nextDocument);
305
- return nextDocument;
322
+ const runtimePaths = buildRuntimePaths(projectRoot);
323
+ // SPEC §5 FR-019: the merge must re-read under the lock — merging over a
324
+ // pre-lock snapshot is the lost-update race this lock exists to close, and
325
+ // no code path may write the cache unlocked.
326
+ const lockedDocument = await withFileLock(runtimePaths.promptCachePath, async () => {
327
+ const cache = await readPromptCacheDocument(projectRoot, { maxEntries });
328
+ const entries = [
329
+ normalizedEntry,
330
+ ...cache.entries.filter((existingEntry) => existingEntry.requestKey !== normalizedEntry.requestKey),
331
+ ].slice(0, maxEntries);
332
+ const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
333
+ await writePromptCacheDocument(projectRoot, nextDocument);
334
+ return nextDocument;
335
+ });
336
+ // Lock-busy → the write was skipped (journaled); return the freshest doc so
337
+ // callers keep a consistent shape without an unlocked write.
338
+ return lockedDocument ?? readPromptCacheDocument(projectRoot, { maxEntries });
306
339
  }
307
340
 
308
341
  /**
@@ -317,18 +350,26 @@ export async function deletePromptCacheEntries(projectRoot, { selectedIds } = {}
317
350
  try {
318
351
  const ids = new Set(Array.isArray(selectedIds) ? selectedIds : []);
319
352
  if (ids.size === 0) return { removed: 0 };
320
- const cache = await readPromptCacheDocument(projectRoot);
321
- if (cache.entries.length === 0) return { removed: 0 };
322
- const kept = cache.entries.filter((entry) => {
323
- const entryIds = Array.isArray(entry?.metadata?.selectedIds)
324
- ? entry.metadata.selectedIds
325
- : [];
326
- return !entryIds.some((id) => ids.has(id));
353
+ const runtimePaths = buildRuntimePaths(projectRoot);
354
+ // SPEC §5 FR-019: the filter+rewrite is a read-merge-write and runs under
355
+ // the cache-file lock — a purge racing a cache writer must not resurrect
356
+ // (or lose) entries. Lock-busy → the purge is skipped (journaled) and the
357
+ // function reports its usual no-op shape.
358
+ const locked = await withFileLock(runtimePaths.promptCachePath, async () => {
359
+ const cache = await readPromptCacheDocument(projectRoot);
360
+ if (cache.entries.length === 0) return { removed: 0 };
361
+ const kept = cache.entries.filter((entry) => {
362
+ const entryIds = Array.isArray(entry?.metadata?.selectedIds)
363
+ ? entry.metadata.selectedIds
364
+ : [];
365
+ return !entryIds.some((id) => ids.has(id));
366
+ });
367
+ const removed = cache.entries.length - kept.length;
368
+ if (removed === 0) return { removed: 0 };
369
+ await writePromptCacheDocument(projectRoot, { entries: kept });
370
+ return { removed };
327
371
  });
328
- const removed = cache.entries.length - kept.length;
329
- if (removed === 0) return { removed: 0 };
330
- await writePromptCacheDocument(projectRoot, { entries: kept });
331
- return { removed };
372
+ return locked ?? { removed: 0 };
332
373
  } catch {
333
374
  return { removed: 0 };
334
375
  }
@@ -697,6 +697,13 @@ async function resolveChangedHintUpdates(rootDir, changedPaths, previousRecordsB
697
697
  if (!isSafeContainedRelative(rootDir, relativePath)) {
698
698
  return null;
699
699
  }
700
+ // Hints under directories discovery never indexes are no-ops, not trust
701
+ // failures: `node_modules/`, `.cache/` and friends bypassed this lane and
702
+ // entered the index as phantom rows that kept the index perpetually stale.
703
+ // Same predicate as collectGitTrackedFiles.
704
+ if (relativePath.split('/').some((segment) => EXCLUDED_DIR_NAMES.has(segment))) {
705
+ continue;
706
+ }
700
707
 
701
708
  const absolutePath = path.join(rootDir, relativePath);
702
709
  let stat = null;
@@ -31,6 +31,17 @@ async function readArtifact(rootDir, artifactName) {
31
31
  }
32
32
  }
33
33
 
34
+ // Returns the artifact's item rows as plain objects, dropping poisoned rows
35
+ // (null, undefined, primitives). Hand-corrupted or truncated `.cache/index/*.json`
36
+ // files can carry `items: [null]` or `items: "corrupted"` — every consumer that
37
+ // dereferences `item.filePath` must see only object rows.
38
+ function safeItems(artifact) {
39
+ if (!artifact || typeof artifact !== 'object' || !Array.isArray(artifact.items)) {
40
+ return [];
41
+ }
42
+ return artifact.items.filter((entry) => entry && typeof entry === 'object');
43
+ }
44
+
34
45
  function normalizePath(filePath) {
35
46
  return String(filePath ?? '').trim().replace(/\\/g, '/').replace(/^\.\//, '');
36
47
  }
@@ -93,9 +104,8 @@ export async function resolveImpactContext({
93
104
  readArtifact(absoluteRoot, INDEX_ARTIFACTS.files),
94
105
  loadRelatedTestArtifacts({ rootDir: absoluteRoot }),
95
106
  ]);
96
-
97
- const callItems = callsArtifact.items ?? [];
98
- const importItems = importsArtifact.items ?? [];
107
+ const callItems = safeItems(callsArtifact);
108
+ const importItems = safeItems(importsArtifact);
99
109
  const risk = classifyImpactRisk(normalizedChangedFiles);
100
110
  const mirrorCounterparts = findMirrorCounterparts(normalizedChangedFiles).slice(0, budget.maxMirrors);
101
111
 
@@ -145,8 +155,7 @@ export async function resolveImpactContext({
145
155
  // Non-relative specifiers (aliases like `@/x`, `~/x`, tsconfig paths) resolve
146
156
  // through the same machinery the index builder uses; relative specifiers keep
147
157
  // the candidate-list match so a changed file absent from files.json (e.g. a
148
- // brand-new file) still counts.
149
- const indexedFileSet = new Set((filesArtifact.items ?? []).map((item) => item.filePath));
158
+ const indexedFileSet = new Set(safeItems(filesArtifact).map((item) => item.filePath));
150
159
  const importAliasContext = importsNeedAliasContext(importItems)
151
160
  ? await loadImportAliasContext({ rootDir: absoluteRoot }).catch(() => null)
152
161
  : null;
@@ -19,19 +19,48 @@ import { renderTemplateString } from './renderTemplate.js';
19
19
 
20
20
  export const PREAMBLE_KEY = '__preamble__';
21
21
 
22
- const SECTION_HEADING_RE = /^## (.+)$/gm;
22
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
23
+ const FENCE_CLOSE_RES = {
24
+ '`': /^ {0,3}(`{3,})\s*$/,
25
+ '~': /^ {0,3}(~{3,})\s*$/,
26
+ };
23
27
  const LEFTOVER_TOKEN_RE = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/;
24
28
 
25
29
  /**
26
30
  * Split markdown into a Map<heading, body> at `## ` boundaries.
27
- * `### ` and deeper stay inside the enclosing body. Leading non-## text lands
28
- * under the reserved `__preamble__` key (absent when the file starts with `## `).
29
- * Throws on a duplicate `## ` heading, naming it.
31
+ * The reserved key PREAMBLE_KEY holds the leading non-## block verbatim.
32
+ * Bodies are raw slices (leading+trailing whitespace byte-meaningful).
33
+ *
34
+ * FR-017 (SPEC §5): heading detection is fence-aware — a `## ` line inside a
35
+ * fenced code block is content, not a boundary. Fences follow CommonMark: the
36
+ * opener is ``` or ~~~ indented ≤3 spaces; the closer uses the same character
37
+ * at the same or greater length with nothing after it. Duplicate `## `
38
+ * headings warn and keep the FIRST boundary (the duplicate line and its text
39
+ * stay inside the first section's verbatim body — no content is dropped).
30
40
  */
31
41
  export function parseSections(markdown) {
42
+ // Collect `## ` heading boundaries by line scan, tracking fence state.
43
+ // Byte offsets are maintained manually so bodies stay raw slices.
44
+ const boundaries = []; // {index, matchLength, heading}
45
+ let fence = null; // {char, length} of the open fence
46
+ let offset = 0;
47
+ for (const line of markdown.split('\n')) {
48
+ if (fence) {
49
+ const close = line.match(FENCE_CLOSE_RES[fence.char]);
50
+ if (close && close[1].length >= fence.length) fence = null;
51
+ } else {
52
+ const open = line.match(FENCE_OPEN_RE);
53
+ if (open) {
54
+ fence = { char: open[1][0], length: open[1].length };
55
+ } else if (line.startsWith('## ') && line.length > 3) {
56
+ boundaries.push({ index: offset, matchLength: line.length, heading: line.slice(3).trimEnd() });
57
+ }
58
+ }
59
+ offset += line.length + 1;
60
+ }
61
+
32
62
  const sections = new Map();
33
- const matches = [...markdown.matchAll(SECTION_HEADING_RE)];
34
- const first = matches[0];
63
+ const first = boundaries[0];
35
64
 
36
65
  const preamble = first ? markdown.slice(0, first.index) : markdown;
37
66
  if (preamble.trim().length > 0) {
@@ -40,18 +69,30 @@ export function parseSections(markdown) {
40
69
  sections.set(PREAMBLE_KEY, preamble);
41
70
  }
42
71
 
43
- for (let i = 0; i < matches.length; i++) {
44
- const m = matches[i];
45
- const heading = m[1].trimEnd();
46
- if (sections.has(heading)) {
47
- throw new Error(`duplicate ## heading in source: ${heading}`);
72
+ // Seed with the reserved key so a literal `## __preamble__` heading after a
73
+ // preamble degrades to keep-first instead of overwriting it.
74
+ const seen = new Set(sections.has(PREAMBLE_KEY) ? [PREAMBLE_KEY] : []);
75
+ const heads = [];
76
+ for (const b of boundaries) {
77
+ if (seen.has(b.heading)) {
78
+ console.warn(
79
+ `instructionRenderer: duplicate ## heading '${b.heading}' — keeping first occurrence`,
80
+ );
81
+ continue;
48
82
  }
49
- const bodyStart = m.index + m[0].length;
50
- const bodyEnd = i + 1 < matches.length ? matches[i + 1].index : markdown.length;
83
+ seen.add(b.heading);
84
+ heads.push(b);
85
+ }
86
+
87
+ for (let i = 0; i < heads.length; i++) {
88
+ const h = heads[i];
89
+ const bodyStart = h.index + h.matchLength;
90
+ const bodyEnd = i + 1 < heads.length ? heads[i + 1].index : markdown.length;
51
91
  // Raw slice up to (not including) the next `## ` heading — leading AND
52
92
  // trailing whitespace are byte-meaningful (marker line vs blank line
53
- // under the heading; multi-blank separators between sections).
54
- sections.set(heading, markdown.slice(bodyStart, bodyEnd));
93
+ // under the heading; multi-blank separators between sections). A skipped
94
+ // duplicate boundary leaves its line inside this slice.
95
+ sections.set(h.heading, markdown.slice(bodyStart, bodyEnd));
55
96
  }
56
97
 
57
98
  return sections;
@@ -111,8 +152,10 @@ export function renderOutput(outputSpec, sources, vars = {}) {
111
152
  out = renderTemplateString(out, vars);
112
153
  const leftover = out.match(LEFTOVER_TOKEN_RE);
113
154
  if (leftover) {
114
- throw new Error(
115
- `output ${outputSpec.target}: unresolved template variable '{{${leftover[1]}}}'`,
155
+ // FR-017(d): warn + emit the token verbatim instead of bricking
156
+ // install/render on an imperfect user overlay.
157
+ console.warn(
158
+ `instructionRenderer: output '${outputSpec.target}' has unresolved template variable '{{${leftover[1]}}}' — emitted verbatim`,
116
159
  );
117
160
  }
118
161
  }
@@ -124,8 +167,10 @@ export function renderOutput(outputSpec, sources, vars = {}) {
124
167
  * Source names are looked up as `sources.get(ref.from)`; overlay sources are
125
168
  * named `overlay:<file-stem>` by convention in renderInstructionsFromDisk.
126
169
  * `varsByResolve` supplies variables to outputs with resolve_vars: true.
127
- * FR-006 strictness: a `## ` section in any source referenced by zero outputs
128
- * throws, naming the heading.
170
+ * FR-006 strictness: a `## ` section referenced by zero outputs throws for
171
+ * shipped sources, preserving the layout contract. FR-017(c): the same
172
+ * condition in a USER overlay (`overlay:*` source name) downgrades to
173
+ * console.warn + keep — an imperfect repo.md must not brick install/render.
129
174
  */
130
175
  export function renderAll(layout, sourceTexts, varsByResolve = {}) {
131
176
  if (!layout || !Array.isArray(layout.outputs) || layout.outputs.length === 0) {
@@ -156,9 +201,16 @@ export function renderAll(layout, sourceTexts, varsByResolve = {}) {
156
201
  }
157
202
  }
158
203
  for (const [name, source] of sources) {
204
+ const isOverlay = name.startsWith('overlay:');
159
205
  for (const heading of source.keys()) {
160
206
  if (heading === PREAMBLE_KEY) continue;
161
207
  if (!referenced.get(name) || !referenced.get(name).has(heading)) {
208
+ if (isOverlay) {
209
+ console.warn(
210
+ `instructionRenderer: overlay source '${name}' has ## section '${heading}' referenced by zero outputs — kept`,
211
+ );
212
+ continue;
213
+ }
162
214
  throw new Error(`source '${name}': ## section '${heading}' referenced by zero outputs`);
163
215
  }
164
216
  }