phewsh 0.15.66 → 0.15.68
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/bin/phewsh.js +6 -1
- package/commands/task.js +259 -0
- package/lib/supabase.js +29 -1
- package/lib/team-tasks.js +72 -0
- package/package.json +1 -1
package/bin/phewsh.js
CHANGED
|
@@ -77,6 +77,9 @@ function showBrand() {
|
|
|
77
77
|
const COMMANDS = {
|
|
78
78
|
session: () => require('../commands/session'),
|
|
79
79
|
intent: () => require('../commands/intent'),
|
|
80
|
+
// `phewsh init` = `phewsh intent --init` — the universal spelling (git init,
|
|
81
|
+
// npm init) and what phewsh.com/cli tells new users to run.
|
|
82
|
+
init: () => { process.argv.splice(3, 0, '--init'); return require('../commands/intent'); },
|
|
80
83
|
clarify: () => require('../commands/clarify').run(),
|
|
81
84
|
push: () => require('../commands/push'),
|
|
82
85
|
pull: () => require('../commands/pull'),
|
|
@@ -108,6 +111,7 @@ const COMMANDS = {
|
|
|
108
111
|
next: () => require('../commands/next')(),
|
|
109
112
|
work: () => require('../commands/work')(),
|
|
110
113
|
remember: () => require('../commands/remember')(),
|
|
114
|
+
task: () => require('../commands/task')(),
|
|
111
115
|
pack: () => require('../commands/pack')(),
|
|
112
116
|
hook: () => require('../commands/hook')(),
|
|
113
117
|
welcome: () => require('../commands/welcome')(),
|
|
@@ -158,7 +162,8 @@ function showHelp() {
|
|
|
158
162
|
console.log(` ${cyan('ambient')} ${g('Continuity without launching phewsh — enhance your other tools')}`);
|
|
159
163
|
console.log(` ${cyan('shim')} ${g('Guaranteed launch banner — phewsh prints status before each tool')}`);
|
|
160
164
|
console.log(` ${cyan('pack')} ${g('Opt-in workflow packs (Karpathy guidelines, GSD…) — attributed, reversible')}`);
|
|
161
|
-
console.log(` ${cyan('
|
|
165
|
+
console.log(` ${cyan('task')} ${g('Shared tasks — request, claim, and ship teammate work via branch + PR')}
|
|
166
|
+
${cyan('receipts')} ${g('Proof trail — what agents actually did, with evidence')}`);
|
|
162
167
|
console.log(` ${cyan('remember')} ${g('Jot a decision to .intent/decisions.md — every tool inherits it')}`);
|
|
163
168
|
console.log(` ${cyan('outcomes')} ${g('Decision record — what was kept, reverted, or failed')}`);
|
|
164
169
|
console.log(` ${cyan('bypass')} ${g('Went around phewsh? Record why — 10 seconds, no guilt')}`);
|
package/commands/task.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// phewsh task — shared-project tasks (team-projects Phase 2, 2026-07-02 ruling)
|
|
2
|
+
//
|
|
3
|
+
// phewsh task List tasks for the linked cloud project
|
|
4
|
+
// phewsh task new <title> Request a task teammates (or you) can claim
|
|
5
|
+
// phewsh task claim <id> Manually claim + execute on an isolated branch + open a PR
|
|
6
|
+
// --via <harness> Route to a specific installed harness
|
|
7
|
+
//
|
|
8
|
+
// Boundaries (approved ruling): claiming is MANUAL — no daemon, no auto-claim.
|
|
9
|
+
// Work happens on a dedicated branch in a dedicated worktree, never on main.
|
|
10
|
+
// The branch carries a PROVISIONAL .intent/work/task-<id>.json — it becomes
|
|
11
|
+
// project truth only when the PR merges. Supabase holds coordination state only.
|
|
12
|
+
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const os = require('os');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
17
|
+
const configFile = require('../lib/config-file');
|
|
18
|
+
const supa = require('../lib/supabase');
|
|
19
|
+
const { normalizeRemote, taskBranch, buildTaskPrompt, proposedOutcome } = require('../lib/team-tasks');
|
|
20
|
+
const { HARNESSES, listHarnesses } = require('../lib/harnesses');
|
|
21
|
+
const { recordResultFile } = require('../lib/receipts-data');
|
|
22
|
+
|
|
23
|
+
const b = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
24
|
+
const g = (s) => `\x1b[38;5;247m${s}\x1b[0m`;
|
|
25
|
+
const w = (s) => `\x1b[97m${s}\x1b[0m`;
|
|
26
|
+
const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
|
|
27
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
28
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
29
|
+
|
|
30
|
+
const CONFIG_PATH = path.join(os.homedir(), '.phewsh', 'config.json');
|
|
31
|
+
|
|
32
|
+
const STATUS_COLOR = {
|
|
33
|
+
open: cyan, claimed: w, in_progress: w, pr_open: green,
|
|
34
|
+
approved: green, merged: green, reconciled: green,
|
|
35
|
+
rejected: red, failed: red, cancelled: g,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
async function getSession() {
|
|
39
|
+
const config = configFile.loadConfig(CONFIG_PATH, {});
|
|
40
|
+
if (!config.supabaseUserId) {
|
|
41
|
+
throw new Error('Not logged in. Run `phewsh login` first.');
|
|
42
|
+
}
|
|
43
|
+
if (!config.supabaseAccessToken && config.supabaseRefreshToken) {
|
|
44
|
+
const session = await supa.refreshSession(config.supabaseRefreshToken);
|
|
45
|
+
if (session?.access_token) {
|
|
46
|
+
config.supabaseAccessToken = session.access_token;
|
|
47
|
+
config.supabaseRefreshToken = session.refresh_token;
|
|
48
|
+
try { configFile.saveConfig(CONFIG_PATH, config); } catch { /* best-effort */ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!config.supabaseAccessToken) throw new Error('Session expired. Run `phewsh login` again.');
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function git(args, cwd) {
|
|
56
|
+
return execFileSync('git', args, { cwd, encoding: 'utf-8' }).trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadCloudProjectId() {
|
|
60
|
+
const ppsPath = path.join(process.cwd(), '.intent', 'pps.json');
|
|
61
|
+
try {
|
|
62
|
+
const pps = JSON.parse(fs.readFileSync(ppsPath, 'utf-8'));
|
|
63
|
+
return pps?.adapters?.phewsh?.cloud_id || null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loadProject(config) {
|
|
70
|
+
const cloudId = loadCloudProjectId();
|
|
71
|
+
if (!cloudId) {
|
|
72
|
+
throw new Error('This project is not linked to the cloud. Run `phewsh push` (or `phewsh link <id>`) first.');
|
|
73
|
+
}
|
|
74
|
+
const rows = await supa.select('projects', `id=eq.${cloudId}&select=id,name,user_id,archetype,github_remote`, config.supabaseAccessToken);
|
|
75
|
+
if (!rows.length) throw new Error(`Cloud project ${cloudId} not found (or you are not a member).`);
|
|
76
|
+
return rows[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The claiming CLI must be sitting in the repo this project coordinates.
|
|
80
|
+
async function ensureRemoteMatch(project, config) {
|
|
81
|
+
let local;
|
|
82
|
+
try {
|
|
83
|
+
local = normalizeRemote(git(['remote', 'get-url', 'origin'], process.cwd()));
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error('Not a git repository with an `origin` remote — claim from the project repo.');
|
|
86
|
+
}
|
|
87
|
+
if (!project.github_remote) {
|
|
88
|
+
if (project.user_id === config.supabaseUserId) {
|
|
89
|
+
await supa.upsert('projects', {
|
|
90
|
+
id: project.id, user_id: project.user_id, name: project.name,
|
|
91
|
+
archetype: project.archetype, github_remote: local,
|
|
92
|
+
}, config.supabaseAccessToken);
|
|
93
|
+
console.log(` ${g('Project repo bound to')} ${w(local)}`);
|
|
94
|
+
return local;
|
|
95
|
+
}
|
|
96
|
+
throw new Error('Project has no github_remote yet — ask the owner to claim once (or set it) first.');
|
|
97
|
+
}
|
|
98
|
+
if (normalizeRemote(project.github_remote) !== local) {
|
|
99
|
+
throw new Error(`Repo mismatch: project coordinates ${project.github_remote}, but this repo's origin is ${local}. Refusing to claim.`);
|
|
100
|
+
}
|
|
101
|
+
return local;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pickHarness(viaFlag, config) {
|
|
105
|
+
const installed = listHarnesses().filter((h) => h.installed && h.headless);
|
|
106
|
+
if (viaFlag) {
|
|
107
|
+
const hit = installed.find((h) => h.id === viaFlag);
|
|
108
|
+
if (!hit) throw new Error(`--via ${viaFlag}: not an installed headless harness (${installed.map((h) => h.id).join(', ') || 'none found'})`);
|
|
109
|
+
return hit.id;
|
|
110
|
+
}
|
|
111
|
+
if (config.defaultRoute && config.defaultRoute !== 'api' && installed.some((h) => h.id === config.defaultRoute)) {
|
|
112
|
+
return config.defaultRoute;
|
|
113
|
+
}
|
|
114
|
+
if (!installed.length) throw new Error('No headless harness installed (Claude Code, Codex, Gemini, …).');
|
|
115
|
+
return installed[0].id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One-shot, human-visible, file-editing runs inside an isolated worktree.
|
|
119
|
+
// claude-code/codex need explicit flags for that; others use the shared table.
|
|
120
|
+
function runnerArgs(harnessId, prompt) {
|
|
121
|
+
if (harnessId === 'claude-code') return ['-p', prompt, '--permission-mode', 'acceptEdits'];
|
|
122
|
+
if (harnessId === 'codex') return ['exec', '--full-auto', '--skip-git-repo-check', prompt];
|
|
123
|
+
return HARNESSES[harnessId].args(prompt);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function listTasks(config) {
|
|
127
|
+
const project = await loadProject(config);
|
|
128
|
+
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`,
|
|
130
|
+
config.supabaseAccessToken);
|
|
131
|
+
console.log(`\n ${b(w(project.name))} ${g('— shared tasks')}\n`);
|
|
132
|
+
if (!rows.length) {
|
|
133
|
+
console.log(` ${g('No tasks yet.')} ${w('phewsh task new "<title>"')} ${g('to request one.')}\n`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const t of rows) {
|
|
137
|
+
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}`);
|
|
140
|
+
if (t.pull_request_url) console.log(` ${cyan(t.pull_request_url)}`);
|
|
141
|
+
}
|
|
142
|
+
console.log(`\n ${g('Claim one:')} ${w('phewsh task claim <id>')}\n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function newTask(config, title) {
|
|
146
|
+
if (!title) throw new Error('Usage: phewsh task new "<title>"');
|
|
147
|
+
const project = await loadProject(config);
|
|
148
|
+
const rows = await supa.insert('tasks', {
|
|
149
|
+
project_id: project.id,
|
|
150
|
+
created_by: config.supabaseUserId,
|
|
151
|
+
title,
|
|
152
|
+
packet: { objective: title },
|
|
153
|
+
}, config.supabaseAccessToken);
|
|
154
|
+
console.log(`\n ${green('✓')} Task requested: ${w(title)} ${g(rows[0].id.slice(0, 8))}`);
|
|
155
|
+
console.log(` ${g('Anyone on the project can now:')} ${w(`phewsh task claim ${rows[0].id.slice(0, 8)}`)}\n`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function claimTask(config, idArg, viaFlag) {
|
|
159
|
+
if (!idArg) throw new Error('Usage: phewsh task claim <task-id>');
|
|
160
|
+
const project = await loadProject(config);
|
|
161
|
+
await ensureRemoteMatch(project, config);
|
|
162
|
+
|
|
163
|
+
// Preflights BEFORE claiming, so a failed claim never strands a task.
|
|
164
|
+
const harnessId = pickHarness(viaFlag, config);
|
|
165
|
+
try { execFileSync('gh', ['auth', 'status'], { stdio: 'ignore' }); } catch {
|
|
166
|
+
throw new Error('`gh` is not installed or not authenticated — needed to open the PR. Run `gh auth login`.');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Resolve a short id prefix to the full task id.
|
|
170
|
+
const candidates = await supa.select('tasks',
|
|
171
|
+
`project_id=eq.${project.id}&id=like.${idArg}*&select=*`, config.supabaseAccessToken)
|
|
172
|
+
.catch(() => []);
|
|
173
|
+
const task = candidates.length === 1
|
|
174
|
+
? candidates[0]
|
|
175
|
+
: (await supa.select('tasks', `id=eq.${idArg}&select=*`, config.supabaseAccessToken))[0];
|
|
176
|
+
if (!task) throw new Error(`Task ${idArg} not found in this project.`);
|
|
177
|
+
|
|
178
|
+
const claimed = await supa.rpc('claim_task', { p_task_id: task.id }, config.supabaseAccessToken);
|
|
179
|
+
claimed.packet = claimed.packet || task.packet;
|
|
180
|
+
console.log(`\n ${green('✓')} Claimed ${w(claimed.title)} ${g(claimed.id.slice(0, 8))}`);
|
|
181
|
+
|
|
182
|
+
// Isolated worktree on a deterministic branch (task id = idempotency key).
|
|
183
|
+
const repoRoot = git(['rev-parse', '--show-toplevel'], process.cwd());
|
|
184
|
+
const branch = taskBranch(claimed.id, claimed.title);
|
|
185
|
+
const worktree = path.join(os.homedir(), '.phewsh', 'worktrees', `${path.basename(repoRoot)}-${claimed.id.slice(0, 8)}`);
|
|
186
|
+
if (!fs.existsSync(worktree)) {
|
|
187
|
+
const branchExists = spawnSync('git', ['rev-parse', '--verify', branch], { cwd: repoRoot }).status === 0;
|
|
188
|
+
git(['worktree', 'add', worktree, ...(branchExists ? [branch] : ['-b', branch])], repoRoot);
|
|
189
|
+
}
|
|
190
|
+
console.log(` ${g('Branch')} ${w(branch)} ${g('in worktree')} ${w(worktree)}`);
|
|
191
|
+
|
|
192
|
+
const host = os.hostname();
|
|
193
|
+
const startedAt = new Date().toISOString();
|
|
194
|
+
await supa.rpc('start_execution', { p_task_id: claimed.id, p_metadata: { harness: harnessId, host } }, config.supabaseAccessToken);
|
|
195
|
+
|
|
196
|
+
console.log(` ${g('Running')} ${w(HARNESSES[harnessId].label)} ${g('— its output follows:')}\n`);
|
|
197
|
+
const run = spawnSync(HARNESSES[harnessId].bin, runnerArgs(harnessId, buildTaskPrompt(claimed)), {
|
|
198
|
+
cwd: worktree, stdio: 'inherit', env: { ...process.env },
|
|
199
|
+
});
|
|
200
|
+
const finishedAt = new Date().toISOString();
|
|
201
|
+
|
|
202
|
+
const dirty = spawnSync('git', ['status', '--porcelain'], { cwd: worktree, encoding: 'utf-8' }).stdout.trim();
|
|
203
|
+
if (run.status !== 0 && !dirty) {
|
|
204
|
+
await supa.rpc('complete_execution', { p_task_id: claimed.id, p_success: false, p_metadata: { reason: 'harness_failed', exit: run.status } }, config.supabaseAccessToken);
|
|
205
|
+
throw new Error(`${HARNESSES[harnessId].label} exited ${run.status} with no changes — task marked failed.`);
|
|
206
|
+
}
|
|
207
|
+
if (!dirty) {
|
|
208
|
+
await supa.rpc('complete_execution', { p_task_id: claimed.id, p_success: false, p_metadata: { reason: 'no_changes' } }, config.supabaseAccessToken);
|
|
209
|
+
throw new Error('The run produced no changes — nothing to propose. Task marked failed (honestly).');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Provisional proposed outcome ON THE BRANCH — truth only after merge.
|
|
213
|
+
const outcome = proposedOutcome({ task: claimed, harnessId, host, startedAt, finishedAt });
|
|
214
|
+
const workDir = path.join(worktree, '.intent', 'work');
|
|
215
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
216
|
+
fs.writeFileSync(path.join(workDir, `task-${claimed.id}.json`), JSON.stringify(outcome, null, 2) + '\n');
|
|
217
|
+
recordResultFile({ taskId: claimed.id, kind: 'team-task', branch, ...outcome });
|
|
218
|
+
|
|
219
|
+
git(['add', '-A'], worktree);
|
|
220
|
+
git(['commit', '-m', `task ${claimed.id.slice(0, 8)}: ${claimed.title}\n\nProposed via phewsh task claim (${harnessId} on ${host}). Provisional until merged.`], worktree);
|
|
221
|
+
git(['push', '-u', 'origin', branch], worktree);
|
|
222
|
+
await supa.rpc('complete_execution', { p_task_id: claimed.id, p_success: true, p_metadata: { harness: harnessId } }, config.supabaseAccessToken);
|
|
223
|
+
|
|
224
|
+
const prBody = [
|
|
225
|
+
`Shared phewsh task \`${claimed.id}\`: ${claimed.title}`,
|
|
226
|
+
'',
|
|
227
|
+
`- requested by: ${claimed.created_by}`,
|
|
228
|
+
`- executed by: ${config.email || config.supabaseUserId} via ${HARNESSES[harnessId].label} on ${host}`,
|
|
229
|
+
`- provisional outcome: \`.intent/work/task-${claimed.id}.json\` (authoritative only when merged)`,
|
|
230
|
+
'',
|
|
231
|
+
'🤖 Proposed with [phewsh](https://phewsh.com) — review, then approve or reject from the project room or GitHub.',
|
|
232
|
+
].join('\n');
|
|
233
|
+
const prUrl = execFileSync('gh', ['pr', 'create', '--head', branch, '--title', `task: ${claimed.title}`, '--body', prBody], { cwd: worktree, encoding: 'utf-8' }).trim().split('\n').pop();
|
|
234
|
+
|
|
235
|
+
await supa.rpc('open_pr', { p_task_id: claimed.id, p_branch: branch, p_pull_request_url: prUrl }, config.supabaseAccessToken);
|
|
236
|
+
|
|
237
|
+
console.log(`\n ${green('✓')} PR open: ${cyan(prUrl)}`);
|
|
238
|
+
console.log(` ${g('Teammates can review from the project room. Worktree kept at')} ${w(worktree)}`);
|
|
239
|
+
console.log(` ${g('Clean up later:')} ${w(`git worktree remove ${worktree}`)}\n`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
module.exports = async function run() {
|
|
243
|
+
const args = process.argv.slice(3);
|
|
244
|
+
const sub = args[0] || 'list';
|
|
245
|
+
const viaIdx = args.indexOf('--via');
|
|
246
|
+
const via = viaIdx !== -1 ? args[viaIdx + 1] : null;
|
|
247
|
+
const rest = args.filter((a, i) => i > 0 && i !== viaIdx && i !== viaIdx + 1);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const config = await getSession();
|
|
251
|
+
if (sub === 'list') return await listTasks(config);
|
|
252
|
+
if (sub === 'new') return await newTask(config, rest.join(' ').trim());
|
|
253
|
+
if (sub === 'claim') return await claimTask(config, rest[0], via);
|
|
254
|
+
console.log(`\n Usage: phewsh task [list | new "<title>" | claim <id> [--via <harness>]]\n`);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
console.error(`\n ${red('✗')} ${err.message}\n`);
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
}
|
|
259
|
+
};
|
package/lib/supabase.js
CHANGED
|
@@ -77,6 +77,34 @@ async function upsert(table, data, accessToken) {
|
|
|
77
77
|
return res.json();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
// REST: insert rows (no upsert semantics)
|
|
81
|
+
async function insert(table, data, accessToken) {
|
|
82
|
+
const res = await req(`/rest/v1/${table}`, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Prefer': 'return=representation' },
|
|
85
|
+
body: JSON.stringify(data),
|
|
86
|
+
}, accessToken);
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
const err = await res.json().catch(() => ({}));
|
|
89
|
+
throw new Error(err.message || `INSERT ${table} failed`);
|
|
90
|
+
}
|
|
91
|
+
return res.json();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// REST: call a Postgres function (used for the atomic task transitions —
|
|
95
|
+
// claim_task, start_execution, complete_execution, open_pr, review_task).
|
|
96
|
+
async function rpc(fn, args = {}, accessToken) {
|
|
97
|
+
const res = await req(`/rest/v1/rpc/${fn}`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
body: JSON.stringify(args),
|
|
100
|
+
}, accessToken);
|
|
101
|
+
const body = await res.json().catch(() => null);
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
throw new Error(body?.message || `${fn} failed (${res.status})`);
|
|
104
|
+
}
|
|
105
|
+
return body;
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
// SAP: fire-and-forget per-inference tracking
|
|
81
109
|
// Session ID — unique per CLI invocation, enables multi-prompt correlation
|
|
82
110
|
const { randomUUID } = require('crypto');
|
|
@@ -126,4 +154,4 @@ function trackSap({ userId, source = 'cli', model, promptTokens, completionToken
|
|
|
126
154
|
}, accessToken).catch(() => { /* never surface SAP errors */ });
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
module.exports = { sendOtp, verifyOtp, refreshSession, select, upsert, trackSap, SUPABASE_URL, SUPABASE_ANON_KEY };
|
|
157
|
+
module.exports = { sendOtp, verifyOtp, refreshSession, select, upsert, insert, rpc, trackSap, SUPABASE_URL, SUPABASE_ANON_KEY };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Pure helpers for shared-project tasks (team-projects Phase 2).
|
|
2
|
+
// The command in commands/task.js does the network/git work; everything
|
|
3
|
+
// here is deterministic and unit-tested.
|
|
4
|
+
|
|
5
|
+
// "git@github.com:Owner/Repo.git" | "https://github.com/owner/repo" →
|
|
6
|
+
// "github.com/owner/repo" (lowercased) — the form stored in projects.github_remote.
|
|
7
|
+
function normalizeRemote(url) {
|
|
8
|
+
if (!url || typeof url !== 'string') return null;
|
|
9
|
+
let s = url.trim();
|
|
10
|
+
if (!s) return null;
|
|
11
|
+
s = s.replace(/^ssh:\/\//i, '');
|
|
12
|
+
s = s.replace(/^[a-z+]+:\/\//i, ''); // https://, git://
|
|
13
|
+
s = s.replace(/^git@([^:/]+)[:/]/i, '$1/'); // git@host:owner/repo → host/owner/repo
|
|
14
|
+
s = s.replace(/^[^@]+@/, ''); // any other user@ prefix
|
|
15
|
+
s = s.replace(/\.git$/i, '');
|
|
16
|
+
s = s.replace(/\/+$/, '');
|
|
17
|
+
return s.toLowerCase() || null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Deterministic branch per task: the task id is the idempotency key, so a
|
|
21
|
+
// retry after a crash reuses the same branch instead of creating a new one.
|
|
22
|
+
function taskBranch(taskId, title) {
|
|
23
|
+
const short = String(taskId).replace(/-/g, '').slice(0, 8);
|
|
24
|
+
const slug = String(title || '')
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
27
|
+
.replace(/^-+|-+$/g, '')
|
|
28
|
+
.slice(0, 40)
|
|
29
|
+
.replace(/-+$/, '') || 'task';
|
|
30
|
+
return `phewsh/${short}-${slug}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The prompt handed to the harness inside the isolated worktree.
|
|
34
|
+
function buildTaskPrompt(task) {
|
|
35
|
+
const packet = task.packet || {};
|
|
36
|
+
const objective = typeof packet.objective === 'string'
|
|
37
|
+
? packet.objective
|
|
38
|
+
: packet.objective?.task || '';
|
|
39
|
+
return [
|
|
40
|
+
`# Task: ${task.title}`,
|
|
41
|
+
'',
|
|
42
|
+
objective,
|
|
43
|
+
packet.context ? `\n## Context\n${typeof packet.context === 'string' ? packet.context : JSON.stringify(packet.context, null, 2)}` : '',
|
|
44
|
+
Array.isArray(packet.verification) && packet.verification.length
|
|
45
|
+
? `\n## Verify\n${packet.verification.map((c) => `- ${c}`).join('\n')}`
|
|
46
|
+
: '',
|
|
47
|
+
'',
|
|
48
|
+
'## Rules',
|
|
49
|
+
'- You are working in an isolated git worktree on a dedicated branch. Stay in this directory.',
|
|
50
|
+
'- Commit your changes here, but do not push, do not open PRs, and do not touch main — phewsh handles publication and review.',
|
|
51
|
+
'- Run the project\'s tests if they exist and report the result honestly.',
|
|
52
|
+
].filter(Boolean).join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The provisional record committed to .intent/work/task-<id>.json ON THE WORK
|
|
56
|
+
// BRANCH. Per the 2026-07-02 ruling it is NOT a decision: it becomes
|
|
57
|
+
// authoritative project truth only when the PR merges.
|
|
58
|
+
function proposedOutcome({ task, harnessId, host, startedAt, finishedAt }) {
|
|
59
|
+
return {
|
|
60
|
+
status: 'proposed',
|
|
61
|
+
note: 'Provisional proposed outcome — becomes authoritative project truth only when this branch\'s PR is merged.',
|
|
62
|
+
task_id: task.id,
|
|
63
|
+
title: task.title,
|
|
64
|
+
requested_by: task.created_by,
|
|
65
|
+
executed_by: { user: task.claimed_by, harness: harnessId, host },
|
|
66
|
+
verification: task.packet?.verification || null,
|
|
67
|
+
started_at: startedAt,
|
|
68
|
+
finished_at: finishedAt,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { normalizeRemote, taskBranch, buildTaskPrompt, proposedOutcome };
|