phewsh 0.15.71 → 0.15.73

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.
@@ -31,6 +31,10 @@ const HOOK_END = { type: 'command', command: 'phewsh hook session-end' };
31
31
  // matcher scopes the hook to the tools the policy actually judges, so it never
32
32
  // fires on harmless reads.
33
33
  const HOOK_PRETOOL = { type: 'command', command: 'phewsh hook pre-tool' };
34
+ // The receipt half of the lifecycle: after a write-ish tool runs, record a
35
+ // redacted breadcrumb (tool + target, never args/content). Installed and
36
+ // removed together with the pre-tool gate — one toggle, whole lifecycle.
37
+ const HOOK_POSTTOOL = { type: 'command', command: 'phewsh hook post-tool' };
34
38
  const PRETOOL_MATCHER = 'Write|Edit|MultiEdit|NotebookEdit|Bash';
35
39
 
36
40
  // ANSI helpers (256-color per cli/lib/ui.js palette rules)
@@ -111,22 +115,31 @@ function preToolGateApplied() {
111
115
  function enablePreToolGate() {
112
116
  const settings = loadClaudeSettings() || {};
113
117
  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;
118
+ let changed = false;
119
+ for (const [event, hook] of [['PreToolUse', HOOK_PRETOOL], ['PostToolUse', HOOK_POSTTOOL]]) {
120
+ settings.hooks[event] = settings.hooks[event] || [];
121
+ if (hasHook(settings, event, hook.command)) continue;
122
+ settings.hooks[event].push({ matcher: PRETOOL_MATCHER, hooks: [hook] });
123
+ changed = true;
124
+ }
125
+ if (changed) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
126
+ return changed;
119
127
  }
120
128
 
121
129
  function disablePreToolGate() {
122
130
  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);
131
+ if (!settings?.hooks) return false;
132
+ let changed = false;
133
+ for (const [event, hook] of [['PreToolUse', HOOK_PRETOOL], ['PostToolUse', HOOK_POSTTOOL]]) {
134
+ const entries = settings.hooks[event];
135
+ if (!entries) continue;
136
+ const before = entries.length;
137
+ settings.hooks[event] = entries
138
+ .map(e => ({ ...e, hooks: (e.hooks || []).filter(h => h.command !== hook.command) }))
139
+ .filter(e => e.hooks.length > 0);
140
+ if ((settings.hooks[event] || []).length === 0) delete settings.hooks[event];
141
+ if (before !== (settings.hooks[event]?.length ?? 0)) changed = true;
142
+ }
130
143
  if (changed) fs.writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2));
131
144
  return changed;
132
145
  }
@@ -249,6 +262,10 @@ async function turnOn(skipConfirm) {
249
262
 
250
263
  function turnOff() {
251
264
  const removed = removeClaudeHooks();
265
+ // Full reversibility: ambient off takes the lifecycle hooks with it too.
266
+ let gateRemoved = false;
267
+ try { gateRemoved = disablePreToolGate(); } catch { /* best-effort */ }
268
+ if (gateRemoved) removed.push('lifecycle hooks (PreToolUse gate + PostToolUse receipt)');
252
269
  const { removed: removedGlobal } = selfheal.removeGlobalBaseFiles();
253
270
  const { removed: removedProject } = selfheal.removeProjectContextFiles();
254
271
  const { removed: removedSlash } = slash.removeSlashCommands();
@@ -256,6 +273,7 @@ function turnOff() {
256
273
  delete ledger.applied['claude-code'];
257
274
  delete ledger.applied.globalBase;
258
275
  delete ledger.applied.slashCommands;
276
+ delete ledger.applied.lifecycle;
259
277
  delete ledger.autoEnabledAt;
260
278
  ledger.disabled = true; // sticky opt-out — first-run auto-enable must respect this
261
279
  saveLedger(ledger);
@@ -352,6 +370,11 @@ async function ensureAuto() {
352
370
 
353
371
  const hasClaude = installed.some(h => h.id === 'claude-code');
354
372
  const changes = hasClaude ? applyClaudeHooks() : [];
373
+ // Neal's ruling (Jul 5): the lifecycle hooks (PreToolUse gate + PostToolUse
374
+ // receipt) install with first-run auto-enable too — they're fail-open,
375
+ // redacted, and removed by the same `phewsh ambient off` / `gate enforce off`.
376
+ let gateApplied = false;
377
+ if (hasClaude) { try { gateApplied = enablePreToolGate(); } catch { /* best-effort */ } }
355
378
  const { written } = selfheal.syncGlobalBaseFiles();
356
379
  const slashWritten = slash.installSlashCommands().written;
357
380
  // Refresh existing project files only — first-run auto-enable must not dump
@@ -365,6 +388,13 @@ async function ensureAuto() {
365
388
  captures: '~/.phewsh/ambient-sessions.jsonl — timestamp, project, cwd only',
366
389
  undo: 'phewsh ambient off',
367
390
  };
391
+ if (gateApplied) {
392
+ ledger.applied.lifecycle = {
393
+ at: now, file: CLAUDE_SETTINGS,
394
+ what: 'PreToolUse gate (deny protected paths, ask on high blast radius) + PostToolUse redacted receipt',
395
+ undo: 'phewsh gate enforce off (or phewsh ambient off)',
396
+ };
397
+ }
368
398
  }
369
399
  if (written.length > 0) {
370
400
  ledger.applied.globalBase = { at: now, files: written, undo: 'phewsh ambient off' };
@@ -381,6 +411,7 @@ async function ensureAuto() {
381
411
  console.log('');
382
412
  console.log(` ${b(cream('phewsh set itself up across your AI tools'))} ${sage('— so they stay in sync with your project intent.')}`);
383
413
  if (hasClaude) console.log(` ${teal('+')} ${slate('Claude Code gets a brief from a project\'s .intent/ at session start')}`);
414
+ if (gateApplied) console.log(` ${teal('+')} ${slate('guardrails: protected-path writes gated + a redacted receipt of what ran')}`);
384
415
  written.forEach(f => console.log(` ${teal('+')} ${slate('environment note added to ' + f)}`));
385
416
  if (slashWritten.length) console.log(` ${teal('+')} ${cream('/intent')} ${slate('command added to ' + slashWritten.join(', '))}`);
386
417
  console.log(` ${sage('In any project with')} ${cream('.intent/')}${sage(', your tools now read its real intent. Reversible:')} ${cream('phewsh ambient off')}`);
@@ -7,7 +7,7 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const os = require('os');
9
9
  const readline = require('readline');
10
- const { readPPS, writePPS, createPPS, generateViews } = require('../lib/pps');
10
+ const { readPPS, createPPS, writeGuardedViews } = require('../lib/pps');
11
11
  const configFile = require('../lib/config-file');
12
12
 
13
13
  const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
@@ -45,35 +45,28 @@ async function askForInput() {
45
45
  });
46
46
  }
47
47
 
48
- // The guided walk — the five strongest nodes of the web's 12-node Intent
49
- // Compass, asked one at a time. The web compass helps the user *see* what
50
- // they're building; this brings that to the terminal. Not a form: every
51
- // question is skippable, and the point is to help you think, not interrogate.
52
- const GUIDE_NODES = [
53
- { id: 'purpose', title: 'Purpose', directive: 'the core reason this exists',
54
- q: 'What outcome are you really after — and why does this need to exist?' },
55
- { id: 'audience', title: 'Audience', directive: 'the people this serves',
56
- q: 'Who is this for? Who feels it most when it works?' },
57
- { id: 'method', title: 'Method', directive: 'the mechanism and approach',
58
- q: 'How does it actually work — the core mechanism or approach?' },
59
- { id: 'scope', title: 'Scope', directive: 'boundaries, in and out',
60
- q: "What's in — and just as important, what's deliberately out, for now?" },
61
- { id: 'differentiation', title: 'Edge', directive: 'what makes this yours',
62
- q: 'What would be lost if someone else built this instead of you?' },
63
- ];
48
+ // The guided walk — nodes of the 12-node Intent Compass, asked one at a
49
+ // time. Default: the five strongest (CORE_NODES). --deep: all twelve. The
50
+ // web compass helps the user *see* what they're building; this brings that
51
+ // to the terminal. Not a form: every question is skippable, and the point
52
+ // is to help you think, not interrogate.
53
+ const { INTENT_NODES, CORE_NODES } = require('../lib/intent-nodes');
54
+ const GUIDE_NODES = CORE_NODES;
64
55
 
65
56
  function ask(rl, question) {
66
57
  return new Promise((resolve) => rl.question(question, (a) => resolve((a || '').trim())));
67
58
  }
68
59
 
69
60
  // rl is injectable so the walk can be driven deterministically in tests.
70
- async function askGuided(rl = readline.createInterface({ input: process.stdin, output: process.stdout })) {
71
- console.log('\n Five quick questions to align your own thinking first —');
61
+ // nodes defaults to the five-node core walk; --deep passes all twelve.
62
+ async function askGuided(rl = readline.createInterface({ input: process.stdin, output: process.stdout }), nodes = GUIDE_NODES) {
63
+ const count = nodes.length === 5 ? 'Five quick questions' : `${nodes.length} questions — the full compass`;
64
+ console.log(`\n ${count} to align your own thinking first —`);
72
65
  console.log(' a sentence or two each. Blank skips. (esc stops, nothing saved.)\n');
73
66
  const answers = [];
74
- for (let i = 0; i < GUIDE_NODES.length; i++) {
75
- const n = GUIDE_NODES[i];
76
- console.log(` ${i + 1}/${GUIDE_NODES.length} ${n.title} — ${n.directive}`);
67
+ for (let i = 0; i < nodes.length; i++) {
68
+ const n = nodes[i];
69
+ console.log(` ${i + 1}/${nodes.length} ${n.title} — ${n.directive}`);
77
70
  const a = await ask(rl, ` ${n.q}\n > `);
78
71
  if (a) answers.push({ ...n, answer: a });
79
72
  console.log('');
@@ -169,13 +162,6 @@ async function callClarifyViaHarness(harnessId, raw, existing) {
169
162
  return extractJson(out || '');
170
163
  }
171
164
 
172
- function writeViews(intentDir, pps) {
173
- const { vision, plan, next } = generateViews(pps);
174
- fs.writeFileSync(path.join(intentDir, 'vision.md'), vision);
175
- fs.writeFileSync(path.join(intentDir, 'plan.md'), plan);
176
- fs.writeFileSync(path.join(intentDir, 'next.md'), next);
177
- }
178
-
179
165
  async function main() {
180
166
  // ESC backs out cleanly at any point — nothing half-written, no error.
181
167
  if (process.stdin.isTTY) {
@@ -194,17 +180,19 @@ async function main() {
194
180
 
195
181
  Usage:
196
182
  phewsh clarify Guided: a 5-question walk that aligns your thinking, then compiles
183
+ phewsh clarify --deep The full 12-node compass, one question at a time
197
184
  phewsh clarify --freeform Free-form: describe it all in one messy blob
198
185
  phewsh clarify --text "..." Inline: pass raw text directly
199
- phewsh clarify --update Refine existing PPS with new input
186
+ phewsh clarify --update Refine existing intent with new input
200
187
 
201
188
  What it does:
202
- Walks you through the five strongest nodes of the Intent Compass —
203
- Purpose, Audience, Method, Scope, Edge one question at a time, so the
204
- terminal helps you *think*, not just compile. Then turns your answers
205
- into a structured project spec (PPS):
206
- Writes .intent/pps.json as the source of truth.
207
- Generates vision.md, plan.md, next.md as human-readable views.
189
+ Walks you through the strongest nodes of the 12-node Intent Compass —
190
+ Purpose, Audience, Method, Scope, Edge (--deep adds Context, Resources,
191
+ Strategy, Signals, Risks, Values, Impact) one question at a time, so
192
+ the terminal helps you *think*, not just compile. Then it writes
193
+ vision.md, plan.md, next.md YOUR files, the project truth every AI
194
+ tool reads. Files you've edited by hand are never overwritten.
195
+ (.intent/pps.json holds the compiled spec + generation receipts.)
208
196
 
209
197
  Requires:
210
198
  An installed agent CLI (Claude Code, Codex, Gemini…) — phewsh uses its
@@ -249,7 +237,9 @@ async function main() {
249
237
  raw = await askForInput();
250
238
  } else {
251
239
  // Guided is the default interactive path: help the user think first.
252
- const answers = await askGuided();
240
+ // --deep walks the full 12-node compass instead of the strongest five.
241
+ const deep = args.includes('--deep') || args.includes('-d');
242
+ const answers = await askGuided(undefined, deep ? INTENT_NODES : GUIDE_NODES);
253
243
  raw = assembleRaw(answers);
254
244
  if (!raw) {
255
245
  // Skipped every question — fall back to a single free-form description.
@@ -310,14 +300,19 @@ async function main() {
310
300
  pps.state.phase = 'plan';
311
301
  }
312
302
 
313
- fs.mkdirSync(INTENT_DIR, { recursive: true });
314
- writePPS(INTENT_DIR, pps);
315
- writeViews(INTENT_DIR, pps);
316
-
317
- console.log(` ✓ .intent/pps.json — structured project spec`);
318
- console.log(` ✓ .intent/vision.md — ${pps.intent.goal}`);
319
- console.log(` ✓ .intent/plan.md ${pps.intent.success_criteria.length} outcomes, ${pps.intent.constraints.length} constraints`);
320
- console.log(` ✓ .intent/next.md — ${pps.tasks.length} actions\n`);
303
+ // The truth guard: the .md files are user-owned truth. Hand-edited (or
304
+ // pre-existing hand-authored) files are preserved, never regenerated.
305
+ const { written, preserved } = writeGuardedViews(INTENT_DIR, pps);
306
+
307
+ console.log(` ✓ .intent/pps.json — compiled spec (the .md files are the truth)`);
308
+ const detail = {
309
+ 'vision.md': pps.intent.goal,
310
+ 'plan.md': `${pps.intent.success_criteria.length} outcomes, ${pps.intent.constraints.length} constraints`,
311
+ 'next.md': `${pps.tasks.length} actions`,
312
+ };
313
+ for (const f of written) console.log(` ✓ .intent/${f.padEnd(14)} — ${detail[f]}`);
314
+ for (const f of preserved) console.log(` ● .intent/${f.padEnd(14)} — kept as-is (yours — edited by hand, phewsh won't overwrite it)`);
315
+ console.log('');
321
316
  console.log(` Goal: ${pps.intent.goal}\n`);
322
317
  if (pps.tasks.length > 0) {
323
318
  console.log(' First actions:');
@@ -338,4 +333,4 @@ if (require.main === module) {
338
333
  });
339
334
  }
340
335
 
341
- module.exports = { run: main, GUIDE_NODES, assembleRaw, askGuided, extractJson };
336
+ module.exports = { run: main, GUIDE_NODES, INTENT_NODES, assembleRaw, askGuided, extractJson };
package/commands/gate.js CHANGED
@@ -402,21 +402,22 @@ function enforce(action) {
402
402
  if (on === 'on' || on === 'enable') {
403
403
  const changed = amb.enablePreToolGate();
404
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`
405
+ ? `\n ${green('●')} Gate enforcement ${green('ON')} — before a tool runs, Claude Code asks/denies on protected-path writes and high-blast-radius commands; after, a redacted receipt records what ran (tool + target, never content).\n ${g('Reversible:')} phewsh gate enforce off\n`
406
406
  : `\n Gate enforcement already on.\n`);
407
407
  return;
408
408
  }
409
409
  if (on === 'off' || on === 'disable') {
410
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`);
411
+ console.log(changed ? `\n Gate enforcement ${yellow('OFF')} — PreToolUse + PostToolUse hooks removed.\n` : `\n Gate enforcement was not on.\n`);
412
412
  return;
413
413
  }
414
414
  // status
415
415
  const applied = amb.preToolGateApplied();
416
- console.log(`\n Gate enforcement: ${applied ? green('ON') : g('off')} ${g('(Claude Code PreToolUse)')}`);
416
+ console.log(`\n Gate enforcement: ${applied ? green('ON') : g('off')} ${g('(Claude Code PreToolUse + PostToolUse)')}`);
417
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/…),')}`);
418
+ console.log(` ${g('Before: deny writes to protected paths (.env, keys, .git/…),')}`);
419
419
  console.log(` ${g('ask before high-blast-radius shell (rm -rf, force-push, sudo…).')}`);
420
+ console.log(` ${g('After: redacted receipt of what ran — tool + target, never args or content.')}`);
420
421
  console.log(` ${g('Opt-in, local-only, fail-open. Other tools: advisory only for now.')}\n`);
421
422
  }
422
423
 
package/commands/hook.js CHANGED
@@ -74,10 +74,39 @@ function firstLines(text, n) {
74
74
  .join('\n');
75
75
  }
76
76
 
77
+ // The accountability invitation. A real project (it has .git) with no
78
+ // .intent/ gets ONE gentle note injected — telling the agent to offer
79
+ // grounding once, and to drop it if declined. Once per project, ever
80
+ // (tracked in ~/.phewsh/intent-nudges.json); every later session is
81
+ // silent again. Invite, never nag — the recorded stance (decisions.md).
82
+ function maybeIntentInvitation() {
83
+ try {
84
+ if (!fs.existsSync(path.join(process.cwd(), '.git'))) return; // not a real project — cost nothing
85
+ const nudgeFile = path.join(PHEWSH_DIR, 'intent-nudges.json');
86
+ let seen = {};
87
+ try { seen = JSON.parse(fs.readFileSync(nudgeFile, 'utf-8')); } catch { /* first nudge ever */ }
88
+ if (seen && seen[process.cwd()]) return; // already invited here — never again
89
+ seen = (seen && typeof seen === 'object') ? seen : {};
90
+ seen[process.cwd()] = new Date().toISOString();
91
+ fs.mkdirSync(PHEWSH_DIR, { recursive: true });
92
+ fs.writeFileSync(nudgeFile, JSON.stringify(seen, null, 2));
93
+ process.stdout.write([
94
+ '# phewsh — one-time note for this project',
95
+ 'This repo has no `.intent/` yet — no durable project truth that travels between AI tools.',
96
+ 'If the user starts substantial (non-trivial) work here, offer ONCE, in plain language, to ground the project:',
97
+ '`phewsh clarify` (guided walk) or `phewsh init` (two questions) creates `.intent/` — vision, plan, and next steps that every AI tool reads, so the next session picks up where this one left off.',
98
+ 'If they decline, or the work is trivial/exploratory, drop it and never raise it again. Do not create `.intent/` yourself uninvited.',
99
+ ].join('\n') + '\n');
100
+ appendBreadcrumb('intent-invite');
101
+ } catch { /* the invitation is a nicety — never break the host */ }
102
+ }
103
+
77
104
  function sessionStart() {
78
105
  if (!fs.existsSync(path.join(INTENT_DIR, 'vision.md')) &&
79
106
  !fs.existsSync(path.join(INTENT_DIR, 'plan.md'))) {
80
- // No .intent/ here — stay silent, cost the host nothing.
107
+ // No .intent/ here — one gentle invitation if this is a real project,
108
+ // then silence forever.
109
+ maybeIntentInvitation();
81
110
  process.exit(0);
82
111
  }
83
112
 
@@ -215,11 +244,41 @@ function preTool() {
215
244
  }
216
245
  }
217
246
 
247
+ // PostToolUse adapter — the other half of the lifecycle. After a write-ish
248
+ // tool runs, leave a redacted receipt (tool name + relative target, or just
249
+ // the binary name for shell — NEVER args, content, or output) so `phewsh`
250
+ // can show what agents actually did here. Always silent to the host, always
251
+ // fail-open: a broken receipt must never slow or break the tool that ran.
252
+ function postTool() {
253
+ try {
254
+ if (process.stdin.isTTY) process.exit(0); // not a real hook invocation
255
+ let raw = '';
256
+ try { raw = fs.readFileSync(0, 'utf-8'); } catch { process.exit(0); }
257
+ let payload = {};
258
+ try { payload = JSON.parse(raw || '{}'); } catch { process.exit(0); }
259
+ const tool = payload.tool_name || payload.toolName;
260
+ if (!tool) process.exit(0);
261
+ const input = payload.tool_input || payload.toolInput || {};
262
+ const cwd = payload.cwd || process.cwd();
263
+ let target = null;
264
+ if (typeof input.file_path === 'string') {
265
+ target = path.relative(cwd, input.file_path) || input.file_path;
266
+ } else if (typeof input.command === 'string') {
267
+ target = String(input.command).trim().split(/\s+/)[0] || null; // binary only, never args
268
+ }
269
+ appendBreadcrumb('post-tool', { tool, ...(target ? { target } : {}) });
270
+ process.exit(0);
271
+ } catch {
272
+ process.exit(0); // fail open, always
273
+ }
274
+ }
275
+
218
276
  function main() {
219
277
  const event = process.argv[3];
220
278
  if (event === 'session-start') return sessionStart();
221
279
  if (event === 'session-end') return sessionEnd();
222
280
  if (event === 'pre-tool') return preTool();
281
+ if (event === 'post-tool') return postTool();
223
282
  // Unknown event: exit silently — hooks must never error the host tool.
224
283
  process.exit(0);
225
284
  }
@@ -2,7 +2,7 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const readline = require('readline');
4
4
  const { execSync } = require('child_process');
5
- const { createPPS, writePPS, generateViews } = require('../lib/pps');
5
+ const { createPPS, writeGuardedViews } = require('../lib/pps');
6
6
 
7
7
  const os = require('os');
8
8
  const configFile = require('../lib/config-file');
@@ -136,16 +136,14 @@ async function initIntent() {
136
136
  },
137
137
  });
138
138
 
139
- writePPS(INTENT_DIR, pps);
140
- const { vision, plan, next } = generateViews(pps);
141
- fs.writeFileSync(path.join(INTENT_DIR, 'vision.md'), vision);
142
- fs.writeFileSync(path.join(INTENT_DIR, 'plan.md'), plan);
143
- fs.writeFileSync(path.join(INTENT_DIR, 'next.md'), next);
139
+ // Truth guard: never overwrite a hand-authored file (a partial .intent/
140
+ // slips past hasExistingArtifacts e.g. vision.md alone).
141
+ const { written, preserved } = writeGuardedViews(INTENT_DIR, pps);
144
142
 
145
- console.log(` ✓ .intent/pps.json — Structured project spec (source of truth)`);
146
- console.log(` ✓ .intent/vision.md The north star`);
147
- console.log(` ✓ .intent/plan.md The strategy`);
148
- console.log(` .intent/next.md What to do right now`);
143
+ console.log(` ✓ .intent/pps.json — Compiled spec (the .md files are the truth)`);
144
+ const label = { 'vision.md': 'The north star', 'plan.md': 'The strategy', 'next.md': 'What to do right now' };
145
+ for (const f of written) console.log(` ✓ .intent/${f.padEnd(12)} ${label[f]}`);
146
+ for (const f of preserved) console.log(` .intent/${f.padEnd(12)} kept as-is (yours, hand-authored)`);
149
147
  console.log(`
150
148
  Tip: Run \`phewsh clarify\` to have AI compile your messy intent into a precise spec.
151
149
 
package/commands/pack.js CHANGED
@@ -36,7 +36,39 @@ function list() {
36
36
  console.log(` ${slate('source: ' + p.source)}`);
37
37
  }
38
38
  console.log('');
39
- console.log(` ${sage('Install:')} ${cream('phewsh pack install <name>')} ${sage('Remove:')} ${cream('phewsh pack remove <name>')}`);
39
+ console.log(` ${sage('Install:')} ${cream('phewsh pack install <name>')} ${sage('All official packs at once:')} ${cream('phewsh pack install all')}`);
40
+ console.log(` ${sage('Remove:')} ${cream('phewsh pack remove <name>')} ${sage('Read about every pack:')} ${cream('phewsh.com/cli#packs')}`);
41
+ console.log('');
42
+ }
43
+
44
+ // `phewsh pack install all` — every official (vendored) pack in one confirmed
45
+ // pass. Linked packs stay pointers to their upstream source; we list them so
46
+ // nothing external ever installs silently.
47
+ async function installAll() {
48
+ const vendored = Object.entries(packs.PACKS).filter(([, p]) => p.kind !== 'linked');
49
+ const linked = Object.entries(packs.PACKS).filter(([, p]) => p.kind === 'linked');
50
+ const pending = vendored.filter(([name]) => !packs.isInstalled(name));
51
+
52
+ console.log('');
53
+ if (pending.length === 0) {
54
+ console.log(` ${sage('All official packs are already installed here.')}`);
55
+ } else {
56
+ console.log(` ${b(cream('Official phewsh packs'))} ${sage('— ' + pending.length + ' to install:')}`);
57
+ pending.forEach(([name, p]) => console.log(` ${cream(name.padEnd(16))} ${slate(p.desc)}`));
58
+ console.log('');
59
+ const ok = await confirm(` ${b('Install ' + (pending.length === 1 ? 'it' : 'all ' + pending.length) + ' here?')} ${slate('[y/N] ')}`);
60
+ if (!ok) { console.log(` ${sage('Nothing changed.')}\n`); return; }
61
+ for (const [name] of pending) {
62
+ const { written } = packs.install(name);
63
+ console.log(` ${green('●')} ${cream(name)} ${slate('→ ' + written.join(', '))}`);
64
+ }
65
+ console.log(` ${sage('Remove any:')} ${cream('phewsh pack remove <name>')}`);
66
+ }
67
+ if (linked.length > 0) {
68
+ console.log('');
69
+ console.log(` ${sage(linked.length + ' more are linked packs — separate tools phewsh points at but never auto-installs:')}`);
70
+ console.log(` ${cream('phewsh pack')} ${sage('lists them ·')} ${cream('phewsh.com/cli#packs')} ${sage('tells their stories')}`);
71
+ }
40
72
  console.log('');
41
73
  }
42
74
 
@@ -89,6 +121,7 @@ function remove(name) {
89
121
  async function main() {
90
122
  const sub = process.argv[3];
91
123
  const name = process.argv[4];
124
+ if (sub === 'install' && name === 'all') return installAll();
92
125
  if (sub === 'install' && name) return install(name);
93
126
  if (sub === 'remove' && name) return remove(name);
94
127
  return list();
@@ -50,7 +50,7 @@ const {
50
50
  relativeFolder,
51
51
  shouldCollapsePaste,
52
52
  } = require('../lib/session-display');
53
- const { recordProject, listProjects, scanForProjects, fmtAgo } = require('../lib/projects-index');
53
+ const { recordProject, listProjects, scanForProjects, scanForCandidates, fmtAgo } = require('../lib/projects-index');
54
54
 
55
55
  // Brand palette shortcuts
56
56
  const { b, d, w, g, green, cyan, yellow,
@@ -500,7 +500,7 @@ async function main() {
500
500
  } else if (atHome || recents.length > 0) {
501
501
  row('PROJECT', slate('none here — your projects are listed below'));
502
502
  } else {
503
- row('PROJECT', cream(projectName) + slate(' · no memory yet — ') + sage('/init'));
503
+ row('PROJECT', cream(projectName) + slate(' · no memory yet — ') + sage('/init') + slate(' fast · ') + sage('/clarify') + slate(' guided'));
504
504
  }
505
505
 
506
506
  row('ROUTE', route
@@ -718,7 +718,13 @@ async function main() {
718
718
  try { recordProject(dir); } catch { /* best-effort */ }
719
719
  bootstrapChoices = null;
720
720
  console.log('');
721
- console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
721
+ if (intentFiles.length === 0) {
722
+ // A candidate, not yet a project — invite intent, don't fake context.
723
+ console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage('no .intent/ yet')} ${slate('· via ' + routeLabel(route, config))}`);
724
+ console.log(` ${sage('Ground it:')} ${cream('/init')} ${sage('two questions, instant artifacts')} ${slate('·')} ${cream('/clarify')} ${sage('guided — compiles your messy idea into a spec')}`);
725
+ } else {
726
+ console.log(` ${teal('●')} ${cream(projectName)} ${slate('·')} ${sage(`.intent/ ${intentFiles.length} file${intentFiles.length !== 1 ? 's' : ''} loaded`)} ${slate('· via ' + routeLabel(route, config))}`);
727
+ }
722
728
  console.log('');
723
729
  maybeHealOnEntry();
724
730
  showContinuity();
@@ -727,6 +733,39 @@ async function main() {
727
733
  console.log('');
728
734
  }
729
735
 
736
+ // The project scanner — bootstrap option AND /scan slash command. Lists
737
+ // .intent/ projects plus likely candidates (git, no .intent yet, reason
738
+ // shown) from the usual folders, numbered so a bare digit opens one.
739
+ function runScanMenu() {
740
+ const spin = ui.spinner('scanning your usual folders');
741
+ const found = scanForProjects();
742
+ let candidates = [];
743
+ try { candidates = scanForCandidates(); } catch { /* advisory — scan still useful without it */ }
744
+ spin.stop();
745
+ if (found.length === 0 && candidates.length === 0) {
746
+ bootstrapChoices = null;
747
+ console.log(` ${sage('No .intent/ projects found in the usual folders.')}`);
748
+ console.log(` ${slate('cd into a project and run phewsh, or /init to start one here.')}`);
749
+ return;
750
+ }
751
+ bootstrapChoices = [];
752
+ if (found.length > 0) {
753
+ console.log(` ${teal('●')} ${sage(`Found ${found.length} project${found.length !== 1 ? 's' : ''} with .intent/:`)}`);
754
+ for (const p of found) {
755
+ bootstrapChoices.push({ kind: 'open', path: p.path });
756
+ console.log(` ${teal(String(bootstrapChoices.length))} ${cream(p.name)} ${slate('· ' + tildify(p.path))}`);
757
+ }
758
+ }
759
+ if (candidates.length > 0) {
760
+ console.log(` ${teal('●')} ${sage(`${candidates.length} likely candidate${candidates.length !== 1 ? 's' : ''} — no shared memory yet:`)}`);
761
+ for (const p of candidates) {
762
+ bootstrapChoices.push({ kind: 'open', path: p.path });
763
+ console.log(` ${teal(String(bootstrapChoices.length))} ${cream(p.name)} ${slate('· ' + tildify(p.path) + ' · ' + p.reason)}`);
764
+ }
765
+ }
766
+ console.log(` ${slate('pick a number to open it')}`);
767
+ }
768
+
730
769
  function showBootstrapMenu(projects) {
731
770
  console.log(` ${b(cream('Where do you want to work?'))}`);
732
771
  bootstrapChoices = [];
@@ -970,7 +1009,7 @@ async function main() {
970
1009
  // RECOGNIZED leading /command (or @harness) token turns teal (peach for @)
971
1010
  // so you know it registered. Arguments stay plain. TTY-only, fail-soft.
972
1011
  const KNOWN_COMMANDS = new Set([
973
- 'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'model',
1012
+ 'quit', 'exit', 'q', 'help', 'h', 'init', 'intent', 'clarify', 'scan', 'model',
974
1013
  'models', 'council', 'all', 'provider', 'route', 'use', 'work', 'switch', 'run',
975
1014
  'clear', 'status', 'key', 'login', 'export', 'push', 'pull', 'serve',
976
1015
  'sync', 'harnesses', 'fallback', 'outcomes', 'tour', 'update', 'upgrade',
@@ -1271,8 +1310,11 @@ async function main() {
1271
1310
  return;
1272
1311
  }
1273
1312
 
1274
- // Root bootstrap: a bare number opens a project, inits, or scans
1275
- if (bootstrapChoices && messages.length === 0 && /^[0-9]{1,2}$/.test(input)) {
1313
+ // Scan/bootstrap menu: a bare number opens a project, inits, or scans.
1314
+ // Any other input means the user moved on — drop the menu so it never
1315
+ // shadows a later digit (mirrors the nextChoices rule above).
1316
+ if (bootstrapChoices && !/^[0-9]{1,2}$/.test(input)) bootstrapChoices = null;
1317
+ if (bootstrapChoices && /^[0-9]{1,2}$/.test(input)) {
1276
1318
  const choice = bootstrapChoices[parseInt(input, 10) - 1];
1277
1319
  if (!choice) {
1278
1320
  console.log(` ${sage('Pick 1-' + bootstrapChoices.length)}`);
@@ -1303,21 +1345,7 @@ async function main() {
1303
1345
  return;
1304
1346
  }
1305
1347
  if (choice.kind === 'scan') {
1306
- const spin = ui.spinner('scanning your usual folders');
1307
- const found = scanForProjects();
1308
- spin.stop();
1309
- if (found.length === 0) {
1310
- bootstrapChoices = null;
1311
- console.log(` ${sage('No .intent/ projects found in the usual folders.')}`);
1312
- console.log(` ${slate('cd into a project and run phewsh, or /init to start one here.')}`);
1313
- } else {
1314
- console.log(` ${teal('●')} ${sage(`Found ${found.length} project${found.length !== 1 ? 's' : ''}:`)}`);
1315
- bootstrapChoices = found.map(p => ({ kind: 'open', path: p.path }));
1316
- found.forEach((p, i) => {
1317
- console.log(` ${teal(String(i + 1))} ${cream(p.name)} ${slate('· ' + tildify(p.path))}`);
1318
- });
1319
- console.log(` ${slate('pick a number to open it')}`);
1320
- }
1348
+ runScanMenu();
1321
1349
  console.log('');
1322
1350
  rl.prompt();
1323
1351
  return;
@@ -1651,6 +1679,7 @@ async function main() {
1651
1679
  console.log(` ${teal('@name')} ${slate('<msg>')} ${sage('one message to one tool — @codex review this')}`);
1652
1680
  console.log(` ${teal('/work')} ${slate('[tool]')} ${sage('hand off to the full interactive tool, outcome on return')}`);
1653
1681
  console.log(` ${teal('/clarify')} ${sage('turn messy thoughts into .intent/ artifacts')}`);
1682
+ console.log(` ${teal('/scan')} ${sage('find your projects — and repos that need shared memory')}`);
1654
1683
  console.log(` ${teal('/outcomes')} ${sage('label what you kept — the record that gets smarter')}`);
1655
1684
  console.log('');
1656
1685
  console.log(` ${slate('/help all')} ${sage('everything')} ${slate('·')} ${slate('/tour')} ${sage('walkthrough')} ${slate('·')} ${slate('/quit')} ${sage('exit')}`);
@@ -1668,6 +1697,7 @@ async function main() {
1668
1697
  console.log(` ${teal('/learn')} ${sage('what your record taught — which tool keeps best, by kind of work')}`);
1669
1698
  console.log('');
1670
1699
  console.log(` ${cream('author .intent/')}`);
1700
+ console.log(` ${teal('/scan')} ${sage('Find your projects — and likely candidates with no .intent/ yet')}`);
1671
1701
  console.log(` ${teal('/init')} ${sage('Create .intent/ for this project')}`);
1672
1702
  console.log(` ${teal('/intent')} ${sage('Pause and reflect — view or update .intent/ before moving on')}`);
1673
1703
  console.log(` ${teal('/remember')} ${sage('Jot a decision to .intent/decisions.md — every tool inherits it')}`);
@@ -1902,6 +1932,13 @@ async function main() {
1902
1932
  return;
1903
1933
  }
1904
1934
 
1935
+ if (cmd === 'scan') {
1936
+ runScanMenu();
1937
+ console.log('');
1938
+ rl.prompt();
1939
+ return;
1940
+ }
1941
+
1905
1942
  if (cmd === 'clarify') {
1906
1943
  try {
1907
1944
  const { spawnSync } = require('child_process');
package/lib/cors.js CHANGED
@@ -37,6 +37,12 @@ function corsHeaders(req) {
37
37
  'Access-Control-Allow-Origin': origin,
38
38
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
39
39
  'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Phewsh-Runtime',
40
+ // Chrome Local/Private Network Access: an HTTPS page (phewsh.com) fetching
41
+ // plain-http loopback gets a preflight carrying Access-Control-Request-
42
+ // Private-Network; without this answer Chrome kills the request before it
43
+ // is ever sent — the cockpit shows "bridge offline" while serve is
44
+ // demonstrably up. Loopback-only server, allowlisted origins only.
45
+ 'Access-Control-Allow-Private-Network': 'true',
40
46
  'Access-Control-Max-Age': '600',
41
47
  Vary: 'Origin',
42
48
  };
@@ -0,0 +1,38 @@
1
+ // The 12-node Intent Compass — the CLI's canonical copy of the model the web
2
+ // compass renders (intent/app/src/lib/intent-analysis.ts). One definition,
3
+ // two surfaces: the web helps you SEE your intent; the terminal helps you
4
+ // SAY it. Ordered as a ladder — the first five are the strongest nodes and
5
+ // form the default clarify walk; --deep continues through all twelve.
6
+
7
+ const INTENT_NODES = [
8
+ { id: 'purpose', title: 'Purpose', directive: 'the core reason this exists',
9
+ q: 'What outcome are you really after — and why does this need to exist?' },
10
+ { id: 'audience', title: 'Audience', directive: 'the people this serves',
11
+ q: 'Who is this for? Who feels it most when it works?' },
12
+ { id: 'method', title: 'Method', directive: 'the mechanism and approach',
13
+ q: 'How does it actually work — the core mechanism or approach?' },
14
+ { id: 'scope', title: 'Scope', directive: 'boundaries, in and out',
15
+ q: "What's in — and just as important, what's deliberately out, for now?" },
16
+ { id: 'differentiation', title: 'Edge', directive: 'what makes this yours',
17
+ q: 'What would be lost if someone else built this instead of you?' },
18
+ // ── the deep walk continues here (--deep) ──
19
+ { id: 'context', title: 'Context', directive: 'the situation that led here',
20
+ q: "What's happening right now that makes this relevant — what led to it?" },
21
+ { id: 'resources', title: 'Resources', directive: 'time, money, energy, tools',
22
+ q: 'What does this require — time, money, energy, tools you already have?' },
23
+ { id: 'strategy', title: 'Strategy', directive: 'the roadmap and sequence',
24
+ q: 'How do you get from here to success — what happens first, second, third?' },
25
+ { id: 'signals', title: 'Signals', directive: 'metrics, validation, feedback',
26
+ q: 'How will you know this is working — what would you actually measure?' },
27
+ { id: 'risks', title: 'Risks', directive: 'threats, unknowns, failure modes',
28
+ q: "What could go wrong — what's the biggest unknown or exposure?" },
29
+ { id: 'values', title: 'Values', directive: 'ethics, trust, non-negotiables',
30
+ q: 'What do you refuse to compromise on, even under pressure?' },
31
+ { id: 'impact', title: 'Impact', directive: 'long-term effects and sustainability',
32
+ q: 'If this fully succeeds, what changes — and how does it sustain itself?' },
33
+ ];
34
+
35
+ // The five strongest nodes — the default clarify walk.
36
+ const CORE_NODES = INTENT_NODES.slice(0, 5);
37
+
38
+ module.exports = { INTENT_NODES, CORE_NODES };
package/lib/intro.js CHANGED
@@ -57,6 +57,8 @@ async function playIntro(opts = {}) {
57
57
  delay = sleep,
58
58
  out = console.log,
59
59
  listHarnesses = require('./harnesses').listHarnesses,
60
+ scanProjects = require('./projects-index').scanForProjects,
61
+ scanCandidates = require('./projects-index').scanForCandidates,
60
62
  } = opts;
61
63
 
62
64
  const { b, cream, sage, slate, teal, green } = ui;
@@ -93,6 +95,24 @@ async function playIntro(opts = {}) {
93
95
  }
94
96
  out('');
95
97
  await pause(160);
98
+
99
+ // Second beat: the tools share memory *per project* — so show the projects.
100
+ // Shallow scan of the usual folders only (same rules as /scan): existing
101
+ // .intent/ projects, plus likely candidates (git repos with no .intent yet).
102
+ let projects = [];
103
+ let candidates = [];
104
+ try { projects = scanProjects(); } catch { /* none */ }
105
+ try { candidates = scanCandidates(); } catch { /* none */ }
106
+ if (projects.length > 0 || candidates.length > 0) {
107
+ const bits = [];
108
+ if (projects.length > 0) bits.push(`${projects.length} project${projects.length !== 1 ? 's' : ''} already share memory (.intent/)`);
109
+ if (candidates.length > 0) bits.push(`${candidates.length} likely candidate${candidates.length !== 1 ? 's' : ''} (git, no .intent yet)`);
110
+ out(` ${teal('●')} ${sage(bits.join(' · '))}`);
111
+ out(` ${slate('run phewsh inside one — or pick from the list when the session opens.')}`);
112
+ out('');
113
+ await pause(160);
114
+ }
115
+
96
116
  if (harnesses.length > 0) {
97
117
  out(` ${sage('Next:')} ${cream('just type to start')} ${slate('·')} ${cream('phewsh setup')} ${slate('to pick a default route')}`);
98
118
  } else {
@@ -100,7 +120,7 @@ async function playIntro(opts = {}) {
100
120
  }
101
121
  out('');
102
122
 
103
- return { toolsFound: harnesses.length };
123
+ return { toolsFound: harnesses.length, projectsFound: projects.length, candidatesFound: candidates.length };
104
124
  }
105
125
 
106
126
  // Quiet sign-off — the shush mark, printed static (no animation; exit is instant).
package/lib/pps.js CHANGED
@@ -1,5 +1,10 @@
1
- // PPS — Portable Project Spec
2
- // pps.json is the source of truth. .md files are generated views.
1
+ // PPS — Portable Project Spec compiler.
2
+ //
3
+ // TRUTH RULING (decisions.md, Jul 5 2026): the .md files ARE the project
4
+ // truth — user-owned, hand-editable, what every tool reads. pps.json is the
5
+ // compiler's receipt: structured output from clarify/init plus the hashes of
6
+ // what it generated, so regeneration can tell machine-owned files from
7
+ // hand-authored ones. phewsh never overwrites a file the human has edited.
3
8
 
4
9
  const fs = require('fs');
5
10
  const path = require('path');
@@ -63,6 +68,10 @@ function createPPS({ entity, archetype = 'product', raw = '', intent = {} }) {
63
68
  };
64
69
  }
65
70
 
71
+ // One line of provenance in every generated file — the user must never
72
+ // mistake compiled output for something they wrote (or vice versa).
73
+ const PROVENANCE = 'provenance: compiled by phewsh clarify — yours to edit; phewsh never regenerates a file you have edited';
74
+
66
75
  function generateViews(pps) {
67
76
  const { entity, intent, tasks, created, updated, archetype } = pps;
68
77
 
@@ -71,6 +80,7 @@ entity: ${entity}
71
80
  archetype: ${archetype}
72
81
  created: ${created}
73
82
  updated: ${updated}
83
+ ${PROVENANCE}
74
84
  ---
75
85
 
76
86
  # Vision
@@ -97,6 +107,7 @@ entity: ${entity}
97
107
  archetype: ${archetype}
98
108
  created: ${created}
99
109
  updated: ${updated}
110
+ ${PROVENANCE}
100
111
  ---
101
112
 
102
113
  # Plan
@@ -131,6 +142,7 @@ entity: ${entity}
131
142
  archetype: ${archetype}
132
143
  created: ${created}
133
144
  updated: ${updated}
145
+ ${PROVENANCE}
134
146
  ---
135
147
 
136
148
  # Next
@@ -155,4 +167,37 @@ ${tasks.length > 0
155
167
  return { vision, plan, next };
156
168
  }
157
169
 
158
- module.exports = { readPPS, writePPS, createPPS, generateViews, genId };
170
+ function hashContent(s) {
171
+ return crypto.createHash('sha256').update(s).digest('hex').slice(0, 16);
172
+ }
173
+
174
+ // The truth guard. Writes generated views, but a file is only machine-owned
175
+ // while its on-disk content still matches the hash recorded when we generated
176
+ // it. Hand-edited since then — or existing before we ever generated it (a
177
+ // hand-authored .intent/, like phewsh's own) — means it IS the truth now:
178
+ // preserve it, never overwrite. Records fresh hashes in pps.generated and
179
+ // persists pps.json. Returns { written, preserved }.
180
+ function writeGuardedViews(intentDir, pps) {
181
+ fs.mkdirSync(intentDir, { recursive: true });
182
+ const views = generateViews(pps);
183
+ const prior = (pps.generated && pps.generated.hashes) || {};
184
+ const written = [];
185
+ const preserved = [];
186
+ pps.generated = { ...(pps.generated || {}), hashes: { ...prior } };
187
+ for (const [key, file] of [['vision', 'vision.md'], ['plan', 'plan.md'], ['next', 'next.md']]) {
188
+ const fp = path.join(intentDir, file);
189
+ let current = null;
190
+ try { current = fs.readFileSync(fp, 'utf-8'); } catch { /* absent → ours to write */ }
191
+ if (current !== null && (!prior[file] || hashContent(current) !== prior[file])) {
192
+ preserved.push(file);
193
+ continue;
194
+ }
195
+ fs.writeFileSync(fp, views[key]);
196
+ pps.generated.hashes[file] = hashContent(views[key]);
197
+ written.push(file);
198
+ }
199
+ writePPS(intentDir, pps);
200
+ return { written, preserved };
201
+ }
202
+
203
+ module.exports = { readPPS, writePPS, createPPS, generateViews, writeGuardedViews, genId };
@@ -64,10 +64,10 @@ function listProjects() {
64
64
  }
65
65
 
66
66
  /** Shallow scan: direct children of common roots that contain .intent/. */
67
- function scanForProjects() {
67
+ function scanForProjects(roots = SCAN_ROOTS) {
68
68
  const found = [];
69
69
  const seen = new Set(); // realpath-dedupe — case-insensitive FS makes ~/Projects and ~/projects one dir
70
- for (const root of SCAN_ROOTS) {
70
+ for (const root of roots) {
71
71
  let entries;
72
72
  try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
73
73
  for (const e of entries) {
@@ -86,6 +86,34 @@ function scanForProjects() {
86
86
  return found;
87
87
  }
88
88
 
89
+ // Likely candidates: real projects (a .git repo is the conservative signal)
90
+ // that don't have .intent/ yet. Same shallow, opt-in scan as scanForProjects —
91
+ // one level deep in the usual folders, never recursive, never dotfiles read.
92
+ // Each hit carries its `reason` so the user sees WHY it was suggested.
93
+ const CANDIDATE_CAP = 15;
94
+ function scanForCandidates(roots = SCAN_ROOTS) {
95
+ const found = [];
96
+ const seen = new Set();
97
+ for (const root of roots) {
98
+ let entries;
99
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
100
+ for (const e of entries) {
101
+ if (!e.isDirectory() || e.name.startsWith('.')) continue;
102
+ const dir = path.join(root, e.name);
103
+ try {
104
+ if (!fs.existsSync(path.join(dir, '.git'))) continue;
105
+ if (fs.existsSync(path.join(dir, '.intent'))) continue; // already phewsh-enabled (or partially)
106
+ const real = fs.realpathSync(dir);
107
+ if (seen.has(real)) continue;
108
+ seen.add(real);
109
+ found.push({ name: e.name, path: real, reason: 'git repo, no .intent/ yet' });
110
+ if (found.length >= CANDIDATE_CAP) return found;
111
+ } catch { /* unreadable dir — skip */ }
112
+ }
113
+ }
114
+ return found;
115
+ }
116
+
89
117
  function fmtAgo(ts) {
90
118
  if (!ts) return '';
91
119
  const mins = Math.floor((Date.now() - new Date(ts).getTime()) / 60000);
@@ -95,4 +123,4 @@ function fmtAgo(ts) {
95
123
  return `${Math.floor(hrs / 24)}d ago`;
96
124
  }
97
125
 
98
- module.exports = { INDEX_FILE, SCAN_ROOTS, recordProject, listProjects, scanForProjects, fmtAgo };
126
+ module.exports = { INDEX_FILE, SCAN_ROOTS, recordProject, listProjects, scanForProjects, scanForCandidates, fmtAgo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "phewsh",
3
- "version": "0.15.71",
3
+ "version": "0.15.73",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"