@onedotmint/charterleaf 0.4.1 → 0.4.2

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.2.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.
@@ -363,7 +384,8 @@ Important limits:
363
384
  path that was checked; spec authorization also preserves the original path's
364
385
  protected identity when a spec symlink points elsewhere inside the project.
365
386
  Foreign UNC paths are not interpreted as local paths; symlink escapes are
366
- rejected for project spec writes. Native Windows behavior needs platform testing.
387
+ rejected for project spec writes. Native Windows CLI, helper/Extension tests and
388
+ RPC mode persistence are covered by hosted CI; platform-specific permissions still apply.
367
389
  - `related` has no filesystem awareness of the supplied target path and
368
390
  no proof of routing coverage. Task-local hashes are not a continuous
369
391
  monitor of external changes. A single spec line larger than Pi's read byte
@@ -386,6 +408,7 @@ Verification commands:
386
408
 
387
409
  ```bash
388
410
  npm test
411
+ npm run test:package # real tarball install and tests against installed code
389
412
  npm run test:pi # isolated real RPC/UI smoke; no model calls
390
413
  CHARTERLEAF_MODEL=deepseek/deepseek-flash npm run test:organic # paid isolated A–G acceptance
391
414
  CHARTERLEAF_MODEL=deepseek/deepseek-flash CHARTERLEAF_BASELINE=5d470db npm run test:context # paid comparison against v0.4.0
@@ -396,10 +419,20 @@ node bin/charterleaf.js related extensions/charterleaf.js
396
419
  node bin/charterleaf.js lint
397
420
  ```
398
421
 
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.
422
+ GitHub Actions runs the installed-package suite on Ubuntu and Windows with Node
423
+ 20.0.0, 22.19.0 and 24.x, plus Pi 1.1.0/1.4.0 smoke checks on host-compatible Node.
424
+ CI makes no model calls and retains packaging/test logs. `PI_CLI` can select the
425
+ Pi Node entry point for smoke checks without relying on platform-specific shims.
426
+
427
+ The [passing eight-job release-candidate matrix](https://github.com/onedotmint/Charterleaf/actions/runs/40078816360)
428
+ checks the actual npm tarball after isolated installation, including byte equality,
429
+ CLI commands and Core/Extension/recovery tests. Each Ubuntu job passes all 227 tests;
430
+ each Windows job passes 220 with zero failures and seven platform-specific skips
431
+ (one Linux/WSL path check, two POSIX permission checks and four SIGKILL checks).
432
+ Windows symlink tests run, rather than being skipped. Host-compatible jobs also
433
+ pass real Pi RPC loading/native selection, cancellation, mode changes, legacy
434
+ migration and cross-branch/process persistence. RPC checks use isolated temporary
435
+ projects/settings and make no model calls.
403
436
 
404
437
  The 0.4.1 organic-capture acceptance used real `deepseek/deepseek-flash` sessions
405
438
  in isolated Git projects. Actual tool results, approval records, specs, routing,
@@ -407,10 +440,13 @@ file snapshots/diffs, original tests and an independent exact-money oracle were
407
440
  checked, not just assistant claims. Empty-spec development and investigation,
408
441
  ordinary-query non-triggers, authoritative-document references, bug-versus-contract
409
442
  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
443
+ evidence. **The earlier strict matrix was not consistently all-green:** a focused
411
444
  A→F1→F2 run passed A and F1, while F2 omitted its knowledge-completion comment and
412
445
  received one redundant fallback review. Its actual routing, code behavior and
413
- unchanged-spec checks passed. These bounded examples do not prove model reliability
446
+ unchanged-spec checks passed. A later complete pre-0.4.2 run on the same model
447
+ passed all 15 scenarios and 231 assertions, including F1/F2 without redundant reviews.
448
+ The final Windows Git-root path fix was subsequently validated by cross-platform
449
+ CI, not another paid model run. These bounded examples do not prove model reliability
414
450
  on every task or provider, or guarantee zero redundant reviews.
415
451
 
416
452
  The controlled organic-capture comparison (`scripts/context-smoke.js`) keeps cwd,
@@ -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]) {
@@ -68,12 +80,23 @@ module.exports = function charterleaf(pi) {
68
80
 
69
81
  function completePages(s, file, hash, event) {
70
82
  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\.\]$/;
83
+ 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
84
  const body = text.replace(footer, '');
73
85
  const lines = fs.readFileSync(path.join(s.root, file), 'utf8').split('\n');
74
86
  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;
87
+ let end = start + body.split('\n').length;
88
+ if (start < 0) return false;
89
+ if (end > lines.length || lines.slice(start, end).join('\n') !== body) {
90
+ const numbered = body.split('\n');
91
+ // AFT adds sequential line labels and a final display newline, not file content.
92
+ if (numbered.at(-1) === '') numbered.pop();
93
+ if (!numbered.length || !numbered.every((line, i) => {
94
+ const match = line.match(/^ *(\d+): (.*)$/);
95
+ return match && Number(match[1]) === start + i + 1 && match[2] === lines[start + i]?.replace(/\r$/, '');
96
+ })) return false;
97
+ end = start + numbered.length;
98
+ if (end === lines.length - 1 && lines.at(-1) === '') end++;
99
+ }
77
100
  let pages = s.pages.get(file);
78
101
  if (!pages || pages.hash !== hash) pages = { hash, ranges: [] };
79
102
  pages.ranges.push([start, end]);
@@ -107,13 +130,19 @@ module.exports = function charterleaf(pi) {
107
130
  const root = projectRoot(ctx.cwd);
108
131
  let mode;
109
132
  try { mode = readMode(root); } catch { mode = 'invalid config (disabled)'; }
110
- const choice = await ctx.ui.select(`Charterleaf — ${mode}\n${root}`, OPTIONS);
133
+ const config = modeConfigPath(root);
134
+ const status = mode === 'off'
135
+ ? (fs.existsSync(config) ? 'off (disabled)' : 'off (not configured)') : mode;
136
+ const effect = MODE_EFFECTS[mode] || MODE_EFFECTS.off;
137
+ const adoption = !MODES.includes(mode) || mode === 'off'
138
+ ? '\nExisting specs do not enable automation; choose a mode to enable it.' : '';
139
+ const choice = await ctx.ui.select(`Charterleaf — ${status}\n${root}\n${effect}${adoption}`, OPTIONS);
111
140
  const index = OPTIONS.indexOf(choice);
112
141
  if (index < 0) return;
113
142
  if (MODES[index] === 'off' && mode === 'off') return;
114
143
  saveMode(root, MODES[index]);
115
144
  state = undefined;
116
- ctx.ui.notify(`Charterleaf: ${MODES[index]} (saved to ${CONFIG})`, 'info');
145
+ ctx.ui.notify(`Charterleaf: ${MODES[index]} — ${MODE_EFFECTS[MODES[index]]} (saved to ${config})`, 'info');
117
146
  } catch (error) { report(ctx, `Charterleaf: ${error.message}`); }
118
147
  },
119
148
  });
@@ -180,6 +209,9 @@ module.exports = function charterleaf(pi) {
180
209
  if (!['read', 'write', 'edit'].includes(event.toolName)) return;
181
210
  const mutation = event.toolName !== 'read';
182
211
  try {
212
+ if (mutation && isModeConfig(s.root, ctx.cwd, event.input.path)) {
213
+ return { block: true, reason: 'Only the user may change Charterleaf mode via /charterleaf.' };
214
+ }
183
215
  const file = projectPath(s.root, ctx.cwd, event.input.path);
184
216
  const lexical = typeof event.input.path === 'string'
185
217
  ? path.relative(s.root, path.resolve(ctx.cwd, toolPath(event.input.path))).replaceAll('\\', '/') : null;
@@ -192,9 +224,6 @@ module.exports = function charterleaf(pi) {
192
224
  // Execute the exact normalized path checked here, including Pi's @/file:/space aliases.
193
225
  const target = path.join(s.root, file);
194
226
  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
227
  if (mutation && (isSpec(file) || (lexical && isSpec(lexical)))) {
199
228
  if (s.mode === 'reference') return { block: true, reason: 'Charterleaf Reference mode: report a proposal; do not modify specs.' };
200
229
  if (s.mode === 'confirm') {
@@ -272,7 +301,8 @@ module.exports = function charterleaf(pi) {
272
301
  }
273
302
  } else if (!call.mutation) {
274
303
  if (isSpec(call.file)) {
275
- const fullRead = event.input.offset === undefined && event.input.startLine === undefined &&
304
+ const fullRead = !/^ *\d+: /.test(visible) &&
305
+ event.input.offset === undefined && event.input.startLine === undefined &&
276
306
  event.input.limit === undefined && event.input.endLine === undefined &&
277
307
  event.details?.truncation?.truncated !== true &&
278
308
  !event.content.some((part) => part.type === 'text' &&
@@ -317,13 +347,13 @@ module.exports = function charterleaf(pi) {
317
347
  const observed = changedSince(s.before, after);
318
348
  const owned = [...s.owned].filter(([file, hash]) => fingerprint(s.root, file) === hash && !isSpec(file)).map(([file]) => file);
319
349
  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;
350
+ const emptySpecs = s.knowledge === 'no-value' && related('', s.root).available.length === 0;
321
351
  if (s.knowledge === 'no-value' && !(emptySpecs && owned.length > 0)) return;
322
352
  const candidate = s.knowledge === 'review';
323
353
  if (!candidate && !owned.length && !unattributed.length && !(s.opaque && !comparable)) return;
324
354
  const relevant = new Set();
325
355
  for (const file of [...owned, ...unattributed].slice(0, 50)) {
326
- const result = resolveRelated(file, s.root);
356
+ const result = related(file, s.root);
327
357
  for (const spec of [...result.global, ...result.scoped, ...result.changes]) relevant.add(spec);
328
358
  }
329
359
  const content = `Charterleaf final review — once only; mode=${s.mode}.\n` +
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.2",
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