@onedotmint/charterleaf 0.3.0 → 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 +25 -5
- package/README.md +200 -24
- package/extensions/charterleaf.js +314 -0
- package/lib/charterleaf.js +39 -20
- package/lib/project.js +142 -0
- package/package.json +10 -4
- package/skills/charterleaf/SKILL.md +20 -132
- package/skills/charterleaf/runtime.md +11 -0
package/AGENTS_SNIPPET.md
CHANGED
|
@@ -4,11 +4,31 @@ Use this file as the project navigation entry.
|
|
|
4
4
|
Document where authoritative information lives — project state,
|
|
5
5
|
validation, resources, history, and other project-specific sources.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
Source code owns implementation, tests own executable verification,
|
|
8
|
+
`AGENTS.md` owns navigation, and `specs/` owns durable constraints. Read
|
|
9
|
+
these authoritative owners progressively; stop when sufficient. Keep one
|
|
10
|
+
owner per fact and link to it rather than duplicating it.
|
|
11
|
+
|
|
12
|
+
Charterleaf automation is explicit opt-in, not implied by this entry or
|
|
13
|
+
by an existing `specs/` directory. In Pi, the user chooses Auto, Confirm,
|
|
14
|
+
Reference or Not Enable through `/charterleaf`; the saved project mode in
|
|
15
|
+
`specs/.charterleaf.json` controls automation. Missing config or `off`
|
|
16
|
+
means no automatic scan, adoption prompt or document creation. Do not
|
|
17
|
+
change activation config on the user's behalf.
|
|
18
|
+
|
|
19
|
+
For manual use on a concrete repository path, run from the project root:
|
|
9
20
|
|
|
10
21
|
charterleaf related <path>
|
|
11
22
|
|
|
12
|
-
Read
|
|
13
|
-
|
|
14
|
-
|
|
23
|
+
Read the returned constraints. A routing miss is not proof that none
|
|
24
|
+
apply. The manual CLI remains available independently of Pi and its mode
|
|
25
|
+
config; run `charterleaf lint` after spec edits (structural checks only).
|
|
26
|
+
|
|
27
|
+
With automation enabled, use supplied constraints or explicitly read the
|
|
28
|
+
named specs before retrying a gated write/edit. Use standard `write`/`edit`
|
|
29
|
+
for spec changes: Confirm requires each approval; Reference allows no
|
|
30
|
+
spec changes. Do not bypass these cooperative checks with Bash or another
|
|
31
|
+
writer. No durable change means no spec edit; never relax a spec merely
|
|
32
|
+
to accommodate conflicting code. Preserve user/parallel work and keep
|
|
33
|
+
state, plans, evidence and history in their existing authoritative owners,
|
|
34
|
+
not in a new Charterleaf workflow.
|
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# Charterleaf
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A lightweight agent-native constraint layer for coding agents.
|
|
4
|
+
|
|
5
|
+
**Code is implementation. Tests are verification. Specs are durable constraints.
|
|
6
|
+
Charterleaf connects them.**
|
|
4
7
|
|
|
5
8
|
Given a concrete repository path, Charterleaf answers one question:
|
|
6
9
|
|
|
7
10
|
> Which durable constraints should I read before touching this code?
|
|
8
11
|
|
|
9
12
|
Durable project intent, deterministic routing, and structural linting —
|
|
10
|
-
without workflow orchestration.
|
|
13
|
+
with opt-in native Pi integration, without workflow orchestration.
|
|
11
14
|
|
|
12
15
|
## Two layers
|
|
13
16
|
|
|
@@ -27,19 +30,121 @@ belongs to a project file.
|
|
|
27
30
|
|
|
28
31
|
## Install
|
|
29
32
|
|
|
30
|
-
Install the
|
|
33
|
+
Install once in Pi to discover the bundled Extension and Skill:
|
|
31
34
|
|
|
32
35
|
```bash
|
|
33
|
-
|
|
36
|
+
pi install npm:@onedotmint/charterleaf
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
**v0.4 is not yet published.** Until publication, install the local package
|
|
40
|
+
with `pi install /absolute/path/to/charterleaf` (a package directory, not a
|
|
41
|
+
`.tgz` file). For the standalone CLI, pack with `npm pack`, then use `npm install -g ./onedotmint-charterleaf-0.4.0.tgz`; after publication,
|
|
42
|
+
use `npm install -g @onedotmint/charterleaf`.
|
|
37
43
|
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
Restart Pi or run `/reload` after installation, then use `/charterleaf` in each
|
|
45
|
+
project you want to enable. To stop automation, choose **Not Enable** in that
|
|
46
|
+
menu. To remove the global package, use `pi remove npm:@onedotmint/charterleaf`
|
|
47
|
+
(or the same local directory source used for installation); project docs remain.
|
|
48
|
+
|
|
49
|
+
A Pi-only install needs no second global npm install: the Extension calls Core
|
|
50
|
+
directly and supplies the bundled CLI entry point for optional manual checks.
|
|
51
|
+
|
|
52
|
+
The **Core/CLI** runs standalone on Node.js **20+**, has no dependencies,
|
|
53
|
+
and requires no Pi. The **Pi Extension** is tested against
|
|
54
|
+
`@earendil-works/pi-coding-agent` **1.1.0** and uses `agent_before_settle`:
|
|
55
|
+
it requires a host with that compatible Extension API, not just any Pi
|
|
56
|
+
version. That Pi host requires Node.js **22.19.0+**; the Core's lower
|
|
57
|
+
minimum does not make older Node versions suitable for the host.
|
|
58
|
+
|
|
59
|
+
## Native Pi modes
|
|
60
|
+
|
|
61
|
+
Run `/charterleaf` in Pi for the native project mode menu:
|
|
62
|
+
|
|
63
|
+
| Choice | Behavior |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| **Auto** (recommended) | Read constraints; maintain only justified durable knowledge. |
|
|
66
|
+
| **Confirm** | Read automatically; approve each exact standard spec write/edit. |
|
|
67
|
+
| **Reference** | Read and check; report proposals or conflicts, never modify specs. |
|
|
68
|
+
| **Not Enable** | Turn automation off; keep existing documents. |
|
|
69
|
+
|
|
70
|
+
The choice is saved immediately in `specs/.charterleaf.json`:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{ "version": 1, "mode": "off" }
|
|
40
74
|
```
|
|
41
75
|
|
|
42
|
-
|
|
76
|
+
Valid modes are `auto`, `confirm`, `reference`, and `off`. Missing config
|
|
77
|
+
means **off**; existing `specs/` alone never enables automation. Activation
|
|
78
|
+
is an explicit user choice, not an Agent decision. Enabling creates only
|
|
79
|
+
this config (and its directory if needed), not a constitution or a pile
|
|
80
|
+
of spec documents. Disabling preserves documents; cancel changes nothing.
|
|
81
|
+
Disabled/unconfigured projects get no automatic adoption prompt, task
|
|
82
|
+
context, scan, or document bootstrap. Manual CLI use is independent of
|
|
83
|
+
this config.
|
|
84
|
+
|
|
85
|
+
Without UI, saved Auto and Reference modes still read/check constraints;
|
|
86
|
+
Confirm denies spec mutations because approval is unavailable. A refused identical
|
|
87
|
+
proposal does not reopen an approval dialog in the same task; a new user task
|
|
88
|
+
can make a fresh proposal. The
|
|
89
|
+
`/charterleaf` menu does nothing without UI.
|
|
90
|
+
|
|
91
|
+
## Architecture and task behavior
|
|
92
|
+
|
|
93
|
+
- **Core** (`lib/charterleaf.js`) exports `resolveRelated` for structured
|
|
94
|
+
deterministic routing shared with the CLI. This adds no CLI command
|
|
95
|
+
and requires no spec-format migration.
|
|
96
|
+
- **Extension** (`extensions/charterleaf.js`, with `lib/project.js`) uses
|
|
97
|
+
native command, task/context, tool-call/result, compaction and settlement
|
|
98
|
+
hooks for opt-in loading, write gates, mode approval and a final review.
|
|
99
|
+
- **Skill** (`skills/charterleaf/SKILL.md`) supplies semantic judgment and
|
|
100
|
+
maintenance discipline. It is not a deterministic semantic validator.
|
|
101
|
+
|
|
102
|
+
For known paths, standard `read` results carry the applicable constraint
|
|
103
|
+
bodies without replacing the original result. A standard code `write` or
|
|
104
|
+
`edit` with unseen constraints is blocked, carrying the constraints or
|
|
105
|
+
an explicit read request. Read and assess them, then retry; do not evade
|
|
106
|
+
this gate via Bash. Delivery counts as loaded only at the **next model
|
|
107
|
+
request**, not when a parallel sibling tool finishes.
|
|
108
|
+
|
|
109
|
+
Automatic spec bodies have a **24,000-byte budget per task** and an
|
|
110
|
+
**8,000-byte per-file limit**. Larger files or exhausted budgets require
|
|
111
|
+
an explicit complete `read`; a partial/truncated read is not full
|
|
112
|
+
knowledge. Native Pi offset pages are credited only when their exact content
|
|
113
|
+
covers the same spec version with no gaps; ordinary prose about truncation does
|
|
114
|
+
not count as a tool truncation notice. On a scoped miss, the Extension supplies metadata only for
|
|
115
|
+
available living specs, capped at 12 entries and 2,000 characters;
|
|
116
|
+
`charterleaf related <path>` provides the full routing list. A miss never
|
|
117
|
+
proves that no constraints apply.
|
|
118
|
+
|
|
119
|
+
Compact rules from `skills/charterleaf/runtime.md` are supplied once per
|
|
120
|
+
project/session branch; the full `SKILL.md` authoring/CLI guide stays on demand.
|
|
121
|
+
Each task adds only its current mode/root. Same-version spec bodies already
|
|
122
|
+
seen in the retained branch are reused across tasks, while ownership, Git
|
|
123
|
+
baseline and body budget are reset per task. Switching branches or compaction
|
|
124
|
+
clears context-delivery bookkeeping.
|
|
125
|
+
Routing is recomputed through Core for each concrete path check, so new scopes
|
|
126
|
+
and changed globs are not hidden by stale routing caches. Changed spec content
|
|
127
|
+
requires fresh delivery; successful standard spec writes invalidate loaded state.
|
|
128
|
+
Compaction invalidates loaded context so constraints must be delivered
|
|
129
|
+
again. This is task-local bookkeeping, not a watcher or persistent index.
|
|
130
|
+
|
|
131
|
+
At successful, actionable `agent_before_settle`, the Extension may request
|
|
132
|
+
**at most one final semantic review per task** after development. It
|
|
133
|
+
separates successful standard-tool paths whose observed content is still
|
|
134
|
+
unchanged from **unattributed** changes observed against the task's Git
|
|
135
|
+
baseline. Neither a touched file nor a dirty worktree proves ownership
|
|
136
|
+
of every line. Preserve existing user dirt and parallel work; unattributed
|
|
137
|
+
changes alone must not justify spec edits. No automatic revert, stage,
|
|
138
|
+
commit or push is performed.
|
|
139
|
+
|
|
140
|
+
**No durable knowledge means no spec edit.** Keep only established,
|
|
141
|
+
non-obvious, mistake-preventing contracts, invariants, boundaries,
|
|
142
|
+
pitfalls or accepted decisions — not code summaries or task logs. A
|
|
143
|
+
code/spec conflict is not permission to accommodate incorrect code:
|
|
144
|
+
distinguish implementation bugs, authoritatively obsolete specs and
|
|
145
|
+
explicitly intended contract changes. When uncertain, preserve the spec
|
|
146
|
+
and report the conflict. This review does not start an independent
|
|
147
|
+
workflow or another development cycle.
|
|
43
148
|
|
|
44
149
|
## CLI
|
|
45
150
|
|
|
@@ -177,8 +282,8 @@ result into living specs and delete the change file. Git keeps history.
|
|
|
177
282
|
|
|
178
283
|
The complete Agent convention lives in
|
|
179
284
|
[`skills/charterleaf/SKILL.md`](skills/charterleaf/SKILL.md): adoption
|
|
180
|
-
|
|
181
|
-
writing discipline, and durable learning.
|
|
285
|
+
and ownership, progressive retrieval, `related` and `lint` semantics,
|
|
286
|
+
mode behavior, spec writing discipline, and durable learning.
|
|
182
287
|
|
|
183
288
|
For agents that read project instructions, the minimal reference
|
|
184
289
|
bootstrap is [`AGENTS_SNIPPET.md`](AGENTS_SNIPPET.md).
|
|
@@ -188,29 +293,100 @@ Markdown and run shell commands can use it.
|
|
|
188
293
|
|
|
189
294
|
## Boundaries
|
|
190
295
|
|
|
191
|
-
Charterleaf has no
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
296
|
+
Charterleaf has no project state/task system, planning or workflow
|
|
297
|
+
orchestration, memory/evidence/history management, scope/owner registry,
|
|
298
|
+
project bootstrap command, semantic search/embeddings, coverage analyzer,
|
|
299
|
+
database, persistent index, daemon, watcher or independent AI service.
|
|
300
|
+
The Extension uses the host lifecycle and lightweight Git snapshots;
|
|
301
|
+
it does not become a session manager or Git workflow.
|
|
302
|
+
|
|
303
|
+
Important limits:
|
|
304
|
+
|
|
305
|
+
- Bash, external writers and opaque/custom tools cannot be fully
|
|
306
|
+
intercepted. Reference and Confirm are **not security sandboxes**;
|
|
307
|
+
use standard `write`/`edit` for all spec changes.
|
|
308
|
+
- In non-Git projects, standard-tool observations still work, but opaque
|
|
309
|
+
changes cannot be reliably discovered or attributed; final review falls
|
|
310
|
+
back to conversation evidence. Git snapshots do not detect gitignored
|
|
311
|
+
files; standard tool observations can still identify directly touched
|
|
312
|
+
paths. Neither mechanism proves ownership of all content.
|
|
313
|
+
- Custom/nested tool integrations depend on compatible host forwarding
|
|
314
|
+
and queue support. Where supported, constraint notes must survive into
|
|
315
|
+
the model-visible result while original content, images and structured
|
|
316
|
+
results are preserved. Do not assume every custom envelope does this;
|
|
317
|
+
read large specs directly with standard `read` and offset pages rather than relying on
|
|
318
|
+
a nested wrapper that can hide or truncate their bodies.
|
|
319
|
+
- `lint` checks structure only. Skill-guided reviews are model judgments
|
|
320
|
+
and may misjudge relevance, durable value or a conflict; neither proves
|
|
321
|
+
semantic correctness or compliance.
|
|
322
|
+
- Paths support repo-relative or same-project absolute paths, `/` or `\`
|
|
323
|
+
separators, Pi's `@path`/`file://` aliases, and Windows drive paths mapped
|
|
324
|
+
to `/mnt/<drive>/` on WSL. Enabled standard tools execute the normalized
|
|
325
|
+
path that was checked; spec authorization also preserves the original path's
|
|
326
|
+
protected identity when a spec symlink points elsewhere inside the project.
|
|
327
|
+
Foreign UNC paths are not interpreted as local paths; symlink escapes are
|
|
328
|
+
rejected for project spec writes. Native Windows behavior needs platform testing.
|
|
329
|
+
- `related` has no filesystem awareness of the supplied target path and
|
|
330
|
+
no proof of routing coverage. Task-local hashes are not a continuous
|
|
331
|
+
monitor of external changes. A single spec line larger than Pi's read byte
|
|
332
|
+
limit cannot be credited as read; do not keep retrying or bypass the gate —
|
|
333
|
+
report it and ask for readable formatting or a user-chosen mode change.
|
|
334
|
+
Files over 2 MiB use coarse size/mtime fingerprints;
|
|
335
|
+
Git checks exceeding 500 changed/untracked entries fall back to conversation
|
|
336
|
+
evidence instead of scanning a large worktree.
|
|
337
|
+
|
|
338
|
+
The two CLI commands remain helpers, not workflow gates. Native tool
|
|
339
|
+
gates apply only to the opt-in Pi integration, within the limits above.
|
|
203
340
|
|
|
204
341
|
## Development
|
|
205
342
|
|
|
206
|
-
|
|
207
|
-
dependencies and no build step.
|
|
343
|
+
Core development requires Node.js 20 or newer; there are no runtime or
|
|
344
|
+
development dependencies and no build step. Use the higher Node/host
|
|
345
|
+
requirements above for Pi smoke checks.
|
|
346
|
+
|
|
347
|
+
Verification commands:
|
|
208
348
|
|
|
209
349
|
```bash
|
|
210
350
|
npm test
|
|
351
|
+
npm run test:pi # requires compatible Pi on PATH; isolated RPC UI smoke, no model calls
|
|
352
|
+
node --test tests/extension.test.js
|
|
211
353
|
npm pack --dry-run
|
|
354
|
+
node bin/charterleaf.js related extensions/charterleaf.js
|
|
355
|
+
node bin/charterleaf.js lint
|
|
212
356
|
```
|
|
213
357
|
|
|
358
|
+
Validated on Node.js 24.21.0 and Pi 1.1.0: 149 automated Core/Extension tests,
|
|
359
|
+
structural lint, dry-run npm packaging, and real Pi RPC loading/native selection
|
|
360
|
+
for cancellation, mode changes, disable and cross-process restore. The RPC smoke
|
|
361
|
+
uses isolated temporary projects/settings and makes no model calls.
|
|
362
|
+
|
|
363
|
+
Independent acceptance also exercised real `openai/gpt-5.6-terra` sessions in
|
|
364
|
+
isolated Git projects: ordinary refactors, intentional API updates, durable
|
|
365
|
+
rounding knowledge, conflicts, all three modes, no-UI Confirm, parallel nested
|
|
366
|
+
tools, oversized specs, dirty/concurrently modified files, Bash writes and
|
|
367
|
+
invalid metadata. Evidence was checked against tool results and file diffs,
|
|
368
|
+
not assistant claims. These bounded examples do not prove model reliability
|
|
369
|
+
on every task or provider. The original activation message measured 8,450 UTF-8
|
|
370
|
+
bytes (8.25 KiB), mostly the full Skill; it was not all native Pi context or a
|
|
371
|
+
Token count. After the dedicated context audit, compact rules plus activation
|
|
372
|
+
measure about 2 KiB: in a controlled first-request comparison the Charterleaf
|
|
373
|
+
increment fell from 1,720 to 403 provider input tokens, with identical native
|
|
374
|
+
prompt/tools. Smaller total context does not guarantee the same reduction in
|
|
375
|
+
uncached input charges: cache-hit sizes vary. Substantive work still requests
|
|
376
|
+
at most one extra model review. A model
|
|
377
|
+
initially misreported a blocked edit as completed; clearer error text corrected
|
|
378
|
+
the observed regression, but final claims still need independent verification.
|
|
379
|
+
|
|
380
|
+
For an interactive smoke check in a disposable project, install the local
|
|
381
|
+
package in a compatible Pi host and run `/charterleaf`: check immediate
|
|
382
|
+
mode persistence, cancel/disable preserving documents, constraint loading
|
|
383
|
+
on a standard read, a blocked unseen-constraint write followed by retry,
|
|
384
|
+
Confirm approve/refuse, and Reference denying spec writes. After a
|
|
385
|
+
substantive edit, check that at most one final review occurs and that no
|
|
386
|
+
durable change leaves specs untouched. Also check saved modes without UI:
|
|
387
|
+
Auto/Reference still read, Confirm denies spec writes, and the menu is
|
|
388
|
+
inactive. Automated hook tests do not substitute for this real-host smoke.
|
|
389
|
+
|
|
214
390
|
## License
|
|
215
391
|
|
|
216
392
|
MIT
|
|
@@ -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,13 +152,10 @@ 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);
|
|
@@ -194,10 +191,7 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
|
|
|
194
191
|
if (patterns.some((pattern) => globMatches(pattern, target))) scoped.push(document);
|
|
195
192
|
}
|
|
196
193
|
|
|
197
|
-
if (errors.length)
|
|
198
|
-
for (const error of errors) write(stderr, error);
|
|
199
|
-
return 2;
|
|
200
|
-
}
|
|
194
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
201
195
|
|
|
202
196
|
const matchedIds = new Set();
|
|
203
197
|
for (const document of scoped) {
|
|
@@ -221,27 +215,51 @@ function related(repoPath, cwd = process.cwd(), streams = {}) {
|
|
|
221
215
|
scoped.sort((left, right) => (left.display < right.display ? -1 : left.display > right.display ? 1 : 0));
|
|
222
216
|
activeChanges.sort();
|
|
223
217
|
|
|
224
|
-
const blocks = [];
|
|
225
218
|
const constitution = documents.find((document) => document.relative === 'constitution.md');
|
|
226
|
-
|
|
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
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function related(repoPath, cwd = process.cwd(), streams = {}) {
|
|
232
|
+
const stdout = streams.stdout || process.stdout;
|
|
233
|
+
const stderr = streams.stderr || process.stderr;
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = resolveRelated(repoPath, cwd);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
write(stderr, error.message);
|
|
239
|
+
return 2;
|
|
240
|
+
}
|
|
241
|
+
const { global, scoped, changes: activeChanges, available } = result;
|
|
242
|
+
const blocks = [];
|
|
243
|
+
const constitution = global[0];
|
|
244
|
+
if (constitution) blocks.push(['Global', [` ${constitution}`]]);
|
|
227
245
|
if (scoped.length) {
|
|
228
|
-
blocks.push(['Scoped', scoped.map((document) => ` ${document
|
|
246
|
+
blocks.push(['Scoped', scoped.map((document) => ` ${document}`)]);
|
|
229
247
|
} else {
|
|
230
248
|
blocks.push(['Scoped', [' No applies_to glob matched this path.']]);
|
|
231
249
|
}
|
|
232
250
|
if (activeChanges.length) {
|
|
233
251
|
blocks.push(['Active changes', activeChanges.map((change) => ` ${change}`)]);
|
|
234
252
|
}
|
|
235
|
-
if (!scoped.length &&
|
|
253
|
+
if (!scoped.length && available.length) {
|
|
236
254
|
const lines = [];
|
|
237
|
-
|
|
255
|
+
available.forEach((document, index) => {
|
|
238
256
|
if (index) lines.push('');
|
|
239
|
-
const id =
|
|
240
|
-
const appliesTo = Array.isArray(document.
|
|
241
|
-
? document.
|
|
242
|
-
: String(document.
|
|
257
|
+
const id = document.id;
|
|
258
|
+
const appliesTo = Array.isArray(document.applies_to)
|
|
259
|
+
? document.applies_to.join(', ')
|
|
260
|
+
: String(document.applies_to);
|
|
243
261
|
lines.push(` ${id || '(missing id)'}`);
|
|
244
|
-
lines.push(` ${document.
|
|
262
|
+
lines.push(` ${document.path}`);
|
|
245
263
|
lines.push(` applies_to: ${appliesTo}`);
|
|
246
264
|
});
|
|
247
265
|
blocks.push(['Available living specs', lines]);
|
|
@@ -410,7 +428,7 @@ function lint(cwd = process.cwd(), streams = {}) {
|
|
|
410
428
|
const display = relativeDisplay(filePath, cwd);
|
|
411
429
|
const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
|
|
412
430
|
const frontmatter = rawFrontmatter || {};
|
|
413
|
-
const sections = changeSections(body);
|
|
431
|
+
const sections = changeSections(stripHtmlComments(body));
|
|
414
432
|
const hasDelta = ['ADD', 'MODIFY', 'REMOVE'].some(
|
|
415
433
|
(section) => stripHtmlComments(sections[section] || '').trim().length > 0,
|
|
416
434
|
);
|
|
@@ -538,5 +556,6 @@ module.exports = {
|
|
|
538
556
|
normalizeRepoPath,
|
|
539
557
|
parseFrontmatter,
|
|
540
558
|
related,
|
|
559
|
+
resolveRelated,
|
|
541
560
|
requirementDefinitions,
|
|
542
561
|
};
|
package/lib/project.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { fileURLToPath } = require('node:url');
|
|
7
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
8
|
+
const { execFileSync } = require('node:child_process');
|
|
9
|
+
|
|
10
|
+
const MODES = ['auto', 'confirm', 'reference', 'off'];
|
|
11
|
+
const CONFIG = 'specs/.charterleaf.json';
|
|
12
|
+
|
|
13
|
+
function projectRoot(cwd) {
|
|
14
|
+
const start = fs.realpathSync(cwd);
|
|
15
|
+
for (let current = start; ; current = path.dirname(current)) {
|
|
16
|
+
if (fs.existsSync(path.join(current, '.git')) || fs.existsSync(path.join(current, CONFIG))) return current;
|
|
17
|
+
if (path.dirname(current) === current) return start;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function inside(root, file) {
|
|
22
|
+
const relative = path.relative(root, file);
|
|
23
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Resolve existing ancestors too: new files below a symlink must not escape the project.
|
|
27
|
+
function canonical(file, depth = 0) {
|
|
28
|
+
if (depth > 40) throw new Error('Too many symlinks');
|
|
29
|
+
try {
|
|
30
|
+
const stat = fs.lstatSync(file);
|
|
31
|
+
if (stat.isSymbolicLink()) return canonical(path.resolve(path.dirname(file), fs.readlinkSync(file)), depth + 1);
|
|
32
|
+
return fs.realpathSync(file);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (error.code !== 'ENOENT') throw error;
|
|
35
|
+
const parent = path.dirname(file);
|
|
36
|
+
if (parent === file) return file;
|
|
37
|
+
return path.join(canonical(parent, depth + 1), path.basename(file));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toolPath(input) {
|
|
42
|
+
let value = input.replace(/[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g, ' ');
|
|
43
|
+
if (value.startsWith('@')) value = value.slice(1);
|
|
44
|
+
if (value.startsWith('file://')) value = fileURLToPath(value);
|
|
45
|
+
value = value.replaceAll('\\', '/');
|
|
46
|
+
if (value === '~') value = os.homedir();
|
|
47
|
+
if (value.startsWith('~/')) value = path.join(os.homedir(), value.slice(2));
|
|
48
|
+
if (process.platform === 'win32') {
|
|
49
|
+
const drive = value.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
50
|
+
if (drive) value = `${drive[1].toUpperCase()}:/${drive[2] || ''}`;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function projectPath(root, cwd, input) {
|
|
56
|
+
if (typeof input !== 'string' || !input || input.includes('\0')) return null;
|
|
57
|
+
let value = toolPath(input);
|
|
58
|
+
if (process.platform !== 'win32' && /^[A-Za-z]:\//.test(value)) {
|
|
59
|
+
value = `/mnt/${value[0].toLowerCase()}/${value.slice(3)}`;
|
|
60
|
+
} else if (process.platform !== 'win32' && value.startsWith('//')) {
|
|
61
|
+
// A foreign UNC host cannot safely be interpreted as a local Linux path.
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const lexical = path.resolve(cwd, value);
|
|
65
|
+
const resolved = canonical(lexical);
|
|
66
|
+
if (!inside(root, lexical) || !inside(root, resolved)) return null;
|
|
67
|
+
return path.relative(root, resolved).replaceAll('\\', '/') || '.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readMode(root) {
|
|
71
|
+
const file = path.join(root, CONFIG);
|
|
72
|
+
if (!fs.existsSync(file)) return 'off';
|
|
73
|
+
if (!inside(root, canonical(file))) throw new Error('Charterleaf config escapes project');
|
|
74
|
+
const config = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
75
|
+
if (config.version !== 1 || !MODES.includes(config.mode)) throw new Error('Invalid Charterleaf config (expected version: 1 and a valid mode)');
|
|
76
|
+
return config.mode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function saveMode(root, mode) {
|
|
80
|
+
if (!MODES.includes(mode)) throw new Error('Invalid Charterleaf mode');
|
|
81
|
+
const file = path.join(root, CONFIG);
|
|
82
|
+
if (!inside(root, canonical(file))) throw new Error('Charterleaf config escapes project');
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
84
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx' });
|
|
87
|
+
fs.renameSync(temporary, file);
|
|
88
|
+
} finally {
|
|
89
|
+
if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function fingerprint(root, relative) {
|
|
94
|
+
const file = path.join(root, relative);
|
|
95
|
+
if (!inside(root, canonical(file))) return 'outside';
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(file);
|
|
98
|
+
if (!stat.isFile()) return 'not-file';
|
|
99
|
+
// shortcut: over 2 MiB use size/mtime, use streaming hashes if large-file attribution is needed.
|
|
100
|
+
if (stat.size > 2 * 1024 * 1024) return `large:${stat.size}:${stat.mtimeMs}`;
|
|
101
|
+
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code === 'ENOENT') return 'missing';
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function gitSnapshot(root) {
|
|
109
|
+
const git = (args) => execFileSync('git', ['-C', root, ...args], {
|
|
110
|
+
encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
|
|
111
|
+
});
|
|
112
|
+
try {
|
|
113
|
+
if (fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()) !== root) return null;
|
|
114
|
+
// Compare index and worktree separately; this also works before the first commit.
|
|
115
|
+
const names = [git(['diff', '--no-renames', '--name-only', '-z']), git(['diff', '--cached', '--no-renames', '--name-only', '-z']),
|
|
116
|
+
git(['ls-files', '--others', '--exclude-standard', '-z'])].join('\0').split('\0').filter(Boolean);
|
|
117
|
+
if (names.length > 500) return null; // Bounded task checks, not a repository scan.
|
|
118
|
+
const snapshot = new Map([...new Set(names)].map((name) => {
|
|
119
|
+
const file = path.join(root, name);
|
|
120
|
+
// Git tracks a symlink's target string, not the target file's contents.
|
|
121
|
+
let stat;
|
|
122
|
+
try { stat = fs.lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
123
|
+
const hash = stat?.isSymbolicLink() ? `symlink:${fs.readlinkSync(file)}` : fingerprint(root, name);
|
|
124
|
+
return [name, hash];
|
|
125
|
+
}));
|
|
126
|
+
const raw = git(['diff', '--cached', '--raw', '--no-abbrev', '--no-renames', '-z']).split('\0');
|
|
127
|
+
snapshot.index = new Map();
|
|
128
|
+
for (let i = 0; i + 1 < raw.length; i += 2) snapshot.index.set(raw[i + 1], raw[i]);
|
|
129
|
+
return snapshot;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function changedSince(before, after) {
|
|
136
|
+
if (!before || !after) return [];
|
|
137
|
+
return [...new Set([...before.keys(), ...after.keys()])].filter((name) =>
|
|
138
|
+
(before.get(name) ?? 'clean') !== (after.get(name) ?? 'clean') ||
|
|
139
|
+
before.index?.get(name) !== after.index?.get(name)).sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onedotmint/charterleaf",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Lightweight Agent-Native Constraint Layer: opt-in spec discovery and maintenance for Pi, with a standalone zero-dependency CLI.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"bin/",
|
|
22
22
|
"lib/",
|
|
23
|
+
"extensions/",
|
|
23
24
|
"skills/",
|
|
24
25
|
"templates/",
|
|
25
26
|
"AGENTS_SNIPPET.md",
|
|
@@ -32,12 +33,17 @@
|
|
|
32
33
|
"coding-agent",
|
|
33
34
|
"specification",
|
|
34
35
|
"agent-skills",
|
|
35
|
-
"pi"
|
|
36
|
+
"pi",
|
|
37
|
+
"pi-package"
|
|
36
38
|
],
|
|
37
39
|
"scripts": {
|
|
38
|
-
"test": "node --test"
|
|
40
|
+
"test": "node --test",
|
|
41
|
+
"test:pi": "node scripts/pi-smoke.js"
|
|
39
42
|
},
|
|
40
43
|
"pi": {
|
|
44
|
+
"extensions": [
|
|
45
|
+
"./extensions/charterleaf.js"
|
|
46
|
+
],
|
|
41
47
|
"skills": [
|
|
42
48
|
"./skills"
|
|
43
49
|
]
|
|
@@ -1,149 +1,37 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: charterleaf
|
|
3
|
-
description:
|
|
3
|
+
description: Discover and maintain durable project constraints when Charterleaf is explicitly enabled or requested.
|
|
4
|
+
disable-model-invocation: true
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Charterleaf
|
|
7
|
+
# Charterleaf authoring guide
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
The essential rules live in [runtime.md](runtime.md); read it only if not already supplied. The Pi Extension supplies those rules automatically only when the project is enabled via `/charterleaf`; it does not preload this guide. Outside Pi, use the CLI only when explicitly requested. Existing `specs/` alone does not enable automation; do not initialize documents or recommend adoption automatically.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Read this guide only for unfamiliar spec formats, staged changes or manual CLI use. Ordinary code edits need no Skill read or CLI call when applicable constraints have already been supplied.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
## Ownership and durable writing
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
- do not create `specs/`;
|
|
16
|
-
- do not recommend adoption automatically;
|
|
17
|
-
- continue using the repository's normal instructions.
|
|
15
|
+
Source owns implementation; tests own executable verification; `AGENTS.md` owns project entry/navigation; specs own durable constraints. Never copy signatures, inventories, current state or existing documentation into specs. Link the authoritative owner instead. Charterleaf is not task management, planning, memory, evidence, history or a project workflow.
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
Place only established, non-obvious knowledge whose absence could cause a future mistake:
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
- `specs/constitution.md`: rare project-wide boundaries;
|
|
20
|
+
- `specs/capabilities/`: observable contracts, invariants and non-goals;
|
|
21
|
+
- `specs/engineering/`: long-lived implementation boundaries, recurring pitfalls and verification constraints;
|
|
22
|
+
- `specs/decisions/`: accepted rationale and revisit conditions.
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
Living capability/engineering specs need a unique `id` and non-empty `applies_to` list. Keep scope narrow, preserve existing formats and requirement IDs, and verify explicit local references normally. `anchors` and `verified_by` are not required. Do not create speculative standards or code summaries to satisfy a final review.
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
## Optional staged changes
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
project-owned documents it points to — answers a different question:
|
|
28
|
+
Use `specs/changes/<name>.md` only when an accepted future contract must coexist with current truth: review, another session or an ongoing migration. Supply valid `affects` spec IDs and at least one non-empty `ADD`, `MODIFY` or `REMOVE` section; `PRESERVE` alone is not a change. Once implemented and verified, merge durable truth into living specs and remove obsolete staging. Git keeps history; never commit automatically.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
## CLI details
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
(`related`), and structural lint (`lint`).
|
|
32
|
+
Run from the project root:
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
consistency, semantic code/spec verification, or repository state
|
|
38
|
-
synchronization.
|
|
34
|
+
- `charterleaf related <path>` returns constitution as Global, capability/engineering specs matching `applies_to`, and active changes affecting returned spec IDs. It returns paths/metadata, not bodies. Decisions and explicit references are followed manually when relevant. A scoped miss is not proof that no constraints apply.
|
|
35
|
+
- `charterleaf lint` checks frontmatter, IDs/globs, duplicate IDs, change references/deltas and conflicting active changes. `0 structural errors` does not prove semantic correctness, coverage or code/spec agreement. The Extension also lints standard spec writes.
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
1. **Identify the need.** Orientation, current state, evidence,
|
|
43
|
-
validation, resources, implementation detail, or durable constraint?
|
|
44
|
-
2. **Locate the owner.** If the owner is known, read it directly. If not,
|
|
45
|
-
read the project `AGENTS.md` / project instructions. Do not scan the
|
|
46
|
-
whole repository's Markdown first.
|
|
47
|
-
3. **Enough?** If the authoritative information you have is sufficient,
|
|
48
|
-
stop reading. Do not keep reading for completeness.
|
|
49
|
-
4. **Expand progressively.** Only when information is insufficient, the
|
|
50
|
-
owner is unclear, sources conflict, the work area is unknown, or
|
|
51
|
-
verification is needed — and only one layer at a time.
|
|
52
|
-
5. **Concrete path.** Once work lands on a concrete repository path, run
|
|
53
|
-
`charterleaf related <path>` and read the returned constraints.
|
|
54
|
-
6. **Work.** Normal implementation, learning, design, or debugging. The
|
|
55
|
-
reference protocol does not manage workflow.
|
|
56
|
-
7. **Write back.** Changed current fact → its single owner. New evidence
|
|
57
|
-
→ the evidence owner. New or changed durable constraint → `specs/`.
|
|
58
|
-
Historical event → project history or Git. Future plan → planning
|
|
59
|
-
docs. Never copy one fact into several files: one owner per fact,
|
|
60
|
-
other documents point to it.
|
|
61
|
-
|
|
62
|
-
## related semantics
|
|
63
|
-
|
|
64
|
-
`charterleaf related <path>`:
|
|
65
|
-
|
|
66
|
-
- always returns `specs/constitution.md` as Global when it exists;
|
|
67
|
-
- routes `capabilities/` and `engineering/` specs whose `applies_to`
|
|
68
|
-
glob matches the path;
|
|
69
|
-
- appends active changes whose `affects` reference a returned spec id.
|
|
70
|
-
|
|
71
|
-
`applies_to` is a **routing hint**, not a completeness proof. The message
|
|
72
|
-
`No applies_to glob matched this path.` means exactly that — it never
|
|
73
|
-
means "no constraints apply". On a scoped miss the command lists
|
|
74
|
-
available living specs by `id`, path, and `applies_to` only. Use that
|
|
75
|
-
list to find the right area, then rerun with the concrete path. The
|
|
76
|
-
command never prints spec bodies, ranks relevance, or guesses.
|
|
77
|
-
|
|
78
|
-
## lint semantics
|
|
79
|
-
|
|
80
|
-
`charterleaf lint` reports mechanical structural problems only:
|
|
81
|
-
frontmatter shape, living-spec ids and `applies_to`, duplicate spec and
|
|
82
|
-
requirement ids, change references, changes whose `ADD` / `MODIFY` /
|
|
83
|
-
`REMOVE` sections are all empty (HTML comments do not count), and
|
|
84
|
-
conflicting active changes.
|
|
85
|
-
|
|
86
|
-
It does **not** verify semantic correctness, code/spec agreement,
|
|
87
|
-
routing coverage, project compliance, whether a change is complete, or
|
|
88
|
-
whether an Agent read the specs. Success prints `0 structural errors`.
|
|
89
|
-
Run it after editing specs.
|
|
90
|
-
|
|
91
|
-
## Spec writing discipline
|
|
92
|
-
|
|
93
|
-
A spec records a constraint whose absence would plausibly cause a future
|
|
94
|
-
developer or agent to make a wrong decision. It should be non-obvious,
|
|
95
|
-
durable, **already true**, and mistake-preventing.
|
|
96
|
-
|
|
97
|
-
Good fits: contracts, invariants, architecture boundaries, non-goals,
|
|
98
|
-
recurring pitfalls, accepted architectural rationale, verification
|
|
99
|
-
constraints.
|
|
100
|
-
|
|
101
|
-
Not specs: current state, future design, aspirations, todos,
|
|
102
|
-
implementation plans, session notes, progress, temporary debugging,
|
|
103
|
-
navigation, history. "Already true" matters — what you hope to become is
|
|
104
|
-
a plan, not a living spec.
|
|
105
|
-
|
|
106
|
-
Place a constraint where it belongs:
|
|
107
|
-
|
|
108
|
-
- rare project-wide durable boundary → `specs/constitution.md`;
|
|
109
|
-
- observable behavior, contract, invariant, or non-goal →
|
|
110
|
-
`specs/capabilities/`;
|
|
111
|
-
- implementation boundary, long-lived constraint, recurring pitfall, or
|
|
112
|
-
verification practice → `specs/engineering/`;
|
|
113
|
-
- important architectural rationale and revisit condition →
|
|
114
|
-
`specs/decisions/`.
|
|
115
|
-
|
|
116
|
-
Keep specs compressed. Prefer durable contracts and pitfalls over
|
|
117
|
-
commentary, and never invent aspirational standards.
|
|
118
|
-
|
|
119
|
-
## changes/ is optional staging
|
|
120
|
-
|
|
121
|
-
Use `specs/changes/<name>.md` only when an accepted future contract must
|
|
122
|
-
temporarily coexist with current living reality — for example when
|
|
123
|
-
implementation happens in another session or by another agent, human
|
|
124
|
-
review comes first, or a multi-step migration is underway. A change must
|
|
125
|
-
contain at least one of `ADD`, `MODIFY`, `REMOVE`; `PRESERVE` alone is
|
|
126
|
-
not a change.
|
|
127
|
-
|
|
128
|
-
Otherwise: implement, verify, and update the living spec if durable truth
|
|
129
|
-
changed. After an accepted change is implemented, merge the durable
|
|
130
|
-
result into living specs and delete the change file. Git keeps history.
|
|
131
|
-
|
|
132
|
-
## Durable learning
|
|
133
|
-
|
|
134
|
-
After non-trivial implementation or debugging, ask once: did this work
|
|
135
|
-
reveal durable knowledge whose absence could cause a future mistake?
|
|
136
|
-
|
|
137
|
-
If not, change nothing. If yes, record only the durable constraint in
|
|
138
|
-
the matching category above.
|
|
139
|
-
|
|
140
|
-
Never record current progress, temporary debug notes, one-off commands,
|
|
141
|
-
session history, todos, or transient observations. Specs accumulate
|
|
142
|
-
durable constraints, not development activity.
|
|
143
|
-
|
|
144
|
-
## Non-goals
|
|
145
|
-
|
|
146
|
-
Do not use Charterleaf to manage tasks, planning, workflow, memory,
|
|
147
|
-
evidence, history, repository state, or scope registries. Do not ask it
|
|
148
|
-
to verify semantics, coverage, or project compliance. It finds
|
|
149
|
-
constraints; it does not manage the project.
|
|
37
|
+
If the CLI is not on PATH, use `node <skill-directory>/../../bin/charterleaf.js related <path>` or `lint`; the Extension supplies the absolute entry point. Report failed writing or validation accurately, and never relax a contract merely to remove an error.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Charterleaf runtime
|
|
2
|
+
|
|
3
|
+
Use constraints supplied with standard read results; do not load all specs or reread unchanged supplied bodies. A blocked write/edit requires inspecting the constraints or reading the named complete spec before retrying; never bypass it through another writer. Routing is a hint, not proof of coverage.
|
|
4
|
+
|
|
5
|
+
Modes govern specs, not code: Auto maintains only justified durable knowledge; Confirm edits code normally and submits exact spec write/edit to the Extension's approval dialog (refusal/no UI: preserve spec, do not retry); Reference reports proposals/conflicts, never writes specs. Use standard write/edit only. Activation config is user-owned via /charterleaf. These guards are not a filesystem sandbox.
|
|
6
|
+
|
|
7
|
+
A code/spec conflict is not permission to relax the spec. Fix implementation bugs; change contracts only with authoritative evidence or explicit accepted intent. Internal/refactor requests do not authorize changing return or ownership contracts. If a requested method violates an invariant, preserve spec/tests and ask for an explicit contract decision before coding. When uncertain, preserve the spec and report the conflict. Preserve user/parallel work; a touched file or dirty worktree does not prove line ownership. Never automatically revert, commit or push.
|
|
8
|
+
|
|
9
|
+
## One final semantic review
|
|
10
|
+
|
|
11
|
+
Only established, non-obvious, mistake-preventing contracts, invariants, boundaries, pitfalls or accepted decisions belong in specs. No durable change means no spec edit: exclude implementation summaries, tasks, progress, debugging and plans. At most one final review may follow development; do not start another cycle. Report task results/tests, not routine review bookkeeping. Spec writes receive structural lint, not semantic verification.
|