@yaawn/workflow 0.0.0-stage → 0.4.0
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 +234 -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 +77 -0
- package/agents/skills/yaawn-grill-story/references/story-checklist.md +44 -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 +84 -0
- package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +397 -0
- package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +539 -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,539 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
unlinkSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
dependsOnLine,
|
|
14
|
+
removeGroupLabels,
|
|
15
|
+
} from "../../yaawn-implement-plan/scripts/targets.js";
|
|
16
|
+
import {
|
|
17
|
+
branchName,
|
|
18
|
+
branchWorktreeFromPorcelain,
|
|
19
|
+
currentToplevel,
|
|
20
|
+
defaultBranches,
|
|
21
|
+
entriesFromPorcelain,
|
|
22
|
+
listPorcelain,
|
|
23
|
+
mainCopyFromPorcelain,
|
|
24
|
+
} from "../../yaawn-implement-plan/scripts/worktrees.js";
|
|
25
|
+
import {
|
|
26
|
+
type Section,
|
|
27
|
+
answerOpenQuestions,
|
|
28
|
+
buildBody,
|
|
29
|
+
filesToChangeContent,
|
|
30
|
+
findSection,
|
|
31
|
+
headingList,
|
|
32
|
+
humanReadableTitle,
|
|
33
|
+
openQuestions,
|
|
34
|
+
openQuestionsTitle,
|
|
35
|
+
ownLlmPlanContent,
|
|
36
|
+
planSubsections,
|
|
37
|
+
readyBlocker,
|
|
38
|
+
toLines,
|
|
39
|
+
} from "./plan-body.js";
|
|
40
|
+
|
|
41
|
+
const plansDir = ".yaawn/plans";
|
|
42
|
+
const refinementLabel = "Yaawn: To Be Refined";
|
|
43
|
+
const readyLabel = "Yaawn: Ready To Implement";
|
|
44
|
+
const readyLabelColor = "0E8A16";
|
|
45
|
+
const readyLabelDescription =
|
|
46
|
+
"Plan saved, no open questions — ready for /yaawn-implement-plan";
|
|
47
|
+
const usage =
|
|
48
|
+
"Usage: npx tsx .agents/skills/yaawn-save-plan/scripts/save-plan.ts <find [issue]|open-questions <issue>|answer <issue>|preview <issue>|save <issue>>";
|
|
49
|
+
|
|
50
|
+
const issuePattern = /^[1-9]\d*$/;
|
|
51
|
+
const planFilePattern = /^yaawn-([1-9]\d*)-plan\.md$/;
|
|
52
|
+
const issueBranchPattern = /^yaawn\/[1-9]\d*$/;
|
|
53
|
+
|
|
54
|
+
export type Resolution<T> = { ok: true; value: T } | { ok: false; reason: string };
|
|
55
|
+
|
|
56
|
+
export interface WorktreeSearch {
|
|
57
|
+
porcelain: string;
|
|
58
|
+
currentToplevel: string;
|
|
59
|
+
defaultBranches: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PlanFiles {
|
|
63
|
+
plan: string;
|
|
64
|
+
humanReadable: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PlanCandidate {
|
|
68
|
+
issue: number;
|
|
69
|
+
worktree: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const planPath = (worktree: string, issue: number): string =>
|
|
73
|
+
path.join(worktree, plansDir, `yaawn-${issue}-plan.md`);
|
|
74
|
+
|
|
75
|
+
const humanReadablePath = (worktree: string, issue: number): string =>
|
|
76
|
+
path.join(worktree, plansDir, `yaawn-${issue}-human-readable.md`);
|
|
77
|
+
|
|
78
|
+
// The issue body snapshots always stay in the current worktree.
|
|
79
|
+
const currentBodyPath = (issue: number): string =>
|
|
80
|
+
`${plansDir}/yaawn-${issue}-body-current.md`;
|
|
81
|
+
|
|
82
|
+
const newBodyPath = (issue: number): string =>
|
|
83
|
+
`${plansDir}/yaawn-${issue}-body.md`;
|
|
84
|
+
|
|
85
|
+
// The user's answers to the plan's open questions — written by the agent,
|
|
86
|
+
// consumed and deleted by the answer command. Like the body snapshots, it
|
|
87
|
+
// lives in the current worktree.
|
|
88
|
+
const answersPath = (issue: number): string =>
|
|
89
|
+
`${plansDir}/yaawn-${issue}-answers.json`;
|
|
90
|
+
|
|
91
|
+
// The issue number ends up in file paths and branch names, so only a
|
|
92
|
+
// positive integer is accepted.
|
|
93
|
+
export function parseIssue(argument: string | undefined): number | null {
|
|
94
|
+
if (!issuePattern.test(argument ?? "")) return null;
|
|
95
|
+
const issue = Number(argument);
|
|
96
|
+
return Number.isSafeInteger(issue) ? issue : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const uniquePaths = (worktrees: (string | null)[]): string[] => [
|
|
100
|
+
...new Set(
|
|
101
|
+
worktrees
|
|
102
|
+
.filter((worktree): worktree is string => worktree !== null)
|
|
103
|
+
.map((worktree) => path.resolve(worktree)),
|
|
104
|
+
),
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// --- plan file resolution
|
|
108
|
+
|
|
109
|
+
// Order matters: the current worktree first, then the issue's own, then the
|
|
110
|
+
// main copy.
|
|
111
|
+
export function planCandidates(issue: number, search: WorktreeSearch): string[] {
|
|
112
|
+
return uniquePaths([
|
|
113
|
+
search.currentToplevel,
|
|
114
|
+
branchWorktreeFromPorcelain(search.porcelain, branchName(issue)),
|
|
115
|
+
mainCopyFromPorcelain(search.porcelain, search.defaultBranches),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const listedWorktrees = (worktrees: string[]): string =>
|
|
120
|
+
worktrees.map((worktree) => ` - ${worktree}`).join("\n");
|
|
121
|
+
|
|
122
|
+
// The current worktree wins; elsewhere exactly one holder is required — a
|
|
123
|
+
// guess between several could save the wrong plan.
|
|
124
|
+
export function resolvePlanDir(
|
|
125
|
+
issue: number,
|
|
126
|
+
candidates: string[],
|
|
127
|
+
hasFile: (file: string) => boolean,
|
|
128
|
+
): Resolution<string> {
|
|
129
|
+
const [current, ...others] = candidates;
|
|
130
|
+
if (current !== undefined && hasFile(planPath(current, issue))) {
|
|
131
|
+
return { ok: true, value: current };
|
|
132
|
+
}
|
|
133
|
+
const holders = others.filter((worktree) => hasFile(planPath(worktree, issue)));
|
|
134
|
+
if (holders.length === 1) return { ok: true, value: holders[0] as string };
|
|
135
|
+
if (holders.length > 1) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: `several worktrees hold yaawn-${issue}-plan.md — run this skill in the one whose plan you want to save:\n${listedWorktrees(holders)}`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
reason: `no worktree holds yaawn-${issue}-plan.md — start a new session and run /yaawn-create-plan ${issue} first; searched:\n${listedWorktrees(candidates)}`,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- issue inference
|
|
148
|
+
|
|
149
|
+
export function inferenceCandidates(search: WorktreeSearch): string[] {
|
|
150
|
+
const issueWorktrees = entriesFromPorcelain(search.porcelain)
|
|
151
|
+
.filter((entry) => !entry.bare && issueBranchPattern.test(entry.branch ?? ""))
|
|
152
|
+
.map((entry) => entry.path);
|
|
153
|
+
return uniquePaths([
|
|
154
|
+
search.currentToplevel,
|
|
155
|
+
...issueWorktrees,
|
|
156
|
+
mainCopyFromPorcelain(search.porcelain, search.defaultBranches),
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const plansIn = (
|
|
161
|
+
worktree: string,
|
|
162
|
+
planFiles: (worktree: string) => string[],
|
|
163
|
+
): PlanCandidate[] =>
|
|
164
|
+
planFiles(worktree)
|
|
165
|
+
.map((file) => planFilePattern.exec(file)?.[1])
|
|
166
|
+
.filter((issue): issue is string => issue !== undefined)
|
|
167
|
+
.map((issue) => ({ issue: Number(issue), worktree }));
|
|
168
|
+
|
|
169
|
+
const listedPlans = (plans: PlanCandidate[]): string =>
|
|
170
|
+
plans.map(({ issue, worktree }) => ` - #${issue} in ${worktree}`).join("\n");
|
|
171
|
+
|
|
172
|
+
export function inferIssue(
|
|
173
|
+
candidates: string[],
|
|
174
|
+
planFiles: (worktree: string) => string[],
|
|
175
|
+
): Resolution<number> {
|
|
176
|
+
const plans = candidates.flatMap((worktree) => plansIn(worktree, planFiles));
|
|
177
|
+
const issues = new Set(plans.map(({ issue }) => issue));
|
|
178
|
+
if (issues.size === 1) return { ok: true, value: [...issues][0] as number };
|
|
179
|
+
if (issues.size > 1) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: `several plan files found — which issue should be saved?\n${listedPlans(plans)}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason: "no plan file found — which issue number should be saved?",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- git and file system
|
|
192
|
+
|
|
193
|
+
// Without git worktrees (no git, no repository) only the current directory
|
|
194
|
+
// is searched — the behavior before worktree support.
|
|
195
|
+
function worktreeSearch(): WorktreeSearch | null {
|
|
196
|
+
try {
|
|
197
|
+
return {
|
|
198
|
+
porcelain: listPorcelain(),
|
|
199
|
+
currentToplevel: currentToplevel(),
|
|
200
|
+
defaultBranches: defaultBranches(),
|
|
201
|
+
};
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function planFilesOf(worktree: string): string[] {
|
|
208
|
+
try {
|
|
209
|
+
return readdirSync(path.join(worktree, plansDir));
|
|
210
|
+
} catch {
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const unwrap = <T>(resolution: Resolution<T>): T => {
|
|
216
|
+
if (!resolution.ok) throw new Error(resolution.reason);
|
|
217
|
+
return resolution.value;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
function findPlanFiles(issue: number, search: WorktreeSearch | null): PlanFiles {
|
|
221
|
+
const candidates = search ? planCandidates(issue, search) : [process.cwd()];
|
|
222
|
+
const worktree = unwrap(resolvePlanDir(issue, candidates, existsSync));
|
|
223
|
+
return {
|
|
224
|
+
plan: planPath(worktree, issue),
|
|
225
|
+
humanReadable: humanReadablePath(worktree, issue),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function findIssue(search: WorktreeSearch | null): number {
|
|
230
|
+
const candidates = search ? inferenceCandidates(search) : [process.cwd()];
|
|
231
|
+
return unwrap(inferIssue(candidates, planFilesOf));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function gh(args: string[]): string {
|
|
235
|
+
return execFileSync("gh", args, { encoding: "utf-8" });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function readRequiredFile(file: string, hint: string): string {
|
|
239
|
+
if (!existsSync(file)) throw new Error(`${file} not found — ${hint}`);
|
|
240
|
+
const content = readFileSync(file, "utf-8").trim();
|
|
241
|
+
if (!content) throw new Error(`${file} is empty — ${hint}`);
|
|
242
|
+
return content;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function readAnswers(file: string): Map<string, string> {
|
|
246
|
+
if (!existsSync(file)) {
|
|
247
|
+
throw new Error(`${file} not found — write the user's answers there first`);
|
|
248
|
+
}
|
|
249
|
+
let parsed: unknown;
|
|
250
|
+
try {
|
|
251
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
252
|
+
} catch (error) {
|
|
253
|
+
throw new Error(`${file} is not valid JSON — ${errorMessage(error)}`);
|
|
254
|
+
}
|
|
255
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`${file} must hold a JSON object mapping question numbers to answers`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const answers = new Map<string, string>();
|
|
261
|
+
Object.entries(parsed).forEach(([number, answer]) => {
|
|
262
|
+
if (typeof answer !== "string" || answer.trim() === "") {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${file}: the answer for #${number} must be a non-empty string`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
answers.set(number, answer);
|
|
268
|
+
});
|
|
269
|
+
if (answers.size === 0) throw new Error(`${file} holds no answers`);
|
|
270
|
+
return answers;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function readPlanSubsections(planFile: string): Map<string, string> {
|
|
274
|
+
const lines = toLines(readRequiredFile(planFile, "run /yaawn-create-plan first"));
|
|
275
|
+
const sections = planSubsections.map((title) => ({
|
|
276
|
+
title,
|
|
277
|
+
section: findSection(lines, 3, title),
|
|
278
|
+
}));
|
|
279
|
+
const missing = sections.filter(({ section }) => section === null);
|
|
280
|
+
if (missing.length > 0) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`${planFile} lacks: ${headingList(missing.map(({ title }) => title))}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return new Map(
|
|
286
|
+
sections.map(({ title, section }) => [
|
|
287
|
+
title,
|
|
288
|
+
lines
|
|
289
|
+
.slice((section as Section).contentStart, (section as Section).contentEnd)
|
|
290
|
+
.join("\n")
|
|
291
|
+
.trim(),
|
|
292
|
+
]),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function readNewContent(files: PlanFiles): Map<string, string> {
|
|
297
|
+
const content = readPlanSubsections(files.plan);
|
|
298
|
+
content.set(
|
|
299
|
+
humanReadableTitle,
|
|
300
|
+
readRequiredFile(
|
|
301
|
+
files.humanReadable,
|
|
302
|
+
"write the Human Readable summary first",
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
return content;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function fetchBody(issue: number): string {
|
|
309
|
+
return gh(["issue", "view", String(issue), "--json", "body", "--jq", ".body"])
|
|
310
|
+
.replace(/\r\n/g, "\n")
|
|
311
|
+
.trimEnd();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// diff exits 1 when the files differ; its output is the result, not an error.
|
|
315
|
+
function diffOf(currentPath: string, newPath: string): string {
|
|
316
|
+
try {
|
|
317
|
+
execFileSync("diff", ["-u", currentPath, newPath], { encoding: "utf-8" });
|
|
318
|
+
return "no changes";
|
|
319
|
+
} catch (error) {
|
|
320
|
+
const { stdout } = error as { stdout?: string };
|
|
321
|
+
if (stdout) return stdout;
|
|
322
|
+
throw error;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function preview(issue: number, files: PlanFiles): void {
|
|
327
|
+
const content = readNewContent(files);
|
|
328
|
+
const body = fetchBody(issue);
|
|
329
|
+
writeFileSync(currentBodyPath(issue), `${body}\n`);
|
|
330
|
+
writeFileSync(newBodyPath(issue), buildBody(body, content));
|
|
331
|
+
console.log(`existing plan: ${ownLlmPlanContent(body) ? "yes" : "no"}`);
|
|
332
|
+
console.log("--- diff of the issue body");
|
|
333
|
+
console.log(diffOf(currentBodyPath(issue), newBodyPath(issue)));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function issueLabels(issue: number): string[] {
|
|
337
|
+
return gh([
|
|
338
|
+
"issue",
|
|
339
|
+
"view",
|
|
340
|
+
String(issue),
|
|
341
|
+
"--json",
|
|
342
|
+
"labels",
|
|
343
|
+
"--jq",
|
|
344
|
+
".labels[].name",
|
|
345
|
+
])
|
|
346
|
+
.split("\n")
|
|
347
|
+
.filter((label) => label !== "");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function hasLabel(issue: number, label: string): boolean {
|
|
351
|
+
return issueLabels(issue).includes(label);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function removeRefinementLabel(issue: number): void {
|
|
355
|
+
if (!hasLabel(issue, refinementLabel)) {
|
|
356
|
+
console.log(`label "${refinementLabel}" not present`);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
gh(["issue", "edit", String(issue), "--remove-label", refinementLabel]);
|
|
360
|
+
console.log(`removed label "${refinementLabel}"`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const errorMessage = (error: unknown): string =>
|
|
364
|
+
error instanceof Error ? error.message.trim() : String(error);
|
|
365
|
+
|
|
366
|
+
// Runs after the body is saved: a label failure must not fail the save.
|
|
367
|
+
function markReady(issue: number, content: Map<string, string>): void {
|
|
368
|
+
const blocker = readyBlocker(content);
|
|
369
|
+
if (blocker) {
|
|
370
|
+
console.log(`not marked ready: ${blocker}`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
ensureReadyLabelExists();
|
|
375
|
+
gh(["issue", "edit", String(issue), "--add-label", readyLabel]);
|
|
376
|
+
console.log(`added label "${readyLabel}"`);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
console.warn(
|
|
379
|
+
`warning: could not add label "${readyLabel}" — add it manually: ${errorMessage(error)}`,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Both sections feed the implementation grouping, so a change to either
|
|
385
|
+
// invalidates the issue's group labels.
|
|
386
|
+
function groupingInputsChanged(oldBody: string, newBody: string): boolean {
|
|
387
|
+
return (
|
|
388
|
+
filesToChangeContent(oldBody) !== filesToChangeContent(newBody) ||
|
|
389
|
+
dependsOnLine(oldBody) !== dependsOnLine(newBody)
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function labelExists(label: string): boolean {
|
|
394
|
+
return gh([
|
|
395
|
+
"label",
|
|
396
|
+
"list",
|
|
397
|
+
"--search",
|
|
398
|
+
label,
|
|
399
|
+
"--limit",
|
|
400
|
+
"100",
|
|
401
|
+
"--json",
|
|
402
|
+
"name",
|
|
403
|
+
"--jq",
|
|
404
|
+
".[].name",
|
|
405
|
+
])
|
|
406
|
+
.split("\n")
|
|
407
|
+
.includes(label);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function ensureReadyLabelExists(): void {
|
|
411
|
+
if (labelExists(readyLabel)) return;
|
|
412
|
+
try {
|
|
413
|
+
gh([
|
|
414
|
+
"label",
|
|
415
|
+
"create",
|
|
416
|
+
readyLabel,
|
|
417
|
+
"--color",
|
|
418
|
+
readyLabelColor,
|
|
419
|
+
"--description",
|
|
420
|
+
readyLabelDescription,
|
|
421
|
+
]);
|
|
422
|
+
console.log(`created label "${readyLabel}"`);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
// Another run may have created it in the meantime ("already exists").
|
|
425
|
+
if (!labelExists(readyLabel)) throw error;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function save(issue: number, files: PlanFiles): void {
|
|
430
|
+
const content = readNewContent(files);
|
|
431
|
+
const oldBody = fetchBody(issue);
|
|
432
|
+
const newBody = buildBody(oldBody, content);
|
|
433
|
+
writeFileSync(newBodyPath(issue), newBody);
|
|
434
|
+
gh(["issue", "edit", String(issue), "--body-file", newBodyPath(issue)]);
|
|
435
|
+
console.log(`saved the plan to #${issue}`);
|
|
436
|
+
removeRefinementLabel(issue);
|
|
437
|
+
if (groupingInputsChanged(oldBody, newBody)) removeGroupLabels(issue);
|
|
438
|
+
markReady(issue, content);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function find(issueArgument: string | undefined): void {
|
|
442
|
+
const search = worktreeSearch();
|
|
443
|
+
const issue =
|
|
444
|
+
issueArgument === undefined ? findIssue(search) : parseIssue(issueArgument);
|
|
445
|
+
if (issue === null) throw new Error(usage);
|
|
446
|
+
const files = findPlanFiles(issue, search);
|
|
447
|
+
console.log(`issue: ${issue}`);
|
|
448
|
+
console.log(`plan: ${files.plan}`);
|
|
449
|
+
console.log(`summary: ${files.humanReadable}`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function openQuestionsCommand(issueArgument: string | undefined): void {
|
|
453
|
+
const issue = parseIssue(issueArgument);
|
|
454
|
+
if (issue === null) throw new Error(usage);
|
|
455
|
+
const files = findPlanFiles(issue, worktreeSearch());
|
|
456
|
+
const content = readPlanSubsections(files.plan);
|
|
457
|
+
const questions = openQuestions(content.get(openQuestionsTitle) ?? "");
|
|
458
|
+
// The answers file lands in the current worktree — make sure it exists.
|
|
459
|
+
mkdirSync(plansDir, { recursive: true });
|
|
460
|
+
console.log(`plan: ${files.plan}`);
|
|
461
|
+
console.log(`open questions: ${questions.length}`);
|
|
462
|
+
questions.forEach(({ number, question, owner }) => {
|
|
463
|
+
console.log(
|
|
464
|
+
`#${number} ${question}${owner === "" ? "" : ` (owner: ${owner})`}`,
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function answerCommand(issueArgument: string | undefined): void {
|
|
470
|
+
const issue = parseIssue(issueArgument);
|
|
471
|
+
if (issue === null) throw new Error(usage);
|
|
472
|
+
const files = findPlanFiles(issue, worktreeSearch());
|
|
473
|
+
const answers = readAnswers(answersPath(issue));
|
|
474
|
+
const plan = readRequiredFile(files.plan, "run /yaawn-create-plan first");
|
|
475
|
+
const lines = toLines(plan);
|
|
476
|
+
const section = findSection(lines, 3, openQuestionsTitle);
|
|
477
|
+
if (!section) {
|
|
478
|
+
throw new Error(`${files.plan} lacks: ### ${openQuestionsTitle}`);
|
|
479
|
+
}
|
|
480
|
+
const result = answerOpenQuestions(
|
|
481
|
+
lines.slice(section.contentStart, section.contentEnd).join("\n"),
|
|
482
|
+
answers,
|
|
483
|
+
);
|
|
484
|
+
if (result.unknown.length > 0) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`no open question(s) ${result.unknown.map((number) => `#${number}`).join(", ")} in ${files.plan} — the plan changed since the questions were listed; run open-questions again`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
if (result.answered.length === 0) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`no answers applied — ${answersPath(issue)} holds ${answers.size} answer(s), none matched an open question`,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
writeFileSync(
|
|
495
|
+
files.plan,
|
|
496
|
+
`${[
|
|
497
|
+
...lines.slice(0, section.contentStart),
|
|
498
|
+
...toLines(result.section),
|
|
499
|
+
...lines.slice(section.contentEnd),
|
|
500
|
+
].join("\n").trimEnd()}\n`,
|
|
501
|
+
);
|
|
502
|
+
unlinkSync(answersPath(issue));
|
|
503
|
+
console.log(
|
|
504
|
+
`answered ${result.answered.length} open question(s) in ${files.plan}: ${result.answered.map((number) => `#${number}`).join(", ")}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function saveCommand(command: string, issueArgument: string | undefined): void {
|
|
509
|
+
const issue = parseIssue(issueArgument);
|
|
510
|
+
if (issue === null) throw new Error(usage);
|
|
511
|
+
const files = findPlanFiles(issue, worktreeSearch());
|
|
512
|
+
mkdirSync(plansDir, { recursive: true });
|
|
513
|
+
if (command === "preview") preview(issue, files);
|
|
514
|
+
else save(issue, files);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function main(): void {
|
|
518
|
+
const [command, issueArgument] = process.argv.slice(2);
|
|
519
|
+
if (command === "find") find(issueArgument);
|
|
520
|
+
else if (command === "open-questions") openQuestionsCommand(issueArgument);
|
|
521
|
+
else if (command === "answer") answerCommand(issueArgument);
|
|
522
|
+
else if (command === "preview" || command === "save")
|
|
523
|
+
saveCommand(command, issueArgument);
|
|
524
|
+
else throw new Error(usage);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const runsAsScript =
|
|
528
|
+
process.argv[1] !== undefined &&
|
|
529
|
+
existsSync(process.argv[1]) &&
|
|
530
|
+
path.basename(realpathSync(process.argv[1])) === "save-plan.ts";
|
|
531
|
+
|
|
532
|
+
if (runsAsScript) {
|
|
533
|
+
try {
|
|
534
|
+
main();
|
|
535
|
+
} catch (error: unknown) {
|
|
536
|
+
console.error(error instanceof Error ? error.message : error);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
}
|