shoud-cli 1.0.11 → 3.0.2
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/context/checkpoint.js +0 -82
- package/src/permissions/engine.js +0 -133
package/src/job/undo.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const chalk = require('chalk');
|
|
5
|
+
const { paths } = require('../utils/config');
|
|
6
|
+
const { loadJob, listJobs } = require('./manager');
|
|
7
|
+
|
|
8
|
+
function run(cmd, cwd) {
|
|
9
|
+
try { return { ok: true, out: execSync(cmd, { cwd, encoding: 'utf-8', stdio: 'pipe' }).trim() }; }
|
|
10
|
+
catch (err) { return { ok: false, out: (err.stdout || '').toString(), err: (err.stderr || '').toString(), code: err.status }; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Undo a job by reversing SHOUD's changes only.
|
|
15
|
+
*
|
|
16
|
+
* Strategy:
|
|
17
|
+
* 1. Reset the working tree to the pre-job state (apply `before.patch`).
|
|
18
|
+
* 2. Re-apply the user's pre-existing changes by applying `before.patch`
|
|
19
|
+
* on top of HEAD... actually we do the inverse: we reverse the `after.patch`
|
|
20
|
+
* against the current tree, which reverts only SHOUD's work.
|
|
21
|
+
*
|
|
22
|
+
* Simpler & safer:
|
|
23
|
+
* - `git checkout -- <files>` is too aggressive.
|
|
24
|
+
* - Instead: compute diff between before.patch and after.patch → that's SHOUD's delta.
|
|
25
|
+
* - Reverse-apply that delta. Since before.patch already contains user changes,
|
|
26
|
+
* reversing the delta won't disturb them.
|
|
27
|
+
*/
|
|
28
|
+
function undoJob(jobId) {
|
|
29
|
+
const job = loadJob(jobId);
|
|
30
|
+
if (!job) throw new Error(`Job ${jobId} not found.`);
|
|
31
|
+
|
|
32
|
+
const dir = paths.jobDir(jobId);
|
|
33
|
+
const beforePath = path.join(dir, 'before.patch');
|
|
34
|
+
const afterPath = path.join(dir, 'after.patch');
|
|
35
|
+
const untrackedPath = path.join(dir, 'untracked.json');
|
|
36
|
+
|
|
37
|
+
if (!fs.existsSync(afterPath)) {
|
|
38
|
+
throw new Error('No final checkpoint for this job. Cannot undo safely.');
|
|
39
|
+
}
|
|
40
|
+
if (!fs.existsSync(beforePath)) {
|
|
41
|
+
throw new Error('No initial checkpoint. Cannot undo safely.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cwd = job.projectRoot;
|
|
45
|
+
if (!fs.existsSync(cwd)) throw new Error(`Project root missing: ${cwd}`);
|
|
46
|
+
|
|
47
|
+
// Revert SHOUD's changes: reset tracked files to `after.patch` inverse.
|
|
48
|
+
// Easiest correct approach:
|
|
49
|
+
// 1. Restore files to the current HEAD state (git checkout -- .)
|
|
50
|
+
// 2. Apply before.patch → restores user's original uncommitted changes
|
|
51
|
+
// 3. Remove SHOUD-created untracked files (those not in the original untracked list)
|
|
52
|
+
|
|
53
|
+
const originalUntracked = fs.existsSync(untrackedPath)
|
|
54
|
+
? JSON.parse(fs.readFileSync(untrackedPath, 'utf-8'))
|
|
55
|
+
: [];
|
|
56
|
+
|
|
57
|
+
// Step 1: hard reset tracked files to HEAD (safe — we have before.patch)
|
|
58
|
+
run('git checkout -- .', cwd);
|
|
59
|
+
|
|
60
|
+
// Step 2: re-apply the user's original changes
|
|
61
|
+
if (fs.existsSync(beforePath) && fs.readFileSync(beforePath, 'utf-8').trim()) {
|
|
62
|
+
const apply = run(`git apply --whitespace=nowarn "${beforePath}"`, cwd);
|
|
63
|
+
if (!apply.ok) {
|
|
64
|
+
throw new Error(`Failed to restore user changes: ${apply.err || apply.out}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Step 3: remove untracked files that SHOUD created
|
|
69
|
+
const currentStatus = run('git status --porcelain', cwd);
|
|
70
|
+
if (currentStatus.ok) {
|
|
71
|
+
const nowUntracked = currentStatus.out.split('\n')
|
|
72
|
+
.filter(l => l.startsWith('?? '))
|
|
73
|
+
.map(l => l.slice(3).trim());
|
|
74
|
+
const created = nowUntracked.filter(f => !originalUntracked.includes(f));
|
|
75
|
+
for (const f of created) {
|
|
76
|
+
try {
|
|
77
|
+
const full = path.join(cwd, f);
|
|
78
|
+
if (fs.existsSync(full)) fs.rmSync(full, { recursive: true, force: true });
|
|
79
|
+
} catch (_) {}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Mark job as undone
|
|
84
|
+
job.status = 'cancelled';
|
|
85
|
+
job.completedAt = new Date().toISOString();
|
|
86
|
+
job.error = 'Undone by user';
|
|
87
|
+
fs.writeFileSync(path.join(dir, 'state.json'),
|
|
88
|
+
JSON.stringify({ ...job, messages: undefined, actions: undefined }, null, 2),
|
|
89
|
+
{ mode: 0o600 });
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
reverted: true,
|
|
93
|
+
preservedUserChanges: originalUntracked.length > 0 || fs.readFileSync(beforePath, 'utf-8').length > 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function undoLatest() {
|
|
98
|
+
const jobs = listJobs();
|
|
99
|
+
const target = jobs.find(j => ['verified', 'implemented_not_verified', 'failed', 'blocked'].includes(j.status));
|
|
100
|
+
if (!target) throw new Error('No completed job available to undo.');
|
|
101
|
+
return { job: target, result: undoJob(target.id) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { undoJob, undoLatest };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const SIGNALS = [
|
|
5
|
+
{ file: 'package.json', lang: 'JavaScript/TypeScript', check: detectNode },
|
|
6
|
+
{ file: 'pyproject.toml', lang: 'Python' },
|
|
7
|
+
{ file: 'requirements.txt', lang: 'Python' },
|
|
8
|
+
{ file: 'Cargo.toml', lang: 'Rust' },
|
|
9
|
+
{ file: 'go.mod', lang: 'Go' },
|
|
10
|
+
{ file: 'pom.xml', lang: 'Java' },
|
|
11
|
+
{ file: 'build.gradle', lang: 'Java/Kotlin' },
|
|
12
|
+
{ file: 'Gemfile', lang: 'Ruby' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function detectNode(root) {
|
|
16
|
+
const pkgPath = path.join(root, 'package.json');
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
18
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
19
|
+
|
|
20
|
+
let framework = 'Node';
|
|
21
|
+
if (deps.next) framework = 'Next.js';
|
|
22
|
+
else if (deps.react) framework = 'React';
|
|
23
|
+
else if (deps.vue) framework = 'Vue';
|
|
24
|
+
else if (deps.svelte) framework = 'Svelte';
|
|
25
|
+
else if (deps.express) framework = 'Express';
|
|
26
|
+
else if (deps.fastify) framework = 'Fastify';
|
|
27
|
+
|
|
28
|
+
let pm = 'npm';
|
|
29
|
+
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) pm = 'pnpm';
|
|
30
|
+
else if (fs.existsSync(path.join(root, 'yarn.lock'))) pm = 'yarn';
|
|
31
|
+
else if (fs.existsSync(path.join(root, 'bun.lockb'))) pm = 'bun';
|
|
32
|
+
|
|
33
|
+
let test = null;
|
|
34
|
+
if (deps.vitest) test = 'vitest';
|
|
35
|
+
else if (deps.jest) test = 'jest';
|
|
36
|
+
else if (deps.mocha) test = 'mocha';
|
|
37
|
+
else if (pkg.scripts?.test) test = pm + ' test';
|
|
38
|
+
|
|
39
|
+
const scripts = pkg.scripts || {};
|
|
40
|
+
const verification = [];
|
|
41
|
+
if (scripts.lint) verification.push(`${pm} run lint`);
|
|
42
|
+
if (scripts.typecheck) verification.push(`${pm} run typecheck`);
|
|
43
|
+
if (scripts.test) verification.push(`${pm} test`);
|
|
44
|
+
if (scripts.build) verification.push(`${pm} run build`);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
framework,
|
|
48
|
+
language: 'TypeScript',
|
|
49
|
+
packageManager: pm,
|
|
50
|
+
testing: test,
|
|
51
|
+
scripts: Object.keys(scripts),
|
|
52
|
+
verification,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function discoverProject(root) {
|
|
57
|
+
const signals = [];
|
|
58
|
+
let primary = null;
|
|
59
|
+
|
|
60
|
+
for (const s of SIGNALS) {
|
|
61
|
+
if (fs.existsSync(path.join(root, s.file))) {
|
|
62
|
+
signals.push(s.file);
|
|
63
|
+
if (s.check) primary = s.check(root);
|
|
64
|
+
else primary = primary || { language: s.lang };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!primary) primary = { language: 'Unknown' };
|
|
69
|
+
|
|
70
|
+
const isGit = fs.existsSync(path.join(root, '.git'));
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
root,
|
|
74
|
+
signals,
|
|
75
|
+
...primary,
|
|
76
|
+
git: isGit,
|
|
77
|
+
verification: primary.verification || [],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatProject(project) {
|
|
82
|
+
const rows = [
|
|
83
|
+
['Language', project.language],
|
|
84
|
+
['Framework', project.framework],
|
|
85
|
+
['Package mgr', project.packageManager],
|
|
86
|
+
['Testing', project.testing],
|
|
87
|
+
['Git', project.git ? 'yes' : 'no'],
|
|
88
|
+
['Verify', (project.verification || []).join(', ') || '—'],
|
|
89
|
+
];
|
|
90
|
+
return rows.filter(([, v]) => v).map(([k, v]) => `${k.padEnd(14)} ${v}`).join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { discoverProject, formatProject };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const ignore = require('ignore');
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PATTERNS = [
|
|
6
|
+
'.env', '.env.*',
|
|
7
|
+
'*.pem', '*.key',
|
|
8
|
+
'secrets/', 'node_modules/',
|
|
9
|
+
'dist/', 'build/', 'coverage/',
|
|
10
|
+
'.git/',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function loadIgnore(root) {
|
|
14
|
+
const ig = ignore();
|
|
15
|
+
ig.add(DEFAULT_PATTERNS);
|
|
16
|
+
|
|
17
|
+
const file = path.join(root, '.shoudignore');
|
|
18
|
+
if (fs.existsSync(file)) {
|
|
19
|
+
ig.add(fs.readFileSync(file, 'utf-8'));
|
|
20
|
+
}
|
|
21
|
+
return ig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isIgnored(root, filePath, ig = null) {
|
|
25
|
+
const manager = ig || loadIgnore(root);
|
|
26
|
+
const rel = path.relative(root, filePath);
|
|
27
|
+
if (!rel || rel.startsWith('..')) return false;
|
|
28
|
+
return manager.ignores(rel);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { loadIgnore, isIgnored };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loads SHOUD.md from the project root (and optionally nested dirs).
|
|
6
|
+
* Returns { rules: string, verification: string[] }.
|
|
7
|
+
*
|
|
8
|
+
* In SHOUD.md, anything under a heading like "## Verification" or after a
|
|
9
|
+
* line "Before completing:" is parsed as a command list.
|
|
10
|
+
*/
|
|
11
|
+
function loadShoudMd(root) {
|
|
12
|
+
const file = path.join(root, 'SHOUD.md');
|
|
13
|
+
if (!fs.existsSync(file)) return { rules: '', verification: [], source: null };
|
|
14
|
+
|
|
15
|
+
const raw = fs.readFileSync(file, 'utf-8');
|
|
16
|
+
const rules = raw.trim();
|
|
17
|
+
|
|
18
|
+
// Extract bullet lines after "Before completing:" or under "## Verification"
|
|
19
|
+
const verification = [];
|
|
20
|
+
const lines = raw.split('\n');
|
|
21
|
+
let capturing = false;
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
const t = line.trim();
|
|
24
|
+
if (/^#{1,6}\s+verification/i.test(t) || /^before completing[:.]?$/i.test(t)) {
|
|
25
|
+
capturing = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (capturing && /^#{1,6}\s+/.test(t)) { capturing = false; continue; }
|
|
29
|
+
if (capturing && /^[-*]\s+/.test(t)) {
|
|
30
|
+
const cmd = t.replace(/^[-*]\s+/, '').replace(/`/g, '').trim();
|
|
31
|
+
if (cmd) verification.push(cmd);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { rules, verification, source: file };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { loadShoudMd };
|