@gavin-hu/my-pi-agent 0.17.0 → 0.18.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 +17 -9
- package/README.zh-CN.md +16 -9
- package/extensions/bytefly/README.md +25 -18
- package/extensions/bytefly/notify/types.ts +1 -1
- package/extensions/bytefly/panel/settings.ts +25 -18
- package/extensions/file-browse/README.md +60 -21
- package/extensions/file-browse/chrome.ts +83 -0
- package/extensions/file-browse/client.ts +62 -0
- package/extensions/file-browse/config.ts +12 -3
- package/extensions/file-browse/diff-html.ts +94 -0
- package/extensions/file-browse/html.ts +19 -147
- package/extensions/file-browse/live-client.ts +47 -1
- package/extensions/file-browse/live.ts +35 -14
- package/extensions/file-browse/rev.ts +42 -0
- package/extensions/file-browse/router.ts +25 -4
- package/extensions/file-browse/server.ts +7 -0
- package/extensions/file-browse/styles.ts +2 -0
- package/extensions/goal/plan-route.ts +8 -2
- package/extensions/job/README.md +7 -5
- package/extensions/job/index.ts +15 -7
- package/extensions/memory/README.md +3 -3
- package/extensions/plan/README.md +280 -86
- package/extensions/plan/commands.ts +56 -5
- package/extensions/plan/feedback.ts +300 -63
- package/extensions/plan/index.ts +1 -1
- package/extensions/plan/plan-index.ts +1 -1
- package/extensions/plan/plan-text.ts +18 -1
- package/extensions/plan/plans.ts +165 -57
- package/extensions/plan/review-prompt.ts +93 -0
- package/extensions/plan/review.ts +78 -98
- package/extensions/plan/runtime.ts +1 -1
- package/extensions/plan/split.ts +87 -0
- package/extensions/plan/steps.ts +80 -93
- package/extensions/plan/tools/enter.ts +8 -3
- package/extensions/plan/tools/exit.ts +101 -23
- package/extensions/plan/tools/write.ts +8 -3
- package/extensions/plan/tools.ts +1 -1
- package/extensions/plan/tui.ts +51 -8
- package/extensions/plan/types.ts +37 -3
- package/extensions/plan/web-chrome.ts +2 -1
- package/extensions/plan/web-client.ts +121 -177
- package/extensions/plan/web-collab.ts +207 -0
- package/extensions/plan/web-comments.ts +269 -0
- package/extensions/plan/web-page.ts +103 -17
- package/extensions/plan/web-router.ts +146 -30
- package/extensions/plan/web-share-session.ts +197 -0
- package/extensions/plan/web-share.ts +169 -0
- package/extensions/plan/web-streams.ts +124 -0
- package/extensions/plan/web-tokens.ts +63 -0
- package/extensions/plan/web.ts +193 -45
- package/extensions/question/README.md +3 -0
- package/extensions/question/dialogs.ts +91 -1
- package/extensions/question/tools.ts +2 -1
- package/extensions/session-bus/README.md +247 -0
- package/extensions/session-bus/commands.ts +165 -0
- package/extensions/session-bus/format.ts +124 -0
- package/extensions/session-bus/index.ts +69 -0
- package/extensions/session-bus/notice.ts +58 -0
- package/extensions/session-bus/protocol.ts +134 -0
- package/extensions/session-bus/result.ts +243 -0
- package/extensions/session-bus/rows.ts +169 -0
- package/extensions/session-bus/runtime.ts +575 -0
- package/extensions/session-bus/store.ts +329 -0
- package/extensions/session-bus/team.ts +102 -0
- package/extensions/session-bus/tools.ts +227 -0
- package/extensions/session-bus/tui.ts +394 -0
- package/extensions/session-bus/types.ts +98 -0
- package/extensions/subagent/README.md +1 -1
- package/extensions/watch/README.md +9 -5
- package/extensions/watch/index.ts +15 -9
- package/extensions/wechat/README.md +20 -33
- package/extensions/wechat/bridge.ts +8 -8
- package/extensions/wechat/commands.ts +2 -2
- package/extensions/wechat/deliver.ts +57 -37
- package/extensions/wechat/interaction.ts +31 -16
- package/extensions/wechat/notices.ts +7 -7
- package/extensions/wechat/peer-worker.ts +19 -34
- package/extensions/wechat/presence.ts +3 -4
- package/extensions/wechat/prompt.ts +39 -24
- package/extensions/wechat/reply-pipeline.ts +12 -4
- package/extensions/worktree/git.ts +5 -0
- package/extensions/worktree/registry.ts +11 -5
- package/extensions/worktree/status.ts +1 -3
- package/lib/README.md +6 -2
- package/lib/claims/overlap.ts +43 -0
- package/lib/http/router.ts +40 -13
- package/lib/http/server.ts +27 -13
- package/lib/interaction.ts +10 -0
- package/lib/notice.ts +29 -2
- package/lib/plan-entry.ts +7 -2
- package/lib/plans/sections.ts +172 -0
- package/lib/plans/setting.ts +52 -0
- package/lib/plans/split.ts +215 -0
- package/lib/plans/store.ts +19 -0
- package/lib/plans/team.ts +151 -0
- package/lib/qr.ts +64 -0
- package/lib/repo-key.ts +74 -0
- package/lib/report.ts +52 -33
- package/lib/requirements.ts +1 -2
- package/lib/tui/README.md +16 -2
- package/lib/tui/dock-screen.ts +85 -17
- package/lib/tui/line-pane.ts +15 -4
- package/lib/tui/tool.ts +23 -4
- package/lib/web/components.ts +1 -1
- package/package.json +3 -2
- package/skills/init/SKILL.md +80 -45
- package/skills/init/references/agents-md-quality.md +26 -2
- package/skills/init/references/interview-questions.md +36 -14
- package/skills/next/SKILL.md +78 -53
- package/skills/parallel-dev/SKILL.md +150 -0
- package/extensions/plan/config.ts +0 -27
- package/extensions/wechat/qrcode.ts +0 -46
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-document sections (pure).
|
|
3
|
+
*
|
|
4
|
+
* One fence-aware scan of a plan's markdown, and one rewrite of a section in
|
|
5
|
+
* place. Two features need it: plan mode, whose review screen navigates by
|
|
6
|
+
* section and whose `set_plan_steps` rewrites `## Steps`, and the split, which
|
|
7
|
+
* writes and replaces `## Team` at approval. A second scanner would disagree
|
|
8
|
+
* about a heading inside a fence, so both go through this module.
|
|
9
|
+
*
|
|
10
|
+
* The scan is deliberately smaller than CommonMark: a heading is up to three
|
|
11
|
+
* leading spaces, a space after the hashes, and an optional closing run; a fence
|
|
12
|
+
* is three backticks or tildes anywhere a fence may start. Inside a fence
|
|
13
|
+
* nothing is a heading, which is what keeps a `# comment` in a shell example out
|
|
14
|
+
* of the outline. Every section keeps its raw source lines, so a caller can edit
|
|
15
|
+
* exactly what it read.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** A heading-delimited chunk of a plan, for navigation and in-place edits. */
|
|
19
|
+
export interface PlanSection {
|
|
20
|
+
/** Plain heading text, or `""` for the content before the first heading. */
|
|
21
|
+
title: string;
|
|
22
|
+
/** The section's markdown, including its heading line. */
|
|
23
|
+
text: string;
|
|
24
|
+
/** 0-based source line of the section's heading (0 for the preamble). */
|
|
25
|
+
line: number;
|
|
26
|
+
/** Heading level, or 0 for the preamble before the first heading. */
|
|
27
|
+
level: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A markdown heading outside a fence, capturing its level and text: up to three
|
|
32
|
+
* leading spaces, a space after the hashes, and a closing hash run only when a
|
|
33
|
+
* space separates it. Shared with the block model, so the section split and the
|
|
34
|
+
* browser outline read a heading line the same way, and `## C#` keeps its title.
|
|
35
|
+
*/
|
|
36
|
+
export const HEADING = /^ {0,3}(#{1,6})\s+(.*?)(?:\s+#+)?\s*$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A markdown code-fence opener or closer (backticks or tildes). Shared with the
|
|
40
|
+
* block model: a tab-indented fence is prose for both parsers, never code for one
|
|
41
|
+
* and prose for the other.
|
|
42
|
+
*/
|
|
43
|
+
export const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A heading or step label as the review surfaces compare it: inline emphasis and
|
|
47
|
+
* code removed, whitespace collapsed. Exported so a renderer labels an outline
|
|
48
|
+
* entry the same way {@link extractSections} labels its section, which is what
|
|
49
|
+
* lets the browser mark a changed section.
|
|
50
|
+
*/
|
|
51
|
+
export function normalizeHeadingText(text: string): string {
|
|
52
|
+
return text
|
|
53
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
54
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
55
|
+
.replace(/(?<![A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, "$1")
|
|
56
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
57
|
+
.replace(/\s+/g, " ")
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sections of a plan, split at heading lines outside code fences. The content
|
|
63
|
+
* before the first heading is a section titled `""`. Pi's `Markdown` renderer
|
|
64
|
+
* exposes no source-line map, so the review screen renders these chunks
|
|
65
|
+
* separately to know where each section begins.
|
|
66
|
+
*/
|
|
67
|
+
export function extractSections(plan: string): PlanSection[] {
|
|
68
|
+
const sections: PlanSection[] = [];
|
|
69
|
+
let current: PlanSection | undefined;
|
|
70
|
+
let fence: string | undefined;
|
|
71
|
+
|
|
72
|
+
const flush = (): void => {
|
|
73
|
+
if (current && (current.title !== "" || current.text.trim() !== "")) sections.push(current);
|
|
74
|
+
current = undefined;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
plan.split("\n").forEach((line, index) => {
|
|
78
|
+
const fenceMatch = line.match(FENCE);
|
|
79
|
+
if (fenceMatch) {
|
|
80
|
+
if (fence === undefined) fence = fenceMatch[1][0];
|
|
81
|
+
else if (fence === fenceMatch[1][0]) fence = undefined;
|
|
82
|
+
} else if (fence === undefined) {
|
|
83
|
+
const heading = line.match(HEADING);
|
|
84
|
+
if (heading) {
|
|
85
|
+
flush();
|
|
86
|
+
current = { title: normalizeHeadingText(heading[2]), text: line, line: index, level: heading[1].length };
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (current === undefined) current = { title: "", text: line, line: 0, level: 0 };
|
|
91
|
+
else current.text += `\n${line}`;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
flush();
|
|
95
|
+
return sections;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The index one past a section's last line: the next heading at its level or
|
|
100
|
+
* shallower, outside a fence, or the end of the document.
|
|
101
|
+
*/
|
|
102
|
+
function sectionEnd(lines: readonly string[], start: number): number {
|
|
103
|
+
const level = lines[start].match(HEADING)?.[1].length ?? 2;
|
|
104
|
+
let fence: string | undefined;
|
|
105
|
+
for (let index = start + 1; index < lines.length; index++) {
|
|
106
|
+
const line = lines[index];
|
|
107
|
+
const fenceMatch = line.match(FENCE);
|
|
108
|
+
if (fenceMatch) {
|
|
109
|
+
if (fence === undefined) fence = fenceMatch[1][0];
|
|
110
|
+
else if (fence === fenceMatch[1][0]) fence = undefined;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (fence !== undefined) continue;
|
|
114
|
+
const heading = line.match(HEADING);
|
|
115
|
+
if (heading && heading[1].length <= level) return index;
|
|
116
|
+
}
|
|
117
|
+
return lines.length;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Replace a level-2 section's body, or remove the section, or append one.
|
|
122
|
+
*
|
|
123
|
+
* `body` is the section's markdown after its heading, without the heading line —
|
|
124
|
+
* the caller keeps the heading text so a refinement cannot rename the section
|
|
125
|
+
* that other code looks for. An empty `body` removes the whole section, which is
|
|
126
|
+
* how a team stops existing; a missing section with a body appends one, so the
|
|
127
|
+
* write is idempotent rather than order-dependent.
|
|
128
|
+
*/
|
|
129
|
+
export function replaceSection(plan: string, title: string, body: string | undefined): string {
|
|
130
|
+
const lines = plan.split("\n");
|
|
131
|
+
const wanted = normalizeHeadingText(title).toLowerCase();
|
|
132
|
+
const sections = extractSections(plan);
|
|
133
|
+
const found = sections.find(
|
|
134
|
+
(candidate) => candidate.level === 2 && normalizeHeadingText(candidate.title).toLowerCase() === wanted,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!found) {
|
|
138
|
+
if (body === undefined) return plan;
|
|
139
|
+
const prefix = plan.trim() === "" ? "" : `${plan.replace(/\s+$/, "")}\n\n`;
|
|
140
|
+
return `${prefix}## ${title}\n${body.replace(/\s+$/, "")}\n`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const end = sectionEnd(lines, found.line);
|
|
144
|
+
const head = lines.slice(0, found.line);
|
|
145
|
+
const tail = lines.slice(end);
|
|
146
|
+
if (body === undefined) {
|
|
147
|
+
// Drop the blank line that separated the section from what follows, so a
|
|
148
|
+
// removed section leaves one gap rather than two.
|
|
149
|
+
const joined = [...head, ...tail];
|
|
150
|
+
return (
|
|
151
|
+
joined
|
|
152
|
+
.join("\n")
|
|
153
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
154
|
+
.replace(/\s+$/, "") + (plan.endsWith("\n") ? "\n" : "")
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const replacement = [`## ${title}`, "", ...body.replace(/\s+$/, "").split("\n")];
|
|
158
|
+
// Keep one blank line between the rewritten section and whatever followed it.
|
|
159
|
+
const gap = tail.length > 0 && tail[0].trim() !== "" ? [""] : [];
|
|
160
|
+
return [...head, ...replacement, ...gap, ...tail].join("\n");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The `## Team` section's body, without its heading or surrounding blank lines. */
|
|
164
|
+
export function teamSectionBody(plan: string): string | undefined {
|
|
165
|
+
const lines = plan.split("\n");
|
|
166
|
+
const section = extractSections(plan).find(
|
|
167
|
+
(candidate) => candidate.level === 2 && normalizeHeadingText(candidate.title).toLowerCase() === "team",
|
|
168
|
+
);
|
|
169
|
+
if (!section) return undefined;
|
|
170
|
+
const body = lines.slice(section.line + 1, sectionEnd(lines, section.line)).join("\n");
|
|
171
|
+
return body.replace(/^(?:[ \t]*\n)+/, "").replace(/\s+$/, "");
|
|
172
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The switch that turns the split on, and the words the user flips it with.
|
|
3
|
+
*
|
|
4
|
+
* It lives in `lib/` because three places must agree: the settings row that
|
|
5
|
+
* writes it, plan approval that reads it, and the bus that refuses a join while
|
|
6
|
+
* it is off. A second copy of the key would mean a panel row that changes
|
|
7
|
+
* nothing — the failure this module exists to prevent.
|
|
8
|
+
*
|
|
9
|
+
* Off by default: the split changes what approval means and adds work nobody
|
|
10
|
+
* asked for, so it ships closed and an existing user upgrades into the single
|
|
11
|
+
* flow they already know.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { asBoolean, configValueAt, loadConfigSection } from "../config.ts";
|
|
15
|
+
|
|
16
|
+
/** The shared document's section and key that hold the switch. */
|
|
17
|
+
export const SPLIT_SECTION = "plan";
|
|
18
|
+
export const SPLIT_KEY = "split";
|
|
19
|
+
export const SPLIT_DEFAULT = false;
|
|
20
|
+
|
|
21
|
+
/** The settings-panel row, so a refusal can name what the user flips. */
|
|
22
|
+
export const SPLIT_SETTING_ID = "plan.split";
|
|
23
|
+
export const SPLIT_SETTING_LABEL = "Split plans into slices";
|
|
24
|
+
|
|
25
|
+
/** The section's shape and its default, so a loader and its documentation agree. */
|
|
26
|
+
export interface SplitConfig {
|
|
27
|
+
split: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_SPLIT_CONFIG: SplitConfig = { split: SPLIT_DEFAULT };
|
|
31
|
+
|
|
32
|
+
/** Read the section over `base`, ignoring a value that is not a boolean. */
|
|
33
|
+
export function normalizeSplitConfig(raw: Record<string, unknown> | undefined, base: SplitConfig): SplitConfig {
|
|
34
|
+
return { split: asBoolean(configValueAt(raw, [SPLIT_KEY]), base.split) };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether this repository (or the user's global config) has the split on.
|
|
39
|
+
*
|
|
40
|
+
* The project file is read at `root` when the caller knows it: a split belongs to
|
|
41
|
+
* the repository, so a session started in a subdirectory must honour what the
|
|
42
|
+
* repository turned on. Without a root the session's cwd is used, which is what a
|
|
43
|
+
* caller outside a repository has.
|
|
44
|
+
*/
|
|
45
|
+
export function splitEnabled(cwd: string, root?: string): boolean {
|
|
46
|
+
return loadConfigSection(root ?? cwd, SPLIT_SECTION, DEFAULT_SPLIT_CONFIG, normalizeSplitConfig).split;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The one line a refusal or a note uses to say the switch is off and where it lives. */
|
|
50
|
+
export const SPLIT_OFF_NOTE =
|
|
51
|
+
`Parallel slices are off (${SPLIT_SETTING_ID}). ` +
|
|
52
|
+
`Enable "${SPLIT_SETTING_LABEL}" in /bytefly settings to split a plan.`;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a plan can be executed in parallel, and by whom (pure).
|
|
3
|
+
*
|
|
4
|
+
* The model proposes the split; this decides whether it is one. Nothing here
|
|
5
|
+
* reads the disk: the refusal rules are facts about the table the plan would be
|
|
6
|
+
* approved with, so they are testable in isolation and identical on every
|
|
7
|
+
* platform.
|
|
8
|
+
*
|
|
9
|
+
* A refusal is the point of the feature. A split whose units overlap, or whose
|
|
10
|
+
* dependency graph loops, or that hands a shared file to an owner, is what costs
|
|
11
|
+
* sessions rather than one tool call — so each rule names the offending pair
|
|
12
|
+
* instead of returning a boolean.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { claimsConflict, findClaimOverlap, normalizeClaimPath } from "../claims/overlap.ts";
|
|
16
|
+
import { type PlanTeam, type PlanUnit, UNIT_NAME, unsafeValue } from "./team.ts";
|
|
17
|
+
|
|
18
|
+
/** Most non-lead units a team may have: the user switches to each one every round. */
|
|
19
|
+
export const MAX_OWNERS = 2;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Files every workstream touches, so an owner that writes one is a merge conflict
|
|
23
|
+
* by rule. The lead lands them; the project can extend the list through config.
|
|
24
|
+
*/
|
|
25
|
+
export const SHARED_FILES: readonly string[] = [
|
|
26
|
+
"package.json",
|
|
27
|
+
"bun.lock",
|
|
28
|
+
"bun.lockb",
|
|
29
|
+
"package-lock.json",
|
|
30
|
+
"CHANGELOG.md",
|
|
31
|
+
"README.md",
|
|
32
|
+
"README.zh-CN.md",
|
|
33
|
+
"test/suite-manifest.ts",
|
|
34
|
+
"test/naming.test.ts",
|
|
35
|
+
"test/structure.test.ts",
|
|
36
|
+
"lib/tool-names.ts",
|
|
37
|
+
"lib/requirements.ts",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** What approval does with a proposed split. */
|
|
41
|
+
export type SplitCheck =
|
|
42
|
+
| { ok: true; verdict: "serial"; note: string }
|
|
43
|
+
| ({ ok: true; verdict: "pair" | "team" } & PlanTeam)
|
|
44
|
+
| { ok: false; reason: string };
|
|
45
|
+
|
|
46
|
+
/** A unit named in a refusal, so the message says which two collide. */
|
|
47
|
+
function unitAt(units: readonly PlanUnit[], index: number): string {
|
|
48
|
+
return units[index]?.name ?? `unit ${index + 1}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether `path` covers, or is covered by, any shared file. */
|
|
52
|
+
function sharedHit(path: string, shared: readonly string[]): string | undefined {
|
|
53
|
+
return shared.find((file) => claimsConflict(path, file));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk the `dependsOn` graph and report the first cycle as a readable path.
|
|
58
|
+
*
|
|
59
|
+
* A cycle means no unit can start, so it is refused rather than reordered: a
|
|
60
|
+
* dependency that points both ways usually means the split cut through one
|
|
61
|
+
* workstream instead of between two.
|
|
62
|
+
*/
|
|
63
|
+
function findCycle(units: readonly PlanUnit[]): string[] | undefined {
|
|
64
|
+
const byName = new Map(units.map((unit) => [unit.name, unit]));
|
|
65
|
+
const state = new Map<string, "open" | "done">();
|
|
66
|
+
const path: string[] = [];
|
|
67
|
+
|
|
68
|
+
const visit = (name: string): string[] | undefined => {
|
|
69
|
+
const mark = state.get(name);
|
|
70
|
+
if (mark === "done") return undefined;
|
|
71
|
+
if (mark === "open") return [...path.slice(path.indexOf(name)), name];
|
|
72
|
+
state.set(name, "open");
|
|
73
|
+
path.push(name);
|
|
74
|
+
for (const next of byName.get(name)?.dependsOn ?? []) {
|
|
75
|
+
const cycle = visit(next);
|
|
76
|
+
if (cycle) return cycle;
|
|
77
|
+
}
|
|
78
|
+
path.pop();
|
|
79
|
+
state.set(name, "done");
|
|
80
|
+
return undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
for (const unit of units) {
|
|
84
|
+
const cycle = visit(unit.name);
|
|
85
|
+
if (cycle) return cycle;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide what approval does with `units`.
|
|
92
|
+
*
|
|
93
|
+
* No units means the plan approves as one flow, which is the common case and
|
|
94
|
+
* never a failure. One unit is refused with the skeleton advice: a single path
|
|
95
|
+
* proven end to end is what a split should be cut from, and approving it as a
|
|
96
|
+
* team would produce a table with nobody to join.
|
|
97
|
+
*/
|
|
98
|
+
export function checkSplit(
|
|
99
|
+
units: readonly PlanUnit[] | undefined,
|
|
100
|
+
shared: readonly string[] = SHARED_FILES,
|
|
101
|
+
): SplitCheck {
|
|
102
|
+
if (!units || units.length === 0) {
|
|
103
|
+
return { ok: true, verdict: "serial", note: "Approved as one flow." };
|
|
104
|
+
}
|
|
105
|
+
if (units.length === 1) {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason:
|
|
109
|
+
"A split needs at least two units. Approve as one flow, or build the thinnest end-to-end path first and approve a split once the boundary is real.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const names = new Set<string>();
|
|
114
|
+
for (const unit of units) {
|
|
115
|
+
if (!UNIT_NAME.test(unit.name)) {
|
|
116
|
+
return { ok: false, reason: `"${unit.name}" is not a unit name; use lowercase letters, digits, and hyphens.` };
|
|
117
|
+
}
|
|
118
|
+
if (names.has(unit.name)) return { ok: false, reason: `Two units are both named "${unit.name}".` };
|
|
119
|
+
names.add(unit.name);
|
|
120
|
+
if (unit.paths.length === 0) return { ok: false, reason: `Unit "${unit.name}" claims no paths.` };
|
|
121
|
+
if (!unit.test.trim())
|
|
122
|
+
return { ok: false, reason: `Unit "${unit.name}" has no test; name the command that proves it.` };
|
|
123
|
+
}
|
|
124
|
+
const unsafe = unsafeValue({ lead: units.find((unit) => unit.lead)?.name ?? "", units: [...units] });
|
|
125
|
+
if (unsafe !== undefined) {
|
|
126
|
+
return { ok: false, reason: `"${unsafe}" would break the team table; remove the pipe or the line break.` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const leads = units.filter((unit) => unit.lead);
|
|
130
|
+
if (leads.length !== 1) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
reason:
|
|
134
|
+
leads.length === 0
|
|
135
|
+
? "No unit is marked as the lead's; the approving session needs one unit of its own."
|
|
136
|
+
: `Units ${leads.map((unit) => `"${unit.name}"`).join(" and ")} are both marked lead; only one may be.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const lead = leads[0].name;
|
|
140
|
+
const owners = units.filter((unit) => !unit.lead);
|
|
141
|
+
if (owners.length > MAX_OWNERS) {
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: `A team has at most ${MAX_OWNERS} owners plus the lead; ${owners.length} owners means the split is wrong, not the team too small.`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Overlap is checked across units, naming the pair: the same predicate the bus
|
|
149
|
+
// applies to claims, so approval and claiming cannot disagree.
|
|
150
|
+
for (const unit of units) {
|
|
151
|
+
const inside = findClaimOverlap(unit.paths.map(normalizeClaimPath));
|
|
152
|
+
if (inside) {
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
reason: `Unit "${unit.name}" lists paths that cover each other: ${inside[0]} and ${inside[1]}. Keep the narrower one.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (let i = 0; i < units.length; i++) {
|
|
160
|
+
for (let j = i + 1; j < units.length; j++) {
|
|
161
|
+
for (const left of units[i].paths) {
|
|
162
|
+
for (const right of units[j].paths) {
|
|
163
|
+
if (claimsConflict(left, right)) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: `Units "${unitAt(units, i)}" and "${unitAt(units, j)}" overlap: ${left} and ${right}. Combine them or serialize.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const unit of units) {
|
|
175
|
+
if (unit.lead) continue;
|
|
176
|
+
const hit = unit.paths.flatMap((path) => sharedHit(path, shared)).at(0);
|
|
177
|
+
if (hit !== undefined) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
reason: `Unit "${unit.name}" claims ${hit}, which every workstream touches; the lead lands shared files.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
for (const unit of units) {
|
|
186
|
+
const unknown = unit.dependsOn.find((name) => !names.has(name));
|
|
187
|
+
if (unknown !== undefined) {
|
|
188
|
+
return { ok: false, reason: `Unit "${unit.name}" depends on "${unknown}", which is not a unit.` };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const cycle = findCycle(units);
|
|
192
|
+
if (cycle) return { ok: false, reason: `Dependencies loop: ${cycle.join(" → ")}.` };
|
|
193
|
+
|
|
194
|
+
return { ok: true, verdict: owners.length === 1 ? "pair" : "team", lead, units: units.map((unit) => ({ ...unit })) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Merge order: the units in dependency order, ties broken by their place in the
|
|
199
|
+
* table, so the lead merges what everything else builds on first.
|
|
200
|
+
*/
|
|
201
|
+
export function mergeOrder(units: readonly PlanUnit[]): string[] {
|
|
202
|
+
const placed: string[] = [];
|
|
203
|
+
const done = new Set<string>();
|
|
204
|
+
const remaining = [...units];
|
|
205
|
+
const names = new Set(units.map((unit) => unit.name));
|
|
206
|
+
while (remaining.length > 0) {
|
|
207
|
+
// The first unit whose dependencies are all placed. Approval refuses a cycle,
|
|
208
|
+
// so a search that finds nothing falls back to table order rather than looping.
|
|
209
|
+
const index = remaining.findIndex((unit) => unit.dependsOn.every((name) => done.has(name) || !names.has(name)));
|
|
210
|
+
const [next] = remaining.splice(index < 0 ? 0 : index, 1);
|
|
211
|
+
placed.push(next.name);
|
|
212
|
+
done.add(next.name);
|
|
213
|
+
}
|
|
214
|
+
return placed;
|
|
215
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a repository's plans live (pure).
|
|
3
|
+
*
|
|
4
|
+
* One rule, two readers: `plan` writes here, and `session-bus` reads a team out of
|
|
5
|
+
* here to resolve a joining unit's paths without importing the plan extension.
|
|
6
|
+
* The directory is keyed by the repository — see `lib/repo-key.ts` — and a
|
|
7
|
+
* directory outside a repository has no key, which is the same store plans had
|
|
8
|
+
* before the key existed.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
/** The agent-directory entry plans are filed under. */
|
|
14
|
+
export const PLANS_DIRNAME = "plans";
|
|
15
|
+
|
|
16
|
+
/** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
|
|
17
|
+
export function plansDir(agentDir: string, key?: string): string {
|
|
18
|
+
return key === undefined ? join(agentDir, PLANS_DIRNAME) : join(agentDir, PLANS_DIRNAME, key);
|
|
19
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan's `## Team` section (pure): the split a plan was approved with.
|
|
3
|
+
*
|
|
4
|
+
* The section is a sentence naming the lead's unit plus a GFM table, because the
|
|
5
|
+
* review screen already renders a heading and a table — the block model has a
|
|
6
|
+
* `table` kind — so the split needs no renderer of its own, and the browser page
|
|
7
|
+
* anchors its comments to the same source lines everything else reads.
|
|
8
|
+
*
|
|
9
|
+
* Render and parse are the same module on purpose: the table's shape is a wire
|
|
10
|
+
* format between approval (`plan`) and joining (`session-bus`), which never
|
|
11
|
+
* imports this extension, so both sides must agree on one writer.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { replaceSection, teamSectionBody } from "./sections.ts";
|
|
15
|
+
|
|
16
|
+
/** One workstream a plan was split into. */
|
|
17
|
+
export interface PlanUnit {
|
|
18
|
+
/** Lowercase slug used to address the unit; also the session label its owner takes. */
|
|
19
|
+
name: string;
|
|
20
|
+
/** Repository-relative path prefixes the unit owns. */
|
|
21
|
+
paths: string[];
|
|
22
|
+
/** The command that proves the unit is done. */
|
|
23
|
+
test: string;
|
|
24
|
+
/** Units that must land first. */
|
|
25
|
+
dependsOn: string[];
|
|
26
|
+
/** True for the one unit the approving session keeps. */
|
|
27
|
+
lead?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A split as it was written into a plan. */
|
|
31
|
+
export interface PlanTeam {
|
|
32
|
+
/** The unit the approving session works. */
|
|
33
|
+
lead: string;
|
|
34
|
+
units: PlanUnit[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where the section's own heading lives, so a caller can name it in an error. */
|
|
38
|
+
export const TEAM_SECTION = "Team";
|
|
39
|
+
|
|
40
|
+
/** Unit names are addressed as session labels, so they take the same shape. */
|
|
41
|
+
export const UNIT_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
42
|
+
|
|
43
|
+
/** A value that would break the table or a message. */
|
|
44
|
+
const UNSAFE = /[|\n\r]/;
|
|
45
|
+
const NONE = "—";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render the section's body.
|
|
49
|
+
*
|
|
50
|
+
* Values are narrowed to one line so a path or a title cannot break the table it
|
|
51
|
+
* is written into; a caller that validates first gets a refusal instead of a
|
|
52
|
+
* mangled table.
|
|
53
|
+
*/
|
|
54
|
+
export function renderTeam(team: PlanTeam): string {
|
|
55
|
+
const rows = team.units.map((unit) => {
|
|
56
|
+
const paths = unit.paths.join(", ");
|
|
57
|
+
const depends = unit.dependsOn.length > 0 ? unit.dependsOn.join(", ") : NONE;
|
|
58
|
+
return `| ${unit.name} | ${paths} | ${unit.test} | ${depends} |`;
|
|
59
|
+
});
|
|
60
|
+
return [`Lead: ${team.lead}`, "", "| unit | paths | test | depends on |", "| --- | --- | --- | --- |", ...rows].join(
|
|
61
|
+
"\n",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One cell's text, without the inline code a writer may have used. */
|
|
66
|
+
function clean(cell: string | undefined): string {
|
|
67
|
+
return (cell ?? "")
|
|
68
|
+
.trim()
|
|
69
|
+
.replace(/^`+|`+$/g, "")
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Split a table cell into its values, dropping backticks and padding. */
|
|
74
|
+
function cells(cell: string): string[] {
|
|
75
|
+
return cell
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((value) =>
|
|
78
|
+
value
|
|
79
|
+
.trim()
|
|
80
|
+
.replace(/^`+|`+$/g, "")
|
|
81
|
+
.trim(),
|
|
82
|
+
)
|
|
83
|
+
.filter((value) => value !== "" && value !== NONE);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a section body back into a team.
|
|
88
|
+
*
|
|
89
|
+
* Every malformed row is an error rather than a silent skip: a team read wrong is
|
|
90
|
+
* a session joining the wrong paths, which is the one failure this feature exists
|
|
91
|
+
* to prevent.
|
|
92
|
+
*/
|
|
93
|
+
export function parseTeam(body: string): PlanTeam | { error: string } {
|
|
94
|
+
const lines = body.split("\n").map((line) => line.trim());
|
|
95
|
+
const leadLine = lines.find((line) => /^lead:/i.test(line));
|
|
96
|
+
const rows = lines.filter((line) => line.startsWith("|") && !/^\|\s*-{3}/.test(line));
|
|
97
|
+
const lead = clean(leadLine?.replace(/^lead:/i, ""));
|
|
98
|
+
if (!lead) return { error: "The team section has no `Lead: <unit>` line." };
|
|
99
|
+
|
|
100
|
+
const [header, ...data] = rows;
|
|
101
|
+
if (!header) return { error: "The team section has no table." };
|
|
102
|
+
const columns = header
|
|
103
|
+
.split("|")
|
|
104
|
+
.slice(1, -1)
|
|
105
|
+
.map((cell) => cell.trim().toLowerCase());
|
|
106
|
+
const at = (name: string): number => columns.indexOf(name);
|
|
107
|
+
const nameAt = at("unit");
|
|
108
|
+
const pathsAt = at("paths");
|
|
109
|
+
const testAt = at("test");
|
|
110
|
+
const dependsAt = at("depends on");
|
|
111
|
+
if (nameAt < 0 || pathsAt < 0 || testAt < 0) {
|
|
112
|
+
return { error: "The team table needs `unit`, `paths`, and `test` columns." };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const units: PlanUnit[] = [];
|
|
116
|
+
for (const row of data) {
|
|
117
|
+
const values = row.split("|").slice(1, -1);
|
|
118
|
+
const name = clean(values[nameAt]);
|
|
119
|
+
if (!name || !UNIT_NAME.test(name)) return { error: `"${name}" is not a unit name.` };
|
|
120
|
+
const paths = cells(values[pathsAt] ?? "");
|
|
121
|
+
const test = clean(values[testAt]);
|
|
122
|
+
if (paths.length === 0) return { error: `Unit "${name}" lists no paths.` };
|
|
123
|
+
if (!test) return { error: `Unit "${name}" lists no test.` };
|
|
124
|
+
const dependsOn = dependsAt < 0 ? [] : cells(values[dependsAt] ?? "");
|
|
125
|
+
units.push({ name, paths, test, dependsOn, ...(name === lead ? { lead: true } : {}) });
|
|
126
|
+
}
|
|
127
|
+
if (units.length === 0) return { error: "The team table has no units." };
|
|
128
|
+
if (!units.some((unit) => unit.name === lead)) return { error: `The lead "${lead}" is not one of the units.` };
|
|
129
|
+
return { lead, units };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The team a plan was approved with, or undefined when it has none. */
|
|
133
|
+
export function readTeam(plan: string): PlanTeam | { error: string } | undefined {
|
|
134
|
+
const body = teamSectionBody(plan);
|
|
135
|
+
return body === undefined ? undefined : parseTeam(body);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The plan with its team section rewritten. */
|
|
139
|
+
export function writeTeam(plan: string, team: PlanTeam): string {
|
|
140
|
+
return replaceSection(plan, TEAM_SECTION, renderTeam(team));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether any rendered value would break the table. */
|
|
144
|
+
export function unsafeValue(team: PlanTeam): string | undefined {
|
|
145
|
+
for (const unit of team.units) {
|
|
146
|
+
for (const value of [unit.name, unit.test, ...unit.paths, ...unit.dependsOn]) {
|
|
147
|
+
if (UNSAFE.test(value)) return value;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return UNSAFE.test(team.lead) ? team.lead : undefined;
|
|
151
|
+
}
|
package/lib/qr.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A QR code as terminal lines or an inline SVG.
|
|
3
|
+
*
|
|
4
|
+
* Uses the `qrcode` package only for the module matrix, then draws Unicode
|
|
5
|
+
* half-blocks for a terminal and hands the package's SVG to a page. The package
|
|
6
|
+
* loads on the first QR, not when an extension registers: a session that never
|
|
7
|
+
* shows one pays nothing for it. No ANSI is emitted; the caller decides colours,
|
|
8
|
+
* and the width is the matrix width plus a quiet zone.
|
|
9
|
+
*
|
|
10
|
+
* Two surfaces share this: `wechat` shows a login QR, and `plan` shows a review
|
|
11
|
+
* link a teammate can scan.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface QrModules {
|
|
15
|
+
size: number;
|
|
16
|
+
get(row: number, col: number): boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** QR error-correction level, one setting for both surfaces. */
|
|
20
|
+
const ERROR_CORRECTION = "M" as const;
|
|
21
|
+
|
|
22
|
+
/** Build the module matrix for `text`, loading `qrcode` on first use. */
|
|
23
|
+
export async function qrMatrix(text: string): Promise<QrModules> {
|
|
24
|
+
const { default: QRCode } = await import("qrcode");
|
|
25
|
+
const qr = QRCode.create(text, { errorCorrectionLevel: ERROR_CORRECTION });
|
|
26
|
+
return qr.modules as unknown as QrModules;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The QR as terminal lines. Each string is exactly `matrix.size + 2 * quiet`
|
|
31
|
+
* columns wide (half-block characters are single width), so a caller can test
|
|
32
|
+
* `qrLines(text)[0].length` against the terminal width.
|
|
33
|
+
*/
|
|
34
|
+
export async function qrLines(text: string, options: { quiet?: number } = {}): Promise<string[]> {
|
|
35
|
+
const quiet = Math.max(0, options.quiet ?? 2);
|
|
36
|
+
const matrix = await qrMatrix(text);
|
|
37
|
+
const size = matrix.size;
|
|
38
|
+
const isDark = (row: number, col: number): boolean =>
|
|
39
|
+
row >= 0 && col >= 0 && row < size && col < size && matrix.get(row, col);
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
for (let y = -quiet; y < size + quiet; y += 2) {
|
|
43
|
+
let line = "";
|
|
44
|
+
for (let x = -quiet; x < size + quiet; x += 1) {
|
|
45
|
+
const top = isDark(y, x);
|
|
46
|
+
const bottom = isDark(y + 1, x);
|
|
47
|
+
line += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " ";
|
|
48
|
+
}
|
|
49
|
+
lines.push(line);
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The QR as an inline SVG, for a page that can paste markup instead of pixels.
|
|
56
|
+
*
|
|
57
|
+
* The markup is generated by the package from a URL this process built, so it
|
|
58
|
+
* carries no caller-supplied text; a page still serves it under its own CSP,
|
|
59
|
+
* which allows no external image.
|
|
60
|
+
*/
|
|
61
|
+
export async function qrSvg(text: string): Promise<string> {
|
|
62
|
+
const { default: QRCode } = await import("qrcode");
|
|
63
|
+
return QRCode.toString(text, { type: "svg", errorCorrectionLevel: ERROR_CORRECTION, margin: 1 });
|
|
64
|
+
}
|