@yaawn/workflow 0.4.0 → 0.5.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.
Files changed (89) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +3 -3
  3. package/agents/skills/yaawn-create-plan/SKILL.md +38 -11
  4. package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
  5. package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
  6. package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
  7. package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
  8. package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
  9. package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
  10. package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
  11. package/agents/skills/yaawn-grill-story/SKILL.md +7 -0
  12. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  13. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  14. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  15. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  16. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  17. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  18. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  19. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  20. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  21. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  22. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  23. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  24. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  25. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  26. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  27. package/agents/skills/yaawn-write-user-story/SKILL.md +3 -2
  28. package/agents/yaawn-glossary.md +4 -3
  29. package/config/install-tasks.json +0 -1
  30. package/config/wizard.json +3 -8
  31. package/dist/cli/args.js +66 -0
  32. package/dist/cli/errors.js +103 -0
  33. package/dist/cli/exit-codes.js +3 -0
  34. package/dist/cli/index.js +11 -0
  35. package/dist/cli/install-report.js +92 -0
  36. package/dist/cli/install.js +124 -0
  37. package/dist/cli/main.js +17 -0
  38. package/dist/cli/package-root.js +17 -0
  39. package/dist/cli/project-root.js +11 -0
  40. package/dist/cli/run.js +141 -0
  41. package/dist/cli/session.js +1 -0
  42. package/dist/cli/terminal.js +5 -0
  43. package/dist/cli/uninstall.js +89 -0
  44. package/dist/cli/version.js +7 -0
  45. package/dist/config/agents.js +108 -0
  46. package/dist/config/config.js +118 -0
  47. package/dist/config/errors.js +45 -0
  48. package/dist/config/index.js +11 -0
  49. package/dist/config/manifest.js +28 -0
  50. package/dist/config/messages.js +86 -0
  51. package/dist/config/names.js +40 -0
  52. package/dist/config/paths.js +73 -0
  53. package/dist/config/settings.js +101 -0
  54. package/dist/config/strict-json.js +119 -0
  55. package/dist/config/tasks.js +77 -0
  56. package/dist/config/wizard.js +139 -0
  57. package/dist/display/index.js +3 -0
  58. package/dist/display/palette.js +61 -0
  59. package/dist/display/renderer.js +169 -0
  60. package/dist/display/report.js +15 -0
  61. package/dist/display/sanitize.js +10 -0
  62. package/dist/link/errors.js +34 -0
  63. package/dist/link/fs-utils.js +79 -0
  64. package/dist/link/index.js +4 -0
  65. package/dist/link/install.js +20 -0
  66. package/dist/link/linker.js +137 -0
  67. package/dist/link/memory.js +221 -0
  68. package/dist/link/names.js +30 -0
  69. package/dist/link/types.js +30 -0
  70. package/dist/link/uninstall.js +90 -0
  71. package/dist/sync/apply.js +74 -0
  72. package/dist/sync/changes.js +31 -0
  73. package/dist/sync/fs-utils.js +63 -0
  74. package/dist/sync/index.js +7 -0
  75. package/dist/sync/install.js +57 -0
  76. package/dist/sync/languages.js +80 -0
  77. package/dist/sync/plan.js +146 -0
  78. package/dist/sync/projects.js +294 -0
  79. package/dist/sync/uninstall.js +234 -0
  80. package/dist/wizard/build.js +89 -0
  81. package/dist/wizard/index.js +5 -0
  82. package/dist/wizard/items.js +83 -0
  83. package/dist/wizard/prompts.js +167 -0
  84. package/dist/wizard/section.js +143 -0
  85. package/dist/wizard/wizard.js +73 -0
  86. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  87. package/package.json +11 -10
  88. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  89. package/scripts/install.js +0 -73
@@ -0,0 +1,7 @@
1
+ export { applySync, SyncApplyError } from "./apply.js";
2
+ export { planFileChanges, writeFileChanges, } from "./changes.js";
3
+ export { applyInstall, InstallError, planInstall, } from "./install.js";
4
+ export { allLanguageTags, detectLanguages, expandLanguages, installedLanguageTags, languageLabel, } from "./languages.js";
5
+ export { planSync } from "./plan.js";
6
+ export { captureProjects, findProjectsLine, mergeProjects, parseFlowList, } from "./projects.js";
7
+ export { applyUninstall, isUninstallPlanEmpty, isYaawnInstalled, PLANS_DIR, planUninstall, uninstallEntries, UninstallError, } from "./uninstall.js";
@@ -0,0 +1,57 @@
1
+ import { applySync, SyncApplyError } from "./apply.js";
2
+ import { planFileChanges, writeFileChanges } from "./changes.js";
3
+ import { planSync } from "./plan.js";
4
+ import { captureProjects, mergeProjects } from "./projects.js";
5
+ /** A failed install step; result holds what was done before the failure. */
6
+ export class InstallError extends Error {
7
+ stage;
8
+ result;
9
+ name = "InstallError";
10
+ constructor(stage, result, cause) {
11
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
12
+ this.stage = stage;
13
+ this.result = result;
14
+ }
15
+ }
16
+ const NO_PROJECTS = { updated: [], skipped: [] };
17
+ /**
18
+ * Captures the issue templates' project names, plans the sync and
19
+ * validates the wizard's file changes. It only reads, so any error here
20
+ * leaves the project untouched.
21
+ */
22
+ export async function planInstall(config, packageRoot, projectRoot, input) {
23
+ return {
24
+ packageRoot,
25
+ projectRoot,
26
+ capture: await captureProjects(config, projectRoot),
27
+ sync: await planSync(config, packageRoot, projectRoot, input.languages),
28
+ changes: planFileChanges(projectRoot, input.changes),
29
+ projectNames: [...input.projectNames],
30
+ };
31
+ }
32
+ /**
33
+ * Syncs the payload, restores the issue templates' project names merged
34
+ * with the entered ones and writes the wizard changes, in that order.
35
+ * Stops at the first failure with an InstallError. Saving the agent and
36
+ * linking are left to the caller.
37
+ */
38
+ export async function applyInstall(config, plan) {
39
+ const sync = await runStage("sync", () => applySync(plan.sync), (error) => syncSoFar(plan, error));
40
+ const projects = await runStage("projects", () => mergeProjects(config, plan.projectRoot, plan.capture, plan.projectNames), () => ({ sync, projects: NO_PROJECTS, changes: [] }));
41
+ const changes = await runStage("changes", () => writeFileChanges(plan.projectRoot, plan.changes), () => ({ sync, projects, changes: [] }));
42
+ return { sync, projects, changes };
43
+ }
44
+ async function runStage(stage, action, resultSoFar) {
45
+ try {
46
+ return await action();
47
+ }
48
+ catch (error) {
49
+ throw new InstallError(stage, resultSoFar(error), error);
50
+ }
51
+ }
52
+ function syncSoFar(plan, error) {
53
+ const sync = error instanceof SyncApplyError
54
+ ? error.result
55
+ : { targets: [], languages: plan.sync.languages, hasLanguageFiles: plan.sync.hasLanguageFiles };
56
+ return { sync, projects: NO_PROJECTS, changes: [] };
57
+ }
@@ -0,0 +1,80 @@
1
+ import { contain } from "../config/index.js";
2
+ import { compareNames, pathExists, readSortedDir } from "./fs-utils.js";
3
+ /**
4
+ * Collects the tagged files directly inside every target's
5
+ * languageInstructions directory. A language counts as installed when one
6
+ * of its files already exists in the project.
7
+ */
8
+ export async function detectLanguages(config, packageRoot, projectRoot) {
9
+ const files = [];
10
+ const installed = new Set();
11
+ for (const target of config.tasks.syncTargets) {
12
+ const found = await targetLanguageFiles(config, target, packageRoot);
13
+ for (const file of found) {
14
+ if (await isLanguageFileInstalled(file, projectRoot)) {
15
+ installed.add(file.tag);
16
+ }
17
+ }
18
+ files.push(...found);
19
+ }
20
+ return { files, options: languageOptions(files, installed, config.settings.languageLabels) };
21
+ }
22
+ async function targetLanguageFiles(config, target, packageRoot) {
23
+ if (target.languageInstructions === undefined) {
24
+ return [];
25
+ }
26
+ const entries = await readSortedDir(contain(packageRoot, target.source, target.languageInstructions));
27
+ const files = [];
28
+ for (const entry of entries ?? []) {
29
+ const tag = languageTag(config, entry.name);
30
+ if (tag !== undefined && entry.isFile()) {
31
+ files.push({ target, name: entry.name, tag });
32
+ }
33
+ }
34
+ return files;
35
+ }
36
+ async function isLanguageFileInstalled(file, projectRoot) {
37
+ const destination = contain(projectRoot, file.target.target, file.target.languageInstructions ?? "", file.name);
38
+ return pathExists(destination);
39
+ }
40
+ /** The language tag captured from a file name, or undefined for untagged files. */
41
+ export function languageTag(config, name) {
42
+ const match = config.settings.languageRegExp.exec(name);
43
+ const tag = match?.[1];
44
+ return tag === undefined || tag === "" ? undefined : tag;
45
+ }
46
+ function languageOptions(files, installed, labels) {
47
+ const tags = [...new Set(files.map((file) => file.tag))].sort(compareNames);
48
+ return tags.map((tag) => ({ tag, label: languageLabel(tag, labels), installed: installed.has(tag) }));
49
+ }
50
+ /** The configured label of tag, or the capitalized tag. */
51
+ export function languageLabel(tag, labels) {
52
+ if (Object.hasOwn(labels, tag)) {
53
+ return labels[tag] ?? tag;
54
+ }
55
+ return tag.charAt(0).toUpperCase() + tag.slice(1);
56
+ }
57
+ /** Every detected tag, sorted. */
58
+ export function allLanguageTags(languages) {
59
+ return languages.options.map((option) => option.tag);
60
+ }
61
+ /** The tags already installed in the project; the wizard preselects them. */
62
+ export function installedLanguageTags(languages) {
63
+ return languages.options.filter((option) => option.installed).map((option) => option.tag);
64
+ }
65
+ /**
66
+ * Returns selected plus the tags each selected tag implies. Implications
67
+ * apply one way only: an implied tag never adds the tag that implied it.
68
+ */
69
+ export function expandLanguages(selected, implications) {
70
+ const expanded = [...selected];
71
+ for (const tag of selected) {
72
+ const implied = Object.hasOwn(implications, tag) ? (implications[tag] ?? []) : [];
73
+ for (const impliedTag of implied) {
74
+ if (!expanded.includes(impliedTag)) {
75
+ expanded.push(impliedTag);
76
+ }
77
+ }
78
+ }
79
+ return expanded;
80
+ }
@@ -0,0 +1,146 @@
1
+ import { lstat, realpath, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { contain, installedName, isSeedOnly, removeDir } from "../config/index.js";
4
+ import { EXEC_MODE, FILE_MODE, pathExists, readSortedDir } from "./fs-utils.js";
5
+ import { detectLanguages, expandLanguages, languageTag } from "./languages.js";
6
+ /**
7
+ * Computes the sync of every target for the selected language tags. It
8
+ * only reads the file system and validates every destination, so a
9
+ * failing plan writes nothing. Untagged files always install, and so do
10
+ * the files of every tag a selected tag implies.
11
+ */
12
+ export async function planSync(config, packageRoot, projectRoot, selectedLanguages) {
13
+ const planner = new SyncPlanner(config, packageRoot, projectRoot, selectedLanguages);
14
+ const languages = await detectLanguages(config, packageRoot, projectRoot);
15
+ const targets = [];
16
+ for (const target of config.tasks.syncTargets) {
17
+ targets.push(await planner.planTarget(target));
18
+ }
19
+ return {
20
+ projectRoot,
21
+ targets,
22
+ languages: selectedOptions(languages, selectedLanguages),
23
+ hasLanguageFiles: languages.files.length > 0,
24
+ };
25
+ }
26
+ function selectedOptions(languages, selected) {
27
+ return languages.options.filter((option) => selected.includes(option.tag));
28
+ }
29
+ class SyncPlanner {
30
+ config;
31
+ packageRoot;
32
+ projectRoot;
33
+ selected;
34
+ userOwned;
35
+ constructor(config, packageRoot, projectRoot, selectedLanguages) {
36
+ this.config = config;
37
+ this.packageRoot = packageRoot;
38
+ this.projectRoot = projectRoot;
39
+ this.selected = expandLanguages(selectedLanguages, config.settings.languageImplications);
40
+ this.userOwned = config.userOwnedFiles();
41
+ }
42
+ async planTarget(target) {
43
+ const plan = { target, sourceMissing: false, removals: [], dirs: [], copies: [], seedKept: [] };
44
+ const sourceDir = contain(this.packageRoot, target.source);
45
+ contain(this.projectRoot, target.target);
46
+ if (!(await isDirectory(sourceDir))) {
47
+ plan.sourceMissing = true;
48
+ return plan;
49
+ }
50
+ plan.removals = await this.planRemovals(target);
51
+ await this.planCopies(plan, sourceDir, "");
52
+ return plan;
53
+ }
54
+ /** Lists the prefixed entries matched by removeFirst, never seed-only or user-owned files. */
55
+ async planRemovals(target) {
56
+ const removals = [];
57
+ const seen = new Set();
58
+ for (const pattern of target.removeFirst) {
59
+ const dirRelative = path.posix.join(target.target, removeDir(pattern));
60
+ const dir = contain(this.projectRoot, dirRelative);
61
+ const entries = await readDirOrSkip(dir);
62
+ if (entries.length === 0) {
63
+ continue;
64
+ }
65
+ const realDir = await realpath(dir);
66
+ for (const entry of entries) {
67
+ const key = path.join(realDir, entry.name);
68
+ if (seen.has(key) || !this.isRemovable(target, entry.name)) {
69
+ continue;
70
+ }
71
+ seen.add(key);
72
+ removals.push(path.posix.join(dirRelative, entry.name));
73
+ }
74
+ }
75
+ return removals;
76
+ }
77
+ isRemovable(target, name) {
78
+ return name.startsWith(this.config.settings.filePrefix) && !isSeedOnly(target, name) && !this.userOwned.includes(name);
79
+ }
80
+ /** Walks the source without following symlinks; only regular files and directories are synced. */
81
+ async planCopies(plan, sourceDir, relativeDir) {
82
+ for (const entry of (await readSortedDir(path.join(sourceDir, ...splitRelative(relativeDir)))) ?? []) {
83
+ const relative = path.posix.join(relativeDir, entry.name);
84
+ if (entry.isDirectory()) {
85
+ plan.dirs.push(this.destination(plan.target, relative));
86
+ await this.planCopies(plan, sourceDir, relative);
87
+ }
88
+ else if (entry.isFile() && !this.isExcludedLanguageFile(plan.target, relative)) {
89
+ await this.planFile(plan, path.join(sourceDir, ...relative.split("/")), relative, entry);
90
+ }
91
+ }
92
+ }
93
+ async planFile(plan, source, relative, entry) {
94
+ const installed = path.posix.dirname(relative) === "." ? installedName(plan.target, relative) : relative;
95
+ const destination = this.destination(plan.target, installed);
96
+ if (isSeedOnly(plan.target, entry.name) && (await pathExists(contain(this.projectRoot, destination)))) {
97
+ plan.seedKept.push(destination);
98
+ return;
99
+ }
100
+ const info = await lstat(source);
101
+ plan.copies.push({ source, destination, mode: modeFor(info.mode) });
102
+ }
103
+ /** The project-relative destination, validated against the project root. */
104
+ destination(target, relative) {
105
+ const destination = path.posix.join(target.target, relative);
106
+ contain(this.projectRoot, destination);
107
+ return destination;
108
+ }
109
+ /**
110
+ * Reports whether relative is a tagged file directly inside the target's
111
+ * languageInstructions directory whose language is not selected.
112
+ */
113
+ isExcludedLanguageFile(target, relative) {
114
+ if (target.languageInstructions === undefined) {
115
+ return false;
116
+ }
117
+ if (path.posix.dirname(relative) !== path.posix.normalize(target.languageInstructions)) {
118
+ return false;
119
+ }
120
+ const tag = languageTag(this.config, path.posix.basename(relative));
121
+ return tag !== undefined && !this.selected.includes(tag);
122
+ }
123
+ }
124
+ function splitRelative(relative) {
125
+ return relative === "" ? [] : relative.split("/");
126
+ }
127
+ async function isDirectory(target) {
128
+ try {
129
+ return (await stat(target)).isDirectory();
130
+ }
131
+ catch {
132
+ return false;
133
+ }
134
+ }
135
+ /** Mirrors the Go sync: a removeFirst directory that cannot be read is skipped. */
136
+ async function readDirOrSkip(dir) {
137
+ try {
138
+ return (await readSortedDir(dir)) ?? [];
139
+ }
140
+ catch {
141
+ return [];
142
+ }
143
+ }
144
+ function modeFor(sourceMode) {
145
+ return (sourceMode & 0o111) !== 0 ? EXEC_MODE : FILE_MODE;
146
+ }
@@ -0,0 +1,294 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { contain, removeDir } from "../config/index.js";
5
+ import { readSortedDir, toDisplayPath } from "./fs-utils.js";
6
+ const ISSUE_TEMPLATE_DIR = "ISSUE_TEMPLATE";
7
+ const FRONTMATTER_FENCE = "---";
8
+ const PROJECTS_KEY = "projects:";
9
+ /**
10
+ * Reads the projects line of every prefixed issue template before the
11
+ * sync deletes and re-copies them. It only reads; templates it cannot
12
+ * read are remembered so mergeProjects skips them.
13
+ */
14
+ export async function captureProjects(config, projectRoot) {
15
+ const names = new Map();
16
+ const skipped = new Set();
17
+ for (const template of await issueTemplates(config, projectRoot)) {
18
+ const content = await readTemplate(template);
19
+ const line = content === undefined ? undefined : findProjectsLine(content);
20
+ if (line === undefined) {
21
+ skipped.add(template);
22
+ }
23
+ else {
24
+ names.set(template, line.names);
25
+ }
26
+ }
27
+ return { names, skipped };
28
+ }
29
+ /**
30
+ * Writes the union of each template's current names, the captured names
31
+ * and the entered names into its projects line. A template is only
32
+ * written when the merged list differs. Templates skipped at capture are
33
+ * never written, so their earlier names are not silently replaced; they
34
+ * are reported as skipped.
35
+ */
36
+ export async function mergeProjects(config, projectRoot, capture, entered) {
37
+ const updated = [];
38
+ const skipped = [];
39
+ for (const template of await issueTemplates(config, projectRoot)) {
40
+ const display = toDisplayPath(projectRoot, template);
41
+ if (capture.skipped.has(template)) {
42
+ skipped.push(display);
43
+ continue;
44
+ }
45
+ const outcome = await mergeTemplate(template, capture.names.get(template) ?? [], entered);
46
+ if (outcome === "skipped") {
47
+ skipped.push(display);
48
+ }
49
+ else if (outcome === "updated") {
50
+ updated.push(display);
51
+ }
52
+ }
53
+ return { updated, skipped };
54
+ }
55
+ async function mergeTemplate(template, captured, entered) {
56
+ const content = await readTemplate(template);
57
+ const line = content === undefined ? undefined : findProjectsLine(content);
58
+ if (content === undefined || line === undefined) {
59
+ return "skipped";
60
+ }
61
+ const merged = unionNames(line.names, captured, entered);
62
+ if (merged.length === unionNames(line.names).length) {
63
+ return "unchanged";
64
+ }
65
+ const rewritten = content.slice(0, line.start) + renderProjectsLine(merged) + line.ending + content.slice(line.end);
66
+ await writeTemplate(template, rewritten);
67
+ return "updated";
68
+ }
69
+ /** Lists the prefixed entries of every removeFirst directory named ISSUE_TEMPLATE, in directory order. */
70
+ async function issueTemplates(config, projectRoot) {
71
+ const templates = [];
72
+ for (const dir of issueTemplateDirs(config, projectRoot)) {
73
+ for (const entry of (await readSortedDir(dir)) ?? []) {
74
+ const template = path.join(dir, entry.name);
75
+ if (entry.name.startsWith(config.settings.filePrefix) && !entry.isDirectory() && !templates.includes(template)) {
76
+ templates.push(template);
77
+ }
78
+ }
79
+ }
80
+ return templates;
81
+ }
82
+ /** Skips directories that escape the project; the sync plan rejects those itself. */
83
+ function issueTemplateDirs(config, projectRoot) {
84
+ const dirs = [];
85
+ for (const target of config.tasks.syncTargets) {
86
+ for (const pattern of target.removeFirst) {
87
+ const dir = removeDir(pattern);
88
+ if (path.posix.basename(dir) !== ISSUE_TEMPLATE_DIR) {
89
+ continue;
90
+ }
91
+ const contained = containOrUndefined(projectRoot, target.target, dir);
92
+ if (contained !== undefined && !dirs.includes(contained)) {
93
+ dirs.push(contained);
94
+ }
95
+ }
96
+ }
97
+ return dirs;
98
+ }
99
+ function containOrUndefined(root, ...elements) {
100
+ try {
101
+ return contain(root, ...elements);
102
+ }
103
+ catch {
104
+ return undefined;
105
+ }
106
+ }
107
+ /** Returns undefined when the template is not a regular file, so symlinks are never followed. */
108
+ async function readTemplate(template) {
109
+ const info = await lstat(template);
110
+ if (!info.isFile()) {
111
+ return undefined;
112
+ }
113
+ return readFile(template, "utf8");
114
+ }
115
+ /** Replaces the template atomically, keeping its file mode, so a failed write never leaves a half-written template. */
116
+ async function writeTemplate(template, content) {
117
+ const info = await lstat(template);
118
+ const temp = path.join(path.dirname(template), `.${path.basename(template)}.${randomBytes(6).toString("hex")}`);
119
+ try {
120
+ await writeFile(temp, content, { flag: "wx", mode: info.mode & 0o777 });
121
+ await chmod(temp, info.mode & 0o777);
122
+ await rename(temp, template);
123
+ }
124
+ finally {
125
+ await rm(temp, { force: true });
126
+ }
127
+ }
128
+ /** Locates the first projects line inside the leading frontmatter block; undefined when there is none or it does not parse. */
129
+ export function findProjectsLine(content) {
130
+ let lineStart = 0;
131
+ for (let index = 0; lineStart < content.length; index++) {
132
+ const { body, ending, next } = splitLine(content, lineStart);
133
+ const isFence = body.trim() === FRONTMATTER_FENCE;
134
+ if ((index === 0 && !isFence) || (index > 0 && isFence)) {
135
+ return undefined;
136
+ }
137
+ if (body.startsWith(PROJECTS_KEY)) {
138
+ const names = parseFlowList(body.slice(PROJECTS_KEY.length).trim());
139
+ return names === undefined ? undefined : { start: lineStart, end: next, ending, names };
140
+ }
141
+ lineStart = next;
142
+ }
143
+ return undefined;
144
+ }
145
+ /** Returns the line starting at start without its ending, the ending ("\n", "\r\n" or "") and the start of the next line. */
146
+ function splitLine(content, start) {
147
+ const end = content.indexOf("\n", start);
148
+ if (end < 0) {
149
+ return { body: content.slice(start), ending: "", next: content.length };
150
+ }
151
+ const body = content.slice(start, end);
152
+ if (body.endsWith("\r")) {
153
+ return { body: body.slice(0, -1), ending: "\r\n", next: end + 1 };
154
+ }
155
+ return { body, ending: "\n", next: end + 1 };
156
+ }
157
+ /** Parses a YAML flow sequence of scalars such as ["A", 'B', C]; undefined for anything else. */
158
+ export function parseFlowList(value) {
159
+ if (!value.startsWith("[") || !value.endsWith("]") || value.length < 2) {
160
+ return undefined;
161
+ }
162
+ const names = [];
163
+ let rest = value.slice(1, -1).trim();
164
+ while (rest !== "") {
165
+ const item = parseFlowItem(rest);
166
+ if (item === undefined) {
167
+ return undefined;
168
+ }
169
+ names.push(item.name);
170
+ rest = item.remainder.trim();
171
+ if (rest === "") {
172
+ break;
173
+ }
174
+ if (!rest.startsWith(",")) {
175
+ return undefined;
176
+ }
177
+ rest = rest.slice(1).trim();
178
+ if (rest === "") {
179
+ return undefined;
180
+ }
181
+ }
182
+ return names;
183
+ }
184
+ function parseFlowItem(item) {
185
+ if (item.startsWith('"')) {
186
+ return parseDoubleQuoted(item);
187
+ }
188
+ if (item.startsWith("'")) {
189
+ return parseSingleQuoted(item);
190
+ }
191
+ const comma = item.indexOf(",");
192
+ const end = comma < 0 ? item.length : comma;
193
+ const name = item.slice(0, end).trim();
194
+ if (name === "" || /["'[\]{}#]/.test(name)) {
195
+ return undefined;
196
+ }
197
+ return { name, remainder: item.slice(end) };
198
+ }
199
+ function parseDoubleQuoted(item) {
200
+ for (let index = 1; index < item.length; index++) {
201
+ if (item[index] === "\\") {
202
+ index++;
203
+ }
204
+ else if (item[index] === '"') {
205
+ const name = unquoteDouble(item.slice(1, index));
206
+ return name === undefined ? undefined : { name, remainder: item.slice(index + 1) };
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+ const SIMPLE_ESCAPES = {
212
+ a: "\u0007",
213
+ b: "\b",
214
+ f: "\f",
215
+ n: "\n",
216
+ r: "\r",
217
+ t: "\t",
218
+ v: "\v",
219
+ "\\": "\\",
220
+ '"': '"',
221
+ "/": "/",
222
+ };
223
+ const HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
224
+ /** Decodes the body of a double-quoted scalar; undefined for an invalid escape or a raw line break. */
225
+ function unquoteDouble(body) {
226
+ let decoded = "";
227
+ for (let index = 0; index < body.length; index++) {
228
+ const character = body.charAt(index);
229
+ if (character === "\n" || character === "\r") {
230
+ return undefined;
231
+ }
232
+ if (character !== "\\") {
233
+ decoded += character;
234
+ continue;
235
+ }
236
+ const escape = body.charAt(index + 1);
237
+ const simple = Object.hasOwn(SIMPLE_ESCAPES, escape) ? SIMPLE_ESCAPES[escape] : undefined;
238
+ if (simple !== undefined) {
239
+ decoded += simple;
240
+ index++;
241
+ continue;
242
+ }
243
+ const length = Object.hasOwn(HEX_ESCAPE_LENGTHS, escape) ? (HEX_ESCAPE_LENGTHS[escape] ?? 0) : 0;
244
+ const hex = body.slice(index + 2, index + 2 + length);
245
+ if (length === 0 || !/^[0-9a-fA-F]+$/.test(hex) || hex.length !== length) {
246
+ return undefined;
247
+ }
248
+ const codePoint = Number.parseInt(hex, 16);
249
+ if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) {
250
+ return undefined;
251
+ }
252
+ decoded += String.fromCodePoint(codePoint);
253
+ index += 1 + length;
254
+ }
255
+ return decoded;
256
+ }
257
+ /** Reads a YAML single-quoted scalar, where a doubled quote stands for one quote. */
258
+ function parseSingleQuoted(item) {
259
+ let name = "";
260
+ for (let index = 1; index < item.length; index++) {
261
+ if (item[index] !== "'") {
262
+ name += item.charAt(index);
263
+ continue;
264
+ }
265
+ if (item[index + 1] === "'") {
266
+ name += "'";
267
+ index++;
268
+ continue;
269
+ }
270
+ return { name, remainder: item.slice(index + 1) };
271
+ }
272
+ return undefined;
273
+ }
274
+ /** The comparison key of a project name; matches the wizard's duplicate check. */
275
+ function normalizeName(name) {
276
+ return name.replaceAll("`", "").trim().toLowerCase();
277
+ }
278
+ /** Merges the lists in order, dropping blanks and case-insensitive duplicates. */
279
+ function unionNames(...lists) {
280
+ const names = [];
281
+ const seen = new Set();
282
+ for (const name of lists.flat().map((entry) => entry.trim())) {
283
+ const key = normalizeName(name);
284
+ if (key !== "" && !seen.has(key)) {
285
+ seen.add(key);
286
+ names.push(name);
287
+ }
288
+ }
289
+ return names;
290
+ }
291
+ /** Double-quotes and escapes every name, so no name can break out of the sequence. */
292
+ function renderProjectsLine(names) {
293
+ return `${PROJECTS_KEY} [${names.map((name) => JSON.stringify(name)).join(", ")}]`;
294
+ }