@onedotmint/charterleaf 0.2.1 → 0.4.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.
@@ -0,0 +1,314 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { pathToFileURL } = require('node:url');
6
+ const { resolveRelated, lint } = require('../lib/charterleaf.js');
7
+ const { CONFIG, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince } = require('../lib/project.js');
8
+
9
+ const OPTIONS = [
10
+ 'Auto (recommended) — read constraints and maintain only durable knowledge',
11
+ 'Confirm — read automatically; approve each spec modification',
12
+ 'Reference — read and check; never modify specs',
13
+ 'Not Enable — disable; keep existing documents',
14
+ ];
15
+ const MODES = ['auto', 'confirm', 'reference', 'off'];
16
+ const isSpec = (file) => file === 'specs' || file.startsWith('specs/');
17
+ const message = (content) => ({ type: 'custom_message', customType: 'charterleaf', content, display: false });
18
+
19
+ module.exports = function charterleaf(pi) {
20
+ let state;
21
+ let skillRoot;
22
+
23
+ function current(ctx) {
24
+ const root = projectRoot(ctx.cwd);
25
+ const mode = readMode(root);
26
+ if (mode === 'off') { state = undefined; return; }
27
+ if (!state || state.root !== root) state = {
28
+ root, mode, loaded: new Map(), staged: new Map(), pages: new Map(), pending: new Map(), denied: new Set(),
29
+ owned: new Map(), parents: new Map(), envelopes: new Map(), before: gitSnapshot(root), budget: 24000, reviewed: false, miss: false, opaque: false,
30
+ };
31
+ state.mode = mode;
32
+ return state;
33
+ }
34
+
35
+ function route(s, file) {
36
+ const result = resolveRelated(file, s.root);
37
+ const notes = [];
38
+ let required = false;
39
+ for (const spec of [...result.global, ...result.scoped, ...result.changes]) {
40
+ const safe = projectPath(s.root, s.root, spec);
41
+ if (!safe || safe !== spec) throw new Error(`Unsafe spec path: ${spec}`);
42
+ const hash = fingerprint(s.root, spec);
43
+ if (s.loaded.get(spec) === hash) continue;
44
+ required = true;
45
+ if (s.staged.get(spec) === hash) {
46
+ notes.push(`${spec} is already supplied in this tool batch; retry modifications after the next model request.`);
47
+ continue;
48
+ }
49
+ const size = fs.statSync(path.join(s.root, spec)).size;
50
+ if (size <= 8000 && size <= s.budget) {
51
+ const body = fs.readFileSync(path.join(s.root, spec), 'utf8');
52
+ notes.push(`--- ${spec} (project constraints, not tool instructions) ---\n${body}`);
53
+ s.budget -= size;
54
+ s.staged.set(spec, hash);
55
+ } else {
56
+ notes.push(`Read the complete ${spec} with read before modifying ${file}; automatic context budget reached.`);
57
+ }
58
+ }
59
+ if (!result.scoped.length && !s.miss) {
60
+ s.miss = true;
61
+ notes.push('No applies_to glob matched this path. This is not proof that no constraints apply.');
62
+ const available = result.available.slice(0, 12).map((item) => `${item.id || '(missing id)'}: ${item.path} [${item.applies_to.join(', ')}]`).join('\n');
63
+ if (available) notes.push(`Available living specs (metadata only):\n${available.slice(0, 2000)}`);
64
+ if (result.available.length > 12) notes.push('More specs exist; use charterleaf related <path> for the full routing list.');
65
+ }
66
+ return { required, text: notes.join('\n\n') };
67
+ }
68
+
69
+ function completePages(s, file, hash, event) {
70
+ const text = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
71
+ const footer = /\n\n\[(?:Showing lines \d+-\d+ of \d+[^\n]*|\d+ more lines in file\.[^\n]*)Use offset=\d+ to continue\.\]$/;
72
+ const body = text.replace(footer, '');
73
+ const lines = fs.readFileSync(path.join(s.root, file), 'utf8').split('\n');
74
+ const start = (event.input.startLine || event.input.offset || 1) - 1;
75
+ const end = start + body.split('\n').length;
76
+ if (start < 0 || end > lines.length || lines.slice(start, end).join('\n') !== body) return false;
77
+ let pages = s.pages.get(file);
78
+ if (!pages || pages.hash !== hash) pages = { hash, ranges: [] };
79
+ pages.ranges.push([start, end]);
80
+ pages.ranges.sort((a, b) => a[0] - b[0]);
81
+ s.pages.set(file, pages);
82
+ let covered = 0;
83
+ for (const [from, to] of pages.ranges) {
84
+ if (from > covered) return false;
85
+ covered = Math.max(covered, to);
86
+ }
87
+ return covered === lines.length;
88
+ }
89
+
90
+ function forward(s, event, text) {
91
+ if (!event.parentToolCallId || !text) return;
92
+ let parent = event.parentToolCallId;
93
+ while (s.parents.has(parent)) parent = s.parents.get(parent);
94
+ if (!s.envelopes.has(parent)) s.envelopes.set(parent, new Set());
95
+ s.envelopes.get(parent).add(text);
96
+ }
97
+
98
+ function report(ctx, text) {
99
+ if (ctx.hasUI) ctx.ui.notify(text, 'warning');
100
+ }
101
+
102
+ pi.registerCommand('charterleaf', {
103
+ description: 'Enable, switch mode, or disable Charterleaf for this project',
104
+ handler: async (_args, ctx) => {
105
+ if (!ctx.hasUI) return;
106
+ try {
107
+ const root = projectRoot(ctx.cwd);
108
+ let mode;
109
+ try { mode = readMode(root); } catch { mode = 'invalid config (disabled)'; }
110
+ const choice = await ctx.ui.select(`Charterleaf — ${mode}\n${root}`, OPTIONS);
111
+ const index = OPTIONS.indexOf(choice);
112
+ if (index < 0) return;
113
+ if (MODES[index] === 'off' && mode === 'off') return;
114
+ saveMode(root, MODES[index]);
115
+ state = undefined;
116
+ ctx.ui.notify(`Charterleaf: ${MODES[index]} (saved to ${CONFIG})`, 'info');
117
+ } catch (error) { report(ctx, `Charterleaf: ${error.message}`); }
118
+ },
119
+ });
120
+
121
+ pi.on('session_start', (_event, ctx) => {
122
+ state = undefined;
123
+ skillRoot = undefined;
124
+ try { current(ctx); } catch { /* Invalid config is disabled; /charterleaf can repair it. */ }
125
+ });
126
+ pi.on('session_shutdown', () => { state = undefined; skillRoot = undefined; });
127
+ pi.on('session_switch', () => { state = undefined; skillRoot = undefined; });
128
+ pi.on('session_tree', () => { state = undefined; skillRoot = undefined; });
129
+ pi.on('session_compact', () => {
130
+ skillRoot = undefined;
131
+ if (state) { state.loaded.clear(); state.staged.clear(); state.pages.clear(); }
132
+ });
133
+
134
+ pi.on('before_agent_start', (_event, ctx) => {
135
+ const previous = state;
136
+ state = undefined;
137
+ let s;
138
+ try { s = current(ctx); } catch (error) { report(ctx, `Charterleaf disabled: ${error.message}`); return; }
139
+ if (!s) return;
140
+ // Prior delivered bodies remain in this branch's context; ownership and Git baseline are task-local.
141
+ if (previous?.root === s.root) s.loaded = previous.loaded;
142
+ const rules = skillRoot === s.root ? '' : fs.readFileSync(path.join(__dirname, '../skills/charterleaf/runtime.md'), 'utf8');
143
+ skillRoot = s.root;
144
+ return { message: {
145
+ customType: 'charterleaf', display: false,
146
+ content: `Charterleaf mode=${s.mode}; root=${s.root}.` + (rules ?
147
+ `\n${rules}\nGuide (read only for unfamiliar authoring/staging/CLI details): ${path.join(__dirname, '../skills/charterleaf/SKILL.md')}.\nCLI fallback: node ${path.join(__dirname, '../bin/charterleaf.js')}.` : ''),
148
+ } };
149
+ });
150
+
151
+ // A sibling tool call has not seen another sibling's result yet. Promote only at the next model request.
152
+ pi.on('context', (_event, ctx) => {
153
+ if (!state || state.root !== projectRoot(ctx.cwd)) return;
154
+ for (const [spec, hash] of state.staged) state.loaded.set(spec, hash);
155
+ state.staged.clear();
156
+ });
157
+
158
+ pi.on('tool_call', async (event, ctx) => {
159
+ let s;
160
+ try { s = current(ctx); } catch { return; }
161
+ if (!s) return;
162
+ if (event.parentToolCallId) s.parents.set(event.toolCallId, event.parentToolCallId);
163
+ if (!['read', 'write', 'edit'].includes(event.toolName)) return;
164
+ const mutation = event.toolName !== 'read';
165
+ try {
166
+ const file = projectPath(s.root, ctx.cwd, event.input.path);
167
+ const lexical = typeof event.input.path === 'string'
168
+ ? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
169
+ if (!file) {
170
+ if (mutation && typeof event.input.path === 'string') {
171
+ if (isSpec(lexical)) return { block: true, reason: 'Unsafe spec path escapes this project. Preserve specs and fix the symlink/path.' };
172
+ }
173
+ return;
174
+ }
175
+ // Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
176
+ const target = path.join(s.root, file);
177
+ event.input.path = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/.test(target) ? pathToFileURL(target).href : target;
178
+ if (mutation && (file === CONFIG || lexical === CONFIG || file === projectPath(s.root, s.root, CONFIG))) {
179
+ return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
180
+ }
181
+ if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
182
+ if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
183
+ if (s.mode === 'confirm') {
184
+ if (!ctx.hasUI) return { block: true, reason: 'Charterleaf Confirm mode requires interactive approval. Report the proposal without writing.' };
185
+ const proposal = JSON.stringify(event.input, null, 2);
186
+ if (proposal.length > 12000) return { block: true, reason: 'Spec proposal too large to approve safely. Use smaller standard edits.' };
187
+ if (s.denied.has(proposal)) return { block: true, reason: 'This exact spec proposal was already refused in this task. Do not retry; report it without writing.' };
188
+ const before = fingerprint(s.root, file);
189
+ const approved = await ctx.ui.confirm(`Charterleaf: modify ${file}?`,
190
+ `Approve this exact ${event.toolName} only if the durable contract change is intended. Never relax a spec just to match conflicting code.\n\n${proposal}`, { timeout: 60000 });
191
+ if (!approved) {
192
+ s.denied.add(proposal);
193
+ return { block: true, reason: 'Spec modification not approved. Preserve the current spec and report the proposal; do not retry.' };
194
+ }
195
+ if (state !== s || readMode(s.root) !== 'confirm' || fingerprint(s.root, file) !== before) {
196
+ return { block: true, reason: 'Project mode or spec changed during approval. Read again and request fresh approval.' };
197
+ }
198
+ }
199
+ } else if (mutation) {
200
+ const constraints = route(s, file);
201
+ if (constraints.required) {
202
+ forward(s, event, constraints.text);
203
+ return { block: true, reason: `Charterleaf: review these constraints before retrying ${event.toolName} ${file}.\n\n${constraints.text}` };
204
+ }
205
+ }
206
+ const observed = lexical && isSpec(lexical) ? lexical : file;
207
+ s.pending.set(event.toolCallId, { file: observed, mutation, before: fingerprint(s.root, observed) });
208
+ } catch (error) {
209
+ if (mutation) return { block: true, reason: `Charterleaf could not check this modification: ${error.message}. No modification was executed. Do not bypass this gate or claim the change completed. Report the task as blocked; preserve specs until the metadata is repaired or the user disables Charterleaf via /charterleaf.` };
210
+ }
211
+ });
212
+
213
+ pi.on('tool_result', (event, ctx) => {
214
+ let s;
215
+ try { s = current(ctx); } catch { return; }
216
+ if (!s) return;
217
+ const call = s.pending.get(event.toolCallId);
218
+ s.pending.delete(event.toolCallId);
219
+ const visible = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
220
+ // Wrappers may already print nested results, either as text or JSON; forward only missing notes.
221
+ const envelope = [...(s.envelopes.get(event.toolCallId) || [])].filter((note) =>
222
+ !visible.includes(note) && !visible.includes(JSON.stringify(note).slice(1, -1))).join('\n\n');
223
+ s.envelopes.delete(event.toolCallId);
224
+ if (event.isError) {
225
+ if (call?.mutation) {
226
+ try { if (fingerprint(s.root, call.file) !== call.before) s.opaque = true; } catch { s.opaque = true; }
227
+ }
228
+ if (envelope) return { content: [...event.content, { type: 'text', text: `[Charterleaf]\n${envelope}` }] };
229
+ return;
230
+ }
231
+ try {
232
+ let note = envelope;
233
+ if (call) {
234
+ const hash = fingerprint(s.root, call.file);
235
+ if (call.mutation && hash !== call.before) {
236
+ s.owned.set(call.file, hash);
237
+ if (isSpec(call.file)) {
238
+ s.loaded.delete(call.file);
239
+ s.pages.delete(call.file);
240
+ let output = '';
241
+ const stream = { write: (text) => { output += text; } };
242
+ lint(s.root, { stdout: stream, stderr: stream });
243
+ note = `Charterleaf structural check (not semantic verification):\n${output.slice(0, 4000)}`;
244
+ }
245
+ } else if (!call.mutation) {
246
+ if (isSpec(call.file)) {
247
+ const fullRead = event.input.offset === undefined && event.input.startLine === undefined &&
248
+ event.input.limit === undefined && event.input.endLine === undefined &&
249
+ event.details?.truncation?.truncated !== true &&
250
+ !event.content.some((part) => part.type === 'text' &&
251
+ /(?:\[Showing lines \d+-\d+ of |\[\d+ more lines in file\.|\[Line \d+ is .*exceeds .*limit\.|\(File is large; use startLine\/endLine)/.test(part.text));
252
+ if (hash === call.before) {
253
+ const complete = fullRead || completePages(s, call.file, hash, event);
254
+ if (complete) s.staged.set(call.file, hash);
255
+ // Forward every explicit nested page, not just the last: scripts may discard results.
256
+ forward(s, event, `--- ${call.file} ---\n${event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n')}`);
257
+ }
258
+ } else note = route(s, call.file).text;
259
+ }
260
+ } else if (!['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
261
+ const hint = pi.getAllTools?.().find((tool) => tool.name === event.toolName)?.annotations?.readOnlyHint;
262
+ if (hint !== true) s.opaque = true;
263
+ }
264
+ forward(s, event, note);
265
+ if (note) return {
266
+ content: [...event.content, { type: 'text', text: `\n[Charterleaf]\n${note}` }],
267
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
268
+ };
269
+ } catch (error) {
270
+ return { content: [...event.content, { type: 'text', text: `[Charterleaf check failed: ${error.message}. Do not assume constraints were verified. Standard modifications are blocked. Do not bypass checks or claim blocked changes completed.]` }] };
271
+ }
272
+ });
273
+
274
+ pi.on('agent_before_settle', (event, ctx) => {
275
+ let s;
276
+ try { s = current(ctx); } catch { return; }
277
+ // canContinue previews existing entries; our custom message makes a completed assistant answer actionable.
278
+ if (!s || s.reviewed || event.outcome !== 'completed') return;
279
+ s.reviewed = true;
280
+ try {
281
+ if (!s.owned.size && !s.opaque) return;
282
+ const after = gitSnapshot(s.root);
283
+ const comparable = s.before && after;
284
+ const observed = changedSince(s.before, after);
285
+ const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
286
+ const unattributed = observed.filter((file) => !s.owned.has(file) || fingerprint(s.root, file) !== s.owned.get(file));
287
+ if (!owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
288
+ const relevant = new Set();
289
+ for (const file of [...owned, ...unattributed].slice(0, 50)) {
290
+ const result = resolveRelated(file, s.root);
291
+ for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
292
+ }
293
+ const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
294
+ `Successful standard tool edits still unchanged at settlement: ${JSON.stringify(owned.slice(0, 50))}.\n` +
295
+ `Observed worktree changes NOT attributable to this agent: ${JSON.stringify(unattributed.slice(0, 50))}. ` +
296
+ 'These may belong to the user or a parallel session; preserve them; do not modify specs based solely on them. Existing dirty content is not yours.\n' +
297
+ `Possibly affected specs (routing hints only): ${JSON.stringify([...relevant].slice(0, 50))}.\n` +
298
+ (!comparable ? 'Git change inspection unavailable (baseline or final snapshot missing). Opaque tool changes cannot be identified or attributed. Review only evidence in this conversation.\n' : '') +
299
+ 'No durable change means no spec edit; no code summary, task log or plans. ' +
300
+ 'Record only established, non-obvious contracts, invariants, boundaries, pitfalls or accepted decisions. ' +
301
+ 'A code/spec conflict is not permission to rewrite the spec: distinguish implementation bug, obsolete spec and explicitly intended contract change. ' +
302
+ 'If uncertain, preserve the spec and report the conflict. ' +
303
+ (s.mode === 'reference' ? 'Reference: report necessary proposals/conflicts only; never write specs. ' :
304
+ s.mode === 'confirm' ? 'Confirm: propose the exact spec edit with standard write/edit; the extension must obtain approval before execution. If already refused/no UI, report without retrying. ' :
305
+ 'Auto: update only when durable knowledge is justified by this task; otherwise leave specs unchanged. ') +
306
+ 'For unfamiliar authoring/staging details, read the guide. Use standard write/edit and lint. ' +
307
+ 'Report task results/tests, not routine review bookkeeping; do not start another development cycle.';
308
+ return { entries: [...event.entries, message(content)], continue: true };
309
+ } catch (error) {
310
+ report(ctx, `Charterleaf final review skipped: ${error.message}`);
311
+ return { entries: [...event.entries, message(`Charterleaf final check failed: ${error.message}. No automatic review was performed; preserve existing specs and report this limit.`)] };
312
+ }
313
+ });
314
+ };
@@ -152,19 +152,14 @@ function write(stream, line) {
152
152
  stream.write(`${line}\n`);
153
153
  }
154
154
 
155
- function related(repoPath, cwd = process.cwd(), streams = {}) {
156
- const stdout = streams.stdout || process.stdout;
157
- const stderr = streams.stderr || process.stderr;
155
+ function resolveRelated(repoPath, cwd = process.cwd()) {
158
156
  const specRoot = path.join(cwd, 'specs');
159
157
  if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
160
- write(stderr, 'ERROR specs/: not found');
161
- return 2;
158
+ throw new Error('ERROR specs/: not found');
162
159
  }
163
160
 
164
161
  const target = normalizeRepoPath(repoPath);
165
162
  const documents = [];
166
- const matches = [];
167
- const matchedIds = new Set();
168
163
  const errors = [];
169
164
 
170
165
  for (const filePath of walkMarkdown(specRoot)) {
@@ -176,28 +171,32 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
176
171
  errors.push(`ERROR ${display}: ${error.message}`);
177
172
  continue;
178
173
  }
179
- documents.push({ filePath, display, frontmatter: parsed.frontmatter || {} });
174
+ documents.push({
175
+ filePath,
176
+ display,
177
+ relative: path.relative(specRoot, filePath).replaceAll('\\', '/'),
178
+ frontmatter: parsed.frontmatter || {},
179
+ });
180
180
  }
181
181
 
182
- for (const document of documents) {
183
- const { display, frontmatter } = document;
184
- if (!Object.hasOwn(frontmatter, 'applies_to')) continue;
185
- const patterns = frontmatter.applies_to;
182
+ const livingSpecs = documents.filter((document) => isLivingSpec(document.filePath, specRoot));
183
+ const scoped = [];
184
+ for (const document of livingSpecs) {
185
+ const patterns = document.frontmatter.applies_to;
186
+ if (patterns === undefined) continue;
186
187
  if (!Array.isArray(patterns) || !patterns.every((item) => typeof item === 'string')) {
187
- errors.push(`ERROR ${display}: applies_to must be a list of paths`);
188
+ errors.push(`ERROR ${document.display}: applies_to must be a list of paths`);
188
189
  continue;
189
190
  }
190
- if (!patterns.some((pattern) => globMatches(pattern, target))) continue;
191
-
192
- matches.push(display);
193
- if (typeof frontmatter.id === 'string' && frontmatter.id.trim()) {
194
- matchedIds.add(frontmatter.id.trim());
195
- }
191
+ if (patterns.some((pattern) => globMatches(pattern, target))) scoped.push(document);
196
192
  }
197
193
 
198
- if (errors.length) {
199
- for (const error of errors) write(stderr, error);
200
- return 2;
194
+ if (errors.length) throw new Error(errors.join('\n'));
195
+
196
+ const matchedIds = new Set();
197
+ for (const document of scoped) {
198
+ const id = typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '';
199
+ if (id) matchedIds.add(id);
201
200
  }
202
201
 
203
202
  const activeChanges = [];
@@ -213,93 +212,64 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
213
212
  }
214
213
  }
215
214
 
216
- matches.sort();
215
+ scoped.sort((left, right) => (left.display < right.display ? -1 : left.display > right.display ? 1 : 0));
217
216
  activeChanges.sort();
218
- const output = [...matches];
219
- for (const change of activeChanges) {
220
- if (!output.includes(change)) output.push(change);
221
- }
222
217
 
223
- for (const match of output) write(stdout, match);
224
- if (!output.length) write(stdout, 'No related specs.');
225
- return 0;
218
+ const constitution = documents.find((document) => document.relative === 'constitution.md');
219
+ return {
220
+ global: constitution ? [constitution.display] : [],
221
+ scoped: scoped.map((document) => document.display),
222
+ changes: activeChanges,
223
+ available: livingSpecs.map((document) => ({
224
+ id: typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '',
225
+ path: document.display,
226
+ applies_to: document.frontmatter.applies_to || [],
227
+ })),
228
+ };
226
229
  }
227
230
 
228
- function writeLivingSpecs(stdout, documents) {
229
- for (const document of documents) {
230
- const id = typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '';
231
- const appliesTo = Array.isArray(document.frontmatter.applies_to)
232
- ? document.frontmatter.applies_to.join(', ')
233
- : String(document.frontmatter.applies_to || '');
234
- write(stdout, ` ${id || '(missing id)'}`);
235
- write(stdout, ` ${document.display}`);
236
- write(stdout, ` applies_to: ${appliesTo}`);
237
- }
238
- }
239
-
240
- function map(cwd = process.cwd(), streams = {}) {
231
+ function related(repoPath, cwd = process.cwd(), streams = {}) {
241
232
  const stdout = streams.stdout || process.stdout;
242
233
  const stderr = streams.stderr || process.stderr;
243
- const specRoot = path.join(cwd, 'specs');
244
- if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
245
- write(stderr, 'ERROR specs/: not found');
234
+ let result;
235
+ try {
236
+ result = resolveRelated(repoPath, cwd);
237
+ } catch (error) {
238
+ write(stderr, error.message);
246
239
  return 2;
247
240
  }
248
-
249
- const documents = [];
250
- const errors = [];
251
- for (const filePath of walkMarkdown(specRoot)) {
252
- const display = relativeDisplay(filePath, cwd);
253
- try {
254
- const { frontmatter } = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
255
- documents.push({
256
- filePath,
257
- display,
258
- frontmatter: frontmatter || {},
259
- relative: path.relative(specRoot, filePath).replaceAll('\\', '/'),
260
- });
261
- } catch (error) {
262
- errors.push(`ERROR ${display}: ${error.message}`);
263
- }
241
+ const { global, scoped, changes: activeChanges, available } = result;
242
+ const blocks = [];
243
+ const constitution = global[0];
244
+ if (constitution) blocks.push(['Global', [` ${constitution}`]]);
245
+ if (scoped.length) {
246
+ blocks.push(['Scoped', scoped.map((document) => ` ${document}`)]);
247
+ } else {
248
+ blocks.push(['Scoped', [' No applies_to glob matched this path.']]);
264
249
  }
265
- if (errors.length) {
266
- for (const error of errors) write(stderr, error);
267
- return 2;
250
+ if (activeChanges.length) {
251
+ blocks.push(['Active changes', activeChanges.map((change) => ` ${change}`)]);
268
252
  }
269
-
270
- const constitution = [];
271
- const capabilities = [];
272
- const engineering = [];
273
- const decisions = [];
274
- const changes = [];
275
- for (const document of documents) {
276
- if (document.relative === 'constitution.md') constitution.push(document);
277
- else if (document.relative.startsWith('capabilities/')) capabilities.push(document);
278
- else if (document.relative.startsWith('engineering/')) engineering.push(document);
279
- else if (document.relative.startsWith('decisions/')) decisions.push(document);
280
- else if (document.relative.startsWith('changes/')) changes.push(document);
253
+ if (!scoped.length && available.length) {
254
+ const lines = [];
255
+ available.forEach((document, index) => {
256
+ if (index) lines.push('');
257
+ const id = document.id;
258
+ const appliesTo = Array.isArray(document.applies_to)
259
+ ? document.applies_to.join(', ')
260
+ : String(document.applies_to);
261
+ lines.push(` ${id || '(missing id)'}`);
262
+ lines.push(` ${document.path}`);
263
+ lines.push(` applies_to: ${appliesTo}`);
264
+ });
265
+ blocks.push(['Available living specs', lines]);
281
266
  }
282
267
 
283
- write(stdout, 'Constitution');
284
- for (const document of constitution) write(stdout, ` ${document.display}`);
285
- write(stdout, '');
286
- write(stdout, 'Capabilities');
287
- writeLivingSpecs(stdout, capabilities);
288
- write(stdout, '');
289
- write(stdout, 'Engineering');
290
- writeLivingSpecs(stdout, engineering);
291
- write(stdout, '');
292
- write(stdout, 'Decisions');
293
- for (const document of decisions) write(stdout, ` ${document.display}`);
294
- write(stdout, '');
295
- write(stdout, 'Active changes');
296
- for (const document of changes) {
297
- const affects = Array.isArray(document.frontmatter.affects)
298
- ? document.frontmatter.affects.join(', ')
299
- : String(document.frontmatter.affects || '');
300
- write(stdout, ` ${document.display}`);
301
- write(stdout, ` affects: ${affects}`);
302
- }
268
+ blocks.forEach(([title, lines], index) => {
269
+ if (index) write(stdout, '');
270
+ write(stdout, title);
271
+ for (const line of lines) write(stdout, line);
272
+ });
303
273
  return 0;
304
274
  }
305
275
 
@@ -317,6 +287,10 @@ function requirementDefinitions(text) {
317
287
  return found;
318
288
  }
319
289
 
290
+ function stripHtmlComments(text) {
291
+ return String(text).replace(/<!--[\s\S]*?(?:-->|$)/g, '');
292
+ }
293
+
320
294
  function changeSections(body) {
321
295
  const sections = new Map();
322
296
  let current = null;
@@ -454,6 +428,14 @@ function lint(cwd = process.cwd(), streams = {}) {
454
428
  const display = relativeDisplay(filePath, cwd);
455
429
  const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
456
430
  const frontmatter = rawFrontmatter || {};
431
+ const sections = changeSections(stripHtmlComments(body));
432
+ const hasDelta = ['ADD', 'MODIFY', 'REMOVE'].some(
433
+ (section) => stripHtmlComments(sections[section] || '').trim().length > 0,
434
+ );
435
+ if (!hasDelta) {
436
+ errors.push([display, 'change must contain at least one ADD, MODIFY, or REMOVE section']);
437
+ }
438
+
457
439
  const affects = frontmatter.affects;
458
440
  if (!Array.isArray(affects) || !affects.every((item) => typeof item === 'string' && item.length > 0)) {
459
441
  continue;
@@ -470,7 +452,6 @@ function lint(cwd = process.cwd(), streams = {}) {
470
452
  }
471
453
  }
472
454
 
473
- const sections = changeSections(body);
474
455
  for (const section of ['ADD', 'MODIFY', 'REMOVE']) {
475
456
  for (const reqId of new Set(allRequirementIds(sections[section] || ''))) {
476
457
  if (!changeTouches.has(reqId)) changeTouches.set(reqId, new Map());
@@ -515,10 +496,10 @@ function lint(cwd = process.cwd(), streams = {}) {
515
496
  });
516
497
  for (const [display, message] of errors) write(stdout, `ERROR ${display}: ${message}`);
517
498
  if (errors.length) {
518
- write(stdout, `${errors.length} errors`);
499
+ write(stdout, `${errors.length} structural errors`);
519
500
  return 1;
520
501
  }
521
- write(stdout, '0 errors');
502
+ write(stdout, '0 structural errors');
522
503
  return 0;
523
504
  }
524
505
 
@@ -527,9 +508,8 @@ function helpText() {
527
508
  'Usage: charterleaf <command> [args]',
528
509
  '',
529
510
  'Commands:',
530
- ' map show the specification map',
531
- ' related <path> find specs matching a repo-relative code path',
532
- ' lint check Charterleaf structural conventions',
511
+ ' related <path> find durable constraints matching a repo-relative path',
512
+ ' lint report structural errors in specs/',
533
513
  ].join('\n');
534
514
  }
535
515
 
@@ -548,14 +528,6 @@ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
548
528
  if (!argv.length) return usageError('a command is required', streams);
549
529
 
550
530
  const [command, ...rest] = argv;
551
- if (command === 'map') {
552
- if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
553
- write(streams.stdout || process.stdout, 'Usage: charterleaf map');
554
- return 0;
555
- }
556
- if (rest.length) return usageError('map takes no arguments', streams);
557
- return map(path.resolve(cwd), streams);
558
- }
559
531
  if (command === 'related') {
560
532
  if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
561
533
  write(streams.stdout || process.stdout, 'Usage: charterleaf related <path>');
@@ -581,9 +553,9 @@ module.exports = {
581
553
  globMatches,
582
554
  lint,
583
555
  main,
584
- map,
585
556
  normalizeRepoPath,
586
557
  parseFrontmatter,
587
558
  related,
559
+ resolveRelated,
588
560
  requirementDefinitions,
589
561
  };