phewsh 0.15.63 → 0.15.65

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 phewsh (hello@phewsh.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -5,9 +5,24 @@ Turn intent into action. Structure your thinking, execute your next step.
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install -g phewsh
8
+ curl -fsSL https://phewsh.com/install.sh | sh
9
9
  ```
10
10
 
11
+ No sudo, nvm-aware. (Prefer a package manager? `npm install -g phewsh` —
12
+ same artifact, built from this repo.)
13
+
14
+ Then let it improve the AI tools you already have — without launching anything:
15
+
16
+ ```bash
17
+ phewsh ambient on # your .intent/ + a verified brief reach Claude Code, Codex,
18
+ # Gemini, Cursor at the start of every session (reversible)
19
+ ```
20
+
21
+ phewsh isn't just a CLI — `ambient` makes every supported tool start aligned to
22
+ what you're building. What it reads, writes, and sends is documented, factually,
23
+ in [SECURITY.md](./SECURITY.md). It's local-first and MIT; this repo is an exact
24
+ mirror of the published npm package.
25
+
11
26
  ## Quick Start
12
27
 
13
28
  ```bash
package/SECURITY.md ADDED
@@ -0,0 +1,97 @@
1
+ # Security & Data Flow — phewsh CLI
2
+
3
+ phewsh is a **local-first** tool. It runs on your machine, reads your project's
4
+ `.intent/` and your installed AI tools' config, and helps you carry context
5
+ between those tools. This document states — factually, traceable to source —
6
+ what it reads, writes, and sends. No vague claims; if something here is wrong,
7
+ it's a bug: **hello@phewsh.com**.
8
+
9
+ > Status: this is an honest self-documented inventory, **not** an independent
10
+ > audit. The CLI source is **public and MIT-licensed** at
11
+ > **github.com/cleverIdeaz/phewsh-cli** — an exact mirror of the published npm
12
+ > package, so you can read exactly what you install (`bin/ commands/ lib/ mcp/`).
13
+ > An independent review is still planned (see "Roadmap").
14
+
15
+ ## What phewsh reads
16
+ - **`.intent/`** in your project (`vision.md`, `plan.md`, `status.md`, `next.md`,
17
+ `project.json`, `next.json`). This is the source of truth it projects.
18
+ - **Your tools' context files** when present: `CLAUDE.md`, `AGENTS.md`,
19
+ `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`, `README.md`.
20
+ - **Your Claude project memory** (`~/.claude/projects/<cwd>/memory/MEMORY.md` and
21
+ files it links) and **global per-user memory** (`~/.claude/CLAUDE.md`,
22
+ `~/.codex/AGENTS.md`, `~/.gemini/GEMINI.md`) — read-only, used to enrich the
23
+ `phewsh seq` summary. Global memory is **never** written into a project file
24
+ unless you pass `--include-global` (`lib/sequencer/index.js`).
25
+ - **Git status** of the current repo (read-only: `git status`, HEAD, diff stats).
26
+
27
+ ## What phewsh writes (and where)
28
+ Every generated block is wrapped in `<!-- PHEWSH:START -->` / `<!-- PHEWSH:END -->`
29
+ markers; **content outside the markers is preserved byte-for-byte** (tested).
30
+ - **Project context files** — the canonical `.intent/` projection into
31
+ `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` / `.cursorrules` (only files that
32
+ already exist, unless you create them). One canonical projector
33
+ (`lib/selfheal.js`) is the single writer.
34
+ - **`~/.phewsh/`** — `config.json` (your settings/keys, mode **0600**, dir 0700),
35
+ `briefs/` (saved handoff briefs), `sessions/`, `receipts/`, ledger files.
36
+ - **Shell rc** — only if you run `phewsh shim on` (adds one PATH line; reversible
37
+ with `phewsh shim off`).
38
+ - **Global base files** — only if you run `phewsh ambient on`: a marker-wrapped
39
+ block in `~/.claude/CLAUDE.md` etc. for tools you already have. Reversible with
40
+ `phewsh ambient off`.
41
+ - **Claude Code hook** — `phewsh ambient on` registers `SessionStart`/`SessionEnd`
42
+ hooks (context injection + a metadata breadcrumb; **never your transcript**).
43
+
44
+ ## Network calls (exactly these)
45
+ phewsh makes **no network calls for its core local features** (sequencing,
46
+ projection, briefs, status). Calls happen only for these named actions:
47
+ - **`api.anthropic.com` / `openrouter.ai` / `api.together.xyz`** — only if you set
48
+ a BYOK key and use `/run` or the API route. Your key goes **directly** to the
49
+ provider; it is **not** proxied through phewsh servers.
50
+ - **`registry.npmjs.org`** — version check for `phewsh update` (notify-only by
51
+ default).
52
+ - **`<project>.supabase.co`** — only if you `/login`: auth + optional cloud sync
53
+ of your `.intent/` (you initiate `push`/`pull`).
54
+ - **`phewsh.com`** — only for `phewsh serve`/cloud-bridge actions you start.
55
+ - **Sustainability telemetry** (`trackSap`, `lib/supabase.js`) — sends **model
56
+ name, token counts, and a kWh estimate only**. No prompts, no responses, no
57
+ file contents. Tied to your account id only when logged in.
58
+
59
+ ## Credentials
60
+ - A bring-your-own key lives in `~/.phewsh/config.json` at mode **0600**
61
+ (`lib/config-file.js`) and is sent **directly to the provider**, never to us.
62
+ - phewsh routes through your installed tools on **their own login** (your Claude
63
+ subscription, your ChatGPT plan) — it does not see or store those credentials.
64
+
65
+ ## The local bridge
66
+ `phewsh serve` binds **`127.0.0.1` only** (loopback), with an origin allowlist,
67
+ and runs only while you run it.
68
+
69
+ ## Known issues / hardening backlog (honest)
70
+ - **Local shell-injection surface (medium, local-only):** a few call sites build
71
+ shell strings from REPL input via `execSync` (e.g. `commands/session.js` gate /
72
+ outcomes arg passthrough). Inputs are your own typed/pasted text on your own
73
+ machine, but crafted args could execute. Fix: convert to argument-safe
74
+ `execFileSync`/`spawnSync`. Tracked as a Next item.
75
+ - **No release provenance yet** — npm provenance, signed tags, and a public
76
+ source repo are planned (Roadmap) so the published artifact is verifiable.
77
+
78
+ ## Disable / uninstall
79
+ - `phewsh ambient off` — removes injected hooks and global base files; restores
80
+ your files.
81
+ - `phewsh shim off` — removes the PATH line.
82
+ - `npm uninstall -g phewsh` — removes the CLI. `rm -rf ~/.phewsh` removes local
83
+ state. Generated `PHEWSH:START/END` blocks can be deleted by hand; everything
84
+ outside them is yours and untouched.
85
+
86
+ ## Roadmap to verifiable trust
87
+ Done: this doc, exec hardening, telemetry stays counts-only and explicit, and a
88
+ **public MIT source repo** (`cleverIdeaz/phewsh-cli`) mirroring the npm package.
89
+ Before broad promotion: npm provenance, signed tags, and dependency/secret
90
+ scanning so the published artifact is cryptographically verifiable from the
91
+ public source. For enterprise: a documented threat model and an independent
92
+ security assessment. We will not call phewsh "audited" until an independent
93
+ audit has actually occurred.
94
+
95
+ ## Reporting
96
+ Security issues: **hello@phewsh.com**. Please allow reasonable time to remediate
97
+ before public disclosure.
@@ -27,6 +27,11 @@ const CLAUDE_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json');
27
27
 
28
28
  const HOOK_START = { type: 'command', command: 'phewsh hook session-start' };
29
29
  const HOOK_END = { type: 'command', command: 'phewsh hook session-end' };
30
+ // Opt-in Decision Gate enforcement (separate from ambient context sync). The
31
+ // matcher scopes the hook to the tools the policy actually judges, so it never
32
+ // fires on harmless reads.
33
+ const HOOK_PRETOOL = { type: 'command', command: 'phewsh hook pre-tool' };
34
+ const PRETOOL_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit|Bash';
30
35
 
31
36
  // ANSI helpers (256-color per cli/lib/ui.js palette rules)
32
37
  const b = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -97,6 +102,35 @@ function removeClaudeHooks() {
97
102
  return removed;
98
103
  }
99
104
 
105
+ // ── Decision Gate enforcement (PreToolUse) — opt-in, reversible ──────────────
106
+ function preToolGateApplied() {
107
+ const s = loadClaudeSettings();
108
+ return !!s && hasHook(s, 'PreToolUse', HOOK_PRETOOL.command);
109
+ }
110
+
111
+ function enablePreToolGate() {
112
+ const settings = loadClaudeSettings() || {};
113
+ settings.hooks = settings.hooks || {};
114
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse || [];
115
+ if (hasHook(settings, 'PreToolUse', HOOK_PRETOOL.command)) return false;
116
+ settings.hooks.PreToolUse.push({ matcher: PRETOOL_MATCHER, hooks: [HOOK_PRETOOL] });
117
+ fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
118
+ return true;
119
+ }
120
+
121
+ function disablePreToolGate() {
122
+ const settings = loadClaudeSettings();
123
+ if (!settings?.hooks?.PreToolUse) return false;
124
+ const before = settings.hooks.PreToolUse.length;
125
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse
126
+ .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => h.command !== HOOK_PRETOOL.command) }))
127
+ .filter(e => e.hooks.length > 0);
128
+ if ((settings.hooks.PreToolUse || []).length === 0) delete settings.hooks.PreToolUse;
129
+ const changed = before !== (settings.hooks.PreToolUse?.length ?? 0);
130
+ if (changed) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
131
+ return changed;
132
+ }
133
+
100
134
  function showConsentScreen(harnesses) {
101
135
  console.log('');
102
136
  console.log(` ${b(cream('PHEWSH ambient'))} ${sage('— continuity without launching phewsh')}`);
@@ -365,3 +399,6 @@ async function main() {
365
399
 
366
400
  module.exports = main;
367
401
  module.exports.ensureAuto = ensureAuto;
402
+ module.exports.enablePreToolGate = enablePreToolGate;
403
+ module.exports.disablePreToolGate = disablePreToolGate;
404
+ module.exports.preToolGateApplied = preToolGateApplied;
package/commands/gate.js CHANGED
@@ -373,6 +373,7 @@ async function main() {
373
373
  if (subcommand === 'update' || subcommand === 'edit') { await update(); return; }
374
374
  if (subcommand === 'pause') { pause(); return; }
375
375
  if (subcommand === 'resume') { resume(); return; }
376
+ if (subcommand === 'enforce') { enforce(args[1]); return; }
376
377
  if (subcommand === 'reset') {
377
378
  let removed = false;
378
379
  const project = loadProjectJson();
@@ -391,6 +392,34 @@ async function main() {
391
392
  console.log(`\n Unknown: ${subcommand}. Run \`phewsh gate --help\`.\n`);
392
393
  }
393
394
 
395
+ // Opt-in deterministic enforcement: register/unregister the Claude Code
396
+ // PreToolUse hook that makes the Decision Gate act BEFORE a tool runs. Reversible,
397
+ // Claude-Code-only for now (it's the one provider with a real veto hook).
398
+ function enforce(action) {
399
+ let amb;
400
+ try { amb = require('./ambient'); } catch { console.log('\n Enforcement unavailable.\n'); return; }
401
+ const on = (action || 'status').toLowerCase();
402
+ if (on === 'on' || on === 'enable') {
403
+ const changed = amb.enablePreToolGate();
404
+ console.log(changed
405
+ ? `\n ${green('●')} Gate enforcement ${green('ON')} — Claude Code asks/denies on protected-path writes and high-blast-radius commands before they run.\n ${g('Reversible:')} phewsh gate enforce off\n`
406
+ : `\n Gate enforcement already on.\n`);
407
+ return;
408
+ }
409
+ if (on === 'off' || on === 'disable') {
410
+ const changed = amb.disablePreToolGate();
411
+ console.log(changed ? `\n Gate enforcement ${yellow('OFF')} — PreToolUse hook removed.\n` : `\n Gate enforcement was not on.\n`);
412
+ return;
413
+ }
414
+ // status
415
+ const applied = amb.preToolGateApplied();
416
+ console.log(`\n Gate enforcement: ${applied ? green('ON') : g('off')} ${g('(Claude Code PreToolUse)')}`);
417
+ console.log(` ${g('Turn on:')} phewsh gate enforce on ${g('· off:')} phewsh gate enforce off`);
418
+ console.log(` ${g('What it does: deny writes to protected paths (.env, keys, .git/…),')}`);
419
+ console.log(` ${g('ask before high-blast-radius shell (rm -rf, force-push, sudo…).')}`);
420
+ console.log(` ${g('Opt-in, local-only, fail-open. Other tools: advisory only for now.')}\n`);
421
+ }
422
+
394
423
  module.exports = main;
395
424
 
396
425
  main().catch(err => {
package/commands/hook.js CHANGED
@@ -163,10 +163,63 @@ function sessionEnd() {
163
163
  setTimeout(() => { appendBreadcrumb('session-end'); process.exit(0); }, 1500);
164
164
  }
165
165
 
166
+ // Load the Decision Gate context (constraints + protected files) for the project
167
+ // being worked in. Best-effort: missing/garbled → empty context (gate allows).
168
+ function loadGateContext(cwd) {
169
+ try {
170
+ const pj = JSON.parse(fs.readFileSync(path.join(cwd, '.intent', 'project.json'), 'utf-8'));
171
+ const dg = pj.decisionGate || {};
172
+ return {
173
+ constraints: dg.constraints || {},
174
+ protectedFiles: dg.protectedFiles || pj.protectedFiles || [],
175
+ };
176
+ } catch { return { constraints: {}, protectedFiles: [] }; }
177
+ }
178
+
179
+ // PreToolUse adapter — the Decision Gate acting BEFORE a tool runs. Reads Claude
180
+ // Code's PreToolUse JSON from stdin, evaluates against the project's constraints
181
+ // + protected files, and emits a permission decision. FAIL-SAFE: any error, no
182
+ // stdin, or no policy hit → exit 0 (allow). A broken gate must never trap you.
183
+ // It logs only a redacted decision line (never the tool payload).
184
+ function preTool() {
185
+ try {
186
+ if (process.stdin.isTTY) process.exit(0); // not a real hook invocation
187
+ const { evaluateAction, auditLine } = require('../lib/gate-policy');
188
+ let raw = '';
189
+ try { raw = fs.readFileSync(0, 'utf-8'); } catch { process.exit(0); }
190
+ let payload = {};
191
+ try { payload = JSON.parse(raw || '{}'); } catch { process.exit(0); }
192
+
193
+ const cwd = payload.cwd || process.cwd();
194
+ const envelope = {
195
+ toolName: payload.tool_name || payload.toolName,
196
+ toolInput: payload.tool_input || payload.toolInput || {},
197
+ ...loadGateContext(cwd),
198
+ };
199
+ let result = { decision: 'allow', reason: '' };
200
+ try { result = evaluateAction(envelope); } catch { process.exit(0); }
201
+
202
+ if (result.decision !== 'allow') {
203
+ try { appendBreadcrumb('pre-tool', { decision: result.decision, action: auditLine(envelope, result) }); } catch { /* audit is best-effort */ }
204
+ process.stdout.write(JSON.stringify({
205
+ hookSpecificOutput: {
206
+ hookEventName: 'PreToolUse',
207
+ permissionDecision: result.decision, // 'deny' | 'ask'
208
+ permissionDecisionReason: result.reason,
209
+ },
210
+ }));
211
+ }
212
+ process.exit(0);
213
+ } catch {
214
+ process.exit(0); // fail open, always
215
+ }
216
+ }
217
+
166
218
  function main() {
167
219
  const event = process.argv[3];
168
220
  if (event === 'session-start') return sessionStart();
169
221
  if (event === 'session-end') return sessionEnd();
222
+ if (event === 'pre-tool') return preTool();
170
223
  // Unknown event: exit silently — hooks must never error the host tool.
171
224
  process.exit(0);
172
225
  }
package/commands/mbhd.js CHANGED
@@ -1,16 +1,17 @@
1
- const { execSync } = require('child_process');
1
+ const { execFileSync } = require('child_process');
2
2
 
3
3
  const WEB_URL = 'https://phewsh.com/mbhd';
4
4
 
5
5
  console.log(`\n 🎵 Opening MBHD Music Engine...\n`);
6
6
 
7
7
  try {
8
+ // execFileSync (no shell) — argument-safe even though the URL is constant.
8
9
  if (process.platform === 'darwin') {
9
- execSync(`open "${WEB_URL}"`);
10
+ execFileSync('open', [WEB_URL]);
10
11
  } else if (process.platform === 'win32') {
11
- execSync(`start "${WEB_URL}"`);
12
+ execFileSync('cmd', ['/c', 'start', '', WEB_URL]);
12
13
  } else {
13
- execSync(`xdg-open "${WEB_URL}"`);
14
+ execFileSync('xdg-open', [WEB_URL]);
14
15
  }
15
16
  } catch {
16
17
  console.log(` Could not open browser. Visit: ${WEB_URL}\n`);
package/commands/mcp.js CHANGED
@@ -48,7 +48,7 @@ function loadLocalProject() {
48
48
  decisionGate: null,
49
49
  };
50
50
 
51
- const files = ['vision.md', 'plan.md', 'next.md', 'status.md', 'narrative.md'];
51
+ const files = ['vision.md', 'plan.md', 'next.md', 'status.md'];
52
52
  for (const file of files) {
53
53
  const filePath = path.join(INTENT_DIR, file);
54
54
  if (fs.existsSync(filePath)) {
@@ -117,6 +117,20 @@ async function main() {
117
117
  // Default behavior: no target = stdout summary
118
118
  const target = flags.target || 'stdout';
119
119
 
120
+ // CANONICAL WRITE. Writing a native projection (e.g. `phewsh seq claude -w`)
121
+ // goes through the one canonical projector (self-heal), NOT the broad
122
+ // sequencer — identical block, identical source policy, no stale clobber.
123
+ if (flags.write && target === 'claude-md') {
124
+ const selfheal = require('../lib/selfheal');
125
+ const res = selfheal.syncContextFiles({ targets: ['CLAUDE.md'], createMissing: true });
126
+ if (res.synced && res.synced.length) {
127
+ console.log(`\n ${green('✓')} ${sage('CLAUDE.md — canonical .intent/ projection (same block self-heal & watch write)')}\n`);
128
+ } else {
129
+ console.log(`\n ${slate('CLAUDE.md already current' + (res.reason ? ' (' + res.reason + ')' : ''))}\n`);
130
+ }
131
+ return;
132
+ }
133
+
120
134
  try {
121
135
  const result = sequence({
122
136
  target,
@@ -1714,7 +1714,6 @@ async function main() {
1714
1714
  console.log(` ${teal('/key')} ${sage('Set API key (optional — harnesses need none)')}`);
1715
1715
  console.log(` ${teal('/login')} ${sage('Identity + cloud sync')}`);
1716
1716
  console.log(` ${teal('/model')} ${slate('<name>')} ${sage('Switch model — passed through, the provider validates')}`);
1717
- console.log(` ${teal('/width')} ${slate('[n]')} ${sage('Fix mid-word wrapping if your terminal misreports its size')}`);
1718
1717
  console.log(` ${teal('/update')} ${sage('Update phewsh')}`);
1719
1718
  console.log(` ${teal('/tour')} ${sage('Quick walkthrough')}`);
1720
1719
  console.log('');
@@ -1927,9 +1926,11 @@ async function main() {
1927
1926
 
1928
1927
  if (cmd === 'gate') {
1929
1928
  try {
1930
- const { execSync } = require('child_process');
1931
- const gateArg = cmdArg || 'status';
1932
- execSync(`node ${path.join(__dirname, 'gate.js')} ${gateArg}`, { stdio: 'inherit' });
1929
+ // execFileSync with an argv array — no shell, so REPL input can never
1930
+ // be interpreted as shell metacharacters (hardening: was execSync).
1931
+ const { execFileSync } = require('child_process');
1932
+ const gateArgs = (cmdArg || 'status').split(/\s+/).filter(Boolean);
1933
+ execFileSync(process.execPath, [path.join(__dirname, 'gate.js'), ...gateArgs], { stdio: 'inherit' });
1933
1934
  intentFiles = loadIntentContext();
1934
1935
  systemPrompt = buildSystemPrompt(intentFiles);
1935
1936
  } catch (err) {
@@ -1942,17 +1943,43 @@ async function main() {
1942
1943
  if (cmd === 'seq' || cmd === 'sequence') {
1943
1944
  try {
1944
1945
  const { sequence } = require('../lib/sequencer');
1946
+ const { resolveProjectRoot, discover } = require('../lib/sequencer/discover');
1945
1947
  const target = cmdArg?.split(/\s+/)[0];
1946
1948
  const explain = cmdArg?.includes('explain') || cmdArg?.includes('-e');
1947
- const write = cmdArg?.includes('-w') || cmdArg?.includes('write');
1948
1949
 
1949
1950
  if (target === 'claude' || target === 'claude-md') {
1950
- const result = sequence({ target: 'claude-md', write: true });
1951
- if (result.writeResult) {
1952
- console.log(`\n ${teal('●')} ${sage('CLAUDE.md ' + result.writeResult)} ${slate('(' + result.chunks.length + ' chunks)')}\n`);
1951
+ // CANONICAL WRITE. /seq claude writes the SAME deterministic block as
1952
+ // self-heal and watch — one source policy (.intent canonical), one
1953
+ // renderer so a manual sequence can never produce a divergent or
1954
+ // stale CLAUDE.md. (Previously this called the broad sequencer, which
1955
+ // ingested narrative.md + memory logs and clobbered the canonical block.)
1956
+ const res = selfheal.syncContextFiles({ targets: ['CLAUDE.md'], createMissing: true });
1957
+ if (res.synced && res.synced.length) {
1958
+ console.log(`\n ${teal('●')} ${sage('CLAUDE.md updated — canonical .intent/ projection')} ${slate('(same block self-heal & watch write)')}\n`);
1959
+ } else {
1960
+ console.log(`\n ${slate('CLAUDE.md already current' + (res.reason ? ' (' + res.reason + ')' : ''))}\n`);
1961
+ }
1962
+ } else if (explain || target === 'sources' || target === 'root') {
1963
+ // Diagnostic: show what the sequencer sees — resolved root, selected
1964
+ // sources (with scope), and the canonical projection's restricted set.
1965
+ const root = resolveProjectRoot(process.cwd());
1966
+ const found = discover(process.cwd());
1967
+ console.log('');
1968
+ console.log(` ${b(cream('Sequence diagnostics'))}`);
1969
+ ui.divider('line');
1970
+ console.log(` ${sage('resolved root')} ${cream(root)}${root !== process.cwd() ? slate(' (walked up from ' + process.cwd() + ')') : ''}`);
1971
+ console.log(` ${sage('canonical write set')} ${slate('vision.md · project.json · status.md · next.md · next.json')}`);
1972
+ console.log(` ${sage('broad read (this preview)')}`);
1973
+ for (const s of found) {
1974
+ const tag = s.scope === 'global' ? slate('global ') : sage('project');
1975
+ const canon = ['vision.md','project.json','status.md','next.md','next.json'].includes(s.name) ? teal(' ✓ canonical') : slate(' · preview-only');
1976
+ console.log(` ${tag} ${cream(s.name.padEnd(20))}${canon}`);
1953
1977
  }
1978
+ ui.divider('line');
1979
+ console.log(` ${slate('write the canonical projection with')} ${cream('/seq claude')}`);
1980
+ console.log('');
1954
1981
  } else {
1955
- // Default: stdout summary
1982
+ // Default: broad, READ-ONLY synthesis to stdout — never writes a file.
1956
1983
  sequence({ target: 'stdout', explain });
1957
1984
  }
1958
1985
  } catch (err) {
@@ -2546,10 +2573,11 @@ async function main() {
2546
2573
 
2547
2574
  if (cmd === 'outcomes') {
2548
2575
  try {
2549
- // execSync so the labeling prompt owns stdin while it runs
2550
- const { execSync } = require('child_process');
2551
- const outcomesArgs = cmdArg ? ' ' + cmdArg : '';
2552
- execSync(`node ${path.join(__dirname, '..', 'bin', 'phewsh.js')} outcomes${outcomesArgs}`, { stdio: 'inherit' });
2576
+ // execFileSync (argv array, no shell) so the labeling prompt owns
2577
+ // stdin AND REPL input can't be shell-interpreted (hardening).
2578
+ const { execFileSync } = require('child_process');
2579
+ const outcomesArgs = cmdArg ? cmdArg.split(/\s+/).filter(Boolean) : [];
2580
+ execFileSync(process.execPath, [path.join(__dirname, '..', 'bin', 'phewsh.js'), 'outcomes', ...outcomesArgs], { stdio: 'inherit' });
2553
2581
  } catch { /* user quit mid-labeling — fine */ }
2554
2582
  rl.prompt();
2555
2583
  return;
@@ -2727,6 +2755,20 @@ async function main() {
2727
2755
  console.log(` ${teal('●')} ${sage('Back in phewsh.')} ${slate('No changes detected. 1 kept · 2 undid · 3 redid · 4 flopped · /reconcile · /switch <tool>')}`);
2728
2756
  }
2729
2757
  console.log(` ${slate('continuing elsewhere?')} ${cream('/handoff')} ${slate('shows the updated brief the next tool inherits')}`);
2758
+ // Auto-reconcile OFFER (human-gated, never auto-writes). Only when the
2759
+ // work left project UNDERSTANDING stale — the docs now disagree with the
2760
+ // code or a projection drifted. Ordinary implementation diffs and a merely
2761
+ // dirty tree do NOT trigger this; that would be noise.
2762
+ try {
2763
+ const truthDrift = (postflight.conflicts || []).filter(c =>
2764
+ /current-state intent disagrees|current-state claims may be stale|should be regenerated|before authoritative intent updated/i.test(c));
2765
+ if (truthDrift.length) {
2766
+ console.log('');
2767
+ console.log(` ${ember('⚠')} ${sage('Your project understanding may now be stale:')}`);
2768
+ console.log(` ${slate(truthDrift[0])}`);
2769
+ console.log(` ${sage('Bring the record up to date so the next tool inherits the truth →')} ${cream('/reconcile')} ${slate('(proposes an exact diff; writes only after you approve)')}`);
2770
+ }
2771
+ } catch { /* the offer is a nicety, never a blocker */ }
2730
2772
  console.log('');
2731
2773
  rl.prompt();
2732
2774
  return;
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ // Deterministic pre-action policy — the enforcement core of the Decision Gate.
4
+ //
5
+ // Pure and provider-neutral: given an action envelope it returns a decision.
6
+ // No I/O, no provider coupling — the hook adapter (commands/hook.js pre-tool)
7
+ // feeds it the action and renders the provider-specific response. This is the
8
+ // "deterministic enforcement" layer from cli/docs/pre-action-architecture.md;
9
+ // it does NOT replace the Decision Gate, it is how the gate acts before a tool
10
+ // runs. Decisions: 'allow' | 'ask' (require human) | 'deny' (block).
11
+ //
12
+ // Design rules: fail OPEN (an empty/garbled envelope returns allow — a policy
13
+ // must never trap the user), and never inspect file CONTENTS (we judge the
14
+ // action's target/shape, not your code or secrets).
15
+
16
+ const path = require('path');
17
+
18
+ // Tools that write/modify the filesystem — common names across providers.
19
+ const WRITE_TOOLS = new Set([
20
+ 'Write', 'Edit', 'MultiEdit', 'NotebookEdit',
21
+ 'create_file', 'apply_patch', 'str_replace_editor', 'edit_file',
22
+ ]);
23
+
24
+ // Shell/command tools.
25
+ const SHELL_TOOLS = new Set(['Bash', 'shell', 'run_command', 'run_terminal_cmd']);
26
+
27
+ // Paths an agent should never silently write — credentials, keys, VCS internals.
28
+ const DEFAULT_PROTECTED = [
29
+ '.env', '.env.*', '*.pem', '*.key', 'id_rsa', 'id_ed25519',
30
+ '.git/', '.npmrc', '.aws/', '.ssh/', '.phewsh/config.json',
31
+ ];
32
+
33
+ // High-blast-radius shell patterns that warrant a human OK.
34
+ const DESTRUCTIVE = [
35
+ { re: /\brm\s+-[a-z]*r[a-z]*f|\brm\s+-[a-z]*f[a-z]*r/, label: 'recursive force delete' },
36
+ { re: /\bgit\s+push\b[^\n]*--force/, label: 'git force-push' },
37
+ { re: /\bgit\s+reset\s+--hard\b/, label: 'git hard reset' },
38
+ { re: /\bDROP\s+(TABLE|DATABASE)\b/i, label: 'SQL DROP' },
39
+ { re: /\bsudo\b/, label: 'sudo' },
40
+ { re: /\bchmod\s+-R\b/, label: 'recursive chmod' },
41
+ { re: /\bmkfs\b|\bdd\s+if=/, label: 'disk write' },
42
+ { re: /\bcurl\b[^\n]*\|\s*(sh|bash)\b|\bwget\b[^\n]*\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
43
+ ];
44
+
45
+ function globToRegExp(glob) {
46
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
47
+ return new RegExp('^' + esc + '$');
48
+ }
49
+
50
+ // Does `target` match any protected entry? Directory entries (trailing '/')
51
+ // match anywhere in the path; file globs match the basename or full path.
52
+ function isProtected(target, protectedList) {
53
+ if (!target || typeof target !== 'string') return false;
54
+ const norm = target.replace(/^\.\//, '');
55
+ const base = path.basename(norm);
56
+ return protectedList.some(p => {
57
+ if (p.endsWith('/')) return norm.split('/').includes(p.slice(0, -1)) || norm.startsWith(p);
58
+ const re = globToRegExp(p);
59
+ return re.test(base) || re.test(norm);
60
+ });
61
+ }
62
+
63
+ function writeTarget(toolInput = {}) {
64
+ return toolInput.file_path || toolInput.path || toolInput.notebook_path
65
+ || toolInput.filename || toolInput.target_file || null;
66
+ }
67
+
68
+ // envelope: { toolName, toolInput, constraints, protectedFiles }
69
+ function evaluateAction(envelope = {}) {
70
+ try {
71
+ const { toolName, toolInput = {}, constraints = {}, protectedFiles = [] } = envelope;
72
+ if (!toolName) return { decision: 'allow', reason: '' };
73
+ const protectedList = [...DEFAULT_PROTECTED, ...(Array.isArray(protectedFiles) ? protectedFiles : [])];
74
+
75
+ // 1. Writes to a protected path → deny (only a deliberate human act should).
76
+ if (WRITE_TOOLS.has(toolName)) {
77
+ const target = writeTarget(toolInput);
78
+ if (isProtected(target, protectedList)) {
79
+ return { decision: 'deny', reason: `phewsh gate: ${toolName} targets a protected path (${target}). Credentials/keys/VCS internals are off-limits to automated edits.` };
80
+ }
81
+ }
82
+
83
+ // 2. High-blast-radius shell → ask (require human confirmation).
84
+ if (SHELL_TOOLS.has(toolName)) {
85
+ const cmd = String(toolInput.command || toolInput.cmd || '');
86
+ const hit = DESTRUCTIVE.find(d => d.re.test(cmd));
87
+ if (hit) {
88
+ return { decision: 'ask', reason: `phewsh gate: high-blast-radius command (${hit.label}) — confirm before it runs.` };
89
+ }
90
+ }
91
+
92
+ // 3. Strict autonomy asks before any write. "delegated"/"guided" do not —
93
+ // that would turn every edit into ceremony.
94
+ const autonomy = String(constraints.autonomy || '').toLowerCase();
95
+ if ((autonomy === 'manual' || autonomy === 'review') && WRITE_TOOLS.has(toolName)) {
96
+ return { decision: 'ask', reason: `phewsh gate: your autonomy is "${autonomy}" — confirm this ${toolName} before it runs.` };
97
+ }
98
+
99
+ return { decision: 'allow', reason: '' };
100
+ } catch {
101
+ return { decision: 'allow', reason: '' }; // fail OPEN: never trap the user
102
+ }
103
+ }
104
+
105
+ // A redacted one-line audit record — decision + target shape only, never the
106
+ // tool payload (which may contain code or secrets).
107
+ function auditLine(envelope = {}, result = {}) {
108
+ const target = WRITE_TOOLS.has(envelope.toolName)
109
+ ? writeTarget(envelope.toolInput || {})
110
+ : (SHELL_TOOLS.has(envelope.toolName) ? '<command>' : '');
111
+ return `${result.decision || 'allow'} ${envelope.toolName || '?'}${target ? ' ' + target : ''}`;
112
+ }
113
+
114
+ module.exports = {
115
+ evaluateAction, isProtected, auditLine, writeTarget,
116
+ WRITE_TOOLS, SHELL_TOOLS, DEFAULT_PROTECTED, DESTRUCTIVE,
117
+ };
package/lib/selfheal.js CHANGED
@@ -205,6 +205,7 @@ function projectionStatus({ cwd = process.cwd(), targets = TARGET_FILES } = {})
205
205
  // `seq`), where the user is actually working in the project.
206
206
  function syncContextFiles({ cwd = process.cwd(), targets = TARGET_FILES, createMissing = true } = {}) {
207
207
  try {
208
+ cwd = require('./sequencer/discover').resolveProjectRoot(cwd);
208
209
  const intentDir = path.join(cwd, '.intent');
209
210
  if (!fs.existsSync(intentDir)) return { synced: [], reason: 'no-intent' };
210
211
  const core = buildContextCore(cwd);
@@ -15,10 +15,38 @@ const fs = require('fs');
15
15
  const path = require('path');
16
16
  const os = require('os');
17
17
 
18
- const INTENT_FILES = ['vision.md', 'plan.md', 'status.md', 'narrative.md', 'next.md'];
18
+ // narrative.md is intentionally NOT a source: it is a deprecated artifact that
19
+ // predates the four-word model (Project · Next · Work · Record) and is no longer
20
+ // authoritative project truth. It must never enter a native projection. See
21
+ // resolveProjectRoot + the canonical projection in selfheal.
22
+ const INTENT_FILES = ['vision.md', 'plan.md', 'status.md', 'next.md'];
19
23
  const INTENT_JSON = ['project.json', 'next.json', 'pps.json', 'gate.json'];
20
24
 
25
+ // Resolve the project root by walking UP from `start` to the nearest ancestor
26
+ // (including start) that contains a `.intent/` directory — bounded by the home
27
+ // dir and the filesystem root. This stops a nested dir (e.g. an app folder under
28
+ // a monorepo) from being treated as its own project, and makes every
29
+ // context-generating path agree on the same root. Returns `start` unchanged when
30
+ // no `.intent/` exists anywhere upward (so bare-directory behavior is untouched).
31
+ function resolveProjectRoot(start = process.cwd(), home = os.homedir()) {
32
+ let dir;
33
+ try { dir = path.resolve(start); } catch { return start; }
34
+ const fsRoot = path.parse(dir).root;
35
+ const homeReal = (() => { try { return fs.realpathSync(home); } catch { return home; } })();
36
+ while (true) {
37
+ if (fs.existsSync(path.join(dir, '.intent'))) return dir;
38
+ if (dir === fsRoot) break;
39
+ let real; try { real = fs.realpathSync(dir); } catch { real = dir; }
40
+ if (real === homeReal) break; // don't escape above the user's home
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) break;
43
+ dir = parent;
44
+ }
45
+ return path.resolve(start);
46
+ }
47
+
21
48
  function discover(cwd = process.cwd(), home = os.homedir()) {
49
+ cwd = resolveProjectRoot(cwd, home);
22
50
  const sources = [];
23
51
  const seenPaths = new Set();
24
52
 
@@ -119,4 +147,4 @@ function discover(cwd = process.cwd(), home = os.homedir()) {
119
147
  return sources;
120
148
  }
121
149
 
122
- module.exports = { discover };
150
+ module.exports = { discover, resolveProjectRoot };
@@ -3,7 +3,7 @@
3
3
  // The core pipeline: discover → parse → rank → compress → emit
4
4
  // One function. N inputs. 1 optimal output per target format.
5
5
 
6
- const { discover } = require('./discover');
6
+ const { discover, resolveProjectRoot } = require('./discover');
7
7
  const { rank } = require('./ranker');
8
8
  const { compress } = require('./compressor');
9
9
 
@@ -53,8 +53,12 @@ function sequence(options = {}) {
53
53
  cwd = process.cwd(),
54
54
  } = options;
55
55
 
56
+ // Resolve to the canonical project root once, so discovery, naming, and any
57
+ // file write all agree on the same project (a nested dir never self-resolves).
58
+ const root = resolveProjectRoot(cwd);
59
+
56
60
  // 1. Discover all source files (project + global)
57
- let sources = discover(cwd);
61
+ let sources = discover(root);
58
62
 
59
63
  // Privacy guard: global per-user memory enriches the summary (stdout), but
60
64
  // never bleeds into a project-file target (CLAUDE.md) unless asked for —
@@ -112,12 +116,12 @@ function sequence(options = {}) {
112
116
  }
113
117
 
114
118
  const output = emitter.emit(chunks, {
115
- projectName: getProjectName(chunks, cwd),
119
+ projectName: getProjectName(chunks, root),
116
120
  });
117
121
 
118
122
  // Write to file if requested
119
123
  if (write && emitter.writeToFile) {
120
- const result = emitter.writeToFile(output, cwd);
124
+ const result = emitter.writeToFile(output, root);
121
125
  return { chunks, output, sources, writeResult: result };
122
126
  }
123
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.63",
3
+ "version": "0.15.65",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"
@@ -10,7 +10,9 @@
10
10
  "commands/",
11
11
  "lib/",
12
12
  "mcp/",
13
- "README.md"
13
+ "README.md",
14
+ "SECURITY.md",
15
+ "LICENSE"
14
16
  ],
15
17
  "keywords": [
16
18
  "intent",
@@ -29,7 +31,10 @@
29
31
  "license": "MIT",
30
32
  "repository": {
31
33
  "type": "git",
32
- "url": "git+https://github.com/cleverIdeaz/phewsh.git"
34
+ "url": "git+https://github.com/cleverIdeaz/phewsh-cli.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/cleverIdeaz/phewsh-cli/issues"
33
38
  },
34
39
  "homepage": "https://phewsh.com",
35
40
  "engines": {