shoud-cli 1.0.11 → 3.0.1
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/shoud.js +220 -116
- package/install.sh +13 -8
- package/package.json +10 -11
- package/src/auth/credentials.js +49 -0
- package/src/auth/deviceFlow.js +89 -111
- package/src/job/baseline.js +82 -0
- package/src/job/checkpoint.js +61 -0
- package/src/job/manager.js +89 -0
- package/src/job/model.js +54 -0
- package/src/job/receipt.js +92 -0
- package/src/job/undo.js +104 -0
- package/src/project/discovery.js +93 -0
- package/src/project/ignore.js +31 -0
- package/src/project/shoudMd.js +38 -0
- package/src/runtime/agentLoop.js +281 -98
- package/src/runtime/budget.js +30 -0
- package/src/runtime/noProgress.js +38 -0
- package/src/runtime/verification.js +39 -0
- package/src/security/permissions.js +132 -0
- package/src/security/riskClassifier.js +127 -0
- package/src/security/secrets.js +57 -0
- package/src/security/shellParser.js +56 -0
- package/src/tools/files.js +81 -0
- package/src/tools/git.js +32 -0
- package/src/tools/index.js +92 -156
- package/src/tools/project.js +7 -0
- package/src/tools/search.js +49 -0
- package/src/tools/shell.js +71 -0
- package/src/ui/banner.js +20 -0
- package/src/ui/output.js +17 -0
- package/src/utils/config.js +47 -0
- package/src/utils/errors.js +21 -0
package/src/auth/deviceFlow.js
CHANGED
|
@@ -1,143 +1,121 @@
|
|
|
1
|
-
const
|
|
1
|
+
const axios = require('axios');
|
|
2
2
|
const chalk = require('chalk');
|
|
3
|
+
const open = require('open');
|
|
3
4
|
const inquirer = require('inquirer');
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
// Use a dedicated config directory
|
|
9
|
-
const CONFIG_DIR = path.join(os.homedir(), '.shoud');
|
|
10
|
-
const SESSION_FILE = path.join(CONFIG_DIR, 'session.json');
|
|
11
|
-
|
|
12
|
-
// Ensure config directory exists with secure permissions
|
|
13
|
-
if (!fs.existsSync(CONFIG_DIR)) {
|
|
14
|
-
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
15
|
-
}
|
|
5
|
+
const ora = require('ora');
|
|
6
|
+
const { loadCredentials, saveCredentials, clearCredentials } = require('./credentials');
|
|
7
|
+
const { loadSettings } = require('../utils/config');
|
|
16
8
|
|
|
17
|
-
/**
|
|
18
|
-
* Decode a JWT token (without verification) to extract payload
|
|
19
|
-
*/
|
|
20
9
|
function decodeJWT(token) {
|
|
21
10
|
try {
|
|
22
11
|
const parts = token.split('.');
|
|
23
12
|
if (parts.length !== 3) return null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} catch (_) {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
13
|
+
return JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
|
|
14
|
+
} catch (_) { return null; }
|
|
29
15
|
}
|
|
30
16
|
|
|
31
17
|
/**
|
|
32
|
-
*
|
|
18
|
+
* Proper device flow: request device code from backend, open browser,
|
|
19
|
+
* poll for completion. Falls back to manual paste if backend doesn't
|
|
20
|
+
* support device flow yet (HTTP 404).
|
|
33
21
|
*/
|
|
34
|
-
function
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
createdAt: Date.now()
|
|
41
|
-
};
|
|
22
|
+
async function login() {
|
|
23
|
+
const settings = loadSettings();
|
|
24
|
+
const apiUrl = settings.apiUrl;
|
|
25
|
+
const spinner = ora('Requesting device code...').start();
|
|
26
|
+
|
|
27
|
+
let deviceCode = null;
|
|
42
28
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
29
|
+
const res = await axios.post(`${apiUrl}/auth/device/start`, {}, { timeout: 10000 });
|
|
30
|
+
deviceCode = res.data;
|
|
31
|
+
spinner.stop();
|
|
47
32
|
} catch (err) {
|
|
48
|
-
|
|
49
|
-
|
|
33
|
+
spinner.stop();
|
|
34
|
+
// Backend not ready — fall back to manual paste
|
|
35
|
+
return manualLogin(apiUrl);
|
|
50
36
|
}
|
|
51
|
-
}
|
|
52
37
|
|
|
53
|
-
|
|
54
|
-
* Retrieve token if present and not expired
|
|
55
|
-
*/
|
|
56
|
-
function getToken() {
|
|
57
|
-
if (!fs.existsSync(SESSION_FILE)) {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
try {
|
|
61
|
-
const raw = fs.readFileSync(SESSION_FILE, 'utf-8');
|
|
62
|
-
const data = JSON.parse(raw);
|
|
63
|
-
const { token, expiresAt } = data;
|
|
38
|
+
const { user_code, verification_uri, device_code, interval = 5, expires_in = 600 } = deviceCode;
|
|
64
39
|
|
|
65
|
-
|
|
40
|
+
console.log(chalk.cyan('\nTo authenticate, open this URL in your browser:'));
|
|
41
|
+
console.log(chalk.white.bold(` ${verification_uri}`));
|
|
42
|
+
console.log(chalk.cyan('\nAnd enter this code:'));
|
|
43
|
+
console.log(chalk.white.bold(` ${user_code}\n`));
|
|
66
44
|
|
|
67
|
-
|
|
68
|
-
if (expiresAt && Date.now() >= expiresAt) {
|
|
69
|
-
// Optionally delete the stale file
|
|
70
|
-
fs.unlinkSync(SESSION_FILE);
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
45
|
+
try { await open(verification_uri); } catch (_) {}
|
|
73
46
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const payload = decodeJWT(token);
|
|
77
|
-
if (payload?.exp) {
|
|
78
|
-
const expMs = payload.exp * 1000;
|
|
79
|
-
if (Date.now() >= expMs) {
|
|
80
|
-
fs.unlinkSync(SESSION_FILE);
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
47
|
+
const pollSpinner = ora('Waiting for authentication...').start();
|
|
48
|
+
const deadline = Date.now() + expires_in * 1000;
|
|
85
49
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
await new Promise(r => setTimeout(r, interval * 1000));
|
|
52
|
+
try {
|
|
53
|
+
const res = await axios.post(`${apiUrl}/auth/device/poll`, { device_code });
|
|
54
|
+
if (res.data?.token) {
|
|
55
|
+
pollSpinner.stop();
|
|
56
|
+
const payload = decodeJWT(res.data.token) || {};
|
|
57
|
+
await saveCredentials({
|
|
58
|
+
token: res.data.token,
|
|
59
|
+
email: payload.email,
|
|
60
|
+
expiresAt: payload.exp ? payload.exp * 1000 : null,
|
|
61
|
+
createdAt: Date.now(),
|
|
62
|
+
kind: 'device',
|
|
63
|
+
});
|
|
64
|
+
console.log(chalk.green(`\n✓ Signed in as ${payload.email || 'user'}`));
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (res.data?.error === 'access_denied') {
|
|
68
|
+
pollSpinner.fail('Authorization denied.');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
} catch (_) { /* keep polling */ }
|
|
91
72
|
}
|
|
73
|
+
|
|
74
|
+
pollSpinner.fail('Device code expired. Please try again.');
|
|
75
|
+
return false;
|
|
92
76
|
}
|
|
93
77
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
*/
|
|
97
|
-
async function login() {
|
|
98
|
-
console.log(chalk.cyan('Opening browser to sign in via shoud.online...'));
|
|
78
|
+
async function manualLogin(apiUrl) {
|
|
79
|
+
console.log(chalk.cyan('Opening browser to sign in...'));
|
|
99
80
|
await open('https://shoud.online/login');
|
|
100
|
-
|
|
101
81
|
console.log(chalk.gray('\nOnce signed in, copy your session token from the browser.'));
|
|
102
82
|
|
|
103
|
-
const { token } = await inquirer.prompt([
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return 'Token cannot be empty.';
|
|
112
|
-
}
|
|
113
|
-
// Simple validation: check if it looks like a JWT (three parts)
|
|
114
|
-
const parts = input.trim().split('.');
|
|
115
|
-
if (parts.length !== 3) {
|
|
116
|
-
return 'Invalid token format. Please paste the full token from the browser.';
|
|
117
|
-
}
|
|
118
|
-
return true;
|
|
119
|
-
}
|
|
83
|
+
const { token } = await inquirer.prompt([{
|
|
84
|
+
type: 'password', name: 'token',
|
|
85
|
+
message: 'Paste your session token:',
|
|
86
|
+
mask: '*',
|
|
87
|
+
validate: (input) => {
|
|
88
|
+
if (!input?.trim()) return 'Token cannot be empty.';
|
|
89
|
+
if (input.trim().split('.').length !== 3) return 'Invalid token format.';
|
|
90
|
+
return true;
|
|
120
91
|
}
|
|
121
|
-
]);
|
|
92
|
+
}]);
|
|
93
|
+
|
|
94
|
+
const payload = decodeJWT(token.trim());
|
|
95
|
+
await saveCredentials({
|
|
96
|
+
token: token.trim(),
|
|
97
|
+
email: payload?.email,
|
|
98
|
+
expiresAt: payload?.exp ? payload.exp * 1000 : null,
|
|
99
|
+
createdAt: Date.now(),
|
|
100
|
+
kind: 'manual',
|
|
101
|
+
});
|
|
102
|
+
console.log(chalk.green('\n✓ Signed in successfully.'));
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
122
105
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
106
|
+
async function getToken() {
|
|
107
|
+
const creds = await loadCredentials();
|
|
108
|
+
if (!creds?.token) return null;
|
|
109
|
+
if (creds.expiresAt && Date.now() >= creds.expiresAt) {
|
|
110
|
+
await clearCredentials();
|
|
111
|
+
return null;
|
|
128
112
|
}
|
|
113
|
+
return creds.token;
|
|
129
114
|
}
|
|
130
115
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
*/
|
|
135
|
-
async function validateToken() {
|
|
136
|
-
const token = getToken();
|
|
137
|
-
if (!token) return false;
|
|
138
|
-
// Could make a lightweight request to backend to verify (e.g., /auth/verify)
|
|
139
|
-
// but we assume token is valid if not expired.
|
|
140
|
-
return true;
|
|
116
|
+
async function getEmail() {
|
|
117
|
+
const creds = await loadCredentials();
|
|
118
|
+
return creds?.email || null;
|
|
141
119
|
}
|
|
142
120
|
|
|
143
|
-
module.exports = { login, getToken,
|
|
121
|
+
module.exports = { login, getToken, getEmail, clearCredentials };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { execSync } = require('child_process');
|
|
2
|
+
const chalk = require('chalk');
|
|
3
|
+
|
|
4
|
+
function tryExec(cmd, opts = {}) {
|
|
5
|
+
try {
|
|
6
|
+
const out = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe', timeout: 60000, ...opts });
|
|
7
|
+
return { ok: true, output: out.trim() };
|
|
8
|
+
} catch (err) {
|
|
9
|
+
return {
|
|
10
|
+
ok: false,
|
|
11
|
+
output: (err.stdout || '').toString().trim(),
|
|
12
|
+
stderr: (err.stderr || '').toString().trim(),
|
|
13
|
+
code: err.status,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function gitState() {
|
|
19
|
+
const status = tryExec('git status --porcelain');
|
|
20
|
+
const diff = tryExec('git diff --stat');
|
|
21
|
+
const head = tryExec('git rev-parse HEAD');
|
|
22
|
+
const untracked = (status.ok ? status.output.split('\n') : [])
|
|
23
|
+
.filter(l => l.startsWith('?? '))
|
|
24
|
+
.map(l => l.slice(3).trim());
|
|
25
|
+
const modified = (status.ok ? status.output.split('\n') : [])
|
|
26
|
+
.filter(l => l.match(/^ ?M /))
|
|
27
|
+
.map(l => l.slice(3).trim());
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
head: head.ok ? head.output : null,
|
|
31
|
+
dirty: status.ok && status.output.length > 0,
|
|
32
|
+
modifiedFiles: modified,
|
|
33
|
+
untrackedFiles: untracked,
|
|
34
|
+
diffStat: diff.ok ? diff.output : '',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collect the before-state of the project. Runs verification commands
|
|
40
|
+
* (from SHOUD.md / discovery) and records pass/fail.
|
|
41
|
+
* Intentionally tolerant: a failing baseline is not an error.
|
|
42
|
+
*/
|
|
43
|
+
async function collectBaseline(project, verificationCommands, { onProgress } = {}) {
|
|
44
|
+
const baseline = {
|
|
45
|
+
collectedAt: new Date().toISOString(),
|
|
46
|
+
git: gitState(),
|
|
47
|
+
checks: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
for (const cmd of verificationCommands || []) {
|
|
51
|
+
if (onProgress) onProgress(cmd);
|
|
52
|
+
const result = tryExec(cmd, { timeout: 300000 });
|
|
53
|
+
baseline.checks.push({
|
|
54
|
+
command: cmd,
|
|
55
|
+
ok: result.ok,
|
|
56
|
+
exitCode: result.ok ? 0 : result.code,
|
|
57
|
+
summary: summarize(result),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return baseline;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function summarize(result) {
|
|
65
|
+
const text = (result.output || result.stderr || '').split('\n').slice(-20).join('\n');
|
|
66
|
+
return text.length > 2000 ? text.slice(-2000) : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatBaseline(baseline) {
|
|
70
|
+
const lines = [chalk.bold('Baseline collected')];
|
|
71
|
+
if (baseline.git.dirty) {
|
|
72
|
+
lines.push(` Git: ${baseline.git.modifiedFiles.length} modified, ${baseline.git.untrackedFiles.length} untracked`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push(' Git: clean');
|
|
75
|
+
}
|
|
76
|
+
for (const c of baseline.checks) {
|
|
77
|
+
lines.push(` ${c.ok ? chalk.green('✓') : chalk.red('✗')} ${c.command}`);
|
|
78
|
+
}
|
|
79
|
+
return lines.join('\n');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { collectBaseline, gitState, formatBaseline, tryExec };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const { paths } = require('../utils/config');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A code checkpoint is a git patch file stored inside the job directory.
|
|
8
|
+
* It captures the *delta introduced by SHOUD* so `shoud undo` can reverse it
|
|
9
|
+
* without touching pre-existing user modifications.
|
|
10
|
+
*/
|
|
11
|
+
function createCheckpoint(jobId, projectRoot) {
|
|
12
|
+
const dir = paths.jobDir(jobId);
|
|
13
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
14
|
+
|
|
15
|
+
const beforePath = path.join(dir, 'before.patch');
|
|
16
|
+
const headPath = path.join(dir, 'head.txt');
|
|
17
|
+
const untrackedPath = path.join(dir, 'untracked.json');
|
|
18
|
+
|
|
19
|
+
const run = (cmd) => {
|
|
20
|
+
try { return execSync(cmd, { cwd: projectRoot, encoding: 'utf-8', stdio: 'pipe' }).trim(); }
|
|
21
|
+
catch (err) { return (err.stdout || '').toString().trim(); }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const head = run('git rev-parse HEAD');
|
|
25
|
+
fs.writeFileSync(headPath, head, { mode: 0o600 });
|
|
26
|
+
|
|
27
|
+
// Save tracked changes as a patch
|
|
28
|
+
const diff = run('git diff HEAD');
|
|
29
|
+
fs.writeFileSync(beforePath, diff, { mode: 0o600 });
|
|
30
|
+
|
|
31
|
+
// Save list of untracked files so we know what to remove on undo
|
|
32
|
+
const status = run('git status --porcelain');
|
|
33
|
+
const untracked = status.split('\n')
|
|
34
|
+
.filter(l => l.startsWith('?? '))
|
|
35
|
+
.map(l => l.slice(3).trim());
|
|
36
|
+
fs.writeFileSync(untrackedPath, JSON.stringify(untracked, null, 2), { mode: 0o600 });
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
head,
|
|
40
|
+
beforePatch: beforePath,
|
|
41
|
+
untrackedList: untrackedPath,
|
|
42
|
+
hadUserChanges: diff.length > 0 || untracked.length > 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Captures the state *after* SHOUD's work, as a patch we can apply/invert.
|
|
48
|
+
*/
|
|
49
|
+
function finalizeCheckpoint(jobId, projectRoot) {
|
|
50
|
+
const dir = paths.jobDir(jobId);
|
|
51
|
+
const afterPath = path.join(dir, 'after.patch');
|
|
52
|
+
const run = (cmd) => {
|
|
53
|
+
try { return execSync(cmd, { cwd: projectRoot, encoding: 'utf-8', stdio: 'pipe' }).trim(); }
|
|
54
|
+
catch (err) { return (err.stdout || '').toString().trim(); }
|
|
55
|
+
};
|
|
56
|
+
const diff = run('git diff HEAD');
|
|
57
|
+
fs.writeFileSync(afterPath, diff, { mode: 0o600 });
|
|
58
|
+
return { afterPatch: afterPath };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { createCheckpoint, finalizeCheckpoint };
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { paths, initDirs } = require('../utils/config');
|
|
4
|
+
const { createJob, JobStatus } = require('./model');
|
|
5
|
+
|
|
6
|
+
initDirs();
|
|
7
|
+
|
|
8
|
+
function jobExists(id) {
|
|
9
|
+
return fs.existsSync(paths.jobDir(id));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function saveJob(job) {
|
|
13
|
+
const dir = paths.jobDir(job.id);
|
|
14
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
15
|
+
|
|
16
|
+
// state.json holds the structured job (no messages, no actions)
|
|
17
|
+
const { messages, actions, ...state } = job;
|
|
18
|
+
fs.writeFileSync(paths.jobFile(job.id, 'state.json'),
|
|
19
|
+
JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
20
|
+
|
|
21
|
+
if (messages) {
|
|
22
|
+
fs.writeFileSync(paths.jobFile(job.id, 'messages.json'),
|
|
23
|
+
JSON.stringify(messages, null, 2), { mode: 0o600 });
|
|
24
|
+
}
|
|
25
|
+
return job;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function appendAction(jobId, action) {
|
|
29
|
+
const file = paths.jobFile(jobId, 'actions.jsonl');
|
|
30
|
+
fs.appendFileSync(file, JSON.stringify(action) + '\n', { mode: 0o600 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function appendMessage(jobId, message) {
|
|
34
|
+
const file = paths.jobFile(jobId, 'messages.jsonl');
|
|
35
|
+
fs.appendFileSync(file, JSON.stringify(message) + '\n', { mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function loadJob(id) {
|
|
39
|
+
const dir = paths.jobDir(id);
|
|
40
|
+
if (!fs.existsSync(dir)) return null;
|
|
41
|
+
|
|
42
|
+
const stateRaw = fs.readFileSync(path.join(dir, 'state.json'), 'utf-8');
|
|
43
|
+
const state = JSON.parse(stateRaw);
|
|
44
|
+
|
|
45
|
+
let messages = [];
|
|
46
|
+
const msgFile = path.join(dir, 'messages.json');
|
|
47
|
+
if (fs.existsSync(msgFile)) {
|
|
48
|
+
try { messages = JSON.parse(fs.readFileSync(msgFile, 'utf-8')); } catch (_) { messages = []; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let actions = [];
|
|
52
|
+
const actFile = path.join(dir, 'actions.jsonl');
|
|
53
|
+
if (fs.existsSync(actFile)) {
|
|
54
|
+
actions = fs.readFileSync(actFile, 'utf-8')
|
|
55
|
+
.split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ...state, messages, actions };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function listJobs() {
|
|
62
|
+
if (!fs.existsSync(paths.JOBS_DIR)) return [];
|
|
63
|
+
return fs.readdirSync(paths.JOBS_DIR)
|
|
64
|
+
.filter(name => name.startsWith('job_'))
|
|
65
|
+
.map(id => {
|
|
66
|
+
try {
|
|
67
|
+
const s = JSON.parse(fs.readFileSync(paths.jobFile(id, 'state.json'), 'utf-8'));
|
|
68
|
+
return {
|
|
69
|
+
id, prompt: s.prompt, status: s.status,
|
|
70
|
+
startedAt: s.startedAt, completedAt: s.completedAt,
|
|
71
|
+
spent: s.spent,
|
|
72
|
+
};
|
|
73
|
+
} catch { return null; }
|
|
74
|
+
})
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.sort((a, b) => (b.startedAt || '').localeCompare(a.startedAt || ''));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findResumableJob() {
|
|
80
|
+
return listJobs().find(j => ![
|
|
81
|
+
JobStatus.VERIFIED, JobStatus.FAILED, JobStatus.CANCELLED,
|
|
82
|
+
JobStatus.BLOCKED, JobStatus.BUDGET_EXCEEDED
|
|
83
|
+
].includes(j.status));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
saveJob, loadJob, listJobs, findResumableJob,
|
|
88
|
+
appendAction, appendMessage, jobExists,
|
|
89
|
+
};
|
package/src/job/model.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
3
|
+
const JobStatus = Object.freeze({
|
|
4
|
+
PENDING: 'pending',
|
|
5
|
+
DISCOVERING: 'discovering',
|
|
6
|
+
BASELINING: 'baselining',
|
|
7
|
+
RUNNING: 'running',
|
|
8
|
+
VERIFYING: 'verifying',
|
|
9
|
+
REVIEWING: 'reviewing',
|
|
10
|
+
VERIFIED: 'verified',
|
|
11
|
+
IMPLEMENTED_NOT_VERIFIED: 'implemented_not_verified',
|
|
12
|
+
BLOCKED: 'blocked',
|
|
13
|
+
FAILED: 'failed',
|
|
14
|
+
CANCELLED: 'cancelled',
|
|
15
|
+
BUDGET_EXCEEDED: 'budget_exceeded',
|
|
16
|
+
PERMISSION_DENIED: 'permission_denied',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function newJobId() {
|
|
20
|
+
return 'job_' + crypto.randomBytes(4).toString('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createJob({ prompt, projectRoot, budget, verification }) {
|
|
24
|
+
return {
|
|
25
|
+
id: newJobId(),
|
|
26
|
+
version: 2,
|
|
27
|
+
prompt,
|
|
28
|
+
status: JobStatus.PENDING,
|
|
29
|
+
startedAt: new Date().toISOString(),
|
|
30
|
+
completedAt: null,
|
|
31
|
+
projectRoot,
|
|
32
|
+
budget: budget ?? 2.0,
|
|
33
|
+
spent: 0,
|
|
34
|
+
project: null, // filled by discovery
|
|
35
|
+
baseline: null, // filled by baseline
|
|
36
|
+
plan: [],
|
|
37
|
+
messages: [],
|
|
38
|
+
actions: [], // one entry per tool execution
|
|
39
|
+
changedFiles: [],
|
|
40
|
+
verification: {
|
|
41
|
+
commands: verification || [],
|
|
42
|
+
results: [],
|
|
43
|
+
},
|
|
44
|
+
permissions: {
|
|
45
|
+
persistentAllow: { commands: [], paths: [] },
|
|
46
|
+
sessionAllow: { commands: [], paths: [] },
|
|
47
|
+
},
|
|
48
|
+
checkpoint: null, // path to git patch or stash ref
|
|
49
|
+
completion: null, // final receipt object
|
|
50
|
+
error: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = { JobStatus, newJobId, createJob };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const { JobStatus } = require('./model');
|
|
3
|
+
|
|
4
|
+
const STATUS_LABEL = {
|
|
5
|
+
[JobStatus.VERIFIED]: chalk.green('✓ VERIFIED'),
|
|
6
|
+
[JobStatus.IMPLEMENTED_NOT_VERIFIED]: chalk.yellow('⚠ IMPLEMENTED (not verified)'),
|
|
7
|
+
[JobStatus.BLOCKED]: chalk.yellow('■ BLOCKED'),
|
|
8
|
+
[JobStatus.FAILED]: chalk.red('✗ FAILED'),
|
|
9
|
+
[JobStatus.CANCELLED]: chalk.gray('· CANCELLED'),
|
|
10
|
+
[JobStatus.BUDGET_EXCEEDED]: chalk.magenta('$ BUDGET EXCEEDED'),
|
|
11
|
+
[JobStatus.PERMISSION_DENIED]: chalk.red('✗ PERMISSION DENIED'),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function delta(before, after) {
|
|
15
|
+
if (!before || !after) return '';
|
|
16
|
+
if (before === after) return chalk.gray(`${before} → ${after}`);
|
|
17
|
+
const improved = isImprovement(before, after);
|
|
18
|
+
const arrow = improved ? chalk.green : chalk.red;
|
|
19
|
+
return `${chalk.gray(before)} ${arrow('→')} ${arrow(after)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isImprovement(before, after) {
|
|
23
|
+
// Heuristic: "pass", "0 errors", "✓" are good
|
|
24
|
+
const good = /^(0|pass|passed|ok|✓|clean)/i.test(String(after));
|
|
25
|
+
const bad = /^(fail|failed|error|✗|/i.test(String(before)) && /^\d+/.test(String(before));
|
|
26
|
+
return good || bad;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatReceipt(job) {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const W = 60;
|
|
32
|
+
const bar = '─'.repeat(W);
|
|
33
|
+
|
|
34
|
+
lines.push(chalk.bold(bar));
|
|
35
|
+
lines.push(chalk.bold(`SHOUD ${STATUS_LABEL[job.status] || job.status} ${chalk.gray(job.id)}`));
|
|
36
|
+
lines.push('');
|
|
37
|
+
lines.push(chalk.white(job.prompt));
|
|
38
|
+
lines.push('');
|
|
39
|
+
|
|
40
|
+
// Results
|
|
41
|
+
if (job.baseline && job.baseline.checks?.length) {
|
|
42
|
+
lines.push(chalk.bold('RESULT'));
|
|
43
|
+
for (const b of job.baseline.checks) {
|
|
44
|
+
const after = job.verification.results.find(r => r.command === b.command);
|
|
45
|
+
const beforeStr = b.ok ? 'pass' : 'fail';
|
|
46
|
+
const afterStr = after ? (after.ok ? 'pass' : 'fail') : '—';
|
|
47
|
+
lines.push(` ${b.command.padEnd(30)} ${delta(beforeStr, afterStr)}`);
|
|
48
|
+
}
|
|
49
|
+
lines.push('');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Changes
|
|
53
|
+
const changed = job.changedFiles || [];
|
|
54
|
+
if (changed.length) {
|
|
55
|
+
lines.push(chalk.bold('CHANGES'));
|
|
56
|
+
lines.push(` ${changed.length} file${changed.length === 1 ? '' : 's'}`);
|
|
57
|
+
for (const f of changed.slice(0, 12)) lines.push(chalk.gray(` ${f}`));
|
|
58
|
+
if (changed.length > 12) lines.push(chalk.gray(` ... and ${changed.length - 12} more`));
|
|
59
|
+
lines.push('');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Verification
|
|
63
|
+
if (job.verification.results.length) {
|
|
64
|
+
lines.push(chalk.bold('VERIFICATION'));
|
|
65
|
+
for (const r of job.verification.results) {
|
|
66
|
+
lines.push(` ${r.ok ? chalk.green('✓') : chalk.red('✗')} ${r.command}`);
|
|
67
|
+
}
|
|
68
|
+
lines.push('');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Safety
|
|
72
|
+
const safety = [];
|
|
73
|
+
if (job.baseline?.git?.modifiedFiles?.length) safety.push('Pre-existing changes preserved');
|
|
74
|
+
safety.push('No external writes performed');
|
|
75
|
+
lines.push(chalk.bold('SAFETY'));
|
|
76
|
+
for (const s of safety) lines.push(` ${chalk.green('✓')} ${s}`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
|
|
79
|
+
// Time and cost
|
|
80
|
+
const elapsed = job.completedAt
|
|
81
|
+
? ((new Date(job.completedAt) - new Date(job.startedAt)) / 1000).toFixed(0) + 's'
|
|
82
|
+
: '—';
|
|
83
|
+
lines.push(chalk.bold('TIME ') + chalk.gray(elapsed) +
|
|
84
|
+
' ' + chalk.bold('COST ') + chalk.gray(`$${(job.spent || 0).toFixed(4)}`));
|
|
85
|
+
lines.push('');
|
|
86
|
+
lines.push(chalk.gray(`Undo with: shoud undo ${job.id}`));
|
|
87
|
+
lines.push(chalk.bold(bar));
|
|
88
|
+
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { formatReceipt };
|