@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.
- package/AGENTS_SNIPPET.md +34 -29
- package/README.md +327 -55
- package/bin/charterleaf.js +0 -0
- package/extensions/charterleaf.js +314 -0
- package/lib/charterleaf.js +84 -112
- package/lib/project.js +142 -0
- package/package.json +10 -4
- package/skills/charterleaf/SKILL.md +20 -41
- package/skills/charterleaf/runtime.md +11 -0
- package/templates/change.md +10 -0
|
@@ -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
|
+
};
|
package/lib/charterleaf.js
CHANGED
|
@@ -152,19 +152,14 @@ function write(stream, line) {
|
|
|
152
152
|
stream.write(`${line}\n`);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
function
|
|
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
|
-
|
|
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({
|
|
174
|
+
documents.push({
|
|
175
|
+
filePath,
|
|
176
|
+
display,
|
|
177
|
+
relative: path.relative(specRoot, filePath).replaceAll('\\', '/'),
|
|
178
|
+
frontmatter: parsed.frontmatter || {},
|
|
179
|
+
});
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 (
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 (
|
|
266
|
-
|
|
267
|
-
return 2;
|
|
250
|
+
if (activeChanges.length) {
|
|
251
|
+
blocks.push(['Active changes', activeChanges.map((change) => ` ${change}`)]);
|
|
268
252
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
'
|
|
531
|
-
'
|
|
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
|
};
|