@onedotmint/charterleaf 0.4.1 → 0.4.3

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
@@ -11,10 +11,21 @@ owner per fact and link to it rather than duplicating it.
11
11
 
12
12
  Charterleaf automation is explicit opt-in, not implied by this entry or
13
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.
14
+ Reference or Not Enable through `/charterleaf`. In Git, the saved mode lives in
15
+ `charterleaf.json` under `git rev-parse --path-format=absolute --git-common-dir`
16
+ (usually `.git/charterleaf.json`), shared by all branches and linked worktrees,
17
+ not independent clones. Outside Git, it remains `specs/.charterleaf.json`.
18
+ The local config is authoritative, including saved `off`; a branch's legacy config
19
+ cannot override it. Only when local config is absent may first load import a valid
20
+ saved mode from current-branch legacy `specs/.charterleaf.json`, without overwriting local
21
+ config or changing the legacy file/worktree. Invalid config means an error and
22
+ disabled automation; never recover lost authorization from Git history or sessions.
23
+ No saved choice means off, not a global Auto default: no automatic scan, adoption
24
+ prompt or document creation. Enabling Git projects creates no `specs/` directory or
25
+ documents; a branch without specs remains usable for normal reads/edits and later
26
+ justified spec creation under the selected mode. Specs route from the current
27
+ worktree, not the shared config directory. Do not change activation config on the
28
+ user's behalf.
18
29
 
19
30
  For manual use on a concrete repository path, run from the project root:
20
31
 
package/README.md CHANGED
@@ -39,7 +39,7 @@ pi install npm:@onedotmint/charterleaf
39
39
  For the standalone CLI, use `npm install -g @onedotmint/charterleaf`.
40
40
  For local development, use `pi install /absolute/path/to/charterleaf` (a package
41
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`.
42
+ then `npm install -g ./onedotmint-charterleaf-0.4.3.tgz`.
43
43
 
44
44
  Restart Pi or run `/reload` after installation, then use `/charterleaf` in each
45
45
  project you want to enable. To stop automation, choose **Not Enable** in that
@@ -51,14 +51,17 @@ directly and supplies the bundled CLI entry point for optional manual checks.
51
51
 
52
52
  The **Core/CLI** runs standalone on Node.js **20+**, has no dependencies,
53
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`:
54
+ `@earendil-works/pi-coding-agent` **1.1.0 and 1.4.0** and uses `agent_before_settle`:
55
55
  it requires a host with that compatible Extension API, not just any Pi
56
56
  version. That Pi host requires Node.js **22.19.0+**; the Core's lower
57
57
  minimum does not make older Node versions suitable for the host.
58
58
 
59
59
  ## Native Pi modes
60
60
 
61
- Run `/charterleaf` in Pi for the native project mode menu:
61
+ Run `/charterleaf` in Pi for the native project mode menu. It distinguishes
62
+ **not configured** from explicitly **disabled**, explains that existing specs do
63
+ not enable automation, and describes the current mode. Saving confirms what the
64
+ selected mode allows, not just where its config was written:
62
65
 
63
66
  | Choice | Behavior |
64
67
  | --- | --- |
@@ -67,20 +70,35 @@ Run `/charterleaf` in Pi for the native project mode menu:
67
70
  | **Reference** | Read and check; report proposals or conflicts, never modify specs. |
68
71
  | **Not Enable** | Turn automation off; keep existing documents. |
69
72
 
70
- The choice is saved immediately in `specs/.charterleaf.json`:
73
+ The choice is saved immediately in `charterleaf.json` under the absolute directory
74
+ returned by `git rev-parse --path-format=absolute --git-common-dir` (usually
75
+ `.git/charterleaf.json`). All branches and linked worktrees of that repository
76
+ share the mode; independent clones do not. Outside Git, the path remains
77
+ `specs/.charterleaf.json`. The config format is unchanged:
71
78
 
72
79
  ```json
73
80
  { "version": 1, "mode": "off" }
74
81
  ```
75
82
 
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.
83
+ Valid modes are `auto`, `confirm`, `reference`, and `off`. The new repository-local
84
+ config is authoritative whenever it exists, including saved `off`; a branch's
85
+ legacy `specs/.charterleaf.json` cannot override it. If no local config exists,
86
+ the first load imports the saved mode from a valid legacy config on the current branch,
87
+ without overwriting a local config or changing/removing the legacy file or tracked
88
+ worktree. Invalid config reports an error and disables automation, not a fallback
89
+ to another source. Lost authorization is never recovered from Git history or sessions.
90
+
91
+ With no saved choice, the mode stays **off**; existing `specs/` alone never enables
92
+ automation. This is repository-local opt-in, **not a global Auto default**.
93
+ Activation is an explicit user choice through `/charterleaf`, not an Agent decision.
94
+ Enabling a Git project creates only the local config, never a `specs/` directory or
95
+ spec documents. A branch without `specs/` is valid: normal code reads/edits and later
96
+ evidence-based spec creation still work under the selected mode. Spec content and
97
+ routing always belong to the current worktree, not the shared Git directory.
98
+ Outside Git, saving may create the `specs/` directory for the config only.
99
+ Disabling preserves documents; cancel changes nothing. Disabled/unconfigured
100
+ projects get no automatic adoption prompt, task context, scan, or document bootstrap.
101
+ The two manual CLI commands remain unchanged and independent of this config.
84
102
 
85
103
  Without UI, saved Auto and Reference modes still read/check constraints;
86
104
  Confirm denies spec mutations because approval is unavailable. A refused identical
@@ -109,9 +127,11 @@ request**, not when a parallel sibling tool finishes.
109
127
  Automatic spec bodies have a **24,000-byte budget per task** and an
110
128
  **8,000-byte per-file limit**. Larger files or exhausted budgets require
111
129
  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
130
+ knowledge. Native Pi raw pages and AFT numbered pages (offset/limit or
131
+ startLine/endLine) are credited only when their exact content covers the same
132
+ spec version with no gaps; AFT line labels must be sequential and correct.
133
+ Display newlines and CRLF line endings do not prevent complete-read credit;
134
+ ordinary prose about truncation does not count as a tool truncation notice. On a scoped miss, the Extension supplies metadata only for
115
135
  available living specs, capped at 12 entries and 2,000 characters;
116
136
  `charterleaf related <path>` provides the full routing list. A miss never
117
137
  proves that no constraints apply.
@@ -130,8 +150,9 @@ again. This is task-local bookkeeping, not a watcher or persistent index.
130
150
 
131
151
  Knowledge capture happens **during normal work**, not just after code changes:
132
152
  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
153
+ accepted technical decisions can establish or uncover durable knowledge. An enabled
154
+ project with no spec documents (or no `specs/` directory in Git) is a valid initial
155
+ state. When evidence warrants
135
156
  it, the Agent creates a concise scoped spec using ordinary authorized `write`/`edit`;
136
157
  Core immediately routes its `id`/`applies_to` on later related code reads or edits.
137
158
  There is no initialization scan, document quota or adoption wizard.
@@ -185,8 +206,10 @@ workflow or another development cycle.
185
206
 
186
207
  ## CLI
187
208
 
188
- Charterleaf exposes exactly two deterministic commands. Run them from a
189
- repository root containing `specs/`:
209
+ Charterleaf exposes exactly two deterministic commands. Run them from the project
210
+ root or a subdirectory: the nearest ancestor containing `specs/` is used, without
211
+ crossing a Git repository or worktree boundary. The target path remains
212
+ repo-relative, not relative to the shell's current directory.
190
213
 
191
214
  ```bash
192
215
  charterleaf related <path>
@@ -363,7 +386,8 @@ Important limits:
363
386
  path that was checked; spec authorization also preserves the original path's
364
387
  protected identity when a spec symlink points elsewhere inside the project.
365
388
  Foreign UNC paths are not interpreted as local paths; symlink escapes are
366
- rejected for project spec writes. Native Windows behavior needs platform testing.
389
+ rejected for project spec writes. Native Windows CLI, helper/Extension tests and
390
+ RPC mode persistence are covered by hosted CI; platform-specific permissions still apply.
367
391
  - `related` has no filesystem awareness of the supplied target path and
368
392
  no proof of routing coverage. Task-local hashes are not a continuous
369
393
  monitor of external changes. A single spec line larger than Pi's read byte
@@ -386,6 +410,7 @@ Verification commands:
386
410
 
387
411
  ```bash
388
412
  npm test
413
+ npm run test:package # real tarball install and tests against installed code
389
414
  npm run test:pi # isolated real RPC/UI smoke; no model calls
390
415
  CHARTERLEAF_MODEL=deepseek/deepseek-flash npm run test:organic # paid isolated A–G acceptance
391
416
  CHARTERLEAF_MODEL=deepseek/deepseek-flash CHARTERLEAF_BASELINE=5d470db npm run test:context # paid comparison against v0.4.0
@@ -396,10 +421,20 @@ node bin/charterleaf.js related extensions/charterleaf.js
396
421
  node bin/charterleaf.js lint
397
422
  ```
398
423
 
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.
424
+ GitHub Actions runs the installed-package suite on Ubuntu and Windows with Node
425
+ 20.0.0, 22.19.0 and 24.x, plus Pi 1.1.0/1.4.0 smoke checks on host-compatible Node.
426
+ CI makes no model calls and retains packaging/test logs. `PI_CLI` can select the
427
+ Pi Node entry point for smoke checks without relying on platform-specific shims.
428
+
429
+ For v0.4.2, the [passing eight-job release-candidate matrix](https://github.com/onedotmint/Charterleaf/actions/runs/40078816360)
430
+ checks the actual npm tarball after isolated installation, including byte equality,
431
+ CLI commands and Core/Extension/recovery tests. Each Ubuntu job passes all 227 tests;
432
+ each Windows job passes 220 with zero failures and seven platform-specific skips
433
+ (one Linux/WSL path check, two POSIX permission checks and four SIGKILL checks).
434
+ Windows symlink tests run, rather than being skipped. Host-compatible jobs also
435
+ pass real Pi RPC loading/native selection, cancellation, mode changes, legacy
436
+ migration and cross-branch/process persistence. RPC checks use isolated temporary
437
+ projects/settings and make no model calls.
403
438
 
404
439
  The 0.4.1 organic-capture acceptance used real `deepseek/deepseek-flash` sessions
405
440
  in isolated Git projects. Actual tool results, approval records, specs, routing,
@@ -407,11 +442,18 @@ file snapshots/diffs, original tests and an independent exact-money oracle were
407
442
  checked, not just assistant claims. Empty-spec development and investigation,
408
443
  ordinary-query non-triggers, authoritative-document references, bug-versus-contract
409
444
  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
445
+ evidence. **The earlier strict matrix was not consistently all-green:** a focused
411
446
  A→F1→F2 run passed A and F1, while F2 omitted its knowledge-completion comment and
412
447
  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.
448
+ unchanged-spec checks passed. A later complete pre-0.4.2 run on the same model
449
+ passed all 15 scenarios and 231 assertions, including F1/F2 without redundant reviews.
450
+ The final Windows Git-root path fix was subsequently validated by cross-platform
451
+ CI, not another paid model run. These bounded examples do not prove model reliability
452
+ on every task or provider, or guarantee zero redundant reviews. A subsequent
453
+ case-A rerun on `deepseek/deepseek-flash` failed knowledge-capture assertions on
454
+ both the v0.4.2 baseline and the patch candidate: accounting tests passed, but
455
+ the model concluded `covered` without writing a routed spec. Automatic knowledge
456
+ capture remains model-dependent; passing structural tests does not prove it.
415
457
 
416
458
  The controlled organic-capture comparison (`scripts/context-smoke.js`) keeps cwd,
417
459
  package paths, prompt and native tools identical. On `deepseek/deepseek-flash`:
@@ -4,7 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { pathToFileURL } = require('node:url');
6
6
  const { resolveRelated, lint } = require('../lib/charterleaf.js');
7
- const { CONFIG, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince } = require('../lib/project.js');
7
+ const { projectRoot, projectPath, toolPath, modeConfigPath, isModeConfig, readMode, saveMode, fingerprint, gitSnapshot, changedSince } = require('../lib/project.js');
8
8
 
9
9
  const OPTIONS = [
10
10
  'Auto (recommended) — read constraints and maintain only durable knowledge',
@@ -13,6 +13,12 @@ const OPTIONS = [
13
13
  'Not Enable — disable; keep existing documents',
14
14
  ];
15
15
  const MODES = ['auto', 'confirm', 'reference', 'off'];
16
+ const MODE_EFFECTS = {
17
+ auto: 'Automatic constraint loading and code edit checks; maintain only justified durable specs.',
18
+ confirm: 'Automatic constraint loading and code edit checks; approve each spec modification.',
19
+ reference: 'Automatic constraint loading and code edit checks; never modify specs.',
20
+ off: 'Automation off: no automatic constraint loading or code edit checks; existing documents preserved.',
21
+ };
16
22
  const isSpec = (file) => file === 'specs' || file.startsWith('specs/');
17
23
  const message = (content) => ({ type: 'custom_message', customType: 'charterleaf', content, display: false });
18
24
 
@@ -32,8 +38,14 @@ module.exports = function charterleaf(pi) {
32
38
  return state;
33
39
  }
34
40
 
41
+ function related(file, root) {
42
+ // Mode survives branch switches even when the new branch has no specs yet.
43
+ if (!fs.existsSync(path.join(root, 'specs'))) return { global: [], scoped: [], changes: [], available: [] };
44
+ return resolveRelated(file, root);
45
+ }
46
+
35
47
  function route(s, file) {
36
- const result = resolveRelated(file, s.root);
48
+ const result = related(file, s.root);
37
49
  const notes = [];
38
50
  let required = false;
39
51
  for (const spec of [...result.global, ...result.scoped, ...result.changes]) {
@@ -53,7 +65,8 @@ module.exports = function charterleaf(pi) {
53
65
  s.budget -= size;
54
66
  s.staged.set(spec, hash);
55
67
  } else {
56
- notes.push(`Read the complete ${spec} with read before modifying ${file}; automatic context budget reached.`);
68
+ const pages = Math.ceil(size / 8000);
69
+ notes.push(`Read the complete ${spec} (${size} bytes, ~${pages} page(s) via offset/limit) before modifying ${file}; it exceeds the ${size > 8000 ? '8 KB per-file limit' : 'per-task context budget'}.`);
57
70
  }
58
71
  }
59
72
  if (!result.scoped.length && !s.miss) {
@@ -68,12 +81,23 @@ module.exports = function charterleaf(pi) {
68
81
 
69
82
  function completePages(s, file, hash, event) {
70
83
  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\.\]$/;
84
+ const footer = /\n\n(?:\[(?:Showing lines \d+-\d+ of \d+[^\n]*|\d+ more lines in file\.[^\n]*)Use offset=\d+ to continue\.\]|\(File is large; use startLine\/endLine or offset\/limit to read a section\.\))$/;
72
85
  const body = text.replace(footer, '');
73
86
  const lines = fs.readFileSync(path.join(s.root, file), 'utf8').split('\n');
74
87
  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;
88
+ let end = start + body.split('\n').length;
89
+ if (start < 0) return false;
90
+ if (end > lines.length || lines.slice(start, end).join('\n') !== body) {
91
+ const numbered = body.split('\n');
92
+ // AFT adds sequential line labels and a final display newline, not file content.
93
+ if (numbered.at(-1) === '') numbered.pop();
94
+ if (!numbered.length || !numbered.every((line, i) => {
95
+ const match = line.match(/^ *(\d+): (.*)$/);
96
+ return match && Number(match[1]) === start + i + 1 && match[2] === lines[start + i]?.replace(/\r$/, '');
97
+ })) return false;
98
+ end = start + numbered.length;
99
+ if (end === lines.length - 1 && lines.at(-1) === '') end++;
100
+ }
77
101
  let pages = s.pages.get(file);
78
102
  if (!pages || pages.hash !== hash) pages = { hash, ranges: [] };
79
103
  pages.ranges.push([start, end]);
@@ -107,13 +131,19 @@ module.exports = function charterleaf(pi) {
107
131
  const root = projectRoot(ctx.cwd);
108
132
  let mode;
109
133
  try { mode = readMode(root); } catch { mode = 'invalid config (disabled)'; }
110
- const choice = await ctx.ui.select(`Charterleaf — ${mode}\n${root}`, OPTIONS);
134
+ const config = modeConfigPath(root);
135
+ const status = mode === 'off'
136
+ ? (fs.existsSync(config) ? 'off (disabled)' : 'off (not configured)') : mode;
137
+ const effect = MODE_EFFECTS[mode] || MODE_EFFECTS.off;
138
+ const adoption = !MODES.includes(mode) || mode === 'off'
139
+ ? '\nExisting specs do not enable automation; choose a mode to enable it.' : '';
140
+ const choice = await ctx.ui.select(`Charterleaf — ${status}\n${root}\n${effect}${adoption}`, OPTIONS);
111
141
  const index = OPTIONS.indexOf(choice);
112
142
  if (index < 0) return;
113
143
  if (MODES[index] === 'off' && mode === 'off') return;
114
144
  saveMode(root, MODES[index]);
115
145
  state = undefined;
116
- ctx.ui.notify(`Charterleaf: ${MODES[index]} (saved to ${CONFIG})`, 'info');
146
+ ctx.ui.notify(`Charterleaf: ${MODES[index]} — ${MODE_EFFECTS[MODES[index]]} (saved to ${config})`, 'info');
117
147
  } catch (error) { report(ctx, `Charterleaf: ${error.message}`); }
118
148
  },
119
149
  });
@@ -180,6 +210,9 @@ module.exports = function charterleaf(pi) {
180
210
  if (!['read', 'write', 'edit'].includes(event.toolName)) return;
181
211
  const mutation = event.toolName !== 'read';
182
212
  try {
213
+ if (mutation && isModeConfig(s.root, ctx.cwd, event.input.path)) {
214
+ return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
215
+ }
183
216
  const file = projectPath(s.root, ctx.cwd, event.input.path);
184
217
  const lexical = typeof event.input.path === 'string'
185
218
  ? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
@@ -192,9 +225,6 @@ module.exports = function charterleaf(pi) {
192
225
  // Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
193
226
  const target = path.join(s.root, file);
194
227
  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
228
  if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
199
229
  if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
200
230
  if (s.mode === 'confirm') {
@@ -272,7 +302,8 @@ module.exports = function charterleaf(pi) {
272
302
  }
273
303
  } else if (!call.mutation) {
274
304
  if (isSpec(call.file)) {
275
- const fullRead = event.input.offset === undefined && event.input.startLine === undefined &&
305
+ const fullRead = !/^ *\d+: /.test(visible) &&
306
+ event.input.offset === undefined && event.input.startLine === undefined &&
276
307
  event.input.limit === undefined && event.input.endLine === undefined &&
277
308
  event.details?.truncation?.truncated !== true &&
278
309
  !event.content.some((part) => part.type === 'text' &&
@@ -317,13 +348,13 @@ module.exports = function charterleaf(pi) {
317
348
  const observed = changedSince(s.before, after);
318
349
  const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
319
350
  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;
351
+ const emptySpecs = s.knowledge === 'no-value' && related('', s.root).available.length === 0;
321
352
  if (s.knowledge === 'no-value' && !(emptySpecs && owned.length > 0)) return;
322
353
  const candidate = s.knowledge === 'review';
323
354
  if (!candidate && !owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
324
355
  const relevant = new Set();
325
356
  for (const file of [...owned, ...unattributed].slice(0, 50)) {
326
- const result = resolveRelated(file, s.root);
357
+ const result = related(file, s.root);
327
358
  for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
328
359
  }
329
360
  const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
@@ -122,7 +122,8 @@ function globMatches(pattern, targetPath) {
122
122
  });
123
123
 
124
124
  source += '$';
125
- return new RegExp(source).test(normalizedPath);
125
+ // Windows filesystems are case-insensitive, so a path's casing is not meaningful there.
126
+ return new RegExp(source, process.platform === 'win32' ? 'i' : '').test(normalizedPath);
126
127
  }
127
128
 
128
129
  function walkMarkdown(root) {
@@ -142,6 +143,15 @@ function walkMarkdown(root) {
142
143
  });
143
144
  }
144
145
 
146
+ // CLI discovery must not inherit another repository's constraints.
147
+ function resolveSpecRoot(cwd) {
148
+ for (let current = path.resolve(cwd); ; current = path.dirname(current)) {
149
+ const candidate = path.join(current, 'specs');
150
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return current;
151
+ if (fs.existsSync(path.join(current, '.git')) || path.dirname(current) === current) return null;
152
+ }
153
+ }
154
+
145
155
  function relativeDisplay(filePath, cwd) {
146
156
  const relative = path.relative(cwd, filePath);
147
157
  if (!relative.startsWith('..') && !path.isAbsolute(relative)) return relative.replaceAll('\\', '/');
@@ -153,7 +163,8 @@ function write(stream, line) {
153
163
  }
154
164
 
155
165
  function resolveRelated(repoPath, cwd = process.cwd()) {
156
- const specRoot = path.join(cwd, 'specs');
166
+ const root = path.resolve(cwd);
167
+ const specRoot = path.join(root, 'specs');
157
168
  if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
158
169
  throw new Error('ERROR specs/: not found');
159
170
  }
@@ -163,7 +174,7 @@ function resolveRelated(repoPath, cwd = process.cwd()) {
163
174
  const errors = [];
164
175
 
165
176
  for (const filePath of walkMarkdown(specRoot)) {
166
- const display = relativeDisplay(filePath, cwd);
177
+ const display = relativeDisplay(filePath, root);
167
178
  let parsed;
168
179
  try {
169
180
  parsed = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
@@ -317,7 +328,8 @@ function allRequirementIds(text) {
317
328
  function lint(cwd = process.cwd(), streams = {}) {
318
329
  const stdout = streams.stdout || process.stdout;
319
330
  const stderr = streams.stderr || process.stderr;
320
- const specRoot = path.join(cwd, 'specs');
331
+ const root = path.resolve(cwd);
332
+ const specRoot = path.join(root, 'specs');
321
333
  if (!fs.existsSync(specRoot) || !fs.statSync(specRoot).isDirectory()) {
322
334
  write(stderr, 'ERROR specs/: not found');
323
335
  return 2;
@@ -332,15 +344,15 @@ function lint(cwd = process.cwd(), streams = {}) {
332
344
  const paths = walkMarkdown(specRoot);
333
345
 
334
346
  for (const filePath of paths) {
335
- const display = relativeDisplay(filePath, cwd);
347
+ const display = relativeDisplay(filePath, root);
336
348
  let result;
337
349
  try {
338
350
  result = parseFrontmatter(fs.readFileSync(filePath, 'utf8'));
339
- parsed.set(filePath, result);
340
351
  } catch (error) {
341
352
  errors.push([display, `invalid frontmatter: ${error.message}`]);
342
353
  continue;
343
354
  }
355
+ parsed.set(filePath, result);
344
356
 
345
357
  const frontmatter = result.frontmatter || {};
346
358
  const living = isLivingSpec(filePath, specRoot);
@@ -353,7 +365,7 @@ function lint(cwd = process.cwd(), streams = {}) {
353
365
  } else {
354
366
  const specId = rawSpecId.trim();
355
367
  if (ids.has(specId)) {
356
- errors.push([display, `duplicate spec id ${specId} (also in ${relativeDisplay(ids.get(specId), cwd)})`]);
368
+ errors.push([display, `duplicate spec id ${specId} (also in ${relativeDisplay(ids.get(specId), root)})`]);
357
369
  } else {
358
370
  ids.set(specId, filePath);
359
371
  }
@@ -411,8 +423,8 @@ function lint(cwd = process.cwd(), streams = {}) {
411
423
  const first = reqPaths[0];
412
424
  for (const filePath of reqPaths.slice(1)) {
413
425
  errors.push([
414
- relativeDisplay(filePath, cwd),
415
- `duplicate requirement id ${reqId} (also in ${relativeDisplay(first, cwd)})`,
426
+ relativeDisplay(filePath, root),
427
+ `duplicate requirement id ${reqId} (also in ${relativeDisplay(first, root)})`,
416
428
  ]);
417
429
  }
418
430
  }
@@ -425,7 +437,7 @@ function lint(cwd = process.cwd(), streams = {}) {
425
437
  const allExistingRequirements = new Set(globalRequirements.keys());
426
438
  for (const filePath of paths) {
427
439
  if (path.dirname(filePath) !== path.join(specRoot, 'changes') || !parsed.has(filePath)) continue;
428
- const display = relativeDisplay(filePath, cwd);
440
+ const display = relativeDisplay(filePath, root);
429
441
  const { frontmatter: rawFrontmatter, body } = parsed.get(filePath);
430
442
  const frontmatter = rawFrontmatter || {};
431
443
  const sections = changeSections(stripHtmlComments(body));
@@ -534,7 +546,7 @@ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
534
546
  return 0;
535
547
  }
536
548
  if (rest.length !== 1) return usageError('related requires exactly one path', streams);
537
- return related(rest[0], path.resolve(cwd), streams);
549
+ return related(rest[0], resolveSpecRoot(cwd) || path.resolve(cwd), streams);
538
550
  }
539
551
  if (command === 'lint') {
540
552
  if (rest.length === 1 && (rest[0] === '--help' || rest[0] === '-h')) {
@@ -542,7 +554,7 @@ function main(argv = process.argv.slice(2), cwd = process.cwd(), streams = {}) {
542
554
  return 0;
543
555
  }
544
556
  if (rest.length) return usageError('lint takes no arguments', streams);
545
- return lint(path.resolve(cwd), streams);
557
+ return lint(resolveSpecRoot(cwd) || path.resolve(cwd), streams);
546
558
  }
547
559
  return usageError(`unknown command ${command}`, streams);
548
560
  }
package/lib/project.js CHANGED
@@ -12,9 +12,11 @@ const CONFIG = 'specs/.charterleaf.json';
12
12
 
13
13
  function projectRoot(cwd) {
14
14
  const start = fs.realpathSync(cwd);
15
+ let legacyRoot;
15
16
  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;
17
+ if (fs.existsSync(path.join(current, '.git'))) return current;
18
+ if (!legacyRoot && fs.existsSync(path.join(current, CONFIG))) legacyRoot = current;
19
+ if (path.dirname(current) === current) return legacyRoot || start;
18
20
  }
19
21
  }
20
22
 
@@ -52,7 +54,7 @@ function toolPath(input) {
52
54
  return value;
53
55
  }
54
56
 
55
- function projectPath(root, cwd, input) {
57
+ function absoluteToolPath(cwd, input) {
56
58
  if (typeof input !== 'string' || !input || input.includes('\0')) return null;
57
59
  let value = toolPath(input);
58
60
  if (process.platform !== 'win32' && /^[A-Za-z]:\//.test(value)) {
@@ -61,35 +63,77 @@ function projectPath(root, cwd, input) {
61
63
  // A foreign UNC host cannot safely be interpreted as a local Linux path.
62
64
  return null;
63
65
  }
64
- const lexical = path.resolve(cwd, value);
66
+ return path.resolve(cwd, value);
67
+ }
68
+
69
+ function projectPath(root, cwd, input) {
70
+ const lexical = absoluteToolPath(cwd, input);
71
+ if (!lexical) return null;
65
72
  const resolved = canonical(lexical);
66
73
  if (!inside(root, lexical) || !inside(root, resolved)) return null;
67
74
  return path.relative(root, resolved).replaceAll('\\', '/') || '.';
68
75
  }
69
76
 
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');
77
+ function modeConfigPath(root) {
78
+ let directory = root;
79
+ let file = path.join(root, CONFIG);
80
+ if (fs.existsSync(path.join(root, '.git'))) {
81
+ const env = { ...process.env };
82
+ // Git hooks and wrappers may export another repository's location; it is not this project's authority.
83
+ for (const key of ['GIT_DIR', 'GIT_COMMON_DIR', 'GIT_WORK_TREE']) delete env[key];
84
+ directory = fs.realpathSync(execFileSync('git', ['-C', root, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
85
+ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'pipe'], env,
86
+ }).trim());
87
+ file = path.join(directory, 'charterleaf.json');
88
+ }
89
+ if (!inside(directory, canonical(file))) throw new Error('Charterleaf config escapes project storage');
90
+ return file;
91
+ }
92
+
93
+ function readConfig(file) {
74
94
  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)');
95
+ if (config?.version !== 1 || !MODES.includes(config.mode)) throw new Error('Invalid Charterleaf config (expected version: 1 and a valid mode)');
76
96
  return config.mode;
77
97
  }
78
98
 
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');
99
+ function readMode(root) {
100
+ const file = modeConfigPath(root);
101
+ if (fs.existsSync(file)) return readConfig(file);
102
+ const legacy = path.join(root, CONFIG);
103
+ if (file === legacy || !fs.existsSync(legacy)) return 'off';
104
+ if (!inside(root, canonical(legacy))) throw new Error('Charterleaf config escapes project');
105
+ // Preserve a prior explicit choice without changing tracked files or replacing a concurrent choice.
106
+ persistMode(file, readConfig(legacy), false);
107
+ return readConfig(file);
108
+ }
109
+
110
+ function persistMode(file, mode, replace) {
83
111
  fs.mkdirSync(path.dirname(file), { recursive: true });
84
112
  const temporary = `${file}.${randomUUID()}.tmp`;
85
113
  try {
86
- fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx' });
87
- fs.renameSync(temporary, file);
114
+ fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, mode }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
115
+ if (replace) fs.renameSync(temporary, file);
116
+ else {
117
+ try { fs.linkSync(temporary, file); }
118
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
119
+ }
88
120
  } finally {
89
121
  if (fs.existsSync(temporary)) fs.unlinkSync(temporary);
90
122
  }
91
123
  }
92
124
 
125
+ function saveMode(root, mode) {
126
+ if (!MODES.includes(mode)) throw new Error('Invalid Charterleaf mode');
127
+ persistMode(modeConfigPath(root), mode, true);
128
+ }
129
+
130
+ function isModeConfig(root, cwd, input) {
131
+ const lexical = absoluteToolPath(cwd, input);
132
+ if (!lexical) return false;
133
+ const target = canonical(lexical);
134
+ return [path.join(root, CONFIG), modeConfigPath(root)].some((file) => target === canonical(file));
135
+ }
136
+
93
137
  function fingerprint(root, relative) {
94
138
  const file = path.join(root, relative);
95
139
  if (!inside(root, canonical(file))) return 'outside';
@@ -110,7 +154,9 @@ function gitSnapshot(root) {
110
154
  encoding: 'utf8', timeout: 3000, maxBuffer: 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'],
111
155
  });
112
156
  try {
113
- if (fs.realpathSync(git(['rev-parse', '--show-toplevel']).trim()) !== root) return null;
157
+ // Windows Git may expand an 8.3 temp-directory alias to its long path.
158
+ root = fs.realpathSync.native(root);
159
+ if (fs.realpathSync.native(git(['rev-parse', '--show-toplevel']).trim()) !== root) return null;
114
160
  // Compare index and worktree separately; this also works before the first commit.
115
161
  const names = [git(['diff', '--no-renames', '--name-only', '-z']), git(['diff', '--cached', '--no-renames', '--name-only', '-z']),
116
162
  git(['ls-files', '--others', '--exclude-standard', '-z'])].join('\0').split('\0').filter(Boolean);
@@ -139,4 +185,4 @@ function changedSince(before, after) {
139
185
  before.index?.get(name) !== after.index?.get(name)).sort();
140
186
  }
141
187
 
142
- module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
188
+ module.exports = { CONFIG, MODES, projectRoot, projectPath, toolPath, modeConfigPath, isModeConfig, readMode, saveMode, fingerprint, gitSnapshot, changedSince };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onedotmint/charterleaf",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
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": {
@@ -38,6 +38,7 @@
38
38
  ],
39
39
  "scripts": {
40
40
  "test": "node --test",
41
+ "test:package": "node scripts/package-smoke.js",
41
42
  "test:pi": "node scripts/pi-smoke.js",
42
43
  "test:organic": "node scripts/organic-smoke.js",
43
44
  "test:context": "node scripts/context-smoke.js"
@@ -10,11 +10,17 @@ The essential rules live in [runtime.md](runtime.md); read it only if not alread
10
10
 
11
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.
12
12
 
13
+ ## Mode scope
14
+
15
+ The user chooses the mode only through `/charterleaf`. In Git, it is saved in `charterleaf.json` under `git rev-parse --path-format=absolute --git-common-dir` (usually `.git/charterleaf.json`), shared across the repository's branches and linked worktrees, not independent clones. Outside Git, keep using `specs/.charterleaf.json`. No saved choice means off, never a global Auto default.
16
+
17
+ An existing local config is authoritative, including saved `off`; branch-local legacy config cannot override it. Only if local config is absent, first load imports the saved mode from a valid current-branch legacy `specs/.charterleaf.json` without overwriting local config or changing/removing the legacy file or tracked worktree. Invalid config must report an error and disable automation, not fall back. Never recover lost authorization from Git history or sessions. Enabling a Git project creates neither `specs/` nor spec documents; spec content and routing remain in the current worktree. The two CLI commands are unchanged and independent of mode storage.
18
+
13
19
  ## Ownership and durable writing
14
20
 
15
21
  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.
16
22
 
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.
23
+ Capture during ordinary development, debugging, code/test/architecture investigation or technical decisions; do not wait for settlement or require existing specs. An enabled project with no spec documents (or no `specs/` directory in Git) is a valid starting state, not a bootstrap request; normal code reads/edits and later justified spec creation remain allowed under the selected mode.
18
24
 
19
25
  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:
20
26
 
@@ -55,7 +61,9 @@ Use `specs/changes/<name>.md` only when an accepted future contract must coexist
55
61
 
56
62
  ## CLI details
57
63
 
58
- Run from the project root:
64
+ Run from the project root or a subdirectory. CLI discovery stops at the nearest
65
+ `specs/` directory or Git repository/worktree boundary; target paths stay
66
+ repo-relative:
59
67
 
60
68
  - `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
69
  - `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.