wile 0.8.0 → 1.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/dist/agent/Dockerfile +1 -1
- package/dist/agent/README.md +20 -20
- package/dist/agent/entrypoint.sh +33 -21
- package/dist/agent/scripts/claude-stream.ts +159 -0
- package/dist/agent/scripts/codex-stream.ts +167 -0
- package/dist/agent/scripts/gemini-stream.ts +97 -0
- package/dist/agent/scripts/mock-claude.sh +48 -21
- package/dist/agent/scripts/mock-codex.sh +48 -21
- package/dist/agent/scripts/mock-gemini.sh +48 -21
- package/dist/agent/scripts/opencode-stream.ts +93 -0
- package/dist/agent/scripts/prompt-compact.md +36 -34
- package/dist/agent/scripts/prompt-preflight.md +0 -7
- package/dist/agent/scripts/prompt.md +26 -24
- package/dist/agent/scripts/test-additional-instructions.sh +4 -2
- package/dist/agent/scripts/test-env-project-docker.sh +5 -4
- package/dist/agent/scripts/test-iteration-limit.sh +4 -2
- package/dist/agent/scripts/test-prd-validation-docker.sh +151 -0
- package/dist/agent/scripts/test-preflight-claude-docker.sh +5 -4
- package/dist/agent/scripts/test-preflight-docker.sh +16 -13
- package/dist/agent/scripts/validate-compact.ts +134 -0
- package/dist/agent/scripts/validate-prd.ts +280 -0
- package/dist/agent/scripts/wile-compact.sh +6 -6
- package/dist/agent/scripts/wile-preflight.sh +4 -4
- package/dist/agent/scripts/wile.sh +4 -4
- package/dist/cli.js +236 -58
- package/package.json +10 -1
- package/dist/agent/scripts/claude-stream.js +0 -88
- package/dist/agent/scripts/codex-stream.js +0 -125
- package/dist/agent/scripts/gemini-stream.js +0 -63
- package/dist/agent/scripts/opencode-stream.js +0 -52
- package/dist/agent/scripts/validate-compact.js +0 -135
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR=$(cd "$(dirname "$0")/../../.." && pwd)
|
|
5
|
+
AGENT_DIR="$ROOT_DIR/packages/agent"
|
|
6
|
+
|
|
7
|
+
docker build -t wile-agent:local "$AGENT_DIR" >/dev/null
|
|
8
|
+
|
|
9
|
+
run_case() {
|
|
10
|
+
CASE_NAME="$1"
|
|
11
|
+
EXPECT_SUCCESS="$2"
|
|
12
|
+
EXPECT_PATTERN="$3"
|
|
13
|
+
|
|
14
|
+
TMP_DIR=$(mktemp -d "/tmp/wile-prd-${CASE_NAME}-XXXXXX")
|
|
15
|
+
cleanup() {
|
|
16
|
+
rm -rf "$TMP_DIR"
|
|
17
|
+
}
|
|
18
|
+
trap cleanup EXIT INT TERM
|
|
19
|
+
|
|
20
|
+
mkdir -p "$TMP_DIR/.wile"
|
|
21
|
+
printf "# Wile Progress Log\n\n## Codebase Patterns\n\n---\n" > "$TMP_DIR/.wile/progress.txt"
|
|
22
|
+
|
|
23
|
+
case "$CASE_NAME" in
|
|
24
|
+
missing_prd)
|
|
25
|
+
;;
|
|
26
|
+
root_only)
|
|
27
|
+
cat > "$TMP_DIR/prd.json" <<'JSON'
|
|
28
|
+
{
|
|
29
|
+
"stories": [
|
|
30
|
+
{
|
|
31
|
+
"id": 1,
|
|
32
|
+
"title": "Root only",
|
|
33
|
+
"description": "should fail because .wile/prd.json is required",
|
|
34
|
+
"acceptanceCriteria": ["n/a"],
|
|
35
|
+
"dependsOn": [],
|
|
36
|
+
"status": "pending"
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
JSON
|
|
41
|
+
;;
|
|
42
|
+
missing_dependency)
|
|
43
|
+
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
44
|
+
{
|
|
45
|
+
"stories": [
|
|
46
|
+
{
|
|
47
|
+
"id": 1,
|
|
48
|
+
"title": "Invalid dep",
|
|
49
|
+
"description": "missing dependency",
|
|
50
|
+
"acceptanceCriteria": ["n/a"],
|
|
51
|
+
"dependsOn": [99],
|
|
52
|
+
"status": "pending"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
JSON
|
|
57
|
+
;;
|
|
58
|
+
cycle)
|
|
59
|
+
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
60
|
+
{
|
|
61
|
+
"stories": [
|
|
62
|
+
{
|
|
63
|
+
"id": 1,
|
|
64
|
+
"title": "Cycle A",
|
|
65
|
+
"description": "depends on B",
|
|
66
|
+
"acceptanceCriteria": ["n/a"],
|
|
67
|
+
"dependsOn": [2],
|
|
68
|
+
"status": "pending"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"id": 2,
|
|
72
|
+
"title": "Cycle B",
|
|
73
|
+
"description": "depends on A",
|
|
74
|
+
"acceptanceCriteria": ["n/a"],
|
|
75
|
+
"dependsOn": [1],
|
|
76
|
+
"status": "pending"
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
JSON
|
|
81
|
+
;;
|
|
82
|
+
later_dependency_valid)
|
|
83
|
+
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
84
|
+
{
|
|
85
|
+
"stories": [
|
|
86
|
+
{
|
|
87
|
+
"id": 1,
|
|
88
|
+
"title": "Can run with later dependency",
|
|
89
|
+
"description": "depends on story 2 which is already done",
|
|
90
|
+
"acceptanceCriteria": ["n/a"],
|
|
91
|
+
"dependsOn": [2],
|
|
92
|
+
"status": "pending"
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
"id": 2,
|
|
96
|
+
"title": "Done prerequisite",
|
|
97
|
+
"description": "already complete",
|
|
98
|
+
"acceptanceCriteria": ["n/a"],
|
|
99
|
+
"dependsOn": [],
|
|
100
|
+
"status": "done"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
}
|
|
104
|
+
JSON
|
|
105
|
+
;;
|
|
106
|
+
*)
|
|
107
|
+
echo "error: unknown case $CASE_NAME" >&2
|
|
108
|
+
exit 1
|
|
109
|
+
;;
|
|
110
|
+
esac
|
|
111
|
+
|
|
112
|
+
OUTPUT_FILE="$TMP_DIR/output.txt"
|
|
113
|
+
|
|
114
|
+
set +e
|
|
115
|
+
docker run --rm \
|
|
116
|
+
-e CODING_AGENT=CC \
|
|
117
|
+
-e CC_ANTHROPIC_API_KEY=dummy-key \
|
|
118
|
+
-e WILE_REPO_SOURCE=local \
|
|
119
|
+
-e WILE_LOCAL_REPO_PATH=/home/wile/workspace/repo \
|
|
120
|
+
-e MAX_ITERATIONS=2 \
|
|
121
|
+
-e WILE_MOCK_CLAUDE=true \
|
|
122
|
+
-v "$TMP_DIR:/home/wile/workspace/repo" \
|
|
123
|
+
wile-agent:local 2>&1 | tee "$OUTPUT_FILE"
|
|
124
|
+
EXIT_CODE=$?
|
|
125
|
+
set -e
|
|
126
|
+
|
|
127
|
+
if [ "$EXPECT_SUCCESS" = "yes" ]; then
|
|
128
|
+
if [ "$EXIT_CODE" -ne 0 ]; then
|
|
129
|
+
echo "error: expected success for case $CASE_NAME" >&2
|
|
130
|
+
exit 1
|
|
131
|
+
fi
|
|
132
|
+
else
|
|
133
|
+
if [ "$EXIT_CODE" -eq 0 ]; then
|
|
134
|
+
echo "error: expected failure for case $CASE_NAME" >&2
|
|
135
|
+
exit 1
|
|
136
|
+
fi
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
grep -q "$EXPECT_PATTERN" "$OUTPUT_FILE"
|
|
140
|
+
|
|
141
|
+
rm -rf "$TMP_DIR"
|
|
142
|
+
trap - EXIT INT TERM
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
run_case missing_prd no ".wile/prd.json not found"
|
|
146
|
+
run_case root_only no ".wile/prd.json not found"
|
|
147
|
+
run_case missing_dependency no "depends on missing story id"
|
|
148
|
+
run_case cycle no "Dependency cycle detected"
|
|
149
|
+
run_case later_dependency_valid yes "Starting Wile Loop"
|
|
150
|
+
|
|
151
|
+
echo "test-prd-validation-docker: ok"
|
|
@@ -20,13 +20,14 @@ trap cleanup EXIT INT TERM
|
|
|
20
20
|
mkdir -p "$TMP_DIR/.wile"
|
|
21
21
|
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
22
22
|
{
|
|
23
|
-
"
|
|
23
|
+
"stories": [
|
|
24
24
|
{
|
|
25
|
-
"id":
|
|
25
|
+
"id": 1,
|
|
26
26
|
"title": "Preflight fail test (claude)",
|
|
27
|
+
"description": "Preflight failure harness",
|
|
27
28
|
"acceptanceCriteria": ["n/a"],
|
|
28
|
-
"
|
|
29
|
-
"
|
|
29
|
+
"dependsOn": [],
|
|
30
|
+
"status": "pending"
|
|
30
31
|
}
|
|
31
32
|
]
|
|
32
33
|
}
|
|
@@ -15,15 +15,16 @@ run_failure_case() {
|
|
|
15
15
|
trap cleanup EXIT INT TERM
|
|
16
16
|
|
|
17
17
|
mkdir -p "$TMP_DIR/.wile"
|
|
18
|
-
|
|
18
|
+
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
19
19
|
{
|
|
20
|
-
"
|
|
20
|
+
"stories": [
|
|
21
21
|
{
|
|
22
|
-
"id":
|
|
22
|
+
"id": 1,
|
|
23
23
|
"title": "Preflight fail test",
|
|
24
|
+
"description": "Preflight failure harness",
|
|
24
25
|
"acceptanceCriteria": ["n/a"],
|
|
25
|
-
"
|
|
26
|
-
"
|
|
26
|
+
"dependsOn": [],
|
|
27
|
+
"status": "pending"
|
|
27
28
|
}
|
|
28
29
|
]
|
|
29
30
|
}
|
|
@@ -87,13 +88,14 @@ run_trailing_case() {
|
|
|
87
88
|
mkdir -p "$TMP_DIR/.wile"
|
|
88
89
|
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
89
90
|
{
|
|
90
|
-
"
|
|
91
|
+
"stories": [
|
|
91
92
|
{
|
|
92
|
-
"id":
|
|
93
|
+
"id": 1,
|
|
93
94
|
"title": "Preflight trailing marker test",
|
|
95
|
+
"description": "Preflight trailing marker harness",
|
|
94
96
|
"acceptanceCriteria": ["n/a"],
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
+
"dependsOn": [],
|
|
98
|
+
"status": "pending"
|
|
97
99
|
}
|
|
98
100
|
]
|
|
99
101
|
}
|
|
@@ -145,13 +147,14 @@ run_success_case() {
|
|
|
145
147
|
mkdir -p "$TMP_DIR/.wile"
|
|
146
148
|
cat > "$TMP_DIR/.wile/prd.json" <<'JSON'
|
|
147
149
|
{
|
|
148
|
-
"
|
|
150
|
+
"stories": [
|
|
149
151
|
{
|
|
150
|
-
"id":
|
|
152
|
+
"id": 1,
|
|
151
153
|
"title": "Preflight success test",
|
|
154
|
+
"description": "Preflight success harness",
|
|
152
155
|
"acceptanceCriteria": ["n/a"],
|
|
153
|
-
"
|
|
154
|
-
"
|
|
156
|
+
"dependsOn": [],
|
|
157
|
+
"status": "pending"
|
|
155
158
|
}
|
|
156
159
|
]
|
|
157
160
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
type JsonObject = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
const prdPath = ".wile/prd.json";
|
|
9
|
+
const progressPath = ".wile/progress.txt";
|
|
10
|
+
const prdOriginalPath = ".wile/prd.json.original";
|
|
11
|
+
const scriptPath = process.argv[1] ?? "validate-compact.ts";
|
|
12
|
+
const validatePrdPath = path.join(path.dirname(scriptPath), "validate-prd.ts");
|
|
13
|
+
|
|
14
|
+
const fail = (message: string): never => {
|
|
15
|
+
console.error(message);
|
|
16
|
+
process.exit(1);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const isObject = (value: unknown): value is JsonObject =>
|
|
20
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
|
|
22
|
+
const parseStories = (value: unknown, errorMessage: string): JsonObject[] => {
|
|
23
|
+
if (!Array.isArray(value)) {
|
|
24
|
+
fail(errorMessage);
|
|
25
|
+
}
|
|
26
|
+
const values = value as unknown[];
|
|
27
|
+
|
|
28
|
+
const stories: JsonObject[] = [];
|
|
29
|
+
for (const entry of values) {
|
|
30
|
+
if (!isObject(entry)) {
|
|
31
|
+
fail(errorMessage);
|
|
32
|
+
}
|
|
33
|
+
stories.push(entry as JsonObject);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return stories;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getStoryId = (story: JsonObject, errorPrefix: string): number => {
|
|
40
|
+
const id = story.id;
|
|
41
|
+
if (typeof id !== "number" || !Number.isInteger(id)) {
|
|
42
|
+
fail(`${errorPrefix} story has invalid id.`);
|
|
43
|
+
}
|
|
44
|
+
return id as number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getStoryStatus = (story: JsonObject, errorPrefix: string): string => {
|
|
48
|
+
const status = story.status;
|
|
49
|
+
if (typeof status !== "string") {
|
|
50
|
+
fail(`${errorPrefix} story ${getStoryId(story, errorPrefix)} has invalid status.`);
|
|
51
|
+
}
|
|
52
|
+
return status as string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (!existsSync(prdPath)) {
|
|
56
|
+
fail("Missing .wile/prd.json after compact.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!existsSync(progressPath)) {
|
|
60
|
+
fail("Missing .wile/progress.txt after compact.");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const validateResult = spawnSync("bun", [validatePrdPath, "--path", prdPath], {
|
|
64
|
+
encoding: "utf8"
|
|
65
|
+
});
|
|
66
|
+
if (validateResult.status !== 0) {
|
|
67
|
+
const message = (validateResult.stderr || validateResult.stdout || "").trim();
|
|
68
|
+
fail(message || "Compacted .wile/prd.json failed schema validation.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let payload: unknown;
|
|
72
|
+
try {
|
|
73
|
+
payload = JSON.parse(readFileSync(prdPath, "utf8"));
|
|
74
|
+
} catch {
|
|
75
|
+
fail("Compacted .wile/prd.json is not valid JSON.");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!isObject(payload)) {
|
|
79
|
+
fail('Compacted .wile/prd.json must contain a top-level "stories" array.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const currentStories = parseStories(
|
|
83
|
+
(payload as JsonObject).stories,
|
|
84
|
+
'Compacted .wile/prd.json must contain a top-level "stories" array.'
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
if (existsSync(prdOriginalPath)) {
|
|
88
|
+
let originalPayload: unknown;
|
|
89
|
+
try {
|
|
90
|
+
originalPayload = JSON.parse(readFileSync(prdOriginalPath, "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
fail("Original .wile/prd.json.original is not valid JSON.");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isObject(originalPayload)) {
|
|
96
|
+
fail('Original .wile/prd.json.original must contain a top-level "stories" array.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const originalStories = parseStories(
|
|
100
|
+
(originalPayload as JsonObject).stories,
|
|
101
|
+
'Original .wile/prd.json.original must contain a top-level "stories" array.'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const originalPending = originalStories.filter(
|
|
105
|
+
(story) => getStoryStatus(story, "Original") === "pending"
|
|
106
|
+
);
|
|
107
|
+
const currentPending = currentStories.filter(
|
|
108
|
+
(story) => getStoryStatus(story, "Compacted") === "pending"
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (originalPending.length !== currentPending.length) {
|
|
112
|
+
fail("Compaction must preserve all pending stories.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const story of originalPending) {
|
|
116
|
+
const storyId = getStoryId(story, "Original");
|
|
117
|
+
const currentStory = currentPending.find((candidate) => getStoryId(candidate, "Compacted") === storyId);
|
|
118
|
+
if (!currentStory) {
|
|
119
|
+
fail(`Compaction removed pending story ${storyId}.`);
|
|
120
|
+
}
|
|
121
|
+
if (JSON.stringify(currentStory) !== JSON.stringify(story)) {
|
|
122
|
+
fail(`Compaction modified pending story ${storyId}. Pending stories must remain unchanged.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const progressText = readFileSync(progressPath, "utf8");
|
|
128
|
+
if (!progressText.startsWith("# Wile Progress Log")) {
|
|
129
|
+
fail("Compacted progress log must start with # Wile Progress Log.");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!progressText.includes("## Codebase Patterns")) {
|
|
133
|
+
fail("Compacted progress log must include ## Codebase Patterns.");
|
|
134
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
type JsonObject = Record<string, unknown>;
|
|
6
|
+
type StoryStatus = "pending" | "done";
|
|
7
|
+
|
|
8
|
+
interface Story {
|
|
9
|
+
id: number;
|
|
10
|
+
title: string;
|
|
11
|
+
description: string;
|
|
12
|
+
acceptanceCriteria: string[];
|
|
13
|
+
dependsOn: number[];
|
|
14
|
+
compactedFrom?: number[];
|
|
15
|
+
status: StoryStatus;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fail = (message: string): never => {
|
|
19
|
+
console.error(message);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const isObject = (value: unknown): value is JsonObject =>
|
|
24
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
|
|
26
|
+
const toObject = (value: unknown, message: string): JsonObject => {
|
|
27
|
+
if (!isObject(value)) {
|
|
28
|
+
fail(message);
|
|
29
|
+
}
|
|
30
|
+
return value as JsonObject;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const toInteger = (value: unknown, message: string): number => {
|
|
34
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
35
|
+
fail(message);
|
|
36
|
+
}
|
|
37
|
+
return value as number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const toNonEmptyString = (value: unknown, message: string): string => {
|
|
41
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
42
|
+
fail(message);
|
|
43
|
+
}
|
|
44
|
+
return value as string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const toStringArray = (value: unknown, message: string): string[] => {
|
|
48
|
+
if (!Array.isArray(value)) {
|
|
49
|
+
fail(message);
|
|
50
|
+
}
|
|
51
|
+
const values = value as unknown[];
|
|
52
|
+
|
|
53
|
+
const items: string[] = [];
|
|
54
|
+
for (const item of values) {
|
|
55
|
+
if (typeof item !== "string" || item.trim().length === 0) {
|
|
56
|
+
fail(message);
|
|
57
|
+
}
|
|
58
|
+
items.push(item as string);
|
|
59
|
+
}
|
|
60
|
+
return items;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const toIntegerArray = (value: unknown, message: string): number[] => {
|
|
64
|
+
if (!Array.isArray(value)) {
|
|
65
|
+
fail(message);
|
|
66
|
+
}
|
|
67
|
+
const values = value as unknown[];
|
|
68
|
+
|
|
69
|
+
const items: number[] = [];
|
|
70
|
+
for (const item of values) {
|
|
71
|
+
if (typeof item !== "number" || !Number.isInteger(item)) {
|
|
72
|
+
fail(message);
|
|
73
|
+
}
|
|
74
|
+
items.push(item as number);
|
|
75
|
+
}
|
|
76
|
+
return items;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const parseStory = (raw: unknown, idx: number): Story => {
|
|
80
|
+
const label = `stories[${idx}]`;
|
|
81
|
+
const storyObj = toObject(raw, `${label} must be an object.`);
|
|
82
|
+
|
|
83
|
+
const statusRaw = storyObj.status;
|
|
84
|
+
if (statusRaw !== "pending" && statusRaw !== "done") {
|
|
85
|
+
fail(`${label}.status must be "pending" or "done".`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let compactedFrom: number[] | undefined;
|
|
89
|
+
if (storyObj.compactedFrom !== undefined) {
|
|
90
|
+
compactedFrom = toIntegerArray(
|
|
91
|
+
storyObj.compactedFrom,
|
|
92
|
+
`${label}.compactedFrom must be an array of integer story IDs.`
|
|
93
|
+
);
|
|
94
|
+
if (new Set(compactedFrom).size !== compactedFrom.length) {
|
|
95
|
+
fail(`${label}.compactedFrom must not contain duplicate IDs.`);
|
|
96
|
+
}
|
|
97
|
+
if (statusRaw !== "done") {
|
|
98
|
+
fail(`${label}.compactedFrom is only allowed when status is "done".`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
id: toInteger(storyObj.id, `${label}.id must be an integer number.`),
|
|
104
|
+
title: toNonEmptyString(storyObj.title, `${label}.title must be a non-empty string.`),
|
|
105
|
+
description: toNonEmptyString(
|
|
106
|
+
storyObj.description,
|
|
107
|
+
`${label}.description must be a non-empty string.`
|
|
108
|
+
),
|
|
109
|
+
acceptanceCriteria: toStringArray(
|
|
110
|
+
storyObj.acceptanceCriteria,
|
|
111
|
+
`${label}.acceptanceCriteria must be an array of non-empty strings.`
|
|
112
|
+
),
|
|
113
|
+
dependsOn: toIntegerArray(
|
|
114
|
+
storyObj.dependsOn,
|
|
115
|
+
`${label}.dependsOn must be an array of integer story IDs.`
|
|
116
|
+
),
|
|
117
|
+
compactedFrom,
|
|
118
|
+
status: statusRaw as StoryStatus
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const findCycle = (stories: Story[]): number[] | null => {
|
|
123
|
+
const edges = new Map<number, number[]>();
|
|
124
|
+
for (const story of stories) {
|
|
125
|
+
edges.set(story.id, story.dependsOn);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const visiting = new Set<number>();
|
|
129
|
+
const visited = new Set<number>();
|
|
130
|
+
const path: number[] = [];
|
|
131
|
+
|
|
132
|
+
const dfs = (id: number): number[] | null => {
|
|
133
|
+
if (visiting.has(id)) {
|
|
134
|
+
const start = path.indexOf(id);
|
|
135
|
+
return start === -1 ? [id] : [...path.slice(start), id];
|
|
136
|
+
}
|
|
137
|
+
if (visited.has(id)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
visiting.add(id);
|
|
142
|
+
path.push(id);
|
|
143
|
+
|
|
144
|
+
const deps = edges.get(id) ?? [];
|
|
145
|
+
for (const depId of deps) {
|
|
146
|
+
const cycle = dfs(depId);
|
|
147
|
+
if (cycle) {
|
|
148
|
+
return cycle;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
path.pop();
|
|
153
|
+
visiting.delete(id);
|
|
154
|
+
visited.add(id);
|
|
155
|
+
return null;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (const story of stories) {
|
|
159
|
+
const cycle = dfs(story.id);
|
|
160
|
+
if (cycle) {
|
|
161
|
+
return cycle;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
let prdPath = ".wile/prd.json";
|
|
170
|
+
let summaryOnly = false;
|
|
171
|
+
|
|
172
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
173
|
+
const arg = args[i];
|
|
174
|
+
|
|
175
|
+
if (arg === "--path") {
|
|
176
|
+
const next = args[i + 1];
|
|
177
|
+
if (!next) {
|
|
178
|
+
fail("Missing value for --path.");
|
|
179
|
+
}
|
|
180
|
+
prdPath = next;
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (arg === "--summary") {
|
|
186
|
+
summaryOnly = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
fail(`Unknown argument: ${arg}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (!existsSync(prdPath)) {
|
|
194
|
+
fail(`Missing ${prdPath}.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let payload: unknown;
|
|
198
|
+
try {
|
|
199
|
+
payload = JSON.parse(readFileSync(prdPath, "utf8"));
|
|
200
|
+
} catch {
|
|
201
|
+
fail(`${prdPath} is not valid JSON.`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const prdObj = toObject(payload, `${prdPath} must be a JSON object.`);
|
|
205
|
+
const rawStories = prdObj.stories;
|
|
206
|
+
if (!Array.isArray(rawStories)) {
|
|
207
|
+
fail(`${prdPath} must contain a top-level "stories" array.`);
|
|
208
|
+
}
|
|
209
|
+
const storyItems = rawStories as unknown[];
|
|
210
|
+
|
|
211
|
+
const stories: Story[] = storyItems.map((story: unknown, idx: number) => parseStory(story, idx));
|
|
212
|
+
const storyById = new Map<number, Story>();
|
|
213
|
+
for (const story of stories) {
|
|
214
|
+
if (storyById.has(story.id)) {
|
|
215
|
+
fail(`Duplicate story id detected: ${story.id}.`);
|
|
216
|
+
}
|
|
217
|
+
storyById.set(story.id, story);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const compactedById = new Map<number, number>();
|
|
221
|
+
for (const story of stories) {
|
|
222
|
+
for (const compactedId of story.compactedFrom ?? []) {
|
|
223
|
+
const existingOwner = compactedById.get(compactedId);
|
|
224
|
+
if (existingOwner !== undefined) {
|
|
225
|
+
fail(
|
|
226
|
+
`Compacted story id ${compactedId} is listed multiple times (stories ${existingOwner} and ${story.id}).`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
compactedById.set(compactedId, story.id);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const [compactedId, ownerStoryId] of compactedById) {
|
|
234
|
+
if (storyById.has(compactedId)) {
|
|
235
|
+
fail(`Story id ${compactedId} is reserved by compactedFrom in story ${ownerStoryId}.`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const story of stories) {
|
|
240
|
+
for (const depId of story.dependsOn) {
|
|
241
|
+
const compactedOwner = compactedById.get(depId);
|
|
242
|
+
if (compactedOwner !== undefined) {
|
|
243
|
+
fail(
|
|
244
|
+
`Story ${story.id} depends on compacted story id ${depId} (compacted in story ${compactedOwner}).`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!storyById.has(depId)) {
|
|
249
|
+
fail(`Story ${story.id} depends on missing story id ${depId}.`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const cycle = findCycle(stories);
|
|
255
|
+
if (cycle) {
|
|
256
|
+
fail(`Dependency cycle detected: ${cycle.join(" -> ")}.`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const pendingStories = stories.filter((story) => story.status === "pending");
|
|
260
|
+
const runnableStory =
|
|
261
|
+
stories.find(
|
|
262
|
+
(story) =>
|
|
263
|
+
story.status === "pending" &&
|
|
264
|
+
story.dependsOn.every((depId) => storyById.get(depId)?.status === "done")
|
|
265
|
+
) ?? null;
|
|
266
|
+
|
|
267
|
+
if (pendingStories.length > 0 && !runnableStory) {
|
|
268
|
+
const blocked = pendingStories.map((story) => story.id).join(", ");
|
|
269
|
+
fail(`No runnable pending stories in ${prdPath}. Pending stories are blocked: ${blocked}.`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (summaryOnly) {
|
|
273
|
+
console.log(
|
|
274
|
+
JSON.stringify({
|
|
275
|
+
pendingCount: pendingStories.length,
|
|
276
|
+
runnableStoryId: runnableStory ? runnableStory.id : null,
|
|
277
|
+
allDone: pendingStories.length === 0
|
|
278
|
+
})
|
|
279
|
+
);
|
|
280
|
+
}
|
|
@@ -62,7 +62,7 @@ run_claude() {
|
|
|
62
62
|
local prompt_path="$1"
|
|
63
63
|
cat "$prompt_path" \
|
|
64
64
|
| claude --model "$CLAUDE_MODEL" --print --output-format stream-json --verbose --dangerously-skip-permissions \
|
|
65
|
-
|
|
|
65
|
+
| bun "$SCRIPT_DIR/claude-stream.ts"
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
run_opencode() {
|
|
@@ -73,7 +73,7 @@ run_opencode() {
|
|
|
73
73
|
fi
|
|
74
74
|
cat "$prompt_path" \
|
|
75
75
|
| opencode run --format json --model "$model_arg" \
|
|
76
|
-
|
|
|
76
|
+
| bun "$SCRIPT_DIR/opencode-stream.ts"
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
run_gemini() {
|
|
@@ -85,7 +85,7 @@ run_gemini() {
|
|
|
85
85
|
model_args=(--model "$GEMINI_MODEL")
|
|
86
86
|
fi
|
|
87
87
|
gemini --output-format stream-json --yolo "${model_args[@]}" "$prompt_text" \
|
|
88
|
-
|
|
|
88
|
+
| bun "$SCRIPT_DIR/gemini-stream.ts"
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
run_codex() {
|
|
@@ -96,7 +96,7 @@ run_codex() {
|
|
|
96
96
|
fi
|
|
97
97
|
cat "$prompt_path" \
|
|
98
98
|
| codex exec --json --dangerously-bypass-approvals-and-sandbox "${model_args[@]}" - \
|
|
99
|
-
|
|
|
99
|
+
| bun "$SCRIPT_DIR/codex-stream.ts"
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
run_agent() {
|
|
@@ -112,7 +112,7 @@ run_agent() {
|
|
|
112
112
|
fi
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
# Snapshot original PRD for validation of
|
|
115
|
+
# Snapshot original PRD for validation of pending-story preservation.
|
|
116
116
|
if [ -f ".wile/prd.json" ]; then
|
|
117
117
|
cp ".wile/prd.json" ".wile/prd.json.original"
|
|
118
118
|
fi
|
|
@@ -121,7 +121,7 @@ echo "Running compact agent..."
|
|
|
121
121
|
OUTPUT=$(run_agent "$PROMPT_FILE" | tee "$TEE_TARGET") || true
|
|
122
122
|
|
|
123
123
|
# Validate the resulting files instead of the response format.
|
|
124
|
-
|
|
124
|
+
bun "$SCRIPT_DIR/validate-compact.ts"
|
|
125
125
|
|
|
126
126
|
if [ -f ".wile/prd.json.original" ]; then
|
|
127
127
|
rm -f ".wile/prd.json.original"
|