@aipper/aiws 0.0.28 → 0.0.29
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/README.md +18 -2
- package/package.json +2 -2
- package/src/cli.js +125 -12
- package/src/commands/change-advice.js +8 -27
- package/src/commands/change-finish.js +16 -87
- package/src/commands/change-next-command.js +5 -1
- package/src/commands/change-review-gates.js +16 -0
- package/src/commands/change-start.js +0 -35
- package/src/commands/change-status-command.js +4 -13
- package/src/commands/change-status-context.js +6 -35
- package/src/commands/change.js +194 -452
- package/src/commands/init.js +8 -0
- package/src/commands/opencode-auto.js +167 -0
- package/src/commands/opencode-status.js +44 -2
- package/src/commands/opencode-supervise.js +200 -0
- package/src/commands/update.js +17 -5
- package/src/commands/validate.js +7 -1
- package/src/commands/ws-commit.js +44 -0
- package/src/commands/ws-deliver.js +67 -0
- package/src/commands/ws-finish.js +83 -0
- package/src/commands/ws-handoff.js +40 -0
- package/src/commands/ws-migrate.js +41 -0
- package/src/commands/ws-plan-verify.js +44 -0
- package/src/commands/ws-pull.js +30 -0
- package/src/commands/ws-push.js +71 -0
- package/src/commands/ws-submodule-setup.js +65 -0
- package/src/commands/ws-verify-before-complete.js +39 -0
- package/src/manifest.js +25 -1
- package/src/opencode-env.js +132 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* aiws finish — ff-merge + push + archive
|
|
7
|
+
* @param {{ cwd?: string, changeId?: string, into?: string, push?: boolean, remote?: string }} opts
|
|
8
|
+
*/
|
|
9
|
+
export async function wsFinishCommand({ cwd = ".", changeId, into, push = false, remote = "origin" } = {}) {
|
|
10
|
+
checkClean(cwd);
|
|
11
|
+
|
|
12
|
+
if (!changeId) {
|
|
13
|
+
changeId = detectChangeBranch(cwd);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (!changeId) {
|
|
17
|
+
throw new UserError("Could not detect change ID. Provide as argument or be on a change branch.", { exitCode: 2 });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const targetBranch = into || "main";
|
|
21
|
+
|
|
22
|
+
// Validate before complete
|
|
23
|
+
try {
|
|
24
|
+
execSync(`npx -y @aipper/aiws change validate ${changeId} --strict 2>/dev/null`, { cwd, stdio: "inherit" });
|
|
25
|
+
} catch {
|
|
26
|
+
throw new UserError(`Validation failed for ${changeId}. Fix issues first.`, { exitCode: 1 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// FF merge
|
|
30
|
+
const changeBranch = `change/${changeId}`;
|
|
31
|
+
const branchExists = execSync(`git branch --list '${changeBranch}'`, { cwd, encoding: "utf8" }).trim();
|
|
32
|
+
if (branchExists) {
|
|
33
|
+
console.log(`info: ff-merging ${changeBranch} → ${targetBranch}...`);
|
|
34
|
+
execSync(`git checkout ${targetBranch}`, { cwd, stdio: "inherit" });
|
|
35
|
+
execSync(`git merge --ff-only ${changeBranch}`, { cwd, stdio: "inherit" });
|
|
36
|
+
} else {
|
|
37
|
+
console.log("info: no change branch found, using current branch");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Push
|
|
41
|
+
if (push) {
|
|
42
|
+
const currentBranch = execSync("git branch --show-current", { cwd, encoding: "utf8" }).trim();
|
|
43
|
+
|
|
44
|
+
// Push submodules first
|
|
45
|
+
if (existsSync(`${cwd}/.gitmodules`)) {
|
|
46
|
+
const targetsFile = `changes/${changeId}/submodules.targets`;
|
|
47
|
+
if (existsSync(targetsFile)) {
|
|
48
|
+
const content = execSync(`cat "${targetsFile}"`, { cwd, encoding: "utf8" });
|
|
49
|
+
for (const line of content.split("\n")) {
|
|
50
|
+
const t = line.trim();
|
|
51
|
+
if (!t || t.startsWith("#")) continue;
|
|
52
|
+
const [subPath, targetBranch] = t.split(/\s+/);
|
|
53
|
+
if (subPath && targetBranch) {
|
|
54
|
+
console.log(`info: pushing submodule ${subPath}...`);
|
|
55
|
+
execSync(`git push ${remote} ${targetBranch}`, { cwd: `${cwd}/${subPath}`, stdio: "inherit" });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
execSync(`git push ${remote} ${currentBranch}`, { cwd, stdio: "inherit" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Archive
|
|
65
|
+
try {
|
|
66
|
+
execSync(`npx -y @aipper/aiws change archive ${changeId} --force 2>/dev/null || aiws change archive ${changeId} --force 2>/dev/null || true`, { cwd, stdio: "inherit" });
|
|
67
|
+
} catch { /* ignore archive failure — main flow done */ }
|
|
68
|
+
|
|
69
|
+
console.log(`ok: finish complete. ${push ? "Pushed and " : ""}Archived.`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkClean(cwd) {
|
|
73
|
+
const out = execSync("git status --porcelain", { cwd, encoding: "utf8" }).trim();
|
|
74
|
+
if (out) {
|
|
75
|
+
throw new UserError("Working tree is not clean. Commit or stash first.", { exitCode: 2 });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectChangeBranch(cwd) {
|
|
80
|
+
const branch = execSync("git branch --show-current", { cwd, encoding: "utf8" }).trim();
|
|
81
|
+
const match = branch.match(/^(?:change|changes|ws|ws-change)\/(.+)$/);
|
|
82
|
+
return match ? match[1] : null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* aiws handoff — read/display handoff documents
|
|
5
|
+
* @param {{ cwd?: string }} opts
|
|
6
|
+
*/
|
|
7
|
+
export async function wsHandoffCommand({ cwd = "." } = {}) {
|
|
8
|
+
const changesDir = `${cwd}/changes`;
|
|
9
|
+
if (!existsSync(changesDir)) {
|
|
10
|
+
console.log("info: no changes/ directory found");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check archive handoffs first
|
|
15
|
+
const archiveDir = `${changesDir}/archive`;
|
|
16
|
+
if (existsSync(archiveDir)) {
|
|
17
|
+
const archives = readdirSync(archiveDir).filter(f => f !== ".DS_Store");
|
|
18
|
+
for (const a of archives) {
|
|
19
|
+
const hf = `${archiveDir}/${a}/handoff.md`;
|
|
20
|
+
if (existsSync(hf)) {
|
|
21
|
+
console.log(`=== ${a} ===`);
|
|
22
|
+
console.log(readFileSync(hf, "utf8"));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check active change handoffs
|
|
28
|
+
const changes = readdirSync(changesDir).filter(f =>
|
|
29
|
+
!f.startsWith(".") && f !== "templates" && f !== "archive" && f !== "README.md"
|
|
30
|
+
);
|
|
31
|
+
for (const c of changes) {
|
|
32
|
+
const hf = `${changesDir}/${c}/handoff.md`;
|
|
33
|
+
if (existsSync(hf)) {
|
|
34
|
+
console.log(`=== ${c} (active) ===`);
|
|
35
|
+
console.log(readFileSync(hf, "utf8"));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log("info: handoffs are generated by aiws change finish --push");
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* aiws migrate — workspace migration (init or update)
|
|
6
|
+
* @param {{ cwd?: string }} opts
|
|
7
|
+
*/
|
|
8
|
+
export async function wsMigrateCommand({ cwd = "." } = {}) {
|
|
9
|
+
const localAiws = `${cwd}/node_modules/.bin/aiws`;
|
|
10
|
+
|
|
11
|
+
if (existsSync(`${cwd}/.aiws/manifest.json`)) {
|
|
12
|
+
console.log("info: workspace detected, running update...");
|
|
13
|
+
runAiws(localAiws, `update ${cwd}`);
|
|
14
|
+
} else {
|
|
15
|
+
console.log("info: no workspace detected, running init...");
|
|
16
|
+
runAiws(localAiws, `init ${cwd}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Enable hooks
|
|
20
|
+
try {
|
|
21
|
+
execSync("git config core.hooksPath .githooks", { cwd, stdio: "inherit" });
|
|
22
|
+
console.log("info: hooks enabled (core.hooksPath=.githooks)");
|
|
23
|
+
} catch {
|
|
24
|
+
console.log("warn: could not set hooks (not a git repo?)");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
console.log("ok: migrate complete");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runAiws(localAiws, args) {
|
|
31
|
+
try {
|
|
32
|
+
if (existsSync(localAiws)) {
|
|
33
|
+
execSync(`${localAiws} ${args}`, { stdio: "inherit" });
|
|
34
|
+
} else {
|
|
35
|
+
execSync(`npx -y @aipper/aiws ${args}`, { stdio: "inherit" });
|
|
36
|
+
}
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(`error: aiws ${args} failed`);
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* aiws plan-verify — check plan completeness
|
|
7
|
+
* @param {{ cwd?: string }} opts
|
|
8
|
+
*/
|
|
9
|
+
export async function wsPlanVerifyCommand({ cwd = "." } = {}) {
|
|
10
|
+
const planDir = `${cwd}/plan`;
|
|
11
|
+
if (!existsSync(planDir)) {
|
|
12
|
+
throw new UserError("No plan/ directory found", { exitCode: 2 });
|
|
13
|
+
}
|
|
14
|
+
const plans = readdirSync(planDir).filter(f => f.endsWith(".md"));
|
|
15
|
+
if (plans.length === 0) {
|
|
16
|
+
throw new UserError("No plan files found in plan/", { exitCode: 2 });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const failures = [];
|
|
20
|
+
for (const planFile of plans) {
|
|
21
|
+
const content = readFileSync(`${planDir}/${planFile}`, "utf8");
|
|
22
|
+
const checks = [];
|
|
23
|
+
|
|
24
|
+
checks.push({ name: "Goal", pass: /^##?\s*Goal/im.test(content) });
|
|
25
|
+
checks.push({ name: "Non-goals", pass: /^##?\s*Non-goals/im.test(content) });
|
|
26
|
+
checks.push({ name: "Scope", pass: /^##?\s*(Scope|In Scope)/im.test(content) });
|
|
27
|
+
checks.push({ name: "Verify", pass: /^##?\s*Verify/im.test(content) });
|
|
28
|
+
checks.push({ name: "Risks", pass: /^##?\s*Risks/im.test(content) });
|
|
29
|
+
|
|
30
|
+
const failed = checks.filter(c => !c.pass);
|
|
31
|
+
if (failed.length > 0) {
|
|
32
|
+
failures.push({ file: planFile, missing: failed.map(c => c.name) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (failures.length > 0) {
|
|
37
|
+
for (const f of failures) {
|
|
38
|
+
console.error(`error: ${f.file} missing sections: ${f.missing.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
throw new UserError("Plan verification failed. Fix missing sections.", { exitCode: 1 });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
console.log("ok: plan verification passed");
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* aiws pull — git pull --ff-only + submodule sync
|
|
7
|
+
* @param {{ cwd?: string }} opts
|
|
8
|
+
*/
|
|
9
|
+
export async function wsPullCommand({ cwd = "." } = {}) {
|
|
10
|
+
checkClean(cwd);
|
|
11
|
+
|
|
12
|
+
// git pull --ff-only
|
|
13
|
+
execSync("git pull --ff-only", { cwd, stdio: "inherit" });
|
|
14
|
+
|
|
15
|
+
// submodule sync if .gitmodules exists
|
|
16
|
+
if (existsSync(`${cwd}/.gitmodules`)) {
|
|
17
|
+
console.log("info: submodules detected, syncing...");
|
|
18
|
+
execSync("git submodule sync --recursive", { cwd, stdio: "inherit" });
|
|
19
|
+
execSync("git submodule update --init --recursive", { cwd, stdio: "inherit" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
console.log("ok: pull complete");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function checkClean(cwd) {
|
|
26
|
+
const out = execSync("git status --porcelain", { cwd, encoding: "utf8" }).trim();
|
|
27
|
+
if (out) {
|
|
28
|
+
throw new UserError("Working tree is not clean. Commit or stash first.", { exitCode: 2 });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* aiws push — submodule-aware push (submodules first, then superproject)
|
|
7
|
+
* @param {{ cwd?: string, remote?: string }} opts
|
|
8
|
+
*/
|
|
9
|
+
export async function wsPushCommand({ cwd = ".", remote = "origin" } = {}) {
|
|
10
|
+
checkClean(cwd);
|
|
11
|
+
|
|
12
|
+
if (existsSync(`${cwd}/.gitmodules`)) {
|
|
13
|
+
pushSubmodules(cwd, remote);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const branch = execSync("git branch --show-current", { cwd, encoding: "utf8" }).trim();
|
|
17
|
+
console.log(`info: pushing ${branch} to ${remote}...`);
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
execSync(`git push ${remote} ${branch}`, { cwd, stdio: "inherit" });
|
|
21
|
+
} catch {
|
|
22
|
+
throw new UserError("Push failed. Check for diverged branch.", { exitCode: 1 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
console.log("ok: push complete");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pushSubmodules(cwd, remote) {
|
|
29
|
+
const targetFile = findSubmodulesTargets(cwd);
|
|
30
|
+
if (!targetFile) {
|
|
31
|
+
console.log("info: no submodules.targets found, skipping submodule push");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const content = readFileSync(targetFile, "utf8");
|
|
36
|
+
for (const line of content.split("\n")) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
39
|
+
const [subPath, targetBranch, rmt = remote] = trimmed.split(/\s+/);
|
|
40
|
+
if (!subPath || !targetBranch) continue;
|
|
41
|
+
|
|
42
|
+
console.log(`info: pushing submodule ${subPath} (${targetBranch})...`);
|
|
43
|
+
try {
|
|
44
|
+
execSync(`git push ${rmt} ${targetBranch}`, { cwd: `${cwd}/${subPath}`, stdio: "inherit" });
|
|
45
|
+
} catch {
|
|
46
|
+
throw new UserError(`Failed to push submodule ${subPath}`, { exitCode: 1 });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function findSubmodulesTargets(cwd) {
|
|
52
|
+
const paths = [
|
|
53
|
+
`${cwd}/changes/*/submodules.targets`,
|
|
54
|
+
];
|
|
55
|
+
// Find first match using shell expansion
|
|
56
|
+
try {
|
|
57
|
+
const out = execSync("ls changes/*/submodules.targets 2>/dev/null || true", { cwd, encoding: "utf8" }).trim();
|
|
58
|
+
if (out) {
|
|
59
|
+
const first = out.split("\n")[0];
|
|
60
|
+
if (first) return `${cwd}/${first}`;
|
|
61
|
+
}
|
|
62
|
+
} catch { /* ignore */ }
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkClean(cwd) {
|
|
67
|
+
const out = execSync("git status --porcelain", { cwd, encoding: "utf8" }).trim();
|
|
68
|
+
if (out) {
|
|
69
|
+
throw new UserError("Working tree is not clean. Commit or stash first.", { exitCode: 2 });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { UserError } from "../errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* aiws submodule-setup — set .gitmodules branch for each submodule
|
|
7
|
+
* @param {{ cwd?: string, branch?: string }} opts
|
|
8
|
+
*/
|
|
9
|
+
export async function wsSubmoduleSetupCommand({ cwd = ".", branch = "main" } = {}) {
|
|
10
|
+
checkClean(cwd);
|
|
11
|
+
|
|
12
|
+
if (!existsSync(`${cwd}/.gitmodules`)) {
|
|
13
|
+
console.log("info: no .gitmodules found, nothing to configure");
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// List submodules
|
|
18
|
+
const out = execSync("git config --file .gitmodules --get-regexp '^submodule\\..*\\.path$'", {
|
|
19
|
+
cwd, encoding: "utf8",
|
|
20
|
+
}).trim();
|
|
21
|
+
|
|
22
|
+
if (!out) {
|
|
23
|
+
console.log("info: no submodules in .gitmodules");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const lines = out.split("\n");
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const parts = line.trim().split(/\s+/);
|
|
30
|
+
if (parts.length < 2) continue;
|
|
31
|
+
const key = parts[0]; // submodule.<name>.path
|
|
32
|
+
const subPath = parts[1];
|
|
33
|
+
const name = key.replace(/^submodule\./, "").replace(/\.path$/, "");
|
|
34
|
+
|
|
35
|
+
// Check if branch already set
|
|
36
|
+
const existing = execSync(
|
|
37
|
+
`git config --file .gitmodules --get submodule.${name}.branch 2>/dev/null || true`,
|
|
38
|
+
{ cwd, encoding: "utf8" }
|
|
39
|
+
).trim();
|
|
40
|
+
|
|
41
|
+
if (existing) {
|
|
42
|
+
console.log(`info: ${subPath} branch already set: ${existing}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Detect current branch
|
|
47
|
+
let detected = branch;
|
|
48
|
+
try {
|
|
49
|
+
const current = execSync(`git -C "${subPath}" branch --show-current 2>/dev/null || true`, { cwd, encoding: "utf8" }).trim();
|
|
50
|
+
if (current) detected = current;
|
|
51
|
+
} catch { /* use default */ }
|
|
52
|
+
|
|
53
|
+
console.log(`info: setting ${subPath} branch → ${detected}`);
|
|
54
|
+
execSync(`git submodule set-branch --branch ${detected} ${name}`, { cwd, stdio: "inherit" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log("ok: submodule branches configured. Review .gitmodules changes before committing.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkClean(cwd) {
|
|
61
|
+
const out = execSync("git status --porcelain", { cwd, encoding: "utf8" }).trim();
|
|
62
|
+
if (out) {
|
|
63
|
+
throw new UserError("Working tree is not clean. Commit or stash first.", { exitCode: 2 });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { UserError } from "../errors.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* aiws verify-bc — verify before complete (check review + validate)
|
|
6
|
+
* @param {{ cwd?: string }} opts
|
|
7
|
+
*/
|
|
8
|
+
export async function wsVerifyBeforeCompleteCommand({ cwd = "." } = {}) {
|
|
9
|
+
const missing = [];
|
|
10
|
+
|
|
11
|
+
// Check spec-review
|
|
12
|
+
const changesDir = `${cwd}/changes`;
|
|
13
|
+
if (existsSync(changesDir)) {
|
|
14
|
+
const changes = readdirSync(changesDir).filter(f => !f.startsWith(".") && f !== "templates" && f !== "archive");
|
|
15
|
+
const hasSpecReview = changes.some(c =>
|
|
16
|
+
existsSync(`${changesDir}/${c}/review/spec-review.md`)
|
|
17
|
+
);
|
|
18
|
+
const hasQualityReview = changes.some(c =>
|
|
19
|
+
existsSync(`${changesDir}/${c}/review/quality-review.md`)
|
|
20
|
+
);
|
|
21
|
+
if (!hasSpecReview) missing.push("spec-review.md");
|
|
22
|
+
if (!hasQualityReview) missing.push("quality-review.md");
|
|
23
|
+
} else {
|
|
24
|
+
missing.push("changes/ directory not found");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Check validate stamp
|
|
28
|
+
const stampDir = `${cwd}/.agentdocs/tmp/aiws-validate`;
|
|
29
|
+
const hasStamp = existsSync(stampDir) && readdirSync(stampDir).some(f => f.endsWith(".json"));
|
|
30
|
+
if (!hasStamp) {
|
|
31
|
+
console.log("warn: no validate stamp found. Run aiws validate . --stamp");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (missing.length > 0) {
|
|
35
|
+
throw new UserError(`Missing gates: ${missing.join(", ")}`, { exitCode: 1 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log("ok: all gates passed");
|
|
39
|
+
}
|
package/src/manifest.js
CHANGED
|
@@ -5,6 +5,31 @@ import { pathExists, readText, writeText } from "./fs.js";
|
|
|
5
5
|
import { findManagedBlock } from "./managed-blocks.js";
|
|
6
6
|
import { UserError } from "./errors.js";
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Detect an older AIWS workspace that predates `.aiws/manifest.json`.
|
|
10
|
+
*
|
|
11
|
+
* Current migration only recognizes the built-in `workspace` template.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} workspaceRoot
|
|
14
|
+
* @returns {Promise<string | null>}
|
|
15
|
+
*/
|
|
16
|
+
export async function detectLegacyWorkspaceTemplateId(workspaceRoot) {
|
|
17
|
+
const requiredSignals = [
|
|
18
|
+
"AI_PROJECT.md",
|
|
19
|
+
"REQUIREMENTS.md",
|
|
20
|
+
"AI_WORKSPACE.md",
|
|
21
|
+
"tools/ws_change_check.py",
|
|
22
|
+
"tools/requirements_contract.py",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
for (const rel of requiredSignals) {
|
|
26
|
+
const abs = joinRel(workspaceRoot, rel);
|
|
27
|
+
if (!(await pathExists(abs))) return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return "workspace";
|
|
31
|
+
}
|
|
32
|
+
|
|
8
33
|
/**
|
|
9
34
|
* @param {any} manifest
|
|
10
35
|
*/
|
|
@@ -150,4 +175,3 @@ export async function validateDrift(options) {
|
|
|
150
175
|
throw new UserError("Workspace drift detected.", { details: problems.join("\n") });
|
|
151
176
|
}
|
|
152
177
|
}
|
|
153
|
-
|
package/src/opencode-env.js
CHANGED
|
@@ -2,6 +2,10 @@ import path from "node:path";
|
|
|
2
2
|
import { pathExists, readText } from "./fs.js";
|
|
3
3
|
|
|
4
4
|
const OPENCODE_AGENT_NAMES = ["planner-sisyphus", "librarian", "explore", "oracle"];
|
|
5
|
+
const SAFE_READ_ONLY_COMMANDS = new Set(["rg", "cat", "sed", "ls", "find", "git status", "git diff"]);
|
|
6
|
+
const REQUIRED_WRITE_ALLOW_PATHS = [".agentdocs/tmp/", "changes/*/analysis/", "changes/*/review/", "changes/*/evidence/"];
|
|
7
|
+
const REQUIRED_DENY_PATHS = ["secrets/", ".env*"];
|
|
8
|
+
const REQUIRED_DENY_COMMANDS = ["rm", "mv", "git commit", "git push", "npm publish"];
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* @param {string} value
|
|
@@ -21,6 +25,72 @@ function readAgentState(raw, agentName) {
|
|
|
21
25
|
present: !!entry && typeof entry === "object",
|
|
22
26
|
enabled: !!(entry && typeof entry === "object" && entry.enabled === true),
|
|
23
27
|
replacePlan: entry && typeof entry === "object" && typeof entry.replace_plan === "boolean" ? entry.replace_plan : null,
|
|
28
|
+
promptAppendConfigured: !!(
|
|
29
|
+
entry &&
|
|
30
|
+
typeof entry === "object" &&
|
|
31
|
+
typeof entry.prompt_append === "string" &&
|
|
32
|
+
entry.prompt_append.trim().length > 0
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {any} raw
|
|
39
|
+
*/
|
|
40
|
+
function readApprovalWhitelistState(raw) {
|
|
41
|
+
const policy =
|
|
42
|
+
raw &&
|
|
43
|
+
typeof raw === "object" &&
|
|
44
|
+
raw.aiws &&
|
|
45
|
+
typeof raw.aiws === "object" &&
|
|
46
|
+
raw.aiws.autonomy &&
|
|
47
|
+
typeof raw.aiws.autonomy === "object" &&
|
|
48
|
+
raw.aiws.autonomy.approval_whitelist &&
|
|
49
|
+
typeof raw.aiws.autonomy.approval_whitelist === "object"
|
|
50
|
+
? raw.aiws.autonomy.approval_whitelist
|
|
51
|
+
: null;
|
|
52
|
+
const readOnlyCommands = Array.isArray(policy?.read_only_commands)
|
|
53
|
+
? policy.read_only_commands.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
54
|
+
: [];
|
|
55
|
+
const writeAllowPaths = Array.isArray(policy?.write_allow_paths)
|
|
56
|
+
? policy.write_allow_paths.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
57
|
+
: [];
|
|
58
|
+
const denyPaths = Array.isArray(policy?.deny_paths)
|
|
59
|
+
? policy.deny_paths.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
60
|
+
: [];
|
|
61
|
+
const denyCommands = Array.isArray(policy?.deny_commands)
|
|
62
|
+
? policy.deny_commands.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
63
|
+
: [];
|
|
64
|
+
const mode = typeof policy?.mode === "string" && policy.mode.trim().length > 0 ? policy.mode : "";
|
|
65
|
+
const hostPermissionMode =
|
|
66
|
+
typeof policy?.host_permission_mode === "string" && policy.host_permission_mode.trim().length > 0
|
|
67
|
+
? policy.host_permission_mode
|
|
68
|
+
: "";
|
|
69
|
+
const enabled = policy?.enabled === true;
|
|
70
|
+
const safeReadOnlyCommands = readOnlyCommands.every((value) => SAFE_READ_ONLY_COMMANDS.has(value));
|
|
71
|
+
const requiredWritePathsPresent = REQUIRED_WRITE_ALLOW_PATHS.every((value) => writeAllowPaths.includes(value));
|
|
72
|
+
const requiredDenyPathsPresent = REQUIRED_DENY_PATHS.every((value) => denyPaths.includes(value));
|
|
73
|
+
const requiredDenyCommandsPresent = REQUIRED_DENY_COMMANDS.every((value) => denyCommands.includes(value));
|
|
74
|
+
const modeValid = mode === "assist-only";
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
present: !!policy,
|
|
78
|
+
enabled,
|
|
79
|
+
mode,
|
|
80
|
+
hostPermissionMode,
|
|
81
|
+
readOnlyCommands,
|
|
82
|
+
writeAllowPaths,
|
|
83
|
+
denyPaths,
|
|
84
|
+
denyCommands,
|
|
85
|
+
configured:
|
|
86
|
+
enabled &&
|
|
87
|
+
modeValid &&
|
|
88
|
+
safeReadOnlyCommands &&
|
|
89
|
+
readOnlyCommands.length > 0 &&
|
|
90
|
+
requiredWritePathsPresent &&
|
|
91
|
+
requiredDenyPathsPresent &&
|
|
92
|
+
requiredDenyCommandsPresent &&
|
|
93
|
+
hostPermissionMode === "manual-only",
|
|
24
94
|
};
|
|
25
95
|
}
|
|
26
96
|
|
|
@@ -52,6 +122,45 @@ export async function detectOpenCodeEnvironment(workspaceRoot) {
|
|
|
52
122
|
const agents = Object.fromEntries(OPENCODE_AGENT_NAMES.map((name) => [name, readAgentState(config, name)]));
|
|
53
123
|
const enabledAgents = OPENCODE_AGENT_NAMES.filter((name) => agents[name]?.enabled === true);
|
|
54
124
|
const recommendedAgentsReady = ["planner-sisyphus", "librarian", "explore", "oracle"].every((name) => agents[name]?.enabled === true);
|
|
125
|
+
const backgroundTasksConfigured = !!(config && typeof config === "object" && config.backgroundTasks && typeof config.backgroundTasks === "object");
|
|
126
|
+
const autoResumeEnabled = !!(
|
|
127
|
+
config &&
|
|
128
|
+
typeof config === "object" &&
|
|
129
|
+
config.experimental &&
|
|
130
|
+
typeof config.experimental === "object" &&
|
|
131
|
+
config.experimental.auto_resume === true
|
|
132
|
+
);
|
|
133
|
+
const approvalWhitelist = readApprovalWhitelistState(config);
|
|
134
|
+
const claudeSettingsExamplePath = path.join(workspaceRoot, ".claude", "settings.json.example");
|
|
135
|
+
const approvalWhitelistHelperPath = path.join(opencodeDir, "helpers", "approval-whitelist-check.sh");
|
|
136
|
+
const approvalWhitelistRunnerPath = path.join(opencodeDir, "helpers", "approval-whitelist-run.sh");
|
|
137
|
+
const approvalWhitelistWatchdogPath = path.join(opencodeDir, "helpers", "approval-whitelist-watchdog.sh");
|
|
138
|
+
const tmuxScanHelperPath = path.join(opencodeDir, "helpers", "tmux-swarm-scan.sh");
|
|
139
|
+
const tmuxRescueHelperPath = path.join(opencodeDir, "helpers", "tmux-swarm-rescue.sh");
|
|
140
|
+
const claudeSettingsExampleExists = await pathExists(claudeSettingsExamplePath);
|
|
141
|
+
const approvalWhitelistHelperExists = await pathExists(approvalWhitelistHelperPath);
|
|
142
|
+
const approvalWhitelistRunnerExists = await pathExists(approvalWhitelistRunnerPath);
|
|
143
|
+
const approvalWhitelistWatchdogExists = await pathExists(approvalWhitelistWatchdogPath);
|
|
144
|
+
const tmuxScanHelperExists = await pathExists(tmuxScanHelperPath);
|
|
145
|
+
const tmuxRescueHelperExists = await pathExists(tmuxRescueHelperPath);
|
|
146
|
+
const watchdogSuperviseReady =
|
|
147
|
+
configStatus === "loaded" &&
|
|
148
|
+
approvalWhitelist.configured &&
|
|
149
|
+
approvalWhitelistHelperExists &&
|
|
150
|
+
approvalWhitelistRunnerExists &&
|
|
151
|
+
approvalWhitelistWatchdogExists;
|
|
152
|
+
const autonomyReady =
|
|
153
|
+
configStatus === "loaded" &&
|
|
154
|
+
agents["planner-sisyphus"]?.promptAppendConfigured === true &&
|
|
155
|
+
backgroundTasksConfigured &&
|
|
156
|
+
autoResumeEnabled &&
|
|
157
|
+
approvalWhitelist.configured &&
|
|
158
|
+
claudeSettingsExampleExists &&
|
|
159
|
+
approvalWhitelistHelperExists &&
|
|
160
|
+
approvalWhitelistRunnerExists &&
|
|
161
|
+
approvalWhitelistWatchdogExists &&
|
|
162
|
+
tmuxScanHelperExists &&
|
|
163
|
+
tmuxRescueHelperExists;
|
|
55
164
|
|
|
56
165
|
return {
|
|
57
166
|
workspaceRoot,
|
|
@@ -67,10 +176,33 @@ export async function detectOpenCodeEnvironment(workspaceRoot) {
|
|
|
67
176
|
recommendedAgentsReady,
|
|
68
177
|
enabledAgents,
|
|
69
178
|
agents,
|
|
179
|
+
backgroundTasksConfigured,
|
|
180
|
+
autoResumeEnabled,
|
|
181
|
+
approvalWhitelist,
|
|
182
|
+
watchdogSuperviseReady,
|
|
183
|
+
autonomyReady,
|
|
184
|
+
claudeSettingsExamplePath,
|
|
185
|
+
claudeSettingsExampleExists,
|
|
186
|
+
approvalWhitelistHelperPath,
|
|
187
|
+
approvalWhitelistHelperExists,
|
|
188
|
+
approvalWhitelistRunnerPath,
|
|
189
|
+
approvalWhitelistRunnerExists,
|
|
190
|
+
approvalWhitelistWatchdogPath,
|
|
191
|
+
approvalWhitelistWatchdogExists,
|
|
192
|
+
tmuxScanHelperPath,
|
|
193
|
+
tmuxScanHelperExists,
|
|
194
|
+
tmuxRescueHelperPath,
|
|
195
|
+
tmuxRescueHelperExists,
|
|
70
196
|
rel: {
|
|
71
197
|
opencodeDir: relativeOrDot(path.relative(workspaceRoot, opencodeDir)),
|
|
72
198
|
configPath: relativeOrDot(path.relative(workspaceRoot, configPath)),
|
|
73
199
|
examplePath: relativeOrDot(path.relative(workspaceRoot, examplePath)),
|
|
200
|
+
claudeSettingsExamplePath: relativeOrDot(path.relative(workspaceRoot, claudeSettingsExamplePath)),
|
|
201
|
+
approvalWhitelistHelperPath: relativeOrDot(path.relative(workspaceRoot, approvalWhitelistHelperPath)),
|
|
202
|
+
approvalWhitelistRunnerPath: relativeOrDot(path.relative(workspaceRoot, approvalWhitelistRunnerPath)),
|
|
203
|
+
approvalWhitelistWatchdogPath: relativeOrDot(path.relative(workspaceRoot, approvalWhitelistWatchdogPath)),
|
|
204
|
+
tmuxScanHelperPath: relativeOrDot(path.relative(workspaceRoot, tmuxScanHelperPath)),
|
|
205
|
+
tmuxRescueHelperPath: relativeOrDot(path.relative(workspaceRoot, tmuxRescueHelperPath)),
|
|
74
206
|
},
|
|
75
207
|
};
|
|
76
208
|
}
|