@onedotmint/charterleaf 0.3.0 → 0.4.1

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 CHANGED
@@ -4,11 +4,36 @@ 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
- Durable constraints live under `specs/`. For work on a concrete
8
- repository path:
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 authoritative owners progressively. If the current source is
13
- sufficient, stop. Keep one owner per fact and point to it elsewhere
14
- instead of copying the same fact into multiple documents.
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. During normal development or investigation, capture newly established
32
+ or newly discovered knowledge only with reliable evidence, long-term value and a
33
+ documentation/discoverability gap; no code change or prior spec is required.
34
+ Prefer short scoped references to authoritative owners over copying documents.
35
+ Explicit no-file-write/read-only instructions override Auto. No justified durable
36
+ knowledge means no spec edit; never relax a spec merely
37
+ to accommodate conflicting code. Preserve user/parallel work and keep
38
+ state, plans, evidence and history in their existing authoritative owners,
39
+ not in a new Charterleaf workflow.
package/README.md CHANGED
@@ -1,13 +1,16 @@
1
1
  # Charterleaf
2
2
 
3
- A tiny specification layer for coding agents.
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,158 @@ belongs to a project file.
27
30
 
28
31
  ## Install
29
32
 
30
- Install the public scoped npm package:
33
+ Install once in Pi to discover the bundled Extension and Skill:
31
34
 
32
35
  ```bash
33
- npm install -g @onedotmint/charterleaf
36
+ pi install npm:@onedotmint/charterleaf
34
37
  ```
35
38
 
36
- Pi can install the same package and discover its bundled Agent Skill:
39
+ For the standalone CLI, use `npm install -g @onedotmint/charterleaf`.
40
+ For local development, use `pi install /absolute/path/to/charterleaf` (a package
41
+ directory, not a `.tgz` file). To test the CLI from a local package, run `npm pack`
42
+ then `npm install -g ./onedotmint-charterleaf-0.4.1.tgz`.
37
43
 
38
- ```bash
39
- pi install npm:@onedotmint/charterleaf
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
- No Pi extension or Pi runtime dependency is used.
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
+ Knowledge capture happens **during normal work**, not just after code changes:
132
+ development, debugging, substantive code/test/architecture investigation and
133
+ accepted technical decisions can establish or uncover durable knowledge. A project
134
+ with only `specs/.charterleaf.json` is a valid initial state. When evidence warrants
135
+ it, the Agent creates a concise scoped spec using ordinary authorized `write`/`edit`;
136
+ Core immediately routes its `id`/`applies_to` on later related code reads or edits.
137
+ There is no initialization scan, document quota or adoption wizard.
138
+
139
+ At successful, actionable `agent_before_settle`, the Extension may request
140
+ **at most one final semantic review per task**. Actual development remains a
141
+ fallback, but an unresolved evidenced knowledge candidate can also trigger it
142
+ without file changes or Git. The existing Agent makes the semantic judgment; the
143
+ Extension observes standalone assistant lifecycle comments outside fenced examples
144
+ (`charterleaf: review` or an explicit `covered`/`no-value`/`proposed` knowledge
145
+ conclusion; an exact terminal conclusion also works inline), never interprets read
146
+ counts as knowledge. A generic `done` cannot suppress the development fallback.
147
+ In an empty-spec project, a development fallback
148
+ remains eligible when the Agent says `no-value`; this gives the existing single
149
+ review a chance to catch a missed discovery gap. These are
150
+ cooperative intent signals, not evidence or permissions. Already judged/captured,
151
+ proposed or effectively documented knowledge needs no repeat review when a
152
+ completion signal is observed. If the Agent
153
+ omits its completion signal, the bounded fallback can repeat an already answered
154
+ knowledge judgment; zero redundant reviews is not guaranteed. Successful
155
+ structurally valid standard spec writes mark capture complete; later actual code
156
+ edits or distinct candidates may rearm. Opaque activity after capture still uses
157
+ bounded Git comparison when available. Ordinary views, queries and chat do not
158
+ by themselves request another model call.
159
+
160
+ For the development fallback, the Extension separates successful standard-tool
161
+ paths whose observed content is still
162
+ unchanged from **unattributed** changes observed against the task's Git
163
+ baseline. Neither a touched file nor a dirty worktree proves ownership
164
+ of every line. Preserve existing user dirt and parallel work; unattributed
165
+ changes alone must not justify spec edits. No automatic revert, stage,
166
+ commit or push is performed.
167
+
168
+ **No justified durable knowledge means no spec edit.** Distinguish **newly
169
+ established** contracts/decisions from **newly discovered** existing constraints;
170
+ both can qualify even in a code-read-only investigation. Require reliable evidence
171
+ (accepted decisions, authoritative docs, tests or sufficiently verified behavior),
172
+ future mistake-prevention value, and a documentation/discoverability gap. If an
173
+ existing authoritative document covers it, prefer a short routed reference when
174
+ needed; otherwise do nothing. Keep non-obvious contracts, invariants, boundaries,
175
+ pitfalls or accepted decisions — not code summaries, progress, debugging logs,
176
+ temporary plans/todos or speculation. Current code alone is not an accepted
177
+ correct contract. Explicit **no-file-write/read-only** instructions override Auto:
178
+ report a finding/proposal without writing. Confirm/Reference retain their existing
179
+ permissions. A
180
+ code/spec conflict is not permission to accommodate incorrect code:
181
+ distinguish implementation bugs, authoritatively obsolete specs and
182
+ explicitly intended contract changes. When uncertain, preserve the spec
183
+ and report the conflict. This review does not start an independent
184
+ workflow or another development cycle.
43
185
 
44
186
  ## CLI
45
187
 
@@ -151,7 +293,8 @@ specs/
151
293
 
152
294
  A spec records a constraint whose absence would plausibly cause a future
153
295
  developer or agent to make a wrong decision. It should be non-obvious,
154
- durable, **already true**, and mistake-preventing. Good fits: contracts,
296
+ durable, **already true or explicitly accepted**, evidenced, mistake-preventing,
297
+ and not already effectively documented. Good fits: contracts,
155
298
  invariants, architecture boundaries, non-goals, recurring pitfalls,
156
299
  accepted rationale, verification constraints.
157
300
 
@@ -169,16 +312,16 @@ review comes first, or a multi-step migration is underway. A change must
169
312
  contain at least one of `ADD`, `MODIFY`, `REMOVE`; `PRESERVE` alone is
170
313
  not a change.
171
314
 
172
- Otherwise, implement, verify, and update the living spec if durable truth
173
- changed. After an accepted change is implemented, merge the durable
315
+ Otherwise, implement/verify as needed and maintain the living spec when durable
316
+ truth changed **or an existing valuable constraint lacks effective documentation**. After an accepted change is implemented, merge the durable
174
317
  result into living specs and delete the change file. Git keeps history.
175
318
 
176
319
  ## Agent conventions
177
320
 
178
321
  The complete Agent convention lives in
179
322
  [`skills/charterleaf/SKILL.md`](skills/charterleaf/SKILL.md): adoption
180
- gate, progressive retrieval, `related` and `lint` semantics, spec
181
- writing discipline, and durable learning.
323
+ and ownership, progressive retrieval, `related` and `lint` semantics,
324
+ mode behavior, spec writing discipline, and durable learning.
182
325
 
183
326
  For agents that read project instructions, the minimal reference
184
327
  bootstrap is [`AGENTS_SNIPPET.md`](AGENTS_SNIPPET.md).
@@ -188,29 +331,125 @@ Markdown and run shell commands can use it.
188
331
 
189
332
  ## Boundaries
190
333
 
191
- Charterleaf has no:
192
-
193
- - project state, tasks, planning, or workflow;
194
- - memory, session lifecycle, evidence, or history management;
195
- - scope registry, owner registry, or project bootstrap command;
196
- - semantic search, embeddings, or code/spec semantic verification;
197
- - repository coverage analysis or filesystem awareness in `related`;
198
- - database, index, cache, daemon, or watcher;
199
- - plugin/extension runtime;
200
- - Git abstraction.
201
-
202
- The two CLI commands are helpers, not workflow gates.
334
+ Charterleaf has no project state/task system, planning or workflow
335
+ orchestration, memory/evidence/history management, scope/owner registry,
336
+ project bootstrap command, semantic search/embeddings, coverage analyzer,
337
+ database, persistent index, daemon, watcher or independent AI service.
338
+ The Extension uses the host lifecycle and lightweight Git snapshots;
339
+ it does not become a session manager or Git workflow.
340
+
341
+ Important limits:
342
+
343
+ - Bash, external writers and opaque/custom tools cannot be fully
344
+ intercepted. Reference and Confirm are **not security sandboxes**;
345
+ use standard `write`/`edit` for all spec changes.
346
+ - In non-Git projects, standard-tool observations still work, but opaque
347
+ changes cannot be reliably discovered or attributed; final review falls
348
+ back to conversation evidence. Git snapshots do not detect gitignored
349
+ files; standard tool observations can still identify directly touched
350
+ paths. Neither mechanism proves ownership of all content.
351
+ - Custom/nested tool integrations depend on compatible host forwarding
352
+ and queue support. Where supported, constraint notes must survive into
353
+ the model-visible result while original content, images and structured
354
+ results are preserved. Do not assume every custom envelope does this;
355
+ read large specs directly with standard `read` and offset pages rather than relying on
356
+ a nested wrapper that can hide or truncate their bodies.
357
+ - `lint` checks structure only. Skill-guided reviews are model judgments
358
+ and may misjudge relevance, durable value or a conflict; neither proves
359
+ semantic correctness or compliance.
360
+ - Paths support repo-relative or same-project absolute paths, `/` or `\`
361
+ separators, Pi's `@path`/`file://` aliases, and Windows drive paths mapped
362
+ to `/mnt/<drive>/` on WSL. Enabled standard tools execute the normalized
363
+ path that was checked; spec authorization also preserves the original path's
364
+ protected identity when a spec symlink points elsewhere inside the project.
365
+ Foreign UNC paths are not interpreted as local paths; symlink escapes are
366
+ rejected for project spec writes. Native Windows behavior needs platform testing.
367
+ - `related` has no filesystem awareness of the supplied target path and
368
+ no proof of routing coverage. Task-local hashes are not a continuous
369
+ monitor of external changes. A single spec line larger than Pi's read byte
370
+ limit cannot be credited as read; do not keep retrying or bypass the gate —
371
+ report it and ask for readable formatting or a user-chosen mode change.
372
+ Files over 2 MiB use coarse size/mtime fingerprints;
373
+ Git checks exceeding 500 changed/untracked entries fall back to conversation
374
+ evidence instead of scanning a large worktree.
375
+
376
+ The two CLI commands remain helpers, not workflow gates. Native tool
377
+ gates apply only to the opt-in Pi integration, within the limits above.
203
378
 
204
379
  ## Development
205
380
 
206
- Requires Node.js 20 or newer. There are no runtime or development
207
- dependencies and no build step.
381
+ Core development requires Node.js 20 or newer; there are no runtime or
382
+ development dependencies and no build step. Use the higher Node/host
383
+ requirements above for Pi smoke checks.
384
+
385
+ Verification commands:
208
386
 
209
387
  ```bash
210
388
  npm test
389
+ npm run test:pi # isolated real RPC/UI smoke; no model calls
390
+ CHARTERLEAF_MODEL=deepseek/deepseek-flash npm run test:organic # paid isolated A–G acceptance
391
+ CHARTERLEAF_MODEL=deepseek/deepseek-flash CHARTERLEAF_BASELINE=5d470db npm run test:context # paid comparison against v0.4.0
392
+ node scripts/organic-smoke.js --self-test # harness/oracle check, no model calls
393
+ node --test tests/extension.test.js
211
394
  npm pack --dry-run
395
+ node bin/charterleaf.js related extensions/charterleaf.js
396
+ node bin/charterleaf.js lint
212
397
  ```
213
398
 
399
+ Validated on Node.js 24.21.0 and Pi 1.1.0: 183 automated Core/Extension tests,
400
+ structural lint, dry-run npm packaging, and real Pi RPC loading/native selection
401
+ for cancellation, mode changes, disable and cross-process restore. The RPC smoke
402
+ uses isolated temporary projects/settings and makes no model calls.
403
+
404
+ The 0.4.1 organic-capture acceptance used real `deepseek/deepseek-flash` sessions
405
+ in isolated Git projects. Actual tool results, approval records, specs, routing,
406
+ file snapshots/diffs, original tests and an independent exact-money oracle were
407
+ checked, not just assistant claims. Empty-spec development and investigation,
408
+ ordinary-query non-triggers, authoritative-document references, bug-versus-contract
409
+ handling, cross-session reuse and mode/read-only/no-UI permissions all have passing
410
+ evidence. **The strict matrix was not consistently all-green:** the latest focused
411
+ A→F1→F2 run passed A and F1, while F2 omitted its knowledge-completion comment and
412
+ received one redundant fallback review. Its actual routing, code behavior and
413
+ unchanged-spec checks passed. These bounded examples do not prove model reliability
414
+ on every task or provider, or guarantee zero redundant reviews.
415
+
416
+ The controlled organic-capture comparison (`scripts/context-smoke.js`) keeps cwd,
417
+ package paths, prompt and native tools identical. On `deepseek/deepseek-flash`:
418
+ disabled 1,616 input tokens; v0.4.0 baseline 2,036 (increment 420); organic runtime
419
+ 2,142 (increment 526), or **106 additional fixed input tokens**. Input here includes
420
+ cached input. Each chat made exactly one request and no tool calls; the full Skill
421
+ remains on demand. Absolute path tokenization and provider tokenizers vary, and
422
+ cache-hit sizes affect uncached charges, so these are measured examples, not fixed
423
+ billing guarantees. The script defaults to `HEAD`; set `CHARTERLEAF_BASELINE` to the
424
+ older revision being compared (the command above uses the v0.4.0 release commit).
425
+ Substantive work requests **zero or at most one** extra model review: observed
426
+ capture/coverage/proposal completion skips the fallback; empty-spec development can
427
+ still get the one fallback after a `no-value` judgment.
428
+
429
+ The real-model acceptance script retains raw RPC/JSONL, conversations, actual tool
430
+ records, before/after files, tracked/untracked diffs, validation output and individual
431
+ assertions in the printed private temporary directory, including failures. It copies
432
+ credentials only to isolated settings and removes that copy on normal/error
433
+ completion; a forced process kill may require deleting the private credential
434
+ copy manually. It does not
435
+ import personal extensions/instructions or modify other repositories, and it is not
436
+ an OS security sandbox. Both paid scripts require explicit `CHARTERLEAF_MODEL=provider/id`; neither chooses
437
+ another provider automatically. The requested model must be available;
438
+ `--case A,B,...` selects bounded scenarios. F cases run A first, then B only if A failed to capture; they reuse only
439
+ model-written knowledge in the same project across fresh sessions. A's failure
440
+ remains a failed assertion; a successful B→F chain does not conceal it. Model usage counts visible assistant responses, not hidden
441
+ provider HTTP retries. Cost fixtures also remove copied credentials on exit.
442
+
443
+ For an interactive smoke check in a disposable project, install the local
444
+ package in a compatible Pi host and run `/charterleaf`: check immediate
445
+ mode persistence, cancel/disable preserving documents, constraint loading
446
+ on a standard read, a blocked unseen-constraint write followed by retry,
447
+ Confirm approve/refuse, and Reference denying spec writes. After a
448
+ substantive edit, check that at most one final review occurs and that no
449
+ durable change leaves specs untouched. Also check saved modes without UI:
450
+ Auto/Reference still read, Confirm denies spec writes, and the menu is
451
+ inactive. Automated hook tests do not substitute for this real-host smoke.
452
+
214
453
  ## License
215
454
 
216
455
  MIT
@@ -0,0 +1,355 @@
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, knowledge: undefined,
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
+ // Semantic intent comes from the existing agent, never from file/read counts.
152
+ pi.on('message_end', (event, ctx) => {
153
+ if (event.message.role !== 'assistant' || ['error', 'aborted'].includes(event.message.stopReason)) return;
154
+ let s;
155
+ try { s = current(ctx); } catch { return; }
156
+ if (!s) return;
157
+ const text = event.message.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
158
+ const prose = text.replace(/```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)/g, '');
159
+ const signal = prose.match(/<!-- charterleaf: (covered|no-value|proposed) -->\s*$/) ||
160
+ [...prose.matchAll(/^<!-- charterleaf: (review|covered|no-value|proposed) -->[ \t]*$/gm)].at(-1);
161
+ if (signal) {
162
+ if (signal[1] === 'proposed' && s.mode === 'confirm' && !s.proposed) return;
163
+ s.knowledge = signal[1];
164
+ if (s.knowledge !== 'review') { s.capture = gitSnapshot(s.root); s.opaque = false; }
165
+ }
166
+ });
167
+
168
+ // A sibling tool call has not seen another sibling's result yet. Promote only at the next model request.
169
+ pi.on('context', (_event, ctx) => {
170
+ if (!state || state.root !== projectRoot(ctx.cwd)) return;
171
+ for (const [spec, hash] of state.staged) state.loaded.set(spec, hash);
172
+ state.staged.clear();
173
+ });
174
+
175
+ pi.on('tool_call', async (event, ctx) => {
176
+ let s;
177
+ try { s = current(ctx); } catch { return; }
178
+ if (!s) return;
179
+ if (event.parentToolCallId) s.parents.set(event.toolCallId, event.parentToolCallId);
180
+ if (!['read', 'write', 'edit'].includes(event.toolName)) return;
181
+ const mutation = event.toolName !== 'read';
182
+ try {
183
+ const file = projectPath(s.root, ctx.cwd, event.input.path);
184
+ const lexical = typeof event.input.path === 'string'
185
+ ? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
186
+ if (!file) {
187
+ if (mutation && typeof event.input.path === 'string') {
188
+ if (isSpec(lexical)) return { block: true, reason: 'Unsafe spec path escapes this project. Preserve specs and fix the symlink/path.' };
189
+ }
190
+ return;
191
+ }
192
+ // Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
193
+ const target = path.join(s.root, file);
194
+ event.input.path = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/.test(target) ? pathToFileURL(target).href : target;
195
+ if (mutation && (file === CONFIG || lexical === CONFIG || file === projectPath(s.root, s.root, CONFIG))) {
196
+ return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
197
+ }
198
+ if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
199
+ if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
200
+ if (s.mode === 'confirm') {
201
+ s.proposed = true;
202
+ if (!ctx.hasUI) return { block: true, reason: 'Charterleaf Confirm mode requires interactive approval. Report the proposal without writing.' };
203
+ const proposal = JSON.stringify(event.input, null, 2);
204
+ if (proposal.length > 12000) return { block: true, reason: 'Spec proposal too large to approve safely. Use smaller standard edits.' };
205
+ 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.' };
206
+ const before = fingerprint(s.root, file);
207
+ const approved = await ctx.ui.confirm(`Charterleaf: modify ${file}?`,
208
+ `Approve this exact ${event.toolName} only if the evidenced durable knowledge maintenance is intended. Never relax a spec just to match conflicting code.\n\n${proposal}`, { timeout: 60000 });
209
+ if (!approved) {
210
+ s.denied.add(proposal);
211
+ return { block: true, reason: 'Spec modification not approved. Preserve the current spec and report the proposal; do not retry.' };
212
+ }
213
+ if (state !== s || readMode(s.root) !== 'confirm' || fingerprint(s.root, file) !== before) {
214
+ return { block: true, reason: 'Project mode or spec changed during approval. Read again and request fresh approval.' };
215
+ }
216
+ }
217
+ } else if (mutation) {
218
+ const constraints = route(s, file);
219
+ if (constraints.required) {
220
+ forward(s, event, constraints.text);
221
+ return { block: true, reason: `Charterleaf: review these constraints before retrying ${event.toolName} ${file}.\n\n${constraints.text}` };
222
+ }
223
+ }
224
+ const observed = lexical && isSpec(lexical) ? lexical : file;
225
+ s.pending.set(event.toolCallId, { file: observed, mutation, before: fingerprint(s.root, observed) });
226
+ } catch (error) {
227
+ 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.` };
228
+ }
229
+ });
230
+
231
+ pi.on('tool_result', (event, ctx) => {
232
+ let s;
233
+ try { s = current(ctx); } catch { return; }
234
+ if (!s) return;
235
+ const call = s.pending.get(event.toolCallId);
236
+ s.pending.delete(event.toolCallId);
237
+ const visible = event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n');
238
+ // Wrappers may already print nested results, either as text or JSON; forward only missing notes.
239
+ const envelope = [...(s.envelopes.get(event.toolCallId) || [])].filter((note) =>
240
+ !visible.includes(note) && !visible.includes(JSON.stringify(note).slice(1, -1))).join('\n\n');
241
+ s.envelopes.delete(event.toolCallId);
242
+ if (event.isError) {
243
+ if (call?.mutation) {
244
+ try {
245
+ if (fingerprint(s.root, call.file) !== call.before) {
246
+ s.opaque = true;
247
+ if (s.knowledge !== 'review') s.knowledge = undefined;
248
+ }
249
+ } catch { s.opaque = true; s.knowledge = undefined; }
250
+ }
251
+ if (envelope) return { content: [...event.content, { type: 'text', text: `[Charterleaf]\n${envelope}` }] };
252
+ return;
253
+ }
254
+ try {
255
+ let note = envelope;
256
+ if (call) {
257
+ const hash = fingerprint(s.root, call.file);
258
+ if (call.mutation && hash !== call.before) {
259
+ if (s.knowledge !== 'review') s.knowledge = undefined;
260
+ s.owned.set(call.file, hash);
261
+ if (isSpec(call.file)) {
262
+ s.loaded.delete(call.file);
263
+ s.pages.delete(call.file);
264
+ let output = '';
265
+ const stream = { write: (text) => { output += text; } };
266
+ if (lint(s.root, { stdout: stream, stderr: stream }) === 0) {
267
+ s.knowledge = 'captured';
268
+ s.capture = gitSnapshot(s.root);
269
+ s.opaque = false;
270
+ }
271
+ note = `Charterleaf structural check (not semantic verification):\n${output.slice(0, 4000)}`;
272
+ }
273
+ } else if (!call.mutation) {
274
+ if (isSpec(call.file)) {
275
+ const fullRead = event.input.offset === undefined && event.input.startLine === undefined &&
276
+ event.input.limit === undefined && event.input.endLine === undefined &&
277
+ event.details?.truncation?.truncated !== true &&
278
+ !event.content.some((part) => part.type === 'text' &&
279
+ /(?:\[Showing lines \d+-\d+ of |\[\d+ more lines in file\.|\[Line \d+ is .*exceeds .*limit\.|\(File is large; use startLine\/endLine)/.test(part.text));
280
+ if (hash === call.before) {
281
+ const complete = fullRead || completePages(s, call.file, hash, event);
282
+ if (complete) s.staged.set(call.file, hash);
283
+ // Forward every explicit nested page, not just the last: scripts may discard results.
284
+ forward(s, event, `--- ${call.file} ---\n${event.content.filter((part) => part.type === 'text').map((part) => part.text).join('\n')}`);
285
+ }
286
+ } else note = route(s, call.file).text;
287
+ }
288
+ } else if (!['read', 'write', 'edit', 'grep', 'find', 'ls'].includes(event.toolName)) {
289
+ const hint = pi.getAllTools?.().find((tool) => tool.name === event.toolName)?.annotations?.readOnlyHint;
290
+ if (hint !== true) s.opaque = true;
291
+ }
292
+ forward(s, event, note);
293
+ if (note) return {
294
+ content: [...event.content, { type: 'text', text: `\n[Charterleaf]\n${note}` }],
295
+ ...(event.structuredContent === undefined ? {} : { structuredContent: event.structuredContent }),
296
+ };
297
+ } catch (error) {
298
+ 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.]` }] };
299
+ }
300
+ });
301
+
302
+ pi.on('agent_before_settle', (event, ctx) => {
303
+ let s;
304
+ try { s = current(ctx); } catch { return; }
305
+ // canContinue previews existing entries; our custom message makes a completed assistant answer actionable.
306
+ if (!s || s.reviewed || event.outcome !== 'completed') return;
307
+ s.reviewed = true;
308
+ try {
309
+ if (['captured', 'covered', 'proposed'].includes(s.knowledge)) {
310
+ if (!s.opaque) return;
311
+ const afterCapture = gitSnapshot(s.root);
312
+ if (s.capture && afterCapture && !changedSince(s.capture, afterCapture).some((file) => !isSpec(file))) return;
313
+ }
314
+ if (!['review', 'no-value'].includes(s.knowledge) && !s.owned.size && !s.opaque) return;
315
+ const after = gitSnapshot(s.root);
316
+ const comparable = s.before && after;
317
+ const observed = changedSince(s.before, after);
318
+ const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
319
+ const unattributed = observed.filter((file) => !s.owned.has(file) || fingerprint(s.root, file) !== s.owned.get(file));
320
+ const emptySpecs = s.knowledge === 'no-value' && resolveRelated('', s.root).available.length === 0;
321
+ if (s.knowledge === 'no-value' && !(emptySpecs && owned.length > 0)) return;
322
+ const candidate = s.knowledge === 'review';
323
+ if (!candidate && !owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
324
+ const relevant = new Set();
325
+ for (const file of [...owned, ...unattributed].slice(0, 50)) {
326
+ const result = resolveRelated(file, s.root);
327
+ for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
328
+ }
329
+ const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
330
+ (candidate ? 'The agent identified unresolved durable knowledge in this task; file changes are not required. Judge the conversation evidence, not the signal itself.\n' : '') +
331
+ (emptySpecs ? 'Development in an empty-spec project: verify the no-value conclusion against evidence of existing undocumented constraints, not just newly changed contracts.\n' : '') +
332
+ `Successful standard tool edits still unchanged at settlement: ${JSON.stringify(owned.slice(0, 50))}.\n` +
333
+ `Observed worktree changes NOT attributable to this agent: ${JSON.stringify(unattributed.slice(0, 50))}. ` +
334
+ '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' +
335
+ `Possibly affected specs (routing hints only): ${JSON.stringify([...relevant].slice(0, 50))}.\n` +
336
+ (!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' : '') +
337
+ 'Consider newly established AND newly discovered knowledge, including read-only investigation or accepted decisions; no code change is required. In an empty-spec project, tests or accepted behavior that reveal a non-obvious invariant without effective code-path guidance is a discovery gap; do not dismiss it merely because tests pass or the implementation already works. ' +
338
+ 'No justified durable knowledge means no spec edit; no code summary, task log or plans. ' +
339
+ 'Record only evidenced, non-obvious contracts, invariants, boundaries, pitfalls or accepted decisions whose absence risks future mistakes and which are not effectively documented. ' +
340
+ 'Current code alone is not an accepted contract. Prefer a short routed reference to an authoritative document over copying it; do not duplicate knowledge already captured in this task. ' +
341
+ 'Honor explicit no-file-write/read-only instructions even in Auto; report only. ' +
342
+ 'A code/spec conflict is not permission to rewrite the spec: distinguish implementation bug, obsolete spec and explicitly intended contract change. ' +
343
+ 'If uncertain, preserve the spec and report the conflict. ' +
344
+ (s.mode === 'reference' ? 'Reference: report necessary proposals/conflicts only; never write specs. ' :
345
+ s.mode === 'confirm' ? 'Confirm: invoke the exact spec write/edit to open the Extension approval dialog; the extension must obtain approval before execution. If already refused/no UI, report without retrying. ' :
346
+ 'Auto: update only when durable knowledge is justified by this task; otherwise leave specs unchanged. ') +
347
+ 'For unfamiliar authoring/staging details, read the guide. Use standard write/edit and lint. ' +
348
+ 'Report task results/tests, not routine review bookkeeping; do not start another development cycle.';
349
+ return { entries: [...event.entries, message(content)], continue: true };
350
+ } catch (error) {
351
+ report(ctx, `Charterleaf final review skipped: ${error.message}`);
352
+ return { entries: [...event.entries, message(`Charterleaf final check failed: ${error.message}. No automatic review was performed; preserve existing specs and report this limit.`)] };
353
+ }
354
+ });
355
+ };
@@ -152,13 +152,10 @@ function write(stream, line) {
152
152
  stream.write(`${line}\n`);
153
153
  }
154
154
 
155
- function related(repoPath, cwd = process.cwd(), streams = {}) {
156
- const stdout = streams.stdout || process.stdout;
157
- const stderr = streams.stderr || process.stderr;
155
+ function resolveRelated(repoPath, cwd = process.cwd()) {
158
156
  const specRoot = path.join(cwd, 'specs');
159
157
  if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
160
- write(stderr, 'ERROR specs/: not found');
161
- return 2;
158
+ throw new Error('ERROR specs/: not found');
162
159
  }
163
160
 
164
161
  const target = normalizeRepoPath(repoPath);
@@ -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
- if (constitution) blocks.push(['Global', [` ${constitution.display}`]]);
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.display}`)]);
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 && livingSpecs.length) {
253
+ if (!scoped.length && available.length) {
236
254
  const lines = [];
237
- livingSpecs.forEach((document, index) => {
255
+ available.forEach((document, index) => {
238
256
  if (index) lines.push('');
239
- const id = typeof document.frontmatter.id === 'string' ? document.frontmatter.id.trim() : '';
240
- const appliesTo = Array.isArray(document.frontmatter.applies_to)
241
- ? document.frontmatter.applies_to.join(', ')
242
- : String(document.frontmatter.applies_to ?? '');
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.display}`);
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.3.0",
4
- "description": "A tiny specification layer for coding agents.",
3
+ "version": "0.4.1",
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,19 @@
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",
42
+ "test:organic": "node scripts/organic-smoke.js",
43
+ "test:context": "node scripts/context-smoke.js"
39
44
  },
40
45
  "pi": {
46
+ "extensions": [
47
+ "./extensions/charterleaf.js"
48
+ ],
41
49
  "skills": [
42
50
  "./skills"
43
51
  ]
@@ -1,149 +1,63 @@
1
1
  ---
2
2
  name: charterleaf
3
- description: A tiny specification layer for durable project intent and constraints.
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
- ## Adoption gate
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
- Before using Charterleaf in a repository, check whether `specs/` exists.
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
- If no `specs/` directory exists:
13
+ ## Ownership and durable writing
13
14
 
14
- - do not run Charterleaf;
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
- Charterleaf is opt-in.
17
+ Capture during ordinary development, debugging, code/test/architecture investigation or technical decisions; do not wait for settlement or require existing specs. An enabled project containing only `specs/.charterleaf.json` is a valid starting state, not a bootstrap request.
20
18
 
21
- ## Boundary
19
+ Distinguish **newly established knowledge** (an accepted new contract/decision) from **newly discovered knowledge** (an existing but ineffectively documented constraint). Either can qualify without code changes; neither obliges every task to produce a document. Apply all three checks:
22
20
 
23
- Charterleaf answers one question:
21
+ 1. Reliable evidence: accepted user intent, authoritative documentation, tests or sufficiently verified implementation behavior. Current code alone does not establish an accepted correct contract.
22
+ 2. Long-term value: ignoring it could plausibly cause a future developer/Agent to make a wrong decision.
23
+ 3. A documentation or discoverability gap: inspect relevant existing owners/specs before writing. If an authoritative document already covers it, add only a short scoped reference when routing is genuinely missing; otherwise do nothing. Never duplicate a captured rule. Executable tests provide evidence, not automatically effective code-path discovery: accepted non-obvious invariants scattered among tests may warrant a short scoped constraint/reference even when no new contract was established. Do not copy test cases or document trivial assertions; judge whether a future code change could miss the boundary.
24
24
 
25
- > For this repository path, which durable constraints should I read?
25
+ Honor explicit no-file-write/read-only restrictions in every mode. Otherwise **code edits are allowed normally in all three modes**, including Reference and Confirm. Auto can create or update specs immediately when justified; Confirm uses the existing exact-spec-write approval; Reference only proposes spec changes, never restricts implementation edits. **In Confirm, invoke standard `write`/`edit` with the exact spec proposal to open the Extension's approval dialog; do not merely ask for prose permission and stop.** The call is an approval request, not an unauthorized mutation: the Extension pauses it before execution, denies it without UI, and only executes the exact operation if approved. If evidence conflicts, verify authority and distinguish a bug from an accepted change, never rewrite truth to match suspect code.
26
26
 
27
- A project's own reference protocol — its `AGENTS.md` entry and the
28
- project-owned documents it points to — answers a different question:
27
+ Place qualifying non-obvious knowledge in:
29
28
 
30
- > Where do this project's authoritative facts live?
29
+ - `specs/constitution.md`: rare project-wide boundaries;
30
+ - `specs/capabilities/`: observable contracts, invariants and non-goals;
31
+ - `specs/engineering/`: long-lived implementation boundaries, recurring pitfalls and verification constraints;
32
+ - `specs/decisions/`: accepted rationale and revisit conditions.
31
33
 
32
- Charterleaf owns only: spec taxonomy, durable constraint routing
33
- (`related`), and structural lint (`lint`).
34
+ Living capability/engineering specs need a unique `id` and non-empty `applies_to` list, including the first organically created spec. For example:
34
35
 
35
- Charterleaf does not own: current project state, tasks, workflow,
36
- planning, memory, evidence, history, session lifecycle, project-wide
37
- consistency, semantic code/spec verification, or repository state
38
- synchronization.
39
-
40
- ## Progressive retrieval
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.
36
+ ```markdown
37
+ ---
38
+ id: engineering.amounts
39
+ applies_to:
40
+ - src/amounts.js
41
+ - tests/amounts.test.js
42
+ ---
43
+ # Amount boundary
90
44
 
91
- ## Spec writing discipline
45
+ Preserve the accepted rounding contract; see [accounting policy](../../docs/accounting.md#rounding) and its executable cases in `tests/amounts.test.js`.
46
+ ```
92
47
 
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.
48
+ Use this shape, not this example's rule, for the actual project evidence. Route rationale in `decisions/` through a relevant capability/engineering reference when automatic code-path discovery is needed. 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. Task progress, implementation inventories, one-off debugging, temporary plans/todos and unverified behavior never belong in Specs.
96
49
 
97
- Good fits: contracts, invariants, architecture boundaries, non-goals,
98
- recurring pitfalls, accepted architectural rationale, verification
99
- constraints.
50
+ The runtime's standalone assistant comments (outside code fences; exact terminal completion comments also work inline) `<!-- charterleaf: review -->`, `<!-- charterleaf: covered -->`, `<!-- charterleaf: no-value -->` and `<!-- charterleaf: proposed -->` are task-local lifecycle signals, not spec contents, evidence or permissions. `review` means a concrete evidenced candidate still needs knowledge judgment; `covered` means relevant authority is already effectively discoverable; `no-value` means the evidence/value/gap checks found no qualifying knowledge; `proposed` means a Reference/read-only proposal was reported or a Confirm proposal passed through the gate and was refused/no UI. Use these exact comments; put explanation outside, never inside them. If an explicit no-write task surfaced a qualified constraint, report the finding/proposal in the normal answer and finish the knowledge judgment there rather than requesting a continuation just to repeat it. Ordinary code/test completion is not any of those conclusions: a generic `done` comment is deliberately ignored. Never use `covered` merely because tests exercise a boundary without effective code-path discovery. In an enabled empty-spec project, do not reject a non-obvious accepted invariant merely because the tests already exercise it. Waiting for human approval is not unresolved knowledge judgment: do not request a semantic continuation just to ask the same permission again. After submitting an exact proposal to the approval gate, handle approval/refusal/no UI and finish normally. New distinct findings may rearm, but the Extension continues at most once. Successful structurally valid standard spec writes also mark capture complete; later code edits rearm the development fallback. In an empty-spec project, owned development after `no-value` still permits the single fallback to check for missed discovery gaps. Omitting a recognized conclusion can cause a redundant fallback even when the normal answer already states that nothing needs documenting. Plain reads, file counts, search results and task wording are not semantic discovery signals.
100
51
 
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.
52
+ ## Optional staged changes
105
53
 
106
- Place a constraint where it belongs:
54
+ 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.
107
55
 
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/`.
56
+ ## CLI details
115
57
 
116
- Keep specs compressed. Prefer durable contracts and pitfalls over
117
- commentary, and never invent aspirational standards.
58
+ Run from the project root:
118
59
 
119
- ## changes/ is optional staging
60
+ - `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.
61
+ - `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.
120
62
 
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.
63
+ 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,15 @@
1
+ # Charterleaf runtime
2
+
3
+ Use supplied specs; no bulk/unchanged rereads. Blocked write/edit: inspect constraints/read the complete spec, then retry; never bypass via another writer. Routing: a hint.
4
+
5
+ Modes govern specs, not code: Auto maintains justified knowledge; Confirm calls spec write/edit to open Extension approval (refusal/no UI: preserve, no retry); Reference proposes, never writes specs. All modes allow normal code edits. Use standard write/edit only. User-owned config. read-only/no-file-write requests override Auto. Not a filesystem sandbox.
6
+
7
+ Internal/refactor requests do not authorize changing return or ownership contracts. Conflicts: verify authority, fix bugs; never relax specs to match code. Invariant-breaking method? Preserve spec/tests; get an explicit contract decision before coding. Uncertain: preserve and report. Preserve user/parallel work; Git dirt/touched files aren't line ownership. No automatic revert/commit/push.
8
+
9
+ ## Organic knowledge capture
10
+
11
+ During development/debugging/investigation/decisions capture newly established OR newly discovered knowledge, even without code changes/prior specs. Need reliable evidence (accepted decisions, authoritative docs, tests or verified behavior), mistake-prevention value and a documentation/discoverability gap. Code alone is not an accepted contract. Check owners; tests aren't automatic code-path routing; prefer short routed references over copying docs. New capability/engineering specs: unique id, narrow applies_to; see guide. No justified knowledge: no spec. No code summaries/progress/logs/plans/todos/speculation.
12
+
13
+ ## One final semantic review
14
+
15
+ Judge during work, not by read counts. Use one exact comment: <!-- charterleaf: review --> (evidenced pending), <!-- charterleaf: covered --> (effective authority), <!-- charterleaf: no-value --> (no value), <!-- charterleaf: proposed --> (Reference/refused Confirm/read-only report). Reasons outside, never inside comments. NOT merely finishing code/tests. Signals aren't evidence/permission. At most one extra review; no loop. Report results/tests, not review logs. Lint is structural only.