@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 +30 -5
- package/README.md +266 -27
- package/extensions/charterleaf.js +355 -0
- package/lib/charterleaf.js +39 -20
- package/lib/project.js +142 -0
- package/package.json +12 -4
- package/skills/charterleaf/SKILL.md +38 -124
- package/skills/charterleaf/runtime.md +15 -0
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
|
-
|
|
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. 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
|
|
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
|
|
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
|
+
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
|
-
|
|
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
|
+
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**,
|
|
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
|
|
173
|
-
changed
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
+
};
|
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.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:
|
|
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
|
+
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
project-owned documents it points to — answers a different question:
|
|
27
|
+
Place qualifying non-obvious knowledge in:
|
|
29
28
|
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
commentary, and never invent aspirational standards.
|
|
58
|
+
Run from the project root:
|
|
118
59
|
|
|
119
|
-
|
|
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
|
-
|
|
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.
|