@yaawn/workflow 0.0.0-stage → 0.3.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/README.md +172 -2
- package/agents/instructions/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/instructions/yaawn-accessibility.css.instructions.md +58 -0
- package/agents/instructions/yaawn-accessibility.html.instructions.md +90 -0
- package/agents/instructions/yaawn-accessibility.javascript.instructions.md +50 -0
- package/agents/instructions/yaawn-clean-code.typescript.instructions.md +74 -0
- package/agents/instructions/yaawn-function-coupling.typescript.instructions.md +100 -0
- package/agents/instructions/yaawn-html-css-js.html.instructions.md +32 -0
- package/agents/instructions/yaawn-open-source-first.instructions.md +38 -0
- package/agents/instructions/yaawn-personas.instructions.md +18 -0
- package/agents/instructions/yaawn-semantic-html.html.instructions.md +40 -0
- package/agents/instructions/yaawn-vanilla-css.css.instructions.md +50 -0
- package/agents/output-styles/yaawn-eli5.instructions.md +45 -0
- package/agents/personas/yaawn-developer.md +16 -0
- package/agents/personas/yaawn-product-owner.md +18 -0
- package/agents/personas/yaawn-reviewer.md +16 -0
- package/agents/skills/yaawn-clean-code/SKILL.md +61 -0
- package/agents/skills/yaawn-clean-code/references/classes.md +114 -0
- package/agents/skills/yaawn-clean-code/references/comments.md +164 -0
- package/agents/skills/yaawn-clean-code/references/error-handling.md +151 -0
- package/agents/skills/yaawn-clean-code/references/formatting.md +146 -0
- package/agents/skills/yaawn-clean-code/references/functions.md +261 -0
- package/agents/skills/yaawn-clean-code/references/meaningful-names.md +234 -0
- package/agents/skills/yaawn-clean-code/references/objects-and-data-structures.md +130 -0
- package/agents/skills/yaawn-clean-code/references/principles.md +76 -0
- package/agents/skills/yaawn-clean-code/references/simple-design-and-code-smells.md +130 -0
- package/agents/skills/yaawn-clean-code/references/unit-tests.md +114 -0
- package/agents/skills/yaawn-create-plan/SKILL.md +228 -0
- package/agents/skills/yaawn-grill-bug-report/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-bug-report/references/bug-report-checklist.md +27 -0
- package/agents/skills/yaawn-grill-design/SKILL.md +68 -0
- package/agents/skills/yaawn-grill-design/references/design-checklist.md +36 -0
- package/agents/skills/yaawn-grill-other/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-other/references/other-checklist.md +19 -0
- package/agents/skills/yaawn-grill-refactor/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-refactor/references/refactor-checklist.md +26 -0
- package/agents/skills/yaawn-grill-spike/SKILL.md +31 -0
- package/agents/skills/yaawn-grill-spike/references/spike-checklist.md +25 -0
- package/agents/skills/yaawn-grill-story/SKILL.md +34 -0
- package/agents/skills/yaawn-grill-story/references/concept-challenge.md +67 -0
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +43 -0
- package/agents/skills/yaawn-implement-plan/SKILL.md +196 -0
- package/agents/skills/yaawn-implement-plan/assets/fix-prompt.md +66 -0
- package/agents/skills/yaawn-implement-plan/assets/review-prompt.md +89 -0
- package/agents/skills/yaawn-implement-plan/assets/session-prompt.md +80 -0
- package/agents/skills/yaawn-implement-plan/scripts/agent.ts +218 -0
- package/agents/skills/yaawn-implement-plan/scripts/caffeinate.ts +56 -0
- package/agents/skills/yaawn-implement-plan/scripts/groups.ts +233 -0
- package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +772 -0
- package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +1176 -0
- package/agents/skills/yaawn-implement-plan/scripts/processes.ts +53 -0
- package/agents/skills/yaawn-implement-plan/scripts/stages.ts +65 -0
- package/agents/skills/yaawn-implement-plan/scripts/targets.ts +381 -0
- package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +431 -0
- package/agents/skills/yaawn-owasp-security/SKILL.md +106 -0
- package/agents/skills/yaawn-owasp-security/references/a01-broken-access-control.md +58 -0
- package/agents/skills/yaawn-owasp-security/references/a02-security-misconfiguration.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a03-software-supply-chain-failures.md +56 -0
- package/agents/skills/yaawn-owasp-security/references/a04-cryptographic-failures.md +67 -0
- package/agents/skills/yaawn-owasp-security/references/a05-injection.md +53 -0
- package/agents/skills/yaawn-owasp-security/references/a06-insecure-design.md +48 -0
- package/agents/skills/yaawn-owasp-security/references/a07-authentication-failures.md +68 -0
- package/agents/skills/yaawn-owasp-security/references/a08-software-data-integrity-failures.md +45 -0
- package/agents/skills/yaawn-owasp-security/references/a09-security-logging-alerting-failures.md +59 -0
- package/agents/skills/yaawn-owasp-security/references/a10-mishandling-exceptional-conditions.md +63 -0
- package/agents/skills/yaawn-prepare-plan/SKILL.md +110 -0
- package/agents/skills/yaawn-save-plan/SKILL.md +65 -0
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +331 -0
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +443 -0
- package/agents/skills/yaawn-split-story/SKILL.md +189 -0
- package/agents/skills/yaawn-split-story/references/spider.md +64 -0
- package/agents/skills/yaawn-split-story/scripts/create-issue.ts +162 -0
- package/agents/skills/yaawn-verify-executed-plan/SKILL.md +154 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +362 -0
- package/agents/skills/yaawn-verify-executed-plan/scripts/pull-request.ts +131 -0
- package/agents/skills/yaawn-write-bug-report/SKILL.md +87 -0
- package/agents/skills/yaawn-write-other/SKILL.md +90 -0
- package/agents/skills/yaawn-write-refactor/SKILL.md +94 -0
- package/agents/skills/yaawn-write-spike/SKILL.md +94 -0
- package/agents/skills/yaawn-write-user-story/SKILL.md +187 -0
- package/agents/skills/yaawn-write-user-story/references/acceptance-criteria.md +186 -0
- package/agents/skills/yaawn-write-user-story/references/five-types-of-value.md +82 -0
- package/agents/skills/yaawn-write-user-story/references/user-story-fundamentals.md +75 -0
- package/agents/yaawn-glossary.md +146 -0
- package/agents/yaawn-grilling.md +75 -0
- package/agents/yaawn-worktrees.md +69 -0
- package/config/install-tasks.json +35 -0
- package/config/settings.json +23 -0
- package/config/wizard.json +128 -0
- package/github/ISSUE_TEMPLATE/yaawn-bug-report.md +59 -0
- package/github/ISSUE_TEMPLATE/yaawn-other.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-refactor.md +38 -0
- package/github/ISSUE_TEMPLATE/yaawn-spike.md +16 -0
- package/github/ISSUE_TEMPLATE/yaawn-story.md +56 -0
- package/package.json +33 -4
- package/scripts/install.js +73 -0
- package/yaawn/gitignore +1 -0
- package/yaawn/yaawn-definition-of-done.md +5 -0
- package/yaawn/yaawn-project-glossary.md +7 -0
- package/yaawn/yaawn-user-roles.md +3 -0
- package/yaawn/yaawn-verification-commands.md +7 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import { finished } from "node:stream/promises";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
|
|
6
|
+
const outputDrainTimeoutMs = 5000;
|
|
7
|
+
|
|
8
|
+
function processStreams(child: ChildProcess): Readable[] {
|
|
9
|
+
return [child.stdout, child.stderr].filter(
|
|
10
|
+
(stream): stream is Readable => stream !== null,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ignoreAbort(error: unknown): void {
|
|
15
|
+
if (error instanceof Error && error.name === "AbortError") return;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 'exit' can fire before the last output lines are read. Wait for the pipes
|
|
20
|
+
// to close, but not forever: a background process the child left running may
|
|
21
|
+
// hold them open, which would also keep this orchestrator from exiting.
|
|
22
|
+
// The losing timeout is aborted so no timer outlives the drain.
|
|
23
|
+
export async function drainOutput(
|
|
24
|
+
child: ChildProcess,
|
|
25
|
+
timeoutMs = outputDrainTimeoutMs,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const streams = processStreams(child);
|
|
28
|
+
const timeout = new AbortController();
|
|
29
|
+
const allClosed = Promise.all(
|
|
30
|
+
streams.map((stream) => finished(stream).catch(() => undefined)),
|
|
31
|
+
);
|
|
32
|
+
const timedOut = delay(timeoutMs, undefined, { signal: timeout.signal })
|
|
33
|
+
.catch(ignoreAbort);
|
|
34
|
+
try {
|
|
35
|
+
await Promise.race([allClosed, timedOut]);
|
|
36
|
+
} finally {
|
|
37
|
+
timeout.abort();
|
|
38
|
+
}
|
|
39
|
+
streams.forEach((stream) => stream.destroy());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// -pid reaches the child's whole process group; spawn the child detached.
|
|
43
|
+
export function killProcessGroup(
|
|
44
|
+
child: ChildProcess,
|
|
45
|
+
signal: NodeJS.Signals,
|
|
46
|
+
): void {
|
|
47
|
+
if (child.pid === undefined) return;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(-child.pid, signal);
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { issueRefs } from "./targets.js";
|
|
2
|
+
|
|
3
|
+
export const STAGES = ["implement", "review", "fix"] as const;
|
|
4
|
+
|
|
5
|
+
export type Stage = (typeof STAGES)[number];
|
|
6
|
+
|
|
7
|
+
// A lane that stops before its first stage marker arrives has no stage yet.
|
|
8
|
+
export const laneStartedLabel = "started";
|
|
9
|
+
|
|
10
|
+
export type StageLabel = Stage | typeof laneStartedLabel;
|
|
11
|
+
|
|
12
|
+
export type LaneOutcomeState = "started" | "done" | "failed" | "stopped";
|
|
13
|
+
|
|
14
|
+
export interface LaneOutcome {
|
|
15
|
+
issue: number;
|
|
16
|
+
state: LaneOutcomeState;
|
|
17
|
+
stage?: StageLabel;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const stageMarkerPrefix = "yaawn-stage:";
|
|
21
|
+
|
|
22
|
+
export function stageMarker(issue: number, stage: Stage): string {
|
|
23
|
+
return `${stageMarkerPrefix} ${issue} ${stage}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Lane output is untrusted: accept only the exact marker of the lane's own issue.
|
|
27
|
+
export function parseStageMarker(line: string, issue: number): Stage | undefined {
|
|
28
|
+
return STAGES.find((stage) => line === stageMarker(issue, stage));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function runningResult(issue: number, stage: StageLabel): string {
|
|
32
|
+
return `#${issue} running — stage ${stage}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stoppedResult(issue: number, stage: StageLabel): string {
|
|
36
|
+
return `#${issue} stopped in ${stage}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function issuesIn(lanes: LaneOutcome[], states: LaneOutcomeState[]): number[] {
|
|
40
|
+
return lanes
|
|
41
|
+
.filter((lane) => states.includes(lane.state))
|
|
42
|
+
.map((lane) => lane.issue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function failedPart(failed: number[]): string {
|
|
46
|
+
const refs = failed.length > 0 ? ` (${issueRefs(failed)})` : "";
|
|
47
|
+
return `${failed.length} failed${refs}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function stoppedPart(stopped: LaneOutcome[]): string {
|
|
51
|
+
const refs = stopped
|
|
52
|
+
.map((lane) => `#${lane.issue} in ${lane.stage ?? laneStartedLabel}`)
|
|
53
|
+
.join(", ");
|
|
54
|
+
return `, ${stopped.length} stopped (${refs})`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function runResultLine(lanes: LaneOutcome[]): string {
|
|
58
|
+
const done = issuesIn(lanes, ["done"]);
|
|
59
|
+
if (done.length === lanes.length)
|
|
60
|
+
return `run result: all ${lanes.length} lanes done`;
|
|
61
|
+
const failed = issuesIn(lanes, ["started", "failed"]);
|
|
62
|
+
const stopped = lanes.filter((lane) => lane.state === "stopped");
|
|
63
|
+
const stoppedSuffix = stopped.length > 0 ? stoppedPart(stopped) : "";
|
|
64
|
+
return `run result: ${done.length} done, ${failedPart(failed)}${stoppedSuffix}`;
|
|
65
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { groupLabelRemovals } from "./groups.js";
|
|
5
|
+
|
|
6
|
+
// Shared by the overnight scripts of yaawn-implement-plan and
|
|
7
|
+
// yaawn-verify-executed-plan: both work on the same targets.
|
|
8
|
+
|
|
9
|
+
export interface Issue {
|
|
10
|
+
number: number;
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
dependsOn: number[];
|
|
14
|
+
externalDependsOn: number[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RawIssue {
|
|
18
|
+
number: number;
|
|
19
|
+
title: string;
|
|
20
|
+
body: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const plansDir = ".yaawn/plans";
|
|
24
|
+
const groupLabelColor = "5319E7";
|
|
25
|
+
const groupLabelDescription = "Issues with this label can be implemented together in parallel";
|
|
26
|
+
const execFileAsync = promisify(execFile);
|
|
27
|
+
|
|
28
|
+
export const resultPath = (issue: number): string =>
|
|
29
|
+
`${plansDir}/yaawn-${issue}-overnight-result.md`;
|
|
30
|
+
|
|
31
|
+
export const progressPath = (issue: number): string =>
|
|
32
|
+
`${plansDir}/yaawn-${issue}-progress.md`;
|
|
33
|
+
|
|
34
|
+
export const issueRefs = (issues: (number | string)[], prefix = "#"): string =>
|
|
35
|
+
issues.map((issue) => `${prefix}${issue}`).join(", ");
|
|
36
|
+
|
|
37
|
+
export function gh(args: string[]): string {
|
|
38
|
+
return execFileSync("gh", args, { encoding: "utf-8" });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function succeeds(command: string, args: string[]): boolean {
|
|
42
|
+
try {
|
|
43
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function ghAsync(args: string[]): Promise<string> {
|
|
51
|
+
const { stdout } = await execFileAsync("gh", args, { encoding: "utf-8" });
|
|
52
|
+
return stdout;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function succeedsAsync(command: string, args: string[]): Promise<boolean> {
|
|
56
|
+
try {
|
|
57
|
+
await execFileAsync(command, args);
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- labels
|
|
65
|
+
|
|
66
|
+
const labelNames = (output: string): string[] =>
|
|
67
|
+
output.split("\n").map((name) => name.trim()).filter(Boolean);
|
|
68
|
+
|
|
69
|
+
export function issueLabels(issue: number): string[] {
|
|
70
|
+
return labelNames(
|
|
71
|
+
gh(["issue", "view", String(issue), "--json", "labels", "--jq", ".labels[].name"]),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function addLabel(issue: number, label: string): void {
|
|
76
|
+
gh(["issue", "edit", String(issue), "--add-label", label]);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function removeLabel(issue: number, label: string): void {
|
|
80
|
+
gh(["issue", "edit", String(issue), "--remove-label", label]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function labelExists(label: string): boolean {
|
|
84
|
+
return labelNames(
|
|
85
|
+
gh(["label", "list", "--search", label, "--limit", "100", "--json", "name", "--jq", ".[].name"]),
|
|
86
|
+
).includes(label);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ensureGroupLabel(label: string): void {
|
|
90
|
+
if (labelExists(label)) return;
|
|
91
|
+
try {
|
|
92
|
+
gh(["label", "create", label, "--color", groupLabelColor, "--description", groupLabelDescription]);
|
|
93
|
+
console.log(`created label "${label}"`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
// Another run may have created it in the meantime ("already exists").
|
|
96
|
+
if (!labelExists(label)) throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function errorMessage(error: unknown): string {
|
|
101
|
+
return error instanceof Error ? error.message : String(error);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const firstLine = (text: string): string => text.split("\n")[0];
|
|
105
|
+
|
|
106
|
+
// The one reset of the implementation-group labels, shared by every skill that
|
|
107
|
+
// changes a plan. It only warns: a failed reset never blocks the caller.
|
|
108
|
+
export function removeGroupLabels(issue: number): void {
|
|
109
|
+
if (!Number.isSafeInteger(issue) || issue < 1) {
|
|
110
|
+
console.log(`WARN group labels not reset: invalid issue ${JSON.stringify(issue)}`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
groupLabelRemovals(issue, issueLabels(issue)).forEach(({ label }) => {
|
|
115
|
+
removeLabel(issue, label);
|
|
116
|
+
console.log(`removed label "${label}" on #${issue}`);
|
|
117
|
+
});
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.log(`WARN could not reset group labels on #${issue}: ${firstLine(errorMessage(error))}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --- issues
|
|
124
|
+
|
|
125
|
+
const dependsOnPattern = /\*\*Depends on:\*\*\s*(.+)/;
|
|
126
|
+
|
|
127
|
+
export function dependsOnLine(body: string): string {
|
|
128
|
+
return dependsOnPattern.exec(body)?.[0] ?? "";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseDependsOn(body: string): number[] {
|
|
132
|
+
const line = dependsOnPattern.exec(body)?.[1] ?? "";
|
|
133
|
+
return [...line.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function toIssue({ number, title, body }: RawIssue): Issue {
|
|
137
|
+
return {
|
|
138
|
+
number,
|
|
139
|
+
title,
|
|
140
|
+
body: body ?? "",
|
|
141
|
+
dependsOn: parseDependsOn(body ?? ""),
|
|
142
|
+
externalDependsOn: [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const fetchIssueArgs = (issue: number): string[] => [
|
|
147
|
+
"issue",
|
|
148
|
+
"view",
|
|
149
|
+
String(issue),
|
|
150
|
+
"--json",
|
|
151
|
+
"number,title,body",
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
const parseIssue = (output: string): Issue => toIssue(JSON.parse(output) as RawIssue);
|
|
155
|
+
|
|
156
|
+
export function fetchIssue(issue: number): Issue {
|
|
157
|
+
return parseIssue(gh(fetchIssueArgs(issue)));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function fetchIssueAsync(issue: number): Promise<Issue> {
|
|
161
|
+
return parseIssue(await ghAsync(fetchIssueArgs(issue)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function splitDependencies(targets: Issue[]): Issue[] {
|
|
165
|
+
const targetNumbers = new Set(targets.map((issue) => issue.number));
|
|
166
|
+
return targets.map((issue) => ({
|
|
167
|
+
...issue,
|
|
168
|
+
dependsOn: issue.dependsOn.filter((dep) => targetNumbers.has(dep)),
|
|
169
|
+
externalDependsOn: issue.dependsOn.filter((dep) => !targetNumbers.has(dep)),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function resolveTargets(parent: number): Issue[] {
|
|
174
|
+
return splitDependencies([fetchIssue(parent)]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function resolveTargetsAsync(parent: number): Promise<Issue[]> {
|
|
178
|
+
return splitDependencies([await fetchIssueAsync(parent)]);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const issueStateArgs = (issue: number): string[] => [
|
|
182
|
+
"api",
|
|
183
|
+
`repos/{owner}/{repo}/issues/${issue}`,
|
|
184
|
+
"--jq",
|
|
185
|
+
".state",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
export function isOpen(issue: number): boolean {
|
|
189
|
+
return gh(issueStateArgs(issue)).trim() === "open";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function isOpenAsync(issue: number): Promise<boolean> {
|
|
193
|
+
return (await ghAsync(issueStateArgs(issue))).trim() === "open";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function isDone(issue: number): boolean {
|
|
197
|
+
if (!existsSync(progressPath(issue))) return false;
|
|
198
|
+
const progress = readFileSync(progressPath(issue), "utf-8");
|
|
199
|
+
return progress.includes("- [x]") && !progress.includes("- [ ]");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Dependencies first; issues whose dependencies are equally settled keep
|
|
203
|
+
// ascending issue-number order.
|
|
204
|
+
export function executionOrder(issues: Issue[]): Issue[] {
|
|
205
|
+
const placed = new Set<number>();
|
|
206
|
+
const ordered: Issue[] = [];
|
|
207
|
+
let remaining = [...issues].sort((a, b) => a.number - b.number);
|
|
208
|
+
while (remaining.length > 0) {
|
|
209
|
+
const ready = remaining.filter((issue) =>
|
|
210
|
+
issue.dependsOn.every((dep) => placed.has(dep)),
|
|
211
|
+
);
|
|
212
|
+
if (ready.length === 0) {
|
|
213
|
+
throw new Error(
|
|
214
|
+
`Circular dependency among ${issueRefs(remaining.map((issue) => issue.number))}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
ready.forEach((issue) => placed.add(issue.number));
|
|
218
|
+
ordered.push(...ready);
|
|
219
|
+
remaining = remaining.filter((issue) => !placed.has(issue.number));
|
|
220
|
+
}
|
|
221
|
+
return ordered;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// The planning and run files under .yaawn/plans/ are not the human's changes.
|
|
225
|
+
const uncommittedChangesArgs = ["status", "--short", "--", ".", `:!${plansDir}`];
|
|
226
|
+
|
|
227
|
+
export function uncommittedChanges(): string {
|
|
228
|
+
return execFileSync("git", uncommittedChangesArgs, { encoding: "utf-8" });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function uncommittedChangesAsync(): Promise<string> {
|
|
232
|
+
const { stdout } = await execFileAsync("git", uncommittedChangesArgs, {
|
|
233
|
+
encoding: "utf-8",
|
|
234
|
+
});
|
|
235
|
+
return stdout;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const fenceMarkers = ["```", "~~~"];
|
|
239
|
+
const sectionBoundaryPattern = /^#{2,4} /;
|
|
240
|
+
|
|
241
|
+
function fenceMarkerOf(trimmedLine: string): string | null {
|
|
242
|
+
return fenceMarkers.find((marker) => trimmedLine.startsWith(marker)) ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function linesOutsideFences(lines: string[]): boolean[] {
|
|
246
|
+
let openFence: string | null = null;
|
|
247
|
+
return lines.map((line) => {
|
|
248
|
+
const marker = fenceMarkerOf(line.trim());
|
|
249
|
+
if (openFence === null && marker !== null) {
|
|
250
|
+
openFence = marker;
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
if (openFence !== null) {
|
|
254
|
+
if (marker === openFence) openFence = null;
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
return true;
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function sectionContent(body: string, heading: string): string {
|
|
262
|
+
const lines = body.split("\n");
|
|
263
|
+
const outside = linesOutsideFences(lines);
|
|
264
|
+
const marker = `### ${heading}`;
|
|
265
|
+
const start = lines.findIndex((line, index) => outside[index] && line.trim() === marker);
|
|
266
|
+
if (start === -1) return "";
|
|
267
|
+
const relativeEnd = lines
|
|
268
|
+
.slice(start + 1)
|
|
269
|
+
.findIndex(
|
|
270
|
+
(line, offset) =>
|
|
271
|
+
outside[start + 1 + offset] && sectionBoundaryPattern.test(line.trim()),
|
|
272
|
+
);
|
|
273
|
+
const end = relativeEnd === -1 ? lines.length : start + 1 + relativeEnd;
|
|
274
|
+
return lines
|
|
275
|
+
.slice(start + 1, end)
|
|
276
|
+
.join("\n")
|
|
277
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
278
|
+
.trim();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Cells may contain escaped pipes ("\|"); only unescaped pipes separate cells.
|
|
282
|
+
function tableBodyRows(markdown: string): string[][] {
|
|
283
|
+
const headerAndSeparator = 2;
|
|
284
|
+
return markdown
|
|
285
|
+
.split("\n")
|
|
286
|
+
.filter((line) => line.trim().startsWith("|"))
|
|
287
|
+
.slice(headerAndSeparator)
|
|
288
|
+
.map((line) =>
|
|
289
|
+
line
|
|
290
|
+
.split(/(?<!\\)\|/)
|
|
291
|
+
.slice(1, -1)
|
|
292
|
+
.map((cell) => cell.trim()),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function unansweredQuestionRows(body: string): string[][] {
|
|
297
|
+
return tableBodyRows(sectionContent(body, "Open Questions")).filter(
|
|
298
|
+
([, question, , answer]) => Boolean(question) && !answer,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function unansweredQuestions(body: string): string[] {
|
|
303
|
+
return unansweredQuestionRows(body).map(([id, question]) => id || question);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ID and text of each unanswered question, so it can be asked without
|
|
307
|
+
// re-reading the issue.
|
|
308
|
+
export function unansweredQuestionTexts(body: string): string[] {
|
|
309
|
+
return unansweredQuestionRows(body).map(([id, question]) =>
|
|
310
|
+
id ? `${id} ${question}` : question,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function missingPlanFailures(issue: Issue): string[] {
|
|
315
|
+
return sectionContent(issue.body, "LLM Plan")
|
|
316
|
+
? []
|
|
317
|
+
: [`#${issue.number} has no saved plan (run yaawn-save-plan)`];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function unansweredQuestionFailures(issue: Issue): string[] {
|
|
321
|
+
const missing = unansweredQuestions(issue.body);
|
|
322
|
+
return missing.length === 0
|
|
323
|
+
? []
|
|
324
|
+
: [`#${issue.number} unanswered questions: ${issueRefs(missing, "Q#")}`];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
type DependencyState = "open" | "closed" | { failure: string };
|
|
328
|
+
|
|
329
|
+
// Fails closed: a dependency whose state cannot be read blocks the issue.
|
|
330
|
+
async function dependencyState(issue: Issue, dependency: number): Promise<DependencyState> {
|
|
331
|
+
try {
|
|
332
|
+
return (await isOpenAsync(dependency)) ? "open" : "closed";
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return {
|
|
335
|
+
failure: `#${issue.number} could not check dependency #${dependency}: ${firstLine(errorMessage(error))}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Sync: the readiness checks of implement-plan's pre-flight, single and multi-issue.
|
|
341
|
+
function openExternalDependencyFailures(issue: Issue): string[] {
|
|
342
|
+
const openExternal = issue.externalDependsOn.filter(isOpen);
|
|
343
|
+
return openExternal.length === 0
|
|
344
|
+
? []
|
|
345
|
+
: [
|
|
346
|
+
`#${issue.number} depends on open issues outside this run: ${issueRefs(openExternal)}`,
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function issueFailures(issue: Issue): string[] {
|
|
351
|
+
return [
|
|
352
|
+
...missingPlanFailures(issue),
|
|
353
|
+
...unansweredQuestionFailures(issue),
|
|
354
|
+
...openExternalDependencyFailures(issue),
|
|
355
|
+
];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Async: every dependency lookup runs to completion, so all failures show at once.
|
|
359
|
+
async function openExternalDependencyFailuresAsync(issue: Issue): Promise<string[]> {
|
|
360
|
+
const states = await Promise.all(
|
|
361
|
+
issue.externalDependsOn.map((dependency) => dependencyState(issue, dependency)),
|
|
362
|
+
);
|
|
363
|
+
const openExternal = issue.externalDependsOn.filter((_, index) => states[index] === "open");
|
|
364
|
+
const lookupFailures = states.flatMap((state) =>
|
|
365
|
+
typeof state === "object" ? [state.failure] : [],
|
|
366
|
+
);
|
|
367
|
+
return openExternal.length === 0
|
|
368
|
+
? lookupFailures
|
|
369
|
+
: [
|
|
370
|
+
`#${issue.number} depends on open issues outside this run: ${issueRefs(openExternal)}`,
|
|
371
|
+
...lookupFailures,
|
|
372
|
+
];
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function issueFailuresAsync(issue: Issue): Promise<string[]> {
|
|
376
|
+
return [
|
|
377
|
+
...missingPlanFailures(issue),
|
|
378
|
+
...unansweredQuestionFailures(issue),
|
|
379
|
+
...(await openExternalDependencyFailuresAsync(issue)),
|
|
380
|
+
];
|
|
381
|
+
}
|