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.
- package/commands/ambient.js +43 -12
- package/commands/clarify.js +41 -46
- package/commands/gate.js +5 -4
- package/commands/hook.js +60 -1
- package/commands/intent.js +8 -10
- package/commands/pack.js +34 -1
- package/commands/session.js +58 -21
- package/lib/cors.js +6 -0
- package/lib/intent-nodes.js +38 -0
- package/lib/intro.js +21 -1
- package/lib/pps.js +48 -3
- package/lib/projects-index.js +31 -3
- package/package.json +1 -1
package/commands/ambient.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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')}`);
|
package/commands/clarify.js
CHANGED
|
@@ -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,
|
|
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 —
|
|
49
|
-
//
|
|
50
|
-
// they're building; this brings that
|
|
51
|
-
// question is skippable, and the point
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 <
|
|
75
|
-
const n =
|
|
76
|
-
console.log(` ${i + 1}/${
|
|
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
|
|
186
|
+
phewsh clarify --update Refine existing intent with new input
|
|
200
187
|
|
|
201
188
|
What it does:
|
|
202
|
-
Walks you through the
|
|
203
|
-
Purpose, Audience, Method, Scope, Edge
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
console.log(` ✓ .intent/pps.json —
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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
|
|
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('
|
|
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 —
|
|
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
|
}
|
package/commands/intent.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 —
|
|
146
|
-
|
|
147
|
-
console.log(` ✓ .intent
|
|
148
|
-
console.log(`
|
|
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('
|
|
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();
|
package/commands/session.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1275
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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 };
|
package/lib/projects-index.js
CHANGED
|
@@ -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
|
|
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 };
|