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.
- package/commands/ambient.js +25 -12
- package/commands/clarify.js +41 -46
- package/commands/gate.js +5 -4
- package/commands/hook.js +30 -0
- package/commands/intent.js +8 -10
- package/commands/session.js +29 -9
- package/commands/task.js +61 -3
- 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
|
}
|
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
|
@@ -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
|
}
|
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/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();
|
|
@@ -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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
|
139
|
-
|
|
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
|
-
//
|
|
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 };
|