phewsh 0.15.70 → 0.15.72

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
  }
@@ -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
@@ -215,11 +215,41 @@ function preTool() {
215
215
  }
216
216
  }
217
217
 
218
+ // PostToolUse adapter — the other half of the lifecycle. After a write-ish
219
+ // tool runs, leave a redacted receipt (tool name + relative target, or just
220
+ // the binary name for shell — NEVER args, content, or output) so `phewsh`
221
+ // can show what agents actually did here. Always silent to the host, always
222
+ // fail-open: a broken receipt must never slow or break the tool that ran.
223
+ function postTool() {
224
+ try {
225
+ if (process.stdin.isTTY) process.exit(0); // not a real hook invocation
226
+ let raw = '';
227
+ try { raw = fs.readFileSync(0, 'utf-8'); } catch { process.exit(0); }
228
+ let payload = {};
229
+ try { payload = JSON.parse(raw || '{}'); } catch { process.exit(0); }
230
+ const tool = payload.tool_name || payload.toolName;
231
+ if (!tool) process.exit(0);
232
+ const input = payload.tool_input || payload.toolInput || {};
233
+ const cwd = payload.cwd || process.cwd();
234
+ let target = null;
235
+ if (typeof input.file_path === 'string') {
236
+ target = path.relative(cwd, input.file_path) || input.file_path;
237
+ } else if (typeof input.command === 'string') {
238
+ target = String(input.command).trim().split(/\s+/)[0] || null; // binary only, never args
239
+ }
240
+ appendBreadcrumb('post-tool', { tool, ...(target ? { target } : {}) });
241
+ process.exit(0);
242
+ } catch {
243
+ process.exit(0); // fail open, always
244
+ }
245
+ }
246
+
218
247
  function main() {
219
248
  const event = process.argv[3];
220
249
  if (event === 'session-start') return sessionStart();
221
250
  if (event === 'session-end') return sessionEnd();
222
251
  if (event === 'pre-tool') return preTool();
252
+ if (event === 'post-tool') return postTool();
223
253
  // Unknown event: exit silently — hooks must never error the host tool.
224
254
  process.exit(0);
225
255
  }
@@ -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
 
@@ -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();
@@ -1305,17 +1311,31 @@ async function main() {
1305
1311
  if (choice.kind === 'scan') {
1306
1312
  const spin = ui.spinner('scanning your usual folders');
1307
1313
  const found = scanForProjects();
1314
+ // Likely candidates: real repos with no .intent/ yet — the projects
1315
+ // most worth grounding. Same shallow scan; shown with their reason.
1316
+ let candidates = [];
1317
+ try { candidates = scanForCandidates(); } catch { /* advisory — scan still useful without it */ }
1308
1318
  spin.stop();
1309
- if (found.length === 0) {
1319
+ if (found.length === 0 && candidates.length === 0) {
1310
1320
  bootstrapChoices = null;
1311
1321
  console.log(` ${sage('No .intent/ projects found in the usual folders.')}`);
1312
1322
  console.log(` ${slate('cd into a project and run phewsh, or /init to start one here.')}`);
1313
1323
  } 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
- });
1324
+ bootstrapChoices = [];
1325
+ if (found.length > 0) {
1326
+ console.log(` ${teal('●')} ${sage(`Found ${found.length} project${found.length !== 1 ? 's' : ''} with .intent/:`)}`);
1327
+ for (const p of found) {
1328
+ bootstrapChoices.push({ kind: 'open', path: p.path });
1329
+ console.log(` ${teal(String(bootstrapChoices.length))} ${cream(p.name)} ${slate('· ' + tildify(p.path))}`);
1330
+ }
1331
+ }
1332
+ if (candidates.length > 0) {
1333
+ console.log(` ${teal('●')} ${sage(`${candidates.length} likely candidate${candidates.length !== 1 ? 's' : ''} — no shared memory yet:`)}`);
1334
+ for (const p of candidates) {
1335
+ bootstrapChoices.push({ kind: 'open', path: p.path });
1336
+ console.log(` ${teal(String(bootstrapChoices.length))} ${cream(p.name)} ${slate('· ' + tildify(p.path) + ' · ' + p.reason)}`);
1337
+ }
1338
+ }
1319
1339
  console.log(` ${slate('pick a number to open it')}`);
1320
1340
  }
1321
1341
  console.log('');
package/commands/task.js CHANGED
@@ -123,11 +123,41 @@ function runnerArgs(harnessId, prompt) {
123
123
  return HARNESSES[harnessId].args(prompt);
124
124
  }
125
125
 
126
+ async function memberNames(config, projectId) {
127
+ try {
128
+ const rows = await supa.rpc('project_member_names', { pid: projectId }, config.supabaseAccessToken);
129
+ const m = {};
130
+ for (const r of rows || []) m[r.user_id] = r.display_name;
131
+ return m;
132
+ } catch { return {}; }
133
+ }
134
+
135
+ // Close the loop from verified GitHub state: any listed task whose PR has
136
+ // actually merged gets recorded as merged (best-effort, never blocks the list).
137
+ function syncMergedFromGitHub(config, rows) {
138
+ const candidates = rows.filter((t) => ['pr_open', 'approved'].includes(t.status) && t.pull_request_url);
139
+ const updated = [];
140
+ for (const t of candidates) {
141
+ try {
142
+ const out = execFileSync('gh', ['pr', 'view', t.pull_request_url, '--json', 'state'], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
143
+ if (JSON.parse(out).state === 'MERGED') updated.push(t);
144
+ } catch { /* gh missing or PR unreachable — leave as-is */ }
145
+ }
146
+ return Promise.all(updated.map((t) =>
147
+ supa.rpc('mark_merged', { p_task_id: t.id, p_metadata: { via: 'gh pr view' } }, config.supabaseAccessToken)
148
+ .then(() => { t.status = 'merged'; })
149
+ .catch(() => {})
150
+ ));
151
+ }
152
+
126
153
  async function listTasks(config) {
127
154
  const project = await loadProject(config);
128
155
  const rows = await supa.select('tasks',
129
- `project_id=eq.${project.id}&select=id,title,status,claimed_by,pull_request_url,created_at&order=created_at.desc&limit=20`,
156
+ `project_id=eq.${project.id}&select=id,title,status,claimed_by,assigned_to,suggested_harness,pull_request_url,created_at&order=created_at.desc&limit=20`,
130
157
  config.supabaseAccessToken);
158
+ await syncMergedFromGitHub(config, rows);
159
+ const names = await memberNames(config, project.id);
160
+ const nameOf = (id) => id === config.supabaseUserId ? 'you' : (names[id] || 'a teammate');
131
161
  console.log(`\n ${b(w(project.name))} ${g('— shared tasks')}\n`);
132
162
  if (!rows.length) {
133
163
  console.log(` ${g('No tasks yet.')} ${w('phewsh task new "<title>"')} ${g('to request one.')}\n`);
@@ -135,13 +165,40 @@ async function listTasks(config) {
135
165
  }
136
166
  for (const t of rows) {
137
167
  const color = STATUS_COLOR[t.status] || w;
138
- const mine = t.claimed_by === config.supabaseUserId ? g(' · claimed by you') : '';
139
- console.log(` ${color('●')} ${w(t.title)} ${g(t.id.slice(0, 8))} ${color(t.status)}${mine}`);
168
+ const bits = [];
169
+ if (t.claimed_by) bits.push(`claimed by ${nameOf(t.claimed_by)}`);
170
+ else if (t.assigned_to) bits.push(`assigned to ${nameOf(t.assigned_to)}`);
171
+ if (t.suggested_harness && !t.claimed_by) bits.push(`suggests ${HARNESSES[t.suggested_harness]?.label || t.suggested_harness}`);
172
+ console.log(` ${color('●')} ${w(t.title)} ${g(t.id.slice(0, 8))} ${color(t.status)}${bits.length ? g(' · ' + bits.join(' · ')) : ''}`);
140
173
  if (t.pull_request_url) console.log(` ${cyan(t.pull_request_url)}`);
174
+ if (t.status === 'merged') console.log(` ${g('merged — record it:')} ${w(`phewsh task reconcile ${t.id.slice(0, 8)}`)}`);
141
175
  }
142
176
  console.log(`\n ${g('Claim one:')} ${w('phewsh task claim <id>')}\n`);
143
177
  }
144
178
 
179
+ // Promote a merged task into the repo's durable Record (.intent/decisions.md)
180
+ // and mark it reconciled — the last beat of the loop.
181
+ async function reconcileTask(config, idArg) {
182
+ if (!idArg) throw new Error('Usage: phewsh task reconcile <task-id>');
183
+ const project = await loadProject(config);
184
+ const rows = await supa.select('tasks', `project_id=eq.${project.id}&id=like.${idArg}*&select=*`, config.supabaseAccessToken);
185
+ const task = rows[0];
186
+ if (!task) throw new Error(`Task ${idArg} not found in this project.`);
187
+ if (task.status !== 'merged') {
188
+ await syncMergedFromGitHub(config, [task]);
189
+ if (task.status !== 'merged') throw new Error(`Task is ${task.status} — only merged tasks can be reconciled.`);
190
+ }
191
+ const names = await memberNames(config, project.id);
192
+ const nameOf = (id) => names[id] || 'a teammate';
193
+ const decisionsPath = path.join(process.cwd(), '.intent', 'decisions.md');
194
+ const line = `- ${new Date().toISOString().slice(0, 10)} — Shared task "${task.title}" (${task.id.slice(0, 8)}) merged and recorded: requested by ${nameOf(task.created_by)}, executed by ${nameOf(task.claimed_by)}, reviewed on GitHub. PR: ${task.pull_request_url || 'n/a'}\n`;
195
+ if (fs.existsSync(decisionsPath)) fs.appendFileSync(decisionsPath, line);
196
+ else console.log(` ${g('(no .intent/decisions.md here — recording in the cloud only)')}`);
197
+ await supa.rpc('mark_reconciled', { p_task_id: task.id }, config.supabaseAccessToken);
198
+ console.log(`\n ${green('✓')} Recorded into project truth: ${w(task.title)}`);
199
+ if (fs.existsSync(decisionsPath)) console.log(` ${g('Appended to .intent/decisions.md — commit it with your normal flow.')}\n`);
200
+ }
201
+
145
202
  async function inviteTeammate(config, email) {
146
203
  if (!email || !email.includes('@')) throw new Error('Usage: phewsh task invite <email>');
147
204
  const project = await loadProject(config);
@@ -296,6 +353,7 @@ module.exports = async function run() {
296
353
  if (sub === 'claim') return await claimTask(config, rest[0], via);
297
354
  if (sub === 'invite') return await inviteTeammate(config, rest[0]);
298
355
  if (sub === 'join') return await joinProjects(config);
356
+ if (sub === 'reconcile') return await reconcileTask(config, rest[0]);
299
357
  console.log(`\n Usage: phewsh task [list | new "<title>" | claim <id> [--via <harness>] | invite <email> | join]\n phewsh dispatch ["<title>" | <id> | next] [--via <harness>]\n`);
300
358
  } catch (err) {
301
359
  console.error(`\n ${red('✗')} ${err.message}\n`);
@@ -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.70",
3
+ "version": "0.15.72",
4
4
  "description": "Turn intent into action. Structure your thinking, execute your next step.",
5
5
  "bin": {
6
6
  "phewsh": "bin/phewsh.js"